본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
5장 : REST API 설계와 구현

페이지·필터·ETag·API 버전

데이터가 늘면 목록을 모두 반환할 수 없고, 두 사용자가 같은 세션을 수정하면 나중 저장이 먼저 수정을 덮을 수 있습니다. 동시에 API 계약도 시간에 따라 진화합니다. 이 절에서는 안정 정렬을 갖춘 페이지, ETag 사전조건, Framework 7의 API versioning을 서로 충돌하지 않는 하나의 REST 계약으로 엮습니다.


목록과 단건 표현은 변화 규칙이 다르다

목록은 동일한 정렬에서 매번 같은 행 경계를 보여야 합니다. studyDate desc 하나만으로는 같은 날짜의 순서가 흔들리므로 id asc를 tie-breaker로 추가합니다. 단건 표현의 version은 ETag가 되어 수정 시점의 전제를 표현하고, API version은 이와 달리 JSON·header·의미의 계약 버전을 표현합니다.

ETag는 자원이 바뀌면 달라지고, API version은 서버가 지원 정책을 바꿀 때 달라집니다. 둘을 같은 version 필드로 취급하지 않아야 자원 동시성과 계약 호환성을 각각 관리할 수 있습니다.


Pageable은 Controller에, If-Match는 transaction에 연결한다

Controller는 page·size를 허용 범위로 정규화하고 안정 정렬을 만들어 service에 넘깁니다. 반면 If-Match는 Controller에서 미리 조회하지 않고 원문 헤더를 service로 전달합니다. service가 한 transaction 안에서 현재 version 조회, ETag 비교, entity 변경, flush를 연속해야 검사와 쓰기 사이의 틈이 열리지 않습니다.

src/main/java/com/andongmin/studylog/web/ApiVersionConfiguration.java
package com.andongmin.studylog.web;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration(proxyBeanMethods = false)
public class ApiVersionConfiguration implements WebMvcConfigurer {
    @Override
    public void configureApiVersioning(ApiVersionConfigurer configurer) {
        configurer.useRequestHeader("X-API-Version");
    }
}
src/main/java/com/andongmin/studylog/session/VersionedStudySessionService.java
package com.andongmin.studylog.session;

import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.prepost.PreAuthorize;

public interface VersionedStudySessionService {
    @PreAuthorize("hasAuthority('SCOPE_study.read')")
    Page<StudySessionResponse> findAll(
            String subject, SessionStatus status, Pageable pageable);
    @PreAuthorize("hasAuthority('SCOPE_study.read')")
    default List<StudySessionResponse> findAll() {
        return findAll(null, null, Pageable.unpaged()).getContent();
    }
    @PreAuthorize("hasAuthority('SCOPE_study.read')")
    StudySessionResponse findOne(UUID id);
    @PreAuthorize("hasAuthority('SCOPE_study.write')")
    StudySessionResponse create(CreateStudySessionRequest request);
    @PreAuthorize("hasAuthority('SCOPE_study.write')")
    StudySessionResponse replace(
            UUID id, String ifMatch, CreateStudySessionRequest request);
    @PreAuthorize("hasAuthority('SCOPE_study.write')")
    StudySessionResponse complete(UUID id);
    @PreAuthorize("hasAuthority('SCOPE_study.write')")
    void delete(UUID id);
}
src/main/java/com/andongmin/studylog/session/JpaVersionedStudySessionService.java
package com.andongmin.studylog.session;

import java.util.ArrayList;
import java.util.UUID;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.ETag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

@Service
@Transactional(readOnly = true)
public class JpaVersionedStudySessionService implements VersionedStudySessionService {
    private final StudySessionJpaRepository repository;

    public JpaVersionedStudySessionService(StudySessionJpaRepository repository) {
        this.repository = repository;
    }

    @Override
    public Page<StudySessionResponse> findAll(
            String subject, SessionStatus status, Pageable pageable) {
        Specification<StudySessionEntity> filters = (root, query, builder) -> {
            var predicates = new ArrayList<Predicate>();
            if (subject != null && !subject.isBlank()) {
                predicates.add(builder.equal(root.get("subject"), subject));
            }
            if (status != null) {
                predicates.add(builder.equal(root.get("status"), status));
            }
            return builder.and(predicates.toArray(Predicate[]::new));
        };
        return repository.findAll(filters, pageable).map(StudySessionResponse::from);
    }

