모든 글

거대한 서비스 클래스를 책임 단위로 나누는 법

개념 지도, 책임의 흐름, 테스트 경계, 구체적인 리팩터링 대안을 통해 비대해진 서비스 클래스를 나누는 방법을 설명한다.

  • refactoring
  • architecture
  • object-oriented-design

출처 및 AI 안내: 이 글은 제미니의 개발실무 유튜브를 기반으로 작성되었습니다. gpt-5.6-sol 모델을 사용해 생성·편집했습니다.

A 도메인의 모든 기능을 AService에 넣으면 처음에는 정돈된 것처럼 보인다. 이름만 보고 코드를 둘 곳을 고를 수 있고 구현도 빠르며 경계를 고민하지 않아도 된다. 그러나 생성, 조회, 수정, 삭제, 검증, 이벤트 발송과 주변의 모든 흐름을 한 클래스가 맡기 시작하면 문제가 드러난다. 이때 “책임에 따라 나누라”는 조언은 옳지만 그대로 실행하기에는 너무 추상적이다.

어떤 클래스 목록으로 나누면 된다는 보편적인 답은 없다. 적절한 경계는 제품, 제품 안의 개념, 기존 코드, 팀의 규칙에 따라 달라진다. 따라서 쓸 만한 리팩터링은 메서드 수나 클래스 크기에 관한 기계적인 규칙이 아니라 전체 흐름에서 얻은 근거로 시작해야 한다.

메서드를 옮기기 전에 개념을 그려라

먼저 지금까지 도메인이라고 부른 이름부터 의심해 보자. ‘커머스’는 하나의 응집된 책임이 아니라 주문, 결제, 배송을 함께 부른 말일 수 있다. ‘주문’조차 여러 단계와 서로 다른 일을 품고 있을 수 있다.

생명주기를 개념 지도로 그려 본다. 주문이 생성되고 결제되며 완료된다. 이후 후처리가 이어진다. 주문이라는 개념은 어디에서 끝나는가? 배송은 주문의 일부인가, 아니면 주문 완료 후 자기 책임을 가진 배송 개념으로 일이 넘어가는가? 결제도 같은 질문을 던질 수 있다. 한 업무 흐름에서 가까이 등장한다는 이유만으로 두 개념이 자동으로 하나의 객체나 서비스가 되는 것은 아니다.

이 지도는 메서드를 옮길 이유를 만들어 준다. 파일을 짧게 만들려고 임의의 묶음을 추출하는 대신, 비즈니스 흐름에 이미 존재하는 책임을 찾게 된다. 이름과 경계는 나중에 코드를 읽는 사람에게 그 책임을 설명할 수 있어야 한다.

동시에 개념 지도는 하나의 가설이다. 전체 코드와 제품 맥락을 보지 않고 짧은 설명만으로 정확한 분리안을 처방할 수는 없다. 리뷰어가 작성자가 놓친 경계를 볼 수는 있지만, 그 피드백의 바탕이 된 개념과 흐름은 설명할 수 있어야 한다.

테스트 가능성과 설명 가능성으로 압력을 가하라

큰 클래스의 문제는 대개 작업 마찰로 나타난다. 두 가지를 물어보자.

  1. 이 서비스가 하는 일을 한 문장으로 분명하게 설명할 수 있는가?
  2. 관련 없는 협력 객체와 상태를 준비하지 않고 한 책임만 테스트할 수 있는가?

두 질문에 모두 답하기 어렵다면 단순히 줄 수가 많다는 것보다 의미 있는 신호다. 관련 없는 메서드가 많은 서비스는 테스트가 지나치게 많은 맥락을 알아야 한다. 한 흐름의 변경이 다른 흐름을 흔들 수 있고, 새 동료는 클래스 구조만 보고 도메인의 책임을 파악하기 어렵다.

서비스 계층 아래도 살펴봐야 한다. 배송 준비에만 쓰이는 메서드까지 모든 보조 함수가 완전한 Order 객체를 받는다고 해보자. 주문 객체가 관련 없는 행위를 두루 오가는 전역 운반자가 된 셈이다. 객체가 과한 책임을 갖고 있거나, 데이터를 받는 객체에 고유한 경계가 없다는 신호일 수 있다.

