비교 요약으로 돌아가기

Full output

Claude 원문 결과물

ETag 콘텐츠 조회 API 구현 / Anthropic / Opus 5 / 실행일 2026-07-25

ETag 콘텐츠 조회 API 구현

Controller → Service로 역할을 나누고, 메모리 데이터는 Article 레코드 두 건을 불변 Map에 담아 제공합니다. ETag는 Article.etag()version으로만 만들고 응답 DTO에는 version을 두지 않아, 계약값이 본문으로 새어 나갈 수 없게 했습니다. {id}는 문자열로 받아 직접 파싱해 abc0을 같은 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 + "\"";
    }
}

패키지 전용 레코드라 컨트롤러 밖으로 나갈 수 없고, versionetag() 안에서만 쓰입니다.

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건).