스프링의 디자인 패턴!

maketheworldwise·2022년 3월 28일
0


이 글의 목적?

스프링의 디자인 패턴에 대해 알아보자.

어댑터 패턴

  • 호출당하는 쪽의 메소드를 호출하는 쪽의 코드에 대응하도록 중간에 변환기를 통해 호출하는 패턴
  • 개방 폐쇄 원칙을 활용한 설계 패턴

어댑터라고 한다면 변환기를 떠올리면 된다. 변환기는 서로 다른 두 인터페이스 사이에 통신이 가능하도록 하는 것을 의미한다. 자바에서 어댑터 패턴이라고 한다면 대표적으로 다양한 데이터베이스를 조작할 수 있는 공통의 인터페이스인 JDBC가 있다.

프록시 패턴

  • 제어 흐름을 조정하기 위한 목적으로 중간에 대리자를 두는 패턴
  • 개방 폐쇄 원칙과 의존 역전 원칙을 활용한 설계 패턴

프록시는 대리자의 의미로, 어떠한 일을 대신 처리해주는 패턴이라고 생각하면 된다.

하단의 이미지로 본다면, Main이 Service의 runSomething() 메소드를 호출하는데, Proxy가 대신 Service의 runSomething() 메소드를 호출을 하고 그 반환 값을 Main에게 전달해주는 구조다.

즉, 중간에 인터페이스를 두어 구체 클래스들에게 영향을 받지 않도록 설계했고, 직접 접근하지 않고 Proxy를 통해서 우회하여 접근하도록 되어있다.

이를 코드로 나타내면 다음과 같다.

public interface IService {
	String runSomething();
}
public class Service implements IService {
	@Override
    public String runSomething() {
    	return "서비스"
    }
}
public class Proxy implements IService {
	IService service;
    
    @Override
    public String runSomething() {
    	System.out.println("호출에 대한 흐름 제어가 주목적, 반환 결과를 그대로 전달");
        service = new Service();
        return service.runSomething();
    }
}
public class Main {
	public static void main(String[] args) {
    	IService proxy = new Proxy();
        System.out.println(proxy.runSomething());
    }
}

💡 프록시 패턴은 어디서 사용할까?

대표적으로 레이지 로딩이 있다. 예를 들어, 용량이 큰 이미지와 글이 함께 있는 문서를 생각해보자. 문서에 모니터를 띄운다고 했을 때, 이미지 파일은 용량이 크고 텍스트는 용량이 작아서 텍스트는 빠르게 출력되지만, 이미지는 조금 느리게 로딩되는 것을 볼 수 있다. 만약 이미지와 텍스트가 모두 로딩이 된 후에 화면에 나오게 될 경우, 사용자에게는 페이지가 로딩될 때까지 계속 기다려야하는 불편함이 생긴다. 따라서 이미지 처리와 텍스트 처리를 별도로 운영하여 로딩이 빠른 텍스트부터 띄워주는 형태에서 프록시 패턴을 사용할 수 있다.

또한 내가 만든 코드와 내가 만들지 않은 코드가 섞여 있는 경우 활용할 수 있다. 예를 들어, 오픈 소스 라이브러리를 이용한다고 했을 때, 직접 오픈 소스 라이브러리 코드를 조작하는 경우가 아니라면 프록시 패턴을 이용하여 내가 작성한 코드와 함께 조작을 할 수 있다. 심지어 방화벽에서도 활용할 수 있는 등, 내가 필요한 시점에 구성할 수 있어 유연성을 가지고 있다.

데코레이터 패턴

  • 메소드 호출의 반환값에 변화를 주기 위해 중간에 장식자를 두는 패턴

데코레이터 패턴의 구현 방식은 프록시 패턴과 동일하다. 단지 프록시 패턴은 클라이언트가 최종적으로 돌려 받는 반환값을 조작하지 않고 그대로 전달한다면, 데코레이터 패턴은 클라이언트가 받는 반환값에 장식을 덧입히는 패턴이다.

프록시 패턴의 코드에서 Proxy 코드만 수정하고 살펴보면 쉽게 이해할 수 있다.

public class Decorator implements IService {
	IService service;
    
    @Override
    public String runSomething() {
    	System.out.println("호출에 대한 장식이 주목적, 클라이언트에게 반환 결과에 장식을 더하여 전달");
        service = new Service();
        return "장식 추가 " + service.runSomething();
    }
}

💡 그래서 프록시 패턴과 데코레이터 패턴의 차이는 정확히 뭐라고 해야할까?

