그래픽 편집기에서 사용자가 어느 버튼 위에 마우스를 얹고 F1을 눌렀다고 하자. 도움말을 띄워야 하는데, 무엇에 대한 도움말인가. 그 버튼에 전용 도움말이 있으면 그걸, 없으면 버튼이 속한 대화상자의 도움말을, 그것도 없으면 애플리케이션 전체의 일반 도움말을 보여줘야 한다. 즉 도움말 요청은 가장 구체적인 곳에서 시작해, 처리할 수 있는 놈을 만날 때까지 바깥으로 번져 나간다.

문제는 F1을 누른 그 버튼이 “누가 이 요청을 처리할지”를 미리 알 수 없다는 것이다. 자기에게 도움말이 있는지, 없다면 다음으로 누구에게 넘겨야 하는지 — 이걸 버튼이 다 알고 있으면, 버튼은 대화상자와 애플리케이션의 도움말 구조에 통째로 묶인다. 요청을 보내는 쪽처리하는 쪽이 단단히 붙어 버린다. 책임 연쇄(Chain of Responsibility)는 이 둘 사이에 사슬을 놓아, 발신자가 수신자를 콕 집지 않고 요청을 사슬에 흘려보내게 한다.

무엇이 문제인가 — 동기

같은 증상을 승인 절차로 옮기면 더 또렷하다. 회사에서 구매를 올리면 금액에 따라 결재선이 다르다. 10만 원 이하는 팀장이, 100만 원 이하는 본부장이, 1000만 원 이하는 임원이, 그 이상은 대표가 승인한다. 이걸 요청을 올리는 쪽에서 짜면 흔히 이렇게 된다.

if (amount <= 100_000)      teamLead.approve(request);
else if (amount <= 1_000_000) director.approve(request);
else if (amount <= 10_000_000) executive.approve(request);
else                        ceo.approve(request);

세 가지가 동시에 아프다.

첫째, 요청을 올리는 코드가 결재선 전체를 알고 있다. 승인자가 누구누구인지, 각자의 한도가 얼마인지, 순서가 어떻게 되는지를 이 if 사슬이 전부 쥐고 있다. 요청자는 “이거 승인해 주세요”만 말하면 될 텐데, 조직도를 통째로 외우고 있는 셈이다. 발신자와 수신자가 이렇게 붙으면, 결재선이 바뀔 때마다 요청을 올리는 코드가 흔들린다.

둘째, 한도 조정·단계 추가가 이 분기를 다시 연다. 팀장 한도를 20만 원으로 올리거나, 본부장과 임원 사이에 ‘실장’ 단계를 끼우려면 이 if/else를 헤집어야 한다. 승인 정책의 변경과 요청 제출이 같은 자리에서 충돌한다. 개방-폐쇄 원칙이 정확히 여기서 무너진다.

셋째, 처리자를 런타임에 바꿀 수 없다. 분기 조건이 코드에 박혀 있으니, 프로모션 기간에 임시 결재선을 넣거나 특정 부서만 다른 순서로 태우려면 또 조건을 타야 한다. “누가 처리하는가”가 값이 아니라 하드코딩된 흐름이라, 순서를 조립하듯 갈아 끼우지 못한다.

판단 기준: “요청 하나를 처리할 후보가 여럿이고, 그중 누가 처리할지는 요청의 내용이나 런타임 상태에 따라 정해진다”가 보이면 책임 연쇄를 의심한다. 함정: 처리자가 늘 하나로 고정돼 있고 앞으로도 그렇다면 그냥 직접 호출이 정답이다 — 후보가 하나뿐인 자리에 사슬을 놓으면 간접성만 는다.

패턴의 구조 — 누가 무엇을 하나

책임 연쇄는 배역이 단출하다. 핵심은 처리자들이 자신의 다음 처리자만 알고, 그 너머는 모른다는 데 있다.

Handler(처리자 인터페이스). “요청을 처리한다”는 계약과, 다음 처리자에 대한 참조를 가진다. Approver라면 handle(Request) 하나와 next 필드 하나. 이 next가 사슬을 잇는 고리다. 대개 요청을 받으면 “내가 처리할 수 있는가”를 먼저 묻고, 그렇지 않으면 next.handle(request)로 넘긴다 — 이 “처리하거나 넘긴다”의 이분법이 패턴의 심장이다.

ConcreteHandler(구체 처리자들). 각자 자기가 처리할 수 있는 조건과 처리 로직을 안다. TeamLead, Director, Executive… 각 처리자는 자기 판단만 한다. 옆 처리자가 무슨 조건으로 처리하는지, 사슬이 몇 개짜리인지 모른다. 자기 한도를 넘으면 그저 다음에게 넘길 뿐이다.

