티스토리 뷰

 

해당 글은 GoF의 행위 패턴(Behavioral Pattern)에 대해 다룹니다.

 

책임 연쇄 (Chain of Responsibility)

해당 패턴은 핸들러(Handler)가 자신이 요청을 처리할 수 없을 때, 다른 핸들러에게 요청을 계속 위임시켜 처리할 수 있을 때까지 연쇄적으로 책임을 전가시키는 것을 말합니다.

 

현실 세계의 패턴 예시

사용자의 요청과 비슷한 개념인 편지를 예로 들어보겠습니다. 여러분은 연인에게 편지를 보내고 싶습니다. 하지만 우체국에 직접 가서 보내는 대신, 다음과 같은 방법으로 써보기로 했습니다.

 

  • 친구에게 편지를 건네줍니다. 친구는 받은 편지를 읽고, 만약 그 친구가 편지를 전달할 수 있다면 이를 연인에게 직접 전달해줍니다. 하지만 친구가 편지를 직접 전달해줄 수 없다면,
  • 친구는 여러분의 엄마에게 편지를 넘겨줍니다. 엄마는 받은 편지를 읽고, 만약 엄마가 편지를 전달할 수 있다면 이를 직접 연인에게 전달해줍니다. 하지만 엄마가 편지를 직접 전달해줄 수 없다면,
  • 여러분의 엄마는 마지막으로 우체국 직원에게 편지를 넘겨줍니다. 우체국 직원은 최종적으로 그 편지를 정해진 절차에 따라 배송해 줍니다.

이처럼 여러분의 편지는 친구, 엄마, 우체국 직원이라는 책임 연쇄를 거쳐 최종적으로 연인에게 도착하게 될 것입니다, 해당 비유에서 여러분은 클라이언트 역할을 하고 친구와 엄마 그리고 우체국 직원은 핸들러 역할이겠죠, 이처럼 각 핸들러는 처리 가능 여부를 판단하며, 처리할 수 없는 경우 다음 핸들러로 넘겨주는 역할을 수행합니다.

 

이어서 해당 패턴을 구현한 간단한 코드 예시를 보도록 하겠습니다.

interface Handler<Request, Result = null | undefined> {
    handle(request: Request): Result;
}

// ... 클라이언트

interface LetterHandlerRequest {
    type: string,
    cost: number,
    target: string,
    content: string,
}

enum LetterHandlerResult {
    SUCCESSED = "successed",
    FAILED = "failed",
}

abstract class LetterHandler implements Handler<LetterHandlerRequest, LetterHandlerResult> {
    constructor(private parent?: LetterHandler) {};

    handle(request: LetterHandlerRequest): LetterHandlerResult {
        return this.parent?.handle(request) ?? LetterHandlerResult.FAILED;
    }
}

class FriendHandler extends LetterHandler {
    handle(request: LetterHandlerRequest): LetterHandlerResult {
        if (request.type == "부탁") {
            return LetterHandlerResult.SUCCESSED;
        }

        return super.handle(request);
    }
}

class MotherHandler extends LetterHandler {
    handle(request: LetterHandlerRequest): LetterHandlerResult {
        if (request.type != "편지") {
            return LetterHandlerResult.SUCCESSED;
        }

        return super.handle(request);
    }
}

class PostOfficer extends LetterHandler {
    handle(request: LetterHandlerRequest): LetterHandlerResult {
        if (request.cost < 5000) {
            return LetterHandlerResult.FAILED;
        }

        console.log(`"${request.type}"의 배송을 준비하는 중...`);
        console.log(`"${request.type}"이(가) 성공적으로 배송되었습니다.`);
    
        return LetterHandlerResult.SUCCESSED;
    }
}

const handler = new FriendHandler(new MotherHandler(new PostOfficer()));

// "편지"의 배송을 준비하는 중...
// "편지"이(가) 성공적으로 배송되었습니다.
const result = handler.handle({
    type: "편지",
    cost: 5000,
    target: "나의 사랑하는 플러터짱",
    content: "너를 사랑해, 아이시떼이루요!~!"
});

