LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

백엔드스터디Spring BootJavaAPIDTOAI 코드 읽기

백엔드 스터디 Day 2: API 계약과 DTO를 읽는 법

2026년 6월 1일·29분 읽기

예상 읽기 시간: 20~30분

오늘의 목표

Day 1에서는 Spring Boot 프로젝트를 큰 지도로 봤습니다. 요청이 들어오면 Controller가 받고, Service가 규칙을 판단하고, Repository가 DB와 대화하며, Entity와 DTO가 데이터의 모양을 나눈다는 흐름을 잡았습니다.

오늘은 그중에서도 AI가 만든 백엔드 코드를 리뷰할 때 가장 먼저 봐야 하는 부분을 다룹니다.

프론트엔드와 백엔드가 서로 약속한 API 계약이 맞는가?

백엔드 코드는 내부적으로 아무리 깔끔해 보여도, 프론트엔드가 보내는 요청과 백엔드가 받는 요청이 다르면 실제 서비스에서는 실패합니다. 반대로 백엔드가 응답하는 데이터 이름이 프론트엔드가 기대하는 이름과 다르면 화면은 비어 보이거나 이상하게 분류됩니다.

오늘 글을 읽고 나면 아래 질문에 답할 수 있어야 합니다.

  • API 계약이란 무엇인가?
  • @GetMapping, @PostMapping, @RequestBody, @PathVariable, @RequestParam은 어떤 위치의 데이터를 읽는가?
  • DTO는 왜 Entity와 따로 두는가?
  • AI가 만든 요청 DTO와 응답 DTO에서 무엇을 확인해야 하는가?
  • 검증 어노테이션과 에러 응답은 왜 프론트엔드 경험과 연결되는가?
  • "코드는 돌아가는데 화면이 이상한" 문제를 API 계약 관점에서 어떻게 의심할 수 있는가?

1. API 계약은 서버와 화면 사이의 약속이다

API 계약이라는 말이 어렵게 들릴 수 있습니다. 하지만 실제 의미는 단순합니다.

프론트엔드와 백엔드가 서로 이렇게 약속하는 것입니다.

예를 들어 게시글 작성 API를 생각해 봅시다.

이 요청에 대해 백엔드는 성공 시 이런 응답을 줄 수 있습니다.

포스트 목록

/study/backend
파일 8개, 폴더 0개
백엔드 스터디 Day 1: 스프링 프로젝트를 읽기 위한 최소 지도백엔드 스터디 Day 2: API 계약과 DTO를 읽는 법백엔드 스터디 Day 3: 검증과 에러 응답을 읽는 법백엔드 스터디 Day 4: Entity와 Repository로 DB 흐름 읽기백엔드 스터디 Day 5: Service와 트랜잭션으로 비즈니스 흐름 읽기백엔드 스터디 Day 6: 로그인과 권한 흐름을 코드에서 읽기백엔드 스터디 Day 7: 테스트 코드로 AI 백엔드 검증하기백엔드 스터디 Day 8: 배포와 운영 환경 읽기
text
프론트엔드:
이 주소로, 이 방식으로, 이런 이름의 데이터를 보낼게.

백엔드:
그러면 나는 이런 규칙으로 처리하고, 이런 이름의 데이터를 돌려줄게.
text
POST /api/posts
Content-Type: application/json
Authorization: Bearer <access-token>

{
  "title": "백엔드 공부 시작",
  "content": "오늘은 API 계약을 읽어 본다.",
  "categoryId": 3
}
json
{
  "id": 101,
  "title": "백엔드 공부 시작",
  "content": "오늘은 API 계약을 읽어 본다.",
  "categoryId": 3,
  "authorName": "진규",
  "createdAt": "2026-06-01T09:00:00"
}

