자바 디자인 패턴 학습 노트 목차

자바 디자인 패턴 — 실무 적용 심화

앞 편이 패턴이 무엇인가였다면, 이 편은 실제 프레임워크 소스에서 그 패턴이 어떤 모양으로 나타나는가다. 인용한 코드는 전부 해당 프로젝트의 실제 소스이고, 각 절 끝에 "그래서 실무에서 무엇이 터지는가"를 붙였다. 패턴 이름을 외우는 것보다 낯선 코드에서 패턴을 알아보는 것이 훨씬 쓸모 있다.


📌 1. Chain of Responsibility — 네 프레임워크가 사실상 같은 코드를 쓴다

서블릿 필터, 스프링 시큐리티, 스프링 AOP, OkHttp, Netty. 겉보기엔 전혀 다른 물건인데 핵심 코드가 똑같다. 한 번 알아보면 처음 보는 프레임워크에서도 즉시 읽힌다.

1.1 공통 뼈대

리스트 + 현재 위치 인덱스 + "다음을 부르는" 메서드

  proceed() {
      if (인덱스가 끝) → 진짜 대상을 호출
      다음 요소를 꺼내고 인덱스++
      그 요소에게 나(this)를 넘기며 실행
  }

각 요소는 자기 일을 하고 proceed()를 부른다. 부르지 않으면 사슬이 거기서 끊긴다 — 이것이 이 패턴의 힘이자 사고의 원인이다.

1.2 Spring Security — VirtualFilterChain

org.springframework.security.web.FilterChainProxy 안의 내부 클래스다.

private static final class VirtualFilterChain implements FilterChain {

    private final FilterChain originalChain;
    private final List<Filter> additionalFilters;
    private final int size;
    private int currentPosition = 0;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response) {
        if (this.currentPosition == this.size) {
            this.originalChain.doFilter(request, response);   // 사슬 끝 → 원래 서블릿으로
            return;
        }
        this.currentPosition++;
        Filter nextFilter = this.additionalFilters.get(this.currentPosition - 1);
        nextFilter.doFilter(request, response, this);         // 자기 자신을 넘긴다
    }
}

this를 넘기는가 — 필터는 "다음이 누구인지" 몰라도 된다. 체인 객체만 들고 있으면 된다. 이 한 줄이 필터끼리의 결합을 0으로 만든다.

스프링 시큐리티 기본 필터 순서 (FilterOrderRegistration 등록 순서에서 발췌)

DisableEncodeUrlFilter
ForceEagerSessionCreationFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter          ← 인증 정보 복원
HeaderWriterFilter
CorsFilter
CsrfFilter                           ← CSRF 검사
LogoutFilter
UsernamePasswordAuthenticationFilter ← 폼 로그인 인증
BasicAuthenticationFilter
RequestCacheAwareFilter
AnonymousAuthenticationFilter        ← 익명 사용자 채우기
SessionManagementFilter
ExceptionTranslationFilter           ← 아래에서 터진 인증/인가 예외를 응답으로 변환
AuthorizationFilter                  ← 최종 인가 판정

순서가 곧 의미다. ExceptionTranslationFilterAuthorizationFilter보다 에 있는 이유 — 뒤에서 던져진 AccessDeniedException을 잡아 401/403 응답이나 로그인 페이지 리다이렉트로 바꿔야 하기 때문이다. 뒤에 있었다면 예외가 그대로 톰캣까지 올라가 500이 된다.

1.3 Spring AOP — ReflectiveMethodInvocation.proceed()

@Transactional·@Cacheable·@Async가 전부 이 한 메서드 위에서 돈다.

@Override
public @Nullable Object proceed() throws Throwable {
    // We start with an index of -1 and increment early.
    if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1) {
        return invokeJoinpoint();          // 어드바이스가 다 끝났다 → 실제 메서드 호출
    }

    Object interceptorOrInterceptionAdvice =
            this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex);
    ...
    return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
}

VirtualFilterChain구조가 동일하다 — 인덱스를 올리고, 다음 요소에게 this를 넘긴다. 어드바이스는 이렇게 생겼다.

public Object invoke(MethodInvocation invocation) throws Throwable {
    // 앞 작업 — 트랜잭션 시작
    try {
        Object result = invocation.proceed();   // 다음 어드바이스 또는 실제 메서드
        // 뒤 작업 — 커밋
        return result;
    } catch (Throwable ex) {
        // 롤백
        throw ex;
    }
}

