왜 API Convention을 따로 정리했는가
- API 개발은 프로젝트마다 그 구조와 코딩 스타일이 상당히 정형화되어있다.
- Controller/Service 기반 계층 구조
BaseResponse기반 정형화된 응답
- 정책에 따라 만들어야 하는 API가 정해져있다.
- 다중 조회는 무조건 페이징
- 수정은 일부 수정을 원칙으로 PATCH Method로 처리
이 Convention의 목표
- 일관된 API 레이어 구조 : Controller/Service/DTO/Exception/Entity 역할 분리
- 얇은 Controller / 두꺼운 Service / Repository 전담 쿼리: 의존 방향 고정
- 비즈니스 로직은 최대한 Entity에: 코드가 더러워지는 것을 막기
이 Convention이 다루는 범위 (Scope)
이 문서는 API 레이어(Controller/Service/DTO/Exception/Entity)의 작성 규칙과 사용법을 다룬다.
- Controller 작성 규칙(헤더/바인딩/응답)
- Service 작성 규칙(트랜잭션/페이징 호출/Entity 변경 위치)
- DTO 구조/네이밍/검증
- Exception 규칙(상태코드, 네이밍, 글로벌 핸들러 사용)
- Entity 기본 규칙(BaseEntity, LAZY, 비즈니스 로직 위치)
아래는 내용은 JPA Convention으로 분리한다.
- DB/Repository/QueryDSL/JPA/PagingHandlers 내부 구현 상세
프로젝트 구조: Domain-first
도메인 기준으로 패키지를 나누고, 각 도메인은 동일한 내부 구조를 유지한다.
src/main/kotlin/com/dalmeng/convention/
├── common/
│ ├── entity/ # BaseEntity
│ ├── exception/ # BaseException, GlobalExceptionHandler
│ ├── paging/ # BasePagingRequest, PagingResponse, PagingService
│ └── response/ # BaseResponse, ErrorResponse
└── {domain}/
├── controller/
├── service/
├── repository/
├── entity/
├── dto/
│ ├── request/
│ └── response/
├── exception/
└── paging/의존성 방향(Dependency direction)
- 의존성 방향 :
controller → service → repository - Controller/Service에서 다른 도메인의
entity를 직접 변경하지 않는다.- 다른 도메인 자원이 필요하면 그 도메인의 Service/QueryRepository를 사용한다.
- API에서 JPA Entity를 직접 반환하지 않는다.
공통 응답 규칙
성공/실패 응답은 BaseResponse로 통일한다.
규칙
- Controller return type은 항상
BaseResponse<T> - 성공 응답은
BaseResponse.ok(data = ...) - [금지] Controller에서
ResponseEntity를 직접 만들지 않는다.- 예외: 파일 다운로드/스트리밍처럼 JSON이 아닌 응답
[성공 응답 예시]
Kotlin
@GetMapping("/{id}")
fun getById(
@RequestHeader("X-User-Id") userId: String,
@PathVariable id: String,
): BaseResponse<ChatroomResponse> =
BaseResponse.ok(data = chatroomService.findById(userId, id))[실패 응답 예시]
Controller에선 실패 응답을 직접 만들지 않는다.
Service/Repository에서 raise로 예외를 던지고, GlobalExceptionHandler가 BaseResponse 형태의 에러 응답으로 변환한다.
JSON
{
"statusCode": 404,
"message": "Failed",
"data": null,
"error": {
"message": "Chatroom not found"
}
}API URI 컨벤션
기본 원칙
- path는 명사 사용
- 리소스 식별자는 path variable로 받기:
/{id} - 기존 코드 스타일을 따라 base path는 단수형을 선호:
/chatroom,/character - 여러 단어는 하이픈:
/user-profile(언더스코어 지양) - [금지]
seq를 path/query/body로 받지 않는다.
예시
GET /chatroom/{id}: chatroom 단건 조회GET /chat/chatroom/{chatroomId}: 특정 chatroom의 chat 목록(페이징)
HTTP Method Semantics
GET (Read)
- 조회 전용
- 서버 상태 변경 금지
POST (Create)
- 생성 전용
- body는
dto/request/CreateXRequest
PATCH (Partial Update)
- 부분 수정 전용
- PATCH DTO는 모든 필드 nullable
- 실제 변경 적용은 Service가 아니라 Entity의
update()메서드에서 처리
DELETE (Delete)
- 삭제 전용
- soft delete를 우선 고려(삭제 전략/DB 룰은
rules/database.mdc)
Controller 컨벤션: “얇은 HTTP 어댑터”
Controller의 책임
- 바인딩 + Service 위임 +
BaseResponse로 감싸기 - [금지] 비즈니스 로직 구현 금지
- [금지]
try/catch로 예외 처리 금지(글로벌 핸들러가 처리)
사용자 식별자 헤더
- 유저 식별자는 헤더로 받는다.
- 헤더명:
X-User-Id - 시그니처:
@RequestHeader("X-User-Id") userId: String
- 헤더명:
- 받은
userId를 그대로 Service로 전달 - 선택 로그인인 경우에만
String?허용 - [금지] Controller에서 인증/인가 로직을 구현하지 않는다.
Controller 표준 템플릿
Kotlin
@RestController
@RequestMapping("/resources")
class ResourceController(
private val resourceService: ResourceService,
) {
@PostMapping
fun create(
@RequestHeader("X-User-Id") userId: String,
@RequestBody request: CreateResourceRequest,
): BaseResponse<ResourceResponse> =
BaseResponse.ok(data = resourceService.create(userId, request))
@GetMapping("/{id}")
fun getById(
@RequestHeader("X-User-Id") userId: String,
@PathVariable id: String,
): BaseResponse<ResourceResponse> =
BaseResponse.ok(data = resourceService.findById(userId, id))
@GetMapping
fun getAllPaging(
@RequestHeader("X-User-Id") userId: String,
@ModelAttribute request: BasePagingRequest,
): BaseResponse<PagingResponse<ResourceResponse>> =
BaseResponse.ok(data = resourceService.findAll(userId, request))
@PatchMapping("/{id}")
fun update(
@RequestHeader("X-User-Id") userId: String,
@PathVariable id: String,
@RequestBody request: UpdateResourceRequest,
): BaseResponse<ResourceResponse> =
BaseResponse.ok(data = resourceService.update(userId, id, request))
@DeleteMapping("/{id}")
fun delete(
@RequestHeader("X-User-Id") userId: String,
@PathVariable id: String,
): BaseResponse<Nothing> =
BaseResponse.ok(data = null)
}컬렉션 조회는 기본 페이징
컬렉션 조회는 요구사항이 명시되지 않으면 페이징이 기본값이다.
Controller에서 받는 방식
@ModelAttribute request: BasePagingRequest로 바인딩- 응답은
BaseResponse<PagingResponse<ResponseDto>>
예시: 채팅방의 채팅 기록 조회(페이징)
Kotlin
@GetMapping("/chatroom/{chatroomId}")
fun findChatroomChatRecord(
@RequestHeader("X-User-Id") userId: String,
@PathVariable chatroomId: String,
@ModelAttribute request: BasePagingRequest,
): BaseResponse<PagingResponse<ChatGroupResponse>> =
BaseResponse.ok(data = chatService.findChatGroups(userId, chatroomId, request))요청 예시(Query Params)
Kotlin
GET /chat/chatroom/chr_123?limit=20&cursor=cg_abc&direction=DOWN
X-User-Id: user_001응답 예시(PagingResponse)
Kotlin
{
"statusCode": 200,
"message": "Succeed",
"data": {
"items": [
{ "id": "cg_001", "message": "hello" },
{ "id": "cg_002", "message": "world" }
],
"page": null,
"limit": 20,
"nextCursor": "cg_002",
"prevCursor": null,
"hasNext": true,
"hasPrev": false,
"itemCount": 2
},
"error": null
}서비스 계층 (Service Layer)
Service의 책임
- 비즈니스 로직은 되도록 Service 계층 내에서 다루는 Entity에 작성
- 소유권/권한 검증은 Service에서 수행(예:
userIdvs owner) - Entity를 반환하지 않고 DTO만 반환
트랜잭션(Transactions)
- 조회는
@Transactional(readOnly = true) - 생성/수정/삭제는
@Transactional - [금지] Controller에 트랜잭션 금지
함수 시그니처/네이밍
- 조회 단건:
findXById(userId, id) - 조회 다건(페이징):
findXs(userId, request) - 생성:
createX(userId, request) - 수정:
updateX(userId, id, request) - 삭제:
deleteX(userId, id)
Service에서의 페이징 사용법(핵심)
PagingService.paginate(request, handlers)사용pagingService.map(...)로 Entity → DTO 변환- [금지] 절대 Entity를
PagingResponse로 반환 금지
[예시]
Kotlin
@Transactional(readOnly = true)
fun findXs(userId: String, request: BasePagingRequest): PagingResponse<XResponse> {
val handlers = XPagingHandlers(*/* ... */*)
val entities = pagingService.paginate(request = request, handlers = handlers)
return pagingService.map(entities) { XResponse.from(it) }
}PagingHandlers구현/Repository 쿼리 상세는 JPA Covention을 따른다.
DTO Convention
폴더 구조
- 도메인 하위에
dto/request,dto/response
{domain}/dto/
├── request/
│ ├── CreateXRequest.kt
│ └── UpdateXRequest.kt
└── response/
└── XResponse.ktRequest DTO 규칙
Create Request
- 필요한 필드는 non-null
- 검증이 필요하면
spring-boot-starter-validation애노테이션 사용 - Controller에서
@Valid로 검증 트리거
[예시]
Kotlin
data class CreateTodoRequest(
@field:NotBlank
@field:Size(max = 100)
val title: String,
@field:Size(max = 1000)
val description: String?,
)[Controller 적용 예시]
Kotlin
@PostMapping
fun create(
@RequestHeader("X-User-Id") userId: String,
@Valid @RequestBody request: CreateTodoRequest,
): BaseResponse<TodoResponse> =
BaseResponse.ok(data = todoService.createTodo(userId, request))Patch(Update) Request
- 모든 필드 nullable(부분 수정)
- Service/Entity update 로직은 nullable을 받아
?.let {}로 반영
[예시]
Kotlin
data class UpdateTodoRequest(
val title: String?,
val description: String?,
val isCompleted: Boolean?,
)Response DTO
- API에 노출 가능한 필드만 포함
- Entity → Response는
companion object { fun from(entity) } - [금지]
seq절대 포함 금지(DB 내부 PK) - [금지] Response 안에 JPA Entity 중첩 금지
[예시]
Kotlin
data class TodoResponse(
val id: String,
val title: String,
val description: String?,
val isCompleted: Boolean,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
) {
companion object {
fun from(todo: TodoEntity): TodoResponse =
TodoResponse(
id = todo.id,
title = todo.title,
description = todo.description,
isCompleted = todo.isCompleted,
createdAt = todo.createdAt,
updatedAt = todo.updatedAt,
)
}
}상황에 따라 필드가 없을 수도 있으면? (Optional fields)
- 상황별로 생략 가능한 필드는 Response에서도 nullable로 모델링
from(entity)오버로드/플래그로 제어
[예시]
Kotlin
data class UserProfileResponse(
val id: String,
val nickname: String,
val bio: String?,
val metadata: UserProfileMetadataResponse?, *// 상황별로 포함될 수도, 아닐 수도*
) {
companion object {
fun from(entity: UserProfileEntity, includeMetadata: Boolean): UserProfileResponse =
UserProfileResponse(
id = entity.id,
nickname = entity.nickname,
bio = entity.bio,
metadata = if (includeMetadata) UserProfileMetadataResponse.from(entity.metadata) else null,
)
}
}Exception Convention
기본 원칙
- 예상 가능한 실패(리소스 없음, 권한 없음, 도메인 규칙 위반)는 커스텀 예외로 표현
- 도메인 예외는
{domain}/exception/아래에 둔다 BaseException(statusCode, message)를 상속한다- [금지] Controller/Service에서 의미 없는
try/catch금지 - [금지] 예외를 삼키지 않는다(로그만 찍고 성공 반환 금지)
네이밍 규칙
XNotFoundException(404)InvalidXRequestException/InvalidXException(400)XForbiddenException(403),XUnauthorizedException(401),XConflictException(409)
Status Codes
- 400: validation 실패, 잘못된 조합, 입력 수준의 도메인 규칙 위반
- 401: 인증 필요(프로젝트 정책에 따름)
- 403: 권한/소유권 불일치
- 404: 리소스 없음
- 409: 충돌(중복 생성, 상태 충돌)
- 500: 예상 못한 오류(글로벌 핸들러 처리)
Entity Convention
- BaseEntity + LAZY + 상태 변경은 Entity 안에서
BaseEntity 상속
- 모든 Entity는
BaseEntity를 상속 - 핵심 필드 컨셉
seq: Long= 내부 DB PK(절대 외부 노출 금지)id: String= 외부 식별자(API-facing)createdAt,updatedAt
JPA 연관관계
- 모든 연관관계는
FetchType.LAZY - [금지]
EAGER금지
비즈니스 로직 위치
- 상태 변경(수정/삭제 플래그/연관관계 변경)은 Entity 메서드로 캡슐화
- [금지] Service에서 Entity 필드를 직접
todo.title = ...처럼 수정 금지
예시: Entity update 패턴(Dirty Checking)
Kotlin
fun update(
title: String?,
description: String?,
isCompleted: Boolean?,
) {
title?.let { this.title = it }
description?.let { this.description = it }
isCompleted?.let { this.isCompleted = it }
this.updatedAt = LocalDateTime.now()
}
댓글 남기기