모든 비교 글

백엔드

ETag 콘텐츠 조회 API 구현

Java 21과 Spring Boot 3으로 강한 ETag, 조건부 304 응답, 계약된 400·404 오류를 지원하는 메모리 기반 콘텐츠 조회 API와 MockMvc 테스트를 구현한다.

발행일
2026년 7월 25일
비교 모델
Claude, Codex
참여 범위
이 과업에서 실행한 모델만 비교
데이터
발행 콘텐츠

Task

성공 조건

  • Controller, 메모리 조회 Service, 응답 DTO를 분리하고 내부 저장 자료구조와 version을 HTTP 응답에 노출하지 않는다.
  • 두 고정 콘텐츠를 조회하면 계약된 JSON과 각각의 강한 ETag를 포함한 200 application/json 응답을 반환한다.
  • 현재 ETag와 If-None-Match가 정확히 같을 때만 ETag와 빈 본문을 포함한 304를 반환하고, 다른 콘텐츠의 ETag에는 200을 반환한다.
  • 콘텐츠를 찾은 뒤 ETag를 비교해, 없는 ID는 If-None-Match가 있어도 ARTICLE_NOT_FOUND 형식의 404를 반환한다.
  • 0 이하 또는 정수가 아닌 ID를 INVALID_ARTICLE_ID 형식의 400으로 처리한다.
  • 구현과 테스트를 4~8개 파일로 제시하고, MockMvc로 여섯 시나리오·최소 일곱 요청과 테스트 실행 방법을 명시한다.

Common conditions

핵심 조건

  • Java 21, Spring Boot 3, Spring Web MVC, JUnit 5, MockMvc를 사용하고 외부 데이터베이스·캐시·파일 시스템·인증은 추가하지 않는다.
  • ID 1과 2의 고정 콘텐츠에서 각각 "article-1-v3", "article-2-v1" 강한 ETag를 만들되 version은 JSON에 포함하지 않는다.
  • If-None-Match 일치 시 304, 미존재 ID는 404, 0 이하·비정수 ID는 400으로 응답하며 명세 밖 캐시 기능은 구현하지 않는다.

Model results

모델별 결과

ClaudeAnthropic / Opus 5 / default / 2026-07-25

도메인 레코드와 응답 DTO, 서비스, 컨트롤러, 범위가 제한된 예외 처리, WebMvcTest를 7개 파일로 구성한 구현.

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

전체 원문 보기

Recommendation

이 과업의 추천 모델: Codex

두 결과 모두 정적 검토에서 모든 성공 조건을 충족했다. Codex는 같은 API·오류·테스트 계약을 5개 파일로 가장 간결하게 제시해 적용 효율이 높고, 전역 advice 범위만 프로젝트 상황에 맞춰 확인하면 된다.

잘 맞는 경우

  • 작은 Spring Boot 시작 프로젝트에 ETag 조회 API와 계약 테스트를 최소 파일로 추가할 때
  • 독립적인 MockMvc 테스트로 200·304·400·404 분기를 빠르게 확인할 때

주의할 점

  • 두 결과 모두 정적 검토만 했으므로 실제 컴파일과 ./gradlew test 통과를 보장하지 않는다.
  • 기존 컨트롤러가 있는 프로젝트에서는 Codex의 전역 타입 변환 예외 처리 범위를 검토한다.

Verification record

검증 기록

정적 검토 프롬프트와 두 원문의 Java 구현·MockMvc 테스트를 정적 대조했다. 제출된 코드를 별도 Gradle 프로젝트에서 실행하지 않았다. · 검토일 2026-07-26

ETag·응답·오류 계약확인
두 원문 모두 고정 데이터, 강한 ETag, 일치 시 304, 조회 우선 순서, 계약된 400·404 JSON을 코드로 제시했다.
역할 분리와 테스트 범위확인
두 원문 모두 Controller·Service·응답 DTO를 분리하고 4~8개 파일 안에서 여섯 테스트 메서드와 일곱 요청을 제시했다.

Prompt and environment

공통 프롬프트와 실행 환경

프롬프트 전문 보기
Java 21과 Spring Boot 3으로 HTTP ETag를 지원하는 최소 콘텐츠 조회 API를 구현해 주세요. 주어진 단독 Gradle 애플리케이션에서 필요한 파일만 추가·수정하고, 구현과 테스트를 합쳐 4~8개 파일 안에 머무르세요.

## 실행 환경

- Java 21
- Gradle 8.x
- Spring Boot 3.x, Spring Web MVC
- JUnit 5, MockMvc
- 외부 데이터베이스, 캐시 서버, 파일 시스템, 인증은 사용하지 않음

## 제공되는 시작 코드 계약

아래 애플리케이션 시작 클래스와 Gradle 설정은 이미 존재하며 변경하지 않습니다.

```text
com.samebrief.content
└── ContentApplication.java
```

`spring-boot-starter-web`와 `spring-boot-starter-test`는 이미 설정되어 있다고 가정합니다. 필요한 구현과 테스트 파일만 제시하세요.