Client(발신자). 사슬의 첫 마디에게만 요청을 던진다. 누가 실제로 처리할지는 모른다 — 알 필요도 없다. chain.handle(request) 한 줄이면, 요청은 처리자를 만날 때까지 사슬을 타고 흐른다.

협력의 결정적 지점은 사슬을 누가 어떻게 엮느냐다. 처리자 각각은 자기 next만 알기에, 전체 순서(팀장→본부장→임원→대표)는 어딘가에서 조립돼야 한다. 대개 클라이언트나 별도의 설정 코드가 처리자들을 연결해 사슬을 만든다. 이 조립 지점이 책임 연쇄에서 가장 유연한 동시에 가장 취약한 자리다 — 순서를 바꾸거나 마디를 끼우기가 쉽지만, 사슬을 잘못 엮어 요청이 아무에게도 처리되지 않고 끝까지 흘러 나가면 조용히 실패한다.

판단 기준: 처리자들이 각자 자기 조건만 알고 다음으로 넘기기만 하면 결합이 잘 끊긴 것이다. 함정: 한 처리자가 “내 다음은 본부장”처럼 특정 후임을 이름으로 알고 있으면, 사슬이 처리자 코드에 박혀 순서를 바꿀 수 없다 — next는 조립 시점에 주입되는 참조여야지, 처리자가 아는 상수가 아니다.

코드로 — 패턴이 자라나는 과정

거대한 if 결재 분기에서 출발해, 처리자를 사슬로 뽑고, 각 마디가 “처리하거나 넘긴다”만 판단하게 만드는 과정을 단계로 따라간다.