proceed()감싸는 위치에 코드를 두면 전/후 처리가 된다. 이것이 AOP의 @Around가 동작하는 전부다.

1.4 OkHttp — 인터셉터 사슬의 실제 조립 순서

RealCall.getResponseWithInterceptorChain() 실제 소스다.

internal fun getResponseWithInterceptorChain(): Response {
  // Build a full stack of interceptors.
  val interceptors = mutableListOf<Interceptor>()
  interceptors += client.interceptors            // ① 애플리케이션 인터셉터
  interceptors += RetryAndFollowUpInterceptor()  // ② 재시도·리다이렉트
  interceptors += BridgeInterceptor()            // ③ 헤더 보정·gzip 해제
  interceptors += CacheInterceptor()             // ④ 캐시 판정
  interceptors += ConnectInterceptor             // ⑤ 연결 확보
  if (!forWebSocket) {
    interceptors += client.networkInterceptors   // ⑥ 네트워크 인터셉터
  }
  interceptors += CallServerInterceptor          // ⑦ 실제 송수신

  val chain = RealInterceptorChain(
      call = this, interceptors = interceptors, index = 0, ...)

애플리케이션 인터셉터(①)와 네트워크 인터셉터(⑥)의 차이가 여기서 그대로 읽힌다.

애플리케이션 인터셉터네트워크 인터셉터
위치캐시·재시도보다 바깥캐시·재시도보다 안쪽
호출 횟수리다이렉트·재시도와 무관하게 1번실제 요청마다 — 리다이렉트 3번이면 3번
캐시 히트 시호출된다호출되지 않는다 (④에서 끊긴다)
보이는 것최초 요청 / 최종 응답실제 나가는 요청, 압축된 원본 바디

로그 인터셉터를 어디에 등록하느냐로 "로그가 안 찍힌다"가 갈린다. 캐시에서 응답이 나오면 CacheInterceptorproceed()를 부르지 않고 바로 돌려주므로, 그 뒤에 있는 네트워크 인터셉터는 존재조차 실행되지 않는다.

1.5 Netty — 방향이 둘인 사슬

ChannelPipeline javadoc의 예시다.

ChannelPipeline p = ...;
p.addLast("1", new InboundHandlerA());
p.addLast("2", new InboundHandlerB());
p.addLast("3", new OutboundHandlerA());
p.addLast("4", new OutboundHandlerB());
p.addLast("5", new InboundOutboundHandlerX());
인바운드(수신) 1 → 2 → 5      (3·4는 아웃바운드라 건너뛴다)
아웃바운드(송신) 5 → 4 → 3    (1·2를 건너뛰고 역방향)

javadoc은 이를 "ChannelPipeline skips the evaluation of certain handlers to shorten the stack depth"라고 설명한다. 하나의 리스트인데 방향에 따라 다른 부분집합이 실행된다CoR의 변형 중 가장 헷갈리는 형태다.

다이어그램 로딩 중…

1.6 실무 함정

proceed()/doFilter()를 안 불러 요청이 증발한다

public void doFilter(req, res, chain) {
    if (!valid(req)) {
        res.setStatus(400);
        return;              // ← chain.doFilter 를 안 부른다. 의도했다면 정상
    }
    chain.doFilter(req, res);
}

의도한 차단이면 맞다. 문제는 예외 경로에서 실수로 빠지는 경우 — 응답이 비어 있고 로그도 없다. finally에 아무것도 없으니 흔적이 남지 않는다.

② 응답을 커밋한 뒤에 다음 필터가 헤더를 건드린다

앞쪽 필터가 response.getWriter().flush()를 하면 헤더가 이미 나갔다. 뒤에서 setHeader를 해도 반영되지 않고, 조용히 무시되거나 IllegalStateException이 난다.

③ 예외를 어디서 잡을지

사슬은 호출 스택이므로 예외는 바깥쪽으로 올라간다. 공통 예외 처리는 가장 바깥 요소에 둔다 — 스프링 시큐리티가 ExceptionTranslationFilter를 인가 필터보다 앞에 둔 이유와 같다.


📌 2. Proxy — 애너테이션 하나가 동작을 바꾸는 구조

2.1 두 가지 프록시

JDK 동적 프록시     java.lang.reflect.Proxy.newProxyInstance(loader, interfaces, handler)
                    인터페이스만 프록시할 수 있다
                    생성되는 클래스 이름: $Proxy0, $Proxy1 …

CGLIB / ByteBuddy   대상 클래스를 상속한 서브클래스를 런타임에 만든다
                    인터페이스가 없어도 된다
                    Spring 6 기준 이름: Foo$$SpringCGLIB$$0
                    (예전 버전: Foo$$EnhancerBySpringCGLIB$$1a2b3c)

Spring Boot 2.0부터 기본값이 CGLIB (spring.aop.proxy-target-class=true). 그래서 인터페이스를 안 만들어도 @Transactional이 걸린다.

2.2 최소 구현 — 직접 만들어 보면 구조가 보인다

interface OrderService { void place(String id); }

class RealOrderService implements OrderService {
    public void place(String id) { System.out.println("주문 저장 " + id); }
}

// JDK 동적 프록시로 "실행 시간 측정"을 끼워 넣는다
OrderService proxy = (OrderService) Proxy.newProxyInstance(
    OrderService.class.getClassLoader(),
    new Class<?>[]{ OrderService.class },
    (p, method, args) -> {
        long t0 = System.nanoTime();
        try {
            return method.invoke(new RealOrderService(), args);   // 진짜 대상 호출
        } finally {
            System.out.printf("%s %.2fms%n", method.getName(), (System.nanoTime()-t0)/1e6);
        }
    });

proxy.place("A-1");

@Transactional이 하는 일도 정확히 이 모양이다 — method.invoke 앞뒤에 트랜잭션 시작·커밋이 들어갈 뿐이다.

2.3 self-invocation — "애너테이션 붙였는데 왜 안 걸리죠"

가장 자주 나오는 스프링 질문이고, 원인은 프록시 구조 그 자체다.

@Service
public class OrderService {

    public void placeAll(List<Order> orders) {
        for (Order o : orders) {
            save(o);          // ← this.save(o) — 프록시를 거치지 않는다
        }
    }

    @Transactional
    public void save(Order o) { ... }   // 트랜잭션이 걸리지 않는다
}
호출자 → [프록시] → OrderService.placeAll()
                        └→ this.save()     ← 여기서 this 는 '진짜 객체'다
                                              프록시는 이미 통과했으므로 다시 안 거친다

해결 순서 — 위에서부터 시도한다.

① 대상 메서드를 다른 빈으로 분리한다 (설계상 가장 깨끗하다)
② ObjectProvider / ApplicationContext 로 자기 프록시를 받아 호출한다
③ AopContext.currentProxy() 로 캐스팅 (exposeProxy=true 필요, 권장하지 않는다)

같은 이유로 private·final 메서드, final 클래스에는 프록시가 안 걸린다. CGLIB이 상속으로 만드는데 오버라이드할 수 없기 때문이다. 코틀린 클래스가 기본 final이라 allopen 플러그인이 필요한 것도 같은 사정이다.

2.4 Hibernate 지연 로딩 프록시

Order order = em.getReference(Order.class, 1L);   // 프록시만 받는다. SELECT 안 나감
order.getId();       // 식별자는 프록시가 이미 안다 → 여전히 SELECT 없음
order.getAmount();   // ← 여기서 처음 SELECT 가 나간다 (초기화)

프록시 클래스는 ByteBuddy로 만들어지고 이름이 Order$HibernateProxy$aB3xY처럼 찍힌다. 디버거에서 이 이름이 보이면 "아직 로딩 안 됨"이라는 신호다.

LazyInitializationException: could not initialize proxy - no Session

원인  세션(트랜잭션)이 닫힌 뒤에 프록시를 건드렸다
      전형적으로 컨트롤러/뷰에서 엔티티를 직렬화할 때 터진다

해법  · 필요한 것을 트랜잭션 안에서 미리 가져온다 (fetch join · EntityGraph)
      · 엔티티를 그대로 응답하지 않고 DTO 로 변환한다 (근본 대응)
      · OSIV(open-in-view) 로 세션을 늘리는 것은 임시방편이다
        커넥션을 뷰 렌더링까지 붙들어 커넥션 풀을 마르게 한다

2.5 Proxy와 Decorator는 무엇이 다른가

구조가 거의 같아 실무에서 가장 많이 혼동된다.

Proxy      대상을 '대신' 한다. 접근을 통제하거나 생성을 미룬다
           대상이 하나로 고정돼 있고, 보통 스스로 만들거나 알고 있다
           기능을 추가하는 것이 목적이 아니다

Decorator  대상에 기능을 '더한다'
           대상을 밖에서 주입받는다 → 여러 겹으로 쌓을 수 있다
           쌓는 순서가 결과를 바꾼다

판별 질문: "겹겹이 쌓는 것이 자연스러운가?" → 예면 Decorator

📌 3. Decorator — 순서가 의미를 바꾼다

3.1 java.io — 교과서 예시가 실제로 불편한 이유

// 3겹으로 쌓는다: 파일 → 문자 변환 → 버퍼링
BufferedReader in = new BufferedReader(
        new InputStreamReader(
            new FileInputStream("a.txt"), StandardCharsets.UTF_8));

유연하지만 초심자에게는 그냥 불편하다. JDK도 이를 인정해 단축 API를 제공한다.

BufferedReader in = Files.newBufferedReader(Path.of("a.txt"));  // 위 3줄과 동등

교훈 — 가장 흔한 조합은 Facade로 감싸 준다. 패턴을 쓰되 사용자에게 조립을 강요하지 않는다.

3.2 Resilience4j — 데코레이터를 API로 노출한 사례

Supplier<Order> decorated = Decorators.ofSupplier(() -> client.fetch(id))
        .withBulkhead(Bulkhead.ofDefaults("order"))
        .withCircuitBreaker(CircuitBreaker.ofDefaults("order"))
        .withRetry(Retry.ofDefaults("order"))
        .decorate();

Order order = decorated.get();

스프링 부트 애너테이션으로 쓸 때의 문서화된 기본 중첩 순서는 다음과 같다.

Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( 실제 호출 ) ) ) ) )

