모든 비교 글

백엔드

콘텐츠 상세 조회 API 구현

Java 21과 Spring Boot 3에서 비교 글 콘텐츠의 상세 정보를 반환하는 읽기 전용 API를 구현한다. 공개·삭제·미존재 콘텐츠와 유효하지 않은 ID를 계약된 응답으로 구분해야 한다.

발행일
2026년 7월 22일
비교 모델
Claude, Codex, Antigravity
데이터
발행 콘텐츠

Task

성공 조건

  • Controller, Service, Spring Data JPA Repository, 응답 DTO를 역할에 맞게 분리하고 엔티티의 status를 응답에 노출하지 않는다.
  • 공개 콘텐츠는 200, 미존재 콘텐츠는 CONTENT_NOT_FOUND를 담은 404, 삭제 콘텐츠는 CONTENT_DELETED를 담은 410으로 구분한다.
  • 숫자가 아니거나 0 이하인 ID에 INVALID_CONTENT_ID JSON 응답과 400을 반환한다.
  • 데이터베이스 연결 없이 MockMvc로 정상·404·400을 반드시 검증하고, 삭제 콘텐츠의 410도 구현한다.

Common conditions

핵심 조건

  • Spring Boot 3 읽기 전용 콘텐츠 상세 API를 최소 파일 수로 구현한다.
  • 삭제와 미존재를 다른 오류 계약으로 처리하고 엔티티를 직접 노출하지 않는다.
  • MockMvc로 정상, 없는 콘텐츠, 유효하지 않은 ID를 검증한다.

Model results

모델별 결과

ClaudeAnthropic / Opus 4.8 / default / 2026-07-22

문자열 ID 직접 파싱으로 비숫자·0 이하를 같은 계약으로 묶고, 예외 응답을 컨트롤러 범위에 한정한 구현.

콘텐츠 상세 조회 API 구현

Controller → Service → Repository로 역할을 나누고, 예외는 단일 ContentException에 상태 코드와 에러 코드를 실어 핸들러 하나로 처리했습니다. {id}는 문자열로 받아 직접 검증해 숫자가 아닌 값과 0 이하 값을 같은 400 계약으로 묶었습니다. 총 8개 파일(구현 7 + 테스트 1)입니다.

src/main/java/com/samebrief/content/content/Content.java

package com.samebrief.content.content;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "contents")
public class Content {

    public enum Status { PUBLISHED, DELETED }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String slug;

    private String title;

    private String body;

    @Enumerated(EnumType.STRING)
    private Status status;

    protected Content() {
    }

    public Content(String slug, String title, String body, Status status) {
        this.slug = slug;
        this.title = title;
        this.body = body;
        this.status = status;
    }

    public Long getId() {
        return id;
    }

    public String getSlug() {
        return slug;
    }

    public String getTitle() {
        return title;
    }

    public String getBody() {
        return body;
    }

    public boolean isDeleted() {
        return status == Status.DELETED;
    }
}

src/main/java/com/samebrief/content/content/ContentRepository.java

package com.samebrief.content.content;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ContentRepository extends JpaRepository<Content, Long> {
}

src/main/java/com/samebrief/content/content/ContentResponse.java

package com.samebrief.content.content;

public record ContentResponse(Long id, String slug, String title, String body) {

    static ContentResponse from(Content content) {
        return new ContentResponse(
                content.getId(),
                content.getSlug(),
                content.getTitle(),
                content.getBody()
        );
    }
}

status를 필드로 갖지 않는 record라서 엔티티 직접 노출과 status 유출을 타입 수준에서 차단합니다.

src/main/java/com/samebrief/content/content/ContentException.java

package com.samebrief.content.content;

import org.springframework.http.HttpStatus;

public class ContentException extends RuntimeException {

    private final HttpStatus status;
    private final String code;

    private ContentException(HttpStatus status, String code, String message) {
        super(message);
        this.status = status;
        this.code = code;
    }

    public static ContentException invalidId() {
        return new ContentException(HttpStatus.BAD_REQUEST,
                "INVALID_CONTENT_ID", "콘텐츠 ID는 1 이상의 정수여야 합니다.");
    }

    public static ContentException notFound() {
        return new ContentException(HttpStatus.NOT_FOUND,
                "CONTENT_NOT_FOUND", "콘텐츠를 찾을 수 없습니다.");
    }

    public static ContentException deleted() {
        return new ContentException(HttpStatus.GONE,
                "CONTENT_DELETED", "삭제된 콘텐츠입니다.");
    }

    public HttpStatus getStatus() {
        return status;
    }

    public String getCode() {
        return code;
    }
}

src/main/java/com/samebrief/content/content/ContentService.java

package com.samebrief.content.content;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ContentService {

    private final ContentRepository contentRepository;

    public ContentService(ContentRepository contentRepository) {
        this.contentRepository = contentRepository;
    }

    @Transactional(readOnly = true)
    public ContentResponse getContent(Long id) {
        Content content = contentRepository.findById(id)
                .orElseThrow(ContentException::notFound);
        if (content.isDeleted()) {
            throw ContentException.deleted();
        }
        return ContentResponse.from(content);
    }
}

src/main/java/com/samebrief/content/content/ContentController.java

package com.samebrief.content.content;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/contents")
public class ContentController {

    private final ContentService contentService;

    public ContentController(ContentService contentService) {
        this.contentService = contentService;
    }

    @GetMapping("/{id}")
    public ContentResponse getContent(@PathVariable String id) {
        return contentService.getContent(parseContentId(id));
    }

    private Long parseContentId(String rawId) {
        long id;
        try {
            id = Long.parseLong(rawId);
        } catch (NumberFormatException e) {
            throw ContentException.invalidId();
        }
        if (id < 1) {
            throw ContentException.invalidId();
        }
        return id;
    }
}

