하리Hari

9. 커맨드 패턴 (Command)

요청 자체를 객체로 캡슐화하여 전달하고 실행하는 커맨드 패턴을 알아봅니다.

한 줄 요약
“해야 할 일” 하나하나를 객체로 만드는 패턴이에요. 일이 객체가 되면 실행을 미루거나, 쌓아 두거나, 되돌리기를 붙이기 쉬워져요.

문제: 하는 법과 되돌리는 법이 따로 노는 코드

간단한 메모장을 만든다고 해 볼게요. 할 수 있는 일은 두 가지예요.

  • 쓰기: 끝에 글자를 붙여요.
  • 지우기: 끝에서 글자를 몇 개 지워요.

그리고 메모장이라면 되돌리기가 꼭 있어야 하죠. 가장 먼저 떠오르는 방법은 편집기가 할 일을 할 때마다 “무엇을 했는지”를 기록해 두고, 되돌릴 때 그 기록을 보고 거꾸로 하는 거예요.

[편집기]를 설계하자:
    '내용'을 [문자열]인 ""으로 정하자
    '기록'을 []로 정하여 숨기자

    <쓰기>를 만들자 ([문자열]인 '글자'):
        '나'의 '내용'을 ('나'의 '내용' + '글자')로 정하자
        '나'의 '기록'에 {"종류": "쓰기", "글자": '글자'}를 추가하자

    <지우기>를 만들자 ([숫자]인 '개수'):
        '길이'를 '나'의 '내용'의 '길이'로 정하자
        '지운글자'를 '나'의 '내용'의 <자르기>('길이' - '개수' + 1, '길이')로 정하자
        '나'의 '내용'을 '나'의 '내용'의 <자르기>(1, '길이' - '개수')로 정하자
        '나'의 '기록'에 {"종류": "지우기", "글자": '지운글자'}를 추가하자

    (참고: 되돌리기는 모든 동작을 거꾸로 하는 법을 한곳에서 다 알아야 해요)
    <되돌리기>를 만들자 ():
        '마지막'을 '나'의 '기록' 뒤에서 꺼낸 값으로 정하자
        ('마지막'의 "종류")에 따라 나누자:
            "쓰기" 인 경우:
                '길이'를 '나'의 '내용'의 '길이'로 정하자
                '나'의 '내용'을 '나'의 '내용'의 <자르기>(1, '길이' - ('마지막'의 "글자")의 '길이')로 정하자
            "지우기" 인 경우:
                '나'의 '내용'을 ('나'의 '내용' + ('마지막'의 "글자"))로 정하자

'메모'를 새로운 [편집기]()로 정하자
'메모'의 <쓰기>("안녕")을 실행하자
'메모'의 <쓰기>("하세요")를 실행하자
'메모'의 <지우기>(2)를 실행하자
틀"지금: {'메모'의 '내용'}"을 출력하자
'메모'의 <되돌리기>()를 실행하자
틀"되돌린 뒤: {'메모'의 '내용'}"을 출력하자
'메모'의 <되돌리기>()를 실행하자
틀"한 번 더: {'메모'의 '내용'}"을 출력하자

실행 결과
지금: 안녕하
되돌린 뒤: 안녕하세요
한 번 더: 안녕

되돌리기가 잘 돼요. 그런데 할 수 있는 일이 늘어나면 아파져요.

  • 하는 법과 되돌리는 법이 떨어져 있어요. “지우기”를 하는 법은 <지우기>에, 지우기를 되돌리는 법은 <되돌리기>의 따라 나누자 안에 있어요. 한 가지 일의 이야기가 두 군데로 나뉘어 있어요.
  • 새 동작마다 두 곳을 고쳐야 해요. “붙여넣기”나 “모두 대문자로”를 넣으려면 새 메서드를 만들고, <되돌리기>에도 경우를 하나 더 넣어야 해요. 되돌리기 쪽을 빠뜨리면 맞는 갈래가 없어서 조용히 아무 일도 안 일어나요.
  • 기록이 글자 이름표로 되어 있어요. "종류"와 "쓰기" 같은 이름을 한 글자만 틀려도 에러 없이 어긋나요.
  • 편집기가 너무 많은 일을 해요. 글자를 다루는 일, 기록하는 일, 되돌리는 일이 모두 [편집기] 한 클래스에 들어 있어요. 기록이 비었을 때 되돌리기를 누르면 어떻게 할지 같은 일도 여기에 붙어요.

