SOLID의 다섯 원칙 중 이름이 가장 자주 오해받는 것이 첫 글자 S다. 단일 책임 원칙(Single Responsibility Principle). 이 이름을 처음 들은 사람은 열에 아홉 “함수는 한 가지 일만 해야 한다”는 뜻으로 읽는다. 마틴은 이 장의 상당 부분을 그 오해를 걷어내는 데 쓴다. 함수를 작게 쪼개 한 가지 일만 하게 만드는 것은 좋은 습관이지만, 그것은 SRP가 아니다. SRP가 말하는 “책임”은 함수의 크기가 아니라 변경을 요청하는 사람에 관한 것이다.
이 차이는 사소한 말장난이 아니다. “한 가지 일”로 SRP를 이해한 팀과 “하나의 액터에 대한 책임”으로 이해한 팀은, 똑같은 급여 시스템을 놓고 완전히 다른 지점에 경계선을 긋는다. 그리고 잘못 그은 경계는 몇 달 뒤 우발적 중복과 병합 충돌이라는 청구서로 돌아온다.
책임의 단위는 함수가 아니라 액터다
SRP의 정확한 진술은 이렇다 — 하나의 모듈은 오직 하나의 액터에 대해서만 책임을 져야 한다. 여기서 액터란 그 코드가 특정 방식으로 동작하기를 원하는, 변경을 요청하는 사용자와 이해관계자의 집단이다. “회계팀”, “인사팀”, “데이터베이스 관리자” 같은 역할이 곧 액터다. 모듈이 변경되는 이유는 언제나 그 액터가 무언가를 바꿔 달라고 요구하기 때문이다. 그래서 SRP는 다르게 말하면 이렇게 된다 — 하나의 모듈은 변경의 이유가 오직 하나여야 한다. 그 “이유” 하나가 곧 액터 하나다.
왜 함수의 크기가 아니라 액터가 기준이어야 하는가. 함수를 한 가지 일만 하도록 잘게 쪼개는 것은 그 함수 내부의 정돈일 뿐, 서로 다른 액터의 요구가 한 모듈 안에서 얽히는 문제를 전혀 건드리지 못하기 때문이다. 다섯 개의 작고 단정한 함수가 한 클래스에 모여 있어도, 그 다섯이 세 액터를 섬긴다면 그 클래스는 SRP를 위반한다. 반대로 제법 큰 함수 하나가 한 액터만을 위한 것이라면 SRP는 지켜지고 있다. 기준은 언제나 “이 코드를 바꿔 달라고 요구할 사람이 몇 무리인가”다.
판단 기준: 어떤 모듈이 SRP를 지키는지 물으려면 “이 코드의 변경을 요청할 사람이 누구누구인가”를 세어라. 서로 다른 부서·역할이 둘 이상 나오면 그 모듈은 이미 갈라야 할 후보다. 함정: “이 함수가 한 가지 일만 하는가”로 SRP를 판정하면 방향을 잘못 잡은 것이다 — 그것은 함수 추출의 지침이지 모듈 경계의 지침이 아니다. 작은 함수들의 단정함에 속아 여러 액터가 한 지붕 아래 뒤섞인 진짜 문제를 놓치기 쉽다.
급여 클래스에 세 액터가 동거한다
마틴이 드는 예시는 어느 급여 애플리케이션의 Employee 클래스다. 이 클래스에는 세 개의 메서드가 있다.
calculatePay()— 급여를 계산한다. 이 계산 규칙을 정하는 것은 회계팀이고, 그들은 CFO에게 보고한다.reportHours()— 근무 시간을 집계해 보고서를 만든다. 이 보고 형식을 정하는 것은 인사팀이고, 그들은 COO에게 보고한다.save()— 직원 데이터를 데이터베이스에 저장한다. 이 스키마와 저장 방식을 정하는 것은 **데이터베이스 관리자(DBA)**이고, 그들은 CTO에게 보고한다.
세 메서드가 한 클래스에 있다는 것은, 서로 다른 세 명의 임원에게 보고하는 세 부서의 요구가 한 소스 파일 안에 동거한다는 뜻이다. 회계팀이 급여 계산을 바꿔 달라고 하면 그 변경은 Employee.java에 가해지고, 그 파일은 인사팀의 reportHours와 DBA의 save도 함께 담고 있다. 세 액터의 운명이 하나의 파일로 묶여 버린 것이다.
이것이 왜 위험한지는 추상적으로는 잘 와닿지 않는다. 마틴의 예시가 날카로운 이유는, 이 동거가 어떻게 실제 버그와 실제 충돌로 터지는지를 두 개의 구체적 장면으로 보여주기 때문이다.
우발적 중복 — 공유된 코드가 서로 다른 이유를 배신한다
첫 번째 장면은 우발적 중복이다. calculatePay()(회계)와 reportHours()(인사)는 우연히도 둘 다 “정규 근무 시간을 계산하는” 로직을 필요로 한다. 부지런한 개발자는 중복을 싫어하므로 이 로직을 regularHours()라는 공유 메서드로 뽑아 둘이 함께 쓰게 만든다. DRY 원칙에 충실한, 겉보기엔 나무랄 데 없는 선택이다.
그런데 여기에 함정이 있다. 회계팀이 어느 날 급여 계산에서 정규 시간을 산정하는 방식을 바꿔 달라고 요구한다. 개발자는 regularHours()를 수정해 회계팀의 요구를 만족시킨다. 급여 계산은 이제 정확하다. 문제는 reportHours()도 같은 regularHours()를 쓰고 있었다는 것이다 — 아무도 건드리지 않았는데 인사팀의 근무 시간 보고서 숫자가 슬며시 틀어진다. 회계팀의 변경이 인사팀의 산출물을 조용히 오염시킨 것이다. 발견은 대개 몇 주 뒤, 잘못된 보고서가 이미 임원에게 올라간 다음에야 이뤄진다.
마틴은 이것을 우발적 중복(accidental duplication) 이라 부른다. 두 메서드가 공유한 regularHours()는 진짜 중복이 아니었다. 회계의 정규 시간과 인사의 정규 시간은 지금 우연히 계산식이 같을 뿐, 서로 다른 액터의 통제를 받으므로 언젠가는 서로 다른 이유로 서로 다르게 바뀔 운명이었다. 그것을 하나로 묶은 순간, 한 액터의 변경이 다른 액터에게 새어 나가는 통로가 열린 것이다.
판단 기준: 중복을 제거하기 전에 “이 두 코드는 같은 액터의 통제를 받는가”를 물어라. 같은 액터라면 통합이 옳다. 서로 다른 액터의 코드가 지금 우연히 똑같이 생겼을 뿐이라면, 그 중복은 남겨 두는 편이 안전하다. 함정: DRY를 무조건적 선으로 신봉해 “똑같이 생긴 코드는 무조건 합친다”는 태도가 우발적 중복을 만든다. 코드가 같은 모양인가가 아니라 같은 이유로 바뀌는가가 통합의 기준이다.
병합 충돌 — 한 파일이 여러 팀의 전쟁터가 된다
두 번째 장면은 병합 충돌이다. 세 액터의 코드가 한 파일에 있으니, 세 팀이 서로 다른 이유로 같은 파일을 동시에 건드릴 확률이 높아진다. DBA 팀이 save()의 저장 방식을 바꾸느라 Employee.java를 수정하는 바로 그 주에, 인사팀의 요구로 다른 개발자가 reportHours()를 고치느라 같은 파일을 수정한다. 두 변경이 합쳐지는 순간 병합 충돌이 난다.
병합 충돌은 단순한 번거로움이 아니다. 그것은 서로 아무 상관 없어야 할 두 액터 — DBA와 인사팀 — 의 작업이 한 파일을 매개로 강제로 충돌하게 된 결과다. 충돌을 푸는 개발자는 회계·인사·DBA 세 영역의 맥락을 동시에 이해한 채 어느 쪽 변경도 깨뜨리지 않게 손으로 병합해야 한다. 한 사람이 세 부서의 의도를 대신 조정하는 이 자리에서 실수가 나면, 그 실수는 세 부서 중 어디로든 번질 수 있다. 파일 하나를 여럿이 나눠 쓴다는 사소해 보이는 사실이, 서로 독립적이어야 할 팀들을 한 병목에 밀어 넣는다.
판단 기준: 한 소스 파일이 서로 다른 팀·역할에 의해 자주 수정된다면 — 커밋 이력에 여러 부서의 이유가 뒤섞여 있다면 — 그 파일은 이미 SRP를 위반하고 있다는 관측 가능한 신호다. 함정: 병합 충돌을 도구나 프로세스의 문제로만 여기고 브랜치 전략으로 덮으려 하면, 진짜 원인인 “한 모듈에 여러 액터가 산다”는 구조를 방치하게 된다. 충돌의 빈발은 대개 버전 관리의 문제가 아니라 경계 설계의 문제다.
데이터와 기능을 갈라 액터별로 나눈다
해법의 방향은 분명하다. 세 액터의 코드를 한 클래스에서 떼어 내 각자의 클래스로 보내는 것이다. 아래 스텝은 세 액터가 동거하던 원본에서 출발해, 우발적 중복이 어떻게 터지는지를 짚고, 데이터와 기능을 갈라 액터별로 분리한 뒤, 마지막으로 파사드로 다시 하나의 입구를 세우는 과정을 따라간다.
public class Employee { private String name; private int[] timeCards; private int hourlyRate; // calculatePay: 회계팀(→ CFO)이 규칙을 정한다 public long calculatePay() { int regular = regularHours(); // ← 공유 메서드 return (long) regular * hourlyRate; } // reportHours: 인사팀(→ COO)이 형식을 정한다 public String reportHours() { int regular = regularHours(); // ← 같은 공유 메서드를 쓴다 return name + ": " + regular + "h"; } // save: DBA(→ CTO)가 스키마를 정한다 public void save() { // INSERT INTO employees ... } // 회계와 인사가 우연히 공유하게 된 계산 — 우발적 중복의 씨앗 private int regularHours() { int sum = 0; for (int t : timeCards) sum += Math.min(t, 8); return sum; }}// 세 메서드가 세 임원에게 보고하는 세 부서의 요구를 한 파일에 담고 있다.// 변경의 이유가 셋 — SRP 위반이다.
무엇이 달라졌나. 원본에서는 회계팀의 정규 시간 규칙 변경이 regularHours()라는 공유 통로를 타고 인사팀의 보고서로 새어 나갔다. 분리 후에는 그 통로가 물리적으로 끊겼다 — PayCalculator의 계산을 아무리 바꿔도 HourReporter는 자기 몫의 계산을 따로 갖고 있어 흔들리지 않는다. 병합 충돌도 마찬가지다. 세 팀이 세 파일에서 일하니, 서로 다른 이유의 변경이 한 파일에서 부딪칠 일이 사라졌다. 경계를 액터를 따라 그었더니, 한 액터의 변경이 다른 액터에게 번지던 파급이 그 액터 안에 갇혔다.
판단 기준: SRP 위반을 풀 때 가장 흔한 첫수는 데이터와 기능을 분리하는 것이다 — 여러 액터가 공유하는 것이 실은 ‘데이터’뿐이고 ‘로직’은 액터마다 다르다면, 데이터를 순수 구조체로 빼고 기능을 액터별 클래스로 나눠라. 함정: 클래스를 셋으로 쪼갰다는 사실만으로 안심하지 말 것. PayCalculator가 여전히 HourReporter의 계산 결과를 참조하도록 짜면, 파일만 셋일 뿐 우발적 중복의 통로는 그대로 살아 있다. 분리의 목표는 파일 수가 아니라 변경 통로의 차단이다.
파사드는 편의를 위한 것이지 책임을 되섞는 자리가 아니다
세 클래스로 나누면 그것들을 매번 따로 생성하고 조립해야 하는 번거로움이 생긴다. 마틴이 제시하는 완충 장치가 파사드다. EmployeeFacade는 세 클래스를 품고 호출을 위임할 뿐, 실제 계산이나 저장 로직을 스스로 갖지 않는다. 바깥에서 보기엔 예전처럼 하나의 Employee를 다루는 듯한 편의를 주면서, 내부의 진짜 책임은 액터별로 갈라진 상태를 유지한다.
여기서 지켜야 할 선이 있다. 파사드가 얇아야 한다는 것이다. 만약 편의를 이유로 파사드에 계산 로직 일부를 슬쩍 얹기 시작하면, 파사드 자신이 다시 여러 액터의 코드를 품는 새로운 Employee가 되어 버린다. 우리가 방금 끊어 낸 동거를 파사드라는 이름으로 되살리는 셈이다. 파사드의 역할은 조립을 숨기는 것이지, 흩어진 책임을 다시 한 몸으로 뭉치는 것이 아니다.
판단 기준: 파사드에 코드를 넣기 전에 “이 코드가 특정 액터에 속하는 로직인가”를 물어라. 그렇다면 그것은 파사드가 아니라 해당 액터의 클래스로 가야 한다. 파사드에는 오직 위임과 조립만 남긴다. 함정: 파사드를 “이것저것 편하게 몰아넣는 곳”으로 쓰기 시작하면, 그것은 SRP를 푼 자리가 아니라 SRP 위반을 재발시키는 자리가 된다.
이 원칙이 컴포넌트와 아키텍처로 확장된다
마틴이 SRP를 SOLID의 첫머리에 두는 이유는, 이 원칙이 단지 클래스 하나의 문제가 아니라 시스템 전체를 관통하는 패턴의 씨앗이기 때문이다. “같은 이유로 바뀌는 것은 모으고, 다른 이유로 바뀌는 것은 나눈다”는 이 사고는 클래스 수준을 넘어 컴포넌트 수준(13장의 공통 폐쇄 원칙 CCP)과 아키텍처 경계 수준(17장의 경계 긋기)에서 똑같은 모양으로 되풀이된다. 지금 우리가 세 액터를 세 클래스로 가른 그 판단은, 나중에 업무 규칙을 UI·DB와 가르는 큰 경계선에서 그대로 확대 적용된다.
그래서 SRP는 “작은 함수를 쓰라”는 코딩 습관이 아니라, 변경의 이유를 따라 시스템을 분할하라는 아키텍처의 첫 문장이다. 변경의 이유 — 즉 액터 — 를 정확히 식별하는 눈이 있어야, 어디에 선을 그을지가 보인다.
정리하면 이렇다.
- SRP의 “책임”은 함수가 하는 일의 개수가 아니라, 그 코드의 변경을 요구하는 액터(사용자·이해관계자 집단)를 가리킨다. 모듈은 오직 하나의 액터에 대해서만 책임져야 한다.
- 여러 액터의 코드가 한 모듈에 동거하면 두 가지가 터진다 — 공유 코드를 통해 한 액터의 변경이 다른 액터로 새는 우발적 중복, 그리고 서로 다른 팀이 한 파일에서 부딪치는 병합 충돌.
- 흔한 해법은 데이터를 순수 구조체로 떼어 내고 기능을 액터별 클래스로 나눈 뒤, 편의를 위해 얇은 파사드로 다시 묶는 것이다.
- 통합의 기준은 “같은 모양인가”가 아니라 “같은 이유(같은 액터)로 바뀌는가”다. 우연히 같은 코드를 성급히 합치면 우발적 중복을 만든다.
다음장으로 8장