왜 원자성이 중요한가
서비스를 만들다 보면 하나의 기능이 여러 단계로 이루어지는 경우가 많다. 예를 들어 송금 기능은 출금, 입금, 거래 로그 기록, 푸시 알림까지 이어진다. 이 중 하나라도 실패하면 전체가 실패한 것으로 처리해야 사용자의 돈이 증발하거나 두 번 나가는 비극을 막을 수 있다. 이렇게 작업 묶음을 모두 성공하거나 전부 실패로 되돌리는 성질이 바로 원자성이다. 간단히 말해 all-or-nothing.
원자성의 정확한 정의
원자성은 트랜잭션을 구성하는 연산들이 일부만 반영되지 않도록 보장하는 성질이다. 트랜잭션이 커밋되면 내부의 모든 변경이 함께 반영되고, 중간에 예외가 나서 롤백되면 아무 것도 반영되지 않는다. 데이터베이스 세계에서 ACID의 A가 바로 이 원자성이다.
주의해야 할 점은 원자성이 곧 격리성(isolation)이나 일관성(consistency)을 대신해 주는 것은 아니라는 것.
원자성은 결과가 일부만 남지 않게 하는 성질이고, 격리성은 동시 실행 시 서로가 간섭하지 않도록 하는 성질, 일관성은 비즈니스 규칙(무결성 제약 등)이 항상 지켜지도록 하는 성질이다.
헷갈리기 쉬운 개념들과 비교
| 개념 | 핵심 질문 | 예 |
|---|---|---|
| 원자성 | 일부만 반영될 수 있는가 | 출금만 되고 입금은 안 되는 상태를 막는다 |
| 격리성 | 동시에 실행해도 안전한가 | 두 송금이 순서 꼬임 없이 처리되는가 |
| 내구성 | 커밋 후에도 남는가 | 전원 장애 이후에도 데이터가 남는가 |
| 멱등성 | 같은 요청을 여러 번 보내도 결과가 같은가 | 같은 결제 콜백이 두 번 와도 잔액이 한 번만 차감되는가 |
멱등성은 클라이언트·분산 환경에서 특히 중요하지만, 원자성과는 역할이 다르다. 원자성은 트랜잭션 내부의 “묶음 처리”에 집중한다.