해결책: 할 일을 객체로 만들기

커맨드 패턴은 “쓰기”와 “지우기”를 메서드가 아니라 객체로 만들어요.

  1. 편집기는 글자를 붙이고 지우는 기본 기능만 가져요.
  2. 명령이라면 지킬 약속, 곧 규정을 만들어요. 명령은 <실행>과 <취소>를 할 줄 알아야 해요.
  3. 동작마다 명령 클래스를 하나씩 만들어요. 하는 법과 되돌리는 법이 한 클래스 안에 나란히 있고, 되돌릴 때 필요한 것(지운 글자)은 명령이 직접 기억해요.
  4. 기록관리자는 명령을 실행하고 쌓아 두기만 해요. 되돌릴 때는 맨 위의 명령을 꺼내 <취소>를 부르면 끝이에요. 그 명령이 무엇인지는 몰라도 돼요.

비유: 식당의 주문서

손님이 주문하면 직원은 주문서에 적어서 주방에 건네요. 주문서 한 장 한 장이 “해야 할 일”이에요. 주문서가 있으니 순서대로 쌓아 둘 수도 있고, 손님이 “아까 그거 취소해 주세요”라고 하면 그 주문서를 찾아 취소할 수도 있어요. 직원은 요리하는 법을 몰라도 돼요. 주문서를 받아서 넘기기만 하면 되니까요. 주문서가 바로 “커맨드”예요.

누가 무엇을 하나요?

역할 이 예제에서 하는 일
명령 약속 [명령] (규정) 모든 명령이 할 줄 알아야 하는 일(실행, 취소)을 정해요
구체적인 명령 [쓰기명령], [지우기명령] 하는 법과 되돌리는 법을 함께 알고, 필요한 것을 기억해요
받는 쪽 [편집기] 실제로 글자를 다뤄요. 명령이 이 객체에게 일을 시켜요
부르는 쪽 [기록관리자] 명령을 실행하고 쌓아 두었다가, 되돌릴 때 꺼내요

하리로 만들기

같은 메모장을 커맨드 패턴으로 다시 만들어 볼게요. 마지막에는 더 되돌릴 것이 없을 때도 눌러 봤어요.

(참고: 1단계 — 편집기는 글자를 붙이고 지우는 기본 기능만 가져요)
[편집기]를 설계하자:
    '내용'을 [문자열]인 ""으로 정하자

    <붙이기>를 만들자 ([문자열]인 '글자'):
        '나'의 '내용'을 ('나'의 '내용' + '글자')로 정하자

    [문자열]을 돌려주는 <끝에서지우기>를 만들자 ([숫자]인 '개수'):
        '길이'를 '나'의 '내용'의 '길이'로 정하자
        '지운글자'를 '나'의 '내용'의 <자르기>('길이' - '개수' + 1, '길이')로 정하자
        '나'의 '내용'을 '나'의 '내용'의 <자르기>(1, '길이' - '개수')로 정하자
        '지운글자'를 돌려주자

(참고: 2단계 — 명령이라면 하는 법과 되돌리는 법을 함께 알아요)
[명령]을 규정하자:
    <실행>이 있어야 한다 ()
    <취소>가 있어야 한다 ()

(참고: 3단계 — 동작마다 명령 하나. 되돌릴 때 필요한 것은 명령이 기억해요)
[명령]을 따르는 [쓰기명령]을 설계하자:
    '편집기'를 [편집기]인 비어있음으로 정하여 숨기자
    '글자'를 [문자열]인 ""으로 정하여 숨기자

    처음 만들어질 때 ([편집기]인 '편집기', [문자열]인 '글자') 다음과 같이 하자:
        '나'의 '편집기'를 '편집기'로 정하자
        '나'의 '글자'를 '글자'로 정하자

    <실행>을 만들자 ():
        '나'의 '편집기'의 <붙이기>('나'의 '글자')를 실행하자

    <취소>를 만들자 ():
        '나'의 '편집기'의 <끝에서지우기>('나'의 '글자'의 '길이')를 실행하자