여기에는 여러 약속이 들어 있습니다.

  • 주소는 /api/posts이다.
  • 새 글 생성이므로 HTTP Method는 POST이다.
  • 요청 본문은 JSON이다.
  • title, content, categoryId라는 이름으로 값을 보낸다.
  • 성공 응답에는 id, title, content, categoryId, authorName, createdAt이 있다.
  • 로그인 토큰이 필요하다.

이 약속 중 하나만 어긋나도 문제가 생깁니다.

예를 들어 프론트엔드가 categoryID라고 보내는데 백엔드 DTO가 categoryId를 기대한다면, 대소문자 하나 차이로 값이 비어 들어갈 수 있습니다. 프론트엔드가 POST /api/post로 보내는데 백엔드가 /api/posts만 열어 두었다면 404가 납니다. 백엔드가 created_at으로 응답하는데 프론트엔드가 createdAt을 읽으면 작성 시간이 화면에 표시되지 않을 수 있습니다.

AI가 만든 코드에서는 이런 문제가 꽤 자주 발생합니다. AI는 각각의 파일을 그럴듯하게 만들 수 있지만, 프론트엔드와 백엔드 사이의 이름, 주소, 응답 모양을 끝까지 맞추는 일은 사람이 검토해야 할 때가 많습니다.


2. Controller에서 API 계약의 입구를 찾는다

Spring Boot에서 API 계약의 입구는 보통 Controller입니다.

예시는 이렇게 생겼습니다.

