당신의 단일 책임 원칙은 아름다운가?

당신의 단일 책임 원칙은 아름다운가?

· #oop#design-patterns#SOLID

원칙이란 일관되게 지켜야 하는 기본적인 규칙이나 법칙을 말한다. SOLID 원칙은 객체지향 프로그래밍을 하는 개발자라면 당연히 지켜야 하는 규칙이다. 이 명제는 한동안 나를 지배했었다. 하지만 비용이라는 벽을 만나며, 규칙을 깨는 범법자가 될 수밖에 없는 상황들을 마주하게 되었다.

현재 개발에 드는 시간과 미래의 유지보수 비용. 어느 쪽에 무게를 둘 것인가에 따라 원칙은 지켜지기도, 깨지기도 하였다. 이 글은 그중에서도 단일 책임 원칙에 대해 나의 경험과 생각을 풀어가며, 그 저울질 끝에 내린 결론에 대한 이야기다.

“책임”은 누가 정하는가

SOLID 원칙 중 나를 가장 오랫동안 괴롭혀온 것은 단일 책임 원칙(Single Responsibility Principle)이다. 클래스는 하나의 책임만 가져야 한다. 모듈이 변경되는 이유는 하나여야 한다. 유지보수성을 위해… 그래, 대략 무엇을 말하는지는 알겠다. 컨트롤러는 요청을 받고, 서비스는 비즈니스 로직을 처리하고. 너무 당연한 말이다.

웹 개발에서 범용적으로 쓰이는 레이어나 정해진 디자인 패턴 같은 코드들은, 오랜 시간 쌓여온 관례를 통해 판단할 수 있는 근거가 있다. 그래서 단일 책임 역시 설명이 되는 듯했다.

하지만 관례가 닿지 않는 곳에서는 논쟁이 일어난다. 특히 단일 책임 원칙은 개발 커뮤니티에서도 유독 의견이 갈린다. “책임”이라는 단어를 해석하는 기준이 사람마다 다르기 때문이다.

나 역시 그 논쟁 앞에 서야 했다. 어느 날, 내가 이해하는 단일 책임 원칙을 명쾌하게 설명해야 하는 순간이 왔다. 내 코드에 대한 확신은 있었다. 하지만 “왜 이것이 하나의 책임인가”를 증명할 수 없었다. 나는 단일 책임 원칙을 이해하는 척 하고 있었을 뿐이다.

내다 버린 의문은 부메랑처럼 돌아온다

주니어 시절, Java를 배우며 SOLID 원칙을 자연스럽게 알게 되었다. 당시 스택오버플로우에서 이 원칙을 두고 의견이 대립하는 글을 읽었고, “필요에 따라 적용하면 된다”는 누군가의 댓글에 안도감을 느끼며 빠르게 타협을 내렸다.

어떤 단어의 줄임말인지, 각 원칙의 이름과 설명, 면접에서 물어볼까 봐 달달 외워둔 대략적인 예시. 그 이상은 큰 관심사가 아니었다. 부끄럽게도.

몇 년이 흐르고, 우매함의 봉우리에서 절망의 계곡으로 곤두박질쳤을 때 SOLID 원칙에 대한 궁금증은 부메랑처럼 돌아왔다. 마치 이제야 진실의 단편을 맛볼 준비가 되었다는 듯이.

더닝-크루거 효과

블로그와 유튜브에서 알려주는 정보로는 갈증을 해결할 수 없었다. 개방 폐쇄부터 의존성 역전까지, 나머지 원칙들은 회사 프로젝트와 개인 프로젝트를 진행하며 차츰 원리를 깨달아갔지만(반쯤은 착각이다), 단일 책임 원칙만큼은 진정으로 이해할 수 없었기 때문이다. 유튜브에는 로버트 마틴이 직접 설명하는 영상도 있었지만, 영어를 못하는 나는 느린 번역과 오역을 견디지 못하고 포기했다.

결국 로버트 C. 마틴의 논문까지 거슬러 올라갔다. 구글 번역기를 돌려가며 한 문장씩 읽어 내려갔고, 몇 년간 무지했던 자신과의 싸움 끝에 — 한 번 더 쓴맛을 맛보았다. 논문 어디에도 단일 책임(Single Responsibility)이라는 원칙이 없었기 때문이다.

논문의 “Principles of Object Oriented Class Design” 섹션에서 다루는 원칙은 OCP(Open Closed Principle), LSP(Liskov Substitution Principle), DIP(Dependency Inversion Principle), ISP(Interface Segregation Principle) 뿐이었다. 내가 제일 궁금했던 부분이 나를 농락하기라도 하듯 보이질 않으니, 미칠 지경이었다.

유레카는 미묘하게 찾아온다