[명령]을 따르는 [지우기명령]을 설계하자:
    '편집기'를 [편집기]인 비어있음으로 정하여 숨기자
    '개수'를 [숫자]인 0으로 정하여 숨기자
    '지운글자'를 [문자열]인 ""으로 정하여 숨기자

    처음 만들어질 때 ([편집기]인 '편집기', [숫자]인 '개수') 다음과 같이 하자:
        '나'의 '편집기'를 '편집기'로 정하자
        '나'의 '개수'를 '개수'로 정하자

    <실행>을 만들자 ():
        '나'의 '지운글자'를 '나'의 '편집기'의 <끝에서지우기>('나'의 '개수')로 정하자

    <취소>를 만들자 ():
        '나'의 '편집기'의 <붙이기>('나'의 '지운글자')를 실행하자

(참고: 4단계 — 기록관리자는 명령을 실행하고 쌓아 두기만 해요. 무슨 명령인지는 몰라요)
[기록관리자]를 설계하자:
    '지난명령들'을 []로 정하여 숨기자

    <하기>를 만들자 ([명령]인 '명령'):
        '명령'의 <실행>()을 실행하자
        '나'의 '지난명령들'에 '명령'을 추가하자

    <되돌리기>를 만들자 ():
        만약 ('나'의 '지난명령들'의 '길이'가 0과 같다) 라면:
            "되돌릴 것이 없어요."를 출력하자
            돌려주자
        '마지막'을 '나'의 '지난명령들' 뒤에서 꺼낸 값으로 정하자
        '마지막'의 <취소>()를 실행하자

'메모'를 새로운 [편집기]()로 정하자
'기록'을 새로운 [기록관리자]()로 정하자

'기록'의 <하기>(새로운 [쓰기명령]('메모', "안녕"))을 실행하자
'기록'의 <하기>(새로운 [쓰기명령]('메모', "하세요"))를 실행하자
'기록'의 <하기>(새로운 [지우기명령]('메모', 2))를 실행하자
틀"지금: {'메모'의 '내용'}"을 출력하자
'기록'의 <되돌리기>()를 실행하자
틀"되돌린 뒤: {'메모'의 '내용'}"을 출력하자
'기록'의 <되돌리기>()를 실행하자
틀"한 번 더: {'메모'의 '내용'}"을 출력하자
'기록'의 <되돌리기>()를 실행하자
'기록'의 <되돌리기>()를 실행하자

실행 결과
지금: 안녕하
되돌린 뒤: 안녕하세요
한 번 더: 안녕
되돌릴 것이 없어요.

앞의 세 줄은 리팩터링 전과 같아요. 세 번째 되돌리기에서 “안녕”까지 지워져 메모가 비고, 네 번째에는 되돌릴 것이 없다고 알려 줘요.

단계별로 다시 보기

  • 1단계 — 편집기는 기본 기능만. [편집기]는 기록도, 되돌리기도 몰라요. 글자를 붙이고, 끝에서 지운 글자를 돌려줄 뿐이에요.
  • 2단계 — 약속 만들기. [명령]은 “할 줄 알고, 되돌릴 줄도 알면 명령”이라는 약속이에요. 새 명령을 만들면서 <취소>를 빼먹으면 하리가 바로 알려 줘요. 앞의 방식처럼 되돌리기가 조용히 안 되는 일이 없어요.
  • 3단계 — 명령마다 자기 이야기를 한곳에. [지우기명령]은 <실행>할 때 지운 글자를 '지운글자'에 기억해 두고, <취소>할 때 그 글자를 다시 붙여요. 하는 법과 되돌리는 법이 한 클래스 안에 나란히 있어요.
  • 4단계 — 부르는 쪽은 무엇인지 몰라도 돼요. [기록관리자]는 명령을 실행하고 목록에 쌓을 뿐이에요. 되돌릴 때는 맨 뒤의 명령을 꺼내 <취소>를 불러요. 쓰기인지 지우기인지는 신경 쓰지 않아요.