    @Override
    public StudySessionResponse findOne(UUID id) {
        return StudySessionResponse.from(load(id));
    }

    @Override
    @Transactional
    public StudySessionResponse create(CreateStudySessionRequest request) {
        var entity = new StudySessionEntity(UUID.randomUUID(), request.subject(),
                request.topic(), request.studyDate(), request.minutes());
        return StudySessionResponse.from(repository.saveAndFlush(entity));
    }

    @Override
    @Transactional
    public StudySessionResponse replace(
            UUID id, String ifMatch, CreateStudySessionRequest request) {
        StudySessionEntity entity = load(id);
        ETag current = new ETag(Long.toString(entity.getVersion()), false);
        boolean matches = ETag.parse(ifMatch).stream()
                .anyMatch(candidate -> candidate.compare(current, true));
        if (!matches) {
            throw new ResponseStatusException(
                    HttpStatus.PRECONDITION_FAILED, "If-Match is stale");
        }
        entity.replace(request.subject(), request.topic(),
                request.studyDate(), request.minutes());
        try {
            repository.flush();
        } catch (OptimisticLockingFailureException failure) {
            throw new ResponseStatusException(HttpStatus.CONFLICT,
                    "session changed after the If-Match check", failure);
        }
        return StudySessionResponse.from(entity);
    }

    @Override
    @Transactional
    public StudySessionResponse complete(UUID id) {
        StudySessionEntity entity = load(id);
        entity.complete();
        repository.flush();
        return StudySessionResponse.from(entity);
    }

    @Override
    @Transactional
    public void delete(UUID id) {
        repository.delete(load(id));
    }

    private StudySessionEntity load(UUID id) {
        return repository.findById(id)
                .orElseThrow(() -> new SessionNotFoundException(id));
    }
}

ch5-1의 StudySessionApi.javaInMemoryStudySessionApi.java는 HTTP 계약을 확인하기 위한 초기 예제였습니다. 이제 두 파일을 제거하고 기존 StudySessionController.java를 아래 코드로 교체합니다. REST와 이후 GraphQL이 모두 JpaVersionedStudySessionService를 사용하므로 같은 PostgreSQL 행을 읽고 씁니다. ch4-3에서 활성화한 method security는 실제 공용 service interface의 read/write 메서드에도 적용되어 HTTP 밖의 호출도 같은 scope를 요구합니다.

src/main/java/com/andongmin/studylog/session/StudySessionController.java
package com.andongmin.studylog.session;

import java.net.URI;
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ETag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/study-sessions")
public class StudySessionController {
    private final VersionedStudySessionService service;

    public StudySessionController(VersionedStudySessionService service) {
        this.service = service;
    }

    @PostMapping(version = "1.0+")
    ResponseEntity<StudySessionResponse> create(
            @Valid @RequestBody CreateStudySessionRequest request) {
        StudySessionResponse created = service.create(request);
        ETag tag = new ETag(Long.toString(created.version()), false);
        return ResponseEntity
                .created(URI.create("/api/study-sessions/" + created.id()))
                .eTag(tag.formattedTag())
                .body(created);
    }

    @GetMapping(version = "1.0+")
    Page<StudySessionResponse> findAll(
            @RequestParam(required = false) String subject,
            @RequestParam(required = false) SessionStatus status,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        int safeSize = Math.max(1, Math.min(size, 100));
        var stableOrder = Sort.by(Sort.Order.desc("studyDate"), Sort.Order.asc("id"));
        return service.findAll(subject, status,
                PageRequest.of(Math.max(page, 0), safeSize, stableOrder));
    }

    @GetMapping(path = "/{id}", version = "1.0+")
    ResponseEntity<StudySessionResponse> findOne(@PathVariable UUID id) {
        var response = service.findOne(id);
        ETag current = new ETag(Long.toString(response.version()), false);
        return ResponseEntity.ok().eTag(current.formattedTag()).body(response);
    }

    @PutMapping(path = "/{id}", version = "1.0+")
    ResponseEntity<StudySessionResponse> replace(
            @PathVariable UUID id,
            @RequestHeader("If-Match") String ifMatch,
            @Valid @RequestBody CreateStudySessionRequest request) {
        var updated = service.replace(id, ifMatch, request);
        ETag updatedTag = new ETag(Long.toString(updated.version()), false);
        return ResponseEntity.ok().eTag(updatedTag.formattedTag()).body(updated);
    }

