Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# 예외는 진짜 에외 상황에서만 사용하라

Java 프로그래밍에서 예외(Exception)는 강력한 도구이지만, 자주 오용되기도 한다.
많은 개발자들이 예외를 정상적인 제어 흐름의 일부로 사용하는 실수를 저지르곤 함. 하지만 이는 예외의 본래 목적과 설계 의도에 어긋나는 행위.
이 글에서는 예외를 잘못 사용하는 사례를 살펴보고, 왜 그런 방식이 바람직하지 않은지, 그리고 더 나은 대안은 무엇인지 알아보자.

> 📌 핵심 정리
> 1. 예외는 예외적인 상황에서만 사용하라
예외는 정상적인 제어 흐름이 아닌, 예상치 못한 예외적인 상황을 처리하기 위해 설계되었습니다. 이를 일상적인 로직에 사용하면 코드가 복잡해지고, 성능이 저하되며, 유지보수가 어려워집니다.

>2. 표준적인 방법을 사용하라
for-each 루프나 상태 검사 메서드 같은 표준적인 방식을 사용하면 코드가 더 명확하고 효율적입니다. 예외를 사용하는 대신 이러한 방법을 활용하면 가독성이 좋아지고 불필요한 오버헤드를 줄일 수 있습니다.

>3. API 설계 시 예외 사용을 강요하지 말라
잘 만든 API는 클라이언트가 예외를 정상적인 흐름에서 사용하지 않도록 설계해야 합니다. 상태 검사 메서드나 Optional 같은 대안을 제공해 예외 사용을 최소화하세요.

## 👎예외의 Bad Case
다음은 예외를 제어 흐름에 사용하는 잘못된 예시:

```java
try {
int i = 0;
while (true) {
range[i++].climb();
}
} catch (ArrayIndexOutOfBoundsException e) {
// 배열 끝에 도달했으므로 루프 종료
}
```

이 코드의 의도는 배열의 모든 요소를 순회하면서 climb() 메서드를 호출하는 것.
무한 루프를 돌다가 배열의 끝에 도달하면 ArrayIndexOutOfBoundsException이 발생하고, 이를 잡아 루프를 종료.
이 방식은 언뜻 보면 배열의 경계를 검사하는 중복을 피함으로써 성능을 최적화하려는 시도로 보일 수도 있음.
-> 일반적인 반복문에서도 배열의 경계를 검사하므로, JVM이 배열 접근 시 수행하는 경계 검사와 중복된다고 생각할 수 있기 때문.
하지만 이는 잘못된 추론!. 실제로 이 방식은 여러 가지 문제를 야기한다.

## 예외를 제어 흐름에 사용해서는 안 되는 이유

1. 예외는 예외 상황을 위해 설계되었다
예외는 프로그램 실행 중 예기치 않은 오류나 드문 상황을 처리하기 위해 설계되었다.
2. 정상적인 제어 흐름에 사용하도록 설계되지 않았으므로, 이를 오용하면 코드의 가독성과 유지보수성이 떨어짐.
위의 예시처럼 예외를 사용하면 코드의 의도를 파악하기 어려워지며, 다른 개발자들이 코드를 이해하는 데 혼란을 줄 수 있습니다.
3. JVM 최적화 제한
코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한될 수 있습니다. 예외 처리는 본질적으로 성능 비용이 들기 때문에, 정상적인 흐름에서 예외를 사용하면 불필요한 오버헤드가 발생합니다.
4. 표준 반복문은 이미 최적화되어 있다
현대 JVM은 배열 순회와 같은 표준적인 반복문을 매우 효율적으로 처리.
예를 들어, 다음과 같은 for-each 루프는 내부적으로 경계 검사를 최적화하여 중복 검사를 피함:
```java
for (Mountain m : range) {
m.climb();
}
```
5. 성능 저하
실제로 예외를 사용한 방식이 표준적인 방식보다 느리다. 다음은 두 가지 접근 방식의 성능을 비교한 간단한 테스트이다:

```java
@Test
public void exceptionTest() {
int i = 0;
int[] arr = new int[100_000_000];
try {
while (true) {
arr[i] = i++;
}
} catch (ArrayIndexOutOfBoundsException e) {
// 예외 발생 시 종료
}
}

@Test
public void loopTest() {
int size = 100_000_000;
int[] arr = new int[size];
for (int i = 0; i < size; i++) {
arr[i] = i;
}
}
```

이 테스트에서 loopTest가 exceptionTest보다 더 빠르게 실행.
->이는 예외 처리가 추가적인 비용을 발생시킨다.

## 잠재적인 문제점

