여러 종류의 파일을 읽어 리포트로 만드는 코드를 짠다고 하자. CSV 파서와 XML 파서와 JSON 파서를 각각 만들었다. 그런데 세 클래스를 나란히 놓고 보면 이상하게 닮았다 — 셋 다 파일을 열고, 헤더를 건너뛰고, 본문을 한 줄씩 읽어 레코드로 바꾸고, 검증하고, 파일을 닫는다. 실제로 다른 곳은 딱 한 군데, “한 줄(혹은 한 노드)을 레코드로 바꾸는” 파싱 부분뿐이다. 나머지 열기·닫기·검증·순회의 뼈대는 세 클래스에 글자 그대로 복제되어 있다.

이 복제가 문제를 일으키는 건 시간이 지나서다. 검증 로직에 버그가 있어 고치려는데, 같은 코드가 세 파일에 흩어져 있으니 세 군데를 똑같이 고쳐야 한다. 한 군데를 빠뜨리면 XML만 조용히 틀린 값을 낸다. “파일을 닫기 전에 로그를 남기자”는 요구가 오면 또 세 곳을 건드린다. 흐름은 하나인데 그 하나가 세 벌로 복사돼 있어서, 흐름의 변경이 매번 세 배로 든다. 템플릿 메서드(Template Method)는 이 공통 골격을 상위 클래스로 끌어올려 한 벌만 남기고, 진짜로 다른 단계 하나만 서브클래스가 채우도록 비워 둔다.

무엇이 문제인가 — 동기

같은 증상은 프레임워크 어디에나 있다. 서블릿을 떠올려 보자. 모든 요청 처리는 “요청을 받고 → 인증하고 → 파라미터를 파싱하고 → 비즈니스 로직을 실행하고 → 응답을 렌더링하고 → 로그를 남긴다”는 순서를 탄다. 이 여섯 단계 중 화면마다 다른 것은 가운데 하나, 비즈니스 로직뿐이다. 나머지 다섯은 모든 요청에 똑같다.

이걸 화면마다 처음부터 짜게 하면 세 가지가 동시에 아프다.

첫째, 뼈대가 복제된다. 화면이 N개면 여닫기·인증·렌더링 코드가 N벌 복사된다. 각 개발자가 조금씩 다르게 베껴 쓰기 시작하면, 어떤 화면은 인증을 빠뜨리고 어떤 화면은 로그 순서가 다르다. 같아야 할 것이 미묘하게 갈라진다.

둘째, 흐름의 변경이 N배로 퍼진다. “모든 요청에 요청 ID를 심자”는 공통 요구가 오면, 복제된 N개를 전부 열어 같은 수정을 반복해야 한다. 하나를 놓치면 그 화면만 추적이 안 된다. 공통 절차를 고치는 비용이 화면 수에 비례해 커진다.

셋째, 순서를 강제할 수 없다. 인증을 로직 뒤에 두거나 렌더링을 빼먹어도 컴파일은 통과한다. “이 다섯 단계를 이 순서로 반드시 밟아야 한다”는 규약이 코드 어디에도 못 박혀 있지 않아서, 순서 자체가 개발자의 기억에 의존한다.

판단 기준: “여러 절차의 큰 흐름이 똑같고 달라지는 건 몇몇 단계뿐”이라는 그림이 보이면 템플릿 메서드를 의심한다. 함정: 흐름이 절차마다 제각각이고 공통 골격이랄 게 없는데 억지로 상위 클래스를 만들면, 공유되지 않는 것을 공유하려다 상속 계층만 뒤틀린다.

패턴의 구조 — 누가 무엇을 하나

템플릿 메서드는 등장인물이 단둘이다. 핵심은 배역이 아니라 누가 흐름을 쥐고 누가 빈칸을 채우느냐의 분배다.

AbstractClass(추상 상위 클래스). 알고리즘의 골격을 담은 메서드 하나를 가진다. 이 메서드 — 템플릿 메서드 — 가 단계들을 정해진 순서로 호출한다. parse()openFile()skipHeader()parseRecord()validate()close()를 차례로 부르는 식이다. 이 순서와 골격은 상위 클래스가 통제하며, 대개 final로 못 박아 서브클래스가 흐름 자체를 못 바꾸게 한다. 골격 안에서 호출되는 단계들 중, 모두에게 똑같은 것은 상위 클래스가 직접 구현하고, 서브클래스마다 달라지는 것은 abstract로 비워 둔다.

ConcreteClass(구체 서브클래스). 상위 클래스가 비워 둔 추상 단계만 채운다. CsvParserparseRecord() 하나만 구현하면, 열기·건너뛰기·검증·닫기는 상위 클래스에서 공짜로 얻는다. 서브클래스는 자기가 다른 부분만 알면 되고, 전체 흐름이 어떻게 도는지는 몰라도 된다. 흐름을 부르는 쪽은 자신이 아니라 상위 클래스이기 때문이다.

