본문 바로가기

템플릿 메서드

템플릿 메서드로 공통 제어 흐름을 고정하고 상속 결합·예외 재전파·확장 지점을 판단합니다.

컨트롤러와 리포지토리마다 begin, try, end, catch를 복사하면 핵심 로직보다 추적 코드가 더 크게 보입니다.

템플릿 메서드 패턴은 실행 순서를 상위 클래스가 소유하고 하위 클래스가 달라지는 작업만 구현하게 합니다.

상속은 흐름을 강하게 고정하는 대신 부모 내부 지식과 생명 주기에 결합된다는 비용이 있습니다.

템플릿 메서드

begin 뒤 try의 업무 반환·success 또는 failure 재전파로 제어가 이동하며 sink 무실패 가정을 유지해야 한다.

TEMPLATE METHOD · FIXED FLOW + OPERATION HOOK

final execute가 순서를 소유하고 hook만 바꾼다

상위 템플릿이 시작·대상 호출·정상/실패 기록을 고정합니다. 하위 클래스는 protected call()에 한 작업의 변형만 제공합니다.

기록기가 실패하지 않는 조건의 템플릿 정상·실패 분기begin은try밖에서호출한다. try안call이정상반환하면success기록뒤원결과를반환한다. RuntimeException또는Error면failure기록뒤같은객체를throw한다. 그림은sink의세메서드가실패하지않는조건이다. 그조건을제거하면begin실패는catch밖이고success실패는catch로,failure의새실패는원실패를덮을수있다. 정상반환과실패종료사이간선은없다.sink.begin · try 밖call() · try 안sink.success원 결과 반환sink.failure같은 실패 throw정상실패그림 조건: begin·success·failure가 실패하지 않음
기록기가 실패하지 않는 조건의 템플릿 정상·실패 분기begin은try밖에서호출한다. try안call이정상반환하면success기록뒤원결과를반환한다. RuntimeException또는Error면failure기록뒤같은객체를throw한다. 그림은sink의세메서드가실패하지않는조건이다. 그조건을제거하면begin실패는catch밖이고success실패는catch로,failure의새실패는원실패를덮을수있다. 정상반환과실패종료사이간선은없다.sink.begintry 밖call()try 안sink.success정상 반환 기록원 결과 반환sink.failure원 실패 기록같은 실패 throw정상실패조건: sink는 실패 안 함begin 실패는 catch 밖
final execute의 고정 흐름과 하위 call hookexecute에서 begin을 기록한 뒤 하위 call을 실행한다. 정상 결과는 success 뒤 반환한다. RuntimeException 또는 Error는 failure 기록 뒤 같은 객체를 다시 던진다. 이 계약은 sink가 실패하지 않을 때 예제에서 유지된다.상위 · final execute(message)sink.begin시작 기록하위 call()업무 한 작업반환sink.success원본 결과 반환예외sink.failure같은 업무 예외 재전파시작·종료 순서는 재정의하지 않음변형 지점은 protected hook 하나
  1. final execute → begin → call

    상위 클래스가 순서를 고정하고 하위 hook은 한 업무 작업만 수행합니다.

  2. call의 반환 / RuntimeException·Error

    정상은 success 뒤 원본 결과, 실패는 failure 뒤 같은 예외를 다시 던집니다. sink가 실패하지 않는 예제 조건입니다.

예제가 보장하는 범위

원문에는 finally와 suppressed 처리가 없습니다. sink.failure가 던지면 업무 실패를 덮을 수 있습니다. 관찰·정리 실패 격리는 추가 구현해야 합니다.

상속 비용과 선택

단계별 hook이 늘면 부모 변경이 하위 타입으로 번집니다. 작업 하나의 변형이나 런타임 조합에는 callback이 가볍습니다. 이 final 템플릿을 클래스 프록시 대상으로 삼을지도 구분합니다.

