하리Hari

12. 컴포지트 패턴 (Composite)

하나짜리와 여러 개 묶음을 같은 약속으로 다뤄서, 나무 모양 구조를 한 번에 다루는 컴포지트 패턴을 알아봅니다.

한 줄 요약
하나짜리(파일)와 여럿을 담은 묶음(폴더)이 같은 약속을 따르게 해서, 쓰는 쪽이 둘을 구별하지 않고 나무 모양 구조 전체를 한 번에 다루게 하는 패턴이에요.

문제: 할 때마다 “파일이야, 폴더야?“를 묻는 코드

컴퓨터의 폴더 정리 프로그램을 만든다고 해 볼게요. 폴더 안에는 파일이 들어 있고, 폴더 안에 또 폴더가 들어 있을 수도 있어요.

  • 폴더의 전체 크기를 알고 싶어요. 안에 든 파일과, 안의 폴더 속 파일까지 모두 더해야 해요.
  • 폴더 안의 내용을 나무 모양으로 보여 주고 싶어요.

가장 먼저 떠오르는 방법은 파일과 폴더를 따로 만들고, 무언가를 할 때마다 “이게 파일인지 폴더인지” 따져서 나누는 거예요.

[텍스트]에서 <반복>을 가져오자

[파일항목]을 설계하자:
    '이름'을 [문자열]인 ""으로 정하자
    '크기'를 [숫자]인 0으로 정하자

    처음 만들어질 때 ([문자열]인 '이름', [숫자]인 '크기') 다음과 같이 하자:
        '나'의 '이름'을 '이름'으로 정하자
        '나'의 '크기'를 '크기'로 정하자

[폴더]를 설계하자:
    '이름'을 [문자열]인 ""으로 정하자
    '안에든것'을 []로 정하자

    처음 만들어질 때 ([문자열]인 '이름') 다음과 같이 하자:
        '나'의 '이름'을 '이름'으로 정하자

    <넣기>를 만들자 ('무엇'):
        '나'의 '안에든것'에 '무엇'을 추가하자

(참고: 무언가를 할 때마다 "파일이야, 폴더야?"를 따져요)
<크기재기>를 만들자 ('항목'):
    만약 ('항목'이 [파일항목]의 일종이다) 라면:
        '항목'의 '크기'를 돌려주자
    '합'을 0으로 정하자
    '항목'의 '안에든것'의 '하나'마다 반복하자:
        '합'을 ('합' + <크기재기>('하나'))로 정하자
    '합'을 돌려주자

<보여주기>를 만들자 ('항목', [숫자]인 '깊이'):
    '앞'을 <반복>("· ", '깊이')로 정하자
    만약 ('항목'이 [파일항목]의 일종이다) 라면:
        틀"{'앞'}{'항목'의 '이름'} ({'항목'의 '크기'}KB)"를 출력하자
        돌려주자
    틀"{'앞'}{'항목'의 '이름'}/"를 출력하자
    '항목'의 '안에든것'의 '하나'마다 반복하자:
        <보여주기>('하나', '깊이' + 1)를 실행하자

'사진'을 새로운 [폴더]("사진")으로 정하자
'사진'의 <넣기>(새로운 [파일항목]("바다.jpg", 300))를 실행하자
'사진'의 <넣기>(새로운 [파일항목]("산.jpg", 200))를 실행하자

'문서'를 새로운 [폴더]("문서")로 정하자
'문서'의 <넣기>(새로운 [파일항목]("일기.txt", 5))를 실행하자
'문서'의 <넣기>('사진')을 실행하자

<보여주기>('문서', 0)를 실행하자
틀"전체 크기: {<크기재기>('문서')}KB"를 출력하자

실행 결과
문서/
· 일기.txt (5KB)
· 사진/
· · 바다.jpg (300KB)
· · 산.jpg (200KB)
전체 크기: 505KB

