Clean Code Day 7 7장 - 오류 처리

Jeeho Park (aquashdw)·2024년 9월 4일

Clean Code

목록 보기
7/10

이제는 조금 정신이 차려지는것 같다. 목만 빼고. 하루종일 말을 하다보니 목은 아직도 조금 아프다. 근데 이건 코로나 때문은 아닌거 같고 그냥 직업병이라 그런듯...

abstract

기록의 습관을 남기자는 생각이 드는 장이었다. 옛날 어느 글에서 if, else if 보다 예외를 던지는 것을 선호하라고 했던거 같은데....이 책을 읽기 전이었으니 당연히 이 책의 이야기는 아니었을 것이다. 어쨋든 오류, 또는 예외 처리는 프로그래밍에서 중요한 부분이란건 너도 나도 체감하는 이야기일 것이다.

이 장에는 좀더 예쁘게 잘못된 상황에 대처하는 것은 물론, 잘못된 상황을 야기할 수 있는 코드를 방지하는 방법에 대해서 설명하는 장이다.

여담으로 읽다보니 참 여러가지 언어가 여러가지 단점을 가지고 있다는 것이 느껴졌다. 이 장에서는 Java의 기능 중 많은 애증을 받는 Checked Exception에 대해서 이야기한다. 본래 일을 Java로 시작하기도 했고, 여전히 Java를 좋은 언어라고 생각하지만 이런 기능 하나가 코드를 어지럽게 만든다는건 외면할 수 없는 진실.

그렇다고 다른 언어들이 그런 약점을 가진것도 아니고. 요즘엔 Next.JS를 열심히 굴려보는 중인데, TypeScript를 쓰면서 Linter를 적용하면, 사파 TS 개발자라 그런지는 모르겠지만 Java에서는 잘만 만들던 DTO들 대신 type이나 interface는 너무 쓰기 힘든 느낌을 받는다. 안써도 되면 결국 안쓰려고 하는게 인간의 마음인데, 그러면 any라서 좋지 못한 코드랜다.

결국엔 모든 언어에는 장단이 있다는 결론으로 도달하는 것 말고는 없는것 같다. 언젠간 Golang도 해보고 Rust도 해보고 Elixer도 해보고 다 해보면 "이 언어는 무조건이야!"하는 언어를 만나는 날이 오게 될것인가?

또또 여담으로 Spring Boot를 쓰는 입장에서 다시한번 Spring은 쩌는 프레임워크가 맞는것 같다. 프로젝트 시작할 때 아주 단순한 ResponseStatusException 부터 컨트롤러 단위의 @ExceptionHandler, 거기에 프로젝트 전역의 예외를 한곳에서 처리할 수 있는 @ControllerAdvice까지. 그야말로 어디서 문제(예외)를 던져도 일관적인 응답을 만들어 낼 수 있는 프레임워크의 기능이 예술의 경지라고까지 느껴진다.

Java는 아직 죽지 않았다.

Stackoverflow 2024 Developer Survey

Java는 살아있다. 아주 멋지게.

7장 오류 처리

뭔가 잘못될 가능성은 늘 존재한다. 뭔가 잘못되면 바로 잡을 책임은 바로 우리 프로그래머에게 있다.

오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵다.

오류 코드보다 예외를 사용하라

사실 클린코드를 읽다보면 Java를 사용하면서 봤던 많은 부분들이 떠오른다. Java의 interfaceQueue<E>만 봐도 그런데, 평범한 FIFO 큐 자료구조를 나타내는 interface다. 대표적인 구현체는 LinkedList이다.

일반적으로 Queue를 공부하면 enqueue와 dequeue를 공부하게 될것이고, 덤으로 peek에 대한 내용도 추가할 수 있다. 그런데 Queue<E>는 좀 희한하다. 각 기능을 위한 메서드가 두개가 존재한다.

  • enqueue: add(E elem) 또는 offer(E elem)
  • dequeue: remove() 또는 poll()
  • peek: element() 또는 peek()

만약 정상적인 처리가 불가능할 경우, 앞의 메서드들은 예외를 발생시키며, 뒤의 메서드들은 특수한 값을 반환한다. 다음은 Oracle 문서에서 가져온 표다.

어떤 메서드가 어떤 이유로 먼저 등장했을까? addoffer든 상세 설명으로 들어가봤자 해당 내용은 나오지 않는다. 대신 offer 부분에 이런 내용은 찾을 수 있다.

offer가 일반적으로 add 보다 났다는 건데, add 예외를 발생시키지 않고서는 원소를 넣는데 실패할 방법이 없다(직역체 극혐)라는 이야기를 한다.

