Table of contents
- 흐름을 누가 쥐고 있느냐
- Template Method — 뼈대를 고정하고 살을 바꾸기
- Chain of Responsibility — 체인을 따라 요청을 흘려보내기
- 두 패턴을 나란히 놓고 보면
- 선택의 기준
흐름을 누가 쥐고 있느냐
코드를 읽다 보면 이 로직이 실행되는 순서를 이해하는 게 핵심인 경우가 많다. 인증을 먼저 하고, 권한을 확인하고, 그 다음에 비즈니스 로직을 태우는 식이다. 이 순서가 여러 곳에 흩어져 있으면 한 곳을 바꿨을 때 다른 곳이 깨진다. 흐름을 한 군데서 제어하면서도, 세부 동작은 유연하게 바꿀 수 있는 구조 — 이번 편에서 다루는 두 패턴이 정확히 그걸 노린다.
Template Method는 상위 클래스가 알고리즘의 뼈대를 잡고, 하위 클래스가 세부 단계를 채운다. Chain of Responsibility(이하 CoR)는 핸들러 객체들을 체인으로 엮어서, 요청이 체인을 따라 흐르게 만든다. 접근 방식은 완전히 다르지만, 둘 다 흐름을 제어한다는 같은 문제를 푼다.
Template Method — 뼈대를 고정하고 살을 바꾸기
패턴의 구조
Template Method 패턴은 GoF(Gang of Four) 디자인 패턴 중 행위(Behavioral) 패턴에 속한다. 핵심 아이디어는 단순하다. 상위 클래스에 알고리즘의 전체 순서를 정의하는 메서드를 두고, 각 단계를 추상 메서드 또는 훅 메서드(hook method)로 열어둔다. 하위 클래스는 그 열어둔 부분만 오버라이드한다.
classDiagram
class AbstractClass {
+templateMethod()
#step1()*
#step2()*
#hook()
}
class ConcreteClassA {
#step1()
#step2()
}
class ConcreteClassB {
#step1()
#step2()
#hook()
}
AbstractClass <|-- ConcreteClassA
AbstractClass <|-- ConcreteClassB
note for AbstractClass "templateMethod()가 전체 흐름을 고정.\nstep1(), step2()는 하위 클래스가 구현.\nhook()은 선택적 오버라이드."
templateMethod()는 final(또는 Kotlin의 기본 메서드)로 선언해서 하위 클래스가 순서 자체를 바꾸지 못하게 한다. 바꿀 수 있는 건 개별 단계의 구현뿐이다.
코드로 보자 — 데이터 처리 파이프라인
CSV 파일과 JSON 파일을 파싱하는 시나리오를 생각해보자. 둘 다 파일 열기 → 데이터 파싱 → 검증 → 결과 반환이라는 뼈대는 같다. 달라지는 건 파싱 방식뿐이다.
Java로 상위 클래스를 먼저 정의한다.
public abstract class DataProcessor {
// 템플릿 메서드 — 순서를 고정한다
public final List<Record> process(String filePath) {
String raw = readFile(filePath);
List<Record> records = parse(raw);
validate(records);
afterProcess(records); // 훅
return records;
}
private String readFile(String filePath) {
return Files.readString(Path.of(filePath));
}
// 하위 클래스가 반드시 구현해야 하는 단계
protected abstract List<Record> parse(String raw);
// 기본 검증 로직. 필요하면 오버라이드 가능
protected void validate(List<Record> records) {
if (records.isEmpty()) {
throw new IllegalStateException("파싱 결과가 비어 있다");
}
}
// 훅 메서드 — 기본은 아무것도 안 한다
protected void afterProcess(List<Record> records) {
// do nothing by default
}
}
이 클래스가 하는 일은 process() 안에서 실행 순서를 못 박는 것이다. readFile은 상위 클래스가 직접 처리하고, parse는 하위 클래스에 맡긴다. validate는 기본 구현이 있지만 오버라이드가 가능하며, afterProcess는 훅이다.
CSV 프로세서를 하나 만들어본다.
public class CsvProcessor extends DataProcessor {
@Override
protected List<Record> parse(String raw) {
return Arrays.stream(raw.split("\n"))
.skip(1) // 헤더 건너뛰기
.map(line -> {
String[] cols = line.split(",");
return new Record(cols[0].trim(), cols[1].trim());
})
.toList();
}
@Override
protected void afterProcess(List<Record> records) {
System.out.println("CSV 처리 완료: " + records.size() + "건");
}
}
JSON 프로세서도 같은 구조로 만든다.
public class JsonProcessor extends DataProcessor {
private final ObjectMapper mapper = new ObjectMapper();
@Override
protected List<Record> parse(String raw) {
try {
return mapper.readValue(raw, new TypeReference<>() {});
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 파싱 실패", e);
}
}
}
JSON 쪽은 afterProcess를 오버라이드하지 않았다. 훅의 존재 이유가 이것이다 — 필요한 서브클래스만 끼어들고, 나머지는 무시해도 된다.
훅 메서드(Hook Method)란
훅은 빈 구현체를 가진 메서드다. 추상 메서드와 달리 오버라이드가 선택이다. 이 시점에 뭔가 끼워넣고 싶으면 여기를 써라라는 확장 포인트를 제공하면서도, 안 쓰면 기본 동작(보통은 아무것도 안 하는 것)으로 넘어간다.
Java의 HttpServlet이 대표적인 예다. service() 메서드가 HTTP 메서드를 판별해서 doGet(), doPost() 등을 호출하는데, 이것들이 훅이다. 필요한 것만 오버라이드하면 된다.
상속 기반이라는 한계
Template Method의 약점은 상속에 의존한다는 것이다.
- 하위 클래스가 하나의 상위 클래스에 묶인다. Java는 다중 상속이 안 되니까, 한 클래스가 두 개의 Template Method를 동시에 활용할 수 없다.
- 상위 클래스를 변경하면 모든 하위 클래스에 영향을 미친다. 단계를 하나 추가하거나 순서를 바꾸면 기존 서브클래스가 깨질 수 있다.
- 깊은 상속 계층이 생기면 디버깅이 어려워진다.
this.parse()가 실제로 어떤 구현을 호출하는지 IDE를 타고 들어가야 한다.
이런 문제 때문에 Strategy 패턴과 조합으로 대체하는 경우가 많다. 알고리즘의 각 단계를 인터페이스로 빼고, 실행기가 그 인터페이스 구현체를 주입받는 식이다. 하지만 단계의 순서 자체를 강제하는 것이 중요한 상황이라면 Template Method가 여전히 유효하다. 프레임워크가 사용자 코드를 호출하는 구조 — 이른바 제어의 역전(IoC, Inversion of Control) — 를 만들 때 자연스럽게 이 패턴이 등장하곤 한다.
Chain of Responsibility — 체인을 따라 요청을 흘려보내기
패턴의 구조
CoR 패턴은 요청을 처리할 수 있는 핸들러 객체들을 체인으로 연결한다. 요청이 들어오면 체인의 첫 번째 핸들러부터 시작해서, 자기가 처리할 수 없으면 다음 핸들러에게 넘긴다. 누군가가 처리하면 거기서 멈추거나, 체인 끝까지 흐른 뒤 최종 결과가 돌아온다.
sequenceDiagram
participant Client
participant AuthHandler
participant LoggingHandler
participant RoleHandler
participant BusinessLogic
Client->>AuthHandler: 요청
AuthHandler->>AuthHandler: 인증 확인
alt 인증 실패
AuthHandler-->>Client: 401 Unauthorized
else 인증 성공
AuthHandler->>LoggingHandler: 다음 핸들러로 전달
LoggingHandler->>LoggingHandler: 요청 로깅
LoggingHandler->>RoleHandler: 다음 핸들러로 전달
RoleHandler->>RoleHandler: 권한 확인
alt 권한 없음
RoleHandler-->>Client: 403 Forbidden
else 권한 있음
RoleHandler->>BusinessLogic: 다음 핸들러로 전달
BusinessLogic-->>Client: 200 OK
end
end
핵심은 클라이언트가 누가 처리하는지를 몰라도 된다는 것이다. 체인에 핸들러를 추가하거나 순서를 바꿔도 클라이언트 코드는 안 건드린다.
코드로 보자 — 미들웨어 체인
실무에서 가장 흔한 CoR 사례가 웹 서버의 미들웨어 체인이다. 인증 → 로깅 → 권한 체크 → 비즈니스 로직 순으로 요청이 흘러간다.
먼저 핸들러의 공통 인터페이스를 정의한다.
public interface Handler {
void setNext(Handler next);
void handle(Request request);
}
중복 코드를 줄이기 위한 베이스 클래스를 만든다. 다음 핸들러를 가리키는 참조와 전달 로직을 여기서 처리한다.
public abstract class BaseHandler implements Handler {
private Handler next;
@Override
public void setNext(Handler next) {
this.next = next;
}
protected void passToNext(Request request) {
if (next != null) {
next.handle(request);
}
}
}
이 클래스가 하는 일은 next 참조를 들고 있다가, passToNext()가 호출되면 다음 핸들러의 handle()을 실행하는 것이다.
이제 인증 핸들러를 구현한다.
public class AuthHandler extends BaseHandler {
@Override
public void handle(Request request) {
String token = request.getHeader("Authorization");
if (token == null || !TokenValidator.isValid(token)) {
request.reject(401, "인증 실패");
return; // 체인 중단
}
request.setUser(TokenValidator.extractUser(token));
passToNext(request); // 다음으로 넘긴다
}
}
로깅 핸들러는 요청을 가로채지 않고, 기록만 남기고 무조건 다음으로 넘긴다.
public class LoggingHandler extends BaseHandler {
private static final Logger log = LoggerFactory.getLogger(LoggingHandler.class);
@Override
public void handle(Request request) {
log.info("요청: {} {} (user={})",
request.getMethod(),
request.getPath(),
request.getUser());
passToNext(request);
}
}
권한 검사 핸들러는 특정 역할이 있는지 확인한다.
public class RoleHandler extends BaseHandler {
private final String requiredRole;
public RoleHandler(String requiredRole) {
this.requiredRole = requiredRole;
}
@Override
public void handle(Request request) {
if (!request.getUser().hasRole(requiredRole)) {
request.reject(403, "권한 없음");
return;
}
passToNext(request);
}
}
체인을 조립하는 코드는 이렇게 생겼다.
Handler auth = new AuthHandler();
Handler logging = new LoggingHandler();
Handler role = new RoleHandler("ADMIN");
Handler business = new BusinessLogicHandler();
auth.setNext(logging);
logging.setNext(role);
role.setNext(business);
// 클라이언트는 첫 번째 핸들러만 안다
auth.handle(incomingRequest);
이 코드는 네 개의 핸들러를 순서대로 연결한 뒤, 클라이언트가 체인의 시작점에만 요청을 던지는 구조다. 핸들러 순서를 바꾸거나 새 핸들러를 끼워넣어도 기존 코드에는 손이 안 간다.
Kotlin으로 더 간결하게
Kotlin의 함수형 기능을 활용하면 CoR을 클래스 계층 없이도 구현할 수 있다. 핸들러를 함수 타입으로 정의하면 된다.
typealias Middleware = (Request, () -> Unit) -> Unit
fun authMiddleware(): Middleware = { request, next ->
val token = request.getHeader("Authorization")
if (token == null || !TokenValidator.isValid(token)) {
request.reject(401, "인증 실패")
} else {
request.user = TokenValidator.extractUser(token)
next()
}
}
fun loggingMiddleware(): Middleware = { request, next ->
println("요청: ${request.method} ${request.path} (user=${request.user})")
next()
}
fun roleMiddleware(role: String): Middleware = { request, next ->
if (request.user?.hasRole(role) != true) {
request.reject(403, "권한 없음")
} else {
next()
}
}
체인을 조립하는 함수도 깔끔해진다. fold로 미들웨어를 역순으로 감싸면 된다.
fun buildChain(
middlewares: List<Middleware>,
finalHandler: (Request) -> Unit
): (Request) -> Unit {
return middlewares.foldRight(finalHandler) { middleware, next ->
{ request -> middleware(request) { next(request) } }
}
}
// 사용
val chain = buildChain(
listOf(authMiddleware(), loggingMiddleware(), roleMiddleware("ADMIN"))
) { request ->
// 비즈니스 로직
request.respond(200, "성공")
}
chain(incomingRequest)
클래스 네 개 대신 함수 세 개와 조립 함수 하나로 같은 구조가 만들어진다. 이게 Kotlin의 장점이 빛나는 지점이기도 하다.
CoR의 두 가지 변형
CoR에는 크게 두 가지 변형이 존재한다.
- 순수 체인(Pure CoR): 핸들러 하나가 요청을 처리하면 체인이 멈춘다. 이벤트를 처리할 수 있는 놈을 찾아라 형태. 예외 처리 체인이 이 유형이다.
- 파이프라인 체인(Pipeline CoR): 모든 핸들러가 요청에 뭔가를 하고 다음에게 넘긴다. 미들웨어 체인이 이 유형이다. 중간에 거부할 수도 있지만, 기본적으로는 전부 통과시키는 게 의도된 동작이다.
위의 미들웨어 예시는 파이프라인 형태에 가깝다. 로깅 핸들러처럼 무조건 통과시키는 핸들러와, 인증 핸들러처럼 조건부로 중단하는 핸들러가 혼재한다.
실무에서 어디에 쓰이나
CoR은 이미 많은 프레임워크에 녹아 있다.
- 서블릿 필터(Servlet Filter):
FilterChain.doFilter()가 정확히 CoR 구조다. 인증, CORS, 인코딩 필터가 체인을 이룬다. - Spring Security:
SecurityFilterChain이 여러 필터를 순서대로 태운다.UsernamePasswordAuthenticationFilter→BasicAuthenticationFilter→AuthorizationFilter같은 식이다. - OkHttp Interceptor: 네트워크 요청에 인터셉터를 체인으로 건다. 로깅, 재시도, 인증 토큰 주입 등이 인터셉터로 구현된다.
- Kotlin의 coroutine
CoroutineExceptionHandler: 처리되지 않은 예외가 핸들러 체인을 타고 올라간다.
두 패턴을 나란히 놓고 보면
Template Method와 CoR은 같은 문제 — 실행 흐름을 제어한다 — 에 대해 정반대 접근을 취한다.
| 기준 | Template Method | Chain of Responsibility |
|---|---|---|
| 결합 방식 | 상속 | 조합(객체 연결) |
| 흐름 결정 주체 | 상위 클래스 | 체인 조립 코드 |
| 확장 방법 | 새 서브클래스 생성 | 새 핸들러를 체인에 추가 |
| 순서 변경 | 불가 (상위 클래스가 고정) | 가능 (조립 순서만 바꾸면 됨) |
| 런타임 유연성 | 낮음 (컴파일 타임에 결정) | 높음 (런타임에 체인 재조립 가능) |
| 적합한 상황 | 알고리즘 골격이 고정된 프레임워크 | 처리 단계가 동적으로 변하는 파이프라인 |
공통점은 개별 단계의 구현을 모르면서도 전체 흐름을 실행할 수 있다는 것이다. Template Method는 상위 클래스가 이걸 보장하고, CoR은 체인 구조가 이걸 보장한다.
핵심 차이는 상속이냐 조합이냐다. Template Method는 IS-A 관계를 만든다. CsvProcessor는 DataProcessor이다. CoR은 HAS-A 관계를 만든다. 인증 핸들러는 다음 핸들러를 가지고 있다. 이 차이가 유연성의 차이로 이어진다.
실무에서는 흐름의 뼈대가 정말 변하지 않을 때 Template Method를, 핸들러 구성이 런타임에 바뀌어야 할 때 CoR을 고른다. 물론 둘을 섞는 경우도 있다 — 핸들러 내부에서 Template Method를 쓰는 식이다.
선택의 기준
두 패턴 앞에서 고민이 될 때 물어볼 질문은 이것이다.
“알고리즘의 단계 순서가 절대 바뀌지 않는가?”
그렇다면 Template Method가 더 간결한 선택이 된다. 단계가 다섯 개인데 순서가 고정이라면, 상위 클래스에 순서를 박아두는 게 실수를 줄인다.
반대로, 어떤 단계가 빠질 수도 있고, 순서가 바뀔 수도 있고, 런타임 설정에 따라 핸들러가 추가될 수 있다면? CoR이 맞다. 미들웨어 체인이 대표적으로 그런 상황이다. 개발 환경에선 로깅을 넣고 프로덕션에선 빼는 식의 유연함이 필요하니까.
어떤 패턴을 고르든, 결국 목표는 하나다. 흐름의 제어권을 한 곳에 두되, 세부 동작은 쉽게 교체할 수 있게 만드는 것. 이 원칙을 놓치지 않으면 두 패턴 모두 제 몫을 한다.
다음 편에서는 객체를 만드는 행위 자체를 설계하는 패턴들을 살펴본다. new를 직접 쓰는 것이 왜 문제가 되는지, Factory Method와 Abstract Factory가 어떻게 생성 로직을 분리하는지, Builder 패턴이 복잡한 객체를 어떻게 단계별로 조립하는지 파고든다.




Loading comments...