    @DeleteMapping(path = "/{id}", version = "1.0+")
    ResponseEntity<Void> delete(@PathVariable UUID id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}
replace.json
{
  "subject": "Spring",
  "topic": "Conditional update",
  "studyDate": "2026-07-13",
  "minutes": 75
}

이 Controller는 ch5-1의 in-memory API를 교체합니다. 두 구현을 동시에 남기면 mapping 충돌이나 bean 선택 모호성이 생길 수 있으므로, 문서의 “교체” 지시를 실제 파일 삭제로 반영합니다. 이후 REST와 GraphQL은 모두 VersionedStudySessionService를 사용하므로 같은 PostgreSQL 행과 method security 규칙을 공유합니다.


버전 헤더와 사전조건 응답을 확인한다

이 검증은 필수 header가 많으므로 서버와 client를 두 terminal로 나눕니다. 생성 응답의 ID로 단건을 조회해 ETag를 저장하고, 잘못된 "999"로 412를 만든 뒤 현재 ETag로 성공하는 순서입니다. 모든 REST 요청에 X-API-Version: 1.0과 용도에 맞는 read/write token이 있는지도 함께 보십시오.

terminal A — local 서버
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:run
terminal B — version과 ETag 요청
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
READ_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.read")"
WRITE_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.write")"
mkdir -p target
curl -sS -o target/created.json -X POST \
  -H "X-API-Version: 1.0" \
  -H "Authorization: Bearer $WRITE_TOKEN" \
  -H "Content-Type: application/json" --data-binary @replace.json \
  http://localhost:8080/api/study-sessions
ID="$(sed -n 's/.*"id":"\([^"]*\)".*/\1/p' target/created.json)"
test -n "$ID"
curl -sS -D target/current.headers -o target/current.json \
  -H "X-API-Version: 1.0" \
  -H "Authorization: Bearer $READ_TOKEN" \
  "http://localhost:8080/api/study-sessions/$ID"
ETAG="$(awk 'BEGIN { IGNORECASE=1 } /^etag:/ { gsub("\r", "", $2); print $2 }' \
  target/current.headers)"
test -n "$ETAG"
test "$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
  -H "X-API-Version: 1.0" -H 'If-Match: "999"' \
  -H "Authorization: Bearer $WRITE_TOKEN" \
  -H "Content-Type: application/json" --data-binary @replace.json \
  "http://localhost:8080/api/study-sessions/$ID")" = 412
curl -i -X PUT -H "X-API-Version: 1.0" -H "If-Match: $ETAG" \
  -H "Authorization: Bearer $WRITE_TOKEN" \
  -H "Content-Type: application/json" --data-binary @replace.json \
  "http://localhost:8080/api/study-sessions/$ID"
API version 확인 결과
GET은 강한 ETag를 반환해야 합니다. 쓰기 transaction이 읽은 version과 다른 If-Match는 변경 전에 412가 되고, 검사 뒤 다른 transaction이 먼저 flush하면 낙관적 잠금 예외가 409가 되어야 합니다.

412와 409는 충돌 시점이 다르다

If-Match 검사와 상태 변경을 같은 @Transactional 메서드에 둡니다. 그 transaction이 읽은 version과 header가 다르면 412이며, 검사 뒤 경쟁 transaction이 먼저 commit해 현재 transaction의 flush()가 실패하면 409입니다. Controller에서 미리 조회하면 이 두 단계 사이가 열리므로 같은 보장을 만들 수 없습니다.


412와 409를 운영 지표에서도 나누어 본다

412가 많다면 client가 오래된 표현을 보고 있거나 ETag를 보내지 않는 흐름을 의심합니다. 409가 많다면 같은 자원에 쓰기가 모이는 업무 구조를 살펴야 합니다. 두 status를 하나의 “충돌”로 묶으면 client 버그와 실제 동시 쓰기 압력을 구분하지 못합니다.


REST 계약이 완성됐으니 새 저장 로직을 만들 필요는 없습니다. 다음 장에서는 같은 service와 security scope 위에 GraphQL schema라는 다른 전송 계약을 올립니다.