비교 요약으로 돌아가기

Full output

Codex 원문 결과물

ETag 콘텐츠 조회 API 구현 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-25

src/main/java/com/samebrief/content/ArticleResponse.java

package com.samebrief.content;

public record ArticleResponse(
        long id,
        String title,
        String body
) {
}

src/main/java/com/samebrief/content/ArticleService.java

package com.samebrief.content;

import org.springframework.stereotype.Service;

import java.util.Map;

@Service
public class ArticleService {

    private final Map<Long, StoredArticle> articles = Map.of(
            1L, new StoredArticle(1L, "봄 소식", "새로운 기능을 소개합니다.", 3),
            2L, new StoredArticle(2L, "여름 소식", "점검 일정을 안내합니다.", 1)
    );

    StoredArticle findById(long id) {
        if (id <= 0) {
            throw new InvalidArticleIdException();
        }

        StoredArticle article = articles.get(id);
        if (article == null) {
            throw new ArticleNotFoundException();
        }
        return article;
    }

    record StoredArticle(long id, String title, String body, int version) {
    }
}

src/main/java/com/samebrief/content/ArticleController.java

package com.samebrief.content;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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 long id,
            @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch
    ) {
        ArticleService.StoredArticle article = articleService.findById(id);
        String etag = "\"article-%d-v%d\"".formatted(article.id(), article.version());

        if (etag.equals(ifNoneMatch)) {
            return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                    .header(HttpHeaders.ETAG, etag)
                    .build();
        }

        ArticleResponse response =
                new ArticleResponse(article.id(), article.title(), article.body());

        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_JSON)
                .header(HttpHeaders.ETAG, etag)
                .body(response);
    }
}

src/main/java/com/samebrief/content/ArticleExceptionHandler.java

package com.samebrief.content;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@RestControllerAdvice
public class ArticleExceptionHandler {

    @ExceptionHandler(ArticleNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    ErrorResponse handleArticleNotFound() {
        return new ErrorResponse(
                "ARTICLE_NOT_FOUND",
                "콘텐츠를 찾을 수 없습니다."
        );
    }

    @ExceptionHandler({
            InvalidArticleIdException.class,
            MethodArgumentTypeMismatchException.class
    })
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    ErrorResponse handleInvalidArticleId() {
        return new ErrorResponse(
                "INVALID_ARTICLE_ID",
                "콘텐츠 ID를 확인해 주세요."
        );
    }
}

record ErrorResponse(String code, String message) {
}

class ArticleNotFoundException extends RuntimeException {
}

class InvalidArticleIdException extends RuntimeException {
}

src/test/java/com/samebrief/content/ArticleControllerTest.java

package com.samebrief.content;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

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;

class ArticleControllerTest {

    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        ArticleService service = new ArticleService();
        mockMvc = MockMvcBuilders
                .standaloneSetup(new ArticleController(service))
                .setControllerAdvice(new ArticleExceptionHandler())
                .build();
    }

    @Test
    void returnsArticleOneWithItsEtag() throws Exception {
        mockMvc.perform(get("/api/v1/articles/1"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(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
    void returnsNotModifiedForMatchingEtag() 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
    void returnsArticleWhenEtagDoesNotMatchCurrentArticle() 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("봄 소식"))
                .andExpect(jsonPath("$.body").value("새로운 기능을 소개합니다."));
    }

    @Test
    void returnsNotFoundForUnknownArticle() throws Exception {
        mockMvc.perform(get("/api/v1/articles/999"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.code").value("ARTICLE_NOT_FOUND"))
                .andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다."));
    }

    @Test
    void checksExistenceBeforeComparingEtag() 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"))
                .andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다."));
    }

    @Test
    void rejectsNonPositiveAndNonIntegerIds() throws Exception {
        assertInvalidArticleId("0");
        assertInvalidArticleId("abc");
    }

    private void assertInvalidArticleId(String id) throws Exception {
        mockMvc.perform(get("/api/v1/articles/{id}", id))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.code").value("INVALID_ARTICLE_ID"))
                .andExpect(jsonPath("$.message").value("콘텐츠 ID를 확인해 주세요."));
    }
}

구현 4개와 테스트 1개, 총 5개 파일입니다.

프로젝트 루트에서 다음 명령으로 실행합니다.

./gradlew test

예상 결과는 테스트 6개가 모두 통과하는 것입니다. 마지막 테스트가 0abc를 각각 요청하므로 전체 MockMvc 요청 검증은 7건입니다.