본문 바로가기

카테고리 없음

[Spring] 어라? 왜 동일 트랜잭션 내 원자성이 보장이 안되지?

안녕하세요!

 

이번에는 제가 최근 실무에서 겪었던 문제를 공유해 보려고 합니다.

 

문제 상황은 동일한 트랜잭션 내에서 데이터들의 원자성이 보장되지 않아 일관성이 깨지는 현상이 발생한 것이었습니다. 😮‍💨 이번 경험과 해결 과정을 함께 공유드리겠습니다.

 

 

 

목차


  • 트랜잭션 4가지 원칙
  • 문제상황
  • 원인 분석 및 해결 과정
  • 결론

 

 

 

트랜잭션 4가지 원칙 (ACID)


문제 상황을 설명하기 전에, 트랜잭션의 4가지 원칙을 간단하게 상기해보겠습니다.

 

원자성(Atomicity)

  • 원자성이란 트랜잭션에 속한 각각의 CRUD문을 하나의 단위로 취급합니다.
  • 따라서 문 전체를 실행하거나 그 문의 어떤 부분도 실행하지 않거나 둘 중 하나입니다.

일관성(Consistency)

  • 트랜잭션이 테이블에 변경 사항을 적용할 때 미리 정의된 예측할 수 있는 방식으로 처리됩니다.
  • 따라서 데이터 손상이나 오류 때문에 테이블 무결성에 의도치 않은 결과가 생기지 않습니다.

격리(Isolation)

  • 여러 사용자가 같은 테이블에서 동시에 읽기와 쓰기 작업을 수행할 때, 각 트랜잭션은 서로 독립적으로 실행되어 서로의 작업에 영향을 주지 않습니다.
  • 즉, 실제로 동시에 발생하더라도 순차적으로 처리되는 것처럼 보장됩니다.

영속성(Durability)

  • 트랜잭션 실행으로 인해 데이터에 적용된 변경 사항이 저장되도록 보장합니다.
  • 시스템 오류가 발생하더라도 변경 사항은 저장이 되어야 합니다.

 

이처럼 트랜잭션은 데이터의 안정성과 무결성을 최대한 보장하기 위해 사용되며, 작업 일부만 완료되어 데이터가 일관성 없는 상태로 남는 상황을 방지합니다. 😊

 

 

 

문제 상황


그러나, 제가 맞닥뜨린 문제는 하나의 트랜잭션 내에서 데이터들의 원자성이 보장되지 않아 일관성이 깨지는 현상이 발생한 것입니다.

 

 

문제 발생 흐름

동일 트랜잭션 내 : A 테이블 a 조회 -> a 변경 -> A 테이블 b 조회 -> b 변경 -> 에러(트랜잭션 롤백) -> a 변경 사항만 실제 반영 됨
  • 동일 트랜잭션 내에서 A 테이블의 a 엔티티 조회 → a 수정
  • 같은 트랜잭션 내에서 A 테이블의 b 엔티티 조회 → b 수정
  • 이후 로직에서 예외 발생 → 트랜잭션 롤백
  • 그러나 실제 DB에서는 a 엔티티의 변경 사항만 반영되고, b를 포함한 나머지 데이터는 정상적으로 롤백됨

 

즉, 하나의 트랜잭션에서 롤백이 발생했음에도 불구하고 a 엔티티의 값은 변경된 채 남아 있었습니다.

 

 

원인 분석 및 해결 과정


우선, 해당 문제가 발생하게 된 원인은 두 가지 정도가 존재하였습니다.

 

사실 첫번째로 설명 드릴 원인은 두번째 원인이 문제가 되지 않았으면 상관이 없던 문제였지만, 그래도 해결 과정을 그대로 공유해드리고자 설명드려 보겠습니다.

 

 

 

첫번째 원인

1. 트랜잭션 내 변경이 가능한 고유한 키로 업데이트 후 조회를 하는 로직 (Flush 유발)


문제의 발생 원인(?)이 된 ScheduleEntity

컬럼 명 데이터 타입 NULL 여부 컬럼 설명 Unique 여부
inbound_schedule_id (PK) INT NOT NULL 입고 일정 관리 PK unique
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
inbound_date DATE NULLABLE 입고 예정일 unique

 