어린 시절, 두발자전거를 처음 탔을 때의 기억이 있다. 몇 번이고 넘어지고, 아무리 발버둥 쳐도 그날은 끝내 타지 못했다. 그런데 며칠 뒤, 아무 기대 없이 다시 올라탔을 때 마법처럼 페달이 돌아갔다. 레미니센스 효과라고 한다. 학습 직후보다 시간이 지난 뒤에 오히려 수행이 향상되는 현상이다.

한동안 내 뇌는 단일 책임 원칙에 대해 찾아보는 행위 자체를 거부했다. 자포자기였던 것 같다.

하지만 그 논문은 예상치 못한 방향으로 자극을 주었다. 발번역된 한글 속에서도 로버트 마틴의 철학이 드러났고, 나는 어느새 그의 다른 저작과 배경을 추적하고 있었다. 그가 상당한 순수주의자라는 것, 그리고 그를 비판하는 측의 글들도 눈에 들어왔다. 물론 당시 SOLID의 S에도 머리를 싸매고 있던 나에게 그걸 판가름할 지혜 따윈 없었다.

다만 하나의 의문은 점점 선명해졌다. 내가 SRP를 어느 정도 이해하고 있는 거라면, 이 원칙은 너무 과한 것 아닌가. 그 불안감 위에, 단일 책임 원칙을 완벽하게 설명했다는 글, 반대로 그 한계를 지적하는 글, 자신만의 철학으로 재해석하는 글이 겹겹이 쌓였다. 다양한 시선을 접하면서 나 역시, 지금이라면 내 생각을 녹여낼 수 있을 것 같다.

내가 이해한 단일 책임 원칙

내 안의 “단일 책임 원칙을 준수하라”는 지침은, 사실 대부분의 코드에 이미 녹아 있다고 생각한다. 한 예를 들자면 그것은 아키텍처이다.

웹 개발을 하다 보면 자연스럽게 “아키텍처”라는 것을 적용하게 된다. 레이어드 아키텍처이든, 클린 아키텍처이든, 프론트엔드라면 FSD나 아토믹 디자인 패턴이든.

백엔드에서 흔히 접할 수 있는 것은 레이어드 아키텍처일 것이다. 이 경우 프레젠테이션 레이어, 비즈니스 레이어, 퍼시스턴스 레이어를 담당하는 클래스들이 자연스럽게 등장한다.

graph TD subgraph "Presentation Layer" C["Controller"] end subgraph "Business Layer" S["Service"] end subgraph "Persistence Layer" R["Repository"] end C --> S --> R style C fill:none,stroke:#3498db,stroke-width:2px style S fill:none,stroke:#2ecc71,stroke-width:2px style R fill:none,stroke:#e74c3c,stroke-width:2px

각 레이어는 특정 도메인과 밀접한 관계를 맺으면서도, 본질적으로는 자신이 수행해야 할 역할만을 완수한다. 컨트롤러는 요청을 받고 응답을 내보내며, 서비스는 비즈니스 로직을 처리하고, 레포지토리는 데이터를 저장하고 조회한다. 각 역할에 맞게 내부 코드를 조율하기 때문에 일정한 패턴이 존재하고, 그렇기에 이런 클래스들에 “책임”을 묻는다면 대부분의 개발자는 이견 없이 동의할 것이다.

하지만, 레이어드 아키텍처는 웹 개발에서 코드의 관심사를 분리했을 뿐 각 레이어 안에서 책임을 어떻게 나눌 것인가에 대한 답을 주지는 않는다. 더 정교한 책임 분리를 고민하다 보면 자연스럽게, 개발자들은 클린 아키텍처에 발을 담그게 된다.

클린 아키텍처와 단일 책임 원칙의 정의

2017년, 로버트 마틴은 클린 아키텍처에서 단일 책임 원칙을 다음과 같이 정의했다. (기존의 원칙에서 조금 더 명시적으로 바뀌었다)

A class should have only one reason to change. 클래스는 변경되어야 하는 이유가 하나여야 한다.

A module should be responsible to one, and only one, actor. 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.

여기서 모듈이란 가장 단순하게는 하나의 소스 파일, 넓게는 함수나 클래스처럼 응집된 코드 단위를 의미한다. 그리고 액터란 시스템 내부의 객체가 아니라, 현실 세계에서 코드의 변경을 요구하는 사람 혹은 집단 — 이해관계자를 의미한다.

클린 아키텍처에서 드는 대표적인 예시가 이것이다.

class Employee {
  calculatePay(): number { /* 급여 계산 */ }
  reportHours(): string { /* 근무시간 보고 */ }
  save(): void { /* 데이터베이스 저장 */ }
}
graph TD CFO["CFO (재무)"] COO["COO (운영)"] CTO["CTO (기술)"] E["Employee"] CFO -->|"calculatePay()"| E COO -->|"reportHours()"| E CTO -->|"save()"| E style CFO fill:none,stroke:#e74c3c,stroke-width:2px style COO fill:none,stroke:#3498db,stroke-width:2px style CTO fill:none,stroke:#2ecc71,stroke-width:2px