성공 로그는 finally에 두지 않습니다. 재시도 조정자는 별도 책임이며 템플릿은 각 시도를 관찰합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • begin은 try 밖에서 호출한다. try 안의 call이 정상 반환하면 success를 기록한 뒤 원래 결과를 반환한다. RuntimeException 또는 Error면 failure를 기록한 뒤 같은 객체를 throw한다. 그림은 sink의 세 메서드가 실패하지 않는 조건이다. 그 조건을 제거하면 begin 실패는 catch 밖이고 success 실패는 catch로 가며, failure의 새 실패는 원래 실패를 덮을 수 있다. 정상 반환과 실패 종료 사이에 간선은 없다.
  • execute에서 begin을 기록한 뒤 하위 call을 실행한다. 정상 결과는 success 뒤 반환한다. RuntimeException 또는 Error는 failure 기록 뒤 같은 객체를 다시 던진다. 이 계약은 sink가 실패하지 않을 때 예제에서 유지된다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1템플릿 메서드
8begin은 try 밖에서 호출한다. try 안의 call이 정상 반환하면 success를 기록한 뒤 원래 결과를 반환한다. RuntimeException 또는 Error면 failure를 기록한 뒤 같은 객체를 throw한다. 그림은 sink의 세 메서드가 실패하지 않는 조건이다. 그 조건을 제거하면 begin 실패는 catch 밖이고 success 실패는 catch로 가며, failure의 새 실패는 원래 실패를 덮을 수 있다. 정상 반환과 실패 종료 사이에 간선은 없다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
두 분기의 공통 시작

sink.begin을 try 밖에서 호출한 뒤 call에 들어갑니다. 정상과 업무 실패는 같은 시작을 갖는 대안입니다.

정상 경로: begin → call 진입 · 실패 경로: begin → call 진입 · 조건: sink 세 메서드 무실패begin이 완료되어야 try 안의 업무 call을 실행합니다.
call 결과별 기록

call이 반환한 경로는 success를 기록하고 RuntimeException·Error를 던진 경로는 catch에서 failure를 기록합니다.

정상 경로: 원 반환값 보관 → success · 실패 경로: 원 실패 보관 → failure · 실패 범위: RuntimeException·Errorcall의 반환 또는 throw가 각 분기의 기록 메서드를 결정합니다.
각 경로의 독립 종료

정상 경로는 보관한 반환값을 돌려주고 실패 경로는 기록한 것과 같은 실패 객체를 재전파합니다. 두 종료 사이 이동은 없습니다.

정상 경로: 원 반환값 return · 실패 경로: 동일 실패 객체 throw · sink 조건 제거: begin은 catch 밖, failure의 새 실패는 원 실패를 덮을 수 있음기록기 무실패 조건에서 success 뒤 return 또는 failure 뒤 동일 객체 throw로 각 경로가 끝납니다.

템플릿 메서드

begin 뒤 try의 업무 반환·success 또는 failure 재전파로 제어가 이동하며 sink 무실패 가정을 유지해야 한다.

TEMPLATE METHOD · FIXED FLOW + OPERATION HOOK

final execute가 순서를 소유하고 hook만 바꾼다

상위 템플릿이 시작·대상 호출·정상/실패 기록을 고정합니다. 하위 클래스는 protected call()에 한 작업의 변형만 제공합니다.

