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

안동민 개발노트

본문 시작
13장 : 실전 Study Log API

REST·보안·오류 계약 완성

앞 절의 service는 이제 실제 PostgreSQL transaction 안에서 동작합니다. 이번에는 생성·조회·완료·삭제를 HTTP 리소스로 노출하되, Controller가 Entity를 그대로 직렬화하거나 보안과 동시성 규칙을 우회하지 않게 만듭니다.

읽기와 쓰기 scope, 입력 검증, ProblemDetail, ETag는 서로 떨어진 부가 기능이 아닙니다. 한 사용자가 POST 응답의 Location과 ETag를 다음 요청에 넘기는 흐름 전체가 외부 계약입니다.


외부 계약은 성공과 실패를 모두 포함한다

Controller는 JSON과 상태 코드를 책임지고 service는 transaction과 도메인 상태를 책임집니다. Security filter는 Controller보다 먼저 scope를 검사하며, ETag는 HTTP에서 받은 사전조건을 JPA version과 연결합니다.

오래된 ETag를 처음 읽은 값과 비교하는 것만으로는 충분하지 않습니다. 비교, 상태 변경, flush가 같은 transaction 안에 있어야 하며 그 사이 다른 transaction이 먼저 commit하면 @Version이 두 번째 방어선으로 충돌을 감지합니다.


Controller·Security·Advice를 완성한다

DTO는 transport 입력의 형식과 길이를 검사하고 Entity factory는 HTTP 밖에서 호출돼도 같은 업무 불변식을 지킵니다. Controller는 Entity 대신 DTO만 반환해 lazy loading과 내부 필드 노출을 막습니다.

완료 요청은 현재 표현을 보고 수정한다는 의미이므로 If-Match를 필수로 받습니다. service가 현재 version으로 만든 ETag와 비교한 뒤 상태를 바꾸고 즉시 flush해, 412와 실제 DB 경쟁 409를 구분합니다.

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

import java.time.LocalDate;
import java.util.UUID;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;

public final class StudySessionDto {
    private StudySessionDto() {
    }

    public record CreateRequest(
            @NotBlank @Size(max = 80) String subject,
            @NotBlank @Size(max = 120) String topic,
            @NotNull @PastOrPresent LocalDate studyDate,
            @Min(1) @Max(1440) int minutes) {
    }

    public record Response(UUID id, String subject, String topic,
            LocalDate studyDate, int minutes, SessionStatus status, long version) {
        public static Response from(StudySession session) {
            return new Response(session.getId(), session.getSubject(), session.getTopic(),
                    session.getStudyDate(), session.getMinutes(), session.getStatus(),
                    session.getVersion());
        }
    }
}
src/main/java/com/andongmin/studylog/session/PreconditionRequiredException.java
package com.andongmin.studylog.session;

public class PreconditionRequiredException extends RuntimeException {
    public PreconditionRequiredException() {
        super("If-Match header is required");
    }
}
src/main/java/com/andongmin/studylog/session/PreconditionFailedException.java
package com.andongmin.studylog.session;

public class PreconditionFailedException extends RuntimeException {
    public PreconditionFailedException() {
        super("If-Match does not match the current session version");
    }
}
src/main/java/com/andongmin/studylog/session/StudySessionService.java
package com.andongmin.studylog.session;

