혹시 @Transactional을 그냥 붙이고 넘기는 적 있으신가요? 저도 그랬는데요, 이것이 사실 어떤 일을 하는지 제대로 알아보면 생각보다 깊은 내용이 있었습니다. 오늘은 트랜잭션이 뭔지부터 시작해서, 격리 수준에 대해 알아보겠습니다.
트랜잭션이란 뭐야?
한마디로 여러 작업을 하나로 묶는 것입니다. 그리고 이 묶음은 두 가지밖에 없어요.
전부 성공 → OK
하나라도 실패 → 전부 되돌림
왜 이런 것이 필요냐? 계좌 이체로 생각해보면 바로 와닿습니다.
A 계좌에서 10만원을 빼고, B 계좌에 10만원을 넣는 작업이 있는데요. 만약 "빼기"는 완료됐는데 "넣기" 중간에 서버가 다운되면?
-- 트랜잭션 없이 실행한 경우
UPDATE accounts SET balance = balance - 100000 WHERE id = 'A'; -- 완료
UPDATE accounts SET balance = balance + 100000 WHERE id = 'B'; -- 오류!
-- 결과: A에서 돈은 나갔지만 B에는 안 들어옴
10만원이 사라집니다. 이건 심각한 문제입니다.
트랜잭션을 사용하면 이런 일이 일어나지 않아요.
BEGIN; -- 작업 시작
UPDATE accounts SET balance = balance - 100000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100000 WHERE id = 'B'; -- 오류!
ROLLBACK; -- 모든 작업 되돌림
-- 결과: A 계좌도 원래 상태 유지
실패하면 처음부터 아무것도 하지 않은 것처럼 돌아가는 것. 이게 트랜잭션의 핵심입니다.
ACID — 트랜잭션이 약속하는 4가지
트랜잭션이 보장하는 성질을 ACID라고 합니다. 각 글자가 무엇을 의미하는지 실제 예시로 짚어보겠습니다.
A — Atomicity (원자성)
위에서 본 계좌 이체답니다. 전부 성공 또는 전부 실패.
C — Consistency (일관성)
트랜잭션이 끝나면 항상 올바른 상태여야 한다는 의미입니다. 예를 들어 "잔액은 0 이상이어야 한다"는 규칙이 있으면, 어떤 트랜잭션이든 이 규칙을 깨면 안 됩니다.
I — Isolation (고립성)
트랜잭션끼리 서로 간섭하지 않는다는 뜻입니다. 이 것이 바로 오늘 이야기할 격리 수준과 직결되는 부분입니다.
D — Durability (내구성)
한 번 저장되면(커밋되면) 서버가 꺼져도 그 데이터는 남아있다는 의미입니다.
Spring Boot에서는 이렇게 쓰는데요
@Transactional // ← 이 한 줄이 위의 ACID를 전부 보장
public void transfer(String fromId, String toId, int amount) {
accountRepo.deduct(fromId, amount); // 빼기
accountRepo.deposit(toId, amount); // 넣기 — 여기서 오류면 위것도 되돌림
}
이 어노테이션(특수한 주석)을 붙이지 않으면, "빼기"와 "넣기"가 각각 별도로 실행되어 위에서 본 문제가 그대로 발생합니다.