책임은 Service, Reader, Writer라는 이름의 클래스에만 있는 것이 아니다. 흐름 안에서 어떤 객체가 생기고 사라지는지, 객체 사이에 어떤 데이터가 넘어가는지, 각 객체의 역할을 알아볼 수 있는지 확인해야 한다. 서비스를 여러 개로 쪼개도 같은 거대 객체가 모든 곳을 돌아다니며 원래의 결합을 유지한다면 겉모양만 바뀐 것이다.

추상적인 피드백을 구체적인 대안으로 바꿔라

피드백이 계속 모호하다면 토론할 수 있는 형태로 만들자. 작게 리팩터링한 대안을 두세 개 만든다. 하나는 주문 진행과 후처리를 나눌 수 있다. 다른 하나는 결제와 배송 협력 객체를 추출할 수 있다. 또 다른 안은 서비스는 유지하되 보조 함수에 전달하는 데이터를 좁힐 수 있다.

모든 대안을 운영 코드로 채택할 필요는 없다. 테스트하기 쉬운 안은 무엇인지, 흐름을 가장 잘 보여주는 안은 무엇인지, 조정이 어색해지는 지점은 어디인지 비교하는 것이 목적이다. 코드는 ‘단일 책임’에 대한 서로 다른 해석을 주장하는 것보다 훨씬 나은 토론 재료다.

그 대안을 리뷰어에게 다시 가져가 원래 의견의 근거가 된 개념이나 팀 규칙을 묻는다. 리뷰어에게 직관적인 모델은 있었지만 아직 구체화하지 못했다는 사실이 드러날 수도 있다. 반대로 내 대안도 타당하고, 팀이 방어 가능한 두 스타일 중 하나를 선택해야 한다는 결론이 날 수도 있다.

대안을 만들 때 한 번에 전체 서비스를 재작성할 필요는 없다. 문제를 가장 잘 드러내는 흐름 하나를 골라 작은 실험으로 분리한다. 기존 테스트를 유지한 채 책임 하나를 옮기고, 생성자 의존성과 전달되는 데이터가 실제로 줄었는지 확인한다. 경계가 맞지 않으면 되돌리거나 다른 안과 비교할 수 있다. 완성된 아키텍처 그림보다 이런 작은 코드가 팀에게 더 구체적인 판단 재료를 준다.

구조를 바꾸기 전에 팀의 코드를 읽어라

기존 코드 역시 중요한 근거다. 경험 많은 동료가 비슷한 흐름을 어떻게 나눴는지, 반복되는 관례가 왜 생겼는지 읽어야 한다. 무조건 따라 하려는 것이 아니다. 새 방식을 제안하기 전에 이 팀의 설계 언어를 이해하려는 일이다.

새로 합류한 사람에게는 특히 중요하다. 먼저 서비스의 성질을 파악하고 유지보수하는 사람들의 이야기를 들으며 암묵적인 규칙이 있는지 확인하자. 그다음 팀이 함께 평가할 수 있는 작은 변경을 제안한다. 일반적인 객체지향 조언이 회사 시스템을 대신 운영해 주지는 않는다. 실제로 일하는 사람들이 지속해서 지킬 규칙에 합의해야 한다.

좋은 분리의 결과는 작은 파일 여러 개에 그치지 않는다. 테스트는 의미 있는 행위를 격리할 수 있고, 이름은 흐름을 보여준다. 객체는 필요한 데이터만 들고 명시적인 경계를 건넌다. 무엇보다 다음 사람이 하나의 거대한 클래스에서 전체 설계를 복원하지 않아도 책임을 읽고 작업을 이어갈 수 있다.

분리 뒤에도 모든 클래스가 같은 의존성을 들고 있다면 다시 살펴봐야 한다. 파일 수만 늘고 알아야 할 범위가 줄지 않았다면 경계를 옮긴 것이 아니라 복제한 셈이다. 생성자와 테스트 준비가 실제로 작아졌는지가 유용한 확인 기준이다.