패키지 구조
저희 백엔드 서비스는 네 개의 형제 모듈로 구성된 패키지-바이-피처 구조를 따릅니다. 이 문서는 새 코드가 어디에 위치해야 하는지에 대한 진실의 원천입니다.
언어 참고 사항. 아래 예제는 저희 주 백엔드 스택인 Java/Spring Boot로 작성되었지만, 구조 자체는 언어에 구애받지 않으며 저희가 사용하는 모든 백엔드 언어(Kotlin, Go, Python, Node 등)에 필수입니다. 파일 확장자와 프레임워크 애너테이션은 번역하되, 폴더 레이아웃, 명명 접미사(
*Db,*Repo,*RepoImpl,*Dao,*Controller,*ControllerImpl) 및 인터페이스/구현 분할은 변경하지 마십시오.
핵심 원칙
기술 계층이 아닌 기능별로 그룹화하십시오. "orders" 기능에 대한 변경은 controllers/, services/, repositories/ 패키지 전체에 흩어진 파일이 아닌, 서로 옆에 있는 파일에 영향을 미쳐야 합니다. 기술 계층화는 모듈 경계(db, client, capabilities, starter)에서 발생하며, 패키지-바이-피처는 각 모듈 내부에서 이루어집니다.
네 가지 모듈
모든 서비스는 다음 네 개의 형제 모듈로 구성됩니다:
<service>/
├── capabilities/ # 기능/비즈니스 로직 — 두뇌
├── db/ # 지속성: 엔티티, 리포지토리, 마이그레이션
├── client/ # 외부 시스템과의 아웃바운드 통합
└── starter/ # Spring Boot 스타터 — 모든 것을 연결하고 실행
capabilities/ — 비즈니스 로직
각 기능은 고유한 패키지를 가지며, 각 기능 내부에는 API 엔드포인트(getById, create, updateById, getByQuery, …)당 하나의 하위 패키지가 있습니다. 각 하위 기능은 세 개의 형제 폴더를 소유합니다:
service/— 해당 API에 대한 단일 서비스 클래스dto/—request/및response/(해당되는 경우)mapper/— 이 하위 기능 전용으로*Db엔티티 ↔ DTO(양방향) 변환
기능의 컨트롤러는 기능 패키지의 루트에 위치하며, 리포지토리와 마찬가지로 인터페이스와 구현으로 분할됩니다:
capabilities/<feature>/<Feature>Controller.java— 인터페이스; 라우트, HTTP 동사, 요청/응답 타입, API 문서 애너테이션 선언capabilities/<feature>/<Feature>ControllerImpl.java— 구현; 프레임워크 애너테이션(@RestController)을 가지며 각 라우트를 일치하는 하위 기능 서비스에 위임
해당 기능의 모든 하위 기능에서 공유됩니다. 컨트롤러를 참조하십시오.
- 의존 대상:
db(*Repo인터페이스만 — DB 사용 규칙 참조),client - 절대 의존하지 않음:
starter - 포함하지 않음:
main클래스,@SpringBootApplication - 소유: HTTP 컨트롤러, API 요청/응답 DTO, 매퍼, 서비스
db/ — 지속성
테이블당 하나의 폴더로 구성됩니다. 각 테이블 폴더에는 정확히 세 개의 하위 폴더가 있습니다:
db/<tableName>/
├── model/
│ ├── <EntityName>Db.java # 주요 JPA 엔티티
│ └── <Other>Db.java # 하위 모델(임베디드 타입, 자식 엔티티, 프로젝션)
├── dao/
│ └── <EntityName>Dao.java # 저수준 데이터 접근(Criteria/JPQL/네이티브 쿼리)
└── repository/
├── <EntityName>Repo.java # PUBLIC 인터페이스 — capabilities가 볼 수 있는 유일한 타입
└── impl/
└── <EntityName>RepoImpl.java # Spring Data + Dao를 인터페이스 뒤에 구성
명명 규칙
| 계층 | 접미사 | 예시 |
|---|---|---|
| JPA 엔티티 | Db | TestcaseDb |
| 리포지토리 인터페이스 | Repo | TestcaseRepo |
| 리포지토리 구현 | RepoImpl | TestcaseRepoImpl |
| 데이터 접근 헬퍼 | Dao | TestcaseDao |
DB 사용 규칙
Capabilities는 *Repo 인터페이스에만 의존해야 합니다. *RepoImpl 및 *Dao는 내부 전용입니다 — db/<tableName>/ 패키지 외부에서 주입되거나 참조되지 않습니다. 이는 지속성 계층을 교체 가능하게 유지하고 기능 테스트가 계약에 집중할 수 있도록 합니다.
- 의존 대상: 이 서비스 내 없음
- 노출:
*Repo인터페이스 및*Db모델 타입 - 절대 포함하지 않음: 비즈니스 로직, API DTO로의 변환
client/ — 아웃바운드 통합
이 서비스가 의존하는 다른 서비스를 호출하는 코드를 보관합니다. 주로 당사 플랫폼 내 의존 마이크로서비스(paymentService, userService, executionService, …)당 하나의 하위 폴더로 구성됩니다. 타사 SDK 래퍼나 메시지 프로듀서도 여기에 위치할 수 있지만, 지배적인 패턴(이 모듈이 존재하는 이유)은 의존 마이크로서비스 클라이언트입니다.
client/<microserviceName>/
├── <Microservice>Client.java # 인터페이스 — capabilities가 의존하는 대상
├── impl/
│ └── <Microservice>ClientImpl.java # 구현: HTTP/gRPC/SDK 호출, 재시도, 타임아웃
├── dto/ # 이 마이크로서비스 API의 요청/응답 형식
│ ├── request/
│ └── response/
└── mapper/ # 선택 사항: 상대방 형식과 당사 형식 간 변환
규칙
-
의존 마이크로서비스당 하나의 하위 폴더, 서비스 이름을 따서 명명. 여러 마이크로서비스를 "관련 있다"고 해서 하나의 패키지에 합치지 마십시오.
-
Capabilities는
*Client인터페이스에만 의존합니다.*ClientImpl은 내부 전용입니다 —*Repo/*RepoImpl과 동일한 규칙입니다. -
클라이언트 인터페이스는 마이크로서비스의 API를 자체 DTO로 노출합니다. Capabilities는 필요한 경우 이를 추가로 조정할 수 있습니다.
-
횡단 관심사(재시도, 서킷 브레이킹, 요청/응답 로깅, 인증 헤더)는
impl/내부에 속합니다 — capabilities는 이를 절대 볼 수 없습니다. -
의존 대상: 이 서비스 내 없음
-
노출:
*Client인터페이스 및 해당 요청/응답 DTO -
절대 포함하지 않음: 비즈니스 로직, 기능 오케스트레이션
starter/ — 구성 루트
실행되는 유일한 모듈입니다. @SpringBootApplication, 글로벌 REST 예외 핸들러, 보안 설정, 필터 및 application.yml을 포함합니다. 컨트롤러는 없음 — 컨트롤러는 capabilities/에 있습니다.
- 의존 대상:
capabilities(및 전이적으로db,client) - 소유: 배포 가능한 아티팩트, 횡단 웹 설정(보안, CORS, 오류 처리)
- 절대 소유하지 않음: 기능 컨트롤러 또는 기능 DTO
의존성 규칙
starter → capabilities → { db, client }
화살표는 단방향입니다. db와 client는 capabilities 또는 starter를 가져오지 않습니다. capabilities는 starter를 가져오지 않습니다.
컨트롤러
하위 기능이 아닌 기능당 하나의 컨트롤러가 있으며, capabilities/ 내부의 기능 패키지 루트에 위치합니다. *Repo / *RepoImpl 분할을 반영하여 두 개의 파일로 분할됩니다:
capabilities/<feature>/<Feature>Controller.java— 인터페이스. 라우트, HTTP 동사, 경로/쿼리 매개변수, 요청/응답 DTO 타입 및 API 문서 애너테이션(OpenAPI/Swagger)을 선언합니다. 인터페이스를 읽는 사람은 전체 HTTP 계약을 볼 수 있어야 합니다.capabilities/<feature>/<Feature>ControllerImpl.java— 구현. 프레임워크 애너테이션(Spring의@RestController)을 가지며, 하위 기능 서비스의 주입 대상이며, 라우트당 하나의 짧은 메서드를 포함하여 일치하는service/를 호출하고 결과를 반환합니다. 위임 외에는 로직이 없습니다.
왜 분할합니까? 인터페이스는 API 계약입니다 — 안정적이고, 문서화되며, 독립적으로 테스트 가능합니다. 구현은 배선입니다 — 계약을 변경하지 않고 변경될 수 있습니다. *Repo vs *RepoImpl과 동일한 이유입니다.
컨트롤러는 각 HTTP 라우트를 capabilities/<feature>/<sub-feature>/service/의 일치하는 하위 기능 서비스에 위임합니다. 요청/응답 DTO는 하위 기능(capabilities/<feature>/<sub-feature>/dto/)에 속합니다. 컨트롤러는 얇은 HTTP 어댑터일 뿐입니다.
왜 starter/가 아닌 capabilities/ 내부에 있습니까? 컨트롤러, 해당 서비스 및 해당 DTO는 API가 변경될 때 모두 함께 변경됩니다 — 하나의 모듈에 유지하면 "하나의 기능, 하나의 장소"라는 패키지-바이-피처 약속이 유지됩니다. starter/는 횡단 배선(보안, 오류 핸들러, 앱 진입점)을 위해 예약되어 있으며, 기능별 웹 코드를 위한 것이 아닙니다.
실제 예제: testcase 기능
아래 트리는 설명을 위해 .java 확장자를 사용합니다. 언어의 파일 확장자와 프레임워크 관용구(코틀린 .kt, Go 패키지, Python 모듈 등)를 대체하되, 폴더 레이아웃과 명명 접미사는 동일하게 유지하십시오.
testcase-service/
├── capabilities/
│ └── com/example/testcase/capabilities/
│ └── testcase/ # 주요 기능
│ ├── TestcaseController.java # 인터페이스 — HTTP 계약, 라우트 선언, API 문서
│ ├── TestcaseControllerImpl.java # 구현 — @RestController, 하위 기능 서비스에 위임
│ ├── getById/ # 하위 기능 = 하나의 API
│ │ ├── service/
│ │ │ └── GetTestcaseByIdService.java
│ │ ├── dto/
│ │ │ └── response/
│ │ │ └── GetTestcaseByIdResponse.java
│ │ └── mapper/
│ │ └── GetTestcaseByIdMapper.java # TestcaseDb → response
│ ├── create/
│ │ ├── service/
│ │ │ └── CreateTestcaseService.java
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── CreateTestcaseRequest.java
│ │ │ └── response/
│ │ │ └── CreateTestcaseResponse.java
│ │ └── mapper/
│ │ └── CreateTestcaseMapper.java # request → TestcaseDb, TestcaseDb → response
│ ├── updateById/
│ │ ├── service/
│ │ │ └── UpdateTestcaseByIdService.java
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── UpdateTestcaseByIdRequest.java
│ │ │ └── response/
│ │ │ └── UpdateTestcaseByIdResponse.java
│ │ └── mapper/
│ │ └── UpdateTestcaseByIdMapper.java
│ └── getByQuery/
│ ├── service/
│ │ └── GetTestcaseByQueryService.java
│ ├── dto/
│ │ ├── request/
│ │ │ └── GetTestcaseByQueryRequest.java
│ │ └── response/
│ │ └── GetTestcaseByQueryResponse.java
│ └── mapper/
│ └── GetTestcaseByQueryMapper.java
│
├── db/ # 테이블당 하나의 폴더
│ └── com/example/testcase/db/
│ └── testcase/ # 테이블 이름
│ ├── model/
│ │ ├── TestcaseDb.java # JPA 엔티티 — 주요
│ │ └── TestcaseStepDb.java # 하위 모델 (자식/임베디드)
│ ├── dao/
│ │ └── TestcaseDao.java # 쿼리/크라이테리아 헬퍼, 패키지-프라이빗 사용
│ └── repository/
│ ├── TestcaseRepo.java # 인터페이스 — capabilities가 이에 의존
│ └── impl/
│ └── TestcaseRepoImpl.java # Spring Data + TestcaseDao 구성
│
├── client/ # 의존 마이크로서비스당 하나의 폴더
│ └── com/example/testcase/client/
│ ├── executionService/ # 호출하는 의존 마이크로서비스
│ │ ├── ExecutionServiceClient.java # 인터페이스
│ │ ├── impl/
│ │ │ └── ExecutionServiceClientImpl.java # HTTP/gRPC 호출, 재시도, 타임아웃
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── RunTestcaseRequest.java
│ │ │ └── response/
│ │ │ └── RunTestcaseResponse.java
│ │ └── mapper/
│ │ └── ExecutionServiceMapper.java
│ └── userService/ # 다른 의존 마이크로서비스
│ ├── UserServiceClient.java
│ └── impl/
│ └── UserServiceClientImpl.java
│
└── starter/ # 횡단 배선만 — 컨트롤러 없음
└── com/example/testcase/
├── TestcaseServiceApplication.java
├── config/
│ ├── SecurityConfig.java
│ └── WebConfig.java
├── error/
│ └── GlobalExceptionHandler.java # @ControllerAdvice — 횡단, 기능별 아님
└── resources/application.yml
실용 규칙
- 기존 기능에 새 API를 추가하는 경우?
capabilities/<feature>/내부에 자체service/,dto/{request,response}/및mapper/와 함께 새 하위 기능 패키지를 만든 다음<Feature>Controller.java(인터페이스)에 라우트 선언을 추가하고<Feature>ControllerImpl.java에 위임 메서드를 추가하십시오. 두 번째 컨트롤러를 만들지 마십시오. - 완전히 새로운 기능을 추가하는 경우?
capabilities/에 기능 패키지(루트에 컨트롤러, API당 하위 기능)를 만들고db/<tableName>/에 일치하는 테이블 폴더(model/,dao/,repository/{impl/}포함)를 만드십시오. 외부 시스템을 호출하는 경우에만 클라이언트를 추가하십시오.starter/는 건드리지 마십시오. - 항상
*Repo를 주입하고, 절대*RepoImpl또는*Dao를 주입하지 마십시오. Capabilities는 인터페이스에만 의존합니다.*RepoImpl및*Dao는db/<tableName>/내부 전용입니다 — 쿼리 전략이나 백엔드 저장소를 기능 코드 변경 없이 교체할 수 있도록 존재합니다. - 항상
*Client를 주입하고, 절대*ClientImpl을 주입하지 마십시오. 의존 마이크로서비스 호출에도 동일한 규칙입니다. Capabilities는 인터페이스에 의존합니다. 전송(HTTP, gRPC, SDK)은impl/뒤에 있습니다. client/내부에 의존 마이크로서비스당 하나의 폴더. 폴더 이름은 마이크로서비스 이름을 따르십시오. "모든 내부 서비스"를 하나의 패키지로 그룹화하지 마십시오.- 하위 기능 세분성은
capabilities/에서 멈춥니다.db/는 테이블별로 구성되고,client/는 외부 시스템별로 구성됩니다. 이를 API별로 분할하면 응집력 있는 코드가 분산됩니다. - 요청/응답 DTO와 해당 매퍼는 하위 기능별로 있으며, 절대 공유되지 않습니다. 오늘
create와updateById가 비슷해 보이더라도, 두 개의 DTO와 두 개의 매퍼를 유지하십시오. 공유는 독립적으로 변할 두 API를 결합시킵니다. - 매퍼는 엔티티 ↔ DTO를 직접 변환합니다. 중간 도메인 모델 계층은 없습니다.
<SubFeature>Mapper는*Db↔ 해당 하위 기능의 요청/응답 DTO를 변환합니다. - 세 개의 하위 기능이 유사한 코드를 가지고 있습니까? 동일한 패턴을 네 개 이상의 위치에서 볼 때까지 공유 서비스나 매퍼를 추출하려는 충동을 참으십시오.
- 기능 간 재사용? 공유 유틸리티는
capabilities/의common/패키지에 넣되, 진정한 공유(예: 날짜 유틸리티, 공통 예외)인 경우에만 해당합니다. 여러 하위 기능에서 동일한 DTO나 매퍼 변환을 사용하고 있다면 잘못된 것입니다.