스프링 프레임워크에 적용된 디자인 패턴 분석
패턴을 생성·구조·행위·아키텍처로 나누는 이유
디자인 패턴을 분류하는 기준은 그 패턴이 해결하는 문제의 성격이다. GoF(Gang of Four)는 23개의 패턴을 세 범주로 나누었고, 프레임워크 수준의 논의를 위해 여기에 아키텍처 범주를 더한다. 분류는 그 자체가 목적이 아니라, 어떤 문제 상황에서 어떤 패턴을 떠올려야 하는지를 안내하는 지도의 역할을 한다.
생성(Creational) 패턴 — "객체를 어떻게 만들 것인가". 객체의 생성 과정을 다룬다. 무엇을, 언제, 몇 개나, 어떤 절차로 생성할지를 사용처로부터 분리하여 결합도를 낮추는 것이 관심사다. 생성 방식이 코드 곳곳에 흩어지면 변경에 취약해지므로, 생성 책임을 한곳으로 모으고 그 방식을 유연하게 만든다. 싱글톤, 팩토리, 빌더가 여기에 속한다.
구조(Structural) 패턴 — "객체를 어떻게 조합할 것인가". 여러 객체를 결합하여 더 큰 구조를 형성하는 방법을 다룬다. 기존 객체를 감싸거나(래핑), 인터페이스를 변환하거나, 여러 객체를 하나처럼 묶는 방식으로, 원본을 수정하지 않고 새로운 구조와 기능을 만들어내는 것이 관심사다. 프록시, 데코레이터, 어댑터가 여기에 속한다.
행위(Behavioral) 패턴 — "객체 간 책임과 흐름을 어떻게 나눌 것인가". 객체들이 상호작용하며 책임을 분배하는 방식을 다룬다. 제어의 흐름, 요청의 전달, 알고리즘의 위임처럼 "객체들이 어떻게 협력하여 일을 처리하는가"가 관심사다. 템플릿 메서드, 전략, 옵서버, 책임 연쇄가 여기에 속한다.
아키텍처(Architectural) 패턴 — "시스템 전체를 어떻게 배치할 것인가". GoF의 세 범주가 클래스·객체 수준의 문제를 다룬다면, 아키텍처 패턴은 애플리케이션 전체의 구조와 흐름을 다룬다. 요청의 진입점을 어디에 둘지, 관심사를 어떤 계층으로 분리할지와 같은 상위 수준의 결정이 관심사다. 프론트 컨트롤러, MVC, IoC/DI가 여기에 속한다.
요컨대 네 범주는 각각 **생성(어떻게 만드나)·구조(어떻게 조합하나)·행위(어떻게 협력하나)·아키텍처(어떻게 배치하나)**라는 서로 다른 층위의 질문에 대응한다. 하나의 HTTP 요청이 처리되는 과정에서 이 네 층위의 패턴이 모두 관여한다는 점은 제4장에서 확인한다.
1. 생성(Creational) 패턴
1.1. 싱글톤(Singleton) — 빈의 기본 스코프
싱글톤 패턴은 클래스의 인스턴스를 단 하나만 생성하여 공유하는 패턴이다. 스프링에서는 @Bean 및 @Component로 등록되는 빈의 기본 스코프가 싱글톤이며, 컨테이너가 빈을 하나만 생성하여 ApplicationContext에 보관하고 모든 사용처가 이를 공유한다.
@Service // 이 빈은 애플리케이션 전체에서 인스턴스 1개
public class OrderService { ... }
서비스나 리포지토리 계층의 객체는 상태를 갖지 않는(stateless) 협력 객체이므로 요청마다 새로 생성할 필요가 없으며, 단일 인스턴스를 공유함으로써 메모리 사용량과 GC 부담을 절감할 수 있다.
다만 두 가지 유의점이 존재한다. 첫째, GoF의 고전적 싱글톤(클래스의 static 멤버 기반)과 스프링의 싱글톤은 적용 범위가 다르다. 스프링의 싱글톤은 컨테이너가 관리하며 ApplicationContext당 1개가 보장되는 개념이다. 둘째, 싱글톤 빈은 다수의 스레드가 동시에 공유하므로 가변 상태(mutable field)를 두는 경우 스레드 안전성이 훼손된다. 서비스 계층에 인스턴스 필드로 상태를 두지 않는 설계 원칙의 근본적인 이유가 여기에 있다.
1.2. 팩토리 메서드 / 추상 팩토리(Factory Method / Abstract Factory) — 컨테이너 자체
팩토리 패턴은 객체 생성 로직을 별도의 팩토리로 캡슐화하여, 호출자가 "무엇을 생성하는지"만 알고 "어떻게 생성하는지"는 알 필요가 없도록 하는 패턴이다.
스프링에서 BeanFactory와 ApplicationContext는 빈의 생성과 조립을 대행하는 거대한 팩토리에 해당한다. 또한 FactoryBean<T> 인터페이스는 복잡한 생성 로직을 캡슐화하는 커스텀 팩토리를 정의하는 수단으로, MyBatis의 SqlSessionFactoryBean이 대표적인 사례이다.
public class MyConnFactoryBean implements FactoryBean<Connection> {
public Connection getObject() { /* 복잡한 생성 로직 */ }
public Class<?> getObjectType() { return Connection.class; }
}
사용처에서 new 연산자로 객체를 직접 생성하는 경우 결합도가 높아지고 생성 로직이 중복된다. 생성을 팩토리에 위임하면 사용처는 인터페이스에만 의존하게 되며, 이는 의존성 주입(DI)의 토대가 된다.
1.3. 빌더(Builder) — 복잡한 객체의 단계적 생성
빌더 패턴은 생성자 인자가 많고 선택적인 경우 발생하는 점층적 생성자(telescoping constructor) 문제를 회피하기 위해, 객체를 단계적으로 구성하는 패턴이다. 스프링에서는 RestClient.builder(), WebClient.builder(), UriComponentsBuilder, BeanDefinitionBuilder 등이 이에 해당하며, 롬복(Lombok)의 @Builder 역시 동일한 계열이다.
WebClient client = WebClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader("Authorization", token)
.build();
2. 구조(Structural) 패턴
2.1. 프록시(Proxy) — AOP의 핵심
프록시 패턴은 원본 객체를 감싸는 대리 객체를 두고, 실제 호출의 전후에 부가 기능(횡단 관심사)을 삽입하는 패턴이다. 스프링 AOP 전체가 프록시 패턴에 기반하며, @Transactional, @Async, @Cacheable, @Secured 등의 선언적 기능이 모두 프록시가 원본 메서드를 감싸는 방식으로 동작한다.
@Transactional // 프록시가 트랜잭션 시작 → 원본 실행 → 커밋/롤백을 수행
public void placeOrder() { ... }
동작 흐름은 다음과 같다.
호출자 → [프록시] → (트랜잭션 begin) → 원본 placeOrder() → (commit) → 반환
프록시 생성 방식은 대상 클래스가 인터페이스를 구현한 경우 JDK Dynamic Proxy, 그렇지 않은 경우 상속 기반의 CGLIB이 사용된다. 이 패턴의 가치는 트랜잭션·로깅·보안과 같은 횡단 관심사를 비즈니스 코드에 혼입시키지 않고 어노테이션 선언만으로 주입할 수 있다는 데 있다.
한 가지 중요한 한계는 자가 호출(self-invocation) 문제이다. 동일 클래스 내부에서 this.otherMethod() 형태로 메서드를 호출하면 프록시를 경유하지 않으므로 @Transactional이 적용되지 않는다. 프록시는 외부에서의 진입점만을 감싸기 때문이다.
2.2. 데코레이터(Decorator) — 기능의 동적 확장
개념. 데코레이터 패턴은 상속을 사용하지 않고, 객체를 동일한 인터페이스를 구현한 래퍼(wrapper)로 감싸 기능을 동적으로 추가하는 패턴이다. 래퍼는 자신이 감싼 원본 객체와 같은 타입이므로, 감싼 결과물을 다시 다른 래퍼로 감쌀 수 있다. 이렇게 여러 겹을 중첩하면 기능이 층층이 쌓이며, 각 층은 자신의 부가 기능을 수행한 뒤 안쪽 객체에 위임한다.
비유. 카페의 음료 주문을 떠올리면 된다. 기본 에스프레소에 우유를 더하면 라떼가 되고, 거기에 휘핑크림을 더하면 또 다른 음료가 된다. 우유와 휘핑크림은 각각 "음료를 감싸 새로운 맛을 더하는" 데코레이터이며, 무엇을 몇 겹 두르느냐에 따라 최종 음료가 결정된다. 원본인 에스프레소 자체는 전혀 바뀌지 않는다.
구체적 예시 — 자바 I/O 스트림. 데코레이터 패턴의 가장 대표적인 실물 사례는 자바의 I/O 스트림이다. 아래 코드에서 각 클래스는 모두 InputStream이라는 동일한 추상 타입을 구현하면서, 서로를 감싸며 기능을 누적한다.
InputStream in =
new BufferedInputStream( // (3) 버퍼링 기능 추가 — 성능 향상
new GZIPInputStream( // (2) 압축 해제 기능 추가
new FileInputStream("data.gz"))); // (1) 원본: 파일에서 바이트를 읽음
int b = in.read(); // 호출 하나가 (3)→(2)→(1) 순으로 위임되며 각 층의 기능이 적용된다
FileInputStream은 "파일에서 바이트를 읽는다"는 핵심 기능만 갖는 원본이다. 이를 GZIPInputStream으로 감싸면 "읽은 바이트를 압축 해제한다"는 기능이 더해지고, 다시 BufferedInputStream으로 감싸면 "버퍼를 두어 읽기 성능을 높인다"는 기능이 더해진다. in.read()를 호출하면 바깥 래퍼부터 안쪽 원본까지 위임이 연쇄적으로 일어나며 각 층의 기능이 순서대로 적용된다.
주목할 점은 기능의 조합이 런타임에 결정된다는 것이다. 만약 상속으로 이를 구현했다면 BufferedGZIPFileInputStream, GZIPFileInputStream, BufferedFileInputStream처럼 기능 조합의 수만큼 클래스가 폭발적으로 늘어난다(조합 폭발). 데코레이터는 이 조합을 감싸는 순서로 표현하므로, 소수의 래퍼 클래스만으로 임의의 조합을 만들어낼 수 있다.
스프링에서의 적용. 스프링에서는 DataSource를 감싸 트랜잭션 동기화 기능을 더하는 TransactionAwareDataSourceProxy가 대표적이다. 원본 커넥션 풀(DataSource)을 감싸, 커넥션을 요청할 때 현재 트랜잭션에 묶인 커넥션을 반환하도록 행동을 추가한다.
DataSource dataSource =
new TransactionAwareDataSourceProxy( // 트랜잭션 인식 기능을 덧입힘
new HikariDataSource(hikariConfig)); // 원본: 실제 커넥션 풀
이 밖에도 요청 객체에 기능을 덧씌우는 서블릿의 HttpServletRequestWrapper(예: 요청 본문을 여러 번 읽을 수 있도록 캐싱하는 래퍼), 여러 Cache 구현을 감싸는 데코레이터 등이 있다.
프록시와의 구별. 프록시와 데코레이터는 "원본을 같은 인터페이스의 객체로 감싼다"는 점에서 구조적으로 거의 동일하나, 의도에서 구별된다. 프록시는 접근 제어와 대리(호출 자체의 통제 — 트랜잭션 경계 설정, 지연 로딩, 권한 검사)를 목적으로 하는 반면, 데코레이터는 원본에 새로운 행동을 더하는 기능 확장(압축, 버퍼링과 같은 기능 누적)을 목적으로 한다. 클래스 이름에 Proxy가 붙었더라도 실제로는 데코레이터적으로 쓰이는 경우가 있는 등, 스프링은 두 패턴을 혼용하는 경우가 많다.
2.3. 어댑터(Adapter) — 인터페이스 변환
개념. 어댑터 패턴은 서로 호환되지 않는 인터페이스를 가진 객체를, 클라이언트가 기대하는 인터페이스로 변환하여 함께 동작할 수 있게 하는 패턴이다. 클라이언트는 자신이 아는 인터페이스로 호출하고, 어댑터가 그 호출을 내부적으로 원본 객체가 이해하는 형태로 바꿔 전달한다. 원본 객체나 클라이언트 어느 쪽도 수정하지 않고, 그 사이에 변환 계층을 끼워 넣는 것이 핵심이다.
비유. 해외여행 시 사용하는 전원 플러그 어댑터(멀티 어댑터)가 정확히 이 패턴이다. 한국의 220V 플러그(원본)를 일본의 콘센트(클라이언트가 기대하는 규격)에 그대로 꽂을 수는 없다. 이때 어댑터를 사이에 끼우면, 플러그도 콘센트도 개조하지 않은 채 둘을 연결할 수 있다. 어댑터는 "한쪽의 모양을 다른 쪽이 받아들이는 모양으로 바꿔주는" 중개자일 뿐이다.
구체적 예시 — 낡은 인터페이스를 새 인터페이스에 맞추기. 결제 시스템에서 클라이언트는 PaymentProcessor라는 인터페이스로 결제를 호출한다고 하자. 그런데 새로 연동해야 할 외부 PG사의 SDK는 시그니처가 전혀 다른 LegacyPgClient를 제공한다.
// 클라이언트가 기대하는 인터페이스 (Target)
interface PaymentProcessor {
void pay(int amount);
}
// 외부 SDK — 우리가 수정할 수 없고, 시그니처도 다르다 (Adaptee)
class LegacyPgClient {
void sendPayment(String currency, long cents) { /* 실제 결제 */ }
}
// 어댑터 — LegacyPgClient를 PaymentProcessor로 변환한다
class LegacyPgAdapter implements PaymentProcessor {
private final LegacyPgClient legacy;
LegacyPgAdapter(LegacyPgClient legacy) { this.legacy = legacy; }
@Override
public void pay(int amount) {
legacy.sendPayment("KRW", amount * 100L); // 호출을 원본이 이해하는 형태로 번역
}
}
클라이언트는 여전히 PaymentProcessor.pay()만 호출하며, 외부 SDK가 LegacyPgClient인지 다른 무엇인지 전혀 알지 못한다. 원본 SDK를 수정하지 않고도(수정할 수도 없다) 우리 시스템에 통합된다. 나중에 PG사가 바뀌어도 새 어댑터만 하나 만들면 되고, 클라이언트 코드는 그대로다.
스프링에서의 적용. 스프링 MVC의 HandlerAdapter가 대표적인 사례다. 스프링에는 여러 유형의 컨트롤러가 존재한다. 어노테이션 기반의 @Controller, 인터페이스 기반의 HttpRequestHandler, 함수형 엔드포인트 등은 호출 방식(메서드 시그니처)이 서로 완전히 다르다. DispatcherServlet이 이들을 각각 다르게 호출하려면 컨트롤러 유형마다 분기 처리를 해야 할 것이다.
// DispatcherServlet은 모든 컨트롤러를 이 단일 인터페이스로만 호출한다
public interface HandlerAdapter {
boolean supports(Object handler); // 이 어댑터가 해당 컨트롤러를 다룰 수 있는지
ModelAndView handle(HttpServletRequest req, HttpServletResponse res, Object handler);
}
RequestMappingHandlerAdapter(@Controller용), HttpRequestHandlerAdapter(HttpRequestHandler용) 등 각 어댑터가 서로 다른 컨트롤러를 handle()이라는 단일 방식으로 변환해준다. 그 결과 DispatcherServlet은 개별 컨트롤러의 유형을 전혀 인지할 필요 없이 handle()만 호출하면 된다. 새로운 컨트롤러 유형이 추가되어도 그에 맞는 HandlerAdapter만 등록하면 되므로, DispatcherServlet의 코드는 변경되지 않는다. 이 구조는 제4장에서 다루는 요청 처리 흐름의 핵심 구성 요소이다.
3. 행위(Behavioral) 패턴
3.1. 템플릿 메서드(Template Method) — 뼈대 고정과 세부 위임
개념. 템플릿 메서드 패턴은 알고리즘의 전체 뼈대(처리 순서)를 상위 클래스가 고정해두고, 그중 단계마다 달라지는 세부 구현만 하위 클래스에 위임하는 패턴이다. 상위 클래스에는 처리 순서를 정의한 메서드(템플릿 메서드)가 있고, 그 안에서 호출되는 개별 단계 중 일부가 추상 메서드로 비어 있다. 하위 클래스는 이 빈칸(단계)만 채우며, 전체 흐름 자체는 바꿀 수 없다. 즉 "변하지 않는 것은 상위가 고정하고, 변하는 것만 하위가 채운다".
비유. 음료 제조 과정을 떠올리면 된다. 커피든 홍차든 "물을 끓이고 → 우려내고 → 잔에 붓고 → 첨가물을 넣는다"는 순서는 동일하다. 다만 "우려내는 방법"(커피는 필터로, 홍차는 찻잎으로)과 "첨가물"(커피는 우유·설탕, 홍차는 레몬)만 다르다. 전체 순서는 고정된 레시피로 두고, 달라지는 두 단계만 음료별로 채우는 것이 템플릿 메서드다.
구체적 예시 — 상속 기반의 고전적 형태. 위 비유를 코드로 옮기면 다음과 같다.
abstract class Beverage {
// 템플릿 메서드: 알고리즘의 뼈대를 final로 고정하여 순서 변경을 막는다
public final void prepare() {
boilWater(); // 공통 단계
brew(); // ← 가변 단계 (하위가 구현)
pourInCup(); // 공통 단계
addCondiments(); // ← 가변 단계 (하위가 구현)
}
private void boilWater() { System.out.println("물을 끓인다"); } // 고정
private void pourInCup() { System.out.println("잔에 붓는다"); } // 고정
protected abstract void brew(); // 빈칸 1: 우려내는 방법
protected abstract void addCondiments(); // 빈칸 2: 첨가물
}
class Coffee extends Beverage {
protected void brew() { System.out.println("필터로 커피를 내린다"); }
protected void addCondiments() { System.out.println("우유와 설탕을 넣는다"); }
}
class Tea extends Beverage {
protected void brew() { System.out.println("찻잎을 우려낸다"); }
protected void addCondiments() { System.out.println("레몬을 넣는다"); }
}
prepare()가 템플릿 메서드로서 처리 순서를 확정하고, Coffee와 Tea는 brew()와 addCondiments()라는 빈칸만 채운다. 템플릿 메서드를 final로 선언하는 이유는 하위 클래스가 알고리즘의 뼈대(순서) 자체를 변경하지 못하도록 강제하기 위함이다. 이로써 "물을 끓이기 전에 첨가물을 넣는" 것과 같은 잘못된 흐름이 원천 차단된다.
스프링에서의 적용. 스프링에서 명칭에 Template이 포함된 구성 요소, 즉 JdbcTemplate, RestTemplate, JmsTemplate, TransactionTemplate 등이 모두 이 패턴을 구현하며, 이는 스프링이라는 프레임워크의 정체성을 상징하는 패턴 중 하나이다. JdbcTemplate을 예로 들면, 상위(템플릿)가 고정하는 뼈대와 개발자가 채우는 빈칸이 다음과 같이 나뉜다.
JdbcTemplate이 고정하는 뼈대 (보일러플레이트)
1. 커넥션 획득
2. Statement 생성 및 SQL 실행
3. ─────────── (빈칸) ─────────── ← 개발자가 채움
4. 결과 처리 후 자원 반납 (커넥션·Statement·ResultSet close)
5. SQLException을 스프링의 DataAccessException으로 변환
List<User> users = jdbcTemplate.query(
"SELECT id, name FROM users",
(rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name"))); // ← 빈칸: 행→객체 매핑
개발자는 커넥션 관리, 자원 반납, 예외 변환과 같은 반복적이고 실수하기 쉬운 보일러플레이트를 전혀 작성하지 않고, "결과 행을 어떤 객체로 변환할 것인가"라는 본질적 로직(RowMapper)만 채운다. 자원 반납을 누락하여 커넥션이 누수되는 것과 같은 오류가 구조적으로 방지된다.
상속 대신 콜백을 쓰는 변형. 고전적 템플릿 메서드는 위 Beverage 예시처럼 하위 클래스가 추상 메서드를 오버라이드하는 상속 방식이다. 반면 JdbcTemplate은 빈칸을 하위 클래스가 아니라 RowMapper라는 콜백 객체(위 예시의 람다)로 받는다. 매번 하위 클래스를 만들 필요 없이 호출 시점에 빈칸을 주입할 수 있어 더 유연하며, 이 점에서 구성(composition) 기반인 전략 패턴과 맞닿는다. 스프링의 Template 계열은 이처럼 템플릿 메서드의 골격에 콜백을 결합한 형태로 구현되어 있다.
3.2. 전략(Strategy) — 알고리즘의 교체
전략 패턴은 동일한 목적을 가진 여러 알고리즘을 인터페이스로 캡슐화하여 런타임에 교체 가능하도록 하는 패턴이다. 스프링 전반에 광범위하게 적용되어 있으며, JPA·JDBC·JTA 구현을 교체할 수 있는 PlatformTransactionManager, ClassPath·File·URL 리소스를 추상화한 Resource, ViewResolver, BCrypt·Argon2 등을 교체할 수 있는 PasswordEncoder가 대표적이다.
@Bean
PasswordEncoder encoder() { return new BCryptPasswordEncoder(); } // 전략 교체만으로 동작 변경
조건 분기문(if/else) 대신 구현체 주입으로 행동을 결정한다는 점에서 DI와 자연스럽게 결합한다. 템플릿 메서드 패턴이 상속에 기반한다면, 전략 패턴은 구성(composition)에 기반한다는 점에서 대비된다.
3.3. 옵저버(Observer) — 이벤트 발행과 구독
개념. 옵저버 패턴은 한 객체(주제, subject)의 상태 변화를 그에 관심 있는 여러 객체(관찰자, observer)에게 자동으로 통지하는 패턴이다. 주제는 관찰자들의 목록을 관리하다가 상태가 바뀌면 목록의 모든 관찰자에게 알림을 보낸다. 핵심은 주제가 관찰자의 구체적인 정체를 알지 못한다는 점이다. 주제는 "관찰자 인터페이스"에만 의존할 뿐, 실제로 누가 몇 명이나 반응하는지는 모른다. 그 결과 관찰자를 추가하거나 제거해도 주제의 코드는 전혀 바뀌지 않는다.
비유. 유튜브 구독을 떠올리면 된다. 유튜버(주제)가 새 영상을 올리면 구독자(관찰자) 전원에게 알림이 간다. 유튜버는 구독자가 누구인지, 몇 명인지 알 필요가 없으며, 그저 "영상을 올렸다"는 사실만 발생시킨다. 구독자는 각자 알림을 받고 자기 방식대로 반응한다(누구는 바로 보고, 누구는 나중에 본다). 새 구독자가 늘어도 유튜버가 하는 일은 달라지지 않는다.
구체적 예시 — 왜 필요한가 (강결합 → 느슨한 결합). "주문 완료" 시 이메일 발송·포인트 적립·통계 집계가 일어나야 한다고 하자. 옵저버 패턴 없이 직접 호출로 구현하면 다음과 같다.
// [Before] 직접 호출 — OrderService가 모든 후속 처리를 직접 안다
public void placeOrder(Order order) {
orderRepository.save(order);
emailService.sendConfirmation(order); // 후속 1
pointService.addPoints(order); // 후속 2
statsService.record(order); // 후속 3
// 요구사항이 늘 때마다 이 메서드를 계속 수정해야 한다
}
이 구조는 세 가지 문제를 낳는다. 첫째, OrderService가 이메일·포인트·통계 서비스를 모두 의존하여 결합도가 높다. 둘째, "주문 시 쿠폰 발급"과 같은 새 반응을 추가할 때마다 placeOrder를 수정해야 하므로 개방-폐쇄 원칙(OCP)에 위배된다. 셋째, 후속 처리 하나가 실패하면 주문이라는 핵심 로직에 영향을 미칠 수 있다.
옵저버 패턴을 적용하면 OrderService는 "주문이 완료되었다"는 사건만 발행하고, 각 후속 처리는 독립적인 관찰자로 분리된다.
// [After] 이벤트 발행 — OrderService는 후속 처리가 무엇인지 모른다
public void placeOrder(Order order) {
orderRepository.save(order);
publisher.publishEvent(new OrderPlacedEvent(order)); // "주문 완료" 사건만 발행
}
이제 새로운 반응은 관찰자를 하나 추가하는 것으로 끝나며, OrderService는 손대지 않는다.
스프링에서의 적용. 스프링은 ApplicationEventPublisher(발행)와 @EventListener(구독)로 이 패턴을 프레임워크 차원에서 지원한다. 위 OrderPlacedEvent에 반응하는 관찰자들은 서로의 존재를 모른 채 각자 등록된다.
@Component
class EmailListener {
@EventListener
void on(OrderPlacedEvent e) { /* 확인 메일 발송 */ }
}
@Component
class PointListener {
@EventListener
void on(OrderPlacedEvent e) { /* 포인트 적립 */ }
}
// 쿠폰 발급이 필요해지면 CouponListener를 새로 추가하기만 하면 된다 (OrderService 무수정)
실무에서 자주 쓰이는 두 가지 확장이 있다. @TransactionalEventListener는 이벤트를 발행한 트랜잭션이 커밋된 이후에 리스너를 실행하도록 하여, 주문이 실제로 저장 확정된 뒤에만 메일이 나가도록 보장한다. @Async를 함께 붙이면 리스너가 별도 스레드에서 비동기로 실행되어, 후속 처리 지연이 주문 응답 시간에 영향을 주지 않는다.
이처럼 옵저버 패턴은 발행자와 구독자 간의 결합도를 제거한다. 이는 메시지 큐 기반의 발행/구독(pub/sub) 모델과 동일한 사상의 단일 프로세스 구현으로 볼 수 있으며, 반응들이 서로 완전히 독립적이어야 하거나 다른 프로세스로 분리되어야 하는 수준에 이르면 이벤트 발행 대상을 인메모리에서 카프카와 같은 외부 메시지 큐로 확장하게 된다.
3.4. 책임 연쇄(Chain of Responsibility) — 요청 파이프라인
개념. 책임 연쇄 패턴은 하나의 요청을 여러 개의 처리기(handler)가 사슬처럼 이어진 형태로 통과시키며, 각 처리기가 요청을 스스로 처리하거나 처리하지 않고 다음 처리기로 넘기도록(위임) 하는 패턴이다. 요청을 보내는 쪽은 "누가 이 요청을 처리할지"를 알 필요가 없으며, 단지 사슬의 첫 처리기에 요청을 던지기만 하면 된다. 각 처리기는 자기 앞의 처리기나 뒤의 처리기가 무엇을 하는지 알지 못한 채, 오직 "내 일을 하고 다음으로 넘긴다"는 규칙만 따른다.
비유. 회사의 결재 라인을 떠올리면 이해가 쉽다. 휴가 신청서가 팀장 → 부장 → 이사 순으로 올라갈 때, 팀장은 자신의 권한 안이면 직접 승인하고, 권한을 넘어서면 부장에게 넘긴다. 신청자는 누가 최종 결재하는지 알 필요 없이 팀장에게 제출하기만 하면 된다. 각 결재자는 "처리하거나, 다음으로 넘기거나" 둘 중 하나를 결정한다. 이것이 책임 연쇄다.
왜 "책임 연쇄"라 부르는가. 요청을 처리할 "책임"이 하나의 객체에 고정된 것이 아니라, 사슬(연쇄)을 따라 흐르며 처리 가능한 지점에서 소화되기 때문이다. 처리 책임이 여러 객체에 분산되어 있고, 요청이 그 사슬을 따라 이동한다는 점이 이름의 근거다.
스프링에서의 적용. 서블릿의 Filter 체인, 스프링 MVC의 HandlerInterceptor, Spring Security의 FilterChain이 모두 이 패턴이다. HTTP 요청이 컨트롤러에 도달하기 전, 여러 필터가 사슬을 이루어 차례로 요청을 검사한다.
요청 → [인증 필터] → [로깅 필터] → [CORS 필터] → ... → 컨트롤러
각 필터가 통과 / 차단 / 다음 위임을 결정
핵심은 각 필터가 다음 필터를 직접 호출하는 구조라는 점이다. 서블릿 필터의 doFilter 메서드 시그니처가 이를 그대로 드러낸다.
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
// (1) 전처리 — 예: 인증 토큰 검증
if (invalid(req)) { res.setStatus(401); return; } // 여기서 사슬을 끊으면 다음으로 안 감
chain.doFilter(req, res); // (2) 다음 처리기로 위임
// (3) 후처리 — 예: 응답 로깅
}
chain.doFilter()를 호출하면 다음 필터로 요청이 넘어가고, 호출하지 않으면 그 지점에서 사슬이 끊겨 요청이 컨트롤러에 도달하지 못한다. 인증 실패 시 401을 반환하고 doFilter를 호출하지 않는 것이 대표적인 "차단"의 예다.
이 패턴의 가치. 인증·로깅·CORS·압축과 같은 횡단 처리를 서로 독립적인 단계로 분리할 수 있다는 데 있다. 각 필터는 자신의 관심사 하나만 담당하며, 다른 필터의 존재를 몰라도 된다. 그 결과 필터를 추가·제거·재배치하는 것만으로 처리 파이프라인을 유연하게 재구성할 수 있고, 각 단계를 독립적으로 테스트할 수 있다. 이 구조는 제4장의 요청 처리 흐름에서 중추적 역할을 담당한다.
3.5. 기타 행위 패턴
이터레이터(Iterator) 패턴은 컬렉션 순회를 추상화하는 패턴으로 자바 언어 차원에서 기본 제공되며, 스프링의 여러 API가 Iterable을 반환하는 형태로 활용한다. 커맨드(Command) 패턴은 Runnable·Callable 태스크 및 @Async 작업의 객체화에서 관찰된다.
4. 아키텍처 패턴
4.1. 프론트 컨트롤러(Front Controller) — 단일 관문
개념. 프론트 컨트롤러 패턴은 애플리케이션으로 들어오는 모든 요청을 하나의 진입점(controller)이 먼저 받아, 공통 처리를 수행한 뒤 실제 담당자에게 넘기도록 하는 아키텍처 패턴이다. "프론트(front)"는 모든 요청 앞단에 서 있는 단일 관문이라는 의미이며, 개별 처리 담당자들 앞에 공통 접수 창구를 하나 두는 구조다.
비유. 대형 병원의 접수 데스크를 떠올리면 된다. 환자가 곧바로 진료과로 가는 것이 아니라, 먼저 단일 접수 창구를 거친다. 이 창구는 접수·보험 확인·순번 배정 같은 공통 절차를 처리한 뒤 환자를 적절한 진료과로 안내한다. 만약 이런 창구가 없다면 모든 진료과가 각자 접수와 보험 확인을 중복해서 처리해야 한다. 프론트 컨트롤러는 바로 이 단일 접수 창구에 해당한다.
프론트 컨트롤러가 없다면. 초기의 서블릿 기반 웹 애플리케이션에서는 URL마다 별도의 서블릿을 두는 방식이 흔했다. 이 경우 인증·로깅·인코딩 설정·예외 처리와 같은 공통 로직이 모든 서블릿에 중복 작성되고, 새로운 공통 정책을 도입할 때 모든 서블릿을 일일이 수정해야 하는 문제가 발생한다.
스프링에서의 적용. 스프링 MVC의 DispatcherServlet이 프론트 컨트롤러를 구현한다. 모든 HTTP 요청은 예외 없이 DispatcherServlet에 먼저 도달하며, DispatcherServlet은 다음의 공통 절차를 수행한다.
1. HandlerMapping을 통해 요청 URL에 대응하는 컨트롤러를 탐색
2. HandlerAdapter를 통해 해당 컨트롤러를 호출 (§2.3 어댑터)
3. 컨트롤러가 반환한 결과를 ViewResolver로 해석 (§3.2 전략)
4. 예외 발생 시 HandlerExceptionResolver로 일괄 처리
이 패턴의 가치. 라우팅과 공통 처리의 책임을 DispatcherServlet 한 곳으로 집중시킴으로써, 개별 컨트롤러는 순수한 비즈니스 로직에만 집중할 수 있게 된다. 공통 정책의 변경이 단일 지점에서 이루어지므로 일관성과 유지보수성이 향상된다. 또한 앞서 살펴본 어댑터, 책임 연쇄, 전략 등 여러 패턴을 하나의 요청 흐름 위에서 조율하는 지휘자 역할을 담당한다.
하나의 HTTP 요청이 처리되는 과정은 앞서 분석한 패턴들이 유기적으로 결합된 여정으로 요약할 수 있다.
HTTP 요청
│
▼ [DispatcherServlet] ← Front Controller (단일 관문)
│
▼ Filter / Interceptor 체인 ← Chain of Responsibility
│
▼ HandlerMapping으로 매핑
│
▼ [HandlerAdapter]로 호출 ← Adapter (컨트롤러 유형 흡수)
│
▼ Controller (싱글톤 빈) ← Singleton
│ └ @Transactional 프록시 ← Proxy (+ Template Method: JdbcTemplate)
│
▼ ViewResolver로 뷰 선택 ← Strategy
│
▼ 응답
4.2. MVC — 관심사의 분리
MVC 패턴은 Model, View, Controller로 데이터·표현·제어를 분리하는 아키텍처 패턴이며, 스프링 MVC라는 명칭 자체가 이 패턴의 채택을 나타낸다.
4.3. IoC/DI — 스프링의 구조적 토대
제어의 역전(IoC)은 객체가 자신의 의존성을 직접 생성하지 않고 컨테이너가 이를 주입하도록 하는 원칙이며, 의존성 주입(DI)은 그 구현 기법이다. GoF의 23개 패턴에는 포함되지 않으나, 본 보고서에서 다룬 모든 패턴이 이 토대 위에서 조립된다는 점에서 최상위 개념으로 다룰 필요가 있다.
@Service
public class OrderService {
private final PaymentGateway pg; // 인터페이스에만 의존
public OrderService(PaymentGateway pg) { this.pg = pg; } // 컨테이너가 구현체 주입
}
IoC/DI는 결합도를 낮춤으로써 전략 구현체의 교체, 프록시의 삽입, 테스트 시 목(mock) 객체의 주입을 모두 가능하게 하며, 이러한 의미에서 나머지 패턴들의 전제 조건에 해당한다.
6. 패턴별 적용 위치 종합
| 패턴 | 분류 | 스프링 내 적용 위치 | 해결하는 문제 |
|---|---|---|---|
| Singleton | 생성 | 빈 기본 스코프 | 상태 없는 협력 객체의 공유 |
| Factory | 생성 | BeanFactory, FactoryBean | 생성 로직 캡슐화 (DI의 토대) |
| Builder | 생성 | WebClient.builder() 등 | 복잡한 객체의 단계적 생성 |
| Proxy | 구조 | AOP, @Transactional, @Cacheable | 횡단 관심사 주입 |
| Decorator | 구조 | TransactionAwareDataSourceProxy, Wrapper류 | 기능의 동적 확장 |
| Adapter | 구조 | HandlerAdapter | 이질적 인터페이스의 통합 |
| Template Method | 행위 | JdbcTemplate, RestTemplate | 보일러플레이트 제거 |
| Strategy | 행위 | PlatformTransactionManager, PasswordEncoder | 알고리즘 교체 |
| Observer | 행위 | ApplicationEvent, @EventListener | 느슨한 이벤트 통신 |
| Chain of Responsibility | 행위 | Filter, Interceptor, Security FilterChain | 요청 파이프라인 구성 |
| Front Controller | 아키텍처 | DispatcherServlet | 단일 관문 라우팅 |
| IoC/DI | 아키텍처 | ApplicationContext | 결합도 제거 (전제 조건) |
7. 주요 쟁점에 대한 분석
스프링 싱글톤 빈의 스레드 안전성. 싱글톤 빈은 인스턴스가 하나이지만, 상태를 갖지 않는 한 스레드 안전하다. 가변 상태를 두는 경우 다수 스레드의 공유로 인해 경쟁 조건(race condition)이 발생할 수 있다. 따라서 서비스 계층은 무상태(stateless)로 설계하는 것이 원칙이며, 상태가 필요한 경우 지역 변수, 파라미터, 또는 프로토타입 스코프를 활용해야 한다.
프록시 이외의 AOP 구현 방식. 런타임 프록시 방식 외에 컴파일 타임 또는 로드 타임에 바이트코드를 조작하는 AspectJ 위빙(weaving) 방식이 존재한다. 프록시 방식은 자가 호출의 한계를 갖지만 경량이며, AspectJ 방식은 강력하지만 빌드 과정이 복잡하다. 스프링의 기본 방식은 프록시이다.
조건 분기 대신 전략과 DI를 선호하는 이유. 새로운 알고리즘을 추가할 때 기존 코드를 수정하지 않고 구현체만 추가하면 된다는 점에서 개방-폐쇄 원칙(OCP)에 부합한다. 조건 분기문 방식은 알고리즘을 추가할 때마다 원본 코드의 수정을 요구한다.
이벤트(옵서버) 남용 시의 문제. 처리 흐름이 코드상에 명시적으로 드러나지 않아 어떤 구독자가 언제 반응하는지 추적이 곤란해진다. 동기 이벤트의 경우 발행자가 구독자의 지연에 종속되며, 트랜잭션 경계와 처리 순서의 보장 또한 까다롭다. 이러한 한계가 프로세스 외부의 메시지 큐로 분리하는 근거가 된다.
프론트 컨트롤러 부재 시의 문제. 서블릿을 직접 사용하는 경우 인증, 로깅, 예외 처리와 같은 공통 처리가 서블릿마다 중복된다. DispatcherServlet은 단일 관문으로서 이러한 공통 처리를 한 곳에서 일괄 적용할 수 있게 한다.
DI의 패턴 여부에 대한 논의. IoC는 넓은 의미의 설계 원칙이고 DI는 그 구현 기법으로서, GoF의 23개 패턴에는 포함되지 않는다. 그러나 스프링에서는 나머지 모든 패턴이 조립되는 토대라는 점에서 아키텍처 패턴의 최상위 개념으로 다루는 것이 타당하다.
8. 결론
스프링 프레임워크는 IoC/DI라는 구조적 토대 위에 GoF 디자인 패턴을 체계적으로 조립한 결과물이다. 객체 생성은 싱글톤과 팩토리가, 횡단 관심사는 프록시가, 보일러플레이트 제거는 템플릿 메서드가, 알고리즘 교체는 전략이, 요청 처리는 프론트 컨트롤러와 책임 연쇄, 어댑터가 각각 담당하며, 이들은 하나의 HTTP 요청이 처리되는 여정 속에서 유기적으로 결합된다.
따라서 스프링에 적용된 디자인 패턴을 설명할 때는 개별 패턴을 무작위로 나열하기보다, 하나의 요청이 DispatcherServlet에서 시작하여 응답으로 끝나는 처리 흐름을 따라 각 패턴의 역할을 짚어내는 방식이 프레임워크의 구조에 대한 이해를 가장 효과적으로 드러낸다. 아울러 싱글톤 빈의 무상태 설계 원칙, 프록시 기반 AOP의 자가 호출 한계, 프록시와 데코레이터의 의도 차이, 상속 기반의 템플릿 메서드와 구성 기반의 전략 패턴의 대비는 패턴 이해의 깊이를 판별하는 핵심 논점으로서 함께 숙지할 필요가 있다.