재밌는것은 책에는 이와 반하는 내용이 적혀있다. 이는 소제에서도 알 수 있는 내용이다.

함수를 호출한 즉시 오류를 확인해야 하기 때문이다. 불행히도 이 단계는 잊어버리기 쉽다. 그래서 오류가 발생하면 예외를 던지는 편이 낫다. 그러면 호출자 코드가 더 갈끔해진다. 논리가 오류 처리 코드와 뒤섞이지 않으니까.

즉 예외를 발생시키는 경우 주된 비즈니스 로직 코드와 오류를 처리하기 위한 코드가 뒤섞이지 않는다는 의미라고 볼 수 있겠다.

Queue 예시를 들었으니 이로 설명을 해보면,

Queue<Integer> queue = new LinkedList<>();
// ... 큐에 열심히 원소를 넣는다.

int sum = 0;
// 사실 `isEmpty()`로 확인하면서 실행하는게 맞지만 그냥 예시를 위해 `while(true)`
while(true) {
    // poll()은 예외를 발생시키지 않는다.
    Integer data = queue.poll();
    // 예외가 발생하지 않았기 때문에 `null` 확인이 필요하다.
    if (data == null) {
        // `null`이 맞을 경우 본문에서 처리한다.
        System.out.println("null 이라 종료!!!");
        break;
    }
    // 본래의 작업을 실행한다.
    sum += data;
}

poll()을 사용할 경우 while이 실행되면서 데이터를 꺼내오는데, 예외가 발생하지 않고 null이 나오게 됨으로 null일때의 코드가 while과 함께 동작하게 된다.

Queue<Integer> queue = new LinkedList<>();
// ... 큐에 열심히 원소를 넣는다.

int sum = 0;
// 사실 `isEmpty()`로 확인하면서 실행하는게 맞지만 그냥 예시를 위해 `while(true)`
try {
    while(true) {
        // remove()는 예외를 발생시킨다.
        // 그러니 `null` 체크 할것도 없이 바로 본론으로.
        sum += queue.remove();
    }
} catch (NoSuchElementException e) {
    // `Queue`가 비었을 때 행동은 여기서 처리한다.
    System.out.println("이미 비었으니 종료!!!");
}

remove()를 사용할 경우 예외가 발생하고, 예외는 try - catch로 잡아주기 때문에, sum += 이라는 본론으로 바로 진행할 수 있다.

어쩌면 try - catch 문법이 일반적인 흐름에서 벗어나서 조금 더 어렵게 느껴질지도 모르겠다. 하지만 특수한 값을 처리하는 것보다 본론 자체는 헐씬 깔끔하게 작성되는 것이 참 예쁘지 않은가?

이는 Spring 개발중에도 많이 느낄 수 있다. Spring Data의 Repository 메서드 중 Optional<E> findById(ID id)를 사용한다고 가정할 때, orElseThrow를 사용하면 단순한 조회를 위한 코드는 정말 간결하게 마무리 되지 않는가?

return repo.findById(1L)
    .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

...물론 이보단 Kotlin식 Elvis가 좀더 예쁘긴 하다.

Try-Catch-Finally 문부터 작성하라

어떤 면에서 try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다.

TDD까지 적용해가며 예시를 들어주신다. try - catch - finally를 먼저 작성하게 될 경우 try를 시작할때 작성할 코드와 끝낼때 작성하는 코드를 먼저 작성하게 되기 때문에, DB 트랜잭션 마냥 응용 소프트웨어의 상태를 일관성 있게 유지하기 좋다는 의미이다.

미확인(Unchecked) 예외를 사용하라

개인적으로 JS의 악은 undefined라고 생각한다. Java의 악은 Checked Exception이 아닐까. 물론 이건 사파 개발자의 그냥 단순한 생각이고 멋진 Uncle Bob은 이유도 잘 설명해주신다.

Java를 사용하다보면 갑자기 난데없이 빨간줄이 뜰때가 한번씩 있다. 이건 알고리즘 풀때 맨날 작성하는 코드인데,

입력에서 한줄 받아오는 코드를 쓰는데 난데없이 빨간줄이 뜬다. Java는 몇몇 예외는 발생 가능성이 메서드에 표시가 되어 Signature의 일부로 작성되어야 하며, 이 예외는 처리하지 않을 경우 Compile Error가 발생하게 된다. 즉, 실행도 되지 않는 심각한 오류로 취급한다는 것. 근데 왜 입력을 읽어들이는데 이런일이 발생할까?

