Full output
Claude 원문 결과물
ETag 콘텐츠 조회 API 구현 / Anthropic / Opus 5 / 실행일 2026-07-25
ETag 콘텐츠 조회 API 구현
Controller → Service로 역할을 나누고, 메모리 데이터는 Article 레코드 두 건을 불변 Map에 담아 제공합니다. ETag는 Article.etag()가 version으로만 만들고 응답 DTO에는 version을 두지 않아, 계약값이 본문으로 새어 나갈 수 없게 했습니다. {id}는 문자열로 받아 직접 파싱해 abc와 0을 같은 400 계약으로 묶었습니다. 총 7개 파일(구현 6 + 테스트 1)입니다.
src/main/java/com/samebrief/content/article/Article.java
package com.samebrief.content.article;
record Article(long id, String title, String body, int version) {
String etag() {
return "\"article-" + id + "-v" + version + "\"";
}
}
패키지 전용 레코드라 컨트롤러 밖으로 나갈 수 없고, version은 etag() 안에서만 쓰입니다.
src/main/java/com/samebrief/content/article/ArticleResponse.java
package com.samebrief.content.article;
public record ArticleResponse(long id, String title, String body) {
static ArticleResponse from(Article article) {
return new ArticleResponse(article.id(), article.title(), article.body());
}
}
version 필드가 없는 record라서 응답 본문에 version이 포함되는 일을 타입 수준에서 차단합니다.
src/main/java/com/samebrief/content/article/ArticleException.java
package com.samebrief.content.article;
import org.springframework.http.HttpStatus;
public class ArticleException extends RuntimeException {
private final HttpStatus status;
private final String code;
private ArticleException(HttpStatus status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
static ArticleException invalidId() {
return new ArticleException(HttpStatus.BAD_REQUEST,
"INVALID_ARTICLE_ID", "콘텐츠 ID를 확인해 주세요.");
}
static ArticleException notFound() {
return new ArticleException(HttpStatus.NOT_FOUND,
"ARTICLE_NOT_FOUND", "콘텐츠를 찾을 수 없습니다.");
}
public HttpStatus getStatus() {
return status;
}
public String getCode() {
return code;
}
}
src/main/java/com/samebrief/content/article/ArticleService.java
package com.samebrief.content.article;
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class ArticleService {
private static final Map<Long, Article> ARTICLES = Map.of(
1L, new Article(1L, "봄 소식", "새로운 기능을 소개합니다.", 3),
2L, new Article(2L, "여름 소식", "점검 일정을 안내합니다.", 1)
);
Article getArticle(long id) {
Article article = ARTICLES.get(id);
if (article == null) {
throw ArticleException.notFound();
}
return article;
}
}
Map.of로 만든 불변 맵이라 요청 사이에 데이터가 변하지 않고, 조회 실패는 여기서 곧바로 404 예외가 됩니다.
src/main/java/com/samebrief/content/article/ArticleController.java
package com.samebrief.content.article;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/articles")
public class ArticleController {
private final ArticleService articleService;
public ArticleController(ArticleService articleService) {
this.articleService = articleService;
}
@GetMapping("/{id}")
public ResponseEntity<ArticleResponse> getArticle(
@PathVariable String id,
@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) {
Article article = articleService.getArticle(parseArticleId(id));
String etag = article.etag();
if (etag.equals(ifNoneMatch)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.header(HttpHeaders.ETAG, etag)
.build();
}
return ResponseEntity.ok()
.header(HttpHeaders.ETAG, etag)
.body(ArticleResponse.from(article));
}
private long parseArticleId(String rawId) {
long id;
try {
id = Long.parseLong(rawId);
} catch (NumberFormatException e) {
throw ArticleException.invalidId();
}
if (id < 1) {
throw ArticleException.invalidId();
}
return id;
}
}
@PathVariable Long으로 받으면abc가 스프링 기본 타입 변환 오류로 빠져 계약된INVALID_ARTICLE_ID본문을 잃습니다. 문자열로 받아 직접 파싱해 "정수 아님"과 "0 이하"를 하나의400으로 처리했습니다.- 조회 → ETag 계산 → 비교 순서라서, 없는 ID에
If-None-Match가 붙어도304가 아니라404가 먼저 나갑니다. .eTag(...)는 값에 따옴표를 보정하는 로직이 있어, 계약값을 글자 그대로 내보내려고.header(HttpHeaders.ETAG, etag)를 씁니다.
src/main/java/com/samebrief/content/article/ArticleExceptionHandler.java
package com.samebrief.content.article;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = ArticleController.class)
public class ArticleExceptionHandler {
record ErrorResponse(String code, String message) {
}
@ExceptionHandler(ArticleException.class)
public ResponseEntity<ErrorResponse> handleArticleException(ArticleException e) {
return ResponseEntity.status(e.getStatus())
.body(new ErrorResponse(e.getCode(), e.getMessage()));
}
}
assignableTypes로 적용 범위를 이 컨트롤러로 한정해, 전역 예외 처리 인프라가 없다는 전제에서 다른 API에 영향을 주지 않습니다.
src/test/java/com/samebrief/content/article/ArticleControllerTest.java
package com.samebrief.content.article;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
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.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(ArticleController.class)
@Import(ArticleService.class)
class ArticleControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@DisplayName("ID 1 조회 시 200, 지정된 JSON, ETag를 반환한다")
void getArticle() throws Exception {
mockMvc.perform(get("/api/v1/articles/1"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(header().string(HttpHeaders.ETAG, "\"article-1-v3\""))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("봄 소식"))
.andExpect(jsonPath("$.body").value("새로운 기능을 소개합니다."))
.andExpect(jsonPath("$.version").doesNotExist());
}
@Test
@DisplayName("현재 ETag와 같은 If-None-Match는 304와 빈 본문을 반환한다")
void getArticleNotModified() throws Exception {
mockMvc.perform(get("/api/v1/articles/2").header(HttpHeaders.IF_NONE_MATCH, "\"article-2-v1\""))
.andExpect(status().isNotModified())
.andExpect(header().string(HttpHeaders.ETAG, "\"article-2-v1\""))
.andExpect(content().string(""));
}
@Test
@DisplayName("다른 콘텐츠의 ETag를 보내면 200과 해당 콘텐츠 본문을 반환한다")
void getArticleWithOtherEtag() throws Exception {
mockMvc.perform(get("/api/v1/articles/1").header(HttpHeaders.IF_NONE_MATCH, "\"article-2-v1\""))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.ETAG, "\"article-1-v3\""))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("봄 소식"));
}
@Test
@DisplayName("존재하지 않는 ID는 404와 ARTICLE_NOT_FOUND를 반환한다")
void getMissingArticle() throws Exception {
mockMvc.perform(get("/api/v1/articles/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("ARTICLE_NOT_FOUND"))
.andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다."));
}
@Test
@DisplayName("존재하지 않는 ID는 If-None-Match가 있어도 404를 반환한다")
void getMissingArticleWithEtag() throws Exception {
mockMvc.perform(get("/api/v1/articles/999").header(HttpHeaders.IF_NONE_MATCH, "\"article-1-v3\""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("ARTICLE_NOT_FOUND"));
}
@Test
@DisplayName("0과 abc는 각각 400과 INVALID_ARTICLE_ID를 반환한다")
void getArticleWithInvalidId() throws Exception {
mockMvc.perform(get("/api/v1/articles/0"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_ARTICLE_ID"))
.andExpect(jsonPath("$.message").value("콘텐츠 ID를 확인해 주세요."));
mockMvc.perform(get("/api/v1/articles/abc"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_ARTICLE_ID"))
.andExpect(jsonPath("$.message").value("콘텐츠 ID를 확인해 주세요."));
}
}
테스트 메서드는 6개, 검증하는 요청은 7건입니다. 데이터가 메모리 상수라서 ArticleService를 목으로 바꾸지 않고 @Import로 실제 빈을 올려 컨트롤러–서비스 경로 전체를 검증합니다.
실행
./gradlew test --tests ArticleControllerTest
예상 결과: 테스트 6건 모두 통과(200 정상 조회 + ETag, 304 미변경, 다른 ETag 시 200, 404 미존재, ETag 동반 404, 400 유효하지 않은 ID 2건).