텍스트 뷰에 스크롤바를 붙이고 싶다. 그리고 테두리도. 어떤 화면에서는 그림자까지. 가장 곧게 떠오르는 방법은 상속이다 — TextView를 물려받아 ScrollableTextView를 만들고, 테두리가 필요하면 BorderedTextView를, 둘 다 필요하면 BorderedScrollableTextView를 만든다. 문제는 여기서 시작된다. 꾸밈이 세 종류(스크롤·테두리·그림자)면 조합은 2^3=8가지고, 대상 뷰가 텍스트·리스트·이미지 셋이면 8×3=24개의 클래스가 필요하다. 꾸밈을 하나 더 추가하는 순간 개수가 배로 뛴다. 이 조합 폭발이 데코레이터(Decorator) 패턴이 태어난 자리다.
핵심 착상은 소박하다. 스크롤바는 텍스트 뷰의 한 종류가 아니라, 텍스트 뷰를 감싸 기능을 얹는 껍질이다. 상속(is-a)이 아니라 감싸기(has-a)로 뒤집으면, 껍질 하나당 클래스 하나만 있으면 되고 조합은 런타임에 겹겹이 쌓아 만든다. 스크롤 껍질 × 테두리 껍질 × 텍스트 뷰 — 세 클래스로 여덟 조합을 낸다.
무엇이 문제인가 — 동기
같은 증상을 커피 주문으로 옮기면 손에 잡힌다. 에스프레소에 우유를 얹으면 라떼, 거기에 휘핑을 얹고, 시럽을 두 번 추가하고… 토핑 조합은 사실상 무한하다. 이걸 상속으로 짜면 EspressoWithMilk, EspressoWithMilkAndWhip, EspressoWithMilkAndWhipAndSyrup 같은 클래스를 조합마다 찍어 내야 한다. 토핑이 하나 늘 때마다 기존 조합 전부에 그 토핑을 얹은 새 클래스가 필요해진다 — 클래스가 지수적으로 폭발한다.
상속으로 기능을 조합할 때 아픈 곳은 셋이다.
첫째, 조합의 수만큼 클래스가 필요하다. 기능이 N개면 켜고 끄는 조합이 2^N이다. 실제로 쓰는 조합은 그중 몇 개뿐인데, 미리 다 만들 수도 없고 필요할 때마다 만들면 클래스 목록이 걷잡을 수 없이 길어진다.
둘째, 조합이 컴파일 시점에 고정된다. BorderedScrollableTextView는 태어날 때부터 테두리와 스크롤이 박혀 있다. 실행 중에 “이 뷰에서 테두리만 떼자”가 안 된다. 사용자가 옵션을 켜고 끌 때마다 다른 클래스의 인스턴스로 갈아 끼워야 하는데, 상속 계층에는 그런 유연성이 없다.
셋째, 같은 기능이 여러 클래스에 중복된다. 스크롤 기능을 텍스트 뷰에도, 리스트 뷰에도, 이미지 뷰에도 얹으려면 각 계층마다 스크롤 코드가 복제된다. 스크롤 로직을 한 번 고치면 세 군데를 다 고쳐야 한다. 기능(스크롤)과 대상(뷰)이라는 두 축을 상속 하나로 엮으려니 축이 서로를 오염시킨다.
판단 기준: “기능 A·B·C를 임의로 켜고 끄는 조합이 필요하고, 그 조합이 앞으로도 늘어난다”가 보이면 데코레이터를 의심한다. 함정: 조합이 사실상 한둘로 고정돼 있고 늘 일이 없다면, 서브클래스 한둘이 더 단순하다 — 오지 않을 조합의 자유를 위해 감싸기 구조를 깔면 간접성만 는다.
패턴의 구조 — 누가 무엇을 하나
데코레이터의 급소는 껍질과 알맹이가 같은 얼굴을 한다는 것이다. 감싸는 쪽과 감싸이는 쪽이 동일한 인터페이스를 구현하기에, 클라이언트는 지금 손에 쥔 것이 맨 객체인지 세 겹 감싼 것인지 구별하지 못한다 — 구별할 필요가 없다.
Component(공통 인터페이스). 알맹이와 껍질이 함께 구현하는 계약. Beverage의 int cost(), String description() 같은 것. 클라이언트가 아는 유일한 얼굴이 이것이고, 껍질을 몇 겹 씌우든 이 얼굴은 변하지 않는다.
ConcreteComponent(구체 컴포넌트). 감싸일 알맹이. Espresso. 아무것도 꾸며지지 않은 원본이며, 데코레이터의 존재를 모른다.
Decorator(데코레이터). Component를 구현하면서 동시에 Component 하나를 필드로 품는다. 이 이중성이 핵심이다 — 같은 인터페이스라 껍질로 쓰일 수 있고, 안에 Component를 품기에 다시 감쌀 수 있다. 대개 자신이 감싼 안쪽에게 일을 넘긴 뒤(wrapped.cost()), 그 결과에 자기 몫을 더한다(+ 500). 이 “안쪽에 위임하고 내 것을 얹는” 구조가 데코레이터의 전부다.
ConcreteDecorator(구체 데코레이터들). Milk, Whip, Syrup. 각자 자기 한 가지 책임만 안다. 우유 껍질은 우유값만, 휘핑 껍질은 휘핑값만 얹는다. 서로를 모르고, 자기가 감싼 것이 알맹이인지 다른 껍질인지도 모른다 — 그냥 “안쪽”으로만 안다.
협력은 재귀적 위임으로 흐른다. syrup.cost()를 부르면 시럽은 자기 안쪽(휘핑)에게 cost()를 묻고, 휘핑은 자기 안쪽(우유)에게 묻고, 우유는 알맹이(에스프레소)에게 묻는다. 요청이 껍질을 타고 안으로 들어갔다가, 값이 밖으로 나오며 각 껍질이 자기 몫을 얹는다. 양파 껍질을 벗겨 심에 닿았다가 다시 껍질을 하나씩 되짚어 나오는 모양이다.
판단 기준: 감싸는 것과 감싸이는 것이 같은 인터페이스를 공유하고, 껍질이 안쪽에 위임한 뒤 자기 몫만 더하는 구조가 자연스러우면 데코레이터가 맞다. 함정: 데코레이터가 안쪽 인터페이스에 없는 새 메서드를 추가하면, 클라이언트가 그 메서드를 쓰려고 형 검사를 해야 하고 “몇 겹 감쌌는지”가 드러난다 — 껍질의 투명성이 깨진다. 데코레이터는 인터페이스를 넓히지 않고 행동만 덧입힐 때 가장 곱게 앉는다.
코드로 — 패턴이 자라나는 과정
상속으로 조합을 만들다 폭발하는 지점에서 출발해, 같은 인터페이스로 감싸는 껍질을 뽑고, 런타임에 겹겹이 쌓는 데까지 단계로 따라간다.
public class Espresso { public int cost() { return 3000; }}// 우유 얹은 것public class EspressoWithMilk extends Espresso { public int cost() { return super.cost() + 500; }}// 우유 + 휘핑public class EspressoWithMilkAndWhip extends Espresso { public int cost() { return 3000 + 500 + 700; }}// 우유 + 휘핑 + 시럽... 조합마다 클래스가 하나씩 필요하다.public class EspressoWithMilkAndWhipAndSyrup extends Espresso { public int cost() { return 3000 + 500 + 700 + 300; }}// 토핑이 3종이면 조합이 2^3=8개. 여기에 아메리카노·라떼 베이스가 더해지면// 다시 곱절로 는다. 토핑을 하나 추가하면 기존 조합 전부에 그 토핑을 얹은// 새 클래스가 또 필요해진다 — 클래스가 지수적으로 터진다.
첫 스텝의 클래스 폭발이 마지막에 세 껍질 + 두 알맹이로 줄었다는 점이 핵심이다. 데코레이터는 조합을 없앤 게 아니라, 조합을 만드는 일을 컴파일 시점의 상속에서 런타임의 감싸기로 옮겼다. 2^3 조합은 여전히 만들 수 있지만, 그 조합은 클래스 목록이 아니라 new Syrup(new Whip(...))라는 조립 표현식으로 존재한다. 자바 표준 라이브러리의 new BufferedReader(new InputStreamReader(new FileInputStream(f)))가 바로 이 구조다 — 버퍼링·문자 변환·바이트 읽기라는 세 껍질을 겹쳐, 필요한 조합을 그 자리에서 조립한다.
판단 기준: “새 기능 추가 = 데코레이터 클래스 하나, 기존 코드 0줄 수정”이 성립하고, 조합은 감싸는 순서로 표현되면 데코레이터가 제대로 앉은 것이다. 함정: 데코레이터끼리 순서에 의존하면(예: 암호화 껍질 다음에 압축 껍질을 씌우면 압축이 안 먹는다) 겉보기엔 자유로운 조합이 실은 특정 순서만 옳다 — 이 순서 제약은 코드에 드러나지 않아 조립하는 쪽이 알아서 지켜야 하는 숨은 규칙이 된다.
결과 — 무엇을 얻고 무엇을 내주나
데코레이터가 사는 값은 정적 상속을 동적 조합으로 바꾸는 것이다. 서브클래스로는 컴파일 시점에 못박혔을 기능 조합을, 런타임에 껍질을 씌우고 벗기며 자유로이 만든다. 기능 하나당 클래스 하나면 되니 2^N의 클래스 폭발이 N개로 눌린다. 각 껍질이 자기 책임 하나만 지므로 단일 책임이 자연히 지켜지고, 필요한 기능만 골라 얹으니 알맹이 클래스는 부가 기능으로 뚱뚱해지지 않는다(핵심과 꾸밈의 분리).
대가는 이렇다. 첫째, 잔가지 같은 작은 객체가 많아진다. 세 겹 감싼 주문은 껍질 셋과 알맹이 하나, 네 객체다. 기능별로 잘게 나뉜 껍질이 수백 개 인스턴스로 떠다니면, 시스템에 정체가 비슷비슷한 작은 객체가 우수수 늘어 전체 그림을 파악하기 어려워진다. 둘째, 디버깅 때 껍질을 하나씩 추적해야 한다. order.cost()가 4800을 냈는데 3500이 나와야 했다면, 시럽→휘핑→우유→에스프레소를 한 겹씩 벗겨 어느 껍질이 잘못 얹었는지 좇아야 한다. 스택 트레이스는 cost()가 여러 껍질에서 반복 호출되는 모양으로 나와, 지금 몇 번째 껍질에 있는지 눈으로 세어야 한다. 상속이라면 한 클래스에서 끝났을 추적이 껍질 수만큼 길어진다. 셋째, 껍질과 알맹이의 동일성(identity) 문제. 감싼 객체는 알맹이와 ==이 아니다. “이게 그 에스프레소냐”를 참조 비교로 묻는 코드가 있으면, 껍질을 씌우는 순간 신원이 달라져 오작동한다.
운영비용 렌즈로 보면 순이득의 조건이 분명하다. 기능 조합이 많고 동적으로 바뀌는 축이면, 늘어난 잔객체와 추적 비용의 대가보다 “클래스 폭발을 막고 런타임에 조립하는” 이득이 크다. 반대로 조합이 한둘로 굳어 있으면, 겹겹의 껍질은 간접성과 디버깅 부담만 더하고 동적 조합의 이득은 쓰이지 않는다.
판단 기준: 얻는 것(동적 조합·클래스 폭발 방지·책임 분리)과 내주는 것(잔객체 증가·껍질 추적·신원 문제)을 세어 순이득이 양수일 때만 감싼다. 함정: 데코레이터를 세 겹 넘게 예사로 쌓으면, cost() 하나 따라가는 데 다섯 파일을 오가야 한다 — 조합의 자유는 얻었지만 코드를 읽는 사람은 매번 양파를 까야 한다.
언제 쓰고, 언제 과용인가
데코레이터가 정확히 맞는 자리는 이렇다. 개별 객체에 책임을 동적으로, 조합해서 붙였다 뗐다 해야 할 때(스트림에 버퍼링·압축·암호화를 겹치기, UI 요소에 스크롤·테두리·그림자를 얹기). 상속으로는 조합 수만큼 클래스가 폭발해 감당이 안 될 때. 기능을 껐다 켰다 해야 하는데 서브클래싱으로는 실행 중 교체가 불가능할 때. 자바 IO의 스트림 계층이 교과서적 사례다 — FilterInputStream을 상속한 BufferedInputStream·DataInputStream·GZIPInputStream이 전부 데코레이터이고, 이들을 겹쳐 필요한 읽기 파이프라인을 조립한다.
과용의 냄새는 반대편에서 난다. 조합이 사실상 한둘로 고정돼 있는데 “확장성”을 위해 데코레이터 구조를 깔면, 오지 않을 조합의 자유에 간접성을 지불하는 것이다. 껍질이 자꾸 안쪽 인터페이스에 없는 메서드를 노출하려 든다면, 그건 데코레이터가 아니라 다른 설계(서브클래스나 별도 객체)를 원하는 신호다. 겹겹의 껍질 때문에 디버깅이 양파 까기가 되어 버렸다면, 조합의 자유가 실제로 쓰이는지부터 되물어야 한다.
여기서 이웃 패턴과 경계를 그어 둘 필요가 있다. [컴포지트]와 데코레이터는 구조가 닮았다 — 둘 다 Component 인터페이스를 구현하면서 Component를 품고 재귀적으로 위임한다. 갈라지는 것은 의도다. 컴포지트는 여럿을 하나처럼 다루려고 자식을 여러 개 품는다(부분-전체 트리). 데코레이터는 하나에 책임을 얹으려고 안쪽 하나만 품는다(기능 중첩). 자식이 여럿이면 컴포지트, 감싼 대상이 하나면서 행동을 덧입히면 데코레이터다. [전략]과는 “무엇을 바꾸느냐”로 갈린다 — 전략은 객체의 속(알맹이 알고리즘)을 통째로 갈아 끼우고, 데코레이터는 객체의 겉(껍질)을 덧씌워 원래 행동에 무언가를 더한다. 속을 바꾸면 전략, 겉을 감싸면 데코레이터다. [프록시]와는 구조가 거의 같다(같은 인터페이스로 하나를 감싼다) — 하지만 프록시는 접근을 제어하려 감싸고(지연 로딩·권한·원격), 데코레이터는 기능을 더하려 감싼다. 프록시는 대개 껍질이 하나로 고정이고, 데코레이터는 여러 개를 겹치는 것이 자연스럽다.
판단 기준: 감싼 대상이 하나이고 그 행동에 무언가를 덧입히면 데코레이터, 여럿을 하나로 묶으면 컴포지트, 접근을 가로막거나 대리하면 프록시다. 함정: 셋이 구조가 닮았다고 아무렇게나 부르면 의도가 흐려진다 — “왜 감쌌는가”(기능 추가/집합 표현/접근 제어)로 이름을 정한다. 구조가 같아도 이름이 다르면 읽는 사람에게 목적을 알려 주는 것이고, 그게 패턴 이름의 값이다.
다음으로 퍼사드