멀티모듈 구조로 구성된 계층형 카테고리 관리 REST API 서비스입니다.
- JDK 21 이상
- Gradle 8.14 이상
# 1. 프로젝트 클론 git clone https://github.com/kangwooc/musinsa-category.git cd musinsa-category # 2. 애플리케이션 실행 ./gradlew :modules:api:bootRun # Windows 환경 gradlew.bat :modules:api:bootRun
- API 서버: http://localhost:8080
- Swagger UI: http://localhost:8080/swagger-ui/index.html
- H2 Database Console: http://localhost:8080/h2-console
- JDBC URL:
jdbc:h2:mem:categorydb - Username:
sa - Password: (공백)
- JDBC URL:
category-service/
├── modules/
│ ├── api/ # REST API 컨트롤러 및 웹 계층
│ ├── application/ # 비즈니스 로직 및 서비스
│ ├── core/ # 공통 DTO, 설정, 예외처리
│ └── infra/ # 데이터베이스 및 인프라 계층
├── gradle/
├── build.gradle.kts
└── settings.gradle.kts
| 모듈 | 역할 | 주요 구성요소 |
|---|---|---|
| api | REST API 엔드포인트 제공 | Controller, DTO, 전역 예외처리 |
| application | 비즈니스 로직 처리 | Service, Mapper |
| core | 공통 기능 및 설정 | DTO, 예외 클래스, 설정 |
| infra | 데이터 영속성 관리 | JPA Entity, Repository |
| Column | Type | Description |
|---|---|---|
| id | BIGINT (PK) | 카테고리 ID |
| name | VARCHAR(100) | 카테고리 명 |
| created_at | TIMESTAMP | 생성일시 |
| updated_at | TIMESTAMP | 수정일시 |
| Column | Type | Description |
|---|---|---|
| parent_id | BIGINT (FK) | 부모 카테고리 ID |
| child_id | BIGINT (FK) | 자식 카테고리 ID |
PRIMARY KEY (parent_id, child_id): 중복 관계 방지CHECK (parent_id != child_id): 자기 참조 방지CASCADE DELETE: 카테고리 삭제 시 관계도 함께 삭제
- Base URL:
http://localhost:8080/api/categories - Content-Type:
application/json
POST /api/categories
새로운 카테고리를 생성합니다. 여러 부모 카테고리를 지정할 수 있습니다.
Content-Type: application/json
{
"name": "string", // 필수: 카테고리 이름 (최대 100자)
"parentIds": [number] // 선택: 부모 카테고리 ID 배열 (최대 10개)
}name: 필수값, 공백 불가, 최대 100자parentIds: 선택값, 최대 10개, 중복 불가
{
"id": 15,
"name": "무선 이어폰",
"parentIds": [1, 3],
"parentNames": ["전자제품", "액세서리"]
}- 400: 이름이 비어있거나 너무 긴 경우
- 400: 부모가 10개를 초과하는 경우
- 404: 지정한 부모 카테고리가 존재하지 않는 경우
curl -X POST http://localhost:8080/api/categories \ -H "Content-Type: application/json" \ -d '{ "name": "무선 이어폰", "parentIds": [1, 3] }'
PUT /api/categories/{id}
기존 카테고리의 정보를 수정합니다.
id(number, 필수): 수정할 카테고리 ID
{
"name": "string", // 선택: 새로운 카테고리 이름
"parentIds": [number] // 선택: 새로운 부모 카테고리 ID 배열
}- 모든 필드가 선택사항
parentIds가 빈 배열이면 모든 부모 관계 제거parentIds가 null이면 부모 관계 변경하지 않음
{
"id": 15,
"name": "업데이트된 이름",
"parentIds": [2, 4],
"parentNames": ["신발", "상의"]
}- 400: 자기 자신을 부모로 지정하는 경우
- 400: 순환 참조가 발생하는 경우
- 404: 카테고리 또는 부모 카테고리가 존재하지 않는 경우
curl -X PUT http://localhost:8080/api/categories/15 \ -H "Content-Type: application/json" \ -d '{ "name": "블루투스 헤드셋", "parentIds": [1] }'
DELETE /api/categories/{id}
카테고리를 삭제합니다. 자식 카테고리가 있는 경우 삭제할 수 없습니다.
id(number, 필수): 삭제할 카테고리 ID
응답 본문 없음
- 400: 자식 카테고리가 존재하는 경우
- 404: 카테고리가 존재하지 않는 경우
curl -X DELETE http://localhost:8080/api/categories/15
GET /api/categories/{id}
특정 카테고리의 상세 정보를 조회합니다.
id(number, 필수): 조회할 카테고리 ID
{
"id": 15,
"name": "무선 이어폰",
"parentIds": [1, 3],
"parentNames": ["전자제품", "액세서리"]
}- 404: 카테고리가 존재하지 않는 경우
curl -X GET http://localhost:8080/api/categories/15
GET /api/categories/{id}/tree
특정 카테고리를 루트로 하는 하위 트리 구조를 조회합니다.
id(number, 필수): 루트로 사용할 카테고리 ID
{
"id": 1,
"name": "전자제품",
"parentIds": [],
"children": [
{
"id": 2,
"name": "스마트폰",
"parentIds": [1],
"children": [
{
"id": 3,
"name": "아이폰",
"parentIds": [2],
"children": []
},
{
"id": 4,
"name": "갤럭시",
"parentIds": [2],
"children": []
}
]
}
]
}- 순환 참조가 감지되면
"(circular reference)"표시 - 자식들은 이름순으로 정렬됨
- 404: 카테고리가 존재하지 않는 경우
curl -X GET http://localhost:8080/api/categories/1/tree
GET /api/categories/{id}/paths
특정 카테고리까지의 모든 가능한 경로를 조회합니다.
id(number, 필수): 경로를 조회할 카테고리 ID
{
"id": 10,
"name": "무선 이어폰",
"paths": [
{
"pathIds": [1, 10],
"pathNames": ["전자제품", "무선 이어폰"],
"depth": 1
},
{
"pathIds": [3, 10],
"pathNames": ["액세서리", "무선 이어폰"],
"depth": 1
},
{
"pathIds": [1, 2, 10],
"pathNames": ["전자제품", "오디오", "무선 이어폰"],
"depth": 2
}
]
}pathIds: 루트부터 현재 카테고리까지의 ID 경로pathNames: 루트부터 현재 카테고리까지의 이름 경로depth: 루트로부터의 깊이 (0: 루트, 1: 1단계 하위...)
- 404: 카테고리가 존재하지 않는 경우
curl -X GET http://localhost:8080/api/categories/10/paths
GET /api/categories
모든 카테고리를 조회합니다. 트리 형태 또는 평면 목록으로 조회 가능합니다.
tree(boolean, 선택, 기본값: false): 트리 형태로 반환할지 여부
[
{
"id": 1,
"name": "전자제품",
"parentIds": [],
"parentNames": []
},
{
"id": 2,
"name": "스마트폰",
"parentIds": [1],
"parentNames": ["전자제품"]
}
][
{
"id": 1,
"name": "전자제품",
"parentIds": [],
"children": [
{
"id": 2,
"name": "스마트폰",
"parentIds": [1],
"children": []
}
]
}
]# 평면 목록 curl -X GET http://localhost:8080/api/categories # 트리 형태 curl -X GET "http://localhost:8080/api/categories?tree=true"
GET /api/categories/roots
부모가 없는 최상위 카테고리들을 조회합니다.
[
{
"id": 1,
"name": "전자제품",
"parentIds": [],
"parentNames": []
},
{
"id": 5,
"name": "의류",
"parentIds": [],
"parentNames": []
}
]curl -X GET http://localhost:8080/api/categories/roots
GET /api/categories/parent/{parentId}/children
특정 카테고리의 직계 자식들을 조회합니다.
parentId(number, 필수): 부모 카테고리 ID
[
{
"id": 2,
"name": "스마트폰",
"parentIds": [1],
"parentNames": ["전자제품"]
},
{
"id": 3,
"name": "노트북",
"parentIds": [1],
"parentNames": ["전자제품"]
}
]- 404: 부모 카테고리가 존재하지 않는 경우
curl -X GET http://localhost:8080/api/categories/parent/1/children
GET /api/categories/depth/{depth}
특정 깊이에 있는 카테고리들을 조회합니다.
depth(number, 필수): 깊이 (0: 루트, 1: 1단계 하위...)
[
{
"id": 2,
"name": "스마트폰",
"parentIds": [1],
"parentNames": ["전자제품"]
},
{
"id": 6,
"name": "상의",
"parentIds": [5],
"parentNames": ["의류"]
}
]- 400: 깊이가 음수인 경우
curl -X GET http://localhost:8080/api/categories/depth/1
GET /api/categories/validate
전체 카테고리 계층 구조의 유효성을 검증합니다.
{
"isValid": true,
"errors": []
}{
"isValid": false,
"errors": [
"Category 5 has itself as parent",
"Category 7 has itself as child"
]
}curl -X GET http://localhost:8080/api/categories/validate
| 상태 코드 | 설명 |
|---|---|
| 200 OK | 조회/수정 성공 |
| 201 Created | 생성 성공 |
| 204 No Content | 삭제 성공 |
| 400 Bad Request | 잘못된 요청 (유효성 검사 실패 등) |
| 404 Not Found | 리소스를 찾을 수 없음 |
{
"errorCode": "NOT_FOUND",
"message": "Category(999) not found"
}- Language: Kotlin 2.2.0
- Framework: Spring Boot 3.5.5
- Database: H2 (In-Memory)
- ORM: Spring Data JPA + Hibernate
- Build Tool: Gradle 8.14
- API Documentation: SpringDoc OpenAPI (Swagger)
- Validation: Spring Boot Starter Validation
- Logging: Kotlin Logging + Logstash Logback Encoder
- Code Quality: Ktlint
# 전체 테스트 실행 ./gradlew test # 특정 모듈 테스트 ./gradlew :modules:api:test ./gradlew :modules:application:test ./gradlew :modules:infra:test # 특정 테스트 클래스 실행 ./gradlew :modules:api:test --tests CategoryControllerTest ./gradlew :modules:api:test --tests CategoryFullIntegrationTest # 특정 테스트 메서드 실행 ./gradlew :modules:api:test --tests "CategoryControllerTest.CreateCategoryTests.정상적인 카테고리 생성" # 빌드 + 테스트 + 코드 품질 검사 (CI/CD에서 사용) ./gradlew clean build
애플리케이션이 실행 중일 때 (./gradlew :modules:api:bootRun) 다음 curl 명령어로 API를 테스트할 수 있습니다:
# 1. 루트 카테고리 생성 curl -X POST http://localhost:8080/api/categories \ -H "Content-Type: application/json" \ -d '{"name": "전자제품"}' # 2. 자식 카테고리 생성 (부모 ID는 위에서 반환된 ID 사용) curl -X POST http://localhost:8080/api/categories \ -H "Content-Type: application/json" \ -d '{"name": "스마트폰", "parentIds": [1]}' # 3. 다중 부모 카테고리 생성 curl -X POST http://localhost:8080/api/categories \ -H "Content-Type: application/json" \ -d '{"name": "게이밍 스마트폰", "parentIds": [1, 2]}' # 4. 카테고리 조회 curl -X GET http://localhost:8080/api/categories/1 # 5. 카테고리 수정 (이름만 변경) curl -X PUT http://localhost:8080/api/categories/1 \ -H "Content-Type: application/json" \ -d '{"name": "소비자 전자제품"}' # 6. 카테고리 수정 (부모 관계만 변경) curl -X PUT http://localhost:8080/api/categories/3 \ -H "Content-Type: application/json" \ -d '{"parentIds": [1]}' # 7. 카테고리 삭제 curl -X DELETE http://localhost:8080/api/categories/3
# 전체 카테고리 조회 (평면 리스트) curl -X GET http://localhost:8080/api/categories # 전체 카테고리 조회 (트리 구조) curl -X GET "http://localhost:8080/api/categories?tree=true" # 루트 카테고리들만 조회 curl -X GET http://localhost:8080/api/categories/roots # 특정 카테고리의 트리 조회 curl -X GET http://localhost:8080/api/categories/1/tree # 특정 카테고리의 모든 경로 조회 curl -X GET http://localhost:8080/api/categories/2/paths # 특정 부모의 자식들 조회 curl -X GET http://localhost:8080/api/categories/parent/1/children # 특정 깊이의 카테고리들 조회 curl -X GET http://localhost:8080/api/categories/depth/1 # 계층 구조 유효성 검증 curl -X GET http://localhost:8080/api/categories/validate
# 전자상거래 카테고리 구조 예시 생성 curl -X POST http://localhost:8080/api/categories \ -H "Content-Type: application/json" \ -d '{"name": "패션"}' curl -X POST http://localhost:8080/api/categories \ -H "Content-Type: application/json" \ -d '{"name": "스포츠"}' # 교차 카테고리 생성 (패션 + 스포츠) curl -X POST http://localhost:8080/api/categories \ -H "Content-Type: application/json" \ -d '{"name": "스포츠웨어", "parentIds": [2, 3]}'
# 순환 참조 시도 (에러 발생해야 함) curl -X PUT http://localhost:8080/api/categories/1 \ -H "Content-Type: application/json" \ -d '{"parentIds": [2]}' \ -v # 상세 HTTP 응답 확인 # 존재하지 않는 부모로 생성 시도 (에러 발생해야 함) curl -X POST http://localhost:8080/api/categories \ -H "Content-Type: application/json" \ -d '{"name": "잘못된 카테고리", "parentIds": [999]}' \ -v # 자식이 있는 카테고리 삭제 시도 (에러 발생해야 함) curl -X DELETE http://localhost:8080/api/categories/1 -v
{
"id": 1,
"name": "전자제품",
"parentIds": [],
"parentNames": []
}{
"errorCode": "BAD_REQUEST",
"message": "Cannot update category: would create circular reference"
}# 프로젝트 빌드 ./gradlew build # JAR 파일 생성 ./gradlew :modules:api:bootJar # 생성된 JAR 실행 java -jar modules/api/build/libs/api-0.0.1-SNAPSHOT.jar
- 멀티모듈 구조: 관심사 분리 및 모듈간 의존성 관리
- 계층형 아키텍처: API → Application → Core → Infra
- 도메인 주도 설계: 비즈니스 로직과 인프라 분리
- 트리 구조: 무제한 depth 카테고리 계층 지원
- Lazy Loading: 성능 최적화를 위한 지연 로딩
- EntityGraph: N+1 문제 해결을 위한 페치 조인
- Swagger UI: 인터랙티브 API 문서
- H2 Console: 개발 중 데이터베이스 확인
- Hot Reload: 개발 중 자동 재시작
core모듈에 DTO 정의application모듈에 서비스 로직 구현api모듈에 컨트롤러 추가
infra모듈의 Entity 수정application.yml의ddl-auto설정 확인- 테스트 데이터 업데이트
- 형식: JSON 구조화 로깅 (Logstash 호환)
- 레벨: INFO (기본), DEBUG (개발 시)
- 추적: TraceId 기반 요청 추적
- Code Style: Ktlint를 통한 코드 스타일 검사
- API 테스트: Swagger UI를 통한 실시간 테스트