리소스 URI와 상태 코드
보안 필터 뒤에 실제 자원을 놓을 차례입니다. URL에 create 같은 동사를 넣기보다 StudySession을 복수형 resource로 보고 HTTP method가 작업의 의미를 담게 합니다. 상태 코드는 Controller의 성공 여부가 아니라 client가 다음에 할 일을 알려 주는 계약입니다.
URI는 작업이 아니라 자원을 식별한다
POST /api/study-sessions는 collection에 새 항목을 추가하고 201과 Location을 돌려줍니다. GET /{id}는 현재 표현을 읽고, DELETE /{id}는 성공 body가 필요 없으므로 204를 보냅니다. 없는 ID를 빈 객체나 200으로 반환하지 않고 404로 자원이 없음을 표현합니다.
같은 URI라도 method에 따라 안전성과 멱등성이 달라집니다. GET은 상태를 바꾸지 않아야 하고, DELETE는 재시도해도 최종 상태가 같아야 합니다. POST는 그대로 재시도하면 중복 생성이 될 수 있으므로 장애 복구 정책에서 따로 다룹니다.
생성·조회·삭제 Controller를 완성한다
첫 Controller는 HTTP 계약에만 집중하기 위해 in-memory service에 연결합니다. Controller는 URI·header·status를 알지만 저장 자료구조를 알지 못하고, service는 HTTP status를 알지 못하게 두는 것이 핵심입니다. 앞서 만든 보안 probe Controller는 같은 GET mapping을 사용하므로 반드시 삭제하고 교체합니다.
package com.andongmin.studylog.session;
import java.time.LocalDate;
import java.util.List;
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 interface StudySessionApi {
StudySessionView create(CreateStudySessionRequest request);
List<StudySessionView> findAll();
StudySessionView findOne(UUID id);
void delete(UUID id);
record CreateStudySessionRequest(
@NotBlank @Size(max = 80) String subject,
@NotBlank @Size(max = 120) String topic,
@NotNull @PastOrPresent LocalDate studyDate,
@Min(1) @Max(1440) int minutes) {
}
record StudySessionView(
UUID id, String subject, String topic, LocalDate studyDate,
int minutes, SessionStatus status, long version) {
}
}package com.andongmin.studylog.session;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
@Service
public class InMemoryStudySessionApi implements StudySessionApi {
private final Map<UUID, StudySessionView> sessions = new ConcurrentHashMap<>();
@Override
public StudySessionView create(CreateStudySessionRequest request) {
UUID id = UUID.randomUUID();
var created = new StudySessionView(id, request.subject(), request.topic(),
request.studyDate(), request.minutes(), SessionStatus.PLANNED, 0);
sessions.put(id, created);
return created;
}
@Override
public List<StudySessionView> findAll() {
return List.copyOf(sessions.values());
}
@Override
public StudySessionView findOne(UUID id) {
StudySessionView found = sessions.get(id);
if (found == null) {
throw new SessionNotFoundException(id);
}
return found;
}
@Override
public void delete(UUID id) {
if (sessions.remove(id) == null) {
throw new SessionNotFoundException(id);
}
}
}ch4-2의 StudySessionSecurityProbeController.java는 보안 확인용 임시 endpoint이므로 삭제하고 실제 REST Controller로 교체합니다.
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.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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/study-sessions")
public class StudySessionController {
private final StudySessionApi sessions;
public StudySessionController(StudySessionApi sessions) {
this.sessions = sessions;
}
@PostMapping
ResponseEntity<StudySessionApi.StudySessionView> create(
@Valid @RequestBody StudySessionApi.CreateStudySessionRequest request) {
var created = sessions.create(request);
return ResponseEntity.created(
URI.create("/api/study-sessions/" + created.id())).body(created);
}
@GetMapping
List<StudySessionApi.StudySessionView> findAll() {
return sessions.findAll();
}
@GetMapping("/{id}")
StudySessionApi.StudySessionView findOne(@PathVariable UUID id) {
return sessions.findOne(id);
}
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable UUID id) {
sessions.delete(id);
return ResponseEntity.noContent().build();
}
}ResponseEntity.created()는 201과 Location을 함께 만듭니다. 반면 단순 조회 body는 Spring MVC가 200으로 직렬화합니다. 이 차이를 service의 return type에 섞지 않아야 나중에 GraphQL이 같은 service를 재사용할 수 있습니다.
성공과 미조회 응답을 curl로 확인한다
서버를 실행하는 terminal A와 curl을 보내는 terminal B를 나눕니다. write token으로 생성한 뒤 응답의 Location을 조회하면 같은 자원이 나와야 합니다. 없는 UUID는 service의 SessionNotFoundException을 거쳐 404가 되어야 하지만, 아직은 오류 body가 통일되지 않았습니다. ch5-3에서 ProblemDetail로 고정합니다.
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"
WRITE_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.write")"
curl -i -X POST http://localhost:8080/api/study-sessions \
-H "Authorization: Bearer $WRITE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"subject\":\"Spring\",\"topic\":\"REST\",\"studyDate\":\"2026-07-13\",\"minutes\":60}"생성 응답은 201, Location 헤더, PLANNED 상태를 포함해야 합니다.멱등성과 안전성을 요청 재시도로 확인한다
GET은 상태를 바꾸지 않고 DELETE는 여러 번 호출해도 최종 상태가 같아야 합니다. POST 재시도에는 별도 idempotency 전략이 필요합니다.
재시도 가능 여부를 method별로 기록한다
타임아웃 후 client가 안전하게 재시도할 수 있는지는 method 이름이 아니라 실제 효과로 판단합니다. GET은 상태를 바꾸지 않고, DELETE는 반복해도 삭제된 최종 상태가 같습니다. POST 멱등성이 필요하면 idempotency key와 결과 저장 정책을 별도로 설계해야 합니다.
현재 Controller는 HTTP body와 저장 모델 사이의 계약을 nested record로 임시 표현합니다. 다음에는 이를 독립 DTO로 분리하고 잘못된 JSON을 service 진입 전에 거부합니다.