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 Gone과 CONTENT_DELETED를 반환합니다.