트랜잭션과 동시성: 두 사용자가 동시에 수정하면 누구의 데이터가 살아남는가

📖 17min read

“저장했는데 내 수정이 사라졌어요”

어느 날 담당자에게서 연락이 왔다.

“프로젝트 정보를 수정하고 저장했는데, 새로고침하니까 원래대로 돌아가 있어요.”

처음에는 버그인 줄 알았다. 코드를 봤지만 로직은 정상이었다. 저장도 되고, DB에도 반영된다. 그런데 로그를 자세히 들여다보니 원인이 보였다.

[14:32:01] 사용자 A: 프로젝트 "농장관리" 조회 (version=1)
[14:32:03] 사용자 B: 프로젝트 "농장관리" 조회 (version=1)
[14:32:10] 사용자 A: 이름을 "스마트농장"으로 수정 → 저장 성공
[14:32:15] 사용자 B: 상태를 "완료"로 수정 → 저장 성공

A가 이름을 바꿨고, 5초 뒤에 B가 상태를 바꿨다. 둘 다 “저장 성공”이었다. 그런데 B가 저장할 때 A의 수정 사항을 덮어써 버린 것이다. B는 version=1 기준의 데이터를 통째로 저장했으니, A가 바꾼 이름은 사라졌다.

이것이 ‘Lost Update(갱신 손실)’ 문제다. 두 사용자가 동시에 같은 데이터를 수정할 때, 나중에 저장한 쪽이 먼저 저장한 쪽의 변경을 덮어쓰는 현상이다.

‘@Transactional을 붙이면 해결되는 거 아니야?’

그렇지 않다. 트랜잭션은 ‘하나의 작업 단위’를 보장할 뿐, ‘두 사용자의 동시 수정’까지 자동으로 해결하지 않는다. 이 글에서는 트랜잭션의 기본을 짚은 뒤, 동시성 문제를 어떻게 제어하는지 실무 관점에서 정리한다.

둘 다 저장에 성공했지만, 먼저 저장한 사람의 수정은 사라졌다.

@Transactional은 어디까지 보장하는가

동시성 문제를 다루기 전에, 트랜잭션의 기본부터 짧게 짚자.

트랜잭션(Transaction)이란, 여러 DB 작업을 하나의 단위로 묶는 것이다. “전부 성공하거나, 전부 실패하거나.” 중간에 하나가 실패하면 전부 원래대로 되돌린다(롤백).

@Transactional
public void transferMoney(Long fromId, Long toId, int amount) {
    Account from = accountRepository.findById(fromId).orElseThrow();
    Account to = accountRepository.findById(toId).orElseThrow();

    from.withdraw(amount);   // 출금
    to.deposit(amount);      // 입금
    // 둘 다 성공해야 커밋. 하나라도 실패하면 둘 다 롤백.
}

Spring에서는 @Transactional 어노테이션 하나로 트랜잭션을 관리한다. 이 메서드 안의 모든 DB 작업이 성공하면 커밋, 하나라도 예외가 나면 롤백된다.

ACID라는 트랜잭션의 4가지 속성을 들어본 적이 있을 것이다. 간단히 정리하면 이렇다.

속성의미한 줄 설명
Atomicity (원자성)전부 아니면 전무출금만 되고 입금이 안 되는 건 없다
Consistency (일관성)규칙을 위반하지 않음잔액이 음수가 되면 안 된다
Isolation (격리성)동시 실행이 서로 간섭하지 않음두 트랜잭션이 서로의 중간 상태를 보지 않는다
Durability (지속성)커밋되면 영구 보존서버가 꺼져도 데이터는 살아 있다

여기서 핵심은 세 번째, ‘격리성(Isolation)’이다. 이론적으로는 트랜잭션끼리 서로 완벽히 격리되어야 하지만, 현실에서는 성능을 위해 격리 수준을 조절한다. 그리고 이 격리 수준만으로 해결되지 않는 문제가 있다. 그것이 바로 ‘동시성 제어’다.


@Transactional에서 자주 하는 실수

동시성으로 넘어가기 전에, @Transactional 자체에서 발생하는 흔한 실수를 먼저 짚자. 이것만 알아도 실무에서 시간을 많이 아낀다.

실수 1: 같은 클래스 안에서 호출하면 트랜잭션이 안 걸린다

@Service
public class ProjectService {

    public void outerMethod() {
        innerMethod();  // @Transactional이 안 걸림!
    }

    @Transactional
    public void innerMethod() {
        // DB 작업...
    }
}

Spring의 @Transactional은 프록시 기반이다. 외부에서 호출해야 프록시를 거쳐서 트랜잭션이 적용된다. 같은 클래스 안에서 this.innerMethod()로 호출하면 프록시를 우회하므로 트랜잭션이 걸리지 않는다.