그런데, 트랜잭션끼리는?
트랜잭션이 하나만 있으면 별 문제없지만, 동시에 여러 명이 같은 데이터에 접근하면서 이상한 일이 일어날 수 있습니다.
예를 들어 내가 잔액을 확인하고 있는데, 동시에 누군가가 그 잔액을 바꾸는 경우인데요. 이런 상황을 어떻게 처리할지를 결정하는 규칙이 바로 격리 수준(Isolation Level)입니다.
격리 수준이 높으면 → 안전하지만 속도가 떨어짐
격리 수준이 낮으면 → 빠르지만 문제가 생길 수 있음
어떤 문제가 생기는지 실제 상황으로 하나씩 봅시다.
문제 1: Dirty Read — "아직 확정되지 않은 정보를 봄"
두 사람이 같은 계좌를 동시에 접근하는 상황입니다.
B가: 잔액을 5만원으로 변경 → 아직 확정(커밋)안 됨
A가: 잔액을 조회 → 5만원으로 보임
B가: 변경을 취소(롤백)
A는 5만원이라고 봤는데, B가 취소했기 때문에 실제로는 그런 금액이 전혀 없었습니다. 확정되지 않은 정보를 실제로 확정난 것처럼 봐서 잘못된 판단을 했는 경우입니다.
문제 2: Non-Repeatable Read — "같은 것을 두 번 봤는데 다르냐?"
A가: 잔액 조회 → 3만원
B가: 잔액을 5만원으로 변경하고 확정
A가: 잔액 다시 조회 → 5만원 ← 달라졌음!
A가 같은 정보를 두 번 봤는데 값이 달라진 거입니다. A가 처음 본 3만원 기준으로 무엇인가를 계산했다면, 두 번째 조회 결과와 맞지 않아져서 문제가 생길 수 있습니다.
문제 3: Phantom Read — "행이 갑자기 생겼냐?"
A가: 오늘 주문 건수 조회 → 10건
B가: 오늘 주문 1건 추가하고 확정
A가: 오늘 주문 건수 다시 조회 → 11건 ← 건수가 달라졌음!
값이 바뀐 게 아니라 항목 자체가 생겼거나 사라진 경우입니다. 위의 Non-Repeatable Read와 비슷한 냄새지만, "개수"나 "목록 범위"가 달라지는 것이라서 조금 다른 문제입니다.
격리 수준 4가지
이제 이 문제들을 어떻게 막는지가 격리 수준의 차이입니다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | 발생 | 발생 | 발생 |
| Read Committed | 방지 | 발생 | 발생 |
| Repeatable Read | 방지 | 방지 | 발생 |
| Serializable | 방지 | 방지 | 방지 |
아래로 내려갈수록 문제를 더 많이 막아주는 대신, 그만큼 다른 트랜잭션과 충돌을 피하기 위해 잠금을 더 많이 잡아야 하여 속도가 떨어집니다.
각 격리 수준을 언제 쓰는지
Read Uncommitted — "일단 빠르게"
확정되지 않은 데이터까지 읽을 수 있어 가장 빠르지만, 가장 위험합니다. 정확도가 별로 중요하지 않은 통계 조회 정도에서만 사용합니다.
실시간 대시보드에서 "지금 대략적으로 몇 명이 온라인인지" 같은 통계를 표시하는 경우. 1명이 더 있고 없고 정확한 숫자보다는 빠르게 보여주는 게 중요합니다.
Read Committed — "확정된 것만 읽어"
확정된 데이터만 읽어 Dirty Read는 막아줍니다. PostgreSQL과 Oracle의 기본값이고, 일반적인 작업에서 가장 많이 쓰는 수준입니다.
온라인 쇼핑몰에서 주문 목록을 조회하는 경우. 누군가가 주문 중인 중간 상태를 본다면 이상하지만, 이미 완료된 주문만 보면 충분합니다.
Repeatable Read — "내가 본 것은 그대로"
트랜잭션이 시작된 순간의 데이터를 기준으로 읽어줍니다. 다른 누군가가 중간에 값을 바꿔도 내가 본 값은 유지됩니다. MySQL의 기본값입니다.
항공기 좌석 예약. 남은 좌석 수를 확인하고 예약까지의 과정에서 다른 사람이 같은 좌석을 잡아가면 안 됩니다. 내가 처음 본 좌석 정보가 예약 완료까지 유지되어야 합니다.
Serializable — "나 혼자 쓰는 것처럼"
모든 문제를 막아주는 가장 강한 수준입니다. 트랜잭션들이 하나씩 순차적으로 실행된 것과 동일한 결과를 보장합니다. 금융 거래나 재고 관리처럼 정확성이 최우선인 경우에 적합합니다. 격리수준이 높은 만큼 데드락이 생길 수도 있으니 주위해야한다.
은행 계좌 간 이체. 출금과 입금이 정확하게 처리되어야 하며, 어떤 순서로든 동시에 여러 이체가 진행되더라도 최종 잔액이 정확해야 합니다.
실제로 쓰는 곳: Creator-Flex 휴가 관리
저는 지금 MCN HR 시스템인 Creator-Flex를 개발하고 있는데요, 휴가 관리에서 이 내용이 바로 와닿았습니다.
휴가 승인 로직에서, 남은 연휴 일수를 읽고 그 기준으로 승인 여부를 결정하는 부분이 있는데요. 만약 같은 직원의 휴가가 동시에 두 번의 승인 요청이 들어오면, 둘 다 잔여일수를 같은 값으로 읽고 차감해서 초과 승인될 수 있습니다. 때문에 Repeatable Read격리 수준을 사용한다.
// 휴가 승인 — 같은 직원의 잔여일수를 동시에 읽고 차감하는 경우를 방지
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void approveVacation(Long vacationId) {
VacationBalance balance = findBalance(vacation.getMemberId());
if (balance.getRemaining().compareTo(vacation.getDays()) < 0) {
throw new InsufficientVacationException();
}
balance.useVacation(vacation.getDays()); // 잔여일수 차감
vacation.approve();
}
반면 단순히 직원 목록과 잔여 연휴를 조회하는 페이지는 정확성보다 속도가 더 중요하니까 낮은 수준으로 쓰면 됩니다.
// 관리자 대시보드 — 전체 직원 연휴 현황 조회
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public List<VacationBalanceDto> getAllBalances() {
return balanceRepository.findAllByYear(currentYear);
}
같은 프로젝트 안에서도 기능마다 격리 수준이 달라지는 거, 이게 핵심입니다.
마지막으로
격리 수준은 무조건 높을수록 좋은 건 아닙니다. 어떤 문제를 막아야 하는가에 따라 적절히 선택하는 것이 포인트입니다. 상황에 맞게 균형을 잡는 거, 이것이 트랜잭션을 제대로 사용하는 핵심이에요.
'-- 오늘 있었던 개발 일기' 카테고리의 다른 글
| Refresh Token을 HttpOnly Cookie로 변경한 이유! (0) | 2026.02.12 |
|---|---|
| ReFresh Token 에서 Refresh Token Rotation까지 사용하자 (0) | 2026.02.11 |
| 더미 데이터에 시퀀스? (1) | 2026.01.29 |
| 테이블 설계... (0) | 2026.01.28 |
| spring MVC에 대해서 (0) | 2026.01.16 |