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 삭제됨).