Retry 가 가장 바깥, Bulkhead 가 가장 안쪽

3.3 이 순서가 왜 중요한가 — 장애를 가르는 지점

Retry(CircuitBreaker(호출))   ← 기본값
  서킷이 열려 있으면 즉시 실패 → 재시도도 즉시 실패
  재시도가 죽어 가는 서버를 다시 때리지 않는다  ✅

CircuitBreaker(Retry(호출))
  재시도 3번을 '한 번의 호출' 로 묶어서 서킷이 본다
  → 서킷 입장에서 실패 1건, 실제 트래픽은 3배
  → 장애 서버에 부하를 3배로 실어 나른다  ❌
  → 서킷이 열리기까지도 3배 오래 걸린다

순서는 resilience4j.retry.retryAspectOrder 같은 속성으로 바꿀 수 있다(값이 클수록 우선). 바꿀 수 있다는 것은 잘못 바꿀 수도 있다는 뜻이다 — 기본값을 건드릴 이유가 없으면 두는 편이 낫다.

3.4 데코레이터를 쌓을 때의 실무 규칙

바깥쪽에 둘 것    재시도 · 타임아웃 · 서킷 브레이커 (호출 전체를 감싸야 의미가 있다)
안쪽에 둘 것      동시 실행 제한 · 커넥션 확보 (실제 자원에 가까울수록 정확하다)
가장 바깥         로깅 · 메트릭 (모든 시도와 최종 결과를 봐야 한다)