기록기가 실패하지 않는 조건의 템플릿 정상·실패 분기begin은try밖에서호출한다. try안call이정상반환하면success기록뒤원결과를반환한다. RuntimeException또는Error면failure기록뒤같은객체를throw한다. 그림은sink의세메서드가실패하지않는조건이다. 그조건을제거하면begin실패는catch밖이고success실패는catch로,failure의새실패는원실패를덮을수있다. 정상반환과실패종료사이간선은없다.sink.begin · try 밖call() · try 안sink.success원 결과 반환sink.failure같은 실패 throw정상실패그림 조건: begin·success·failure가 실패하지 않음
기록기가 실패하지 않는 조건의 템플릿 정상·실패 분기begin은try밖에서호출한다. try안call이정상반환하면success기록뒤원결과를반환한다. RuntimeException또는Error면failure기록뒤같은객체를throw한다. 그림은sink의세메서드가실패하지않는조건이다. 그조건을제거하면begin실패는catch밖이고success실패는catch로,failure의새실패는원실패를덮을수있다. 정상반환과실패종료사이간선은없다.sink.begintry 밖call()try 안sink.success정상 반환 기록원 결과 반환sink.failure원 실패 기록같은 실패 throw정상실패조건: sink는 실패 안 함begin 실패는 catch 밖
final execute의 고정 흐름과 하위 call hookexecute에서 begin을 기록한 뒤 하위 call을 실행한다. 정상 결과는 success 뒤 반환한다. RuntimeException 또는 Error는 failure 기록 뒤 같은 객체를 다시 던진다. 이 계약은 sink가 실패하지 않을 때 예제에서 유지된다.상위 · final execute(message)sink.begin시작 기록하위 call()업무 한 작업반환sink.success원본 결과 반환예외sink.failure같은 업무 예외 재전파시작·종료 순서는 재정의하지 않음변형 지점은 protected hook 하나
  1. final execute → begin → call

    상위 클래스가 순서를 고정하고 하위 hook은 한 업무 작업만 수행합니다.

  2. call의 반환 / RuntimeException·Error

    정상은 success 뒤 원본 결과, 실패는 failure 뒤 같은 예외를 다시 던집니다. sink가 실패하지 않는 예제 조건입니다.

예제가 보장하는 범위

원문에는 finally와 suppressed 처리가 없습니다. sink.failure가 던지면 업무 실패를 덮을 수 있습니다. 관찰·정리 실패 격리는 추가 구현해야 합니다.

상속 비용과 선택

단계별 hook이 늘면 부모 변경이 하위 타입으로 번집니다. 작업 하나의 변형이나 런타임 조합에는 callback이 가볍습니다. 이 final 템플릿을 클래스 프록시 대상으로 삼을지도 구분합니다.

성공 로그는 finally에 두지 않습니다. 재시도 조정자는 별도 책임이며 템플릿은 각 시도를 관찰합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • begin은 try 밖에서 호출한다. try 안의 call이 정상 반환하면 success를 기록한 뒤 원래 결과를 반환한다. RuntimeException 또는 Error면 failure를 기록한 뒤 같은 객체를 throw한다. 그림은 sink의 세 메서드가 실패하지 않는 조건이다. 그 조건을 제거하면 begin 실패는 catch 밖이고 success 실패는 catch로 가며, failure의 새 실패는 원래 실패를 덮을 수 있다. 정상 반환과 실패 종료 사이에 간선은 없다.
  • execute에서 begin을 기록한 뒤 하위 call을 실행한다. 정상 결과는 success 뒤 반환한다. RuntimeException 또는 Error는 failure 기록 뒤 같은 객체를 다시 던진다. 이 계약은 sink가 실패하지 않을 때 예제에서 유지된다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1템플릿 메서드
8begin은 try 밖에서 호출한다. try 안의 call이 정상 반환하면 success를 기록한 뒤 원래 결과를 반환한다. RuntimeException 또는 Error면 failure를 기록한 뒤 같은 객체를 throw한다. 그림은 sink의 세 메서드가 실패하지 않는 조건이다. 그 조건을 제거하면 begin 실패는 catch 밖이고 success 실패는 catch로 가며, failure의 새 실패는 원래 실패를 덮을 수 있다. 정상 반환과 실패 종료 사이에 간선은 없다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
두 분기의 공통 시작

sink.begin을 try 밖에서 호출한 뒤 call에 들어갑니다. 정상과 업무 실패는 같은 시작을 갖는 대안입니다.

정상 경로: begin → call 진입 · 실패 경로: begin → call 진입 · 조건: sink 세 메서드 무실패begin이 완료되어야 try 안의 업무 call을 실행합니다.
call 결과별 기록

call이 반환한 경로는 success를 기록하고 RuntimeException·Error를 던진 경로는 catch에서 failure를 기록합니다.

