비교 요약으로 돌아가기

Full output

Codex 원문 결과물

콘텐츠 상세 조회 API 구현 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-22

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

package com.samebrief.content.content;

import jakarta.persistence.Column;
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 {

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

    @Column(nullable = false, unique = true)
    private String slug;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String body;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Status status;

    protected Content() {
    }

    public Content(Long id, String slug, String title, String body, Status status) {
        this.id = id;
        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 Status getStatus() {
        return status;
    }

    public enum Status {
        PUBLISHED, 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/ContentDetailResponse.java

package com.samebrief.content.content;

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

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

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

package com.samebrief.content.content;

public class ContentAccessException extends RuntimeException {

    private final Reason reason;

    private ContentAccessException(Reason reason) {
        this.reason = reason;
    }

    public static ContentAccessException notFound() {
        return new ContentAccessException(Reason.NOT_FOUND);
    }

    public static ContentAccessException deleted() {
        return new ContentAccessException(Reason.DELETED);
    }

    public Reason getReason() {
        return reason;
    }

    public enum Reason {
        NOT_FOUND, DELETED
    }
}

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
@Transactional(readOnly = true)
public class ContentService {

    private final ContentRepository contentRepository;

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

    public ContentDetailResponse getDetail(long id) {
        Content content = contentRepository.findById(id)
                .orElseThrow(ContentAccessException::notFound);

        if (content.getStatus() == Content.Status.DELETED) {
            throw ContentAccessException.deleted();
        }

        return ContentDetailResponse.from(content);
    }
}

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

package com.samebrief.content.content;

import org.springframework.http.ResponseEntity;
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 ResponseEntity<ContentDetailResponse> getDetail(@PathVariable long id) {
        if (id < 1) {
            throw new InvalidContentIdException();
        }
        return ResponseEntity.ok(contentService.getDetail(id));
    }
}

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

package com.samebrief.content.content;

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

@RestControllerAdvice
public class ContentExceptionHandler {

    @ExceptionHandler(ContentAccessException.class)
    public ResponseEntity<ErrorResponse> handleContentAccess(ContentAccessException exception) {
        return switch (exception.getReason()) {
            case NOT_FOUND -> error(HttpStatus.NOT_FOUND, "CONTENT_NOT_FOUND", "콘텐츠를 찾을 수 없습니다.");
            case DELETED -> error(HttpStatus.GONE, "CONTENT_DELETED", "삭제된 콘텐츠입니다.");
        };
    }

    @ExceptionHandler({InvalidContentIdException.class, MethodArgumentTypeMismatchException.class})
    public ResponseEntity<ErrorResponse> handleInvalidId(Exception exception) {
        return error(HttpStatus.BAD_REQUEST, "INVALID_CONTENT_ID", "콘텐츠 ID는 1 이상의 정수여야 합니다.");
    }

    private ResponseEntity<ErrorResponse> error(HttpStatus status, String code, String message) {
        return ResponseEntity.status(status).body(new ErrorResponse(code, message));
    }

    private record ErrorResponse(String code, String message) {
    }
}

class InvalidContentIdException extends RuntimeException {
}

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

package com.samebrief.content.content;

import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
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.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(ContentController.class)
class ContentControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ContentService contentService;

    @Test
    void 공개_콘텐츠를_조회한다() throws Exception {
        given(contentService.getDetail(42L)).willReturn(
                new ContentDetailResponse(42L, "spring-api-basics", "Spring API 기초", "본문입니다.")
        );

        mockMvc.perform(get("/api/v1/contents/{id}", 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
    void 없는_콘텐츠는_404를_반환한다() throws Exception {
        given(contentService.getDetail(404L)).willThrow(ContentAccessException.notFound());

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

    @Test
    void 유효하지_않은_ID는_400을_반환한다() throws Exception {
        mockMvc.perform(get("/api/v1/contents/{id}", 0))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_CONTENT_ID"))
                .andExpect(jsonPath("$.message").value("콘텐츠 ID는 1 이상의 정수여야 합니다."));

        then(contentService).should(never()).getDetail(0L);
    }
}

실행: ./gradlew test (Maven 프로젝트라면 ./mvnw test)

예상 결과: ContentControllerTest의 3개 테스트가 모두 통과합니다. 서비스는 DELETED 상태에 410 GoneCONTENT_DELETED를 반환합니다.