메트릭을 안쪽에 두면 재시도 3번이 3건으로 집계된다
바깥에 두면 1건으로 집계된다 — 무엇을 세고 싶은지에 따라 고른다

📌 4. Strategy — 람다 시대에 이 패턴이 남은 이유

4.1 인터페이스가 이미 전략인 것들

프레임워크전략 인터페이스주입 방식
JDKComparator<T>인자로 전달
JDKCollector<T,A,R>collect() 인자
Spring SecurityPasswordEncoder빈 등록
SpringTaskExecutor빈 등록
KafkaPartitioner · Serializerpartitioner.class 설정 문자열
JacksonJsonSerializer<T>모듈 등록
CaffeineCacheLoader<K,V>빌더 인자

설정 문자열로 클래스를 지정하는 것(Kafka)이 전략 패턴의 가장 실용적인 형태다 — 재컴파일 없이 알고리즘을 바꾼다.

4.2 if-else 사슬 → 전략 맵

실무 리팩터링에서 가장 자주 나오는 변환이다.

// 이전 — 결제 수단이 늘 때마다 이 메서드를 연다 (OCP 위반)
public Receipt pay(PayRequest req) {
    if (req.method() == CARD)        { ... 100줄 ... }
    else if (req.method() == KAKAO)  { ... 80줄 ... }
    else if (req.method() == BANK)   { ... 120줄 ... }
    else throw new IllegalArgumentException();
}
// 이후 — 스프링이 구현체를 전부 주입해 준다
public interface PaymentStrategy {
    PayMethod supports();
    Receipt pay(PayRequest req);
}