책에서는 프록시 패턴이 그대로 결과를 전달하고 데코레이터 패턴은 결과값을 조작해 전달하는 부분에서 차이가 있다고 했다. 하지만 결과값 조작은 프록시 패턴에서도 적용할 수 있는 부분인데 굳이 패턴을 별도로 나눈 이유가 뭘까?

가장 근본적으로는 앞에서 살펴보았듯, 프록시 패턴은 원본 클래스의 접근에 대한 제어를 목적으로 하고, 데코레이터 패턴은 원본 클래스의 기능에 다른 기능을 추가하는 목적으로 한다.

여기서 추가적으로 찾은 정보로는 - 프록시 패턴은 원본 클래스와 프록시 클래스의 관계가 컴파일타임에 정해지지만, 데코레이터 패턴은 런타임에 정해진다는 차이가 있다. 즉, 프록시 패턴은 애플리케이션 시작전부터 정해져있지만, 데코레이터 패턴은 최종적으로 돌려 받는 반환값을 조작하기 때문에 애플리케이션이 실행되고 있을 때 적용된다는 점에서 차이가 있다고 볼 수 있다.

싱글톤 패턴

  • 클래스의 인스턴스, 즉 객체를 하나만 만들어 사용하는 패턴

싱글톤 패턴은 인스턴스 하나만 만들어 사용하기 위한 패턴이다. 커넥션 풀, 스레드 풀, 디바이스 설정 객체 등과 같은 경우 인스턴스를 여러 개 만들게 되면 불필요한 자원을 사용하게 되고, 프로그램이 예상치 못한 결과를 낳을 수 있다. 즉, 싱글톤 패턴은 단 하나의 인스턴스를 만들고 그것을 계속 재사용하는 패턴이다.

싱글톤 패턴이 되기 위한 조건은 다음과 같다.

  • new를 실행할 수 없도록 private 접근 제어자를 지정한다.
  • 유일한 단일 객체를 반환할 수 있는 정적 메소드가 필요하다.
  • 유일한 단일 객체를 참조할 정적 참조 변수가 필요하다.

public class Singleton { 
	static Singleton singletonObject;
    
    private Singleton() { }
    
    public static Singleton getInstance() {
    	if(singletonObject == null) singletonObject = new Singleton();
        
        return singletonObject;
    }
}

💡 자바에서 싱글톤 패턴을 적용하기 어렵다?

자바에서 싱글톤 패턴을 적용하기 어려운 점은 테스트하기 어렵다는 점도 있지만, 가장 메인 원인은 동적으로 할당하는 부분에 있다. 만약 두 개의 스레드가 위의 코드에서 getInstance() 메소드에 동시에 접근하게 되면 싱글톤 패턴의 목적과 다르게 동작하게 된다. 그럼 어떻게 해결할 수 있을까?

가장 단순한 방법은 getInstance() 메소드 단에 synchronized 키워드를 붙이는 방법이 있다. 하지만 성능 저하의 문제가 올 수 있기 때문에 더 효율적으로 구성한다면 다음과 같이 Double-checked Locking을 적용하여 다음과 같이 조건문을 개량하는 방법이 있다.

if(singletonObject == null) {
	synchronzied(Singleton.class) {
		if(instance == null) singletonObject = new Singleton();
	}
}

하지만 그럼에도 불구하고 다양한 이슈가 있어 위의 방법을 이용한다고 하더라도 적용하기 쉽지 않다. (위의 코드상에서 발생할 수 있는 이슈가 무엇이 있는지에 대한 내용은 내용이 길어질 수 있어 생략한다.)

따라서 스프링에서는 구현하기 어려운 싱글톤 기능(SingletonRegistry)을 별도로 제공해주고 있다. (스프링에서 제공하는 싱글톤 기능은 실제로 싱글톤 패턴으로 구현되어있지 않다고 한다.)

템플릿 메소드 패턴

  • 상위 클래스의 견본 메서드에서 하위 클래스가 오버라이딩한 메소드를 호출하는 패턴
  • 다형성을 제공하는 패턴
  • 의존 역전 원칙을 활용한 설계 패턴

템플릿 메소드 패턴은 상위 클래스에 공통 로직을 수행하는 템플릿 메소드와 하위 클래스에 오버라이딩을 강제하는 추상 메소드 또는 선택적으로 오버라이딩할 수 있는 훅 메소드를 두는 패턴이다.

템플릿 메소드 패턴을 적용하는 과정을 코드로 이해해보자.