console.log(result); // successed

 

참고로 해당 코드에서는 책임을 전가할 핸들러의 정의는 생성자에서 수행하지만 실제 상황에서 더 유연한 특성을 가질 수 있도록 메서드로 따로 분리해야 할 수 있습니다.

 

커맨드 (Command)

해당 패턴은 클라이언트 코드와 서비스 로직 사이의 결합도를 낮추기 위해 별도의 추상적 개념인 커맨드(Command)를 통해 서비스 로직 코드를 제어하는 것을 말합니다.

 

즉, 별도의 객체를 사용하여 서비스 로직에 대한 여러 동작과 요청들을 캡슐화하는 것입니다.

 

현실 세계의 패턴 예시

여러분들이 더 쉽게 이해할 수 있도록, 현실 세계에 비유해서 설명해보겠습니다. 여러분 앞에 셰프가 있다고 상상해보세요. 여러분은 배가 고파서 음식을 먹고 싶어 합니다. 따라서 셰프를 통해 여러 음식을 만들어야 합니다.


첫 번째 방법은 여러분이 직접 만든 레시피를 셰프에게 하나하나 설명해주는 것입니다. 그러면 셰프는 여러분이 설명한 레시피에 따라 음식을 만들 것입니다.

두 번째 방법은 간단히 레시피를 셰프에게 건네는 것입니다. 작은 레시피 종이를 주면서요.

 

해당 예시를 들어보면 여기서 레시피 종이는 커맨드(Command) 역할이라는 것을 쉽게 알 수 있습니다, 또한 셰프는 서비스 로직 코드에 일부이며 바로 여러분들이 클라이언트 코드의 일부인 것입니다.

 

이어서 해당 패턴을 구현하는 간단한 코드 예시를 보도록 하겠습니다.

abstract class Chef {}
abstract class ChefCommand<T extends Chef> {
    constructor(public parent: T) {}

    abstract execute(): void;
}

class SuperChef extends Chef {
    cut(material: string, count: number): void {
        console.log(`"${material}"을(를) ${count}번 칼로 썰었습니다.`);
    }

    put(material: string, target: string): void {
        console.log(`"${material}"을(를) ${target}에 올렸습니다.`);
    }

    bake(material: string, time: string): void {
        console.log(`"${material}"을(를) ${time} 동안 구웠습니다.`);
    }
}

class OrdinaryHamburgerCommand extends ChefCommand<SuperChef> {
    execute(): void {
        this.parent.put("빵-1", "도마");
        this.parent.bake("패티", "5분");
        this.parent.put("패티", "빵-1 위");
        this.parent.put("치즈", "패티 위"); 
        this.parent.put("소스", "치즈 위");
        this.parent.cut("양배추", 10);
        this.parent.put("양배추", "소스 위");
        this.parent.put("베이컨", "양배추 위");
        this.parent.put("빵-2", "베이컨 위");
    }
}

const chef = new SuperChef();
const command = new OrdinaryHamburgerCommand(chef);

// "빵-1"을(를) 도마에 올렸습니다.
// "패티"을(를) 5분 동안 구웠습니다.
// "패티"을(를) 빵-1 위에 올렸습니다.
// "치즈"을(를) 패티 위에 올렸습니다.
// "소스"을(를) 치즈 위에 올렸습니다.
// "양배추"을(를) 10번 칼로 썰었습니다.
// "양배추"을(를) 소스 위에 올렸습니다.
// "베이컨"을(를) 양배추 위에 올렸습니다.
// "빵-2"을(를) 베이컨 위에 올렸습니다.
command.execute();

 

번외로 제가 여기서 강조하고 싶은 점은, 많은 패턴들을 최대한 많이 사용하려고 하기보다는 앞서 언급한 패턴에 최대한 근접하려고 노력하는 것이 더 났다는 것을 말하고 싶습니다.

 

해당 글을 읽어주셔서 감사합니다.

 

Happy coding in the another world!