여기서 단계는 두 종류로 갈린다. 추상 단계(반드시 채워야 하는 것)훅(hook, 채워도 되고 말아도 되는 것) 이다. 추상 단계는 서브클래스가 구현하지 않으면 컴파일이 안 된다 — 필수 빈칸이다. 훅은 상위 클래스가 “아무것도 안 함” 혹은 기본 동작으로 미리 채워 두고, 서브클래스가 원할 때만 덮어쓴다. “파일을 닫기 전에 후처리 훅을 부르되, 필요 없는 파서는 그냥 넘어간다” 같은 선택적 개입 지점이 훅이다. 골격은 이 두 종류의 빈칸을 순서대로 호출하며 흐른다.

판단 기준: 모든 서브클래스가 반드시 자기 버전을 가져야 하는 단계는 추상으로, 대부분은 기본 동작으로 충분하고 일부만 손대는 단계는 훅으로 만든다. 함정: 모든 단계를 추상으로 만들면 서브클래스가 빈칸을 잔뜩 강제로 채워야 해 상속이 짐이 된다 — 정말 달라지는 것만 비우고, 나머지는 상위에서 구현하거나 훅으로 둔다.

헐리우드 원칙 — 부르지 마라, 우리가 부른다

이 패턴의 제어 흐름에는 이름이 붙어 있다. “Don’t call us, we’ll call you” — 헐리우드 원칙이다. 오디션을 본 배우가 극단에 전화를 걸어 “저 뽑혔나요?” 묻는 게 아니라, 극단이 필요할 때 배우에게 전화를 건다는 그 말이다.

일반적인 라이브러리 사용에서는 내 코드가 라이브러리를 부른다. 내가 흐름을 쥐고, 필요할 때 정렬 함수든 파서든 호출한다. 템플릿 메서드는 이 방향을 뒤집는다. 흐름을 쥔 것은 상위 클래스이고, 서브클래스가 채운 단계는 상위 클래스가 정해진 시점에 불러 준다. 서브클래스는 “언제 내 parseRecord()가 불릴까”를 통제하지 못한다 — 그저 빈칸을 채워 두면, 골격이 흐르다가 그 자리에 이르렀을 때 알아서 호출한다. 제어의 주도권이 내 코드에서 프레임워크로 넘어간 것, 이것이 제어의 역전(IoC) 이고 프레임워크와 라이브러리를 가르는 핵심이다.

그래서 템플릿 메서드는 프레임워크의 확장점을 만드는 가장 기본적인 방법이다. 프레임워크가 전체 생애주기(초기화 → 실행 → 정리)의 골격을 쥐고, 사용자에게는 onCreate()·onStart()·onDestroy() 같은 빈칸만 내준다. 사용자는 흐름을 짜지 않고 빈칸만 채운다. 흐름은 프레임워크가 부른다.

판단 기준: “흐름의 통제권을 상위(프레임워크)가 쥐고 사용자에겐 빈칸만 열어 주고 싶다”면 템플릿 메서드가 맞는 자리다. 함정: 사용자가 흐름 자체를 갈아 끼워야 하는 상황이라면 헐리우드 원칙이 오히려 족쇄가 된다 — 그럴 땐 흐름을 통째로 주입하는 전략이 맞다.

코드로 — 패턴이 자라나는 과정

복제된 세 파서에서 출발해, 골격을 상위로 끌어올리고 달라지는 단계만 남기는 과정을 단계로 따라간다.