public class Dog {
	public void playWithOwner() {
    	System.out.println("귀염둥이 이리온...");
    	System.out.println("멍!멍!");
    	System.out.println("꼬리 살랑 살랑...");
    	System.out.println("잘했어");
    }
}
public class Cat {
	public void playWithOwner() {
    	System.out.println("귀염둥이 이리온...");
    	System.out.println("야옹!야옹!");
    	System.out.println("꼬리 살랑 살랑...");
    	System.out.println("잘했어");
    }
}

상속을 이용하여 동일한 부분은 상위 클래스로, 달라지는 부분을 하위 클래스로 분할하여 템플릿 메소드 패턴을 적용해보자.

public abstract class Animal {
	// 템플릿 메소드
	public void playWithOwner() {
   		System.out.println("귀염둥이 이리온...");
        play();
        runSomething();
        System.out.println("잘했어");
    }
    
    // 추상 메소드
    abstract play();
    
    // 훅 메소드
    void runSomething() {
    	System.out.println("꼬리 살랑 살랑...");
    }
}
public class Dog extends Animal {
	
    @Override
    void play() {
    	System.out.println("멍!멍!");
    }
	
    @Override
    void runSomething() {
    	System.out.println("강아지 꼬리 살랑 살랑...");
    }
}
public class Cat extends Animal {
	
    @Override
    void play() {
    	System.out.println("야옹!야옹!");
    }
	
    @Override
    void runSomething() {
    	System.out.println("고양이 꼬리 살랑 살랑...");
    }
}
public class Driver {
	public static void main(String[] args) {
    	Animal aDog = new Dog();
        Animal aCat = new Cat();
        
        aDog.playWithOwner();
        aCat.playWithOwner();
    }
}

팩터리 메소드 패턴

  • 오버라이드된 메소드가 객체를 반환하는 패턴
  • 어떤 조건에서 어떤 구현체를 리턴할지에 대한 복잡한 로직을 팩토리 메소드에 위임하는 패턴
  • 의존 역전 원칙을 활용한 설계 패턴

팩터리는 물건을 생산하는 공장을 의미한다. 자바에서의 팩터리 메소드는 객체를 생성 반환하는 메소드를 의미한다. 즉, 팩터리 메소드 패턴은 하위 클래스에서 팩터리 메소드를 오버라이딩하여 객체를 반환하게 하는 패턴이다.

위의 템프릿 메소드 패턴 예제에서 강아지와 고양이가 장난감을 가져오는 내용을 추가해보자.

public abstract class Animal {
	// 추상 팩터리 메소드
    abstract AnimalToy getToy();
}
// 팩터리 메소드가 생성할 객체의 상위 클래스
public abstract class AnimalToy {
	abstract void identify();
}
public class Dog extends Animal {
	// 추상 팩터리 메소드 오버라이딩
    @Override
    AnimalToy getToy() {
    	return new DogToy();
    }
}
public class Cat extends Animal {
    @Override
    AnimalToy getToy() {
    	return new CatToy();
    }
}
public class DogToy extends AnimalToy {
	@Override
    public void identify() {
    	System.out.println("강아지 장난감");
    }
}
public class CatToy extends AnimalToy {
	@Override
    public void identify() {
    	System.out.println("고양이 장난감");
    }
}
public class Driver {
	public static void main(String[] args) {
    	Animal aDog = new Dog();
        Animal aCat = new Cat();
        
        AnimalToy aDogToy = aDog.getToy();
        AnimalToy aCatToy = aCat.getToy();
        
        aDogToy.identify();
        aCatToy.identify();
    }
}

전략 패턴 (가장 많이 사용)

  • 클라이언트가 전략을 생성해 전략을 실행할 컨텍스트에 주입하는 패턴
  • 개방 폐쇄 원칙과 의존 역전 원칙을 활용한 설계 패턴

전략 패턴을 구성하는 3가지 요소는 다음과 같다.

  • 전략 메소드를 가진 전략 객체
  • 전략 객체를 사용하는 컨텍스트 (전략 객체의 사용자/소비자)
  • 전략 객체를 생성해 컨텍스트에 주입하는 클라이언트 (제 3자, 전략 객체의 공급자)

책에서는 군인을 예제로 설명했다. 군인은 사용할 무기가 있고 보급 장교가 무기를 군인에게 지급해주면 군인은 주어진 무기에 따라 전투를 수행한다. 이 상황에서 무기는 전략이 되고, 군인은 컨텍스트, 보급 장교는 제 3자인 클라이언트가 된다.