테이블을 보면, 실제 주 원인은 두 번째 원인에서 밝혀졌지만, 첫 번째 해결 과정에서는 PK가 아닌 unique 컬럼인 `inbound_date`가 문제의 일부였습니다.

 

 

문제 발생 시 코드와 동일한 예제 코드

// 최상위 부모 트랜잭션
@Transactional
public InboundResDTO updateMultiInboundAndSchedule(...) {
    ...
    // 문제 발생 예제 로직
    ScheduleEntity beforeScheduleEntity = inboundScheduleService.findByInboundDate(beforeShippingDate);
    scheduleService.adjustReservedInboundQty(-beforeExpectedCount, beforeScheduleEntity);
    ScheduleEntity afterScheduleEntity = inboundScheduleService.findByInboundDate(updateShippingDate);
    scheduleService.adjustReservedInboundQty(updateExpectedCount, afterScheduleEntity);
    ...
}
public class ScheduleService {
    ...
    @Transactional
    public void adjustReservedInboundQty(
            Integer expectedQty,
            ScheduleEntity scheduleEntity
    ) {
        if (inboundScheduleEntity == null) {
            throw new BusinessException(ExceptionStatus.SCHEDULE_NOT_FOUND);
        }
        scheduleEntity.adjustReservedInboundQty(expectedQty);
    }
    ...
}
@Entity
public class InboundScheduleEntity {
    ...
    public void adjustReservedInboundQty(Integer expectedQty) {
        if (expectedQty > 0 && expectedQty > getAvailableQty()) {
            throw new BusinessException(ExceptionStatus.RECEIVE_QTY_EXCEEDS_AVAILABLE_LIMIT);
        }
        this.reservedQty += expectedQty;
        if (this.reservedQty < 0) {
            this.reservedQty = 0;
        }
    }
    ...
}

 

코드를 보시면 눈치 빠르신분들은 이미 눈치를 채셨겠을텐데요! JPA의 영속성 컨텍스트에 존재하는 엔티티는 변경 감지가 적용되며 변경 시 자동으로 flush() 동작을 수행하게 됩니다.

 

즉, 트랜잭션이 끝나기도 전에
1️⃣ A 테이블 a 조회 → 영속성 컨텍스트에 저장
2️⃣ a 수정 → 변경 감지
3️⃣ A 테이블 b 조회 (PK가 아닌 컬럼으로) → 강제 flush() 발생
4️⃣ 변경된 a가 먼저 UPDATE 된 후 b가 조회

 

따라서, 해당 코드 실행 시 beforeScheduleEntity의 값이 변경되어 PK가 아닌 조건으로 조회할 때 정합성을 맞추기 위해 강제 flush()가 발생하게 되었습니다.

 

 

실제 발생했던 로그 (간소화)

-- 첫번째 엔티티 조회
select
    se1_0.schedule_id,
    se1_0.inbound_date 
from
    schedule se1_0      
where
    se1_0.inbound_date='2025-03-11T00:00:00.000+0000'
    
-- 첫번째 엔티티 변경사항 반영 (두번째 엔티티 조회를 위한 반영)
update
    schedule      
set
    inbound_date='2025-03-11T00:00:00.000+0000'
where
    schedule_id=497

-- 두번째 엔티티 조회
select
    se1_0.schedule_id,
    se1_0.inbound_date   
from
    schedule se1_0      
where
    se1_0.inbound_date='2025-03-12T00:00:00.000+0000'

 

 

 

그렇다면 flush()가 발생하지 않도록 수정을 하려면 어떤 방법이 있을까? 🤔


  1. pk와 같이 변경되지 않은 고유한 식별자로 조회.
  2. 데이터를 변경하기 전에 미리 조회.

 

로직 상 pk로 조회를 할 수는 없었기 때문에, 데이터를 변경하기 전에 두 엔티티를 모두 영속성 컨텍스트에 두어 처리를 해보았습니다.

 

수정된 예제코드