Refactoring Step 복제된 파서 — 골격이 세 클래스에 그대로 베껴져 있다
1/4
public class CsvReportParser {    public Report parse(File file) {        open(file);        skipHeader();               // 공통        List<Record> records = new ArrayList<>();        for (String line : readLines()) {            String[] cols = line.split(",");        // ← 여기만 CSV 고유            records.add(new Record(cols[0], cols[1]));        }        validate(records);          // 공통        close();                    // 공통        return new Report(records);    }    // open/skipHeader/validate/close ... 전부 여기 구현}public class JsonReportParser {    public Report parse(File file) {        open(file);        skipHeader();               // CsvReportParser와 글자까지 똑같음        List<Record> records = new ArrayList<>();        for (String line : readLines()) {            JsonNode node = JsonParser.parse(line);  // ← 여기만 JSON 고유            records.add(new Record(node.get("id"), node.get("name")));        }        validate(records);          // 또 복제        close();                    // 또 복제        return new Report(records);    }}// 다른 곳은 for 안의 한 줄뿐인데, 열기·건너뛰기·검증·닫기·순회의 골격이// 통째로 복제됐다. validate에 버그가 나면 두(세) 파일을 똑같이 고쳐야 한다.

첫 스텝의 복제가 마지막에 한 벌로 합쳐졌다는 점이 이 패턴의 전부다. 전략이 알고리즘 전체를 객체로 뽑아 갈아 끼웠다면, 템플릿 메서드는 뼈대는 그대로 두고 살점 몇 군데만 비운다. 서브클래스가 아는 것은 자기가 다른 단계뿐이고, 흐름의 통제권은 끝까지 상위 클래스가 쥔다. parse()final로 잠근 것이 그 통제의 선언이다 — 순서는 협상 대상이 아니다.

판단 기준: 리팩터링 후 “새 포맷 추가 = parseLine 하나 구현, 골격 0줄 수정”이 성립하고 공통 단계가 상위에 한 벌만 남으면 템플릿 메서드가 제대로 앉은 것이다. 함정: 서브클래스가 parse()를 오버라이드해 순서를 바꾸기 시작하면 골격 통제가 무너진다 — 템플릿 메서드는 반드시 final로 잠가 흐름을 지킨다.

결과 — 무엇을 얻고 무엇을 내주나

템플릿 메서드가 사는 값은 중복 제거와 흐름 통제다. 복제됐던 골격이 상위 클래스 한 곳으로 모이니, 공통 절차의 변경이 한 번의 수정으로 끝난다 — 검증 버그를 상위에서 고치면 모든 파서가 함께 고쳐진다. 그리고 흐름의 순서를 상위가 final로 못 박으므로, “이 단계들을 이 순서로”라는 규약이 코드에 강제된다. 서브클래스는 잘못된 순서를 짤 자유조차 없다. 각 서브클래스는 자기가 다른 단계 하나에만 집중하니 코드가 짧고, 새 변종은 빈칸 하나 채우기로 끝난다.

대가는 세 가지다. 첫째, 상속이라는 결합이 생긴다. 서브클래스는 상위 클래스에 컴파일타임에 묶인다. 상위의 골격이 바뀌면 모든 서브클래스가 영향권에 든다. 합성보다 결합이 뻣뻣하다 — 관계가 코드에 고정돼 런타임에 갈아 끼울 수 없다. 둘째, 흐름을 거꾸로 읽어야 한다. 실제 실행 순서는 상위의 템플릿 메서드에 있는데 구체 동작은 서브클래스에 흩어져 있어서, 한 서브클래스만 봐서는 전체 그림이 안 보인다. 상위와 서브클래스를 오가며 “이 parseLine이 언제 불리나”를 상위에서 확인해야 한다 — 제어의 역전이 낳는 간접성 비용이다. 셋째, 상속의 제약을 그대로 물려받는다. 자바는 단일 상속이라 서브클래스가 이미 다른 클래스를 상속하고 있으면 템플릿 메서드 계층에 낄 수 없다. 골격을 상속으로 나눠 주는 순간, 그 자리는 다른 상속에 쓸 수 없게 잠긴다.

운영비용 렌즈로 보면 순이득의 조건이 분명하다. 공통 골격이 실제로 크고 안정적이며 달라지는 단계가 소수로 국한될 때, 중복 제거의 이득이 상속 결합의 대가를 넘는다. 반대로 골격이 얼마 안 되거나 서브클래스마다 흐름이 자꾸 갈라진다면, 억지 상속 계층은 간접성만 더하고 유연성은 놀게 된다.

판단 기준: 얻는 것(중복 제거·흐름 통제·짧은 서브클래스)과 내주는 것(상속 결합·역전된 흐름·단일 상속 소진)을 세어 순이득이 양수일 때만 앉힌다. 함정: 공통 골격이 몇 줄뿐인데 “중복 제거”를 명분으로 상속 계층을 세우면, 아낀 중복보다 상속이 잠근 유연성의 대가가 더 크다.

전략과의 차이 — 상속으로 살 것인가, 합성으로 살 것인가

템플릿 메서드와 전략은 같은 문제를 푼다 — “흐름은 같고 일부만 다르다”를 다룬다. 그래서 자매 패턴이라 부르지만, 파는 방식이 정반대다. 템플릿 메서드는 상속으로, 전략은 합성으로 같은 유연성을 다르게 산다.

첫 번째 축은 상속 대 합성이다. 템플릿 메서드는 골격을 상위 클래스에 두고 서브클래스가 빈칸을 채운다 — 달라지는 부분이 상속 계층 안에 있다. 전략은 알고리즘을 별도 객체로 뽑아 Context가 필드로 들고 위임한다 — 달라지는 부분이 바깥의 협력 객체에 있다. 템플릿 메서드에서 “다른 것”은 서브클래스의 메서드고, 전략에서 “다른 것”은 주입된 객체다.

두 번째 축은 컴파일타임 대 런타임이다. 템플릿 메서드의 조합은 컴파일 시점에 고정된다 — CsvReportParser는 태어날 때부터 CSV 파싱으로 결정돼 있고, 실행 중에 이 인스턴스를 JSON 파서로 바꿀 수 없다. 전략은 런타임에 갈아 끼운다 — order.changePolicy(new GradeDiscount())로 실행 중에 방법을 교체한다. 방법을 값처럼 주고받아야 하면 전략이고, 방법이 태어날 때 정해지고 바뀔 일 없으면 템플릿 메서드다.

세 번째 축은 범위다. 전략은 알고리즘 전체를 통째로 갈아 끼운다 — 줄바꿈 방법 전부를 새 객체로 바꾼다. 템플릿 메서드는 알고리즘의 일부 단계만 바꾸고 골격은 공유한다 — 열고 닫는 흐름은 고정한 채 파싱 한 단계만 다르게 한다. 달라지는 게 흐름 통째면 전략, 흐름 속 몇 군데뿐이면 템플릿 메서드다.

한 가지 더. 템플릿 메서드가 서브클래스에 넘긴 “빈칸”들 중 무엇을 만들지 결정하는 단계는 대개 팩토리메서드가 된다. 템플릿 메서드가 “이 흐름의 이 지점에서 객체가 하나 필요하다”고 골격에 뚫어 둔 구멍을, 서브클래스가 “나는 이 구체 타입을 만든다”로 채우는 것 — 그게 팩토리 메서드다. 팩토리 메서드는 “무엇을 생성하는가”에 특화된 템플릿 메서드의 한 갈래다.

판단 기준: 런타임 교체가 필요하고 알고리즘 전체를 바꾸면 전략(합성), 조합이 컴파일타임에 고정돼도 되고 골격 속 일부 단계만 다르면 템플릿 메서드(상속)를 고른다. 함정: 두 개 이상의 축이 독립적으로 변하는데 템플릿 메서드로 풀면 상속 계층이 조합 폭발한다 — 그럴 땐 각 축을 전략으로 뽑아 합성으로 곱하는 게 맞다.

언제 쓰고, 언제 과용인가

템플릿 메서드가 정확히 맞는 자리는 이렇다. 여러 절차의 큰 흐름이 동일하고 달라지는 것이 몇몇 단계로 국한될 때. 여러 클래스에 흩어진 공통 골격을 한 곳으로 모으고 싶을 때. 프레임워크가 생애주기의 흐름을 통제하면서 사용자에게 확장점(빈칸) 만 열어 주고 싶을 때. 자바 라이브러리에 예가 흔하다 — AbstractList가 순회·검색의 골격을 쥐고 get(int)·size()만 서브클래스에 비워 두는 것, HttpServlet이 요청 처리 흐름을 쥐고 doGet·doPost 훅만 여는 것이 교과서적이다. InputStreamread(byte[])가 골격을 돌리며 추상 read() 한 바이트짜리를 반복 호출하는 것도 마찬가지다.

과용의 냄새는 반대편에서 난다. 공통 골격이 몇 줄 안 되는데 중복 제거를 명분으로 상속 계층을 세우면, 아낀 중복보다 상속이 잠근 유연성의 대가가 크다. 서브클래스마다 흐름이 조금씩 달라 골격을 자꾸 if로 갈래 치기 시작하면, 그건 애초에 공유할 골격이 없었다는 신호다 — 억지 상속을 접고 각각 두는 게 낫다. 빈칸이 하나뿐이고 그 한 단계 때문에 클래스 계층 전체를 세운다면, 자바에서는 그 단계를 함수 인자(전략의 경량 버전) 로 넘기는 편이 더 가볍다. 상속 대신 parse(file, line -> parseCsv(line))처럼 다른 단계를 람다로 주입하면, 상속 결합 없이 같은 효과를 얻는다.

가장 흔한 함정은 상속의 방향을 착각하는 것이다. 템플릿 메서드는 “is-a” 관계가 실제로 성립할 때만 자연스럽다. CsvReportParserReportParser의 한 종류라는 말이 참이라 상속이 맞다. 그런데 단지 코드를 공유하려고 아무 상관없는 두 절차를 억지로 부모-자식으로 묶으면, 문법은 상속이되 의미는 없는 계층이 된다 — 상속은 코드 재사용 수단이기 전에 분류 관계다.

판단 기준: 서브클래스가 상위와 “is-a” 관계가 참이고, 공유하는 골격이 실제로 크며, 달라지는 단계가 소수로 명확할 때만 템플릿 메서드를 앉힌다. 함정: 빈칸 하나 때문에 상속 계층을 세우는 것 — 단일 단계 변형은 람다·함수 인자로 넘기는 편이 상속 결합 없이 더 가볍다.

다음으로 방문자