페이지·필터·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를 연속해야 검사와 쓰기 사이의 틈이 열리지 않습니다.
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");
}
}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);
}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.java와 InMemoryStudySessionApi.java는 HTTP 계약을 확인하기 위한 초기 예제였습니다. 이제 두 파일을 제거하고 기존 StudySessionController.java를 아래 코드로 교체합니다. REST와 이후 GraphQL이 모두 JpaVersionedStudySessionService를 사용하므로 같은 PostgreSQL 행을 읽고 씁니다. ch4-3에서 활성화한 method security는 실제 공용 service interface의 read/write 메서드에도 적용되어 HTTP 밖의 호출도 같은 scope를 요구합니다.
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();
}
}{
"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이 있는지도 함께 보십시오.
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:runexport 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"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라는 다른 전송 계약을 올립니다.