아티클/개발
Controller와 Service를 나누고 나서야 이해한 것들
오웬
회사 Node.js 서버를 처음 만들 때는 라우터 안에 비즈니스 로직을 전부 작성했다.
요청을 받고, 데이터를 검증하고, DB를 조회하고, 결과를 응답하는 코드가 한곳에 있었다. 처음에는 빠르고 직관적이었다. 파일 하나만 열면 처리 과정을 전부 볼 수 있으니, 굳이 나눌 필요도 느끼지 못했다.
그런데 기능이 늘어나면서 상황이 달라졌다. 파일은 계속 길어졌고, 작은 기능을 수정할 때도 어디까지 영향을 주는지 확인하는 데 시간이 걸렸다.
최근에 미뤄왔던 리팩토링을 하면서 Controller와 Service를 분리했다. 그제야 계층을 나누라는 말이 단순한 관행이 아니라, 변경과 테스트를 다루기 위한 기준이라는 걸 이해했다.
파일 크기보다 중요한 것은 역할의 차이
처음에는 파일이 길어져서 나눠야 한다고 생각했다. 하지만 더 중요한 문제는 서로 다른 역할이 한곳에 섞여 있다는 점이었다.
Controller는 HTTP 요청과 응답을 다룬다. 요청에서 필요한 값을 꺼내고, 입력 형식을 확인하고, 처리 결과를 상태 코드와 응답 데이터로 변환한다.
Service는 그 요청으로 수행해야 할 업무를 다룬다. 어떤 조건에서 처리를 허용할지, 어떤 순서로 데이터를 조회하고 변경할지 같은 규칙이 여기에 들어간다.
예를 들어 필수 입력값이 빠졌는지 확인하는 것과, 해당 요청을 업무 규칙상 처리해도 되는지 판단하는 것은 다른 책임이다.
이 기준으로 보면 분리의 목적도 명확해진다.
Controller는 외부 요청을 업무 처리에 필요한 입력으로 바꾸고, Service는 그 입력으로 실제 업무를 수행한다.
단순히 코드를 다른 파일로 옮기는 것이 아니라, 서로 다른 이유로 바뀌는 코드를 구분하는 일이었다.
HTTP 밖에서도 사용할 수 있는 로직
업무 로직이 req, res 같은 HTTP 객체에 의존하면, 다른 실행 환경에서 재사용하기가 불편해진다.
같은 기능을 실행하고 싶은데도 HTTP 요청 객체를 흉내 내거나, 필요한 부분을 복사해서 다시 작성하게 될 수 있다.
반면 Service가 필요한 값을 명시적으로 받아 처리 결과를 반환하면, 호출하는 쪽을 바꾸기 쉬워진다.
- Controller에서는 HTTP 요청을 받아 호출한다.
- cron 작업에서는 정해진 시간에 호출한다.
- 웹소켓에서는 이벤트를 받아 호출한다.
- 관리자용 CLI에서는 명령을 받아 호출한다.
실행을 시작하는 방식은 달라도, 같은 업무 규칙을 공유할 수 있는 것이다.
물론 파일 이름을 Service로 바꾼다고 재사용성이 생기는 것은 아니다. 중요한 건 Service가 HTTP 객체 대신 업무에 필요한 입력에 의존하도록 만드는 것이었다.
테스트할 범위를 작게 만들 수 있다
테스트 코드를 계속 미뤘던 이유도 리팩토링하면서 조금 더 분명해졌다.
업무 로직을 검증하고 싶은데 요청과 응답 처리까지 얽혀 있으니, 테스트를 어디서 시작해야 할지부터 막막했다.
Service를 분리하면 HTTP 요청을 만들지 않고도 업무 처리를 직접 호출해 검증할 수 있다. 정상적인 입력에서 어떤 결과가 나오는지, 특정 조건에서 어떤 오류가 발생해야 하는지를 나눠 확인하기 쉬워진다.
다만 Service라고 해서 자동으로 순수 함수가 되는 것은 아니다. DB를 조회하거나 외부 API를 호출한다면 여전히 외부 의존성이 있다. 단위 테스트에서는 이런 의존성을 테스트용 객체로 대체하고, 실제 연동이 정상적인지는 통합 테스트로 별도 확인해야 한다.
Controller도 HTTP 서버를 띄우지 않고 단위 테스트할 수 있다. 따라서 분리하지 않으면 테스트가 불가능하다는 뜻은 아니다.
내가 느낀 이점은 테스트하고 싶은 업무 규칙과, 그 규칙을 실행하기 위한 주변 준비를 구분하기 쉬워졌다는 것이다.
트랜잭션도 업무 단위로 바라보기
하나의 업무가 여러 DB 변경으로 이루어지는 경우가 있다. 그중에는 일부만 반영되면 안 되고, 전체가 함께 성공하거나 실패해야 하는 작업도 있다.
이런 작업에서는 트랜잭션의 경계를 어디에 둘지 중요해진다.
이번처럼 Service가 하나의 업무 흐름을 조율하는 구조라면, 어떤 DB 변경을 하나로 묶어야 하는지도 Service에서 판단하기 자연스럽다. Controller는 요청을 전달하고, Service는 업무를 완료하기 위해 필요한 작업을 조율하는 식이다.
다만 모든 DB 작업을 무조건 하나의 트랜잭션으로 묶어야 하는 것은 아니다. 업무 규칙상 함께 반영되어야 하는 변경이 무엇인지 먼저 정해야 한다.
또한 코드를 Service로 옮겼다고 원자성이 보장되지는 않는다. 실제로 해당 DB 작업들이 같은 트랜잭션에 참여하도록 구현해야 한다.
계층 분리는 정합성을 자동으로 보장하는 장치가 아니라, 정합성을 책임질 위치를 명확하게 만드는 데 도움이 되는 구조였다.
결국 나누고 싶었던 것은 변경의 이유
처음에는 Controller와 Service를 나누는 일을 코드 정리 정도로 생각했다.
지금은 조금 다르게 본다. 요청 형식이나 응답 방식이 바뀌는 것과, 업무 규칙이 바뀌는 것은 서로 다른 변경이다. 이를 한곳에 섞어두면 한쪽을 고칠 때 다른 쪽까지 함께 살펴봐야 한다.
비즈니스 로직도 변한다. Service가 변하지 않는 코드를 담는 곳은 아니다. 다만 외부 인터페이스가 바뀌었다는 이유만으로 업무 규칙까지 수정해야 하는 상황은 줄일 수 있다.
계층 분리는 변하지 않는 코드를 만드는 일이 아니라, 서로 다른 변경이 불필요하게 번지지 않도록 경계를 세우는 일에 가까웠다.
NestJS가 역할과 모듈의 구분을 강조하는 이유도 전보다 이해가 된다. 무조건 구조가 많은 쪽이 좋다는 뜻은 아니다. 내 코드에서 어떤 책임이 섞여 있고, 무엇을 따로 바꾸고 싶은지 먼저 보는 게 중요했다.
미루지 말 걸 그랬다 ㅎㅎ.
리팩토링을 끝내고 나니, 머릿속에 계속 걸려 있던 “나중에 정리하자”가 사라진 게 제일 좋았다.
댓글 0개
댓글은 로그인 후 작성할 수 있어요.
로그인하기아직 작성된 댓글이 없습니다.