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

안동민 개발노트

본문 시작
3장 : 데이터베이스와 트랜잭션

StudySession JPA 매핑

2장에서 메모리에 두었던 StudySession을 이제 PostgreSQL에 남깁니다. 객체를 테이블에 옮기는 일은 필드와 컬럼을 짝짓는 작업으로 끝나지 않습니다. enum을 어떤 값으로 보존할지, 어떤 생성 경로를 허용할지, 동시 수정을 어떻게 감지할지까지가 영속 모델의 계약입니다.


객체 모델과 테이블 모델의 차이를 명시한다

JPA entity는 테이블의 모양과 업무 상태를 함께 담는 경계 객체입니다. id는 동일성을, status는 상태 전이를, version은 두 transaction이 같은 행을 수정했는지를 표현합니다. 이 중 하나라도 Controller의 임시 값으로 남기면 재시작과 동시성 상황에서 정합성을 잃습니다.


Entity와 enum 값을 안전하게 저장한다

EnumType.STRING은 enum의 순서가 바뀌어도 기존 행의 의미를 유지합니다. @Version은 업무 필드가 아니라 JPA가 UPDATE study_session SET status = ? WHERE id = ? AND version = ?처럼 기존 버전을 조건에 넣기 위한 숨은 동시성 토큰입니다. protected 기본 생성자는 JPA에게만 열어 두고, 애플리케이션은 의미 있는 생성자와 메서드로만 상태를 바꾸게 합니다.

pom.xml — dependencies에 추가
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <scope>runtime</scope>
</dependency>
src/main/java/com/andongmin/studylog/session/SessionStatus.java
package com.andongmin.studylog.session;

public enum SessionStatus {
    PLANNED,
    COMPLETED,
    CANCELLED
}
src/main/java/com/andongmin/studylog/session/StudySessionEntity.java
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 StudySessionEntity {
    @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 StudySessionEntity() {
    }

    public StudySessionEntity(UUID id, String subject, String topic,
            LocalDate studyDate, int minutes) {
        this.id = id;
        this.subject = subject;
        this.topic = topic;
        this.studyDate = studyDate;
        this.minutes = minutes;
        this.status = SessionStatus.PLANNED;
    }

    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; }

    public void replace(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.subject = subject;
        this.topic = topic;
        this.studyDate = studyDate;
        this.minutes = minutes;
    }

    public void complete() {
        if (status != SessionStatus.PLANNED) {
            throw new IllegalStateException("only PLANNED session can complete");
        }
        status = SessionStatus.COMPLETED;
    }
}

DDL과 조회 결과로 매핑을 확인한다

이 절에서는 아직 schema를 자동 생성하지 않습니다. 우선 컴파일로 annotation과 타입 계약을 확인하고, ch3-4에서 Flyway가 만든 실제 컬럼과 ddl-auto=validate를 맞추겠습니다. version이 entity에 있는데 migration에서 빠지면 startup이 실패하는 것이 정상입니다.

./mvnw -q -DskipTests compile
Version 확인 결과
Jakarta Persistence import만 사용하고 SessionStatus가 문자열, studyDate가 date, version이 낙관적 잠금 열로 매핑되어야 합니다.

매핑을 고정했으니 다음에는 SQL을 직접 조립하지 않고도 조건을 조합할 수 있는 Repository 경계를 올립니다.