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

안동민 개발노트

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

서비스 트랜잭션과 잠금 충돌

Repository를 직접 여러 번 호출하면 각 호출이 성공해도 전체 업무는 반쯤만 반영될 수 있습니다. 세션 완료는 행을 읽고, 상태 규칙을 확인하고, 변경을 DB에 확정하는 하나의 유스케이스입니다. @Transactional은 이 업무 경계에 놓습니다.


트랜잭션 경계는 유스케이스 경계와 맞춘다

Spring의 transaction interceptor는 public service method 호출 앞뒤에 transaction을 열고 닫습니다. 그 안에서 조회한 entity는 managed 상태가 되므로 setter 후 save()를 반드시 다시 호출할 필요는 없습니다. 다만 SQL은 보통 commit 직전까지 지연되므로, DB 제약과 낙관적 잠금 실패를 서비스 안에서 다루려면 flush() 시점을 의도적으로 당깁니다.


상태 변경과 flush를 한 서비스에 모은다

complete()는 ID로 entity를 찾지 못했을 때와, 이미 완료된 상태일 때를 다른 실패로 남깁니다. 이 차이는 5장에서 404와 409 ProblemDetail로 옮겨갑니다. 지금은 Controller를 만들지 않고 서비스가 어떤 원인을 보존하는지에 집중합니다.

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

import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class StudySessionCommandService {
    private final StudySessionJpaRepository repository;

    public StudySessionCommandService(StudySessionJpaRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public StudySessionEntity complete(UUID id) {
        StudySessionEntity entity = repository.findById(id)
                .orElseThrow(() -> new SessionNotFoundException(id));
        if (entity.getStatus() != SessionStatus.PLANNED) {
            throw new IllegalStateException("only PLANNED session can complete");
        }
        entity.complete();
        repository.flush();
        return entity;
    }
}
src/main/java/com/andongmin/studylog/session/SessionNotFoundException.java
package com.andongmin.studylog.session;

import java.util.UUID;

public class SessionNotFoundException extends RuntimeException {
    public SessionNotFoundException(UUID id) {
        super("study session not found: " + id);
    }
}

flush가 실패를 드러내는 위치를 고정한다

아직 HTTP 수정 endpoint가 없으므로 이 절에서 “두 요청”을 억지로 연출하지 않습니다. 컴파일로 transaction 경계와 flush() 위치를 고정하고, 실제 412·40409 분기는 ETag endpoint가 완성되는 ch5-4에서 두 터미널로 재현합니다.

./mvnw -q -DskipTests compile
Conflict 확인 결과
`complete` 메서드의 transaction 안에서 조회·상태 전이·flush가 하나의 call stack으로 이어지면 준비가 된 것입니다.

외부 호출과 데이터 변경을 같은 트랜잭션에 묶지 않는다

느린 네트워크 호출을 transaction 안에 넣으면 DB connection과 lock을 잡은 채 원격 서비스를 기다리게 됩니다. 더 큰 문제는 DB rollback이 이미 보낸 메시지나 HTTP 요청을 되돌릴 수 없다는 점입니다. 7장에서 Outbox로 커밋과 발행 사이를 분리할 때 이 제약으로 돌아옵니다.


서비스가 도달할 실제 테이블이 없으면 transaction 설계는 검증할 수 없습니다. 이제 schema 생성 책임을 Hibernate에서 Flyway로 옮깁니다.