@PathVariable Long으로 받으면 숫자가 아닌 값이 스프링 기본 타입 변환 오류로 빠져 계약된 JSON 본문을 잃습니다. 문자열로 받아 직접 파싱해 "숫자가 아님"과 "0 이하"를 하나의 400 응답으로 처리했습니다.

src/main/java/com/samebrief/content/content/ContentExceptionHandler.java

package com.samebrief.content.content;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice(assignableTypes = ContentController.class)
public class ContentExceptionHandler {

    record ErrorResponse(String code, String message) {
    }

    @ExceptionHandler(ContentException.class)
    public ResponseEntity<ErrorResponse> handleContentException(ContentException e) {
        return ResponseEntity.status(e.getStatus())
                .body(new ErrorResponse(e.getCode(), e.getMessage()));
    }
}

assignableTypes로 범위를 이 컨트롤러로 한정해, 기존 전역 예외 처리 인프라가 없다는 전제에서 다른 API에 영향을 주지 않습니다.

src/test/java/com/samebrief/content/content/ContentControllerTest.java

package com.samebrief.content.content;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(ContentController.class)
class ContentControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private ContentService contentService;

    @Test
    @DisplayName("공개 콘텐츠 조회 시 200과 응답 필드를 반환한다")
    void getPublishedContent() throws Exception {
        given(contentService.getContent(42L))
                .willReturn(new ContentResponse(42L, "spring-api-basics", "Spring API 기초", "본문입니다."));

        mockMvc.perform(get("/api/v1/contents/42"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.slug").value("spring-api-basics"))
                .andExpect(jsonPath("$.title").value("Spring API 기초"))
                .andExpect(jsonPath("$.body").value("본문입니다."))
                .andExpect(jsonPath("$.status").doesNotExist());
    }

    @Test
    @DisplayName("존재하지 않는 콘텐츠 조회 시 404와 CONTENT_NOT_FOUND를 반환한다")
    void getMissingContent() throws Exception {
        given(contentService.getContent(999L))
                .willThrow(ContentException.notFound());

        mockMvc.perform(get("/api/v1/contents/999"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value("CONTENT_NOT_FOUND"))
                .andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다."));
    }

    @Test
    @DisplayName("유효하지 않은 ID 조회 시 400과 INVALID_CONTENT_ID를 반환한다")
    void getContentWithInvalidId() throws Exception {
        mockMvc.perform(get("/api/v1/contents/abc"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_CONTENT_ID"))
                .andExpect(jsonPath("$.message").value("콘텐츠 ID는 1 이상의 정수여야 합니다."));

        mockMvc.perform(get("/api/v1/contents/0"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_CONTENT_ID"));
    }

    @Test
    @DisplayName("삭제된 콘텐츠 조회 시 410과 CONTENT_DELETED를 반환한다")
    void getDeletedContent() throws Exception {
        given(contentService.getContent(7L))
                .willThrow(ContentException.deleted());

        mockMvc.perform(get("/api/v1/contents/7"))
                .andExpect(status().isGone())
                .andExpect(jsonPath("$.code").value("CONTENT_DELETED"))
                .andExpect(jsonPath("$.message").value("삭제된 콘텐츠입니다."));
    }
}

@WebMvcTest로 웹 계층만 띄우고 ContentService를 목으로 대체해 데이터베이스 연결 없이 검증합니다. Spring Boot 3.4 미만이라면 @MockitoBean 대신 org.springframework.boot.test.mock.mockito.MockBean을 사용합니다.

실행

./gradlew test --tests ContentControllerTest

예상 결과: 테스트 4건 모두 통과(200 정상 조회, 404 미존재, 400 유효하지 않은 ID, 410 삭제됨).

전체 원문 보기

Recommendation

이 과업의 추천 모델: Codex

필수 오류 계약을 빠뜨리지 않으면서도 파일 수와 계층 구성을 가장 절제했고, 비숫자 ID의 400 응답까지 웹 계층에서 안정적으로 처리했다.

잘 맞는 경우

  • 작은 Spring Boot API에서 명세 기반 오류 계약과 MockMvc 테스트를 함께 구현할 때
  • 불필요한 인프라 없이 최소 변경으로 읽기 전용 엔드포인트를 추가할 때

주의할 점

  • 삭제 콘텐츠의 410 회귀 테스트를 추가하고 프로젝트의 실제 Spring Boot 버전에 맞춰 테스트 어노테이션을 확인해야 한다.

Prompt and environment

공통 프롬프트와 실행 환경

프롬프트 전문 보기
Java 21과 Spring Boot 3으로 비교 글 콘텐츠의 상세 정보를 반환하는 읽기 전용 API를 구현해 주세요.

GET /api/v1/contents/{id}에서 공개 콘텐츠는 id, slug, title, body를 담아 200으로 반환합니다. 존재하지 않으면 CONTENT_NOT_FOUND와 404, 삭제 상태면 CONTENT_DELETED와 410, 숫자가 아니거나 0 이하인 ID면 INVALID_CONTENT_ID와 400을 반환해야 합니다.

Controller, Service, Spring Data JPA Repository, 응답 DTO를 분리하고 엔티티를 JSON으로 직접 노출하지 마세요. MockMvc로 정상 조회, 404, 400을 검증하고 410도 구현해 주세요. 구현과 테스트는 4-8개 파일로 제한하며, 인증·목록 API·페이지네이션 등 명세 밖 기능은 추가하지 마세요.
  • Java 21
  • Spring Boot 3.x
  • Spring Data JPA와 PostgreSQL 호환 설정
  • JUnit 5, MockMvc