데이터베이스 트랜잭션에서의 원자성
예시 1: 단순 송금 트랜잭션 (MySQL/InnoDB)
-- 자동 커밋 끄기
SET autocommit = 0;
START TRANSACTION;
UPDATE account SET balance = balance - 10000 WHERE id = 1;
-- 잔액 부족 등 검사
UPDATE account SET balance = balance + 10000 WHERE id = 2;
-- 모든 단계가 성공하면
COMMIT;
-- 중간에 실패했다면
-- ROLLBACK;
위 트랜잭션은 둘 중 하나의 UPDATE라도 실패하면 전체가 ROLLBACK되어 “출금만 되고 입금은 안 되는” 반쪽짜리 결과가 남지 않는다. 이것이 원자성의 핵심 가치다.
예시 2: Spring에서의 원자성(@Transactional)
일반적으로 많이 쓰이는 기술스택인 Java/Spring 예시를 보자.
@Service
public class TransferService {
private final AccountRepository accountRepository;
private final LedgerRepository ledgerRepository;
public TransferService(AccountRepository accountRepository, LedgerRepository ledgerRepository) {
this.accountRepository = accountRepository;
this.ledgerRepository = ledgerRepository;
}
@Transactional
public void transfer(Long fromId, Long toId, long amount) {
Account from = accountRepository.findByIdForUpdate(fromId)
.orElseThrow(() -> new IllegalArgumentException("from not found"));
Account to = accountRepository.findByIdForUpdate(toId)
.orElseThrow(() -> new IllegalArgumentException("to not found"));
if (from.getBalance() < amount) {
throw new IllegalStateException("insufficient balance");
}
from.withdraw(amount);
to.deposit(amount);
ledgerRepository.save(Ledger.transfer(fromId, toId, amount));
// 예외가 던져지면 메서드 전체가 롤백되어 DB에 아무 것도 반영되지 않는다.
}
}
주의사항
- 체크 예외/언체크 예외 처리 정책: 기본 설정에서 런타임 예외가 던져지면 롤백된다. 체크 예외까지 롤백하려면 롤백 규칙을 명시한다.
- 트랜잭션 경계: 컨트롤러가 아닌 서비스 계층에서 경계를 잡는 것이 일반적이다.
- 동일 트랜잭션 내 외부 I/O 금지: 트랜잭션 안에서 이메일 전송, 외부 결제 API 호출을 실행하면, 실패 시 롤백되더라도 외부 세계에는 이미 부작용이 남을 수 있다. 이런 경우는 아웃박스 패턴을 통해 DB 커밋 이후 비동기로 처리하는 것이 안전하다.
부분 롤백이 필요할 때: Savepoint
업무적으로 일부 단계만 되돌리고 나머지는 유지해야 할 때가 있다. 이건 트랜잭션을 쪼개는 것이 아니라 저장점을 찍고 그 지점까지만 되돌리는 방식으로 처리한다.
START TRANSACTION;
-- step1
SAVEPOINT s1;
-- step2 중 일부 실패
ROLLBACK TO SAVEPOINT s1;
-- step1 상태는 유지
COMMIT;
CPU/메모리 관점의 원자성: Atomic 연산
원자성은 DB만의 이야기가 아니다. 멀티스레드 환경에서 “한 연산이 쪼개져 보이지 않는가”도 원자성이다. 단, 여기서의 원자성은 “하나의 메모리 위치에 대한” 원자 연산에 가깝고, DB 트랜잭션처럼 여러 자원을 묶어 보장하는 것은 아니다.
public class Counter {
private final AtomicInteger value = new AtomicInteger(0);
public int increment() {
return value.incrementAndGet(); // 원자적 증가
}
public boolean compareAndSet(int expect, int update) {
return value.compareAndSet(expect, update); // CAS
}
}
AtomicInteger의 incrementAndGet은 분해 불가한 한 덩어리로 실행된다. 그러나 이것이 여러 필드나 여러 객체에 걸친 “트랜잭션”을 보장하는 것은 아니다. 따라서 도메인 규칙상 “여러 상태를 함께” 바꿔야 한다면 여전히 DB 트랜잭션(혹은 트랜잭션 유사 메커니즘)을 사용해야 한다.
분산 시스템에서의 원자성: 현실과 대안
마이크로서비스나 외부 결제·배송 같은 시스템 간 연동에서 “단일 전역 트랜잭션”을 쓰기 어렵다. 2PC 같은 분산 트랜잭션은 복잡성과 장애 전파 비용이 크다. 그래서 다음과 같은 전략을 더 자주 쓴다.
- 사가(Saga) 패턴
업무 단계를 지역 트랜잭션들의 연쇄로 구성하고, 실패 시 보상 트랜잭션을 실행해 전체 상태를 되돌린다. 완벽한 원자성 대신 “업무적으로 수습 가능한” 보상 절차를 설계한다. - 아웃박스 패턴 + 이벤트 전파
DB 내 테이블에 “발행 예정 이벤트”를 함께 커밋하고, 별도 워커가 이를 메시지 브로커로 내보낸다. DB 커밋과 이벤트 발행을 분리하면 중간 실패에도 반쪽 상태를 피하기 쉽다. - 멱등성 키
네트워크 재시도 중 중복 처리를 막기 위해 요청마다 멱등성 키를 부여한다. 원자성과 다른 개념이지만, 분산 환경에서 원자성의 체감 품질을 크게 높여 준다.
실무에서 자주 터지는 함정과 체크리스트
- 자동 커밋이 켜진 상태에서 여러 UPDATE를 순차 실행
첫 번째 UPDATE는 이미 커밋되고, 두 번째가 실패하면 반쪽 결과가 남는다. 트랜잭션 경계를 명확히 잡을 것. - 트랜잭션 안에서 외부 API 호출
내부는 롤백되어도 외부는 되돌릴 수 없다. 아웃박스 패턴이나 사가 보상을 설계하자. - 여러 저장소 혼용
하나는 RDB, 다른 하나는 캐시 또는 검색 엔진 같은 경우, 원자적 업데이트가 불가능하다. 이벤트 기반 최종 일관성을 설계한다. - 동시성 이슈로 인한 잃어버린 업데이트
격리성과 잠금 전략을 함께 고려해야 진짜 의미의 “원자적 결과”를 얻는다. 필요하면 비관적 잠금(SELECT … FOR UPDATE)이나 낙관적 잠금(버전 컬럼)을 사용한다.
간단한 낙관적 잠금 예시 (JPA)
@Entity
public class Account {
@Id
private Long id;
@Version
private Long version;
private long balance;
public void withdraw(long amount) {
if (balance < amount) {
throw new IllegalStateException("insufficient balance");
}
balance -= amount;
}
public void deposit(long amount) {
balance += amount;
}
}
버전 컬럼을 두면 동시 업데이트 충돌 시 예외가 발생하고 전체 트랜잭션이 롤백되어 원자성이 보완된다.
요약
- 원자성은 작업 묶음이 일부만 반영되지 않게 만드는 all-or-nothing 성질이다.
- DB에서는 트랜잭션으로, 코드에서는 원자 연산과 잠금 전략으로, 분산 환경에서는 사가·아웃박스·멱등성으로 현실적인 대안을 조합한다.
- 외부 I/O를 트랜잭션 경계 안에 넣지 말고, 이벤트와 보상 절차로 수습 경로를 명시하자.
- 결국 좋은 원자성은 “경계의 명확화”와 “실패를 가정한 설계”에서 나온다.
참고 자료
- MySQL 8.0 Reference Manual: Transactions and Atomic Operations (dev.mysql.com/doc/refman/8.0/en/transactions.html)
- Spring Framework Reference: Transaction Management (@Transactional) (docs.spring.io/spring-framework/reference/data-access/transaction.html)
- Java Platform SE 11: AtomicInteger (docs.oracle.com/javase/11/docs/api/java.base/java/util/concurrent/atomic/AtomicInteger.html)
- Designing Data-Intensive Applications, Martin Kleppmann (dataintensive.net)
- PostgreSQL Documentation: Concurrency Control (www.postgresql.org/docs/current/mvcc.html)
'IT' 카테고리의 다른 글
| 맥북 M1 ~ M5 칩셋 성능 변화 한 번에 보기 (2025년 11월 기준) (3) | 2025.11.11 |
|---|---|
| MySQL VS 오라클(Oracle) 비교: 돈, 기능, 규모로 따져보는 현실적인 DB 선택 가이드 (1) | 2025.11.11 |
| ChatGPT Pulse(지피티 펄스 최신 신기능 AI 비서) 잘 사용하는 방법 (10) | 2025.10.01 |
| 신입 개발자를 위한 실무 현업들과 소통하는 방법 (7) | 2025.08.14 |
| NoSQL vs RDBMS, 무엇을 언제 선택할까? (10) | 2025.08.13 |