Articles

스프링 서버를 네 개의 모듈로 나눈 기준

WhoIsLoafing 서버는 하나의 Spring Boot 앱이지만 Gradle 모듈 네 개로 나뉘어 있어요. 어떤 기준으로 나눴는지, 모듈끼리 어느 방향으로만 알게 했는지, 반대 방향이 필요할 때 인터페이스로 끊은 방법을 정리했어요.

WhoIsLoafing 서버는 Kotlin과 Spring Boot로 만들었어요. 처음에는 모듈 하나에 모든 코드가 들어 있었고, 중간에 Gradle 멀티 모듈로 나눴어요. GitHub 호출, Claude 호출, Redis, 회원, 채팅, 분석 코드가 한 모듈에 있으면 무엇이 무엇을 부르는지 코드만 보고는 알기 어려워요.

네 개의 모듈

whoisloafing (루트, 공통 빌드 설정만 있어요)
  loafing-common   어디서나 쓰는 순수 공통 코드
  loafing-domain   엔티티, 리포지토리, 도메인 예외
  loafing-infra    외부 연동 (Redis, GitHub, Claude)
  loafing-api      실행되는 Spring Boot 앱 (컨트롤러, 서비스, DTO, 보안, 설정 파일)

기능 단위가 아니라 계층 단위로 나눴어요. 회원 모듈, 채팅 모듈처럼 나누는 방법도 있지만, 지금 규모에서는 기능끼리 서로 쓰는 일이 많아서 경계를 긋기 어려웠어요. 반면 "데이터를 저장하는 코드", "바깥 서비스를 부르는 코드", "요청을 받아 흐름을 짜는 코드"는 성격이 분명하게 달랐어요.

모듈 이름에는 모두 loafing- 접두사를 붙였어요. common, domain 같은 흔한 이름만 쓰면 다른 프로젝트의 모듈과 구분하기 어려워요.

의존은 한 방향으로만

나누는 것보다 중요한 것은 누가 누구를 알게 할지예요.

모듈 의존하는 모듈
loafing-api loafing-domain, loafing-infra, loafing-common
loafing-domain loafing-common
loafing-infra loafing-common
loafing-common 없음

규칙은 세 가지예요.

  • loafing-common은 아무것도 몰라요. Spring에서도 spring-context만 써요.
  • loafing-domain과 loafing-infra는 서로를 몰라요. 둘 다 loafing-common만 써요.
  • loafing-api만 모든 모듈을 알아요. 실행 jar도 여기서만 만들어요.

Gradle 설정에 의존을 적어 두면, 규칙을 어기는 코드는 컴파일이 안 돼요. 리뷰에서 "이건 여기서 부르면 안 돼요"라고 말할 필요 없이 빌드가 막아 줘요.

각 모듈에 들어간 것

loafing-common에는 어디서나 쓰는데 다른 것에 기대지 않는 코드를 뒀어요. 공통 응답 형식과 오류 코드, 글자 수 제한 같은 문자열 도구, 가상 스레드 실행기, 작은 메모리 캐시예요.

loafing-domain에는 엔티티와 리포지토리, 도메인 예외가 있어요. 회원, 채팅, AI 사용량이 여기 있어요. 데이터베이스에서 바로 만드는 합계 DTO도 여기 뒀어요. 쿼리와 붙어 있는 값이라 쿼리 옆에 두는 편이 자연스러워요.

loafing-infra에는 바깥 세계와 이야기하는 코드가 있어요. Redis 설정과 요청 횟수 제한, GitHub REST API 클라이언트, Claude API 클라이언트, GitHub와 카카오 로그인 클라이언트예요. 이 모듈은 회원이 무엇인지, 채팅이 무엇인지 몰라요. "GitHub에서 커밋 목록을 받아 온다" 같은 일만 해요.

loafing-api에는 나머지가 모두 있어요. 컨트롤러, 서비스, 요청과 응답 DTO, 보안 설정, 설정 파일이에요. 분석 흐름처럼 여러 모듈을 엮는 일은 여기서 해요.

반대 방향이 필요할 때

규칙을 지키다 보면 반대 방향이 필요한 순간이 와요. 대표적인 것이 AI 사용량 기록이에요.

Claude를 부르는 코드는 loafing-infra에 있어요. 호출이 끝나면 토큰 사용량을 데이터베이스에 남겨야 해요. 그런데 사용량 엔티티는 loafing-domain에 있고, loafing-infra는 loafing-domain을 몰라요. 직접 저장할 수 없어요.

그래서 loafing-infra에는 "기록할 수 있는 무언가"라는 인터페이스만 둬요.

package dev.hidly.whoisloafing.infra.claude

interface ClaudeUsageRecorder {
    fun record(usage: ClaudeUsage)
}

Claude 클라이언트는 호출이 끝나면 이 인터페이스에 사용량을 넘겨요. 넘겨받은 것을 실제로 어디에 어떻게 저장할지는 몰라요.

이 인터페이스를 구현하는 것은 loafing-api의 AiUsageService예요. loafing-api는 모든 모듈을 아니까, 사용량 엔티티를 만들어 리포지토리로 저장할 수 있어요. 실행할 때 Spring이 구현체를 찾아서 Claude 클라이언트에 넣어 줘요.

이렇게 하면 의존 방향은 그대로 유지돼요. loafing-infra는 여전히 loafing-common만 알고, 기록하는 방법을 바꾸고 싶으면 loafing-api의 구현만 고치면 돼요.

나눠서 좋아진 것

  • 바깥 서비스 코드가 깨끗해졌어요. GitHub 클라이언트를 고칠 때 회원이나 채팅 코드를 신경 쓸 필요가 없어요. 받는 것과 주는 것이 GitHub 데이터뿐이에요.
  • 어디에 둘지 고민이 줄었어요. 새 코드를 쓸 때 "이건 바깥을 부르나, 저장하나, 흐름을 짜나"만 물으면 위치가 정해져요.
  • 잘못된 의존이 빌드에서 걸려요. 실수로 엔티티를 infra에서 쓰면 컴파일 오류가 나요.

아직 남은 것

loafing-api는 여전히 커요. 분석, 회원, 채팅, 관리자, 탐색, 피드백이 모두 여기 있고, 지금은 패키지로만 나눠 두고 있어요. 분석 흐름이 회원과 채팅을 함께 쓰는 일이 많아서, 기능 단위로 모듈을 더 쪼개려면 그 경계부터 정리해야 해요.

정리

  • 기능 사이의 경계가 흐릴 때는 계층 단위로 모듈을 나누는 것이 시작하기 쉬워요.
  • 모듈을 나누는 것보다 의존 방향을 정하는 것이 중요하고, Gradle 설정으로 빌드가 그 방향을 지키게 할 수 있어요.
  • 반대 방향이 필요하면 아래 모듈에 인터페이스를 두고, 위 모듈에서 구현해요.
  • 실행 앱은 하나의 모듈에서만 만들어요.
다음 글 GitHub와 카카오 로그인을 붙이면서 정한 규칙들