잘 돌아가요. 그런데 할 일이 늘어나면 아파져요.

  • 할 일마다 “파일이야, 폴더야?“를 물어요. <크기재기>와 <보여주기>가 둘 다 일종이다로 나누고, 폴더라면 안을 돌면서 자기 자신을 다시 불러요. “이름으로 찾기”, “오래된 파일 지우기”를 만들 때마다 같은 모양의 코드를 또 써야 해요.
  • 새 종류가 생기면 모든 함수를 고쳐야 해요. “압축 파일”이나 “바로 가기”가 생기면, 할 일마다 만약을 하나씩 더해야 해요.
  • 바깥이 속사정을 다 알아요. <크기재기>는 파일에는 '크기'가, 폴더에는 '안에든것'이 있다는 걸 알고 있어요. 폴더가 내용을 담는 방식을 바꾸면 바깥 함수들도 다 바뀌어요.
  • 아무거나 넣을 수 있어요. <넣기>('무엇')에는 타입이 없어서, 실수로 숫자나 글자를 넣어도 막지 못하고 크기를 잴 때에야 이상해져요.

해결책: 하나와 묶음이 같은 약속을 따르게 하기

컴포지트 패턴은 파일과 폴더를 같은 약속으로 묶어요.

  1. 파일이든 폴더든 항목이라면 지킬 약속, 곧 규정을 만들어요. 항목은 <크기>를 알려 주고 <보여주기>를 할 줄 알아야 해요.
  2. 잎(파일)은 자기 일을 직접 해요. 크기를 물으면 자기 용량을 돌려줘요.
  3. 묶음(폴더)은 항목들을 품고, 할 일을 안의 항목들에게 나눠 맡겨요. 크기를 물으면 안의 항목들에게 크기를 물어 더해요. 안에 폴더가 있어도 그 폴더가 알아서 더하니, 깊이가 얼마든 상관없어요.
  4. 쓰는 쪽은 상대가 파일인지 폴더인지 묻지 않아요. 그냥 <크기>()를 불러요.

비유: 군대의 명령 전달

장군이 “모두 집합!“이라고 하면, 장군은 병사 한 명 한 명에게 말하지 않아요. 사단장에게 말하면 사단장은 연대장들에게, 연대장은 중대장들에게, 중대장은 병사들에게 전해요. 명령을 받는 쪽이 병사 한 명이든 부대 전체든, 장군은 똑같이 “집합!“이라고 말하면 돼요. 부대(묶음)는 아래로 전하고, 병사(잎)는 직접 움직이죠. 이 구조가 바로 “컴포지트”예요.

누가 무엇을 하나요?

역할 이 예제에서 하는 일
항목 약속 [항목] (규정) 잎과 묶음이 똑같이 할 줄 알아야 하는 일을 정해요
잎 [파일항목] 더 나눌 수 없는 하나예요. 일을 직접 해요
묶음 [폴더] 항목들을 품고, 일을 안의 항목들에게 나눠 맡겨요
쓰는 쪽 프로그램 맨 아래 파일인지 폴더인지 묻지 않고 약속대로 불러요

하리로 만들기

같은 폴더 정리 프로그램을 컴포지트 패턴으로 다시 만들어 볼게요.

[텍스트]에서 <반복>을 가져오자

(참고: 1단계 — 파일이든 폴더든 '항목'이라면 지킬 약속)
[항목]을 규정하자:
    [숫자]를 돌려주는 <크기>가 있어야 한다 ()
    <보여주기>가 있어야 한다 ([숫자]인 '깊이')

