-
Spring의 주요 디자인 패턴Spring 2023. 4. 17. 20:18
디자인 패턴은 소프트웨어 개발의 필수적인 부분이다.
반복되는 문제를 해결해주며, 개발자로 하여금 자주 사용되는 패턴을 통해 프레임워크의 설계를 이해하도록 도움을 준다.
Spring Framework에서는 여러 디자인 패턴을 적용하여 개발자의 부담을 덜어주고, 지루한 작업을 빠르게 수행할 수 있도록 도와준다.
Spring Framework에서 가장 많이 사용되는 4가지 패턴
1. Singleton pattern
2. Factory Method pattern
3. Proxy pattern
4. Template pattern
1. Singleton pattern
싱글톤 패턴은 어플리케이션 당 오직 하나의 인스턴스만 존재하도록 보장해주는 패턴이다. 공유 자원을 관리하거나 cross-cutting services(ex. logging)를 제공할 때 유용하다.
1.1 Singleton Beans
일반적으로 Singleton Object는 어플리케이션에서 유일해야 하지만, Spring에서는 이러한 제약이 완화된다.
Spring에서는 하나의 Spring IoC Contaner당 하나의 Singleton Object를 갖도록 제한한다.
실제로 이것은 Spring Framework가 하나의 Application Context당 하나의 Bean을 생성하는 것을 의미한다.
따라서, Spring Framework에서는 Application에서 여러 Spring Container를 가질 수 있기 때문에 singleton의 정의와는 다르다.
즉, 여러 Container를 가진 Application 에서는 같은 클래스의 객체가 여러개 존재할 수 있다.
1.2 Autowired Singletons
기본적으로 스프링은 모든 Bean들을 싱글톤으로 생성한다.
예를 들어, 단일 Application Context 내에서 두 Controller를 생성하고, 같은 타입의 Bean을 각각에 주입할 수 있다.
@RestController public class LibraryController { @Autowired private BookRepository repository; @GetMapping("/count") public Long findCount() { System.out.println(repository); return repository.count(); } }
@RestController public class BookController { @Autowired private BookRepository repository; @GetMapping("/book/{id}") public Book findById(@PathVariable long id) { System.out.println(repository); return repository.findById(id).get(); } }
이후 Application을 실행하고 두 요청을 실행하면
$ curl -X GET http://localhost:8080/count $ curl -X GET http://localhost:8080/book/1
아래와 같이 동일한 Object ID를 가진 객체를 확인할 수 있다.
com.baeldung.spring.patterns.singleton.BookRepository@3ea9524f com.baeldung.spring.patterns.singleton.BookRepository@3ea9524f
즉, Spring이 LibraryController와 BookController에 같은 BookRepository 빈을 주입했다는 것을 증명할 수 있다.
@Scope를 사용하여 Bean 스코프를 싱글톤에서 프로토타입으로 변경하여 Book Repository Bean의 별도 인스턴스를 생성할 수 있다. 이렇게 하면 Spring이 작성하는 각 Book Repository 빈에 대해 서로 다른 객체를 생성하도록 지시한다.
ex) @Scope(ConfigurationBeanFactory.SCOPE_PROTOTYPE)
2. Factory Method Pattern
팩토리 메소드 패턴은 원하는 객체를 생성하기 위한 추상 메서드가 있는 팩토리 클래스를 생성한다.
예를 들어, 아래와 같이 Vehicle 객체를 생성하고자 할 때, 해상 환경에서는 Boat 객체를, 항공 환경에서는 Airplan 객체를 만들고 싶다고 가정하면, 각 환경별 Factory 구현체를 구현할 수 있고, 구체화한 Factory Method로 부터 원하는 객체를 반환할 수 있다.
2.1 Application Context
스프링은 이 기술을 Dependency Injection(DI)에서 사용한다.
기본적으로, 스프링은 Bean Container를 Bean을 생성하는 Factory로 취급한다.
따라서, Spring Framework는 BeanFactory 인터페이스를 Bean Container의 추상화로 정의한다.
public interface BeanFactory { getBean(Class<T> requiredType); getBean(Class<T> requiredType, Object... args); getBean(String name); // ... }
각 getBean() 메서드는 팩토리 메서드로 간주되어 메서드에 제공된 기준(name, requiredType...)과 일치하는 빈을 반환한다.
Spring은 BeanFactory를 상속하여 추가적인 Application 설정을 다루는 ApplicationContext 인터페이스를 구현한다.
Spring은 XML 파일 또는 자바 어노테이션과 같은 일부 외부 설정을 기반으로 빈 컨테이너를 시작하기 위해 이러한 ApplicationContext를 사용한다.
AnootationConfigApplicationContext와 같은 ApplicationContext의 구현체를 사용하여 BeanFactory 인터페이스로부터 상속한 다양한 Factory Method를 통해 빈을 생성할 수 있다.
먼저, 간단한 Application Configuration을 생성한다.
@Configuration @ComponentScan(basePackageClasses = ApplicationConfig.class) public class ApplicationConfig { }
다음으로 Foo클래스를 생성한다.
@Component public class Foo { }
Bar라는 다른 클래스를 생성한다.
@Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class Bar { private String name; public Bar(String name) { this.name = name; } // Getter ... }
마지막으로, ApplicationContext의 구현체인 AnnotationConfigApplicationContext를 통해 빈을 생성한다.
@Test public void whenGetSimpleBean_thenReturnConstructedBean() { ApplicationContext context = new AnnotationConfigApplicationContext(ApplicationConfig.class); Foo foo = context.getBean(Foo.class); assertNotNull(foo); } @Test public void whenGetPrototypeBean_thenReturnConstructedBean() { String expectedName = "Some name"; ApplicationContext context = new AnnotationConfigApplicationContext(ApplicationConfig.class); Bar bar = context.getBean(Bar.class, expectedName); assertNotNull(bar); assertThat(bar.getName(), is(expectedName)); }
getBean 팩토리 메서드를 사용해 class type과 파라미터만을 가지고 설정된 Bean을 생성할 수 있다.
2.2 External Configuration
이러한 Factory Method 패턴은 Application의 동작을 외부 설정에 맞게 변경할 수 있기 때문에 다양하게 사용 가능하다.
만약 빈 설정정보가 Java가 아닌 xml인 경우 ClassPathXmlApplicationContext와 같은 XML기반 구성 클래스로 변경할 수 있다.
@Test public void givenXmlConfiguration_whenGetPrototypeBean_thenReturnConstructedBean() { String expectedName = "Some name"; ApplicationContext context = new ClassPathXmlApplicationContext("context.xml"); // Same test as before ... }
결론적으로 ApplicationContext의 getBean()메서드를 추상메서드로 선언하여 서브클래스인 ClassPathXmlApplicationContext, AnnotationConfigApplicationContext와 같은 서브클래스에서 객체생성을 책임지도록 한다.
3. Proxy pattern
프록시 패턴은 한 객체(proxy)가 다른 객체(subject or service)로의 접근을 제어하도록 하는 기술이다.
3.1 Transactions
Proxy를 생성하기 위해 Subject와 동일한 인터페이스를 구현하고 Subject에 대한 참조를 포함하는 객체를 생성한다.
이로써 Subject 대신 Proxy를 사용할 수 있다.
대표적인 예가 Transaction이다.
@Service public class BookManager { @Autowired private BookRepository repository; @Transactional public Book create(String author) { System.out.println(repository.getClass().getName()); return repository.create(author); } }
BookManager 클래스에서 create()메서드에 @Transactional 어노테이션을 추가했다.
@Transactional 어노테이션은 Spring에게 create 메서드를 원자적(Automacally)으로 실행하도록 지시한다.
Proxy 없이는 Spring이 BookRepository Bean에 접근해서 트랜잭션 일관성(Consistency)을 보장할 수 없다.
3.2 CGLib Proxies
CGLib Proxy는 스프링이 BookRepository를 감싸고 Bean들에게 create 메서드를 원자적으로 실행하도록 지시하는 Proxy를 생성한다.
BookManager의 create 메소드를 호출하면 다음과 같은 출력을 볼 수 있다.
com.baeldung.patterns.proxy.BookRepository$$EnhancerBySpringCGLIB$$3dc2b55c
일반적으로, 기본 BookRepository의 Object ID가 출력될 것을 기대하지만, 그 대신, EnhancerBySpringCGLib의 Object ID가 출력되는 것을 볼 수 있다.
뒷단에서 Spring Framework는 BookRepository 객체를 EnhancerBySpringCGLIB 객체로 wrapping한다. 그럼으로써 Spring Framework는 BookRepository 객체(트랜잭션 일관성을 보장하는)에 대핸 접근을 제어할 수 있게 된다.
* 스프링의 AOP 프록시 구현 방법(JDK Dynamic Proxy, CGLib Proxy)
AOP는 부가 기능을 핵심 기능으로 분리하기 위해 등장한 기술로, 부가 기능을 분리함으로써 해당 로직을 재사용할 수 있고, 핵심 기능은 핵심 역할에만 집중할 수 있도록 도와준다.
Spring의 AOP는 기본적으로 프록시 방식으로 동작하도록 되어 있는데, Spring에서 AOP를 활용하기 위해서는 @EnableAspectAutoProxy 어노테이션을 붙여주어야 하며, 이에 대한 옵션으로 proxyTargetClass가 있다.
이 옵션을 주지 않으면 스프링 빈을 찾지 못하는 등의 에러가 발생할 수 있는데, 이를 이해하기 위해 우리가 수동으로 구현하는 프록시 방식과 Spring이 자동으로 구현하는 프록시 방식에 대해 알아보자.
수동으로 직접 Proxy 구현
예를 들어 다음과 같은 DiscountController와 DiscountService 및 구현체(RateDiscountService)가 있다고 하자.
@RestController @RequiredArgsConstructor public class DiscountController { private final DiscountService discountService; } public interface DiscountService { int discount(); } @Service public class RateDiscountService implements DiscountService { @Override public int discount() { ... } }
우리는 RateDiscountService의 discount 메소드가 호출되기 이전에 부가 기능을 적용하기 위해 다음과 같이 인터페이스를 구현하여 Proxy 객체를 직접 생성할 수 있다.
@Service public class RateDiscountServiceProxy implements DiscountService { // 여기서는 RateDiscountService에 해당한다. private DiscountService discountService; public DiscountServiceProxy(DiscountService discountService) { this.discountService = discountService; } @Override public int discount() { // 1. 메소드 호출 전의 부가 기능 처리 // 2. 실제메소드 호출 this.discountService.discount() // 3. 메소드 호출 후의 부가 기능 처리 } }
위와 같이 실제 객체의 메소드가 호출 전/후에 처리해야 하는 부가기능을 추가함으로써 AOP를 구현할 수 있다.
하지만 이러한 방식은 다음과 같은 문제점을 가지고 있다.
- 불필요하게 DiscountService 타입의 빈이 2개 등록됨
- DI(Dependency Injection, 의존성 주입) 시에 문제가 발생할 수 있음
Spring이 1개의 타입에 대해 불필요하게 여러 개의 빈을 관리해야 할 뿐만 아니라 해당 타입의 빈이 여러 개이므로 의존성 주입 시에도 문제가 발생할 여지가 있는 것이다. 물론 변수 이름이나 지시자 등으로 피할 수 있지만 이는 번거롭다.
Spring의 JDK Dynamic Proxy 구현
위와 같은 문제를 해결하기 위해 Spring은 Java 언어 차원에서 제공하는 자동 프록시 생성기를 통해 직접 프록시 객체를 생성한 후에 특별한 처리를 해주는데, 이를 JDK 동적 프록시 또는 JDK 다이나믹 프록시라고 부른다.
Spring은 프록시를 구현할 때 프록시를 구현한 객체(RateDiscountServiceProxy)를 마치 실제 빈(RateDiscountService)인 것처럼 포장하고, 2개의 빈을 모두 등록하는 것이 아니라 실제 빈을 프록시가 적용된 빈으로 바꿔치기한다.
이러한 방식이 가능한 이유는 프록시 구현 대상(RateDiscountService)이 인터페이스(DiscountService)를 구현하고 있으며, Spring이 프록시 구현체(RateDiscountServiceProxy)를 만들때 프록시 대상과 동일한 인터페이스(DiscountService)를 구현하도록 했기 때문이다.
즉, 프록시 대상(RateDiscountService)과 프록시 구현체(RateDiscountServiceProxy) 모두 동일한 인터페이스(DiscountService) 타입 및 구현체이기 때문에 기존의 RateDiscountService 빈을 RateDiscountServiceProxy로 바꿔치기 하고, 빈 후처리기를 통해 이미 정의된 의존 관계 역시 바꿀 수 있는 것이다.
이를 그림으로 표현하면 다음과 같다.
이러한 방식은 괜찮아 보이지만 만약 실제 빈을 직접 참조하고 있는 경우라면 문제가 발생한다.
위의 예제에서 문제가 발생하지 않은 이유는 실제 빈(RateDiscountService)이 DiscountService라는 인터페이스에 의존하고 있고, DiscountController에서도 DiscountService에 의존하고 있기 때문이다.
하지만 만약 다음과 같이 결제를 담당하는 PaymentService에서 구체 클래스(RateDiscountService)를 주입받고 있다면 어떻게 될까?
@Service @RequiredArgsConstructor public class PaymentService { private final RateDiscountService rateDiscountService; }
Spring이 새롭게 추가한 RateDiscountServiceProxy는 DiscountService 인터페이스를 구현한 클래스이지 RateDiscountService를 상속받은 클래스가 아니다. 그래서 RateDiscountService 타입의 빈을 찾을 수 없어 에러가 발생하게 된다.
또한 JDK dynamic proxy는 인터페이스를 기반으로 프록시 객체를 생성하는데, 인터페이스를 구현한 클래스가 아니면 프록시 객체를 생성할 수 없다. 즉, JDK 동적 프록시는 다음과 같은 두 가지 한계점을 가지게 된다.
- 프록시를 적용하기 위해서 반드시 인터페이스를 생성해야 함
- 구체 클래스로는 빈을 주입받을 수 없고, 반드시 인터페이스로만 주입받아야 함
하지만 실제 개발을 하다보면 인터페이스 없이 구체 클래스에 의존하는 경우도 많은데, AOP를 적용하기 위해 모든 빈들에게 인터페이스를 만들어주는 것은 상당히 번거롭다. 또한 구체 클래스에 의존해야 하는 경우에는 빈을 찾을 수 없어서 에러가 발생하므로 대처가 어렵다. 이러한 이유로 Spring은 JDK 동적 프록시 방식이 아닌 또 다른 프록시 구현 방식을 구현하였다.
Spring의 CGLib Proxy 구현
위와 같은 문제를 해결하기 위해서는 Spring이 구현해주는 Proxy 객체가 인터페이스(DiscountService)를 기반으로 하지 않고 클래스(RateDiscountService)를 구현한 객체여야 한다. 즉, 클래스 상속을 기반으로 프록시를 구현하도록 강제해야 하는 것이다.
public class RateDiscountServiceProxy extends RateDiscountService { ... }
그러면 Spring은 RateDiscountServiceProxy를 구현할 때 위와 같이 RateDiscountService를 상속받아 구현하는데, 이러한 클래스 기반의 프록시를 구현하기 위해서는 바이트 코드를 조작해야 한다. 그래서 Spring은 CGLib이라는 바이트 조작 라이브러리를 통해 클래스 상속으로 프록시를 구현함으로써 JDK 동적 프록시에 의한 문제를 완전히 해결하고 있다.
CGLib을 이용한 프록시 방식은 클래스 기반의 프록시이므로 인터페이스가 없어도 적용가능하며, 인터페이스가 구현된 빈의 경우에도 인터페이스 주입과 구체 클래스 주입이 모두 가능하다.
대신 CGLib 프록시는 상속을 이용하므로 기본 생성자를 필요로 하며, 생성자가 2번 호출되고 final 클래스나 final 메소드면 안된다는 제약이 있다.
정리
원래 Spring은 프록시 타깃 객체에 인터페이스가 있다면 그 인터페이스를 구현한 JDK 다이내믹 프록시 방식으로 객체를 생성하고, 인터페이스가 없다면 CGLib을 이용한 클래스 프록시를 만든다.
- 인터페이스를 구현하고 있는지 확인함
- 인터페이스를 구현하고 있으면 JDK 다이내믹 프록시 방식으로 객체를 생성
- 인터페이스를 구현하지 않으면 GCLib 방식으로 객체를 생성
하지만 JDK 동적 프록시 방식은 다음과 같은 2가지 한계점을 가지고 있다.
- 프록시를 적용하기 위해서 반드시 인터페이스를 생성해야 함
- 구체 클래스로는 빈을 주입받을 수 없고, 반드시 인터페이스로만 주입받아야 함
그래서 스프링은 CGLib 방식의 프록시를 강제하는 옵션을 제공하고 있는데, 이것이 바로 proxyTargetClass이며, 이 값을 true로 지정해주면 Spring은 인터페이스가 있더라도 무시하고 클래스 프록시를 만들게 된다.
SpringBoot에서는 CGLib 라이브러리가 갖는 단점들을 모두 해결하였고, proxyTargetClass 옵션의 기본값을 true로 사용하고 있다.
참고
Design Patterns in the Spring Framework | Baeldung
'Spring' 카테고리의 다른 글
템플릿 메서드 패턴, 팩토리 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴 (0) 2023.05.02 Spring 빈 스코프(Bean Scope) - 싱글톤, 프로토타입 (0) 2023.03.30 Spring 필터(Filter), 인터셉터(Interceptor), AOP (0) 2023.03.16 Open EntityManager In View 필터 (0) 2023.02.14 Spring의 @EventListener (0) 2023.01.30