도메인·JPA·Flyway 수직 통합
이제 프로젝트에 첫 번째 수직 기능을 넣습니다. StudySession의 일곱 필드에는 식별자와 JPA version까지 포함되며, Java 객체와 PostgreSQL 행이 같은 분량·상태 규칙을 지켜야 합니다.
Entity만 먼저 만들고 Hibernate가 schema를 추론하게 두지 않습니다. Flyway가 테이블을 만들고 Hibernate는 validate만 수행하게 해, 배포할 SQL과 애플리케이션 매핑이 서로 어긋나면 시작 단계에서 실패하도록 합니다.
도메인 불변식과 저장 제약을 함께 고정한다
StudySession.plan은 생성 가능한 값과 초기 상태를 고정하고 complete는 PLANNED에서 COMPLETED로 가는 한 방향만 허용합니다. DB의 CHECK 제약은 다른 쓰기 경로에서도 분량과 상태 집합을 지키고, service transaction은 조회와 상태 변경을 하나의 작업으로 묶습니다.
같은 규칙을 Java와 SQL에 두는 것은 중복이 아니라 방어 층의 차이입니다. Java 검증은 호출자 가까이에서 읽기 좋은 오류를 내고, DB 제약은 경쟁 요청이나 별도 관리 도구가 규칙을 우회하지 못하게 마지막 경계를 지킵니다.
Entity·Repository·Service·migration을 완성한다
먼저 V1 migration을 적용한 뒤 같은 열 이름·길이·enum 표현을 Entity에 매핑합니다. @Version 값은 insert 때 0으로 시작하고 update의 WHERE 조건에 포함되어, 뒤 절의 ETag 검사 이후 발생한 DB 경쟁도 감지합니다.
service의 complete는 관리 상태의 Entity를 읽고 도메인 메서드를 호출한 뒤 flush합니다. flush를 transaction 끝까지 미루지 않으므로 optimistic lock 오류를 service 호출 안에서 받아 HTTP 409로 변환할 수 있습니다.
CREATE TABLE study_session (
id UUID PRIMARY KEY,
subject VARCHAR(80) NOT NULL,
topic VARCHAR(120) NOT NULL,
study_date DATE NOT NULL,
minutes INTEGER NOT NULL CHECK (minutes BETWEEN 1 AND 1440),
status VARCHAR(20) NOT NULL CHECK (status IN ('PLANNED', 'COMPLETED', 'CANCELLED')),
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_study_session_date_id
ON study_session (study_date DESC, id);services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: studylog
POSTGRES_USER: studylog
POSTGRES_PASSWORD: studylog
ports:
- "5432:5432"
volumes:
- studylog-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U studylog -d studylog"]
interval: 5s
timeout: 3s
retries: 20
volumes:
studylog-data:package com.andongmin.studylog.session;
public enum SessionStatus {
PLANNED,
COMPLETED,
CANCELLED
}package com.andongmin.studylog.session;
import java.util.UUID;
public class SessionNotFoundException extends RuntimeException {
public SessionNotFoundException(UUID id) {
super("study session not found: " + id);
}
}package com.andongmin.studylog.session;
import java.time.LocalDate;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "study_session")
public class StudySession {
@Id private UUID id;
@Column(nullable = false, length = 80) private String subject;
@Column(nullable = false, length = 120) private String topic;
@Column(name = "study_date", nullable = false) private LocalDate studyDate;
@Column(nullable = false) private int minutes;
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 20)
private SessionStatus status;
@Version private long version;
protected StudySession() {
}
private StudySession(UUID id, String subject, String topic,
LocalDate studyDate, int minutes) {
if (subject == null || subject.isBlank() || subject.length() > 80) {
throw new IllegalArgumentException("subject must be 1 to 80 characters");
}
if (topic == null || topic.isBlank() || topic.length() > 120) {
throw new IllegalArgumentException("topic must be 1 to 120 characters");
}
if (studyDate == null || minutes < 1 || minutes > 1440) {
throw new IllegalArgumentException("date and minutes are invalid");
}
this.id = id;
this.subject = subject;
this.topic = topic;
this.studyDate = studyDate;
this.minutes = minutes;
this.status = SessionStatus.PLANNED;
}
public static StudySession plan(String subject, String topic,
LocalDate studyDate, int minutes) {
return new StudySession(UUID.randomUUID(), subject, topic, studyDate, minutes);
}
public void complete() {
if (status != SessionStatus.PLANNED) {
throw new IllegalStateException("only PLANNED session can complete");
}
status = SessionStatus.COMPLETED;
}
public UUID getId() { return id; }
public String getSubject() { return subject; }
public String getTopic() { return topic; }
public LocalDate getStudyDate() { return studyDate; }
public int getMinutes() { return minutes; }
public SessionStatus getStatus() { return status; }
public long getVersion() { return version; }
}package com.andongmin.studylog.session;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StudySessionRepository extends JpaRepository<StudySession, UUID> {
}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.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 repository.findById(id).orElseThrow(() -> new SessionNotFoundException(id));
}
@Transactional
public StudySession complete(UUID id) {
StudySession session = findOne(id);
session.complete();
repository.flush();
return session;
}
@Transactional
public void delete(UUID id) {
StudySession session = findOne(id);
repository.delete(session);
}
public record CreateCommand(String subject, String topic,
LocalDate studyDate, int minutes) {
}
}findOne은 읽기 transaction을 제공하지만 complete에서 호출될 때는 바깥 쓰기 transaction이 전체 작업을 지배합니다. private load로 조회를 모으면 self-invocation annotation에 기대지 않고도 not-found 규칙을 재사용할 수 있습니다.
실제 PostgreSQL에서 migration과 매핑을 확인한다
spring-boot:run은 앞에서 실행하면 다음 명령으로 넘어가지 않는 장기 실행 프로세스입니다. 아래처럼 background PID와 정리 trap을 명시하고, local decoder를 사용해 외부 issuer 없이 애플리케이션 시작과 Flyway 이력을 확인합니다.
docker compose up -d --wait postgres
export LOCAL_JWT_SECRET=study-log-local-development-key-2026
mkdir -p target
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:run \
> target/ch13-2-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
for attempt in {1..60}; do
if docker compose exec -T postgres psql -U studylog -d studylog -Atc \
"SELECT count(*) FROM flyway_schema_history WHERE success" 2>/dev/null \
| grep -qx '1' && \
grep -q "Started StudyLogApplication" target/ch13-2-app.log; then
break
fi
test "$attempt" -lt 60
sleep 1
done
grep -q "Started StudyLogApplication" target/ch13-2-app.log
docker compose exec -T postgres psql -U studylog -d studylog \
-c "SELECT version, description, success FROM flyway_schema_history"V1 migration이 성공하고 애플리케이션 로그에 시작 완료가 남아야 합니다. 여기까지는 schema와 JPA 매핑을 검증한 것이며, 실제 생성 행과 version 0은 Controller까지 연결한 다음 절의 POST 시나리오에서 확인합니다.DB 제약과 Java 검증은 서로를 보완한다
Java 검증은 빠른 오류를 제공하고 DB 제약은 다른 쓰기 경로와 경쟁 상황에서도 최종 무결성을 지킵니다.
두 규칙이 다르면 더 관대한 쪽을 통과한 값이 다른 층에서 실패합니다. 열 길이, minutes 범위, enum 문자열을 변경할 때는 Entity와 새 Flyway migration을 같은 변경 묶음으로 검토합니다.
migration 실패에서는 schema를 임의로 고치지 않는다
적용된 migration 파일을 수정해 checksum을 맞추거나 테이블을 손으로 고치면 다른 환경과 이력이 갈라집니다. 실패 로그와 flyway_schema_history를 보존하고, 아직 적용되지 않은 로컬 volume만 명시적으로 초기화하거나 새 보정 migration을 추가합니다.
저장 경계가 확인됐으므로 다음에는 이 service를 HTTP에 노출하고, JWT scope와 ETag가 같은 transaction 경계를 지키도록 연결합니다.