Chain of Responsibility가 실무에서 나타나는 가장 흔한 형태. 겉보기에 전혀 다른 프레임워크들이 사실상 같은 코드를 쓴다.
공통 뼈대
리스트 + 현재 위치 인덱스 + '다음을 부르는' 메서드
- proceed() {
- if (인덱스가 끝) → 진짜 대상을 호출
- 다음 요소를 꺼내고 인덱스++
- 그 요소에게 나(this)를 넘기며 실행
- }
실제 소스 — 스프링 시큐리티
private static final class VirtualFilterChain implements FilterChain {
private int currentPosition = 0;
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); // 자기 자신을 넘긴다
}
}
실제 소스 — 스프링 AOP
public Object proceed() throws Throwable {
if (this.currentInterceptorIndex ==
this.interceptorsAndDynamicMethodMatchers.size() - 1) {
return invokeJoinpoint(); // 어드바이스가 끝났다 → 실제 메서드
}
Object advice = this.interceptorsAndDynamicMethodMatchers
.get(++this.currentInterceptorIndex);
return ((MethodInterceptor) advice).invoke(this);
}
구조가 동일하다. @Transactional·@Cacheable이 전부 이 위에서 돈다.
왜 this를 넘기는가
각 요소는 "다음이 누구인지" 몰라도 된다. 체인 객체만 들고 있으면 된다 — 이 한 줄이 요소끼리의 결합을 0으로 만든다. 순서를 바꿔도, 중간에 하나를 빼도 나머지 코드는 변하지 않는다.
순서가 곧 의미다
스프링 시큐리티에서 ExceptionTranslationFilter 는 AuthorizationFilter 보다 '앞' 에 있다
- 뒤에서 던져진 AccessDeniedException 을 잡아
- 401/403 응답이나 로그인 리다이렉트로 바꿔야 하기 때문이다
- 뒤에 있었다면 예외가 톰캣까지 올라가 500 이 된다
실무 함정
-
① 다음을 안 불러 요청이 증발한다
- 의도한 차단이면 정상이지만, 예외 경로에서 실수로 빠지면
- 응답이 비어 있고 로그도 남지 않는다
-
② 응답이 이미 커밋된 뒤 헤더를 건드린다
- 앞 요소가 flush 했으면 뒤에서 setHeader 가 무시되거나 예외가 난다
-
③ 예외 처리 위치
- 사슬은 호출 스택이므로 예외는 바깥으로 올라간다
- 공통 예외 처리는 가장 바깥 요소에 둔다
OkHttp — 위치가 동작을 정한다
interceptors += client.interceptors // ① 애플리케이션 인터셉터
interceptors += RetryAndFollowUpInterceptor() // ② 재시도·리다이렉트
interceptors += BridgeInterceptor() // ③ 헤더 보정·gzip 해제
interceptors += CacheInterceptor() // ④ 캐시 판정
interceptors += ConnectInterceptor // ⑤ 연결 확보
interceptors += client.networkInterceptors // ⑥ 네트워크 인터셉터
interceptors += CallServerInterceptor // ⑦ 실제 송수신
-
애플리케이션 인터셉터(①) — 재시도·리다이렉트와 무관하게 1번 호출
- 캐시 히트여도 호출된다
-
네트워크 인터셉터(⑥) — 실제 요청마다 호출 (리다이렉트 3번이면 3번)
- 캐시 히트면 ④에서 끊겨 아예 실행되지 않는다
-
"로그가 안 찍힌다" 의 원인이 대개 이 위치 선택이다
방향이 둘인 사슬 — Netty
p.addLast("1", InboundHandlerA); p.addLast("2", InboundHandlerB); p.addLast("3", OutboundHandlerA); p.addLast("4", OutboundHandlerB); p.addLast("5", InboundOutboundHandlerX);
- 인바운드(수신) — 1 → 2 → 5 (3·4는 건너뛴다)
- 아웃바운드(송신) 5 → 4 → 3 — (1·2를 건너뛰고 역방향)
하나의 리스트인데 방향에 따라 다른 부분집합이 실행된다. CoR 변형 중 가장 헷갈리는 형태다.
면접 함정
- ❌ "필터와 인터셉터는 같다" → 서블릿 필터는 컨테이너가, 스프링 인터셉터는 디스패처가 호출한다. 예외 처리 범위가 다르다.
- ❌ "순서는 상관없다" → 순서가 곧 동작 정의다.