@Service
public class PaymentService {
    private final Map<PayMethod, PaymentStrategy> strategies;

    public PaymentService(List<PaymentStrategy> list) {          // 구현체 전부 주입
        this.strategies = list.stream()
            .collect(toMap(PaymentStrategy::supports, identity()));
    }

    public Receipt pay(PayRequest req) {
        var s = strategies.get(req.method());
        if (s == null) throw new UnsupportedPaymentException(req.method());
        return s.pay(req);
    }
}
새 결제 수단 추가 = 새 클래스 파일 하나
기존 코드는 한 줄도 열지 않는다 → 회귀 위험이 0 에 가깝다

스프링에서 List<인터페이스> 주입은 이 패턴의 전용 도구다. 구현체를 스프링이 전부 찾아 넣어 주므로 등록 코드를 따로 쓰지 않는다.

4.3 enum 전략 — 가장 자주 쓰는 형태

전략이 몇 개로 고정돼 있고 상태가 없다면 클래스를 나눌 필요조차 없다.

public enum DiscountPolicy {
    NONE     { public int apply(int p) { return p; } },
    RATE_10  { public int apply(int p) { return p * 90 / 100; } },
    FIXED_1K { public int apply(int p) { return Math.max(0, p - 1000); } };

    public abstract int apply(int price);
}

int finalPrice = policy.apply(price);
장점  분기가 사라지고, 새 정책 추가 시 컴파일러가 apply 구현을 강제한다
      switch 로 쓰면 빠뜨려도 컴파일이 통과하지만 이 형태는 불가능하다
한계  정책이 협력 객체를 필요로 하면(리포지토리 조회 등) 빈으로 빼야 한다

4.4 실무 함정 — 전략은 상태를 가지면 안 된다

@Component
public class CardPaymentStrategy implements PaymentStrategy {
    private PayRequest current;        // ❌ 싱글톤 빈에 요청 상태를 담았다

    public Receipt pay(PayRequest req) {
        this.current = req;            // 동시 요청이 서로 덮어쓴다
        ...
    }
}

스프링 빈은 **기본이 싱글톤**이다. 필드에 요청별 데이터를 담는 순간 동시성 버그가 된다 — 부하가 낮을 때는 절대 재현되지 않고, 트래픽이 오르면 "가끔 남의 주문이 섞인다"로 나타난다. 전략 객체는 무상태로 만들고 필요한 것은 전부 인자로 받는다.


📌 5. Template Method — JdbcTemplate 실제 소스로 읽기

5.1 상속형이 아니라 콜백형이다

org.springframework.jdbc.core.JdbcTemplate의 실제 코드다.

private <T> T execute(StatementCallback<T> action, boolean closeResources) {
    Connection con = DataSourceUtils.getConnection(obtainDataSource());
    Statement stmt = null;
    try {
        stmt = con.createStatement();
        applyStatementSettings(stmt);
        T result = action.doInStatement(stmt);      // ← 가변부. 우리가 넘긴 콜백
        handleWarnings(stmt);
        return result;
    }
    catch (SQLException ex) {
        String sql = getSql(action);
        JdbcUtils.closeStatement(stmt);
        stmt = null;
        DataSourceUtils.releaseConnection(con, getDataSource());
        con = null;
        throw translateException("StatementCallback", sql, ex);   // 예외 변환
    }
    finally {
        if (closeResources) {
            JdbcUtils.closeStatement(stmt);
            DataSourceUtils.releaseConnection(con, getDataSource());
        }
    }
}
프레임워크가 갖는 불변부
  · 커넥션 획득 / 반납
  · Statement 생성 / 닫기
  · SQLException → DataAccessException 변환 (벤더별 에러코드 해석)
  · 예외 시 커넥션 조기 반납 (풀 데드락 회피 — 주석에 명시돼 있다)

사용자가 채우는 가변부
  · doInStatement(stmt) 한 줄

이 자원 관리 코드를 매 쿼리마다 손으로 쓰면 어딘가에서 반드시 빠뜨린다. 그것이 이 패턴이 존재하는 이유다.

5.2 상속형 vs 콜백형

상속형 Template Method (GoF 원형)
   abstract class Report {
       final void print() { header(); body(); footer(); }   // 골격 고정
       abstract void body();                                // 하위가 채운다
   }
   장점  구조가 명시적
   단점  구현마다 클래스가 필요하고, 상속은 한 번뿐이다
         런타임에 못 바꾼다

