2021.09.13

예외처리

만능 제어흐름용 가짜 예외 패턴

예외는 예외 상황에서만 쓰여야 한다. 하지만 제어 흐름용으로 쓰이는 것을 나는 여러번 목격했다. 그런데 놀랍게도 중첩된 함수 호출 흐름속에서 프레임워크와 합쳐저 하나의 패턴을 이루는 경우를 커리어 내내 봐왔다. 상세하게 예를 들어보자.

가령 함수 A → B → C 를 호출하는 흐름에서, C 에서 예외를 던지는 것을 A 혹은 A 외부에서 처리하는 경우를 많이 봤다. 예를들어 C 함수에서 유저의 결제를 처리하기 위해 유저정보를 조회하였지만 없다고 치자. 이때 적절하게 C → B → A 그리고 A 에 일상적인 제어흐름을 표현하기 보다는, 예외를 던저버린다. GOTO 문을 하나 넣는 것이다.

이런 패턴 속에서는 Checked Exception 는 사용되지 않는다. A, B, C 모든 사용처의 함수 시그니쳐에 영향을 끼치니까 말이다. 이쯤 되면 여기서 그만두어야 할 텐데, 여기서 이 Checked 를 Unchecked Exception 으로 으로 바꾸어버린다. 그리고 전역적로 이 예외를 처리하는 코드를 작성한다. 이렇게 만능 제어흐름용 가짜 예외 패턴이 탄생한다. 시간이 지나 Unchecked Exception 는 정말로 이제 아무도 처리하지 않는 그런 함수가 되어버린다.

나는 이러한 패턴이 아래와 같은 문제가 있다고 생각한다.

  1. 일단 가짜 예외이고 ( 방만하게 제어흐름으로 사용된다는 측면에서 )
  2. 유지보수를 힘들게 한다.

1번에 대해서는 대부분, 사람 마다 예외에 대한 기준이 다르다는 점이다. 빈번하게 예측되는 Case ( 가령 MSA 환경에서 회원정보가 없는 경우 )는 예외가 아니다. 이미 작성전에 컴퓨터의 어떠한 상황 ( Disk 가 부족하다던지 등 )이 아니라 논리적인 흐름에 의해 생길수 있는 부분이라면 이것은 예외가아니라 제어의 흐름이라는 점을 강조하고 싶다.

2번에 대해서는 스프링의 Transaction marked as rollbackOnly 를 예로들 수 있다. 아까 예시로 든 C 함수에서 유저 정보조회시에 익셉션을 발생시키고, 이때 이 익셉션을 받아다가 유저정보를 저장하는! 함수를 짰다고 해보자. 그러면 예외가 발생했고, 이를 catch 를 받아다가 처리했으니 트랜잭션이 이쁘게 처리될 것 같지만, 사실은 스프링의 트랜잭션에서는 이러한 케이스에 트랜잭션을 재활용하지 못한다. 세세한 상황은 여기 에서 확인하자.

2번의 본질은 스프링코드 구현과 철학의 문제도 아니며, 그것을 알지 못하고 사용한 프로그래머의 문제는 더더욱 아니다. 스프링 트랜잭션도 일부에서만 예외가 발생해도 전역롤백을 가져가는 데 주목해보자. 코드에 표현되지 않는 암묵적인 전역 동작이 있는 것이다. 이런 전역적 가정은 느끼지도 못하고 효용을 준다면 훌륭하다. 스프링의 이러한 트랜잭션 동작은 훌륭하다고 본다. 하지만 늘 전역적인 문제해결이 가까운 미래에 상황에 보편타당하지 않게 될 수 있으므로 조심해야 한다.

'만능 제어흐름용 가짜 예외 패턴'이 엄청나게 잘못된 것처럼 이야기 했지만, 어디서나 있는 패턴이라 마냥 악으로 몰아갈 수는 없다. 늘 그러지 않는가. 상황에 최선을 다할 뿐이라고. 효용성이 있는 전역 패턴은 어느 시점까지는 Don't Reat Yourself 를 충실히 따르는 훌륭한 코드일 수도 있다. 그땐 맞고 지금은 틀리게 되는 것이 늘 어려운 것이다.