Home Item76 (가능한 한 실패 원자적으로 만들라)
Post
Cancel

Item76 (가능한 한 실패 원자적으로 만들라)

가능한 한 실패 원자적으로 만들라

  • 실패 원자적이란?
    • 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지하는 특성.

예외가 발생해도 그 객체는 여전히 정상적으로 사용할 수 있는 상태면 좋을 것이다.

만약 예외가 검사 예외인 경우에는 호출자가 오류 상태를 복구할 수 있으니 더더욱 좋을 것이다.

만약 실패 원자적으로 만들지 않으면 어떻게 될까?

  • 예외가 발생했을 때 객체의 일관성이 깨질 수 있다.
    • ex) 은행 계좌 객체에서 출금 메서드를 호출했다고 가정하자.
    • 출금 과정에서 예외가 발생했는데 막상 잔고는 잘못된 값으로 변경되어 있을 수 있는 것이다.
  • 오류 복구가 어려워진다.
    • 예외가 발생했음에도 객체의 상태가 부분적으로 변경되었을 때 원래 상태로 되돌리기 어렵고, 오류를 발생시키기 쉽다.

이러한 이유들로 가능하면 메서드를 실패 원자적으로 만들어야 한다. 어떻게 실패 원자적으로 만들 수 있는지 보자.

불변 객체로 설계한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Example {
	private final int number;
	private final int height;
	private final List<Integer> ex;

	private Example(int number, int height, List<Integer> ex) {
		this.number = number;
		this.height = height;
		this.ex = List.copyOf(ex);
	}

	public static Example of(int number, int height, List<Integer> ex) {
		return new Example(number, height, ex);
	}

	public List<Integer> getEx() {
		return Collection.unmodifiableList(this.ex);
	}
}

불변 객체는 태생적으로 실패 원자적이다.

만약 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있지만, 기존 객체가 불안정한 상태에 빠지는 일은 결코 없다.

불변 객체는 생성 시점에 고정되어 절대 변하지 않는다.

가변 객체의 메서드는 매개변수의 유효성을 검사하면 된다.

가변 객체의 메서드를 실패 원자적으로 만드는 가장 흔한 방법은 작업 수행에 앞서 매개변수의 유효성을 검사하는 것이다.

객체의 내부 상태를 변경하기 전에 잠재적 예외의 가능성 대부분을 걸러낼 수 있기 때문이다.

1
2
3
4
5
6
7
8
9
public Object pop() {
	if (size == 0) {
		throw new EmptyStackException();
	}

	Object result = elements[--size];
	elements[size] = null; // 복습: 다 쓴 참조 해제
	return result;
}
  • size의 값을 확인해 0이면 예외를 던지도록 한다.
  • 물론 검증 코드가 없어도 스택이 비었다면 여전히 예외를 던지게 된다.
    • 하지만, size 값이 음수가 되어 다음번 호출도 실패하게 만든다.
    • 그리고 이 때 던지는 ArrayIndexOutOfBoundsException은 추상화 수준이 상황에 어울리지 않다고 볼 수 있다.

따라서 가변 객체의 메서드는 매개변수의 유효성을 검사하면 된다.

실패할 가능성이 있는 모든 코드를, 객체의 상태를 바꾸는 코드보다 앞에 배치한다.

가변 객체의 메서드를 실패 원자적으로 만드는 방법 또 한가지는 실패할 가능성이 있는 모든 코드를, 객체의 상태를 바꾸는 코드보다 앞에 배치하는 것이다.

계산을 수행해보기 전에는 인수의 유효성을 검사해볼 수 없을 때 앞선 방식에 덧붙여 쓸 수 있는 방법이다.

TreeMap을 통해 그 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
private V put(K key, V value, boolean replaceOld) {  
    Entry<K,V> t = root;  
    if (t == null) {  
        addEntryToEmptyMap(key, value);  
	    return null;  
    }  
    int cmp;  
    Entry<K,V> parent;  
    // split comparator and comparable paths  
    Comparator<? super K> cpr = comparator;
    
    //...
}
  • TreeMap의 put() 메서드이다.
  • TreeMap은 원소를 주어진 기준으로 정렬한다.
  • TreeMap에 원소를 추가하려면 그 원소는 TreeMap의 기준에 따라 비교할 수 있는 타입이어야 한다.
    • 알맞지 않은 타입의 원소를 추가하려 하면 트리를 변경하기 앞서, 해당 원소가 들어갈 위치를 찾는 과정에서 ClassCastException이 발생할 것이다.