예외를 제어 흐름에 사용하면 버그를 숨길 수 있다.
예를 들어, 루프 내에서 호출된 메서드가 내부적으로 다른 배열을 사용하다가 ArrayIndexOutOfBoundsException을 발생시킬 수 있다.
이 경우, 예외를 잡아 루프를 종료해버리면 실제 버그를 놓치게 된다.
표준적인 반복문에서는 이러한 예외가 잡히지 않고 스택 트레이스를 남기며 스레드를 종료시켜 버그를 명확히 드러내지만, 예외를 사용한 방식에서는 이를 정상적인 종료로 오해할 수 있다.

## 더 나은 대안: 상태 검사 메서드와 Optional

예외 대신 정상적인 제어 흐름을 위해 설계된 방법을 사용해야 한다. 대표적인 예로 상태 검사 메서드를 사용하는 것.
예를 들어, Iterator 인터페이스의 hasNext() 메서드는 next()를 호출하기 전에 더 이상 요소가 있는지 확인하는 상태 검사 메서드!

```java
for (Iterator<Foo> i = collection.iterator(); i.hasNext(); ) {
Foo foo = i.next();
// 작업 수행
}
```

이 방식은 명확하고, 잘못 사용했을 때 버그를 쉽게 발견할 수 있다.
만약 hasNext()를 호출하지 않고 next()를 호출하면 NoSuchElementException이 발생하여 문제를 즉시 드러나다.

상태 검사 메서드 외에도, 메서드가 특정 상태에서 호출될 수 없을 때 Optional이나 특정 값을 반환하는 방법이 있다.
예를 들어, Optional.empty()나 null을 반환하여 호출자에게 상태를 알릴 수 있다.

### 언제 어떤 방법을 선택할까?
외부 요인으로 상태가 변할 수 있는 경우: Optional이나 특정 값을 사용.

👉🏻상태 검사 메서드와 상태 의존적 메서드 호출 사이에 객체의 상태가 변할 수 있기 때문

성능이 중요한 경우: 상태 검사 메서드가 상태 의존적 메서드의 작업을 중복 수행한다면, Optional이나 특정 값을 선택

그 외의 경우: 상태 검사 메서드를 사용하는 것이 가독성과 버그 발견 용이성 면에서 더 낫다..

## 결론 및 정리
예외는 오직 예외적인 상황에서만 사용해야 합니다. 정상적인 제어 흐름에 예외를 사용하면 코드가 복잡해지고, 성능이 저하되며, 버그를 숨길 수 있다.
대신, 표준적인 반복문이나 상태 검사 메서드를 사용하여 명확하고 유지보수하기 쉬운 코드를 작성하자.
잘 설계된 API는 클라이언트가 예외를 정상적인 흐름에서 사용할 일이 없도록 해야 한다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# 가능한 실패를 원자적으로 만들어라

## 실패 원자성(Failure Atomicity)이란?
실패 원자성은 메서드가 실패하더라도 해당 객체가 메서드 호출 전 상태를 유지해야 하는 특성을 말합니다.
즉, 어떤 연산이 예외를 던지더라도 객체의 상태는 그 연산이 시작되기 전 상태와 동일해야 합니다.
이는 객체의 일관성(consistency)을 유지하고, 프로그램의 예측 가능성을 높이는 중요한 특성입니다.

## 실패 원자적이지 않은 경우의 예
다음은 실패 원자적이지 않은 코드의 예입니다:

```java
public class Main {
private static class Counter {
private int count = 0;

public void addCount(int number) {
count += number;

if (count < 0) { // 숫자가 음수가 들어왔거나, 정수형 overflow가 발생했을 경우 예외를 발생시킨다.
throw new IllegalArgumentException();
}
}

public int count() {
return count;
}
}

private static final Counter COUNTER = new Counter();

public static void main(String[] args) {
final Thread thread1 = new Thread(() -> {
try {
failAdd();
} catch (InterruptedException | RuntimeException e) {
System.out.println("예외가 발생했습니다.");
}
});

final Thread thread2 = new Thread(Main::readNumber);
thread1.start();
thread2.start();
}

private static void failAdd() throws InterruptedException {
COUNTER.addCount(1);
COUNTER.addCount(1);
COUNTER.addCount(Integer.MAX_VALUE);
}

private static void readNumber() {
for (int i = 0; i < 1000; i++) {
System.out.println(COUNTER.count());
}
}
}
```

위 코드의 실행 결과는 다음과 같습니다:
```
...
-2147483647
-2147483647
예외가 발생했습니다.
-2147483647
-2147483647
...
```

- Counter 클래스:

addCount(int number) 메서드는 입력받은 정수를 내부의 count 필드에 더합니다.
더한 후, count가 음수가 되면(음수 입력이나 정수 오버플로우 시) 예외를 발생시킵니다.