java
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @PostMapping
    public PostResponse createPost(
            @Valid @RequestBody PostCreateRequest request,
            @AuthenticationPrincipal UserPrincipal user
    ) {
        return postService.createPost(request, user.getId());
    }

    @GetMapping("/{postId}")
    public PostResponse getPost(@PathVariable  postId 

처음 보면 어노테이션이 많아서 복잡해 보입니다. 하지만 읽는 순서를 정하면 어렵지 않습니다.

2.1 기본 주소: @RequestMapping

java
@RequestMapping("/api/posts")

이 Controller 안의 API들은 기본적으로 /api/posts로 시작합니다.

2.2 세부 주소와 행동: @PostMapping, @GetMapping

java
@PostMapping
public PostResponse createPost(...)

@PostMapping에 별도 경로가 없으므로 전체 주소는 다음과 같습니다.

text
POST /api/posts

아래 코드는 다릅니다.

java
@GetMapping("/{postId}")
public PostResponse getPost(@PathVariable Long postId)

기본 주소 /api/posts에 /{postId}가 붙습니다.

text
GET /api/posts/{postId}

실제 호출은 이런 모양입니다.

text
GET /api/posts/101

AI가 만든 Controller를 볼 때는 먼저 이 두 가지를 확인하면 됩니다.

  • 전체 URL이 무엇인가?
  • HTTP Method가 무엇인가?

이 단계에서 프론트엔드 API 호출 코드와 비교해야 합니다.

ts
// 프론트엔드 예시
await fetch("/api/posts", {
  method: "POST",
  body: JSON.stringify({ title, content, categoryId }),
});

백엔드가 POST /api/posts를 열고 있고 프론트엔드도 POST /api/posts를 호출하면 첫 번째 약속은 맞습니다.


3. 요청 데이터는 위치가 다르다

프론트엔드가 서버로 보내는 데이터는 한 곳에만 있지 않습니다. 위치가 다릅니다.

Spring Boot에서는 데이터 위치에 따라 읽는 어노테이션도 다릅니다.

text
URL 경로 안의 값       -> @PathVariable
물음표 뒤의 값         -> @RequestParam
JSON 본문              -> @RequestBody
로그인 사용자 정보     -> @AuthenticationPrincipal
헤더                   -> @RequestHeader

각각을 낮은 단계부터 봅시다.

3.1 @PathVariable: 주소 안의 값

java
@GetMapping("/{postId}")
public PostResponse getPost(@PathVariable Long postId) {
    return postService.getPost(postId);
}

요청이 이렇게 들어오면,

text
GET /api/posts/101

postId에는 101이 들어갑니다.

여기서 확인할 점은 이름입니다.

java
@GetMapping("/{id}")
public PostResponse getPost(@PathVariable Long postId) { ... }

이렇게 경로에는 {id}라고 되어 있는데 변수는 postId라면 설정에 따라 매핑이 실패하거나 헷갈리는 코드가 됩니다. 안전하게는 이름을 맞추는 편이 좋습니다.

java
@GetMapping("/{postId}")
public PostResponse getPost(@PathVariable Long postId) { ... }

AI가 코드를 만들 때 이런 이름 불일치가 생길 수 있으므로, 경로 변수 이름과 메서드 파라미터 이름을 같이 봐야 합니다.

3.2 @RequestParam: 검색 조건, 페이지 번호 같은 값

java
@GetMapping
public Page<PostSummaryResponse> getPosts(
        @RequestParam(required = false) String keyword,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size
) {
    return postService.getPosts(keyword, page, size);
}

이 API는 이런 요청을 받습니다.

text
GET /api/posts?keyword=spring&page=0&size=20

? 뒤에 붙는 값들이 @RequestParam입니다.

리뷰할 때는 아래를 확인합니다.

  • 프론트엔드가 보내는 쿼리 이름과 백엔드 파라미터 이름이 같은가?
  • 기본값이 적절한가?
  • 페이지 번호가 0부터 시작하는지 1부터 시작하는지 프론트엔드와 약속되어 있는가?
  • size가 너무 크게 들어왔을 때 제한이 있는가?

특히 페이지 번호는 자주 어긋납니다. 백엔드는 page=0을 첫 페이지로 보는데, 프론트엔드는 page=1을 첫 페이지로 보낼 수 있습니다. 그러면 첫 화면에서 두 번째 페이지가 보이거나, 비어 보이는 버그가 생깁니다.

3.3 @RequestBody: JSON 본문

게시글 작성처럼 본문이 있는 요청은 @RequestBody로 받습니다.

java
@PostMapping
public PostResponse createPost(@Valid @RequestBody PostCreateRequest request) {
    return postService.createPost(request);
}

여기서 PostCreateRequest가 요청 DTO입니다.

java
public record PostCreateRequest(
        @NotBlank String title,
        @NotBlank String content,
        @NotNull Long categoryId
) {}

프론트엔드는 이런 JSON을 보내야 합니다.

json
{
  "title": "백엔드 공부 시작",
  "content": "오늘은 DTO를 본다.",
  "categoryId": 3
}

이제 API 계약을 읽는 핵심이 나옵니다.

요청 DTO의 필드 이름이 곧 프론트엔드가 보내야 하는 JSON 이름이다.

따라서 AI가 만든 DTO를 보면 바로 프론트엔드 요청과 비교해야 합니다.

java
public record PostCreateRequest(
        String postTitle,
        String body,
        Long category
) {}

이런 DTO를 AI가 만들었다면 프론트엔드가 기존에 보내던 title, content, categoryId와 맞지 않습니다. 문법은 맞아도 계약은 틀린 코드입니다.


4. DTO는 외부와 주고받는 데이터의 모양이다

DTO는 Data Transfer Object의 줄임말입니다. 이름은 어렵지만 의미는 단순합니다.

계층 사이 또는 서버 밖과 데이터를 주고받기 위해 만든 데이터 모양.

처음에는 Entity 하나로 다 하면 안 되나 싶을 수 있습니다. 예를 들어 DB 테이블과 연결된 Post Entity가 있다면, 그냥 그걸 요청과 응답에 쓰면 간단해 보입니다.

java
@Entity
public class Post {
    @Id
    @GeneratedValue
    private Long id;

    private String title;
    private String content;

    @ManyToOne
    private User author;

    @ManyToOne
    private Category category;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

하지만 API에서는 이 Entity를 그대로 밖으로 내보내면 위험합니다.

4.1 Entity를 그대로 응답하면 내부 구조가 새어 나간다

Post 안에는 author, category, createdAt, updatedAt 같은 내부 구조가 있습니다. author 안에는 이메일, 권한, 비밀번호 해시 같은 민감한 값이 있을 수도 있습니다.

Entity를 그대로 JSON으로 응답하면 의도하지 않은 값이 밖으로 나갈 수 있습니다. 또 Post -> User -> Posts -> User처럼 관계가 서로 물고 있으면 JSON 변환 중 순환 참조 문제가 생길 수도 있습니다.

그래서 응답 DTO를 따로 둡니다.

java
public record PostResponse(
        Long id,
        String title,
        String content,
        Long categoryId,
        String categoryName,
        String authorName,
        LocalDateTime createdAt
) {
    public static PostResponse from(Post post) {
        return new PostResponse(
                post.getId(),
                post.getTitle(),
                post.getContent(),
                post.getCategory().getId

이 DTO는 프론트엔드에 필요한 값만 골라서 내보냅니다.

4.2 요청 DTO와 응답 DTO는 다르다

요청 DTO와 응답 DTO를 같은 것으로 착각하면 안 됩니다.

게시글 작성 요청에는 이런 값이 필요합니다.

java
public record PostCreateRequest(
        @NotBlank String title,
        @NotBlank String content,
        @NotNull Long categoryId
) {}

하지만 작성 응답에는 서버가 만든 값도 들어갑니다.

java
public record PostResponse(
        Long id,
        String title,
        String content,
        Long categoryId,
        String authorName,
        LocalDateTime createdAt
) {}

요청할 때는 id, authorName, createdAt을 프론트엔드가 보낼 수 없습니다. 서버가 생성하거나 로그인 정보에서 가져오는 값이기 때문입니다.

AI가 만든 코드를 리뷰할 때는 DTO를 이렇게 나눠 봅니다.

text
CreateRequest: 생성할 때 사용자가 보내는 값만 있는가?
UpdateRequest: 수정 가능한 값만 있는가?
Response: 화면에 필요한 값만 안전하게 나가는가?
SummaryResponse: 목록 화면용으로 너무 무겁지 않은가?

5. 검증 어노테이션은 프론트엔드 UX와 연결된다

요청 DTO에는 종종 이런 어노테이션이 붙습니다.

java
public record PostCreateRequest(
        @NotBlank(message = "제목은 필수입니다.")
        @Size(max = 100, message = "제목은 100자 이하로 입력해 주세요.")
        String title,

        @NotBlank(message = "본문은 필수입니다.")
        String content,

        @NotNull(message = "카테고리를 선택해 주세요.")
        Long categoryId
) {}

@NotBlank, @NotNull, @Size 같은 것이 검증 어노테이션입니다.

이 검증은 단순히 백엔드만을 위한 것이 아닙니다. 프론트엔드 사용자 경험과도 연결됩니다.

사용자가 제목을 비워 둔 채 등록 버튼을 눌렀다고 합시다. 백엔드가 검증 없이 DB 저장을 시도하면 이상한 데이터가 들어가거나 DB 오류가 날 수 있습니다. 반대로 백엔드가 검증을 하고 친절한 에러 응답을 주면, 프론트엔드는 "제목은 필수입니다"라는 메시지를 화면에 보여 줄 수 있습니다.

중요한 점은 @Valid입니다.

java
@PostMapping
public PostResponse createPost(@Valid @RequestBody PostCreateRequest request) {
    return postService.createPost(request);
}

DTO 필드에 검증 어노테이션이 있어도 Controller에서 @Valid를 빼먹으면 검증이 실행되지 않을 수 있습니다.

AI가 만든 코드에서 자주 볼 수 있는 문제입니다.

java
@PostMapping
public PostResponse createPost(@RequestBody PostCreateRequest request) {
    return postService.createPost(request);
}

DTO에는 @NotBlank가 잔뜩 붙어 있는데 Controller에는 @Valid가 없습니다. 이 경우 "검증을 넣은 것처럼 보이지만 실제로는 작동하지 않는" 코드가 될 수 있습니다.

리뷰 체크는 간단합니다.

  • 요청 DTO에 필요한 검증 어노테이션이 있는가?
  • Controller의 @RequestBody 앞에 @Valid가 있는가?
  • 검증 실패 시 공통 에러 응답이 프론트엔드가 읽을 수 있는 모양인가?

6. 에러 응답도 API 계약이다

성공 응답만 API 계약이 아닙니다. 실패 응답도 계약입니다.

프론트엔드는 실패했을 때도 뭔가를 해야 합니다.

  • 로그인 만료면 로그인 화면으로 보낸다.
  • 권한 없음이면 안내 메시지를 보여 준다.
  • 입력값 오류면 필드별 메시지를 보여 준다.
  • 서버 오류면 잠시 후 다시 시도하라고 안내한다.

그러려면 백엔드 에러 응답 모양이 일정해야 합니다.

예를 들어 이런 모양을 약속할 수 있습니다.

json
{
  "code": "VALIDATION_ERROR",
  "message": "입력값을 확인해 주세요.",
  "fieldErrors": {
    "title": "제목은 필수입니다.",
    "categoryId": "카테고리를 선택해 주세요."
  }
}

또는 단순하게 이렇게 시작할 수도 있습니다.

json
{
  "code": "POST_NOT_FOUND",
  "message": "게시글을 찾을 수 없습니다."
}

중요한 것은 모든 API가 실패할 때 제각각 다른 모양을 내지 않는 것입니다.

나쁜 예시는 이런 것입니다.

json
"게시글이 없습니다"

어떤 API는 문자열만 반환하고,

json
{
  "error": "not found"
}

어떤 API는 error만 반환하고,

json
{
  "status": 404,
  "timestamp": "...",
  "path": "/api/posts/999"
}

어떤 API는 Spring 기본 에러를 그대로 반환하면 프론트엔드가 공통 처리하기 어렵습니다.

Spring Boot에서는 보통 @RestControllerAdvice로 공통 에러 응답을 만듭니다.

java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(PostNotFoundException.class)
    public ResponseEntity<ErrorResponse> handlePostNotFound(PostNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("POST_NOT_FOUND", ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity  ex 

처음부터 이 코드를 다 외울 필요는 없습니다. 읽을 때는 이렇게 보면 됩니다.

text
예외가 발생했을 때 JSON 응답 모양을 한 곳에서 통일하고 있는가?
프론트엔드가 code/message/fieldErrors 같은 값을 일관되게 읽을 수 있는가?
HTTP 상태 코드가 의미와 맞는가?

7. HTTP 상태 코드는 화면의 판단 재료다

API 응답에는 JSON 본문만 있는 것이 아닙니다. HTTP 상태 코드도 있습니다.

자주 보는 코드는 아래 정도입니다.

text
200 OK             조회/수정 성공
201 Created        생성 성공
204 No Content     삭제 성공, 응답 본문 없음
400 Bad Request    요청 데이터가 잘못됨
401 Unauthorized   로그인 필요 또는 토큰 문제
403 Forbidden      로그인은 했지만 권한 없음
404 Not Found      대상 리소스를 찾을 수 없음
409 Conflict       이미 존재하거나 상태 충돌
500 Server Error   서버 내부 오류

AI가 만든 코드를 볼 때 상태 코드가 의미와 맞는지도 확인해야 합니다.

예를 들어 로그인하지 않은 사용자가 글을 삭제하려 할 때 500이 나오면 안 됩니다. 서버가 고장 난 것이 아니라 인증이 필요한 상황이기 때문입니다. 이때는 401 또는 403이 맞습니다.

없는 게시글을 조회했는데 빈 객체를 200으로 반환하는 것도 조심해야 합니다. 프론트엔드는 성공으로 오해할 수 있습니다. 보통은 404를 주고, 프론트엔드가 "게시글을 찾을 수 없습니다" 화면을 보여 주는 편이 명확합니다.

생성 API도 마찬가지입니다.

java
@PostMapping
public ResponseEntity<PostResponse> createPost(@Valid @RequestBody PostCreateRequest request) {
    PostResponse response = postService.createPost(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

201 Created를 꼭 써야만 하는 것은 아니지만, 생성 성공이라는 의미를 명확히 해 줍니다. 서비스 초반에는 200으로 통일해도 되지만, 리뷰할 때 "이 API의 의미와 상태 코드가 맞는가"를 보는 습관은 필요합니다.


8. AI 코드 리뷰에서 DTO를 보는 순서

이제 실제로 AI가 만든 Spring Boot 코드를 받았다고 생각해 봅시다. 어디부터 봐야 할까요?

문법 줄 단위로 들어가기 전에, 아래 순서로 보면 효율적입니다.

8.1 Controller의 주소표 만들기

먼저 Controller 파일을 열고 API 표를 만듭니다.

text
POST   /api/posts            게시글 생성
GET    /api/posts/{postId}   게시글 단건 조회
GET    /api/posts            게시글 목록 조회
PATCH  /api/posts/{postId}   게시글 수정
DELETE /api/posts/{postId}   게시글 삭제

이 표를 프론트엔드 API 클라이언트와 비교합니다.

ts
export const postApi = {
  create: (body: PostCreateRequest) => api.post("/api/posts", body),
  get: (postId: number) => api.get(`/api/posts/${postId}`),
  list: (params: PostListParams) => api.get("/api/posts", { params }),
  update: (postId: number, body: PostUpdateRequest) =>

여기서 주소와 Method가 다르면 가장 먼저 고쳐야 합니다.

8.2 요청 DTO와 프론트엔드 payload 비교

다음은 요청 DTO입니다.

java
public record PostCreateRequest(
        String title,
        String content,
        Long categoryId
) {}

프론트엔드 payload가 아래처럼 생겼다면 맞습니다.

ts
const payload = {
  title,
  content,
  categoryId,
};

하지만 프론트엔드가 이렇게 보내고 있다면 어긋납니다.

ts
const payload = {
  postTitle: title,
  body: content,
  category: categoryId,
};

이 경우 백엔드 DTO를 바꾸든, 프론트엔드 payload를 바꾸든 하나로 맞춰야 합니다.

8.3 응답 DTO와 화면에서 읽는 값 비교

응답 DTO도 봅니다.

java
public record PostSummaryResponse(
        Long id,
        String title,
        String authorName,
        LocalDateTime createdAt
) {}

프론트엔드 목록 카드가 이렇게 읽는다면 맞습니다.

tsx
<PostCard
  title={post.title}
  authorName={post.authorName}
  createdAt={post.createdAt}
/>

하지만 프론트엔드가 post.author.nickname을 기대한다면 응답 모양이 다릅니다.

이런 불일치는 타입스크립트 타입이 있으면 잡히기도 하지만, API 타입이 수동으로 작성되어 있거나 any가 섞이면 실제 실행 전까지 놓칠 수 있습니다.

8.4 서버가 만든 값과 사용자가 보내는 값을 구분

요청 DTO에 이런 필드가 있으면 의심해야 합니다.

java
public record PostCreateRequest(
        Long id,
        Long authorId,
        LocalDateTime createdAt,
        String title,
        String content
) {}

id, authorId, createdAt은 보통 사용자가 마음대로 보내면 안 되는 값입니다.

  • id: DB가 생성
  • authorId: 로그인한 사용자 정보에서 가져옴
  • createdAt: 서버 시간이 생성

프론트엔드가 이런 값을 보내고 백엔드가 그대로 믿으면 보안 문제가 생깁니다. 다른 사람의 authorId로 글을 작성하는 식의 취약점이 될 수 있습니다.

AI가 만든 코드에서 특히 주의할 부분입니다. "필드가 많아서 편해 보이는 DTO"가 오히려 위험할 수 있습니다.


9. 작은 실전 예시: 댓글 작성 API 리뷰

간단한 댓글 작성 API를 리뷰해 봅시다.

AI가 아래 코드를 만들었습니다.

java
@RestController
@RequestMapping("/api/comments")
@RequiredArgsConstructor
public class CommentController {

    private final CommentService commentService;

    @PostMapping
    public CommentResponse createComment(@RequestBody CommentCreateRequest request) {
        return commentService.createComment(request);
    }
}

요청 DTO는 이렇습니다.

java
public record CommentCreateRequest(
        Long postId,
        Long userId,
        String content
) {}

겉보기에는 간단하게 잘 작동할 것 같습니다. 하지만 리뷰할 점이 많습니다.

9.1 주소가 리소스 관계를 잘 드러내는가?

댓글은 특정 게시글에 달립니다. 그래서 이런 주소도 가능합니다.

text
POST /api/posts/{postId}/comments

반드시 이 방식만 정답은 아니지만, 프론트엔드와 약속되어 있는지 확인해야 합니다. 프론트엔드가 /api/posts/101/comments를 호출하는데 백엔드는 /api/comments만 열어 두었다면 실패합니다.

9.2 userId를 요청 바디로 받아도 되는가?

요청 DTO에 userId가 있습니다.

java
public record CommentCreateRequest(
        Long postId,
        Long userId,
        String content
) {}

이건 위험할 수 있습니다. 댓글 작성자는 로그인 토큰에서 가져오는 것이 일반적입니다. 사용자가 요청 바디에 userId를 직접 넣게 하면 다른 사용자인 척 댓글을 쓰는 문제가 생길 수 있습니다.

더 안전한 모양은 이렇습니다.

java
public record CommentCreateRequest(
        @NotBlank String content
) {}

그리고 postId는 주소에서, userId는 인증 정보에서 가져옵니다.

java
@PostMapping("/api/posts/{postId}/comments")
public CommentResponse createComment(
        @PathVariable Long postId,
        @AuthenticationPrincipal UserPrincipal user,
        @Valid @RequestBody CommentCreateRequest request
) {
    return commentService.createComment(postId, user.getId(), request);
}

이 코드는 처음 코드보다 조금 길지만 역할이 명확합니다.

text
postId  -> 어떤 게시글에 다는 댓글인지, URL에서 온다.
userId  -> 누가 작성하는지, 로그인 정보에서 온다.
content -> 사용자가 입력한 댓글 내용, JSON 본문에서 온다.

9.3 검증이 실행되는가?

처음 코드에는 @Valid가 없습니다.

java
public CommentResponse createComment(@RequestBody CommentCreateRequest request)

댓글 내용이 비어 있으면 막아야 하므로 DTO와 Controller를 함께 봐야 합니다.

java
public record CommentCreateRequest(
        @NotBlank(message = "댓글 내용을 입력해 주세요.")
        @Size(max = 1000, message = "댓글은 1000자 이하로 입력해 주세요.")
        String content
) {}
java
public CommentResponse createComment(@Valid @RequestBody CommentCreateRequest request)

두 조각이 같이 있어야 합니다.


10. API 계약 리뷰 체크리스트

AI가 만든 Spring Boot API를 볼 때 아래 순서로 체크하면 됩니다.

Controller

  • @RequestMapping과 @GetMapping/@PostMapping을 합친 전체 URL을 적어 봤는가?
  • HTTP Method가 행동과 맞는가?
  • 프론트엔드 API 클라이언트가 같은 URL과 Method를 호출하는가?
  • @PathVariable 이름이 URL 경로 변수와 맞는가?
  • @RequestParam 이름과 기본값이 프론트엔드와 맞는가?
  • 요청 본문을 받는 곳에 @RequestBody가 있는가?
  • 검증이 필요한 요청에 @Valid가 있는가?

요청 DTO

  • 프론트엔드가 보내는 JSON 필드명과 DTO 필드명이 같은가?
  • 사용자가 보내면 안 되는 값이 요청 DTO에 들어가 있지 않은가? 예: id, authorId, createdAt, role
  • 필수값에는 @NotNull 또는 @NotBlank가 있는가?
  • 길이 제한, 범위 제한이 필요한 값에 검증이 있는가?
  • 생성 요청과 수정 요청 DTO가 분리되어 있는가?

응답 DTO

  • 화면이 실제로 필요한 값이 응답에 포함되어 있는가?
  • 민감한 내부 값이 응답에 섞여 있지 않은가?
  • 목록 응답이 너무 무겁지 않은가? 예: 상세 본문, 큰 관계 객체 전체
  • 프론트엔드가 읽는 필드명과 응답 DTO 필드명이 같은가?
  • 날짜 형식이 프론트엔드에서 처리 가능한가?

에러

  • 검증 실패 응답 모양이 일정한가?
  • 예외가 Spring 기본 HTML/기본 JSON으로 새어 나가지 않는가?
  • 401, 403, 404, 409, 500이 의미에 맞게 나뉘는가?
  • 프론트엔드가 code 또는 message를 안정적으로 읽을 수 있는가?

11. 오늘의 핵심 정리

오늘은 Spring Boot 문법을 많이 외우는 대신, API 계약과 DTO를 읽는 법을 잡았습니다.

핵심은 이것입니다.

백엔드 리뷰의 첫 단계는 "이 코드가 멋진가"가 아니라 "프론트엔드와 약속한 요청/응답 모양이 맞는가"이다.

오늘 내용만으로도 AI가 만든 백엔드 코드에서 꽤 많은 문제를 잡을 수 있습니다.

  • URL이 프론트엔드 호출과 다른 문제
  • HTTP Method가 의미와 맞지 않는 문제
  • DTO 필드명이 payload와 다른 문제
  • 사용자가 보내면 안 되는 값을 요청 DTO로 받는 문제
  • 검증 어노테이션은 있는데 @Valid가 빠진 문제
  • 응답 DTO가 화면이 기대하는 필드를 주지 않는 문제
  • 에러 응답이 제각각이라 프론트엔드가 처리하기 어려운 문제

다음 Day에서는 Service 계층을 볼 예정입니다. Controller와 DTO가 "외부 계약"을 다룬다면, Service는 "서비스 규칙"을 다룹니다. 예를 들어 게시글을 수정할 때 작성자만 수정할 수 있는지, 이미 삭제된 글은 어떻게 처리하는지, 댓글 수를 어떻게 계산하는지 같은 판단이 Service에 놓입니다.

문법을 완벽히 몰라도 괜찮습니다. 우리가 먼저 얻고 싶은 능력은 AI가 만든 코드를 보며 이런 질문을 던지는 것입니다.

text
이 값은 어디서 오지?
이 이름은 프론트엔드와 같나?
사용자가 이 값을 마음대로 보내도 되나?
실패하면 화면은 어떤 응답을 받지?

이 질문들이 생기기 시작하면, 백엔드 코드는 더 이상 검은 상자가 아닙니다.

Long
)
{
return postService.getPost(postId);
}
}
(
)
,
post.getCategory().getName(),
post.getAuthor().getName(),
post.getCreatedAt()
);
}
}
<ValidationErrorResponse>
handleValidation
(
MethodArgumentNotValidException
)
{
Map<String, String> fieldErrors = new HashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
fieldErrors.put(error.getField(), error.getDefaultMessage());
}
return ResponseEntity.badRequest()
.body(new ValidationErrorResponse("VALIDATION_ERROR", "입력값을 확인해 주세요.", fieldErrors));
}
}
api.patch(`/api/posts/${postId}`, body),
remove: (postId: number) => api.delete(`/api/posts/${postId}`),
};