Spring Convention Cursor Rules [API]


왜 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로 예외를 던지고, GlobalExceptionHandlerBaseResponse 형태의 에러 응답으로 변환한다.

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에서 수행(예: userId vs 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.kt

Request 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()
}

댓글 남기기

Dalmeng's Footprints에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기