// 최상위 부모 트랜잭션
@Transactional
public InboundResDTO updateMultiInboundAndSchedule(...) {
    ...
    // 수정할 엔티티를 미리 다 불러옴
    ScheduleEntity beforeScheduleEntity = inboundScheduleService.findByInboundDate(beforeShippingDate);
    ScheduleEntity afterScheduleEntity = inboundScheduleService.findByInboundDate(updateShippingDate);
    
    // 이후 한번에 수정
    scheduleService.adjustReservedInboundQty(-beforeExpectedCount, beforeScheduleEntity);
    scheduleService.adjustReservedInboundQty(updateExpectedCount, afterScheduleEntity);
    ...
}

이렇게 수정하니, 강제 flush()가 발생하지 않아 update 쿼리가 트랜잭션 종료 전까지 실행되지 않고, 데이터 정합성을 유지할 수 있었습니다.

 

 

 

하지만.. 제가 원하는 것은 해당 에러 상황이 왜 발생하게 되었는지 근본적인 이유가 궁금해 졌습니다.

 

 

 

 

두번째 이유 (진짜 이유)

2. Inno DB 스토리지 엔진이 아닌 경우


제가 사용했던 DB 버전은 MySQL 5.1이었는데, 이 버전에서는 기본 스토리지 엔진이 MyISAM이었습니다. 이로 인해 트랜잭션 롤백이 정상적으로 수행되지 않는 문제가 발생했습니다.

 

 

 

InnoDB vs MyISAM의 트랜잭션 차이


InnoDB:

  • 트랜잭션 지원: BEGIN TRANSACTION, COMMIT, ROLLBACK 사용 가능
  • ACID(원자성, 일관성, 격리, 영속성) 보장
  • MySQL 5.5 이상부터 기본 스토리지 엔진

MyISAM:

  • 트랜잭션 미지원: ROLLBACK이 적용되지 않으며, 데이터가 즉시 반영(autocommit)됨
  • 트랜잭션 로그(redo log, undo log)를 사용하지 않음
  • 한 번 실행된 INSERT, UPDATE, DELETE 문은 되돌릴 수 없음
비고 MyISAM InnoDB
트랜잭션 지원 ❌ 지원 안 함 ✅ 지원
ROLLBACK 가능 여부 ❌ 불가능 (즉시 반영) ✅ 가능
외래키(Foreign Key) ❌ 지원 안 함 ✅ 지원
속도 ✅ 읽기(SELECT) 속도 빠름 ❌ 쓰기(INSERT, UPDATE) 속도 상대적으로 느림
ACID 보장 ❌ 불가능 ✅ 가능

 

즉, MyISAM은 자동 커밋(autocommit) 방식으로 동작하여 변경 사항을 되돌릴 수 없으므로, 문제가 발생한 원인이 되었습니다.

 

 

따라서, 해당 테이블의 기본 스토리지 엔진을 MyISAM에서 InnoDB로 변경했습니다.

ALTER TABLE schedule ENGINE = InnoDB;

 

그 결과, 트랜잭션 롤백이 정상적으로 수행되어 데이터 정합성을 유지할 수 있게 되었습니다. 😮‍💨

 

 

 

 

🎯  결론


이처럼 트랜잭션은 데이터 일관성을 보장하기 위해 사용되지만, MySQL 5.5 이하 버전에서 MyISAM이 기본 스토리지 엔진으로 사용될 경우, 하나의 트랜잭션 내에서도 원자성이 깨지는 문제가 발생할 수 있습니다.

 

 

이번 경우에서는 다행히 사전에 미리 테스트를 하며 정합성이 깨지는 것을 확인하고 바로 flush()가 발생하지 않도록 하여 문제가 생기지는 않았지만, 만약 이를 모르는 상태에서 스프링의 트랜잭션이 당연히 롤백 시 일관성 있게 정합성이 지켜진다 믿고 진행했었다면 문제가 발생하였을 때 큰 어려움이 발생하였을 것 같았습니다. 😣

 

따라서 MySQL을 사용할 때 버전이 5.5이하로 낮은 버전을 사용할 때는 테이블의 기본 스토리지 엔진 설정을 반드시 확인하고, 필요하다면 InnoDB로 변경하는 것이 좋습니다.

 

 

 

참고하기 좋은 자료