(참고: 2단계 — 잎: 더 나눌 수 없는 하나)
[항목]을 따르는 [파일항목]을 설계하자:
    '이름'을 [문자열]인 ""으로 정하자
    '용량'을 [숫자]인 0으로 정하자

    처음 만들어질 때 ([문자열]인 '이름', [숫자]인 '용량') 다음과 같이 하자:
        '나'의 '이름'을 '이름'으로 정하자
        '나'의 '용량'을 '용량'으로 정하자

    [숫자]를 돌려주는 <크기>를 만들자 ():
        '나'의 '용량'을 돌려주자

    <보여주기>를 만들자 ([숫자]인 '깊이'):
        틀"{<반복>("· ", '깊이')}{'나'의 '이름'} ({'나'의 '용량'}KB)"를 출력하자

(참고: 3단계 — 묶음: 항목들을 품고, 할 일을 안의 항목들에게 나눠 맡겨요)
[항목]을 따르는 [폴더]를 설계하자:
    '이름'을 [문자열]인 ""으로 정하자
    '안에든것'을 []로 정하여 숨기자

    처음 만들어질 때 ([문자열]인 '이름') 다음과 같이 하자:
        '나'의 '이름'을 '이름'으로 정하자

    <넣기>를 만들자 ([항목]인 '무엇'):
        '나'의 '안에든것'에 '무엇'을 추가하자

    [숫자]를 돌려주는 <크기>를 만들자 ():
        '합'을 0으로 정하자
        '나'의 '안에든것'의 '하나'마다 반복하자:
            '합'을 ('합' + '하나'의 <크기>())로 정하자
        '합'을 돌려주자

    <보여주기>를 만들자 ([숫자]인 '깊이'):
        틀"{<반복>("· ", '깊이')}{'나'의 '이름'}/"를 출력하자
        '나'의 '안에든것'의 '하나'마다 반복하자:
            '하나'의 <보여주기>('깊이' + 1)를 실행하자

'사진'을 새로운 [폴더]("사진")으로 정하자
'사진'의 <넣기>(새로운 [파일항목]("바다.jpg", 300))를 실행하자
'사진'의 <넣기>(새로운 [파일항목]("산.jpg", 200))를 실행하자

'문서'를 새로운 [폴더]("문서")로 정하자
'문서'의 <넣기>(새로운 [파일항목]("일기.txt", 5))를 실행하자
'문서'의 <넣기>('사진')을 실행하자

(참고: 쓰는 쪽은 파일인지 폴더인지 묻지 않아요)
'문서'의 <보여주기>(0)를 실행하자
틀"전체 크기: {'문서'의 <크기>()}KB"를 출력하자
틀"사진 폴더만: {'사진'의 <크기>()}KB"를 출력하자

실행 결과
문서/
· 일기.txt (5KB)
· 사진/
· · 바다.jpg (300KB)
· · 산.jpg (200KB)
전체 크기: 505KB
사진 폴더만: 500KB

나무 모양과 전체 크기는 리팩터링 전과 같아요. 마지막 줄처럼 안쪽 폴더 하나만 따로 재는 것도 똑같은 <크기>()로 돼요.

단계별로 다시 보기

  • 1단계 — 같은 약속. [항목]은 “크기를 알려 주고 보여 줄 수 있으면 항목”이라는 약속이에요. 파일도 폴더도 이 약속을 따라요.
  • 2단계 — 잎은 직접. [파일항목]의 <크기>는 자기 '용량'을 돌려줄 뿐이에요.
  • 3단계 — 묶음은 나눠 맡기기. [폴더]의 <크기>는 안의 항목마다 <크기>()를 불러 더해요. 안의 항목이 또 폴더라면, 그 폴더가 다시 자기 안의 항목들에게 물어요. 이렇게 나무 끝까지 저절로 내려가요. 깊이를 세는 코드도, 일종이다도 없어요.
  • 쓰는 쪽은 묻지 않아요. '문서'의 <크기>()도, '사진'의 <크기>()도 똑같이 불러요. 상대가 파일 하나였더라도 똑같이 부르면 돼요.
  • 넣을 수 있는 것도 약속으로. <넣기>가 [항목]인 '무엇'만 받으니, 실수로 숫자를 넣으면 그 자리에서 막혀요.

