모든 글

거대한 서비스 클래스를 책임과 계층으로 분리하는 법

생성자 의존성과 import로 비대해진 서비스를 진단하고, 새로운 아키텍처 이름을 붙이기 전에 응집된 책임부터 분리합니다.

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

UserService라는 클래스에 생성자 의존성이 스무 개 있고 메서드가 백 개라면, 저는 그 클래스가 하나의 응집된 책임을 가진다고 믿기 어렵습니다. 사용자가 조금이라도 관련된 기능을 전부 받아들이면서 서로 다른 일이 쌓이는 장소가 된 경우가 많습니다.

생성자에 사용자 리포지토리뿐 아니라 결제와 쿠폰처럼 서로 다른 영역의 의존성이 함께 나타난다면 여러 책임이 한 서비스에 모였다는 신호입니다. 이름은 그대로인데 의미만 계속 넓어져서 UserService가 결국 “이 애플리케이션의 대부분”을 뜻하게 됩니다.

이미 책임이 너무 많다고 느껴진다면 분리하는 방향이 대체로 맞습니다. 어려운 일은 큰 파일 하나를 임의의 작은 파일 열 개로 바꾸지 않고, 실제로 코드를 낫게 만드는 경계를 찾는 것입니다.

메서드 전체보다 생성자를 먼저 보기

레거시 코드에서 생성자는 빠른 진단 도구입니다. 의존성 목록만 봐도 클래스가 어떤 영역을 조율하도록 요구받았는지 알 수 있습니다.

사용자 서비스가 사용자 리포지토리에 의존하는 것은 자연스럽습니다. 그런데 결제 리포지토리, 쿠폰 리포지토리와 서로 무관한 여러 클라이언트가 함께 나오면 이야기가 달라집니다. import 목록도 비슷한 신호를 줍니다. 모든 분기를 읽기 전에 이 클래스가 시스템 어디까지 손을 뻗는지 볼 수 있습니다.

먼저 같은 개념과 같은 변경 이유를 가진 의존성과 메서드를 묶습니다. 결제 관련 작업은 UserPaymentService가 될 수도 있고, 책임이 실제로 사용자에게 있지 않다면 그냥 PaymentService가 될 수도 있습니다. 쿠폰 작업은 쿠폰 중심 컴포넌트로 옮길 수 있습니다. 첫 이름은 임시여도 괜찮습니다. 응집도를 높일수록 기존 클래스의 생성자에서 의존성이 사라지는지가 더 중요한 신호입니다.

오래된 코드를 한 번에 전부 이해하고 다시 설계할 필요는 없습니다. 호출자와 데이터 의존성을 비교적 분명히 알 수 있는 책임 하나를 추출합니다. 남은 모습을 다시 봅니다. 생성자와 import가 줄어들면 다음 경계도 더 잘 보입니다.

메서드 위치만 옮겨서는 부족합니다. 추출한 모든 클래스가 여전히 모든 리포지토리에 의존한다면 같은 매듭을 여러 파일에 복제한 것입니다. 분리는 각 컴포넌트가 알아야 할 범위도 줄여야 합니다.

컨트롤러-서비스-리포지토리의 터널에서 나오기

두 번째 문제는 제가 “서비스 지옥”이라고 부르는 상태입니다. 컨트롤러, 서비스, 리포지토리만 가능한 모양이라고 생각해서 모든 동작을 Service로 끝나는 클래스에 넣습니다.

이 구조는 출발점으로 쓸 만한 관습입니다. 모든 책임을 설명하는 완전한 모델은 아닙니다. 큰 서비스를 응집된 영역으로 나눈 다음 남은 코드의 상대적인 위치를 봐야 합니다. 어떤 컴포넌트는 비즈니스 유즈케이스를 표현합니다. 어떤 것은 데이터를 읽거나 구현 세부사항을 처리합니다. 여러 유즈케이스를 합치거나 외부 인터페이스를 번역하는 것도 있습니다. 전부 서비스라고 부르면 차이가 사라집니다.

모든 책임을 서비스로만 풀면 서비스 사이의 위상과 계층 차이가 무시될 수 있습니다. 더 멋진 접미사를 찾는다고 해결되지는 않습니다. 클래스마다 다른 위상과 역할이 있다는 사실을 인정하고 팀이 설명할 수 있는 경계를 만들어야 합니다.

완성된 아키텍처 템플릿을 가져올 필요는 없습니다. 작은 구현 컴포넌트, 리더, 퍼사드나 코디네이터 하나로 충분할 수 있습니다. 어떤 단어를 썼는지보다 누가 비즈니스 흐름을 소유하고 누가 이를 지원하는지 코드에 드러나는지가 중요합니다.

스프링 어노테이션이 클래스 이름을 정하지 않는다

스프링에 @Service가 있다는 이유만으로 모든 클래스에 Service를 붙이는 방식을 좋아하지 않습니다. 어노테이션은 컴포넌트를 등록하는 데 도움을 주지만, 어노테이션이 붙은 클래스 이름을 모두 SomethingService로 만들라고 명령하지는 않습니다.

컨트롤러도 마찬가지입니다. 한 프로젝트에서는 팀과 논의한 뒤 라우트를 제공하는 클래스를 Router라고 이름 붙였습니다. 모든 팀에 그 이름을 권하는 것이 아닙니다. 프레임워크의 어휘가 설계 어휘까지 결정해야 한다는 가정을 깨 본 사례입니다.

물론 팀의 통일성은 지켜야 합니다. 혼자만의 이름 실험으로 주변 코드를 더 이해하기 어렵게 만들면 개선이 아닙니다. 프로젝트 안에서 이름이 무엇을 뜻하는지 합의하고 일관되게 사용해야 합니다. 관습은 작성자의 용어 지식을 보여주는 대신 읽는 사람이 책임을 예상하게 해야 합니다.

분리 뒤에 확인할 것

쓸모 있는 분리라면 눈에 보이는 변화가 생깁니다.

  • 기존 생성자에서 무관한 의존성이 줄어듭니다.
  • import가 하나의 개념 주변으로 모입니다.
  • 추출된 클래스의 변경 이유를 평범한 문장으로 설명할 수 있습니다.
  • 계층 관계가 이전보다 분명해집니다.
  • 이름이 프레임워크 관습을 반복하는 대신 실제 작업을 설명합니다.

첫 경계가 완벽하지 않을 수 있습니다. 레거시 시스템에서는 자연스러운 일입니다. 아직 이해하지 못한 코드에 완성된 그림을 강요하는 것보다 이해 가능한 추출 하나를 만들고 거기서 배우는 편이 낫습니다.

목표는 메서드 수만 낮추는 것이 아닙니다. 백 개의 메서드를 혼란스러운 클래스 열 개로 바꾸기는 쉽습니다. 각 코드 조각의 책임과 위치를 선명하게 만들어 다음 사람이 애플리케이션 전체를 다시 열지 않고도 변경할 곳을 판단하게 하는 것이 목표입니다.