콜백형 (스프링의 선택)
   template.execute(stmt -> stmt.executeQuery(sql));
   장점  람다 한 줄. 상속 안 씀. 런타임 교체 자유
   단점  골격이 코드에 덜 드러난다

콜백형은 사실상 Template Method의 뼈대에 Strategy를 주입한 형태다. 자바에 람다가 들어온 뒤로 실무는 거의 이쪽으로 왔다.

5.3 같은 구조를 쓰는 것들

JdbcTemplate       Connection · Statement · 예외 변환
TransactionTemplate  begin · commit · rollback
RestClient / RestTemplate  연결 · 직렬화 · 상태코드 처리
@BeforeEach / @AfterEach   테스트 준비·정리 골격

📌 6. 생성 패턴 — 실무에서 실제로 쓰는 형태

6.1 정적 팩토리 메서드는 GoF Factory Method가 아니다

List.of(1, 2, 3)              // 정적 팩토리 (Effective Java Item 1)
Optional.ofNullable(x)
Duration.ofSeconds(3)
GoF Factory Method   하위 클래스가 '무엇을 만들지' 결정한다 (상속이 핵심)
정적 팩토리          이름 있는 생성자. 캐싱·하위타입 반환이 가능하다 (상속과 무관)

실무 대화에서는 뭉뚱그려 "팩토리" 라고 부르지만, 면접에서는 구분해서 답한다

정적 팩토리가 주는 실제 이득:

Integer.valueOf(100);   // 캐시된 인스턴스를 준다 — new 로는 불가능
List.of();              // 원소 수에 따라 다른 구현체를 반환 (ImmutableCollections)

6.2 ServiceLoader — JDK가 배선하는 방식

설정 파일로 구현체를 갈아 끼우는 표준 메커니즘이다. JDBC 드라이버·SLF4J 바인딩이 이 위에서 동작한다.

public interface PaymentGateway { Receipt charge(long amount); }

// META-INF/services/com.example.PaymentGateway  파일에 구현 클래스명을 적는다
//   com.example.tosspay.TossGateway

ServiceLoader<PaymentGateway> loaded = ServiceLoader.load(PaymentGateway.class);
PaymentGateway gw = loaded.findFirst().orElseThrow();
jar 을 클래스패스에 넣고 빼는 것만으로 구현이 바뀐다
애플리케이션 코드는 구현 클래스 이름을 모른다 → 진짜 의미의 플러그인

6.3 Builder — 언제 쓰고 언제 안 쓰나

HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/orders"))
        .header("Content-Type", "application/json")
        .timeout(Duration.ofSeconds(3))
        .POST(BodyPublishers.ofString(json))
        .build();
쓴다      선택 인자가 4개 이상 / 같은 타입 인자가 연속 (순서 실수 방지)
          불변 객체를 단계적으로 조립해야 할 때
안 쓴다   필드 2~3개의 단순 값 객체 → record 가 낫다
          전부 필수인 경우 → 생성자가 필수값을 컴파일 타임에 강제한다

Lombok @Builder의 실무 함정

@Builder
public class OrderRequest {
    private String id;
    private int quantity = 1;        // ❌ @Builder.Default 가 없으면 0 이 된다
}
① 필드 초기값은 @Builder.Default 없이는 무시된다 (조용히 0/null)
② 필수값 검증이 없다 — build() 는 아무것도 안 채워도 성공한다
   → @Builder 를 생성자에 붙이고 생성자에서 검증하거나,
     record 의 컴팩트 생성자로 검증한다
③ 테스트 데이터 빌더로는 매우 유용하다
   OrderRequest.builder().id("A").build()  ← 관심 있는 필드만 지정

📌 7. Observer — 스프링 이벤트와 트랜잭션의 관계

7.1 기본은 동기다

@Service
class OrderService {
    private final ApplicationEventPublisher publisher;

    @Transactional
    public void place(Order order) {
        repository.save(order);
        publisher.publishEvent(new OrderPlacedEvent(order.getId()));
        // ↑ 여기서 리스너가 '지금, 같은 스레드에서, 같은 트랜잭션 안에서' 실행된다
    }
}

@Component
class MailListener {
    @EventListener
    public void on(OrderPlacedEvent e) {
        mailClient.send(...);       // 3초 걸리면 트랜잭션이 3초 길어진다
    }
}
흔한 오해  "이벤트를 쐈으니 비동기다"
실제       @EventListener 는 동기 · 같은 스레드 · 같은 트랜잭션