정상 경로: 원 반환값 보관 → success · 실패 경로: 원 실패 보관 → failure · 실패 범위: RuntimeException·Errorcall의 반환 또는 throw가 각 분기의 기록 메서드를 결정합니다.
각 경로의 독립 종료

정상 경로는 보관한 반환값을 돌려주고 실패 경로는 기록한 것과 같은 실패 객체를 재전파합니다. 두 종료 사이 이동은 없습니다.

정상 경로: 원 반환값 return · 실패 경로: 동일 실패 객체 throw · sink 조건 제거: begin은 catch 밖, failure의 새 실패는 원 실패를 덮을 수 있음기록기 무실패 조건에서 success 뒤 return 또는 failure 뒤 동일 객체 throw로 각 경로가 끝납니다.

공통 흐름과 변형

게시글 조회와 등록 모두 트레이스 시작, 대상 호출, 정상 종료, 예외 종료가 같습니다.

SQL과 반환 타입만 다릅니다.

공통 블록이 단순히 앞뒤 두 줄인지, 예외 매핑과 리소스 정리까지 포함하는지 써 보면 추상화 경계를 찾기 쉽습니다.

템플릿은 final execute()로 순서를 고정하고 protected abstract call()을 확장 지점으로 둡니다.

하위 클래스가 시작이나 정리를 재정의하게 열어 두면 불변 흐름이 다시 깨집니다.

src/main/java/board/template/TraceTemplate.java
package board.template;

public abstract class TraceTemplate<T> {
    private final TraceSink sink;

    protected TraceTemplate(TraceSink sink) {
        this.sink = sink;
    }

    public final T execute(String message) {
        long started = System.nanoTime();
        sink.begin(message);
        try {
            T result = call();
            sink.success(message, System.nanoTime() - started);
            return result;
        } catch (RuntimeException | Error failure) {
            sink.failure(message, failure, System.nanoTime() - started);
            throw failure;
        }
    }

    protected abstract T call();

    public interface TraceSink {
        void begin(String message);
        void success(String message, long elapsedNanos);
        void failure(String message, Throwable failure, long elapsedNanos);
    }
}

제네릭 반환 타입을 사용해 쿼리와 명령 결과를 그대로 돌려줍니다.

검사 예외까지 지원하려면 call()과 execute()의 throws 계약과 실패 기록 범위를 함께 맞춥니다. 원 call()에는 throws가 없으므로 하위 구현에만 throws IOException을 추가하면 컴파일 오류입니다. IOException을 받는 별도 템플릿은 두 메서드에 throws IOException을 선언하고 그 실패를 처리하도록 만들 수 있으며, 함수형 인터페이스나 예외용 타입 파라미터가 반드시 필요한 것은 아닙니다. 다만 모든 호출자에 검사 예외를 노출할지 검토합니다. Java의 throws·재정의 규칙에 원 선언을 대조하세요.


익명 하위 클래스

다음 실행은 한 번의 게시글 조회를 익명 하위 클래스로 감쌉니다.

기록기의 begin·success·failure가 예외를 던지지 않는 조건에서 대상 예외는 실패 로그 뒤 같은 인스턴스로 재전파됩니다.

src/main/java/board/template/TemplateMethodDemo.java
package board.template;

public final class TemplateMethodDemo {
    public static void main(String[] args) {
        var sink = new ConsoleSink();
        var operation = new TraceTemplate<String>(sink) {
            @Override
            protected String call() {
                return "post-41";
            }
        };
        System.out.println("result=" + operation.execute("find post"));
    }

    static final class ConsoleSink implements TraceTemplate.TraceSink {
        @Override
        public void begin(String message) {
            System.out.println("BEGIN " + message);
        }

        @Override
        public void success(String message, long elapsedNanos) {
            System.out.println("END " + message + " success");
        }

        @Override
        public void failure(
                String message,
                Throwable failure,
                long elapsedNanos
        ) {
            System.out.println("END " + message + " failure="
                    + failure.getClass().getSimpleName());
        }
    }
}
TemplateMethodDemo 실행
BEGIN find post
END find post success
result=post-41

