안녕하세요!
이번에는 제가 최근 실무에서 겪었던 문제를 공유해 보려고 합니다.
문제 상황은 동일한 트랜잭션 내에서 데이터들의 원자성이 보장되지 않아 일관성이 깨지는 현상이 발생한 것이었습니다. 😮💨 이번 경험과 해결 과정을 함께 공유드리겠습니다.
목차
- 트랜잭션 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()가 발생하지 않도록 수정을 하려면 어떤 방법이 있을까? 🤔
- pk와 같이 변경되지 않은 고유한 식별자로 조회.
- 데이터를 변경하기 전에 미리 조회.
로직 상 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로 변경하는 것이 좋습니다.
참고하기 좋은 자료