하리Hari

7. 데코레이터 패턴 (Decorator)

객체에 껍질을 씌우듯 동적으로 기능을 추가하는 데코레이터 패턴을 알아봅니다.

한 줄 요약
객체를 같은 약속을 따르는 껍질로 감싸서 기능을 덧붙이는 패턴이에요. 껍질 위에 껍질을 또 씌울 수 있어서, 기능을 원하는 만큼 원하는 순서로 쌓을 수 있어요.

문제: 조합마다 하나씩 늘어나는 클래스

카페 계산 프로그램을 만든다고 해 볼게요. 기본은 에스프레소이고, 손님이 토핑을 골라요.

  • 우유 +500원
  • 시럽 +300원
  • 샷 +500원

가장 먼저 떠오르는 방법은 메뉴마다 상속으로 클래스를 하나씩 만드는 거예요.

[에스프레소]를 설계하자:
    [숫자]를 돌려주는 <가격>을 만들자 ():
        3000을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        "에스프레소"를 돌려주자

(참고: 조합마다 클래스를 하나씩 만들어요)
[에스프레소]를 바탕으로 [라떼]를 설계하자:
    [숫자]를 돌려주는 <가격>을 만들자 ():
        (3000 + 500)을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        "에스프레소 + 우유"를 돌려주자

[에스프레소]를 바탕으로 [바닐라라떼]를 설계하자:
    [숫자]를 돌려주는 <가격>을 만들자 ():
        (3000 + 500 + 300)을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        "에스프레소 + 우유 + 시럽"을 돌려주자

(참고: 우유 없이 시럽만 넣으려면 또 하나 만들어야 해요)
[에스프레소]를 바탕으로 [바닐라에스프레소]를 설계하자:
    [숫자]를 돌려주는 <가격>을 만들자 ():
        (3000 + 300)을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        "에스프레소 + 시럽"을 돌려주자

'메뉴'를 [새로운 [에스프레소](), 새로운 [라떼](), 새로운 [바닐라라떼](), 새로운 [바닐라에스프레소]()]로 정하자
'메뉴'의 '커피'마다 반복하자:
    틀"{'커피'의 <설명>()}: {'커피'의 <가격>()}원"을 출력하자

실행 결과
에스프레소: 3000원
에스프레소 + 우유: 3500원
에스프레소 + 우유 + 시럽: 3800원
에스프레소 + 시럽: 3300원

메뉴가 네 개일 때는 괜찮아요. 그런데 토핑으로 고를 수 있는 게 늘어나면 금방 아파져요.

  • 조합만큼 클래스가 늘어나요. 토핑이 세 가지면 넣고 빼는 조합이 8가지, 네 가지면 16가지예요. 토핑 하나가 늘 때마다 클래스 수가 두 배가 돼요.
  • 같은 계산이 여기저기 복사돼요. “시럽은 +300원”이 [바닐라라떼]와 [바닐라에스프레소]에 따로 들어 있어요. 시럽이 400원으로 오르면 시럽이 들어간 클래스를 모두 찾아 고쳐야 해요.
  • 같은 토핑을 두 번 넣는 것도 클래스예요. “샷 두 번”을 하려면 [투샷에스프레소]를 또 만들어야 해요.
  • 손님의 주문은 실행 중에 들어와요. 그런데 클래스는 코드를 쓸 때 정해져 있어요. 아무도 예상하지 못한 조합(샷 + 우유 + 시럽 두 번)을 주문하면 받아 줄 클래스가 없어요.

해결책: 같은 모양의 껍질로 감싸기

데코레이터 패턴은 토핑을 “커피의 종류”가 아니라 “커피를 감싸는 껍질“로 봐요.

  1. 커피라면 지킬 약속, 곧 규정을 만들어요. <가격>과 <설명>을 할 줄 알아야 해요.
  2. 껍질도 이 약속을 따르게 해요. 껍질은 안쪽에 커피를 하나 품고 있고, 기본으로는 안쪽 커피에게 일을 그대로 넘겨요.
  3. 토핑마다 껍질을 하나씩 만들고, “안쪽 결과에 무엇을 더할지”만 적어요. 우유 껍질은 안쪽 가격에 500을 더해요.
  4. 껍질도 커피니까, 껍질을 또 다른 껍질로 감쌀 수 있어요. 순서도 개수도 자유예요.

비유: 선물 포장

선물을 상자에 넣고, 포장지로 싸고, 리본을 달아요. 포장지로 싼 상자도 여전히 “선물”이라서 그 위에 리본을 또 달 수 있어요. 받는 사람은 겉에서부터 하나씩 풀면서 안쪽 선물에 닿아요. 포장 한 겹 한 겹이 바로 “데코레이터”예요. 포장지 종류가 늘어도 “포장된 상자” 종류를 미리 다 만들어 둘 필요는 없죠.