핵심은 예외가 발생할 가능성이 있는 코드를 먼저 실행해, 그 과정에서 문제가 발생하면 객체의 상태가 변경되지 않도록 하는 것이다.

다시 TreeMap의 put()을 생각해보자.

  • put()은 새로운 요소를 추가(객체의 상태를 바꿈)하기 전 요소가 트리에 삽입될 위치를 찾는다.
    • 삽입될 위치를 찾을 때 ClassCastException이 발생할 수 있다.
    • 즉, 삽입될 위치를 찾는 코드는 실패할 가능성이 있는 코드이다.
  • 따라서, 상태를 바꾸는 코드보다 예외가 발생할 수 있는 코드를 먼저 실행해 예외가 발생하더라도 트리의 상태가 손상되지 않도록 하는 실패 원자적으로 잘 만든 예시를 TreeMap을 통해 볼 수 있는 것이다.
    • 계산해보기 전에는 인수의 유효성을 파악할 수 없는 경우의 예시가 되기도 한다.

객체의 임시 복사본에서 작업을 수행한 후, 원래 객체와 교체한다.

실패 원자성을 얻을 수 있는 또 다른 방법으로는 객체의 임시 복사본에서 작업을 수행하고, 작업이 성공적으로 완료됐을 때 원래 객체와 교체하는 방법이다.

데이터를 임시 자료구조에 저장해 작업하는 게 더 빠를 때 적용하기 좋은 방법이다.

예시를 보자.

1
2
3
4
5
6
7
8
9
default void sort(Comparator<? super E> c) {  
	Object[] a = this.toArray();  
	Arrays.sort(a, (Comparator) c);  
	ListIterator<E> i = this.listIterator();  
	for (Object e : a) {  
		i.next();  
		i.set((E) e);  
	}  
}

List.sort 메서드이다.

일부 정렬 메서드에서는 정렬을 수행하기 전에 입력 리스트의 원소들을 배열로 옮겨 담는다. 배열을 사용하면 정렬 알고리즘의 반복문에서 원소들에 훨씬 빠르기 접근할 수 있기 때문이다.

물론 이는 성능을 높이고자 취한 방법이지만, 정렬에 실패하더라도 입력 리스트는 변하지 않는 효과를 얻어 실패 원자적으로 만들 수 있는 것이다.

작업 도중 발생하는 실패를 가로채는 복구 코드를 작성해 작업 전 상태로 되돌린다.

주로 디스크 기반의 내구성을 보장해야 하는 자료구조에 사용된다. 자주 사용되는 방법은 아니다.

그러나 실패 원자성은 항상 달성할 수 없다.

실패 원자성은 일반적으로 권장되는 덕목이지만 항상 달성할 수는 없다.

예를 들면 두 쓰레드가 동기화 없이 같은 객체를 동시에 수정한다면 일관성이 깨질 수 있다.

ConcurrentModificationException을 잡았더라도 객체의 변경 가능성이 있기 때문에 해당 객체를 여전히 쓸 수 있다는 보장이 없어 실패 원자성을 달성할 수 없다.

그리고 Error의 경우에는 복구할 수 없으므로 AssertionError에 대해서는 실패 원자적으로 만들 필요도 없다.

만약 실패 원자적으로 만들 수 있다고 해도, 항상 그렇게 해야만 하는 것은 아니다. 실패 원자성을 달성하기 위한 비용과 복잡도를 고려해야 된다.

일단 기본적인 규칙은 메서드 명세에 기술한 예외라면 설혹 예외가 발생하더라도 객체의 상태는 메서드 호출 전과 똑같이 유지해야 한다는 것이다.

이 규칙을 지키지 못한다면 실패 시 객체 상태를 API 설명에 명시해야 한다.

정리

되도록 실패 원자적으로 만들어야 한다. 그래야 예외가 발생하더라도 사용하던 객체를 예외 발생 전의 상태 그대로 다시 사용할 수 있다.

실패 원자적으로 만드는 방법은 객체를 불변으로 만들거나, 가변 객체라면 매개변수의 유효성을 검증하고 예외가 발생할 수 있는 코드를 상태 변경 코드보다 먼저 실행시켜 실패 원자성을 얻을 수 있다.

다만, 실패 원자적으로 만드는 것은 항상 가능한 것이 아니기 때문에 기본적인 규칙, 권장 사항으로 알아두면 좋을 것 같다. 가능한 상황이라면 지키도록 하자.

This post is licensed under CC BY 4.0 by the author.

Item75 (예외의 상세 메시지에 실패 관련 정보를 담으라)

Item77 (예외를 무시하지 말라)