- Main 클래스와 스레드 동작:
n 클래스는 static 필드로 Counter 객체를 생성하여 공유 변수로 사용합니다.
- thread1:
Counter에 1을 두 번 더한 후, 마지막에 Integer.MAX_VALUE를 더합니다.
- thread2:
Counter의 count() 메서드를 호출하여 현재 count 값을 읽어옵니다.
결과와 문제점:
결과에서는 count 값이 음수(-2147483647)로 변경된 후 예외가 발생하며, 이후에도 변경된 상태가 유지됩니다.
메서드 호출 중 예외가 발생했음에도 불구하고 객체의 상태가 메서드 호출 전 상태로 복원되지 않기 때문에 실패 원자적이지 않다고 표현할 수 있습니다.

## 실패 원자적으로 만드는 방법

### 1. 불변 객체로 설계하기
불변 객체는 태생적으로 실패 원자적입니다. 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않기 때문에, 메서드 호출이 실패하더라도 객체의 상태가 변경될 위험이 없습니다.

### 2. 매개변수의 유효성을 미리 검사하기
가변 객체의 메서드를 실패 원자적으로 만드는 가장 보편적인 방법은 객체의 상태를 변경하기 전에 매개변수의 유효성을 검사하는 것입니다.

앞서 예시에서 addCount 메서드를 실패 원자적으로 수정하면:
```java
public void addCount(int number) {
if (count + number < 0) { // 상태 변경 전에 유효성 검사
throw new IllegalArgumentException();
}

count += number;
}
```

이렇게 하면 실행 결과는 다음과 같이 변합니다:
```
...
2
2
예외가 발생했습니다.
2
2
...
```

유효성 검사를 먼저 함으로써 객체의 상태가 변경되기 전에 예외가 발생하므로, 객체는 메서드 호출 전 상태(count=2)를 유지합니다.

### 3. 실패 가능성이 있는 코드를 객체 상태 변경 코드보다 앞에 배치하기
계산을 수행해보기 전에 인수의 유효성을 검사해볼 수 없을 때는, 실패 가능성이 있는 모든 코드를 객체의 상태를 바꾸는 코드보다 앞에 배치합니다.

예를 들어, TreeMap의 put(K,V) 메서드는 비교할 수 없는 타입의 원소를 추가하려 할 경우, 트리를 변경하기 전에 해당 원소가 들어갈 위치를 찾는 과정에서 ClassCastException을 던집니다.

### 4. 객체의 임시 복사본에서 작업 후 교체하기
객체의 임시 복사본에서 작업을 수행한 다음, 작업이 성공적으로 완료된 경우에만 원래 객체와 교체하는 방법입니다.

예를 들어, 정렬 메서드들은 보통 입력 리스트의 원소들을 배열에 복사한 후 정렬 작업을 수행합니다. 이렇게 하면 정렬에 실패하더라도 원본 리스트는 영향을 받지 않습니다.

### 5. 복구 코드 작성하기
작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전 상태로 되돌리는 방법입니다. 이 방법은 주로 디스크 기반의 내구성을 보장해야 하는 자료구조에 사용되지만, 일반적으로는 자주 쓰이지 않습니다.

## 실패 원자성의 한계
실패 원자성은 권장되는 덕목이지만, 항상 달성할 수 있는 것은 아닙니다.

### 달성하기 어려운 경우:
1. 다중 스레드 환경: 두 스레드가 동기화 없이 같은 객체를 동시에 수정할 경우, 객체의 일관성이 깨질 수 있습니다. ConcurrentModificationException을 잡아냈다고 해서 그 객체가 여전히 사용 가능한 상태라고 가정할 수 없습니다.

2. Error 발생 시: Error는 복구할 수 없기 때문에 AssertionError 등에 대해서는 실패 원자적으로 만들려는 시도조차 할 필요가 없습니다.

### 비용 대비 효율성:
실패 원자성을 달성하기 위한 비용이나 복잡도가 너무 크다면, 항상 실패 원자적으로 만들어야 하는 것은 아닙니다. 그러나 문제를 제대로 이해하면 실패 원자성을 비교적 쉽게 얻을 수 있는 경우가 많습니다.

## 정리
- 메서드 명세에 기술한 예외라면 예외가 발생하더라도 객체의 상태는 메서드 호출 전과 동일해야 합니다.
- 이 규칙을 지키지 못할 경우에는 실패 시의 객체 상태를 API 설명에 명시해야 합니다.
- 실패 원자성을 달성하는 가장 간단한 방법은 불변 객체를 사용하는 것입니다.
- 가변 객체라면 상태를 변경하기 전에 매개변수 유효성을 검사하고, 임시 복사본을 활용하는 등의 방법을 사용합시다.
- 트랜잭션의 원자성처럼, 메서드의 실패 원자성도 시스템의 안정성을 높이는 중요한 특성입니다.

실패 원자성은 견고한 시스템을 구축하는 데 필수적인 요소입니다.
적절하게 구현된 실패 원자적 메서드는 예외 상황에서도 시스템이 일관된 상태를 유지할 수 있게 해주며, 디버깅과 유지보수를 쉽게 만듭니다.