이는 BufferedReader 자체가 입력을 읽는것이 아니라, Reader라는 데이터 입력을 담당하는 추상 클래스의 입력을 버퍼링을 적용해서 읽는 Decorator 클래스라 그런다. 그래서 BufferedReader는 입력의 출처를 알 필요가 없지만 그 입력의 출처 자체는 사용자의 콘솔 입력이 될 수도, 파일이 될 수도 있다.

근데 만약 파일 데이터를 읽을 경우 프로그램 실행 "해당 파일이 존재하는지? 읽는게 가능한 파일인지?"등, 개발자가 어떤 코드를 작성해도 발생할 가능성이 있는 예외가 존재하기 때문이다. 즉 개발자의 코드가 난데없이 종료되는것을 방지해주기 위해 "이런 예외는 무조건 발생하니까 꼭 처리해!"라고 컴파일러가 사전에 알려주는 것이다.

결국 정상적인 프로그램 실행 상황에서는 솔직히 일어날 가능성이 1도 되지 않는 (0.00001 정도는 컴퓨터가 맛이가서 일어날 수 있다고 생각은 한다) 예외를 위해 try - catch를 작성하는건 불편하니까 보통은 그냥 메서드 옆에다 달아준다.

그리고 거의 99프로의 Checked Exception은 IOException이라는 사실도 덤으로 알게 된다(...).

지금은 이게 main이고, mainthrows를 하면 그냥 프로그램 비정상 종료니까 상관이 없는데, 문제는 큰 프로젝트의 엄청 작은 메서드라면?

IOException과 아무 상관 없는 호출하는 메서드들이 모두 throws가 붙어야 한다(...). 물론 처리하고 RuntimeException을 던지는 것도 가능하겠다. 만약 cMethod에 새로운 예외상황이 발생한다면, 해당 예외가 throws 절에 들어갈 것이며, 이를 대응하기 위해 aMethodbMethod도 해당 예외의 존재를 알아야 할 필요가 생긴다. 결국 하위 메서드의 변화가 상위 메서드의 변화를 일으키게 되며, Open Closed Principle을 위반하게 되는 결과를 일으키게 된다.

RuntimeException과 이를 상속받는 예외들을 쓰자. RuntimeException은 다른 언어들의 예외와 비슷하게 throws를 강요하지 않는다.

호출자를 고려해 예외 클래스를 정의하라

대다수 상황에서 우리가 오류를 처리하는 방식은 (오류를 일으킨 원인과 무관하게) 비교적 일정하다.

몇몇 라이브러리들은 세분화된 예외를 제공하지만, 사실 우리는 그것을 전부 활용하긴 어려워서 이유를 기록하고 실패시의 대처를 진행하기 마련이다. 그럴 경우 예외를 잡아서 일정한 하나의 예외로 다시 던져주는게 났다.

public void open() {
    try {
        innerPort.open();
    catch (DeviceResponseException | ATM1212UnlockedException | GMXError e) {
        log.error(e.getMessage());
        throw new PortDeviceFailure(e);
    }
}

그외 이야기들

나머지는 그냥 끄덕끄덕 하면서 "재밌네~"하고 읽은게 많다. null 부분은 예외. null은 악이다. 순수악 그 잡채

예외에 의미를 제공하라

예외를 던질 때는 전후 상황을 충분히 덧붙인다. 그러면 오류가 발생한 원인과 위치를 찾기가 쉬워진다.

근데 Spring Boot는 거의 5겹의 클래스가 별도로 동작해서 그런지 몰라도 원인 찾는거 자체도 좀 어렵.....긴 한데 대부분 프레임워크들 다 비슷한듯

정상 흐름을 정의하라

때로는 중단이 적합하지 않은 때도 있다.

몇몇 특수한 경우들에 대해서 일반적인 흐름으로 유도될 수 있도록, 오류 대신 동작할 특수 사례를 반환하는 방식을 활용할 수 있다.

null을 반환하지 마라 & null을 전달하지 마라

누구 하나라도 null 확인을 빼먹는다면 애플리케이션이 통제 불능에 빠질지도 모른다.

정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 null을 전달하는 코드는 최대한 피하라.

null은 참으로 처치 곤란할 때가 많은것 같다. 특히 null이 들어간 Queue<E>poll() 같은 메서드가 있다면 그야말로 지옥이다. 과연 방금 나온 null은 데이터가 없어서 나온 null일까, 아니면 null을 의미하는 null일까? 아주 널럴하다

결론

내가 어떤 말을 써도 이 장의 결론에 있는 말 이상으로 좋은말이 떠오르지 않을것 같다. 하고싶은 얘기는 앞에 많이 해두었으니 결론 문단의 첫 문장을 발췌하며 마무리한다.

깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다. 이 둘은 상충하는 목표가 아니다.

인증샷 추가

0개의 댓글