누가 무엇을 하나요?

역할 이 예제에서 하는 일
약속 [커피] (규정) 커피든 껍질이든 할 줄 알아야 하는 일을 정해요
알맹이 [에스프레소] 감싸지는 기본 객체예요
기본 껍질 [토핑] 안쪽 커피를 품고, 기본으로는 일을 그대로 넘겨요
구체적인 껍질 [우유], [시럽], [샷] 안쪽 결과에 자기 몫을 더해요

하리로 만들기

같은 카페를 데코레이터 패턴으로 다시 만들어 볼게요. 코드 속 (참고: n단계)를 따라 읽어 보세요.

(참고: 1단계 — 커피라면 지킬 약속)
[커피]를 규정하자:
    [숫자]를 돌려주는 <가격>이 있어야 한다 ()
    [문자열]을 돌려주는 <설명>이 있어야 한다 ()

[커피]를 따르는 [에스프레소]를 설계하자:
    [숫자]를 돌려주는 <가격>을 만들자 ():
        3000을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        "에스프레소"를 돌려주자

(참고: 2단계 — 커피를 하나 품고 있는 껍질. 껍질도 커피라서, 기본으로는 안쪽에 그대로 맡겨요)
[커피]를 따르는 [토핑]을 설계하자:
    '안쪽'을 [커피]인 비어있음으로 정하여 물려주자

    처음 만들어질 때 ([커피]인 '안쪽') 다음과 같이 하자:
        '나'의 '안쪽'을 '안쪽'으로 정하자

    [숫자]를 돌려주는 <가격>을 만들자 ():
        '나'의 '안쪽'의 <가격>()을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        '나'의 '안쪽'의 <설명>()을 돌려주자

(참고: 3단계 — 껍질마다 '안쪽에 무엇을 더할지'만 적어요)
[토핑]을 바탕으로 [우유]를 설계하자:
    [숫자]를 돌려주는 <가격>을 만들자 ():
        ('나'의 '안쪽'의 <가격>() + 500)을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        틀"{'나'의 '안쪽'의 <설명>()} + 우유"를 돌려주자

[토핑]을 바탕으로 [시럽]을 설계하자:
    [숫자]를 돌려주는 <가격>을 만들자 ():
        ('나'의 '안쪽'의 <가격>() + 300)을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        틀"{'나'의 '안쪽'의 <설명>()} + 시럽"을 돌려주자

[토핑]을 바탕으로 [샷]을 설계하자:
    [숫자]를 돌려주는 <가격>을 만들자 ():
        ('나'의 '안쪽'의 <가격>() + 500)을 돌려주자

    [문자열]을 돌려주는 <설명>을 만들자 ():
        틀"{'나'의 '안쪽'의 <설명>()} + 샷"을 돌려주자

(참고: 껍질을 원하는 만큼, 원하는 순서로 씌워요)
'바닐라라떼'를 [커피]인 새로운 [시럽](새로운 [우유](새로운 [에스프레소]()))로 정하자
'진한커피'를 [커피]인 새로운 [샷](새로운 [샷](새로운 [에스프레소]()))로 정하자

'메뉴'를 [새로운 [에스프레소](), '바닐라라떼', '진한커피']로 정하자
'메뉴'의 '커피'마다 반복하자:
    틀"{'커피'의 <설명>()}: {'커피'의 <가격>()}원"을 출력하자

(참고: 손님이 고른 순서대로, 실행 중에 껍질을 씌울 수도 있어요)
'주문'을 ["샷", "우유", "시럽", "시럽"]으로 정하자
'손님커피'를 [커피]인 새로운 [에스프레소]()로 정하자
'주문'의 '이름'마다 반복하자:
    '이름'에 따라 나누자:
        "우유" 인 경우:
            '손님커피'를 새로운 [우유]('손님커피')로 정하자
        "시럽" 인 경우:
            '손님커피'를 새로운 [시럽]('손님커피')로 정하자
        "샷" 인 경우:
            '손님커피'를 새로운 [샷]('손님커피')로 정하자
틀"손님 주문 — {'손님커피'의 <설명>()}: {'손님커피'의 <가격>()}원"을 출력하자

실행 결과
에스프레소: 3000원
에스프레소 + 우유 + 시럽: 3800원
에스프레소 + 샷 + 샷: 4000원
손님 주문 — 에스프레소 + 샷 + 우유 + 시럽 + 시럽: 4600원

토핑 클래스는 세 개뿐인데, 어떤 조합이든 만들 수 있어요. 마지막 줄처럼 아무도 미리 만들어 두지 않은 조합도요.