익명 클래스가 여러 메서드에 반복되기 시작하면 람다를 받는 콜백 구조가 더 간결합니다.

템플릿 메서드는 알고리즘 단계마다 재정의가 필요하거나 하위 클래스 타입 자체가 의미 있을 때 적합합니다.


예외·자원 정리 불변식

성공 로그를 finally에 두면 실패도 성공으로 기록됩니다.

예외 처리에서 실패를 기록하고 원래 예외를 다시 던집니다.

정리가 필요하면 별도 finally를 두되 정리 예외가 원래 업무 실패를 덮지 않게 억제 예외 관계를 보존합니다.

트레이서가 예외 메시지 전체를 출력하면 인증 정보나 SQL 파라미터가 유출될 수 있습니다.

수집 대상은 실패 클래스와 안정적인 코드를 받고 스택 트레이스는 요청 경계 정책이 결정합니다.

템플릿의 책임을 로깅 백엔드 설정까지 넓히지 않습니다.

작업이 null을 반환해도 성공인지, 선택적 결과를 요구할지 규칙에 적습니다.

예시 원 execute에서 sink.begin은 try 밖입니다. begin이 실패하면 call도 실행되지 않고 이 catch에도 들어가지 않습니다. call이 성공해도 sink.success가 RuntimeException을 던지면 catch가 그 기록기 실패를 sink.failure에 전달합니다. call의 실패 E 뒤 sink.failure가 다른 실패 L을 던지면 마지막 throw E에 도달하지 못하고 L이 호출자에게 나옵니다. Error도 원 catch의 대상이라는 점은 같습니다.

따라서 “업무 실패를 그대로 전달한다”는 목표와 지금 예제의 보장 조건을 구분합니다. 원 코드는 finally나 suppressed 연결을 구현하지 않았습니다. 관찰자 실패 격리·정리를 더 구현할 때 업무 실패를 어떻게 보존할지 따로 정해야 합니다.

템플릿이 null을 임의의 실패로 바꾸면 대상 의미를 침범합니다.


상속 변경 전파

하위 클래스는 부모의 호출 순서와 protected API를 알아야 합니다.

생성자 의존성이 늘거나 훅 순서가 바뀌면 모든 하위 클래스가 영향을 받습니다.

Java는 단일 상속이라 이미 다른 기반 클래스가 있는 컴포넌트에는 적용하기 어렵습니다.

상속을 실제 도메인 하위 타입 관계로 오해하지 않습니다.

PostRepository extends TraceTemplate는 리포지토리가 트레이스 템플릿의 한 종류라는 모델이 아니라 코드 재사용 관계입니다.

객체 조합 기반의 전략과 콜백은 이 결합을 줄입니다.

final 템플릿은 순서를 보호하지만 프록시가 하위 클래스를 만들어야 하는 환경과 충돌할 수 있습니다.

이 템플릿 객체 자체를 Spring AOP 대상으로 삼을지, 내부 도우미로만 쓸지 구분합니다.


호출 순서·예외 검증

기록 수집 대상에 이벤트를 모아 BEGIN → END 순서를 확인하고 대상 실패와 호출자가 받은 객체가 같은지 봅니다.

지속 시간 숫자는 가짜 시간 제공자를 주입해 결정적으로 만듭니다.

콘솔 문자열 스냅샷만 비교하면 핵심 순서보다 포매팅 변화에 취약합니다.


템플릿 메서드 선택 기준

공통 순서가 안정적이고 변형 지점이 소수이며 하위 클래스가 자연스러운 이름을 가질 때 템플릿 메서드가 읽기 좋습니다.

런타임에 작업을 바꾸거나 조합하고 싶고 변형이 람다 하나라면 콜백이 낫습니다.

공통 코드가 두 줄뿐이라면 추상화가 오히려 탐색 비용을 만들 수 있습니다.


연습 문제

재시도를 템플릿 단계로 추가하지 말고 한 번의 시도만 추적하도록 유지하세요.