import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Sort;
import org.springframework.http.ETag;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class StudySessionService {
    private final StudySessionRepository repository;

    public StudySessionService(StudySessionRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public StudySession create(CreateCommand command) {
        return repository.save(StudySession.plan(command.subject(), command.topic(),
                command.studyDate(), command.minutes()));
    }

    @Transactional(readOnly = true)
    public List<StudySession> findAll() {
        return repository.findAll(
                Sort.by(Sort.Order.desc("studyDate"), Sort.Order.asc("id")));
    }

    @Transactional(readOnly = true)
    public StudySession findOne(UUID id) {
        return load(id);
    }

    @Transactional
    public StudySession complete(UUID id, String ifMatch) {
        StudySession session = load(id);
        ETag current = new ETag(Long.toString(session.getVersion()), false);
        boolean matches;
        try {
            matches = ETag.parse(ifMatch).stream()
                    .anyMatch(candidate -> candidate.compare(current, true));
        } catch (IllegalArgumentException malformed) {
            throw new PreconditionFailedException();
        }
        if (!matches) {
            throw new PreconditionFailedException();
        }
        session.complete();
        repository.flush();
        return session;
    }

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

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

    public record CreateCommand(String subject, String topic,
            LocalDate studyDate, int minutes) {
    }
}
src/main/java/com/andongmin/studylog/session/StudySessionController.java
package com.andongmin.studylog.session;

import java.net.URI;
import java.util.List;
import java.util.UUID;
import jakarta.validation.Valid;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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.RestController;

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

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

    @PostMapping
    ResponseEntity<StudySessionDto.Response> create(
            @Valid @RequestBody StudySessionDto.CreateRequest request) {
        var session = service.create(new StudySessionService.CreateCommand(
                request.subject(), request.topic(), request.studyDate(), request.minutes()));
        var body = StudySessionDto.Response.from(session);
        return ResponseEntity.created(URI.create("/api/study-sessions/" + body.id()))
                .eTag(new ETag(Long.toString(body.version()), false).formattedTag())
                .body(body);
    }

    @GetMapping
    List<StudySessionDto.Response> findAll() {
        return service.findAll().stream().map(StudySessionDto.Response::from).toList();
    }

    @GetMapping("/{id}")
    ResponseEntity<StudySessionDto.Response> findOne(@PathVariable UUID id) {
        var body = StudySessionDto.Response.from(service.findOne(id));
        ETag tag = new ETag(Long.toString(body.version()), false);
        return ResponseEntity.ok().eTag(tag.formattedTag()).body(body);
    }

    @PatchMapping("/{id}/completion")
    ResponseEntity<StudySessionDto.Response> complete(@PathVariable UUID id,
            @RequestHeader(value = "If-Match", required = false) String ifMatch) {
        if (ifMatch == null || ifMatch.isBlank()) {
            throw new PreconditionRequiredException();
        }
        var body = StudySessionDto.Response.from(service.complete(id, ifMatch));
        return ResponseEntity.ok()
                .eTag(new ETag(Long.toString(body.version()), false).formattedTag())
                .body(body);
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(@PathVariable UUID id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}
src/main/java/com/andongmin/studylog/security/ApiSecurity.java
package com.andongmin.studylog.security;

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.health.actuate.endpoint.HealthEndpoint;
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(proxyBeanMethods = false)
public class ApiSecurity {
    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(requests -> requests
                        .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
                        .requestMatchers(EndpointRequest.toAdditionalPaths(
                                WebServerNamespace.SERVER, HealthEndpoint.class)).permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/study-sessions/**")
                        .hasAuthority("SCOPE_study.read")
                        .requestMatchers("/api/study-sessions/**")
                        .hasAuthority("SCOPE_study.write")
                        .anyRequest().denyAll())
                .oauth2ResourceServer(server -> server.jwt(withDefaults()))
                .build();
    }
}
src/main/java/com/andongmin/studylog/web/ApiErrors.java
package com.andongmin.studylog.web;

import java.net.URI;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.andongmin.studylog.session.PreconditionFailedException;
import com.andongmin.studylog.session.PreconditionRequiredException;
import com.andongmin.studylog.session.SessionNotFoundException;

@RestControllerAdvice
public class ApiErrors {
    @ExceptionHandler(SessionNotFoundException.class)
    ProblemDetail notFound(SessionNotFoundException failure) {
        return problem(HttpStatus.NOT_FOUND, "Session not found", failure.getMessage(),
                "session-not-found");
    }

    @ExceptionHandler(IllegalStateException.class)
    ProblemDetail invalidState(IllegalStateException failure) {
        return problem(HttpStatus.CONFLICT, "Invalid session state", failure.getMessage(),
                "invalid-session-state");
    }

    @ExceptionHandler(PreconditionRequiredException.class)
    ProblemDetail preconditionRequired(PreconditionRequiredException failure) {
        return problem(HttpStatus.PRECONDITION_REQUIRED, "Precondition required",
                failure.getMessage(), "precondition-required");
    }

    @ExceptionHandler(PreconditionFailedException.class)
    ProblemDetail preconditionFailed(PreconditionFailedException failure) {
        return problem(HttpStatus.PRECONDITION_FAILED, "Precondition failed",
                failure.getMessage(), "precondition-failed");
    }

    @ExceptionHandler(OptimisticLockingFailureException.class)
    ProblemDetail concurrentChange(OptimisticLockingFailureException failure) {
        return problem(HttpStatus.CONFLICT, "Concurrent change",
                "The session changed after its precondition was checked", "concurrent-change");
    }

    private ProblemDetail problem(HttpStatus status, String title,
            String detail, String type) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setTitle(title);
        problem.setType(URI.create("https://andongmin.com/problems/" + type));
        return problem;
    }
}
create.json
{
  "subject": "Spring",
  "topic": "Capstone smoke test",
  "studyDate": "2026-07-13",
  "minutes": 60
}
dev-api-smoke.sh
#!/usr/bin/env bash
set -euo pipefail

export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
WRITE_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.read study.write")"
READ_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.read")"

docker compose up -d --wait postgres
mkdir -p target
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:run \
  > target/local-app.log 2>&1 &
APP_PID=$!
cleanup() {
  kill "$APP_PID" 2>/dev/null || true
  wait "$APP_PID" 2>/dev/null || true
  docker compose down
}
trap cleanup EXIT

ready=false
for ((attempt = 1; attempt <= 60; attempt++)); do
  if curl -fsS http://localhost:8080/livez >/dev/null; then
    ready=true
    break
  fi
  sleep 1
done
test "$ready" = true

test "$(curl -s -o /dev/null -w '%{http_code}' \
  http://localhost:8080/api/study-sessions)" = 401
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  -H "Authorization: Bearer $READ_TOKEN" \
  -H "Content-Type: application/json" --data-binary @create.json \
  http://localhost:8080/api/study-sessions)" = 403
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  -H "Authorization: Bearer $WRITE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"","topic":"REST","studyDate":"2026-07-13","minutes":0}' \
  http://localhost:8080/api/study-sessions)" = 400
test "$(curl -s -o /dev/null -w '%{http_code}' \
  -H "Authorization: Bearer $READ_TOKEN" \
  http://localhost:8080/api/study-sessions/00000000-0000-0000-0000-000000000001)" = 404

CREATE_STATUS="$(curl -sS -D target/create.headers -o target/create.json \
  -w '%{http_code}' -X POST \
  -H "Authorization: Bearer $WRITE_TOKEN" \
  -H "Content-Type: application/json" --data-binary @create.json \
  http://localhost:8080/api/study-sessions)"
ID="$(sed -n 's/.*"id":"\([^"]*\)".*/\1/p' target/create.json)"
ETAG="$(awk 'BEGIN { IGNORECASE=1 } /^etag:/ { gsub("\r", "", $2); print $2 }' \
  target/create.headers)"
LOCATION="$(awk 'BEGIN { IGNORECASE=1 } /^location:/ { gsub("\r", "", $2); print $2 }' \
  target/create.headers)"
test "$CREATE_STATUS" = 201
test -n "$ID"
test -n "$ETAG"
test "$LOCATION" = "/api/study-sessions/$ID"

test "$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \
  -H "Authorization: Bearer $WRITE_TOKEN" \
  "http://localhost:8080/api/study-sessions/$ID/completion")" = 428
test "$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \
  -H "Authorization: Bearer $WRITE_TOKEN" -H 'If-Match: malformed' \
  "http://localhost:8080/api/study-sessions/$ID/completion")" = 412
test "$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \
  -H "Authorization: Bearer $WRITE_TOKEN" -H 'If-Match: "999"' \
  "http://localhost:8080/api/study-sessions/$ID/completion")" = 412

COMPLETE_STATUS="$(curl -sS -D target/complete.headers -o target/complete.json \
  -w '%{http_code}' -X PATCH \
  -H "Authorization: Bearer $WRITE_TOKEN" -H "If-Match: $ETAG" \
  "http://localhost:8080/api/study-sessions/$ID/completion")"
NEW_ETAG="$(awk 'BEGIN { IGNORECASE=1 } /^etag:/ { gsub("\r", "", $2); print $2 }' \
  target/complete.headers)"
test "$COMPLETE_STATUS" = 200
test -n "$NEW_ETAG"
test "$NEW_ETAG" != "$ETAG"
grep -q '"status":"COMPLETED"' target/complete.json

test "$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
  -H "Authorization: Bearer $WRITE_TOKEN" \
  "http://localhost:8080/api/study-sessions/$ID")" = 204
test "$(curl -s -o /dev/null -w '%{http_code}' \
  -H "Authorization: Bearer $READ_TOKEN" \
  "http://localhost:8080/api/study-sessions/$ID")" = 404

ApiSecurity는 health와 추가 probe 경로만 공개하고 GET에는 study.read, 나머지 세션 요청에는 study.write를 요구합니다. 401·403은 Security filter 단계에서 만들어지므로 모든 오류를 같은 ProblemDetail 모양으로 맞추려면 별도의 AuthenticationEntryPointAccessDeniedHandler가 필요합니다. 현재 ApiErrors는 Controller 이후의 404·409·412·428을 담당합니다.

smoke script는 local profile의 실제 HMAC 서명과 scope 변환을 통과합니다. Test에서 decoder를 mock한 결과와 역할이 다르며, script가 만든 컨테이너는 종료하되 docker compose down-v를 쓰지 않아 학습 DB volume은 지우지 않습니다.


권한·검증·사전조건 시나리오를 호출한다

먼저 script 자체가 시작한 PostgreSQL과 애플리케이션만 대상으로 실행합니다. 토큰 없음, scope 부족, 검증 실패, 없는 ID를 각각 확인한 뒤 정상 생성 응답의 ID와 ETag를 조건부 완료 요청으로 전달합니다.

bash dev-api-smoke.sh
Concurrency 확인 결과
local profile의 실제 서명 JWT로 토큰 없음 401, read scope의 쓰기 403, 잘못된 입력 400, 미조회 404, 누락된 If-Match 428, 오래되거나 잘못된 ETag 412, 올바른 ETag 완료 200을 재현해야 합니다. 두 동시 transaction의 optimistic lock 409는 단일 순차 smoke로 재현됐다고 주장하지 않고 별도 동시성 통합 테스트에서 확인합니다.

HTTP 시나리오를 사용자 순서로 연결한다

토큰 획득 이후 생성, 조회, 조건부 완료, 삭제를 실행하고 각 단계의 Location, ETag, ProblemDetail을 다음 요청의 입력으로 사용합니다.

POST가 201을 반환해도 Location이 틀리면 클라이언트는 새 리소스를 이어서 조회할 수 없습니다. PATCH 성공 뒤에는 새 ETag와 COMPLETED 상태를 확인하고, DELETE 204 뒤 같은 ID가 404인지까지 보아 한 사용자 흐름을 닫습니다.


412와 409는 복구 방법이 다르다

412는 요청을 보내기 전에 이미 최신 표현이 아니었다는 뜻이므로 GET으로 새 ETag를 받은 뒤 사용자가 변경을 다시 판단합니다. 409는 사전조건 검사 뒤 commit 경쟁에서 졌다는 뜻이며 역시 최신 상태를 다시 읽어야 하지만, 서버 로그에는 optimistic lock 충돌을 별도 운영 신호로 남깁니다.

HTTP 계약이 완성됐으므로 마지막 절에서는 같은 흐름을 Testcontainers로 반복하고 candidate image를 PostgreSQL과 함께 올린 뒤 이전 image 복귀까지 리허설합니다.