(기록) 한 개의 메소드만 갖는 계층형 컨트롤러/서비스 패키지 스타일
찾기 쉬워야 한다
일러두기
- 이 글은 최근 동료와 함께 실험적으로 작업한 내용과 그 장단점을 기록한 것입니다.
- 장점 외에 다양한 단점이 존재할 수 있습니다.
발단: 파일 탐색의 어려움과 유지보수
파일 탐색은 유지보수의 가장 큰 어려움 중 하나이다.
Java와 Spring Boot를 사용해 웹 애플리케이션을 구현하고 유지보수하며 가장 지겨운 건 영원히 Controller - Service - Repository를 찾아 헤매고 다녀야 한다는 것. 1 프로젝트에 꽤나 익숙해지기 전에는 뭐가 어디에 있는지 알 수가 없으며, 3일 이상 휴가라도 다녀오면 그나마 알던 위치도 잊게 된다.
게다가 프로젝트 규모가 일정 이상이라면, 객체-객체 간 책임이 명확하지 않은 연산이 다수 존재할 수 밖에 없으므로 유지보수를 담당하는 개발자는 어느 서비스가 어떤 행위를 제공하고 제공하지 않는지를 정확히 알기 어렵다.
"위치"를 정확히 알지 못한다면 각 행위와 행위 사이의 의존 관계나 관련 순서도 파악하기 어렵다.
쉽게 말하자면 "이 기능 언제까지 구현 가능한가요?" 라는 질문에 개발자가 "먼저 좀 살펴볼게요"라고 대답했을 때, 개발자가 살펴보는 시간의 대부분은 파일 시스템 탐색이 차지하고 있을 가능성이 크다는 것.
[[/pattern/layered-architecture]]가 지향하는 바가 상통하는 면이 있긴 하지만 계층형 아키텍처를 적용하기에 애매한 사이즈의 프로젝트가 있는 것도 사실이다.
그래서 요즘은 페어 프로그래밍 파트너인 최선혁님과 함께 새로운 시도를 해보고 있다.
이 실험적인 시도의 핵심은 다음과 같다.
- 집합의 포함관계를 강력히 의식한 계층 구조로 패키지를 구성한다.
- 한 가지 용도를 암시하는 클래스 이름으로 행위와 책임을 제한한다.
- 공개된 메소드를 딱 한 개만 갖는 서비스/컨트롤러 클래스를 작성한다.
게시판 웹 애플리케이션의 사례
패키징에 대해 이야기하기 전에 먼저 클래스 이름에 대해 먼저 생각해 보자.
가령 게시판을 만든다고 하자.
Command, Query의 분리
1~2년차의 나는 BoardService
같은 걸 만들고 이 녀석에 모든 메소드를 쑤셔넣고, 클래스가 600줄 넘어가도록 불안하게 방치하곤 했다.
그 외의 방법이 떠오르지 않았다. 5년차의 나는 이걸 둘로 쪼개고 command, query로 나눴다.
BoardCommandService
: 게시판 데이터 추가, 변경, 삭제를 담당한다.BoardQuerySevice
: 게시판 조회를 담당한다.
변경과 조회로 책임이 둘로 나뉘었다.
이제 유지보수할 때 조회와 관련된 작업이 필요하면 BoardQueryService
파일을 열어 보면 되고, 변경과 관련된 작업이 필요하면 BoardCommandService
파일을 열어 보면 된다.
그러나 전체적인 어려움이 크게 감소하지는 않았다. 반으로 나뉘었을 뿐 체감상 파일 탐색의 복잡함은 큰 차이가 없었던 것이다.
단 하나의 메소드를 제공하는 클래스로 SRP를 준수하자
그러던 어느날, 단일 책임 원칙(SRP)을 강력하게 지키도록 작업해보면 뭔가 더 단순해질 것 같다는 생각이 들었다.
그래서 컴포넌트 하나가 public
메소드 하나만 서비스하도록 해봤더니 생각보다 괜찮았다.
해당 클래스에 대한 테스트 클래스 파일도 메소드 하나만을 주제로 테스트하게 되어 제법 읽기 좋았다.
그리고 서비스 이름에 모두 -er
을 붙였다. 객체의 행위를 한정짓는 이름이라 우려되는 면도 있었지만, 서비스의 의미를 생각해보니 괜찮은 선택일 수 있다고 생각했다.
설계가 매우 명확하고 실용적이더라도 개념적으로 어떠한 객체에도 속하지 않는 연산이 포함될 때가 있다. 이러한 문제를 억지로 해결하려 하기보다는 문제 자체의 면면에 따라 SERVICE를 모델에 명시적으로 포함할 수 있다.2
한편으로는 새로운 시도를 시작하기도 전에 중단하고 싶지는 않았다. 일단 시도한 다음 결과를 보고 좋았는지 나빴는지를 판단하려 했다.
게다가 내가 좋아했던 Go 언어에서는 인터페이스 이름에 -er
붙이는 게 당연했는데
Java로 코딩한다는 이유만으로 아이디어를 기각하는 건 아쉬운 일이라고 여겼다.
BoardCreator
: 게시판 생성을 담당한다.CommentCreator
: 댓글 생성을 담당한다.CommentDeleter
: 댓글 삭제를 담당한다.- …
이렇게 하고 보니 BoardService
하나 뿐이었던 서비스가 CRUD 오퍼레이션 단위로 촘촘하게 분리되었으며, 이름과 용도가 일치되어 투명한 블랙박스를 만든 느낌이 들었다.
즉, 이름에 Creator
가 있는 클래스 파일에는 update
로직을 추가하기 어려울 것이다.
클래스의 이름이 그러한 결정을 방해하기 때문이다.
즉 네이밍으로 용도와 책임을 제한하게 된다.
그리고 앞에서 이야기한 바와 같이 이들은 단 하나의 public
메소드만 갖고 있어서, 심리적으로 다른 용도의 기능을 추가하기 어렵다.
이런 이름의 제한은 유지보수하게 될 사람에게 주는 강력한 힌트가 된다.
BoardCreator::create
CommentCreator::create
CommentDeleter::delete
다음은 이런 스타일을 따르도록 작성한 클래스의 예제이다.
@Service
class CommentDeleter {
/* public 메소드는 클래스당 하나만 정의하며,
경우에 따라 같은 이름을 가진 오버로딩 메소드를 추가한다. */
public CommentOperationResult delete(CommentDeleteRequest request) {
/*
메소드의 본문은 오퍼레이션의 절차적 표현을 가능한 한 짧게 기술한다.
*/
return result;
}
/* private 메소드는 public 메소드의 가독성과 낮은 복잡도 유지를 지원하기 위해 정의한다. */
private void doSomeThing() {
// ...
}
}
공개된 메소드가 하나이기 때문에 class
는 메소드 하나를 제공하기 위한 껍데기일 뿐이다.
이렇게 해 보았더니
- 클래스 하나가 30줄을 넘는 경우가 매우 드물다.
- 파일 열어보면 오래 걸려도 5초 안에 뭐하는 파일인지 바로 파악이 된다.
-er
postfix가 붙은 클래스끼리 이름으로 구분이 되니 읽는 사람의 마음속에서-er
-layer 가 구분된다는 것도 특징. - 파일 내 테스트 코드 응집도가 높아져 만족스러웠다.
- 다만 파일이 많아지는 건 단점일 수도 있는데, 우리는 별로 신경쓰이지 않았다.
그래서 이번 시도에서 작업중인 프로젝트에서는 이름이 Service
로 끝나는 클래스가 단 하나도 없다.
집합의 포함관계를 의식한 가독성있는 계층형 패키지 구성
이 시도에서는 탐색의 용이성을 돕기 위한 방법으로 계층형 패키지를 고려한다.
물론 계층형 구조는 분류자의 주관이 많이 개입되기 때문에 올바른 분류기준은 존재하지 않으며, 언제나 상대적으로 적합하거나 그렇지 않은 분류기준만이 존재할 뿐이다.
따라서 우리는 서비스 클래스 패키지 경로 전략에 대해 철저하게 우리 두 사람의 도메인 지식을 토대로 삼는 탐색 편의 위주로 고려했다.
생물의 분류 기준으로 표현해보자. 동물을 예로 든다면 다음과 같은 형식이다.
- /척삭동물문/포유동물강/식육목/개과/너구리속
- /척삭동물문/포유동물강/식육목/개과/여우속
- /척삭동물문/파충강/거북목/자라상과
이 형식은 디렉토리 구조로는 다음과 같이 표현된다.
- /척삭동물문
- /포유동물강
- /식육목
- /개과
- /너구리속
- /여우속
- /파충강
- /거북목
- 자라상과
이런 식의 개념적 계층화만 염두에 두고 만들어서 탐색이 어렵지 않다.
다만 이러면 접근권한 붙이기가 좀 짜증난다는 문제가 있다. 이런 구조로 작업을 할 때 내가 필요하다고 생각하는 접근권한자는 하위 경로에만 열려 있는 recursive private 이라 할 수 있다. 그러나 자바의 패키지 접근권한 개념이 지향하는 바는 내 바램과는 개념이 달라서 앞으로도 추가될 일은 없을 것 같다.
그래서 클래스 접근권한은 가능한 한 각 메소드 호출 구조에 따라 default
가 방해가 되는 경우에 한해 public
을 사용한다.
이에 대해서는 주의깊게 접근하는 것이 자바답게 생각하는 방식이라 볼 수 있을 것이다.
그러나 이러한 구성에서는 public
이 많아도 큰 문제가 없다고 생각한다.
클래스 이름으로 용도를 광고하고 있는 메소드 딱 한 개만 지원하므로 public
이어도 위험은 크지 않다고 본다.
패키지 접근제한으로 개발자의 실수와 복잡도 상승을 제한하는 건 자바를 통해 문제에 접근하는 방법 중 하나이겠지만, 나는 가독성이 개발자의 능력을 끌어올리고 끌어올린 능력만큼 실수를 방지하고 복잡도도 억제할 수 있다고 생각한다.
컨트롤러에 계층형 패키지 구성
이런 구성은 도메인 서비스 클래스 파일들을 배치할 때에도 나름의 유용성이 있었지만, 특히 컨트롤러 클래스의 위치를 결정할 때 도움이 되었다.
컨트롤러에 대해서는 다음과 같은 규칙을 만들었다.
- 컨트롤러 하나가 API URI 단 하나를 담당하게 한다.
- URI 경로를 그대로 패키지 경로로 사용한다.
나는 보통 컨트롤러 클래스 하나가 여러 엔드포인트를 갖게끔 코딩을 해왔고, 그런 코드를 많이 봤다.
하지만 이 방법은 조금 다르다.
가령 상품 조회와 관련된 컨트롤러가 있다고 하자. URI가 /shop/product/
라고 할 때 패키지를 아예 web/shop/product
이런 식으로 만들고 이 안에 컨트롤러를 배치한다.
- src
- web
- shop
- product
- ProductQueryController
그리고 이 컨트롤러는 해당 URI에 대한 HTTP method 처리만 한다.3 만약 다른 URI가 필요하다면 해당 URI에 맞춰 패키지를 구성하고 새 컨트롤러를 그 위치에 만들어 준다.
- src
- web
- shop
- product
- ProductQueryController
- order
- OrderCreateController
컨트롤러 하나가 엔드포인트 딱 한 개만 담당하게 하는 방법이다. (앞에서 공개 메소드 한 개만 제공하도록 작업한 서비스 클래스와 비슷한 개념이라 할 수 있다.) 이 방법은 URI와 패키지 경로가 일치하기 때문에 IDE 안에서 디렉토리 구조만 보고 해당 컨트롤러 클래스 파일을 찾아내기가 무척 쉽다.
가상 대화를 생각해 보자. 나는 패키지 구성을 나름 주의깊게 구성하려 했지만 시간이 좀 지나면 까먹곤 해서 지금까지 이런 상황을 가끔씩 겪었다.
- A:
GET /shop/product/
를 담당하는 클래스를 찾아 살펴봐야겠어요. 로그를 보니 버그가 있을 것 같아요.- B: 음 생각이 잘 안 나는데, 주소 이름을 보니
Product....Controller
일 것 같네요. 이 파일을 찾아 봅시다.- A:
ProductController
랑ProductAdminController
가 있네요.- B:
ProductController
를 열어보니@GetMapping
이 없는데요? 다른 클래스에 있나봐요.- A: 다른 파일도 열어 보죠.
- B: 이 파일인가? 아니네.
- (A, B는 다른 파일도 줄줄이 열어본다.)
하지만 이 방법을 적용하고 나서는 다음과 같이 대화가 흘러갔다.
- A:
GET /shop/product/
를 담당하는 클래스를 찾아 살펴봐야겠어요. 로그를 보니 버그가 있을 것 같아요.- B: 음 생각이 잘 안 나는데, 주소 이름을 보니
/web/shop/product
패키지를 열어보면 되겠네요.- A:
ProductController
가 있네요. 이 클래스 안에@GetMapping
메소드가 1개 있을테니 그걸 보면 되겠다.- (A, B는 디버깅을 시작한다.)
그리고 그 컨트롤러에서만 필요로 하는 리퀘스트 객체와 리스폰스 객체도 package private으로 만들어줬다. 이렇게 해보니 엔드포인트 관리가 꽤 쉬웠고, RestDoc 테스트코드를 작성할 때에도 테스트 파일의 사이즈도 이전에 비해 많이 작아졌다.
한편 URI 구성에 대해 계층구조로 표현한 명사의 집합관계라는 컨셉을 분명히 인식하며 URI를 디자인할 수 있었다.
자원의 아이디로서 계층구조의(/
로 구분된) URI를 제공한다는 걸 동료들과 함께 인식하니 커뮤니케이션도 편리했다.
- 아직까지 단점은 거의 못 느끼고 있다.
- 패키지가 깊어지고 각 컨트롤러 클래스에 메소드가 한두개만 있다는 정도의 문제는 있다.
- 이건 오히려 장점이라고 생각한다.
- 패키지가 깊어진다 해도 URI만 알아도 컨트롤러를 금방 찾을 수 있어 편의성이 더 크다.
이렇게 하면 URI를 점진적으로 마이그레이션 할 때에도 도움이 될 거라 생각한다.
- src
- web
- v1
- shop
- product
- ProductQueryController
- ProductQueryRequest
- ProductQueryResponse
- v2
- shop
- order
- OrderCreateController
이 패키지 구조를 보면 /web/v1
에서 /web/v2
로의 이행 작업이 점진적으로 이루어지고 있음을 알 수 있다.
그리고 /v2/shop/order
는 이행이 완료되었지만, /v1/shop/product
는 아직 옮겨지지 않았다는 것도 확인할 수 있다.
즉, 마이그레이션에 대해 패키지 자체가 TODO 목록이 되는 셈이다.
일단 배포된 URI 주소는 불변한다는 특징이 있기 때문에 이 구조가 이러한 효과를 발휘하는 것 같다.
- 일반적으로
web
패키지 하위의 패키지들은 거의 불변으로 유지된다. - 만약 어떠한 URI를 더이상 서비스하지 않게 된다면 해당 패키지(와 그 안의 클래스)만 삭제하면 된다는 것도 편리하다.
- 여러 URI를 리커시브하게 삭제할 일이 있어도 상위 패키지(디렉토리)만 삭제하면 된다.
추가 대화
이 아이디어를 트위터에 공유했더니 다음과 같은 반응을 볼 수 있었다.
@dveamer 님
말씀 주신대로 장점만 있는 멋진 아이디어 인 것 같습니다!
— Dveamer (@dveamer) October 13, 2021
질문이 있습니다.
1. path variable 을 갖는 URI의 패키지는 어떻게 정의하시나요?
2. 여러 컨트롤러에서 사용하는 리퀘스트, 리스폰스 DTO의 패키지 위치와 접근제한은 어떻게 되나요?
3. v2 만드실때 private DTO는 어떻게 하시나요?
감사합니다.
— John Grib (@John_Grib) October 13, 2021
1 적당히(..) 짓습니다. 다만 이 프로젝트에서는 이런 경로 엔트리가 말단에만 있어서 아직은 문제가 없었어요.
2 대체로 공통경로 부모에 해당하는 곳에 두게 되더라고요.
3. 이건 저희도 경험이 도 필요할 것 같아요. 지금은 그냥 public으로 바꾸고 냅다 옮기고 있어요.
@ahastudio 님
한편 메소드를 하나 갖는 서비스에 대해서는 아샬님이 코멘트를 주셨다.
클린 아키텍처에서 그렇게 해요. 기존 사례가 필요하면 그쪽을 참고해 보세요.
— 아샬 (Ashal aka JOKER) (@ahastudio) October 15, 2021
@nameEO 님
이런 것도 괜찮은 듯... 솔직히 REST API 구조로 CRUD만 컨트롤러에 넣어도 나중에 이것저것 추가하다보면 어떤 API를 담당하는 함수를 찾아 스크롤을 한참 하게 된다... https://t.co/vdCvFhQ52u
— 이름뭐하지 (@nameEO) October 25, 2021
특히 public 함수 하나만 노출시키고 private 함수를 정의해서 가독성을 높이는 부분이 맘에 든다. 기존 컨트롤러 방식은 저짓하면 수많은 함수가 섞여서 최대한 함수 1개에 몰아적는게 나을 정도인데. 저런 구조면 함수가 아무리 많아도 어떤 동작을 구현하기 위한 것인지 바로 파악이 될 듯.
— 이름뭐하지 (@nameEO) October 25, 2021
함께 읽기
소리치는 아키텍처
로버트 마틴의 클린 아키텍처에 등장하는 '소리치는 아키텍처'에 나오는 이야기.
건물의 청사진을 살펴보고 있다고 상상해보자. 이 문서는 아키텍트가 작성했고 건물에 대한 일련의 계획을 보여주고 있다. 이 계획서는 무슨 이야기를 해 주는가?
그 계획서가 한 가족이 거주할 주택을 그리고 있다면, 정문, 거실로 연결되는 현관, 그리고 식당 역시 볼 수 있을 것이다. 식당 가까이에 주방이 있을 것이고, 아마 주방 근처에는 간이 식탁이 있고, 바로 붙어서 가족 방이 있을 가능성이 높다. 이러한 계획서를 본다면 한 가족이 사는 주택을 보고 있다는 사실에 의심의 여지가 없을 것이다. 다시 말해, 이 아키텍처는 "집이야"라고 소리칠 것이다.
이제 도서관의 아키텍처를 보고 있다고 가정해 보자. 커다란 정문, 체크인과 체크아웃을 담당할 사서를 위한 공간, 독서 공간, 작은 회의실, 도서관의 장서를 모두 보관할 정도의 책장을 배치한 진열실이 차례로 나타날 것이다. 이 아키텍처는 "도서관이야"라고 소리칠 것이다.
자, 여러분의 애플리케이션 아키텍처는 뭐라고 소리치는가? 상위 수준의 디렉터리 구조, 최상위 패키지에 담긴 소스 파일을 볼 때, 이 아키텍처는 "헬스 케어 시스템이야" 또는 "재고 관리 시스템이야"라고 소리치는가? 아니면 "레일스Rails야", "스프링 Spring/하이버네이트Hibernate야", 아니면 "ASP야"라고 소 리치는가?
(중략)
아키텍처는 시스템을 이야기해야 하며, 시스템에 적용한 프레임워크에 대해 이야기해서는 안 된다. 당신이 헬스케어 시스템을 구축하고 있다면, 새로 들어온 프로그래머가 소스 저장소를 봤을 때 첫 인상은 "오, 헬스 케어 시스템이군"이어야만 한다. 새로 합류한 프로그래머는 시스템이 어떻게 전달될지 알지 못한 상태에서도 시스템의 모든 유스케이스를 이해할 수 있어야 한다. 언젠가 이들은 당신을 찾아와서 이렇게 말할 것이다.
"모델처럼 보이는 것들을 확인했습니다. 그런데 뷰와 컨트롤러는 어디에 있죠?"
그러면 당신은 응당 다음과 같이 답해야 한다.
"아, 그것은 세부사항이므로 당장은 고려할 필요가 없습니다. 나중에 결정 할 겁니다." 4
참고문헌
- 도메인 주도 설계 / 에릭 에반스 저 / 이대엽 역 / 위키북스 / 2011년 07월 21일 / 원제 : Domain-Driven Design
- 클린 아키텍처 / 로버트 C. 마틴 저/송준이 역 / 인사이트(insight) / 초판 1쇄 2019년 08월 20일 / 원제 : Clean Architecture: A Craftsman's Guide to Software Structure and Design