호출자가 재시도할 때 각 시도와 전체 작업을 구별할 이벤트 모델을 설계하고 실패 동일성이 보존되는 테스트를 작성하세요.

해설 보기

시도 템플릿과 재시도 조정자를 분리하면 트레이싱이 업무 재시도 정책을 소유하지 않습니다.

package board.template;

public final class RetryCoordinator {
    public <T> T run(int maximumAttempts, Attempt<T> attempt) {
        RuntimeException last = null;
        for (int number = 1; number <= maximumAttempts; number++) {
            try {
                return attempt.run(number);
            } catch (RuntimeException failure) {
                last = failure;
            }
        }
        throw last;
    }

    @FunctionalInterface
    public interface Attempt<T> {
        T run(int number);
    }
}

실제 재시도는 멱등성, 일시적 분류, 기한, 백오프를 추가해야 합니다.

템플릿은 각 시도의 관찰만 담당합니다.

독립 확인: 원 RetryCoordinator에서 maximumAttempts=2입니다. 첫 attempt는 런타임 예외 객체 E1, 둘째는 다른 런타임 예외 객체 E2를 던집니다. 시도 번호와 최종 전파 객체를 구하세요. maximumAttempts=0인 별도 호출은 attempt를 몇 번 실행하고 어떤 실패가 나는지 원 코드를 그대로 따라가세요.

풀이 확인

양수 조건에서는 attempt.run(1), attempt.run(2)를 실행하고 last를 E1에서 E2로 바꾼 뒤 E2 자체를 던집니다. 각 시도의 TraceTemplate 기록기가 실패하지 않는다면 BEGIN/실패 기록도 각각 두 번입니다. 0이면 반복문을 한 번도 실행하지 않고 null인 last를 throw하므로 NullPointerException이 생깁니다. 이 최소 조정자를 사용하려면 최대 시도 수가 1 이상이라는 입력 전제 또는 별도 검증이 필요합니다. 무조건 RuntimeException을 재시도하는 원 구조는 업무의 안전한 재시도 정책을 완성한 구현은 아닙니다.

직접 연습: 두 시도와 두 업무 효과는 다르다.

첫 시도가 저장 뒤 실패하면 다시 실행해도 안전한가?

원 RetryCoordinator를 최대 2회로 실행하고 각 시도를 TraceTemplate으로 감쌉니다. 기록기의 세 메서드는 실패하지 않습니다. 교육용 메모리 저장소의 주문 생성 횟수는 처음 0이며, call()은 호출될 때마다 이 횟수를 1 늘립니다. 첫 시도는 이 변경을 완료한 뒤 응답을 만들다 런타임 예외 E를 던지고, 둘째는 post-41을 반환합니다. 롤백·중복 요청 검사는 없으며 동시 호출도 없습니다. call() 호출 수, 최종 생성 횟수, 호출자의 결과와 시도별 이벤트 순서를 구하세요. 성공 로그가 하나라면 논리 주문도 반드시 하나인가요?

풀이: call()은 2번 실행되고 생성 횟수도 2입니다. 이벤트는 첫 BEGIN → 실패 기록, 둘째 BEGIN → 성공 기록이며 호출자는 post-41을 받습니다. 한 시도가 예외로 끝났다는 사실은 그 전에 완료한 업무 효과가 취소됐다는 뜻이 아닙니다. 템플릿은 관찰 순서를 고정할 뿐 롤백이나 멱등성을 제공하지 않으므로 성공 기록 1개로 논리 주문 1건을 증명할 수 없습니다. 실제 조정자는 재시도 가능 실패·기한·백오프를 판단하고, 동일 요청의 중복 효과를 막을 업무 계약을 따로 마련해야 합니다. 예를 들어 요청 키별 결과 기록과 중복 판정을 원자적으로 처리한다는 계약이 있어야 두 번째 시도가 첫 저장의 결과를 재사용할 수 있습니다. 단순히 두 시도에 같은 트레이스 ID를 붙이는 것으로는 중복 저장이 막히지 않습니다.