왜 맨 뒤부터 되돌리나요?
마지막에 한 일이 가장 먼저 되돌려져야 하기 때문이에요. “안녕” → “하세요” → 2글자 지우기 순서로 했다면, 되돌릴 때는 지우기 → “하세요” → “안녕” 순서여야 메모가 올바르게 돌아와요. 쌓아 둔 접시를 위에서부터 꺼내는 것과 같아요.

새 명령을 더해 보면

“모두 지우기”를 넣는다고 해 볼게요.

  • 앞의 방식: [편집기]에 <모두지우기>를 만들고, <되돌리기>의 따라 나누자에도 "모두지우기" 인 경우:를 넣어야 해요.
  • 커맨드 패턴: [명령]을 따르는 [모두지우기명령] 하나만 만들면 돼요. <실행>에서 지금 내용을 기억해 두고 비우고, <취소>에서 기억한 내용을 되붙이면 끝이에요. [편집기]도 [기록관리자]도 그대로예요.

직접 해 보기

  1. 위 코드에 [모두지우기명령]을 더해 보세요. [편집기]의 <끝에서지우기>에 지금 글자 수를 넘기면 모두 지울 수 있어요.
  2. 더 해 보고 싶다면 [기록관리자]에 다시하기를 붙여 보세요. <되돌리기>에서 꺼낸 명령을 '되돌린명령들' 목록에 쌓아 두고, <다시하기>에서 거기서 꺼내 <실행>하면 돼요. 명령이 객체라서 가능한 일이에요.

언제 쓰면 좋을까요?

  • 되돌리기(그리고 다시하기)가 필요할 때
  • 할 일을 바로 하지 않고 쌓아 두었다가 나중에, 또는 순서대로 실행하고 싶을 때 (작업 대기열, 예약 실행)
  • 버튼, 단축키, 메뉴처럼 여러 곳에서 같은 일을 시킬 때. 같은 명령 객체를 나눠 쓰면 돼요
  • 했던 일을 기록하거나 다시 재생하고 싶을 때 (매크로)

좋은 점과 아쉬운 점

좋은 점 아쉬운 점
하는 법과 되돌리는 법이 한 클래스에 모여 있어요 동작 하나마다 클래스가 하나씩 생겨요
새 동작을 더해도 부르는 쪽(기록관리자)을 고치지 않아요 명령이 받는 쪽(편집기)을 알아야 해서 연결이 하나 더 생겨요
되돌리기, 다시하기, 쌓아 두기, 나중에 실행하기가 쉬워요 되돌리기를 정확하게 만들려면 “무엇을 기억해 둘지” 잘 정해야 해요
규정 덕분에 <취소>를 빼먹으면 바로 알려 줘요

너무 이른 패턴은 독이에요.
되돌리기도, 쌓아 두기도 필요 없고 버튼 하나가 한 가지 일만 한다면, 그 일을 바로 부르는 게 가장 읽기 쉬워요. “되돌리기를 붙여 주세요”라는 말이 나올 때가 커맨드 패턴으로 옮길 때예요.

다른 패턴과의 관계

  • 전략 패턴과 모양이 비슷해요. 둘 다 일을 객체로 떼어 내요. 전략은 어떻게 할지(방법)를 갈아 끼우는 데 집중하고, 커맨드는 무엇을 할지(한 번의 일)를 객체로 만들어 쌓고, 미루고, 되돌리는 데 집중해요.
  • 옵저버 패턴과 함께 쓸 수 있어요. 일이 생겼을 때 실행할 명령들을 목록에 구독시켜 두면, 소식이 올 때 모두 실행돼요.
  • 데코레이터 패턴으로 명령을 감쌀 수 있어요. 명령을 실행하기 전후에 기록을 남기는 껍질을 씌우면, 명령 클래스를 고치지 않고도 모든 명령의 기록을 남길 수 있어요.
놀이터
결과
코드를 작성하고 '실행' 버튼을 눌러보세요.