결과       · 리스너가 느리면 커넥션을 그만큼 오래 잡는다
           · 리스너가 예외를 던지면 주문 저장까지 롤백된다
           · 메일 발송 실패로 주문이 취소되는 사고가 여기서 나온다

7.2 커밋 이후로 미룬다

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderPlacedEvent e) { mailClient.send(...); }
AFTER_COMMIT  커밋이 확정된 뒤 실행 → 리스너 실패가 주문에 영향을 주지 않는다
              다만 이 시점의 DB 쓰기는 새 트랜잭션이 필요하다
              (REQUIRES_NEW 를 붙이지 않으면 반영되지 않는다 — 자주 걸리는 함정)
AFTER_ROLLBACK / AFTER_COMPLETION / BEFORE_COMMIT 도 지정할 수 있다

7.3 비동기로 뺄 때 잃는 것

@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void on(OrderPlacedEvent e) { ... }
얻는 것  응답 지연이 줄어든다
잃는 것  · 실패해도 아무도 모른다 → 별도 실패 처리·재시도가 필요하다
         · 스레드가 바뀌므로 ThreadLocal 기반 컨텍스트가 사라진다
           (보안 컨텍스트 · 트레이싱 ID · MDC 로그 상관관계)
         · 애플리케이션이 죽으면 이벤트가 유실된다

유실이 곤란하면 이벤트를 DB 에 저장하고 별도 워커가 읽는 구조로 간다
(트랜잭셔널 아웃박스 — 저장과 발행을 같은 트랜잭션에 묶는다)

7.4 java.util.Observable은 쓰지 않는다

Java 9(2017) 부터 @Deprecated
  · 직렬화 불가 · 스레드 안전하지 않음 · 이벤트에 타입이 없다(Object)
  · Observable 이 클래스라 상속을 강제한다

대체  스프링 이벤트 · java.util.concurrent.Flow(리액티브 스트림)
      PropertyChangeListener · 도메인 이벤트

📌 8. State — 상태 전이를 코드로 강제하기

주문·결제·배송처럼 상태가 규칙대로만 움직여야 하는 도메인에서 쓴다.

public enum OrderState {
    CREATED  { public Set<OrderState> next() { return Set.of(PAID, CANCELED); } },
    PAID     { public Set<OrderState> next() { return Set.of(SHIPPED, REFUNDED); } },
    SHIPPED  { public Set<OrderState> next() { return Set.of(DELIVERED); } },
    DELIVERED{ public Set<OrderState> next() { return Set.of(); } },
    CANCELED { public Set<OrderState> next() { return Set.of(); } },
    REFUNDED { public Set<OrderState> next() { return Set.of(); } };

    public abstract Set<OrderState> next();

    public OrderState to(OrderState target) {
        if (!next().contains(target))
            throw new IllegalStateTransitionException(this + " → " + target);
        return target;
    }
}
다이어그램 로딩 중…
이 구조가 막아 주는 것
  · 배송 완료된 주문이 다시 결제 대기로 돌아가는 일
  · 취소된 주문에 환불이 두 번 일어나는 일
  · 상태 검사 로직이 서비스 곳곳에 복사되는 일

전이 규칙이 '한 곳' 에만 있으므로 규칙이 바뀌어도 고칠 데가 하나다

Strategy와의 차이 — 겉모습이 같아 자주 혼동된다.

Strategy  누가 고르나: 클라이언트가 고른다
          전략끼리 서로를 모른다
          "정렬 방식을 바꾼다"

State     누가 고르나: 상태 자신이 다음 상태를 안다
          상태끼리 전이 관계로 연결돼 있다
          "결제되면 배송 대기로 넘어간다"

📌 9. 모던 자바가 흡수한 패턴

패턴고전적 구현현재
Visitoraccept(Visitor) + visitXxx 오버로드sealed + switch 패턴 매칭 (Java 21) — 망라성컴파일러가 검사한다
Strategy인터페이스 + 구현 클래스함수형 인터페이스 + 람다 / 메서드 참조
Commandexecute() 를 가진 클래스Runnable · Callable<V> + ExecutorService.submit
Template Method추상 클래스 상속콜백을 받는 메서드 (JdbcTemplate)
Builder (단순 값)빌더 클래스record — 컴팩트 생성자로 검증까지
PrototypeCloneable + clone()복사 생성자 / List.copyOf (Effective Java Item 13)
Singletonprivate 생성자 + DCLenum 싱글톤 (Item 3) 또는 DI 컨테이너에 위임
IteratorhasNext / next 직접 구현Iterable + 향상된 for / Stream

