76. 가능한 한 실패 원자적으로 만들라
👉 들어가며
소프트웨어 개발에서 예외 상황은 피할 수 없는 현실입니다. 중요한 것은 예외가 발생했을 때 시스템이 어떻게 대응하느냐입니다. 아이템 76은 견고한 시스템을 구축하기 위한 핵심 원칙인 '실패 원자성(failure atomicity)'에 대해 다룹니다. 메서드가 실패하더라도 객체의 상태는 호출 전과 동일하게 유지되어야 한다는 이 원칙은 버그에 강한 시스템을 구축하는 데 필수적입니다.
✅ 핵심 요약
실패 원자성의 정의: 메서드 호출이 예외를 던지더라도 객체는 메서드 호출 전 상태를 유지해야 합니다.
중요성: 호출자가 예외 발생 후에도 객체를 일관된 상태로 신뢰하고 사용할 수 있습니다.
적용 범위: 특히 검사 예외(checked exception)가 발생하는 경우에 중요하며, 호출자가 상황을 복구할 수 있도록 합니다.
달성 방법: 불변 객체 설계, 매개변수 유효성 검사, 임시 복사본 사용, 복구 코드 작성 등의 방법이 있습니다.
한계: 모든 상황에서 실패 원자성을 보장할 수는 없으며, 비용과 복잡도를 고려해 적용해야 합니다.
📚 실패 원자성 이해하기
실패 원자성(Failure Atomicity)이란?
실패 원자성이란 메서드 실행 중에 예외가 발생하더라도 객체가 메서드 호출 전의 유효한 상태를 그대로 유지하는 특성을 말합니다. 이는 데이터베이스의 트랜잭션 특성 중 하나인 '원자성(Atomicity)'과 유사한 개념입니다.
실패 원자성이 중요한 이유
디버깅 용이성: 객체 상태가 일관되게 유지되어 문제 진단이 쉬워집니다.
복구 가능성: 호출자가 예외를 잡아 상황을 복구하기 쉬워집니다.
불변식 유지: 객체의 불변식(invariant)이 깨지지 않아 시스템 안정성이 보장됩니다.
버그 방지: '부분적으로 갱신된' 객체로 인한 비정상적인 동작을 방지합니다.
실패 원자성과 예외 종류의 관계
실패 원자성은 모든 예외 상황에서 중요하지만, 특히 다음과 같은 경우에 더 중요합니다
검사 예외(Checked Exception): 호출자가 복구를 시도할 가능성이 높으므로 객체 상태가 일관되게 유지되어야 합니다.
런타임 예외(Unchecked Exception): 프로그래밍 오류를 나타내지만, 상태가 일관되게 유지되면 디버깅이 용이합니다.
다음과 같은 경우에는 실패 원자성을 보장하기 어렵거나 불가능할 수 있습니다
Error
: 복구가 불가능한 심각한 문제를 나타내므로 실패 원자성이 덜 중요합니다.ConcurrentModificationException
: 여러 스레드가 동시에 객체를 수정할 때 발생하는 예외로, 상태가 이미 일관성을 잃었을 수 있습니다.
🎨 실패 원자성을 달성하는 방법
실패 원자성을 달성하기 위한 다양한 접근법이 있으며, 상황에 따라 적절한 방법을 선택해야 합니다.
1. 불변 객체로 설계하기
불변 객체(Immutable Object)는 생성된 후에는 상태가 변경되지 않기 때문에 기본적으로 실패 원자성을 갖습니다.
public final class Complex {
private final double re; // 실수부
private final double im; // 허수부
public Complex(double re, double im) {
this.re = re;
this.im = im;
}
// 연산 메서드는 항상 새로운 객체를 반환한다
public Complex plus(Complex c) {
return new Complex(re + c.re, im + c.im);
}
// 다른 연산 메서드들...
}
장점
가장 간단하고 확실한 방법
동시성 문제에도 안전
한계
모든 객체가 불변으로 설계될 수는 없음
일부 도메인에서는 성능상의 이유로 가변 객체가 필요함
2. 작업 수행 전 매개변수 유효성 검사
객체의 상태를 변경하기 전에 매개변수가 유효한지 먼저 확인합니다. 이를 통해 대부분의 예외를 객체 상태 변경 전에 발생시킬 수 있습니다.
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_CAPACITY];
}
public Object pop() {
if (size == 0)
throw new EmptyStackException(); // 상태 변경 전에 검사
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
// push와 다른 메서드들...
}
장점
구현이 간단하고 성능 오버헤드가 적음
대부분의 경우에 효과적
한계
모든 유효성 검사를 미리 할 수 없는 경우가 있음
여러 매개변수 간의 관계에 따른 검증이 복잡할 수 있음
3. 객체의 임시 복사본에서 작업 수행
실제 객체 상태를 변경하기 전에 임시 복사본을 만들어 작업을 수행하고, 성공적으로 완료된 경우에만 실제 객체 상태를 업데이트합니다.
public class ArrayList<E> {
private Object[] elementData;
private int size;
// 임시 배열을 사용하는 addAll 메서드
public boolean addAll(Collection<? extends E> c) {
// 입력 컬렉션을 배열로 변환
Object[] a = c.toArray();
int numNew = a.length;
if (numNew == 0)
return false;
// 배열 확장 필요 시 처리
ensureCapacityInternal(size + numNew);
// 임시 배열의 모든 요소를 elementData로 복사
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return true;
}
// 다른 메서드들...
}
장점
복잡한 상태 변경 작업에서도 원자성 보장 가능
특히 여러 단계의 상태 변경이 필요한 경우 효과적
한계
객체 복사 비용 발생
모든 객체가 쉽게 복사할 수 있는 것은 아님
4. 실패를 가로채는 복구 코드 작성
작업 중간에 예외가 발생할 경우, 객체를 원래 상태로 되돌리는 복구 코드를 작성합니다.
public class BankAccount {
private long balance;
public void transfer(BankAccount target, long amount) {
if (amount > balance)
throw new InsufficientFundsException();
if (amount <= 0)
throw new IllegalArgumentException("양수만 전송 가능합니다");
long originalBalance = balance;
try {
balance -= amount; // 내 계좌에서 차감
target.deposit(amount); // 대상 계좌에 입금
} catch (Exception e) {
// 예외 발생 시 원래 상태로 복구
balance = originalBalance;
throw e; // 예외는 다시 던짐
}
}
public void deposit(long amount) {
if (amount <= 0)
throw new IllegalArgumentException("양수만 입금 가능합니다");
balance += amount;
}
}
장점
다른 방법으로 실패 원자성을 보장하기 어려운 경우에 유용
복잡한 상태 변경 시나리오 처리 가능
한계
코드가 복잡해짐
모든 실패 시나리오를 고려하기 어려움
중첩된 호출에서 완벽한 복구가 어려울 수 있음
5. 명확한 문서화
실패 원자성을 보장하지 못하는 경우, API 문서에 명확히 이를 명시해야 합니다.
/**
* 지정된 경로에 있는 파일들을 삭제합니다.
*
* @param directory 삭제할 파일이 있는 디렉토리
* @throws IOException 파일 삭제 중 I/O 오류가 발생한 경우
* @apiNote 이 메서드는 실패 원자성을 보장하지 않습니다.
* 일부 파일만 삭제되고 예외가 발생할 수 있습니다.
*/
public void deleteFiles(Path directory) throws IOException {
// 구현 코드
}
중요
실패 원자성 보장이 어렵거나 비용이 너무 큰 경우, API 문서에 명확히 그 한계를 기술해야 합니다.
🥼 실패 원자성 구현 시 고려 사항
비용과 복잡도 분석
실패 원자성을 구현할 때 다음 요소를 고려해야 합니다:
성능 영향: 임시 복사본 생성이나 추가적인 검증은 성능에 영향을 줄 수 있습니다.
코드 복잡도: 복구 메커니즘은 코드를 더 복잡하게 만들 수 있습니다.
개발 비용: 모든 실패 시나리오를 처리하려면 개발 비용이 증가합니다.
적절한 절충안 찾기
중요도 평가: 메서드의 중요도와 실패 시 영향을 평가합니다.
발생 가능성: 실패 가능성이 높은 부분에 더 주의를 기울입니다.
복구 가능성: 호출자가 복구할 가능성이 있는 예외에 대해 더 주의를 기울입니다.
// 중요한 금융 거래 - 엄격한 실패 원자성 필요
public void processPayment(Payment payment) {
// 모든 검증을 먼저 수행
validatePayment(payment);
// 상태 변경을 한 번에 수행
persistPaymentAtomically(payment);
}
// 덜 중요한 로깅 - 실패 원자성 불필요
public void logActivity(String activity) {
// 실패 원자성 없이 진행
activityLog.add(activity);
}
동시성 고려 사항
멀티스레드 환경에서 실패 원자성을 보장하는 것은 더 복잡합니다:
Lock 사용: 객체 상태 변경 동안 락을 획득하여 동기화합니다.
원자적 업데이트:
AtomicReference
등을 사용하여 원자적인 상태 업데이트를 합니다.불변 객체 사용: 가능한 경우 불변 객체를 사용하여 동시성 문제를 회피합니다.
public class ThreadSafeCounter {
private final AtomicInteger value = new AtomicInteger(0);
// 원자적으로 증가
public void increment() {
value.incrementAndGet();
}
// 원자적인 조건부 업데이트
public boolean compareAndSet(int expected, int update) {
return value.compareAndSet(expected, update);
}
}
✨ 실제 프로젝트에 적용하기
코드 리뷰 체크리스트
실패 원자성을 보장하기 위한 코드 리뷰 체크리스트
상태 변경 전 검증: 모든 입력과 사전 조건을 검증하는가?
예외 처리: 예외 발생 시 객체 상태를 일관되게 유지하는가?
원자적 업데이트: 여러 단계의 상태 변경이 필요한 경우, 모두 성공하거나 모두 실패하는가?
문서화: 실패 원자성에 대한 보장이나 제한이 문서화되어 있는가?
테스트 전략
실패 원자성 테스트를 위한 접근법
예외 주입: 다양한 시점에서 예외를's 발생시켜 객체 상태를 검증합니다.
경계 조건 테스트: 상태 변경의 모든 경계 조건에서 테스트합니다.
실패 후 동작 검증: 예외 발생 후 객체가 올바르게 동작하는지 검증합니다.
@Test
public void testTransferFailureAtomicity() {
// 준비
BankAccount source = new BankAccount(1000);
BankAccount targetThatFailsOnDeposit = new FailingBankAccount();
// 실행
try {
source.transfer(targetThatFailsOnDeposit, 500);
fail("예외가 발생해야 합니다");
} catch (RuntimeException expected) {
// 검증: 실패 후 원래 상태가 유지되어야 함
assertEquals(1000, source.getBalance());
}
}
리팩토링 전략
기존 코드에 실패 원자성을 추가하는 리팩토링 전략
위험 영역 식별: 실패 원자성이 중요한 메서드를 식별합니다.
점진적 개선: 가장 중요한 메서드부터 점진적으로 개선합니다.
테스트 커버리지 확보: 변경 전 테스트를 충분히 작성합니다.
// 리팩토링 전
public void updateUserProfile(User user, Profile newProfile) {
user.setProfile(newProfile); // 만약 여기서 예외가 발생하면?
saveUser(user); // 이 코드는 실행되지 않음
}
// 리팩토링 후
public void updateUserProfile(User user, Profile newProfile) {
// 사전 검증
validateProfile(newProfile);
// 기존 값 저장
Profile oldProfile = user.getProfile();
try {
user.setProfile(newProfile);
saveUser(user);
} catch (Exception e) {
// 실패 시 복구
user.setProfile(oldProfile);
throw e;
}
}
🎯 결론
실패 원자성은 신뢰할 수 있는 객체와 견고한 시스템을 구축하는 데 있어 중요한 원칙입니다. 예외가 발생하더라도 객체의 상태가 일관성을 유지하도록 보장하면, 디버깅은 쉬워지고 시스템의 복구 가능성도 크게 향상됩니다. 이를 통해 코드의 예측 가능성과 유지보수성이 높아지고, 프로그램의 신뢰도가 향상됩니다.
불변 객체
를 설계하거나 상태를 변경하기 전에 유효성을 철저히 검사하고, 변경이 필요한 경우에는 단계적으로 수행하는 방식은 실패 원자성
을 확보하는 데 큰 도움이 됩니다. 때에 따라 복구 코드를 명시적으로 작성하거나, 실패 원자성을 보장하지 못하는 경우에는 그 사실을 API 문서에 명확히 기록해야 합니다.
완벽한 실패 원자성을 항상 보장하는 것은 현실적으로 어려울 수 있지만, 노력하는 만큼 시스템의 견고함은 분명히 달라집니다. 가능한 경우에는 실패 원자성을 구현하고, 불가한 경우에는 그 사실을 투명하게 드러내는 태도 자체가 프로페셔널한 API 설계의 출발점이라 할 수 있습니다.
Last updated