Skip to content

Commit 7bb8586

Browse files
committed
Update: Error.md / 호출자를 고려해 예외 클래스를 따로 정의하자 까지 추가.
1 parent d0e1bb7 commit 7bb8586

File tree

1 file changed

+283
-0
lines changed

1 file changed

+283
-0
lines changed

src/7. error.md

+283
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# Error
2+
3+
오류 처리는 프로그램에 반드시 필요한 요소 중 하나다. 뭔가 실패할 가능성은 늘 존재한다. 또한 그 잘못된 것을 바로 잡는 책임도 프로그래머에게 있다.
4+
5+
클린 코드와 오류 처리 또한 연관이 있다. 여기저기 흩어진 오류 처리 코드들 때문에 실제 코드가 하는 일을 파악하기 어렵다면, 클린 코드라고 할 수 없다.
6+
7+
이번 챕터에서는 오류 처리를 우아하고 고상하게 만드는 기법과 고려 사항들을 다룬다.
8+
<br><br>
9+
10+
## 목차
11+
1. [오류 코드보단 예외를 사용하라](#예외)
12+
2. [Try-Catch-Finally 문부터 작성하라](#폼부터)
13+
3. [미확인 예외를 사용하라](#미확인)
14+
4. [예외에 의미를 제공하라](#의미)
15+
5. [호출자를 고려해 예외 클래스를 정의하라](#호출자)
16+
6. [정상 흐름을 정의하라](#정상)
17+
7. [null을 반환하지 마라](#null반환)
18+
8. [null을 전달하지 마라](#null전달)
19+
9. [결론](#결론)
20+
<br><br>
21+
22+
## <div id="예외">오류 코드보단 예외를 사용하라 </div>
23+
24+
대부분의 언어는 예외 처리를 지원한다. **오류 플래그나 코드를 반환하는 함수는 호출자로 하여금, 함수를 호출한 즉시 오류를 확인하도록 강제한다.** 또한 이 단계는 무의식적으로 넘어가기 쉽다. 때문에 오류가 발생하면 예외를 던지는 편이 낫다.
25+
26+
예외를 던지도록 하면, 호출자 코드가 더 깔끔해진다. **오류 처리 코드와 비즈니스 로직이 뒤섞이지 않는다. (바꿔 말하면, 둘이 뒤섞이면 이해하기 힘든 코드가 된다.)** 즉, 각 개념을 독립적으로 살펴보고 이해할 수 있게 된다!
27+
28+
**Bad:**
29+
```java
30+
public class DeviceController {
31+
...
32+
public void sendShutDown() {
33+
DeviceHandle handle = getHandle(DEV1);
34+
// 디바이스 상태 점검
35+
if (handle != DeviceHandle.INVALID) {
36+
// 레코드 필드에 디바이스 상태 저장
37+
retrieveDeviceRecord(handle);
38+
39+
// 디바이스가 일시정지 상태가 아니라면 종료 (즉, 오류 상황)
40+
if (record.getStatus() != DEVICE_SUSPENDED) {
41+
pauseDevice(handle);
42+
clearDeviceWorkQueue(handle);
43+
closeDevice(handle);
44+
} else {
45+
logger.log("Device suspended. Unable to shut down");
46+
}
47+
} else {
48+
logger.log("Invalid handle for: " + DEV1.toString());
49+
}
50+
}
51+
}
52+
```
53+
54+
**Good:**
55+
```java
56+
public class DeviceController {
57+
...
58+
public void sendShutDown() {
59+
try {
60+
tryToShutDown();
61+
} catch (DeviceShutDownError e) {
62+
logger.log(e);
63+
}
64+
}
65+
66+
private void tryToShutDown() throws DeviceShutDownError {
67+
DeviceHandle handle = getHandle(DEV1);
68+
DeviceRecord record = retrieveDeviceRecord(handle);
69+
pauseDevice(handle);
70+
clearDeviceWorkQueue(handle);
71+
closeDevice(handle);
72+
}
73+
74+
private DeviceHandle getHandle(DeviceID id) {
75+
...
76+
throw new DeviceShutDownError("Invalid handle for: " + id.toString());
77+
...
78+
}
79+
...
80+
}
81+
```
82+
83+
확실히 코드가 깔끔해지고, 메인 로직이 어떤 일을 하는지 한 눈에 파악하기 쉬워졌다. 디바이스를 종료하는 알고리즘과 오류 처리 알고리즘의 분리만으로도 가독성이 크게 좋아진다.
84+
85+
<br><br>
86+
87+
## <div id="폼부터">Try-Catch-Finally 문부터 작성하라 </div>
88+
89+
예외가 발생할 수 있는 코드를 짜는 경우 try-catch-finally 문으로 시작하자. try-catch-finally 구문은 하나의 트랜잭션과도 같아서 try 블록에서 무슨 일이 일어나든지 catch 블록이 프로그램 상태를 일관성있게 유지해준다. 따라서 try 내부에서 어떤 일이 발생하더라도, expect result의 type이나 상태를 정의하기 쉬워진다.
90+
91+
아래 예제를 살펴보자.
92+
93+
**파일을 열어서 직렬화된 객체 몇 개를 읽어 들이는 코드가 필요하다는 요구사항**을 가정하자. 아래 코드는 파일이 없으면 예외를 던지는지 확인하는 단위 테스트 코드이다.
94+
95+
```java
96+
@Test(expected = StorageException.class)
97+
public void retrieveSectionShouldThrowOnInvalidFileName() {
98+
sectionStore.retrieveSection("invalid - file");
99+
}
100+
```
101+
102+
테스트에 맞춰 아래 코드를 구현했다.
103+
104+
```java
105+
public List<RecordedGrip> retrieveSection(String sectionName) {
106+
// 실제로 구현할 때까지 비어 있는 더미를 반환한다.
107+
return new ArrayList<RecordedGrip>();
108+
}
109+
```
110+
111+
반면 해당 함수는 예외를 던지지 않기 때문에 단위 테스트가 실패로 나온다. 잘못된 파일 접근을 시도하도록 구현을 아래처럼 변경하자.
112+
113+
```java
114+
public List<RecordedGrip> retrieveSection(String sectionName) {
115+
try {
116+
FileInputStream stream = new FileInputStream(sectionName);
117+
} catch (Exception e) {
118+
throw new StorageException("retrieval error", e);
119+
}
120+
return new ArrayList<RecordedGrip>();
121+
}
122+
```
123+
124+
이제는 코드가 예외를 던지기 때문에 테스트가 성공한다. **이 시점부터 리팩토링이 가능해진다.** catch 블록에서 예외 유형을 좁혀 실제로 FileInputStream 생성자가 던지는 FileNotFoundException을 잡아내자.
125+
126+
```java
127+
public List<RecordedGrip> retrieveSection(String sectionName) {
128+
try {
129+
FileInputStream stream = new FileInputStream(sectionName);
130+
stream.close();
131+
} catch (FileNotFoundException e) {
132+
throw new StorageException("retrieval error", e);
133+
}
134+
return new ArrayList<RecordedGrip>();
135+
}
136+
```
137+
138+
이제 try-catch 구조를 통해 범위를 정의했으므로, TDD를 사용해 필요한 나머지 논리들을 추가하면 된다. 나머지 논리는 FileInputStream을 생성하는 코드와 close 문 사이에 추가될 것이고, 오류나 예외를 발생시키지 않는다는 가정 하에 위 구조가 유지된다.
139+
140+
지금까지 살펴봤듯, 강제로 예외를 일으키는 테스트케이스를 먼저 작성하고, 테스트를 통과하게 코드를 작성하자. 이렇게 되면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되므로 트랜잭션의 본질을 유지하기 쉬워진다.
141+
<br><br>
142+
143+
## <div id="미확인">미확인 예외를 사용하라 </div>
144+
145+
자바 첫 버전에서 선보인 checked exception은 멋진 아이디어처럼 보였다. 메소드 선언시 메소드가 반환할 예외를 모두 열거했다. 또한 코드가 메소드를 사용하는 방식이 이 선언과 일치하지 않으면 컴파일도 안됐다.
146+
147+
반면 이제는 안정적인 소프트웨어를 제작하는데 checked exception가 반드시 필요하지 않다는 사실이 분명해졌다. 그러므로 checked exception가 그것을 사용하는 비용 이상의 가치를 제공하는지 다시 한 번 생각해보자.
148+
149+
가장 먼저 checked exception은 OCP(Open Closed Principle)을 위반한다. 하위단계에서 코드를 변경하면, 그것을 사용하는 모든 함수의 선언부에 올라가서 전부 exception을 추가해줘야 한다.
150+
151+
또한, checked exception은 캡슐화를 깨뜨린다. throws 경로에 위치하는 모든 함수가 최하위 함수의 예외를 파악하고 있어야 하기 때문이다. (하나라도 빠뜨리면 컴파일 에러가 뜨게 된다!)
152+
153+
```java
154+
//checked exception을 사용하는 경우 아래 상위 코드인 func에서도 FileNotFoundException을 선언해야 한다.
155+
public void func(String filename) throws FileNotFoundException {
156+
FileInputStream fis = openFile(filename);
157+
...
158+
}
159+
public void openFile(String filename) throws FileNotFoundException {
160+
try {
161+
FileInputStream stream = new FileInputStream(sectionName);
162+
stream.close();
163+
} catch (FileNotFoundException e) {
164+
throw new StorageException("retrieval error", e);
165+
}
166+
}
167+
```
168+
169+
아주 중요한 라이브러리를 만드는 경우 모든 예외를 잡아서 checked exception으로 명시하는 것은 유용할 수 있다. 하지만 **일반적인 application에서는 이익보다는 checked exception으로 인한 의존성이라는 비용이 훨씬 더 커지게 된다.**
170+
<br><br>
171+
172+
## <div id="의미">예외에 의미를 제공하라 </div>
173+
174+
아주 중요한 부분인데, 예외를 던질 때는 오류 메세지에 전후 맥락과 상황을 충분히 쉽게 이해할 수 있도록 작성해주자. 자바는 모든 예외에 call stack을 제공하지만 이 정보만으로는 부족한 상황이 있다.
175+
176+
오류 메세지를 정확히 명시해준다면, 오류가 발생한 원인과 위치를 찾기 용이해진다. 실패한 연산의 이름과 실패 유형 등을 기록해주고, application에서 logging을 사용한다면, catch 블록에서 더 자세한 정보를 로그로 남겨 오류에 대한 충분한 정보를 기록해주자.
177+
178+
<br><br>
179+
180+
## <div id="호출자">호출자를 고려해 예외 클래스를 정의하라 </div>
181+
182+
프로그래머가 application에서 오류를 정의할 때(혹은 예외 코드를 작성할 때) 가장 중요한 신경써야하는 것은 **오류를 잡는 방법이 되어야 한다.**
183+
184+
아래 코드를 보자.
185+
186+
**Bad:**
187+
```java
188+
ACMEPort port = new ACMEPort(12);
189+
190+
try {
191+
port.open();
192+
} catch (DeviceResponseException e) {
193+
reportPortError(e);
194+
logger.log("Device response exception", e);
195+
} catch (ATM1212UnlockedException e) {
196+
reportPortError(e);
197+
logger.log("Unlock exception", e);
198+
} catch (GMXError e) {
199+
reportPortError(e);
200+
logger.log("Device response exception");
201+
} finally {
202+
...
203+
}
204+
```
205+
206+
위와 같이 에러를 잡고 있었다면 해당 레포지토리를 더 열심히 읽자. 위 코드에서 반복되는 중복 코드들은 그렇게 놀랍지도 않다. 그렇다면 이를 어떻게 개선해야 할까?
207+
208+
사실 생각해보면, 대부분의 상황에서 우리가 에러를 처리하는 방식은 비교적 일정하다. 보통 아래와 같은 절차를 밟는다.
209+
210+
> 1. 오류를 기록한다.
211+
> 2. 프로그램을 계속 진행해도 좋은지 확인한다.
212+
213+
위 코드의 경우 특히 예외에 대응하는 방식이 완전히 동일하기 때문에 코드를 간결히 고치기 더 쉬워진다. 호출하는 라이브러리 API를 감싸면서 예외 유형 하나를 반환하면 된다.
214+
215+
**Good:**
216+
```java
217+
LocalPort port = new LocalPort(12);
218+
try {
219+
port.open();
220+
} catch (PortDeviceFailure e) {
221+
reportError(e);
222+
logger.log(e.getMessage(), e);
223+
} finally {
224+
...
225+
}
226+
```
227+
```java
228+
public class LocalPort {
229+
private ACMEPort innerPort;
230+
231+
public LocalPort(int portNumber) {
232+
innerPort = new ACMEPort(portNumber);
233+
}
234+
235+
public void open() {
236+
try {
237+
innerPort.open();
238+
} catch (DeviceResponseException e) {
239+
throw new PortDeviceFailure(e);
240+
} catch (ATM1212UnlockedException e) {
241+
throw new PortDeviceFailure(e);
242+
} catch (GMXError e) {
243+
throw new PortDeviceFailure(e);
244+
}
245+
}
246+
...
247+
}
248+
```
249+
250+
위의 LocalPort 클래스는 단순히 ACMEPort 클래스가 던지는 예외를 잡아 변환해주는 Wrapper 클래스이다. 이 방법은 실제로 외부 API를 사용할 때는 최적의 방법이다. 아래와 같은 장점들을 제공해주기 때문이다.
251+
252+
> 1. 외부 라이브러리와 프로그램 사이의 의존성이 크게 줄어든다.
253+
> 2. 나중에 다른 라이브러리로의 교체가 용이하다. (코드를 크게 뜯어고칠 필요가 없어진다.)
254+
> 3. Wrapper 클래스에서 외부 API를 호출하는 대신, 테스트 코드를 넣어주는 방법으로 프로그램을 테스트하기도 쉬워진다.
255+
> 4. 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다. 프로그램에 적합하고, 사용하기 편리한 API로 정의하면 된다.
256+
257+
당장 위 예제만 봐도 port 디바이스 실패를 표현하는 예외 유형을 하나만 정의해도 이렇게 프로그램이 깔끔해진 모습을 볼 수 있다.
258+
259+
예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우는 예외 클래스가 하나만 있어도 충분하다. 반면, 한 예외는 잡아내고 어떤 예외는 무시해도 괜찮은 경우라면, 여러 예외 클래스를 사용하자.
260+
<br><br>
261+
262+
## <div id="정상">정상 흐름을 정의하라 </div>
263+
264+
265+
<br><br>
266+
267+
## <div id="null반환">null을 반환하지 마라 </div>
268+
269+
270+
271+
<br><br>
272+
273+
## <div id="null전달">null을 전달하지 마라 </div>
274+
275+
276+
277+
<br><br>
278+
279+
## <div id="결론">결론 </div>
280+
281+
282+
283+
<br><br>

0 commit comments

Comments
 (0)