// 무기 인터페이스 (전략)
public interface Strategy {
	public abstract void runStrategy();
}
// 총
public class StrategyGun implements Strategy {
	@Override
    public void runStrateguy() {
    	System.out.println("탕!");
    }
}
// 검
public class StrategySword implements Strategy {
	@Override
    public void runStrategy() {
    	System.out.println("챙!");
    }
}
// 활
public class StrategyBow implements Strategy {
	@Override
    public void runStrategy() {
    	System.out.println("슉!");
    }
}
// 군인 (컨텍스트)
public class Soldier {
	void runContext(Strategy strategy) {
    	System.out.println("전투 시작");
        strategy.runStrategy();
    	System.out.println("전투 종료");
    }
}
// 제 3자, 클라이언트
public class Client {
	public static void main(String[] args) {
    	Strategy strategy = null;
        Soldier rambo = new Soldier();
        
        // 총을 람보에게 전달해 전투를 수행하게 함
        strategy = new StrategyGun();
        rambo.runContext(strategy);
        
        // 검을 람보에게 전달해 전투를 수행하게 함
        strategy = new StrategySword();
        rambo.runContext(strategy);
        
        // 활을 람보에게 전달해 전투를 수행하게 함
        strategy = new StrategyBow();
        rambo.runContext(strategy);
    }
}

전략 패턴은 템플릿 메소드와 유사하다. 하지만 다른 점은 객체 주입을 한다는 점이다. 즉, 동일한 문제에 대한 해결책으로 상속을 이용한 템플릿 메소드 패턴을 이용할 것인지 혹은 객체 주입을 통한 전략 패턴 중에서 선택하여 적용할 수 있다. 단일 상속만이 가능한 자바에서는 상속이라는 제한이 있는 템플릿 메소드보다는 전략 패턴이 더 많이 활용된다.

템플릿 콜백 패턴 (스프링 DI 패턴)

  • 전략을 익명 클래스로 구현한 전략 패턴
  • 개방 폐쇄 원칙과 의존 역전 원칙을 활용한 설계 패턴

템플릿 콜백 패턴은 전략 패턴의 변형으로 스프링의 3대 프로그래밍 모델 중 하나인 의존성 주입에서 사용하는 특별한 형태의 전략 패턴이다. 전략 패턴과 모든 것이 동일하지만 다른 점은 전략을 익명 내부 클래스로 정의해서 사용한다는 특징이다. 대표적으로는 JdbcTemplate와 같이 *Template 형태의 클래스들이 이러한 패턴으로 구현되어있다.

전략 패턴에서의 전략(무기) 클래스를 별도로 만들지 않고 익명 내부 클래스로 구현해보자.

public class Client {
	public static void main(String[] args) {
    	Soldier rambo = new Soldier();
        
        // 총
        rambo.runContext(new Strategy() {
        	@Override
            public void runStrategy() {
            	System.out.println("탕!");
            }
        });
        
        // 검
        rambo.runContext(new Strategy() {
        	@Override
            public void runStrategy() {
            	System.out.println("챙!");
            }
        });
        
        // 활
        rambo.runContext(new Strategy() {
        	@Override
            public void runStrategy() {
            	System.out.println("슉!");
            }
        });
    }
}

여기서 중복되는 코드를 지워보자.

public class Soldier {
	void runContext(String weaponSound) {
    	System.out.println("전투 시작");
        executeWeapon(weaponSound).runStrategy();
    	System.out.println("전투 종료");
    }
    
    private Strategy executeWeapon(final String weaponSound) {
    	return new Strategy() {
        	@Override
            public void runStrategy() {
            	System.out.println(weaponSound);
            }
        };
    }
}
public class Client {
	public static void main(String[] args) {
    	Soldier rambo = new Soldier();
        
        // 총
        rambo.runContext("탕!");
        // 검
        rambo.runContext("챙!");
        // 활
        rambo.runContext("슉!");
        
    }
}

생각을 정리해보자

각 패턴을 통해 어떠한 이점을 얻을 수 있는지 잘 파악하고 실무에 시기적절하게 적용한다는 것은 경험의 영역이라고 한다. 그리고 이러한 패턴들은 사실상 도메인 설계의 영역이라고 볼 수 있다. 각 도메인의 관계에 따라 패턴이 달라질 수 있기 때문이다. 즉, 도메인 설계가 사실상 가장 중요하다는 이야기다.

따라서 지금의 나처럼 미숙한 개발자라면 어떤 패턴을 적용할지 생각하고 개발을 하는 것보다, 인터페이스를 잘 쪼개고 각각의 클래스가 명확하게 의미를 가져가도록하는 등 도메인 설계에 더 신경을 쓰는 것부터 시작해야 한다고 생각한다.

이 글의 레퍼런스

profile
세상을 현명하게 이끌어갈 나의 성장 일기 📓

0개의 댓글