해결법: 트랜잭션이 필요한 메서드는 별도의 서비스 클래스로 분리하거나, outerMethod에 @Transactional을 붙인다.

실수 2: 예외 종류에 따라 롤백이 안 된다

@Transactional
public void createProject(ProjectDto dto) {
    projectRepository.save(dto.toEntity());
    
    if (someCondition) {
        throw new IOException("파일 오류");  // 체크 예외 → 롤백 안 됨!
    }
}

Spring의 @Transactional은 기본적으로 RuntimeException(언체크 예외)에서만 롤백한다. IOException 같은 체크 예외는 롤백하지 않는다. 체크 예외에서도 롤백하려면 명시해야 한다.

@Transactional(rollbackFor = Exception.class)

실수 3: 읽기 전용 트랜잭션

조회만 하는 메서드에는 readOnly를 붙이면 성능이 좋아진다. Hibernate가 변경 감지(dirty checking)를 생략하고, DB도 읽기 최적화를 적용할 수 있다.

@Transactional(readOnly = true)
public List<Project> getProjects() {
    return projectRepository.findAll();
}

동시성 문제: 트랜잭션만으로는 부족한 이유

서론에서 봤던 Lost Update를 다시 떠올려 보자.

시간순서:
1. 트랜잭션 A: 프로젝트 조회 (이름="농장관리", 상태="진행중")
2. 트랜잭션 B: 프로젝트 조회 (이름="농장관리", 상태="진행중")
3. 트랜잭션 A: 이름을 "스마트농장"으로 수정 → 커밋
4. 트랜잭션 B: 상태를 "완료"로 수정 → 커밋
   → A의 이름 변경이 사라짐!

두 트랜잭션 모두 정상적으로 커밋되었다. ACID의 원자성도 깨지지 않았다. 하지만 결과적으로 A의 변경은 사라졌다. 왜?

PostgreSQL의 기본 격리 수준은 ‘READ COMMITTED’다. 이 수준에서는 “다른 트랜잭션이 커밋한 데이터만 볼 수 있다”는 것만 보장한다. 두 트랜잭션이 같은 데이터를 동시에 수정하는 것 자체를 막지는 않는다.

격리 수준을 올리면(REPEATABLE READ, SERIALIZABLE) 일부 문제를 방지할 수 있지만, 성능 비용이 크고 데드락 위험이 높아진다. 그래서 실무에서는 격리 수준을 올리기보다, ‘락(Lock)’을 사용해서 동시성을 제어한다.


낙관적 락 vs 비관적 락

동시성 문제의 핵심 해결책이다. 이 둘의 차이를 이해하면, 실무에서 어떤 상황에 어떤 락을 써야 하는지 판단할 수 있다.

낙관적 락(Optimistic Lock): “충돌은 드물 것이다. 발생하면 그때 처리하자”

충돌이 자주 일어나지 않는다고 가정하고, 저장하는 시점에 충돌을 감지한다. JPA에서는 @Version 어노테이션으로 구현한다.

@Entity
public class Project {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String status;

    @Version  // 낙관적 락의 핵심
    private Long version;
}

@Version을 붙이면, JPA가 UPDATE 시 자동으로 버전을 체크한다.

-- JPA가 생성하는 SQL
UPDATE project
SET name = '스마트농장', version = 2
WHERE id = 1 AND version = 1

만약 다른 트랜잭션이 이미 version을 2로 올렸다면, WHERE 조건이 맞지 않아 업데이트 행 수가 0이 된다. JPA는 이를 감지하고 ‘OptimisticLockException’을 던진다.

시간순서 (낙관적 락 적용 후):
1. 트랜잭션 A: 조회 (version=1)
2. 트랜잭션 B: 조회 (version=1)
3. 트랜잭션 A: 수정 → UPDATE ... WHERE version=1 → 성공 (version=2)
4. 트랜잭션 B: 수정 → UPDATE ... WHERE version=1 → 실패! (version 불일치)
   → OptimisticLockException 발생
   → "다른 사용자가 수정했습니다. 다시 시도해 주세요."
// 예외 처리 예시
@PutMapping("/api/projects/{id}")
public ResponseEntity<?> updateProject(@PathVariable Long id,
                                        @RequestBody ProjectUpdateDto dto) {
    try {
        projectService.update(id, dto);
        return ResponseEntity.ok().build();
    } catch (OptimisticLockException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body("다른 사용자가 이미 수정했습니다. 새로고침 후 다시 시도해 주세요.");
    }
}

비관적 락(Pessimistic Lock): “충돌이 일어날 것이다. 미리 잠그자”

