일러두기

  • 이 글은 최근 동료와 함께 실험적으로 작업한 내용과 그 장단점을 기록한 것입니다.
  • 장점 외에 다양한 단점이 존재할 수 있습니다.

발단: 파일 탐색의 어려움과 유지보수

파일 탐색은 유지보수의 가장 큰 어려움 중 하나이다.

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: ProductControllerProductAdminController가 있네요.
  • 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 님

@ahastudio 님

한편 메소드를 하나 갖는 서비스에 대해서는 아샬님이 코멘트를 주셨다.

@nameEO 님

참고문헌

  • 도메인 주도 설계 / 에릭 에반스 저 / 이대엽 역 / 위키북스 / 2011년 07월 21일 / 원제 : Domain-Driven Design

주석

  1. Controller - Service - Repository 세가지 컴포넌트 자체는 문제의 원인이 아니다. 

  2. 도메인 주도 설계. 5장. 107쪽 

  3. 처음 컨셉은 이랬지만 지금은 이것도 분리했다.