Refactoring Step 거대한 if — 발신자가 결재선 전체를 안다
1/4
public class PurchaseService {    public void submit(Request request) {        long amount = request.getAmount();        // 요청을 올리는 이 코드가 승인자·한도·순서를 전부 쥐고 있다.        if (amount <= 100_000) {            teamLead.approve(request);        } else if (amount <= 1_000_000) {            director.approve(request);        } else if (amount <= 10_000_000) {            executive.approve(request);        } else {            ceo.approve(request);        }    }}// 한도를 바꾸거나 단계를 추가하면 이 분기를 다시 연다. 발신자와 결재선이// 한 덩어리로 묶여, 승인 정책의 변경이 요청 제출 코드를 흔든다.

첫 스텝의 if 사슬이 사라진 것이 아니라 처리자 객체들로 분산됐다는 점이 중요하다. “금액이 얼마 이하인가”라는 판단은 여전히 어딘가에 있어야 한다 — 다만 그 판단이 한 메서드의 분기에 뭉쳐 있던 것을, 각 처리자가 자기 몫만 아는 형태로 흩었다. 그 대신 발신자는 결재선을 몰라도 되고, 순서는 조립 지점에서 값처럼 다뤄진다.

판단 기준: 리팩터링 후 “새 승인 단계 추가 = 새 처리자 클래스 하나 + 조립 한 줄, 기존 코드 0줄 수정”이 성립하면 사슬이 제대로 앉은 것이다. 함정: 사슬 끝까지 갔는데 아무도 처리하지 않는 경우를 방치하면, 요청이 조용히 사라진다 — 최종 처리자를 두거나(위의 Ceo) 끝에서 명시적으로 실패를 알려야 한다.

결과 — 무엇을 얻고 무엇을 내주나

책임 연쇄가 사는 값은 발신-수신의 분리와 사슬의 유연성이다. 발신자는 사슬의 첫 마디만 알 뿐, 실제 처리자가 누구인지 모른다 — 요청을 보내는 쪽과 처리하는 쪽의 결합이 끊긴다. 처리자를 추가·제거·재정렬하는 일이 조립 지점의 몇 줄로 끝나고, 각 처리자는 자기 조건만 아는 작은 객체라 홀로 테스트된다. 무엇보다 사슬의 구성이 런타임의 값이 된다 — 부서마다 다른 결재선을 태우고, 설정 파일로 순서를 바꾸고, 요청 종류에 따라 다른 사슬에 흘려보낼 수 있다.

대가는 두 가지가 무겁다. 첫째, 처리가 보장되지 않는다. 요청이 사슬을 끝까지 흘러도 아무도 손들지 않으면 그대로 떨어져 나간다. 전략(Strategy)은 주입된 전략이 반드시 계산하지만, 책임 연쇄는 “처리자가 있을 수도, 없을 수도” 있다 — 이 미보장이 패턴의 본질적 성질이라 최종 처리자나 끝단 검사로 방어해야 한다. 둘째, 디버깅이 어렵다. “이 요청을 누가 처리했나”가 코드에 명시돼 있지 않고 런타임 사슬을 타고 결정되므로, 흐름을 눈으로 좇기 어렵다. 요청이 엉뚱한 곳에서 처리되거나 처리되지 않을 때, 어느 마디에서 어긋났는지 찾으려면 사슬을 통째로 훑어야 한다. if 한 곳에서 결재선을 다 읽던 편함은 사라진다.

운영비용 렌즈로 보면 순이득의 조건이 분명하다. 처리자 후보가 여럿이고 그 구성·순서가 자주 바뀌는 축이라면, 흩어진 처리자와 추적 비용의 대가보다 “발신자를 안 흔들고 사슬을 조립하는” 이득이 크다. 반대로 처리자가 하나로 굳어 있거나 순서가 불변이라면, 사슬은 간접성과 조용한 실패의 위험만 더하고 유연성은 놀고 있다.

판단 기준: 얻는 것(발신-수신 분리·사슬 재구성·처리자 독립 테스트)과 내주는 것(처리 미보장·추적 난이도·조립 복잡성)을 세어 순이득이 양수일 때만 사슬을 앉힌다. 함정: “요청은 무조건 처리된다”고 가정하고 끝단 방어를 빼먹으면, 사슬을 잘못 엮은 날 요청이 소리 없이 사라지고 아무도 눈치채지 못한다.

언제 쓰고, 언제 과용인가

책임 연쇄가 정확히 맞는 자리는 이렇다. 하나의 요청을 처리할 후보가 여럿이고, 누가 처리할지가 코드가 아니라 런타임 상태로 정해질 때. 도움말 컨텍스트(구체→일반으로 번지는 요청), 이벤트 버블링(가장 안쪽 위젯부터 바깥으로), 승인 결재선, 그리고 서블릿 필터·미들웨어처럼 요청이 여러 관문을 순차로 통과하는 구조. 자바 서블릿의 FilterChain이 교과서적 사례다 — 각 필터가 요청을 처리(인증·로깅·압축)하고 chain.doFilter()로 다음에게 넘긴다.

여기서 두 갈래의 변형을 구분해 둘 필요가 있다. 하나가 처리하면 멈추는 사슬(승인·도움말 — 첫 처리자가 끝냄)과, 모두가 각자 처리하고 통과시키는 사슬(로깅·필터 체인 — 모든 마디가 요청을 만짐)이다. 앞은 “처리하거나 넘긴다”의 배타적 선택이고, 뒤는 “처리하고 또 넘긴다”의 누적 통과다. 둘 다 같은 구조지만 handle의 계약이 다르다 — 어느 쪽인지 정하지 않고 짜면 사슬이 애매해진다.

과용의 냄새는 반대편에서 난다. 처리자가 늘 하나로 정해져 있는데 “확장성”을 위해 사슬을 판다면, 그건 직접 호출 한 줄이면 될 일에 간접성을 씌운 것이다. 또, 사슬을 조립하는 코드가 다시 거대한 if로 “이 요청은 어느 사슬에” 하고 고르고 있다면, 분기를 없앤 게 아니라 한 칸 옆으로 밀었을 뿐이다.

이웃 패턴과의 경계도 그어 두자. [커맨드]와는 요청을 다루는 방식이 다르다 — 커맨드는 요청을 객체로 캡슐화해 실행 취소·큐·로그를 가능케 하지만, 그 요청을 누가 처리할지는 명시적으로 안다. 책임 연쇄는 반대로 요청 자체는 단순하게 두되 누가 처리할지를 흐릿하게 만든다. 둘을 합쳐 커맨드 객체를 사슬에 흘려보내면, 캡슐화된 요청을 처리자 미상으로 넘기는 구조가 된다. [컴포지트]와는 뿌리가 닿는다 — 책임 연쇄의 next 참조는 흔히 컴포지트의 부모 참조와 겹친다. 도움말 요청이 위젯에서 부모 대화상자로, 다시 애플리케이션으로 번지는 흐름은, 컴포지트 트리를 타고 위로 올라가는 사슬이다. 즉 사슬을 선형으로 두지 않고 트리의 부모 방향으로 놓으면, 컴포지트 구조가 그대로 책임 연쇄의 뼈대가 된다.

판단 기준: 요청을 처리할 후보가 여럿이고 그 결정을 발신자에게서 떼어 내고 싶으면 책임 연쇄, 요청 자체를 저장·취소·재실행하고 싶으면 커맨드를 고른다. 함정: 처리 순서나 미보장이 중요한 자리에 사슬 대신 단순 리스트 순회를 쓰면, “처리하면 멈춘다/모두 통과한다”의 계약이 흐려져 요청이 두 번 처리되거나 누락된다 — “처리 후 멈추는가, 계속 넘기는가”를 먼저 못박아야 한다.

다음으로 커맨드