비교 요약으로 돌아가기

Full output

Claude 원문 결과물

콘텐츠 상세 조회 API 구현 / Anthropic / Opus 4.8 / 실행일 2026-07-22

콘텐츠 상세 조회 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 삭제됨).