충돌이 자주 일어날 것으로 예상되면, 조회 시점부터 해당 행을 잠근다. 다른 트랜잭션은 락이 풀릴 때까지 대기한다.

public interface ProjectRepository extends JpaRepository<Project, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Project p WHERE p.id = :id")
    Optional<Project> findByIdForUpdate(@Param("id") Long id);
}
-- 실행되는 SQL (PostgreSQL)
SELECT * FROM project WHERE id = 1 FOR UPDATE

FOR UPDATE가 붙으면, 이 트랜잭션이 커밋하거나 롤백할 때까지 다른 트랜잭션은 해당 행을 수정할 수 없다. 대기하다가 락이 풀리면 그때 진행한다.

시간순서 (비관적 락 적용):
1. 트랜잭션 A: SELECT ... FOR UPDATE → 행 잠김
2. 트랜잭션 B: SELECT ... FOR UPDATE → 대기 (A가 끝날 때까지)
3. 트랜잭션 A: 수정 → 커밋 → 락 해제
4. 트랜잭션 B: 이제 진행 → 최신 데이터 기준으로 수정 → 커밋

어떤 락을 써야 하는가

상황추천이유
충돌이 드문 경우 (일반 CRUD)낙관적 락성능 좋음, 대기 없음
충돌이 잦은 경우 (재고, 포인트)비관적 락확실한 동시성 보장
읽기가 많고 수정이 드문 경우낙관적 락대부분 충돌 없이 통과
금융/결제 같은 정확성 필수비관적 락데이터 정합성 최우선
낙관적 락은 충돌 시 거부, 비관적 락은 애초에 잠금. 상황에 맞게 선택한다.

실무 조언

1. 데드락에 주의하라

비관적 락을 쓸 때 가장 무서운 것이 데드락(Deadlock)이다. 두 트랜잭션이 서로 상대방의 락을 기다리면서 영원히 멈추는 상태다.

트랜잭션 A: 프로젝트 1 잠금 → 프로젝트 2 잠금 대기
트랜잭션 B: 프로젝트 2 잠금 → 프로젝트 1 잠금 대기
→ 서로 영원히 대기 (데드락!)

PostgreSQL은 데드락을 자동 감지하고 한쪽을 강제 롤백한다. 하지만 예방이 더 좋다.

  • 락을 거는 순서를 일관되게 유지한다 (항상 ID 오름차순으로 잠금)
  • 트랜잭션을 가능한 짧게 유지한다
  • 비관적 락에 타임아웃을 설정한다
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))  // 3초 대기 후 포기
@Query("SELECT p FROM Project p WHERE p.id = :id")
Optional<Project> findByIdForUpdate(@Param("id") Long id);

2. 낙관적 락의 재시도 전략

낙관적 락에서 OptimisticLockException이 발생하면, 단순히 에러를 보여주는 것보다 자동 재시도가 더 나을 때도 있다.

@Retryable(value = OptimisticLockException.class, maxAttempts = 3)
@Transactional
public void updateWithRetry(Long id, ProjectUpdateDto dto) {
    Project project = projectRepository.findById(id).orElseThrow();
    project.update(dto);
}

다만 모든 상황에서 자동 재시도가 적합한 건 아니다. 사용자가 직접 편집한 내용이 포함된 경우, 자동 재시도보다 “충돌 알림 + 새로고침 유도”가 더 안전하다.

3. 실무 체크리스트

점검 항목확인
@Transactional이 프록시를 통해 호출되는가?self-invocation 주의
체크 예외에서 롤백이 필요한가?rollbackFor 설정 확인
읽기 전용 메서드에 readOnly=true를 붙였는가?성능 최적화
동시 수정이 가능한 엔티티에 @Version이 있는가?Lost Update 방지
비관적 락 사용 시 락 순서가 일관되는가?데드락 방지
비관적 락에 타임아웃이 설정되어 있는가?무한 대기 방지

마치며: “트랜잭션”만으로는 부족하다

정리하면 이렇다.

  • @Transactional은 ‘하나의 작업 단위’를 보장하지, ‘동시 수정’을 막지 않는다.
  • 동시 수정 문제(Lost Update)는 락으로 해결한다.
  • 충돌이 드물면 낙관적 락(@Version), 충돌이 잦으면 비관적 락(FOR UPDATE).
  • 비관적 락을 쓸 때는 데드락과 타임아웃을 반드시 고려한다.

다음 글에서는 API 문서화 이야기를 다뤄보려 한다. 팀원이 “이 API 파라미터가 뭐예요?” 하고 물어볼 때마다 코드를 열어서 설명하는 건 한계가 있다. Swagger에서 SpringDoc으로 넘어간 이유와, API 문서를 자동으로 생성하는 방법을 알아보겠다.

댓글 남기기