PUBLISHED
비즈니스 로직은 어디에 있어야 할까
작성일: 2025.01.30

Nest.js에서 컨트롤러와 각각의 프로바이더는 분명한 책임을 갖는다. 하지만 막상 서버를 개발하다 보면, 이 책임들을 깊이 고민하지 않은 채 코드를 작성하게 되고, 그 결과 클래스들이 서로의 영역을 침범하는 상황이 반복된다. 나 역시 컨트롤러에 비즈니스 로직이 섞이거나, 서비스가 지나치게 많은 역할을 떠안는 코드를 수없이 작성해왔다.
처음에는 단순히 “레이어를 잘 나누지 못해서 그렇다”고 생각했다. 그래서 컨트롤러, 서비스, 레포지토리의 책임을 다시 정리해보고, 헬퍼나 유틸리티 클래스를 도입해 공통 로직을 분리하려 했다. 이 글은 그렇게 시작된 정리의 기록이다. 다만 정리해 나가다 보니, 문제는 레이어의 개수가 아니라 비즈니스 로직이 머무는 위치라는 쪽으로 생각이 이동하게 되었다.
레이어를 나눴는데도 서비스는 왜 계속 비대해질까
컨트롤러와 GraphQL 레졸버는 기술적으로는 다르지만, 역할만 놓고 보면 동일하다. 둘 다 외부 요청을 받아 내부로 전달하는 엔드포인트이며, 요청을 검증하고 서비스를 호출한 뒤 응답을 반환하는 것이 주된 책임이다. 이 둘을 묶어 엔드포인트 클래스라고 생각하면, 책임은 비교적 명확해진다.
문제는 서비스 클래스다. 엔드포인트에서 로직을 최대한 걷어내다 보면, 모든 판단과 규칙이 자연스럽게 서비스로 몰린다. 서비스는 레포지토리를 호출하고, 조건을 분기하고, 에러를 던지며, 결과를 가공한다. 처음에는 이 구조가 합리적으로 보인다. 서비스는 비즈니스 로직을 담당하는 계층이니까.
하지만 시간이 지나면 서비스는 빠르게 비대해진다. 비슷한 조건문이 반복되고, 하나의 메서드가 여러 개념을 동시에 다루기 시작한다. 이 문제를 해결하기 위해 흔히 선택하는 방법이 헬퍼 클래스다. 실제로 헬퍼와 유틸리티를 도입하면 서비스의 코드량은 줄어들고 가독성도 좋아진다.
class OrderService {
async completeOrder(orderId: string) {
const order = await this.orderRepository.findById(orderId);
if (order.status === 'CANCELLED') {
throw new Error('취소된 주문입니다.');
}
if (order.items.length === 0) {
throw new Error('주문 항목이 없습니다.');
}
if (!order.paymentCompleted) {
throw new Error('결제가 완료되지 않았습니다.');
}
order.status = 'COMPLETED';
await this.orderRepository.save(order);
}
}다만 여기서 미묘한 문제가 생긴다. 헬퍼가 늘어날수록, 정작 비즈니스 규칙의 주인이 누구인지가 흐려진다는 점이다. 어떤 규칙은 서비스에 있고, 어떤 규칙은 헬퍼에 있으며, 둘 사이의 경계도 점점 모호해진다. 이쯤 되면 “이 규칙은 왜 여기 있는가?”라는 질문에 명확히 답하기 어려워진다.
이 시점에서 깨달은 것은, 문제의 핵심이 헬퍼를 쓰느냐 마느냐가 아니라는 점이었다. 레이어를 나누고, 클래스를 더 쪼개도 해결되지 않는 이유는 비즈니스 로직이 여전히 절차적으로 흘러다니고 있기 때문이었다.
헬퍼를 넘어서, 도메인으로 책임을 되돌리다
이 고민의 끝에서 접하게 된 개념이 풍성한 도메인 모델(Rich Domain Model)이다. 이 접근 방식의 핵심은 간단하다. 비즈니스 규칙을 서비스나 헬퍼에 흩뿌리는 대신, 도메인 객체 자체가 자신의 규칙과 상태를 책임지도록 만드는 것이다.
기존의 구조에서는 서비스가 엔티티의 상태를 읽고 판단한 뒤 직접 변경했다. 반면 풍성한 도메인 모델에서는 “이 객체가 이런 상태 변화가 가능한가?”라는 질문에 대한 답을 객체 스스로 알고 있다. 서비스의 역할은 판단자가 아니라 조율자가 된다. 레포지토리에서 객체를 가져오고, 도메인 객체의 메서드를 호출한 뒤, 다시 저장하는 흐름만 담당한다.
이렇게 관점을 옮기면 여러 문제가 동시에 정리된다. 서비스 클래스는 자연스럽게 얇아지고, 헬퍼 클래스는 규칙의 주인이 아니라 보조 도구로 자리를 잡는다. 무엇보다 비즈니스 로직이 객체 내부에 응집되면서, 코드의 의미가 훨씬 또렷해진다. “이 주문이 완료될 수 있는가?”라는 질문에 대한 답이 서비스 코드 어딘가가 아니라, Order 객체 안에 존재하게 되는 것이다.
class Order {
complete() {
if (this.status === 'CANCELLED') {
throw new Error('취소된 주문입니다.');
}
if (this.items.length === 0) {
throw new Error('주문 항목이 없습니다.');
}
if (!this.paymentCompleted) {
throw new Error('결제가 완료되지 않았습니다.');
}
this.status = 'COMPLETED';
}
}class OrderService {
async completeOrder(orderId: string) {
const order = await this.orderRepository.findById(orderId);
order.complete();
await this.orderRepository.save(order);
}
}결국 이 글에서 하고 싶은 이야기는 단순하다. 컨트롤러, 서비스, 레포지토리라는 레이어를 나누는 것만으로는 충분하지 않다. 진짜 중요한 것은 비즈니스 로직이 어디에 있어야 하는지에 대한 선택이다. 헬퍼를 늘려 서비스를 가볍게 만드는 것보다, 도메인 객체를 더 책임감 있게 만드는 편이 장기적으로 훨씬 건강한 구조라고 느끼고 있다.
그래서 나는 책임을 이렇게 바라보게 되었다
풍성한 도메인 모델을 접하고 나서, 클래스의 책임을 나누는 기준이 이전과는 조금 달라졌다. 예전에는 “이 로직을 어디에 두면 서비스가 덜 복잡해질까”를 고민했다면, 이제는 “이 규칙의 주인은 누구인가”를 먼저 묻게 된다.
엔드포인트 클래스는 여전히 요청과 응답에만 집중한다. 서비스는 흐름을 조율하고 트랜잭션의 경계를 관리한다. 레포지토리는 영속성에 대한 책임만 진다. 그리고 그 사이에서 발생하는 비즈니스 규칙 자체는 가능한 한 도메인 객체 안으로 끌어들인다.
이 관점에서 헬퍼 클래스의 위치도 명확해졌다. 헬퍼는 결정을 내리지 않는다. 계산을 도와주거나, 복잡한 절차를 감싸는 역할만 한다. “될까, 안 될까”를 판단하는 책임은 도메인 객체가 가진다.
class DiscountCalculator {
calculate(order: Order): number {
// 할인 계산만 담당
return order.totalPrice * 0.9;
}
}이렇게 기준을 바꾸고 나니, 서비스가 비대해질 때마다 “더 쪼개야 하나?”가 아니라 “이 로직은 객체의 책임이 아닌가?”를 먼저 생각하게 되었다. 완벽한 구조와는 거리가 멀지만, 적어도 코드가 흘러다니는 느낌은 훨씬 줄어들었다.
내가 정리한 Nest.js 클래스 책임 기준은 아래와 같다. 이 표는 정답이라기보다는, 지금의 내가 코드를 판단하는 기준에 가깝다. 시간이 지나면 또 달라질 수 있겠지만, 최소한 “이 로직이 왜 여기에 있는가”라는 질문에는 답할 수 있게 되었다.