Visitor가 사라지는 과정

// 고전 Visitor — 새 연산 추가는 쉽지만, 새 타입 추가가 모든 Visitor를 깨뜨린다
interface Shape { <R> R accept(ShapeVisitor<R> v); }

// Java 21 — 타입 계층을 봉인하고 switch 로 처리한다
sealed interface Shape permits Circle, Rect, Triangle {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
record Triangle(double b, double h) implements Shape {}

double area(Shape s) {
    return switch (s) {                       // default 가 없어도 된다
        case Circle c   -> Math.PI * c.r() * c.r();
        case Rect r     -> r.w() * r.h();
        case Triangle t -> t.b() * t.h() / 2;
    };
}
Triangle 을 새로 추가하면?
  Visitor      모든 Visitor 구현을 고쳐야 한다 (안 고쳐도 컴파일은 될 수 있다)
  sealed+switch 이 switch 가 컴파일 오류를 낸다 — 빠뜨릴 수 없다

즉 '망라성 검사' 를 언어가 해 준다. Visitor 의 존재 이유 절반이 사라졌다

📌 10. 패턴을 넣을지 판단하는 기준

10.1 넣어도 되는 신호

□ 같은 형태의 분기가 세 곳 이상에 복사돼 있다
□ 새 케이스를 추가할 때마다 여는 파일이 정해져 있다 (= OCP 위반이 반복된다)
□ 그 변경이 실제로 자주 일어났다 (git log 로 확인 가능하다)
□ 테스트에서 특정 동작만 갈아 끼우고 싶은데 방법이 없다

10.2 넣으면 안 되는 신호

□ "나중에 바뀔 수도 있으니 미리" — 가장 비싼 핑계다
□ 구현이 하나뿐인 인터페이스를 만들고 있다
□ 클래스 수는 늘었는데 각 클래스가 하는 일이 한 줄이다
□ 새로 온 사람이 호출 흐름을 못 따라간다

추상화의 비용은 읽는 사람이 지불한다. 분기 3개짜리 if는 눈으로 읽히지만, 전략 3개는 파일 4개를 열어야 전체가 보인다. 그 비용보다 얻는 것이 클 때만 옳다.

10.3 리팩터링 순서

① 중복을 먼저 없앤다 (패턴 없이 메서드 추출만으로 해결되는 경우가 많다)
② 그래도 남는 분기가 있으면 그 분기의 '축' 이 무엇인지 이름 붙인다
③ 축이 하나면 Strategy, 상태 전이가 축이면 State,
   전·후 처리가 축이면 Decorator/CoR, 생성이 축이면 Factory
④ 테스트를 먼저 붙이고 옮긴다 — 패턴 도입은 동작을 바꾸지 않아야 한다

📌 11. 한 줄 요약

CoR         리스트 + 인덱스 + proceed(). 스프링 시큐리티·AOP·OkHttp·Netty 가 같은 코드다
Proxy       대상을 대신한다. self-invocation·final 은 프록시를 못 거친다
Decorator   겹쳐 쌓는다. Retry 는 CircuitBreaker 보다 바깥에 있어야 한다
Strategy    빈으로 등록하고 List 로 주입받는다. 전략은 무상태여야 한다
Template    불변부는 프레임워크, 가변부는 람다 (JdbcTemplate)
Observer    스프링 이벤트는 기본이 동기다. AFTER_COMMIT 을 쓴다
State       전이 규칙을 enum 한 곳에 모아 잘못된 전이를 막는다
모던 자바   sealed+switch 가 Visitor 를, record 가 Builder 를, 람다가 Strategy 를 흡수했다

1차 자료

  • org.springframework.security.web.FilterChainProxy · FilterOrderRegistration — spring-projects/spring-security
  • org.springframework.aop.framework.ReflectiveMethodInvocation · org.springframework.jdbc.core.JdbcTemplate — spring-projects/spring-framework
  • okhttp3.internal.connection.RealCall#getResponseWithInterceptorChain — square/okhttp
  • io.netty.channel.ChannelPipeline javadoc — Netty 4.1 API
  • Resilience4j 공식 문서 — Spring Boot aspect order
  • Effective Java 3e (Bloch) Item 1·2·3·13 · GoF Design Patterns (1994) · Java SE 21 API Docs
빠른 참조 — 어디서 무엇이 쓰이나