“나 자신을 다시 부른다”는 건 재귀 아닌가요?
맞아요. 앞의 <크기재기>도, 뒤의 [폴더]의 <크기>도 재귀로 나무를 내려가요. 차이는 누가 재귀를 하느냐예요. 앞에서는 바깥 함수가 파일인지 폴더인지 따지며 재귀하고, 컴포지트에서는 폴더가 자기 안의 항목들에게 물을 뿐이에요. 재귀가 객체들 사이에 자연스럽게 나뉘어 들어가서, 바깥에서는 보이지 않아요.

새 종류를 더해 보면

“압축 파일”이 생겼다고 해 볼게요. 크기는 원래 크기의 절반으로 쳐요.

  • 앞의 방식: <크기재기>, <보여주기>처럼 할 일 함수마다 만약 ('항목'이 [압축파일]의 일종이다) 라면:을 넣어야 해요.
  • 컴포지트 패턴: [항목]을 따르는 [압축파일] 하나만 만들면 돼요. 폴더에 바로 넣을 수 있고, 폴더의 크기 계산에도 저절로 들어가요.

직접 해 보기

  1. 위 코드에 [압축파일]을 더해 보세요. 이름과 원래 용량을 받고, <크기>는 용량의 절반을 돌려줘요. <보여주기>는 · · 사진모음.zip (압축, 100KB)처럼 보여 주세요.
  2. 이번엔 새 할 일을 더해 보세요. [항목]에 [숫자]를 돌려주는 <파일수>가 있어야 한다 ()를 더하고, 파일은 1을, 폴더는 안의 항목들의 <파일수>를 더해 돌려주게 해 보세요.

언제 쓰면 좋을까요?

  • 데이터가 나무 모양일 때 (폴더와 파일, 메뉴와 하위 메뉴, 회사 조직도, 화면의 상자 안 상자)
  • 하나짜리와 묶음을 똑같이 다루고 싶을 때
  • 깊이가 얼마나 될지 모를 때
  • “파일이야, 폴더야?“를 묻는 만약이 여러 함수에 반복될 때

좋은 점과 아쉬운 점

좋은 점 아쉬운 점
쓰는 쪽이 잎과 묶음을 구별하지 않아도 돼요 약속이 너무 넓어지면, 잎에 어울리지 않는 일(파일에 “넣기”?)까지 약속에 들어가기 쉬워요
깊이와 상관없이 나무 전체를 한 번에 다뤄요 묶음 안에 특정 종류만 들어가게 막기는 어려워요
새 종류를 더해도 쓰는 쪽은 그대로예요 아주 큰 나무에서는 매번 끝까지 내려가는 계산이 느릴 수 있어요
재귀가 객체 안에 숨어서 코드가 단순해요

너무 이른 패턴은 독이에요.
폴더 안에 폴더가 들어갈 일이 없고 깊이가 늘 한 단계라면, 목록 하나를 도는 것으로 충분해요. 묶음 안에 묶음이 들어가기 시작할 때가 컴포지트를 쓸 때예요.

다른 패턴과의 관계

  • 데코레이터 패턴과 닮았어요. 둘 다 같은 약속을 따르는 객체를 품어요. 데코레이터는 하나를 감싸서 기능을 더하고, 컴포지트는 여럿을 담아서 일을 나눠 맡겨요.
  • 반복자 패턴을 함께 쓰면 좋아요. 나무의 모든 항목을 하나씩 차례로 꺼내 보고 싶을 때, 그 순서를 반복자가 맡을 수 있어요.
  • 커맨드 패턴으로 “묶음 명령”을 만들 수 있어요. 명령 여러 개를 담은 묶음 명령도 명령 약속을 따르게 하면, 한 번에 실행하고 한 번에 되돌릴 수 있어요. 바로 컴포지트 모양이에요.
놀이터
결과
코드를 작성하고 '실행' 버튼을 눌러보세요.