단계별로 다시 보기

  • 1단계 — 약속 만들기. [커피]는 “가격과 설명을 알려 줄 수 있으면 커피”라는 약속이에요. 알맹이도 껍질도 이 약속을 따르기 때문에, 쓰는 쪽은 상대가 알맹이인지 껍질인지 신경 쓰지 않아요.
  • 2단계 — 기본 껍질. [토핑]은 안쪽 커피를 '안쪽'에 품고, 기본으로는 일을 그대로 넘겨요. '안쪽'은 물려주자로 만들어서 밖에서는 못 건드리고, 자식 껍질만 쓸 수 있어요.
  • 3단계 — 껍질마다 자기 몫만. [우유]는 “안쪽 가격 + 500, 안쪽 설명 + 우유”만 알아요. 만들어질 때 할 일은 따로 적지 않았는데, 자식에 생성자가 없으면 부모([토핑])의 생성자가 대신 쓰이기 때문이에요.
  • 감싸는 순서가 곧 결과의 순서. 새로운 [시럽](새로운 [우유](...))은 우유로 먼저 감싸고 시럽으로 한 번 더 감싸요. 그래서 설명도 “우유 + 시럽” 순서로 나와요.

<가격>을 부르면 무슨 일이 일어나나요?
'바닐라라떼'의 <가격>()을 부르면 맨 바깥 [시럽]이 먼저 안쪽 [우유]에게 묻고, [우유]는 다시 [에스프레소]에게 물어요. [에스프레소]가 3000을 돌려주면 [우유]가 500을 더해 3500, [시럽]이 300을 더해 3800이 돼요. 선물 포장을 풀 듯 바깥에서 안으로 들어갔다가, 결과는 안에서 바깥으로 쌓여 나와요.

새 토핑을 더해 보면

“휘핑크림” +700원이 생겼다고 해 볼게요.

  • 앞의 방식: 휘핑크림이 들어갈 수 있는 모든 조합마다 클래스를 새로 만들어야 해요. 지금 있는 클래스 수만큼 더 생겨요.
  • 데코레이터 패턴: [토핑]을 바탕으로 [휘핑]이라는 껍질 하나만 만들면 돼요. 이미 있는 모든 조합 위에 바로 얹을 수 있어요.

직접 해 보기
위 코드에 [휘핑] 껍질을 더해 보세요. 가격은 안쪽 + 700원, 설명은 + 휘핑이에요. 그리고 새로운 [휘핑](새로운 [우유](새로운 [에스프레소]()))의 설명과 가격을 출력해 보세요. 다른 클래스는 하나도 고칠 필요가 없어요.

언제 쓰면 좋을까요?

  • 기능을 골라서 조합해야 하고, 조합 수가 많아서 클래스로 다 만들 수 없을 때
  • 같은 기능을 여러 번 겹쳐 쓰거나, 순서를 바꿔 쓰고 싶을 때
  • 이미 있는 클래스를 고치지 않고 기능을 덧붙이고 싶을 때 (남이 만든 클래스, 패키지의 클래스)
  • 조합이 실행 중에 정해질 때 (사용자 선택, 설정)

좋은 점과 아쉬운 점

좋은 점 아쉬운 점
토핑 수만큼만 클래스를 만들면 모든 조합이 가능해요 작은 객체가 겹겹이 생겨서 한눈에 보기 어려워요
같은 계산이 한곳(그 껍질)에만 있어요 감싸는 순서가 결과에 영향을 줘서, 순서를 신경 써야 할 때가 있어요
실행 중에 기능을 붙였다 뗐다 할 수 있어요 중간 껍질 하나만 쏙 빼기는 어려워요
원래 클래스를 고치지 않아요

너무 이른 패턴은 독이에요.
메뉴가 고정되어 있고 조합이 두세 개뿐이라면, 메뉴마다 클래스를 두거나 값 하나로 처리하는 게 더 읽기 쉬워요. 조합이 늘어나 클래스가 불어나기 시작할 때 껍질로 바꿔도 늦지 않아요.

다른 패턴과의 관계

  • 전략 패턴과 비교해 보세요. 전략은 일의 속(방법)을 바꿔 끼우고, 데코레이터는 일을 겉에서 감싸 기능을 더해요. 전략은 객체 안에 하나만 끼우지만, 데코레이터는 여러 겹을 쌓아요.
  • 어댑터 패턴도 감싸는 모양이에요. 다만 어댑터는 모양(약속)을 바꾸려고 감싸고, 데코레이터는 모양은 그대로 두고 기능을 더하려고 감싸요.
  • 팩토리 패턴과 함께 쓸 수 있어요. 위 예제 마지막 부분처럼 주문 이름을 보고 어떤 껍질을 씌울지 고르는 코드를 공장으로 옮기면, 주문 받는 곳은 더 간단해져요.
놀이터
결과
코드를 작성하고 '실행' 버튼을 눌러보세요.