CFO는 급여 계산 방식의 변경을 요구하고, COO는 근무시간 보고 형식의 변경을 요구하며, CTO는 데이터 저장 방식의 변경을 요구한다. 세 명의 액터가 하나의 클래스에 변경을 요구하고 있으니 단일 책임 원칙을 위반한다. 비즈니스 요구사항을 만들어내는 이해관계자가 곧 액터이고, 하나의 모듈은 하나의 액터에 대해서만 책임져야 한다고 한다.

따라서 위 클래스는 각 액터에 대응하는 클래스로 분리될 수 있다.

class PayCalculator {
  calculatePay(): number { /* 급여 계산 */ }
}

class HourReporter {
  reportHours(): string { /* 근무시간 보고 */ }
}

class EmployeeRepository {
  save(): void { /* 데이터베이스 저장 */ }
}
graph TD CFO["CFO (재무)"] COO["COO (운영)"] CTO["CTO (기술)"] PS["PayCalculator"] HR["HourReporter"] ER["EmployeeRepository"] CFO --> PS COO --> HR CTO --> ER style PS fill:none,stroke:#e74c3c,stroke-width:2px style HR fill:none,stroke:#3498db,stroke-width:2px style ER fill:none,stroke:#2ecc71,stroke-width:2px

절대적인 경험의 차이

“뭐야, 로버트 마틴이 단일 책임 원칙에 대해 이렇게 잘 정의해놨잖아. 뭐가 문제야. 이걸 이해하고 받아들이면 되지.” — 내가 스스로에게 타협을 내리며 이젠 진짜 납득했다고 되새김질 했던 말이다. 하지만 어째서 이것조차도 받아들여지기 쉽지 않은 것인가.

내가 일하는 환경을 대입해보자. 유지보수는 거의 할 일 없는 납품의 사이클을 돌리는 환경이다. 클린 아키텍처에서 정의된 대로 액터를 먼저 따져보자면 발주처의 클라이언트와 프로젝트 리더, PM 정도가 있을 수 있겠다.

예제에서 save를 다루는 부분은, 내가 해석하기로는 엔티티가 데이터를 직접 저장하는 Active Record 패턴에서 Data Mapper 패턴으로 분리한 것이다. 별도의 Repository 클래스가 퍼시스턴스를 담당하도록 책임을 빼낸 셈이다. Java Spring 진영에서 JPA를 사용하면 Data Mapper 패턴이 기본이고, 내가 사용하던 NestJS + TypeORM 환경도 마찬가지였기에 save의 변경을 요구하는 액터 CTO의 경우는 나에게 크게 와닿지 않는 예였다.

그렇다면 남은 주체는 고객이다. 운영처, 기술지원처 등 여러 분류가 있겠지만, 내가 인지하는 변경의 주체는 그저 “클라이언트”라는 하나의 덩어리였다. 그들은 소프트웨어의 품질을 확인하고 변경을 요구하지만, CFO와 COO처럼 서로 다른 관심사를 가진 액터로 나뉘지 않았다. 배웠던 개념을 써먹을 만한 상황은 내게 오지 않았다.

인정하고 싶지 않지만, 문제는 나에게 있었다. 책을 보고, 인터넷의 여러 정보를 접하고, 프로젝트를 진행하며 능력치를 차곡차곡 쌓으면 노력한 만큼 실력이 늘어난다고 생각했다. 하지만 레벨업과 각성은 다른 문제였다. 트래픽이 많은 소프트웨어에 대해 오랫동안 유지보수를 했던 경험. 또는 규모가 크고 이해관계자들이 많은 프로젝트에 참여한 경험. 이런 경험이 없는 나에겐 “모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.”는 말이 진정으로 와닿을 수 없었다. - 나는 아직 감동을 받아본 적이 없다.

그럼에도 나아가는 법

그렇다면 액터라는 기준이 와닿지 않는 나는 단일 책임 원칙을 어떻게 적용해야 하는가. 나는 SRP가 궁극적으로 지향하는 두 가지 성질에 집중하기로 했다. 느슨한 결합(Loose Coupling)높은 응집도(High Cohesion)이다.

결합도란 모듈 간의 의존 정도를 말한다. 한 모듈을 변경했을 때 다른 모듈까지 함께 수정해야 한다면 결합도가 높은 것이다. 응집도란 모듈 내부의 요소들이 하나의 목적을 위해 얼마나 긴밀하게 협력하는가를 말한다. 이 두 기준은 “액터”와 달리 코드를 들여다보는 것만으로 판단할 수 있었다.

앞서 봤던 Employee 예제를 이 렌즈로 다시 들여다보자.