## 데이터와 요청 계약

애플리케이션은 아래 두 콘텐츠를 메모리에서 제공해야 합니다. 데이터는 요청 사이에 변하지 않습니다.

| ID | title | body | version |
| --- | --- | --- | --- |
| 1 | `봄 소식` | `새로운 기능을 소개합니다.` | 3 |
| 2 | `여름 소식` | `점검 일정을 안내합니다.` | 1 |

`GET /api/v1/articles/{id}`는 정수 `id`를 받습니다.

정상 조회 시 HTTP 200, `Content-Type: application/json`, 그리고 다음 형식의 JSON을 반환합니다.

```json
{
  "id": 1,
  "title": "봄 소식",
  "body": "새로운 기능을 소개합니다."
}
```

성공 응답에는 현재 콘텐츠의 ETag 헤더를 반드시 포함합니다. ETag 값은 큰따옴표를 포함한 아래의 강한 ETag여야 합니다.

- ID 1: `"article-1-v3"`
- ID 2: `"article-2-v1"`

요청에 `If-None-Match` 헤더가 있고 그 값이 현재 ETag와 정확히 같으면 HTTP 304를 반환합니다. 이때 응답 본문은 비어 있어야 하며 ETag 헤더는 그대로 포함해야 합니다.

`id`가 존재하지 않으면 HTTP 404와 아래 JSON을 반환합니다.

```json
{
  "code": "ARTICLE_NOT_FOUND",
  "message": "콘텐츠를 찾을 수 없습니다."
}
```

`id`가 0 이하이거나 정수가 아니면 HTTP 400과 아래 JSON을 반환합니다.

```json
{
  "code": "INVALID_ARTICLE_ID",
  "message": "콘텐츠 ID를 확인해 주세요."
}
```

## 구현 조건

- Controller, 메모리 조회 Service, 응답 DTO를 역할에 맞게 분리합니다.
- 엔티티나 내부 저장 자료구조를 HTTP 응답으로 직접 노출하지 않습니다.
- 응답 JSON에 `version`을 포함하지 않습니다. `version`은 ETag를 만드는 데에만 사용합니다.
- ETag 비교는 콘텐츠를 찾고 현재 ETag를 계산한 뒤에 수행합니다.
- 예외 응답 처리는 이 과업에 필요한 최소 범위로 구현합니다. 기존 전역 예외 처리 인프라가 없다고 가정해도 됩니다.
- 경로 변수 변환 실패를 포함해 정수가 아닌 `id`는 `INVALID_ARTICLE_ID` 오류 응답으로 처리합니다.
- `If-None-Match`의 와일드카드, 복수 ETag, 약한 ETag 처리와 콘텐츠 변경·쓰기 API는 이 과업 범위에 포함하지 않습니다.
- `ShallowEtagHeaderFilter`처럼 응답 본문에서 ETag를 자동 생성하는 필터는 사용하지 않습니다. ETag는 위 계약값을 그대로 사용합니다.
- 명세에 없는 오류 형식, 전역 캐시, 데이터베이스, 인증을 추가하지 않습니다.

## 테스트 조건

MockMvc로 아래 여섯 가지 시나리오를 검증하세요. 6번은 두 입력을 각각 검증하므로, 최소 일곱 건의 요청 검증이 필요합니다.

1. `GET /api/v1/articles/1`은 200, 지정된 JSON, `ETag: "article-1-v3"`를 반환한다.
2. ID 2의 ETag를 `If-None-Match`로 보내면 304, 빈 본문, `ETag: "article-2-v1"`를 반환한다.
3. ID 1에 ID 2의 ETag를 보내면 200, ID 1 본문, `ETag: "article-1-v3"`를 반환한다.
4. 존재하지 않는 ID는 404 `ARTICLE_NOT_FOUND`를 반환한다.
5. 존재하지 않는 ID에 `If-None-Match: "article-1-v3"`를 보내도 304가 아니라 404 `ARTICLE_NOT_FOUND`를 반환한다.
6. `0`과 `abc`는 각각 400 `INVALID_ARTICLE_ID`를 반환한다.

## 제출물

- 변경·추가한 파일의 전체 내용을 제시하고, 각 파일 경로를 명확히 표시합니다.
- 구현과 테스트를 합친 파일 수가 4~8개임을 짧게 명시합니다.
- 마지막에 `./gradlew test` 실행 방법과 예상 테스트 결과를 적습니다.
- 설명보다 구현 코드가 중심이 되게 작성합니다.

## 최종 요청

위 계약만 사용해 ETag 콘텐츠 조회 API와 MockMvc 테스트를 구현해 주세요. 외부 인프라나 명세 밖 HTTP 캐시 기능은 추가하지 마세요.
  • Java 21 · Gradle 8.x · Spring Boot 3.x
  • Spring Web MVC · JUnit 5 · MockMvc
  • 고정 메모리 데이터 · 외부 인프라 없음