classDiagram class Employee { +calculatePay() +reportHours() +save() } Employee --> PayRules : calculatePay Employee --> HourFormat : reportHours Employee --> Database : save style Employee fill:none,stroke:#e74c3c,stroke-width:2px

클린 아키텍처에서는 CFO, COO, CTO라는 액터를 기준으로 이 클래스가 SRP를 위반한다고 설명했다. 하지만 액터를 모르더라도, 결합도와 응집도만으로 같은 결론에 도달할 수 있다. calculatePay는 급여 계산 규칙에, reportHours는 보고 형식에, save는 데이터베이스에 각각 의존한다. 세 메서드가 바라보는 의존성이 서로 겹치지 않는다. 하나의 목적을 위해 협력하고 있다고 보기 어렵다 — 응집도가 낮다. 동시에 데이터베이스 스키마가 변경되면, 급여 계산과는 무관한 이 클래스를 열어야 한다 — 결합도가 높다.

응집도를 기준으로 분리하면 이렇게 된다.

classDiagram class PayCalculator { +calculatePay() } class HourReporter { +reportHours() } class EmployeeRepository { +save() } PayCalculator --> PayRules HourReporter --> HourFormat EmployeeRepository --> Database style PayCalculator fill:none,stroke:#2ecc71,stroke-width:2px style HourReporter fill:none,stroke:#3498db,stroke-width:2px style EmployeeRepository fill:none,stroke:#9b59b6,stroke-width:2px

각 클래스는 자신이 가진 의존성만으로 하나의 역할을 완수한다. 데이터베이스 스키마가 바뀌어도 PayCalculator와 HourReporter는 건드릴 필요가 없다. 결합도는 낮아지고, 각 클래스의 응집도는 높아졌다.

사실 이 중 save의 분리는 우리에게 이미 익숙한 것이다. 엔티티가 직접 데이터를 저장하는 대신, 별도의 Repository가 퍼시스턴스를 담당하는 Data Mapper 패턴. 앞서 말했듯 JPA나 TypeORM을 사용하면 자연스럽게 따라오는 구조다. 우리는 이미 결합도와 응집도에 근거한 분리를 하고 있었던 셈이다.

응집도를 판단하는 소소한 팁이 하나 있다. 클래스의 import 문을 살펴보는 것이다. 우리가 만드는 클래스는 대부분 이미 결론이 난 범주에 속한다. 컨트롤러인지, 서비스인지, 레포지토리인지 — 레이어와 관례가 책임의 윤곽을 잡아준다. 서비스 레이어에서 조립하는 모델들도 마찬가지다. DTO, 엔티티, VO 같은 것들은 자신이 표현해야 할 데이터의 범위가 이미 정해져 있다. 그 윤곽 안에서, 이 클래스가 참조하고 있는 모듈들이 자신의 책임과 어울리는지를 살펴보면 된다. 서비스 레이어의 클래스가 메일 라이브러리와 PDF 엔진을 동시에 import하고 있다면, 그것은 책임을 분리할 수 있다는 신호일지 모른다.

그래서, 아름다운가?

글의 시작에서 나는 이렇게 물었다 — “책임”은 누가 정하는가. 긴 여정을 돌아왔지만, 나는 아직 그 답을 모른다.

그럼에도 이 원칙 덕분에 나는 고민을 멈추지 않았다. 탄력을 받아 나아가기도 했고, 벽에 부딪혀 멈추기도 했다. 이렇게 글을 쓰는 이유도 내 생각을 정리하다 보면 정답에 가까워지지 않을까 하는 기대가 있었다. 도움이 된 것은 사실이다. 다만 경험을 뒤집을 수는 없었다.

오늘날 개발자에게 SOLID는 짧은 단어 안에 많은 의미를 품고 있다. 필요에 따라 적용하면 객체지향을 이해하고 있다는 달콤한 착각에 빠지게 하는 OCP. “정사각형은 직사각형이다”라는 직관을 뒤집으며 계약의 의미를 깨닫게 해주는 LSP. 거대한 인터페이스를 클라이언트가 실제로 쓰는 만큼만 나누라는 ISP. 인터페이스 하나로 구현체를 갈아끼울 수 있다는 사실에 감동했던 DIP. 이 원칙들은 적용의 근거가 비교적 명확하다.

하지만 단일 책임 원칙은 다르다. 책에 나온 정의를 들이밀어도, 나는 이 의미를 진정으로 이해하지 못한다. 내 경험이 배척당하는 느낌. 마치 내가 알려고 노력하지 않은 것처럼, 끝까지 나에게 울림을 주지 않는 원칙.

그런 점에서 나에게 단일 책임 원칙은 그다지 — 아름답지 않다.

끝까지 읽어주셔서 감사합니다

다른 글도 둘러보세요

목록으로 돌아가기