성장일기

미션에서 여러분은 예외 처리를 어떻게 하고 계신가요? 프리코스를 하셨던 분들이라면 다들 한번쯤은 해봤던 고민이었을 것 같습니다. 아래와 같이 디스커션에서 많은 분들이 질문하고, 의견을 나누는 것을 보았습니다.

더보기

 

 

목차

  1. 개요
  2. 간단한 예제
  3. default 예제 코드
    1. validate(검증)
    2. handle(다루다)
  4. Exception Handling - ver.0
  5. Exception Handling - ver.1
  6. Exception Handling - ver.2
  7. Exception Handling - ver.3
  8. 마무리
  9. Github Repository Link

 


 

1. 개요

해당 글은 우테코 5기 프리코스의 미션을 진행하면서 가장 효과적이고 강력한 예외 처리 방법에 대해 고민하고, 방법을 찾아보면서 공부한 과정을 정리 및 공유하기 위한 글입니다. 혹여나 잘못된 정보나 추가 의견은 댓글로 남겨주시면 감사하겠습니다.

 

우테코 5기 프리코스 미션을 진행하면서 버그 없는 프로그래밍을 하기 위해서 예외 처리가 중요하다는 것을 느끼게 되었습니다. 따라서 어디서 검증을 하며 예외를 발생시키고, 발생 시킨 예외를 어디서 다루는 게 좋을지에 대해 많은 분들이 고민하고 디스커션에서 의견을 주고받는 것을 보았습니다.

 

저도 마찬가지인데요, 프리코스를 마치며 클린 코드를 읽으면서 예외를 어떻게 하면 깔끔하게 처리하고, 효과적으로 처리할 수 있을지 고민하고, 구글링을 하며 다양한 방법들을 시도해보았습니다.

 

이전의 MVC 패턴의 글과 마찬가지로 예외 처리 방법을 업그레이드 시켜가며 놀아봤습니다.

 

 

프로그램 예제는 우테코 프리코스 3주 차 미션인 로또 게임 미션을 간략화했고, 프로그램 흐름의 이해를 위하여 도서 구입 기능을 추가했습니다.(예제에서 도서 구입 기능은 구현하진 않습니다.)

 

들어가기 전에,

  1. 프로그램 예제는 아주 간단한 MVC 패턴을 따르고 있습니다. 기본적인 MVC의 개념과 구현 방법에 대해서 설명하지 않습니다.
  2. Java 문법, 클린 코드, 객체지향이 핵심이 아니기 때문에 해당 부분은 깊게 신경 쓰지 않았으며 부족한 부분이 있을 수 있습니다.(상수 화도 고려하지 않았습니다.)
  3. 저도 공부하면서 적용해본거라, 부족한 부분이 있을 수 있습니다.

 

 

2. 간단한 예제

메인 기능

  • 1. 로또 구입 기능
  • 2. 도서 구입 기능
  • Q. 종료

로또 구입 기능

  • 1. 로또 구입
  • 2. 구입한 로또 조회
  • 3. 로또 삭제
  • B. 돌아가기

도서 구입 기능

  • 1. 도서 구입
  • 2. 구입한 도서 조회
  • 3. 도서 삭제
  • B. 돌아가기

실행 예시

더보기

1. 로또 구입 기능
2. 도서 구입 기능
Q. 종료

원하는 기능을 입력하세요: 
1
1. 로또 구입
2. 구입한 로또 조회
3. 로또 삭제
B. 돌아가기

원하는 로또 기능을 입력하세요
1
구입할 로또 번호를 입력하세요.
1,2,3,4,5,6
로또를 구입했습니다.

1. 로또 구입
2. 구입한 로또 조회
3. 로또 삭제
B. 돌아가기

원하는 로또 기능을 입력하세요
1
구입할 로또 번호를 입력하세요.
4,5,6,7,8,9
로또를 구입했습니다.

1. 로또 구입
2. 구입한 로또 조회
3. 로또 삭제
B. 돌아가기

원하는 로또 기능을 입력하세요
2
1. [1, 2, 3, 4, 5, 6]
2. [4, 5, 6, 7, 8, 9]

1. 로또 구입
2. 구입한 로또 조회
3. 로또 삭제
B. 돌아가기

원하는 로또 기능을 입력하세요
3
조회목록의 삭제할 로또 번호를 입력하세요.
1
로또를 삭제했습니다.

1. 로또 구입
2. 구입한 로또 조회
3. 로또 삭제
B. 돌아가기

원하는 로또 기능을 입력하세요
2
1. [4, 5, 6, 7, 8, 9]

1. 로또 구입
2. 구입한 로또 조회
3. 로또 삭제
B. 돌아가기

원하는 로또 기능을 입력하세요
B
1. 로또 구입 기능
2. 도서 구입 기능
Q. 종료

원하는 기능을 입력하세요:

 

메인 기능은 크게 2가지 관리 기능으로 나뉘고, 각 관리 기능은 3가지(생성, 삭제, 조회)로 나눠져 있는 아주 간단한 프로그램입니다.

(프로그램 흐름과 이해를 위하여 도서 구입 기능을 추가하였고, 로또 기능만 다루겠습니다.)

 

프로그램 실행 흐름

프로그램은 사용자가 Q. 종료를 하기 전까지 종료할 수 없고, 로또 구입 기능을 선택했을 때 해당 로또 구입 기능에서 B. 돌아가기를 하기 전까지 로또 구입 기능을 계속 수행합니다.

 

3. default 예제 코드

예외 처리를 다루기 위해서는 앞서 개요에서 언급했듯이 크게 2가지를 생각할 수 있습니다.

1. 어디서 검증을 하며 예외를 발생

2. 발생 시킨 예외를 어디서 다루는

 

즉, validate(검증)와 handle(다루는) 두가지 요점으로 나눌 수 있습니다.

(이후로 "예외를 처리한다.", "예외를 다룬다."라는 용어를 "예외를 핸들링한다."라고도 표현하겠습니다.)

 

1. validate(검증)

프로그램에서 다음과 같은 요구사항이 있다고 하겠습니다.

  1. 각 로또 번호는 1~45의 숫자만 가능합니다.
  2. 하나의 로또에서 각각의 로또 번호는 중복될 수 없습니다.
  3. 로또 번호는 6개여야 합니다.
  4. 로또를 최대 10개까지만 구매 가능합니다.
  5. 구입할 로또 번호 입력 값은 1,2,3,4,5,6과 같은 형식을 지킵니다.
  6. 예외가 발생하면 예외 메시지를 "[ERROR] ..."와 같은 형태로 보여주고, 해당 시점에서 재입력 받게끔 합니다.(입력 값은 올바르나 비지니스 예외가 발생하면 해당 기능을 다시 실행) 

 

여기서 버그를 막기 위한 검증에서 크게 2가지를 생각할 수 있습니다. 잘못된 사용자의 입력에 대한 검증비지니스 요구사항에 대한 검증입니다.

 

예를 들어 입력에 대한 검증은 로또 번호를 1,2,3,4,5,6과 같은 형태로 입력받길 원하지만, 사용자가 1-2-3-4-5-6과 같은 형태로 입력을 했을 경우가 있습니다.

 

비지니스 요구사항에 대한 검증은 1,2,3,4,5,46과 같은 형태로 입력 요구 사항은 지켰지만, "로또 번호는 1~45까지만 가능하다."는 비지니스 요구사항을 어겼을 경우입니다.

 

즉, 저는 입력 요구사항 같은 검증은 InputView 레이어에서 이후 비지니스 요구사항은 각각 model 혹은 service에서 검증을 해주었습니다.

 

하지만 "잘못된 예외가 발생했을 때, 해당 시점부터 재입력 받게끔 한다."라는 요구 사항 때문에 즉, 재입력을 해야 하고 프로그램 흐름을 해당 시점에서 이어나가야 하기 때문에 InputView 레이어에서 비지니스 요구사항까지 검증해야 하는 상황이 발생했습니다.

(Controller에서 한번에 처리하는 경우도 생각할 수 있지만, 특정 기능에서 입력을 여러 번 받는다면 Controller에서 한꺼번에 처리할 경우 해당 기능의 입력을 처음부터 다시 받아야 합니다.)

 

저 또한 프리코스에서 재입력과 프로그램 흐름을 위해 InputView에서 domain 계층에서 둘 다 검증을 진행했습니다. 물론 검증 로직은 클래스로 공통 처리했지만, 비지니스 요구사항을 View에서 검증하고 있는 점이 마음에 안 들었습니다.

 

해당 부분에 대해서 우테코 디스커션에서도 많은 분들이 의견을 나눴는데요. 그중 하나의 의견은 해당 상황을 웹 프론트, 백엔드로 비유하여, 프론트를 InputView에 빗대어 프론트에서도 백에서도 둘 다 검증을 하기 때문에 자바 프로그램의 InputView와 domain에서의 중복 검증이 괜찮다는 의견이었습니다.

 

하지만 제 생각은 다음과 같습니다. 프론트와 백이 서로 다루는 데이터 형식 및 포맷을 위해 각각 검증을 할테고, 비지니스 요구사항에 대한 검증은 백에서는 필수이고, 일부 비지니스 요구사항도 프론트 단에도 사용자의 경험(빠른 응답, 리소스등)을 위해 하겠지만, 백단에서만 할 수 있는 검증이 있을 수 있습니다.

 

예제를 예로 들자면 로또를 최대 10개까지만 구매 가능하다는 비지니스 요구사항 검증은 Repository에 저장되어 있는 Lotto의 개수를 알아야 검증을 할 수 있기 때문에 프론트 단에서 검증을 할 수 없습니다. 하지만 우리 Java 프로그램으로 따지면 재입력과 프로그램 흐름을 위해 InputView에서 해당 검증을 해야 하는 셈입니다.

 

이러한 문제 때문에 웹과는 비유가 적절하지 않고, 만약 웹과 비유를 하고 싶다면 앞서 언급했던 Repository를 의존하는 검증을 domain에서만 할 수 있어야 합니다.

 

현재 우리는 순수 Java로만 프로그래밍을 하고 있기 때문에 현재 상황에서 문제를 해결하고 싶었습니다.

 

정리해보자면, InputView는 입력 요구사항 검증만, Domain레이어는 비지니스 요구사항 검증만 하면서 예외 처리 조건인 재입력과 프로그램의 흐름을 유지까지 가능하게 하면서 예외 핸들링을 한 곳에서 그리고 클린하고, 유연하게 할 수 없을까? 에 대한 고민이었습니다.

 

 

이제 검증 로직과 default 예제 코드를 작성해보겠습니다.

Lotto 클래스

Lotto 클래스이며 비지니스 요구사항을 검증하고 있습니다. IllegalArgumanetException을 던집니다.

 

LottoRepository 클래스

Lotto의 인스턴스를 저장하고 관리하는 LottoRepository 클래스입니다.

 

LottoService 클래스

이번에는 Service 계층을 두었으며, 로또 구매 시 로또의 개수가 10개 이상인지 검증하는 비지니스 요구사항을 검증하고 있습니다. IllegalStateException을 던집니다. Repository는 로또를 저장하고, 변경하고, 삭제하고, 조회하는 책임만을 가지고, 비지니스 요구사항이나 Repository에 의존하는 검증, 여러 모델에 의존하는 검증은 Service 레이어에서 할 수 있습니다.

 

MainFeature 이넘

메인 입력 기능 요구사항은 enum으로 관리하며, 사용자 입력 값으로 해당 enum 인스턴스를 찾아오는 과정에서 검증하도록 처리하도록 했습니다. 해당 입력을 enum으로 관리하여 if문 분기를 줄이고, enum에서 입력 요구사항을 검증할 수 있습니다.

 

LottoFeature 이넘

로또 입력 기능 요구사항도 역시 enum으로 관리했습니다.

 

Controllable 인터페이스
FrontController 클래스

해당 FrontController는 사용자 입력에 따라 MainFeature를 받고(LottoInputView에서 사용자 입력에 따라 MainFeature를 검증 후 반환), 해당 도메인 요청(Lotto)을 처리할 수 있는 Controller를 찾아서 요청을 처리합니다.

 

예제에서는 Lotto 기능만 다룰 것이기 때문에 LottoController만 저장했지만, Book 기능을 처리하는 BookController를 둘 수 있고, 다른 Controller도 둘 수 있습니다.

 

LottoController 클래스

LottoController는 각 사용자 요청에 따라서 View와 LottoService를 이용하여 기능을 수행합니다.

 

LottoInputView 클래스

입력 요구 사항 검증은 메인 기능 입력(1, 2, Q)로또 기능 입력(1, 2, 3, B)에 대해서만 올바르게 입력했는지 검증하도록 하겠습니다.(구입할 로또 번호 입력 형식과 삭제할 로또 번호 입력이 숫자인지등의 입력 값 검증은 생략하도록 하겠습니다.)

메인 기능 입력과 로또 기능 입력의 경우 위에서 언급한 enum에서 검증하고 있습니다.

 

여기까지 기본적인 비지니스 로직과 검증 로직은 다 작성했습니다.

 

LottoOutputView는 중요하지 않으므로 생략하겠습니다.

 

2. handle(다루다)

이제 검증에서 예외가 발생했을 때, 이 예외들을 어떻게 다루냐가 문제입니다. HTTP처럼 각각의 요청과 응답이 명확한 경우는 예외를 공통으로 다루기 쉽지만, 우리는 순수 Java로 프로그래밍하고 있고, 재입력프로그램의 흐름을 고려해야 하기 때문에 예외를 공통으로 그리고 클린 하게 처리하기가 쉽지 않았습니다.

 

저는 Java 프로그램에서 모든 예외를 한 곳에서 그리고 클린하게 핸들링할 수 없을까? 고민했습니다. 한 곳에서 예외를 핸들링한다는 의미는 하나의 클래스에서만 모든 예외를 try - catch 하고, 원하는 핸들링 방식 별로 나눠서 예외를 처리하는 점입니다.

 

핵심은 재입력프로그램의 흐름 유지입니다. 이 특성 때문에 쉽지 않았습니다.

 

프로그램 흐름도

예외를 한 곳에서 처리하는 방법은 프로그램의 흐름도를 보면 FrontController를 쉽게 떠올릴 수 있습니다. 하지만 웹과 다르게 FrontController를 통해 응답이 나가지 않고, 우리는 순수 Java로 프로그램을 구현하면서 재입력과 프로그램의 실행 흐름을 유지해야 하기 때문에 FrontController에서 한 번에 예외를 핸들링할 수 없습니다.

 

프로그램의 흐름을 유지하기 위해서는 결국 입력을 받는 LottoInputView와 각 기능이 호출되는 Controller 내 메서드(lottoBuy, lottoGet, lottoDelete, back)에서 예외를 핸들링해야 한다는 의미입니다. 그러면 각 메서드마다 try - catch문이 남발하게 되는데, 이는 정상 로직과 섞여 있어서 유지 보수를 어렵게 만들고, 가독성이 떨어집니다.

 

클린 코드 책에 예외 처리에 관하여 다음과 같은 문구가 있습니다.

오류 처리도 한 가지 작업이다.
함수는 '한 가지' 작업만 해야 한다. 오류 처리도 '한 가지' 작업에 속한다. 그러므로 오류를 처리하는 함수는 오류만 처리해야 마땅하다.
...

 

따라서 코드 리뷰, 디스커션을 보니 많은 분들이 오류 처리 부분을 함수형 프로그래밍, 제네릭을 이용하여 메서드로 뽑아내어 공통 처리하는 것을 보았습니다. 저는 이 부분에서도 아쉬운 점을 느껴 한, 두 단계 더 개선시켰는데요, Exception Handling 과정을 어떻게 개선시켜보며 공부했는지 코드를 통해 정리해보았습니다.

 

 

4. Exception Handling - ver.0

우리는 크게 입력에서의 예외비지니스 로직에서의 예외를 핸들링 해야하기 때문에 두가지로 나눠서 접근하겠습니다.

 

1. 입력 요구사항 Exception Handling

LottoInputView 클래스

가장 간단하게 예외를 처리하면 다음과 같이 할 수 있습니다. 예외 처리 방법은 요구 사항에 따라 올바른 값을 입력할 때까지 재입력을 받도록 해주었습니다. 검증은 위에서 언급했던 각각의 enum에서 검증을 합니다.

 

메인 기능 입력 로직은 MainInputView 클래스로 나누어서 따로 처리할 수 있지만, 예제의 복잡도를 낮추기 위해 일단은 같은 클래스에 두도록 하겠습니다.

 

(재입력 방법은 재귀를 이용하도록 하겠습니다. 재귀의 장단점에 대해서 고려하지 않습니다. 또한 예제를 간단히 하기 위해 메인 입력과, 로또 기능 입력에 대한 검증만 다루기로 했기 때문에 나머지 입력 기능의 예외처리와 검증에 대해서는 다루지 않겠습니다.)

 

2. 비지니스 요구사항 Exception Handling

(해당 메서드들은 LottoController 클래스 내의 메서드이며, 헷갈리신다면 위에서 LottoController 클래스를 참고 바랍니다.)

LottoController 클래스 내 메소드

위의 프로그램 흐름도에서 언급했듯이 프로그램의 실행 흐름을 이어나가고, 해당 시점에서 기능을 다시 수행해야 하기 때문에 각 메서드마다 예외를 핸들링해주었습니다.(getBuyLottos 메서드는 예외가 발생할 수 있는 상황이 없지만, 있다고 가정하겠습니다.)

 

+ Controller의 run 메서드에서 한꺼번에 try - catch를 하면 되는 거 아니냐라고 생각하시는 분들도 있을 텐데, 그러면 예외가 발생했을 때 해당 시점에서 기능을 다시 수행하는 것이 아니라 사용자로부터 수행할 로또 입력 기능을 다시 입력받게 됩니다.

 

아쉬운 점

  1. 정상 로직과 예외를 핸들링하는 로직이 섞여있어서 이해가 어려울뿐더러 유지보수에 어려움이 있습니다. (비지니스 요구 사항이 바뀌거나 예외처리 요구 사항이 바뀌어도 해당 코드를 봐야 합니다.)

정상 로직에서 try - catch문을 떼어내고 예외를 한 곳에서 핸들링할 수 없을까요? 

 

이 점을 개선해 보겠습니다.

 

 

5. Exception Handling ver.1

ExceptionHandler 클래스로 중복 제거

ExceptionHandler 클래스

예외 로직을 정상 로직에서 분리하고 한 곳에서 예외를 핸들링하는 것이 목적이기 때문에 ExceptionHandler라는 클래스를 만들어서 input 메서드는 입력 예외process 메서드는 비지니스 예외를 핸들링하는 각각의 메서드로 분리했습니다.

 

사용자 입력 값 메서드는 입력 값이 없고 반환 값이 있기 때문에 Supplier 형태를 띠고, 비지니스 로직 수행 메소드는 입력 값이 있고 반환 값이 없기 때문에 Consumer 형태를 띱니다. (자바의 제네릭, 람다와 함수형 프로그래밍에 대해서 익숙하지 않은 분들을 이해가 어려울 수도 있는데요, 어려우신 분들은 제네릭과 람다와 스트림의 키워드에 대해서 공부해보시길 추천드립니다!)

 

1. 입력 요구사항 Exception Handling

FrontController 클래스
LottonController 클래스 내 메소드들
LottoInputView 클래스(try - catch문 없어짐)

ExceptionHandler 클래스의 input 메서드를 이용하여 InputView에는 정상 로직만 가질 수 있게 되면서 예외를 핸들링할 수 있게 되었습니다.

 

2. 비지니스 요구사항 Exception Handling

LottoController 클래스(각 메소드 try - catch문 없어짐)

ExceptionHandler 클래스의 process 메서드를 이용하여 LottonController 내 메서드들은 정상 로직만 가질 수 있게 되면서 예외를 핸들링할 수 있게 되었습니다. 

 

하지만 여기서 enum과 함수형 프로그래밍을 사용했으니, run 메서드 내 if문 분기를 없애보도록 하겠습니다.

리팩터링 한 LottoFeature 이넘

각 기능별로 실행할 메서드를 상태로 가질 수 있도록 함수형 프로그래밍을 이용하였고, 해당 이넘 인스턴스가 process 메서드를 호출하면 상태를 가지고 있는 각각의 기능(메서드)을 수행합니다. 메서드 호출을 이곳에서 하기 때문에 예외 처리 요구사항에 따라 try - catch문이 해당 proces 메서드 내 있어햐 하지만 ExceptionHandler를 이용하여 예외를 핸들링해주었습니다.

 

리팩터링 한 LottoController 클래스

run 메서드 내 if문 분기를 없앴으며, 단지 반환된 LottoFeature enum의 인스턴스의 process 메서드만 호출하면 됩니다.

 

실행 결과

실행 결과

실행 결과를 살펴보면 입력 값 예외 시 재입력을, 비지니스 예외가 발생하면 기능을 다시 수행하게 됩니다.

 

개선한 점

  1. 예외를 핸들링하는 부분을 ExceptionHandler라는 클래스를 만들어 예외를 한곳에서 다룰 수 있게 되었습니다.(try - catch문은 ExceptionHandler에서 밖에 쓰이지 않습니다.)
  2. 함수형 프로그래밍을 이용하여 run 메서드 내 if문 분기를 없애서 확장성 좋게 개선했습니다.(로또의 기능이 추가될 경우 if문 분기를 추가하거나 해당 코드를 고칠 필요가 없습니다.)

 

아쉬운 점

  1. 예외를 처리하는 공통의 관심사를 ExceptionHandler로 처리했지만, 입력 값의 예외를 핸들링하거나 비지니스 예외를 핸들링할 경우 Controller 내 곳곳마다 ExceptionHandler를 도배해줘야 합니다. 즉, 정상 로직에서 try - catch문을 떼어냈지만, 대신 ExceptionHandler를 써줘야 해서 ExceptionHandler의 의존성이 InputView와 Controller의 곳곳마다 퍼지게 됩니다. ExceptionHandler는 static 클래스 형태로 객체지향의 관점에서 본다면 그다지 좋은 방향성은 아닌 것 같습니다.
  2. 예외 핸들링의 요구사항이 바뀔 때 기존 로직을 수정해줘야 합니다. 이는 객체지향의 5원칙 중 Open - Closed Principal을 위배합니다. (아래 + 예외 핸들링 요구 사항의 변경 참고)
  3. 정상 로직과 예외 처리 로직을 분리했지만, 여전히 가독성 측면에서 정상 로직을 읽는 중에 ExceptionHandler라는 로직이 항상 있어서 눈에 거슬립니다.

ExceptionHandler의 input 메소드 사용 + 정상 로직
정상 로직만 있는 경우

위의 두 로직 중 누구에게나 물어봐도 아래 정상 로직만 있는 경우가 가독성이 좋고, 이해하기 쉽습니다. 위의 경우는 ExceptionHandler 때문에 개발자가 한번 더 생각해야 합니다. ExceptionHandler의 input 메서드에 대해서 잘 인지하지 못한 개발자라면 input 메서드의 로직까지 들여다봐야 할 것입니다.

 

정상 로직은 정상 로직에만 집중하고, 예외 핸들링은 한 곳에서 각각의 핸들링 방법에 따라 처리하고, 예외 핸들링의 요구 사항이 변경될 때마다 유연하게 확장하여 변경하고, Java 프로그램 특성상 사용자 재입력과 프로그램의 흐름을 유지하면서까지 예외를 강력하게 처리할 수 있는 방법이 없을까요? 해당 부분을 고민하면서 재입력과 프로그램의 흐름 유지 특성 때문에 쉽지 않아서 포기할까 했지만, 방법을 찾을 수 있었습니다.

 

+ 예외 핸들링 요구 사항의 변경

현재 비지니스 로직의 모든 예외 처리를 재입력과 기능 재실행으로 처리해줬기 때문에 비지니스 요구사항 중 10개 이상일 때 로또 구입 시 예외가 발생하면, 사용자는 다시 로또 구입 기능에서 재입력을 하게 되고, 입력을 하면 다시 예외가 발생하며 무한 루프에 빠지게 됩니다. 이는 나중에 예외 핸들링 요구 사항의 변경을 위해 예제를 이렇게 구성했습니다.

재기능 수행 핸들링으로 해당 비지니스 예외 발생시 무한 루프

이후 마지막 버전에서 로또 구입 기능 실행 시 로또의 개수가 10개 이상일 때 예외가 발생하면, 해당 기능을 다시 실행하는 것이 아니라, 로또를 삭제하는 기능을 바로 실행시키도록 예외 핸들링을 수정해보겠습니다.

 

 

6. Exception Handling ver.2

어떠한 방법으로 개선할 수 있을까요? 저는 AOP(Aspect Oriented Programming - 관점 지향 프로그래밍)를 접목시켰습니다. AOP란 이름에서 알 수 있듯이 관점 즉, 공통 관심사항을 기준으로 요소들을 추출하여 모듈화 하는 기법입니다.

 

예를 들어 어느 날 갑자기 모든 메서드에 로깅을 남기거나 실행 시간을 측정해야 하는 요구사항이 있을 경우 모든 메서드에 해당 로직들을 추가해줘야 합니다. 공통 기능들을 클래스로 뽑는다고 해도 앞서 ExceptionHandler 클래스처럼 해당 메서드에 코드를 추가시키거나 나중에 필요 없을 시 다시 제거해야 하는 일이 발생합니다. 이러한 일을 감수한다고 하더라도 개발자가 로직을 추가, 제거하는 중에 실수를 하거나 서비스 규모가 엄청나게 클 경우는??

 

또한 어떠한 문제가 있을까요? 바로 핵심 비지니스 로직에 특정 관점을 위한 부가적인 공통 기능이 섞인다는 문제가 있습니다. 이러한 이유로 앞서 try - catch문도 분리하려고 노력했는데요. AOP는 OOP와 다른 게 아니라 OOP를 OOP 답게 쓰게 해주는 프로그래밍 기법이라고 합니다. 저도 이번에 공부한 거라 AOP에 대한 설명은 여기까지 하고 이제 예제로 돌아가 보도록 하겠습니다. (더 궁금하신 분들은 구글링 GO GO!)

 

이제 우리의 예제에서 모든 메서드에서의 예외를 핸들링하는 로직을 하나의 공통 관심 사항(관점)이라고 보고, 해당 공통 기능을 모듈화 하여 분리해보도록 하겠습니다.

AOP 도식화

우리의 예제에서의 AOP 적용을 도식화해보았습니다.

 

AOP를 적용하려면 디자인 패턴 중 Proxy 패턴에 대해서 알아야 합니다. Proxy를 간단하게 설명하자면 Proxy는 대리, 대리인이라는 뜻으로 A라는 객체가 B라는 객체에게 접근할 때, A는 B에게 직접 접근하는 대신에 ProxyB라는 객체(대리인, 가짜 객체)를 통해서 접근 가능하도록 하는 패턴입니다. 

Proxy 패턴 간단한 그림

다형성을 이용하면 A는 ProxyB인지 인지하지 못하도록 하여 B에게 명령할 수 있습니다. Proxy 패턴이 핵심이 아니기 때문에 Proxy 패턴에 대해서 자세하게 설명하지 않겠습니다.(궁금하신 분들은 구글링 GO~!)

 

이제 직접 Proxy를 적용시켜보도록 하겠습니다. Proxy는 상속과 인터페이스로 구현할 수 있는데, 저는 인터페이스로 구현해보도록 하겠습니다.

 

1. 입력 요구사항 Exception Handling

LottoViewalbe 인터페이스

먼저 LottoViewable 인터페이스를 정의했습니다.

 

ProxyLottoInputView 클래스

LottoInputView의 프록시 클래스인 ProxyLottoInputView 클래스를 만들었습니다. 프록시에서는 공통 관심 사항인 예외 핸들링을 담당하고 있습니다.

 

LottoInputView 클래스

LottoInputView 클래스는 본인의 프록시 객체를 반환하도록 합니다.

 

LottoController 내 메서드

이제 LottonController에서 ExceptionHandler 클래스의 의존성을 제거하여 정상 로직만 남게 되면서, 예외 핸들링까지 처리할  수 있게 됐습니다.

 

이제 비지니스 요구사항의 핸들링도 비슷하게 처리해주면 되는데요. 하지만 눈치가 빠르신 분들은 느꼈을 수도 있습니다. 예외를 아주 깔끔하게 처리했는데, 뭔가 배보다 배꼽이 더 크다는 느낌이 들지 않으신가요? 각 클래스마다 프록시 클래스를 정의해줘야하고 관리해야하는 문제가 있습니다. (따라서 비지니스 예외 핸들링은 넘어가도록 하겠습니다.)

 

개선한 점

  1. AOP를 적용하여 예외 핸들링을 한 곳에서 깔끔하고, 강력하게 처리했습니다.(정상 비지니스 로직에 ExceptionHandler의 의존성을 제거하여, 비지니스 로직은 비지니스 로직만, 예외는 한 곳에서 처리했습니다.)

 

아쉬운 점

  1. AOP를 적용하기 위해 프록시 클래스를 일일이 정의해줘야 합니다.(배보다 배꼽이 더 큰 경우가 발생)

 

해당 아쉬운 점을 개선해보도록 하겠습니다.

 

 

7. Exception Handling ver.3

앞선 문제를 위해서 자바에서는 다이나믹 프록시 Api를 제공합니다. 즉, Proxy 클래스를 미리 일일이 정의해놓을 필요가 없이 런타임 시점에(필요한 시점) 프록시 객체를 생성하는 기술을 자바 reflection을 통해서 제공합니다. (저도 이번 문제를 해결하면서 reflection을 처음 써보는데요, 궁금하신 분들은 구글링 GO GO~!)

 

자바 Proxy Api에 대한 설명은 잘 정리되어있는 링크를 첨부하도록 하겠습니다.

 

이제 예제에 적용해보도록 하겠습니다.

 

1. 입력 요구사항 Exception Handling

LottoViewalbe 인터페이스

인터페이스는 앞선 예제에서 직접 Proxy를 만들기 위해 사용했던 인터페이스를 그대로 사용하겠습니다.

 

LottoInputView 클래스
InputExceptionHandler 클래스

이제 Proxy 클래스를 직접 정의하는 것이 아닌 자바 다이나믹 Proxy Api를 이용하여 런타임 시점에 동적으로 프록시 객체를 생성할 수 있습니다. 프록시 객체를 생성할 때 InvocationHandler를 인자로 받고 있는데요. 해당 인터페이스를 implements 하여 invoke 메서드 내에서 입력 요구사항 예외 발생 시 핸들링 처리를 해주었습니다.

 

입력 예외를 핸들링 하는 핸들러 이므로 예외 메시지를 출력하고 재입력을 받을 수 있도록 해주었습니다.

(예외가 발생하면 InvocationTargetException 예외로 래핑 하네요!)

 

 

2. 비지니스 요구사항 Exception Handling

JDK 다이나믹 Proxy는 인터페이스 기반으로 동작하므로 LottoController 인터페이스 내 메서드를 정의했습니다.

 

Controller 동적 Proxy 생성 부분
MethodExceptionHandler 클래스

MethodExceptionHandler에는 비지니스 요구 사항의 예외가 발생했을 때 invoke 메서드에서 핸들링을 해주었습니다.(지금 보니 BusinessExceptionHandler라는 이름이 더 어울리지 않나 싶네요..)

 

앞서 ExceptionHandling - ver.1에서 언급했던 로또 구입 기능 시 예외가 발생하면 다시 기능을 수행하는 것이 아니라 로또 삭제 기능을 바로 실행하도록 예외 핸들링 요구사항 변경이 기억나시나요? 

 

즉, 비지니스 예외 발생 시 해당 기능들을 다시 수행하지만, 해당 비지니스 예외(로또의 개수가 10개일때 구입 시 예외 발생)가 발생했을 때는 로또 삭제기능을 실행 시켜주어야 사용자가 무한 루프에 빠지지 않는데요!

 

 

사용자 정의 예외(IllegalStateLottoCountException)를 만들어서 처리했습니다.(위의 사진의 파란색 박스 참고.)

(직접 테스트를 위해 요구사항을 10개 -> 2개로 변경)

 

정리하자면, 나머지 비지니스 예외는 해당 기능을 재실행 하지만, 위의 예외는 로또 삭제 기능을 수행하도록 핸들링해주었습니다.

 

앞서 ExceptionHandling - ver.1에서는 핸들링 요구 사항이 변경되었을 때 ExceptionHandler 클래스의 의존성이 곳곳에 퍼져있어서 핵심 로직(Controller 로직)에도 변경사항이 생겼었는데요, 이제는 handler만 처리해주면 되는 구조가 되었습니다. Open - Closed Principal을 지켰다고 볼 수 있네요!

 

실행 결과(간단하게 직접 테스트를 위해 요구사항을 10개 -> 2개로 변경)

 

개선한 점

  1. 개요에서 제가 목표로 언급했 던, InputView는 입력 요구사항 검증만, Domain레이어는 비지니스 요구사항 검증만 하면서 예외 처리 조건인 재입력과 프로그램의 흐름을 유지까지 가능하게 하면서 예외 핸들링을 한 곳에서 그리고 클린 하고, 유연하게 할 수 없을까?를 만족한 것 같습니다.

 

아쉬운 점

  1. 핸들러에서 다른 추가 요구사항이 있을 때 if문을 사용하여 처리해주었습니다. 하지만 LottonController 내 메서드 4개의 핸들링 요구사항이 각각 다르다면, 그리고 사용자 정의 예외를 사용할 경우 유연하게 대처할 수 없습니다. 유연하게 대처하려면 인터페이스를 쪼개야 하는데 그러면 너무 많은 인터페이스가 필요로 합니다.
  2. 즉 인터페이스가 필요한 점이 문제인데요, CGLib는 JDK Dynamic Proxy와는 다르게 인터페이스가 아닌 클래스 기반으로 바이트코드를 조작하여 프록시를 생성하는 방식이 있다고 합니다.(https://www.baeldung.com/spring-aop-vs-aspectj)

 

해당 아쉬운 점을 어노테이션을 적용하면 예외 타입별로 더 유연하고, 확장성 있게 개선할 수 있다고 생각했는데요.(이건 제 생각이고, 시도해보지 않아서 틀릴 수도 있습니다. 하지만 많은 라이브러리들이 어노테이션으로 기능을 편하게 제공해주므로 맞을 겁니다!)

 

저는 목표를 달성했으니, 개선을 여기서 멈추도록 하겠습니다.

 

하지만 자바에서는 AOP를 위한 @AspectJ 라이브러리를 제공해주고 있고, 이를 사용한다면 위에서 언급했던 아쉬운 점을 더 유연하게 처리할 수 있겠네요. 잘 정리되어 있는 글 첨부합니다!

https://jiwondev.tistory.com/152

 

자바 AOP의 모든 것(Spring AOP & AspectJ)

이 글은 사전지식이 없다면 읽기 어려울 수 있다. 바이트코드와 리플렉션을 모른다면 아래의 글을 꼭 읽어보도록하자. 2021.08.17 - [기본 지식/Java 기본지식] - 바이트코드 조작(리플렉션, 다이나믹

jiwondev.tistory.com

 

+ 추가로 저는 자바 프로그램의 재입력과 프로그램 흐름 유지를 위해 즉, 주어진 문제를 해결하기 위해 예외 처리를 AOP로 해결했지만, Spring에서의 예외 처리 전략은 위와 같은 방법으로 구성되어 있지 않습니다.

 

 

8. 마무리

프리코스를 하다가 예외 처리의 중요성에 대해서 깨닫고, 주어진 상황에서 효과적인 예외 처리에 대해 고민하고, 개선해보면서 많은 것들을 공부할 수 있었습니다. 이번 경험을 통해 자바 reflection, Proxy 디자인 패턴, JDK dynamic Proxy, CGLib Proxy, AOP 등 많은 것들을 알게 되었는데요. 물론 각각 깊이 있게 학습하지는 못하였지만, 저에게 주어진 문제를 해결할 정도의 학습을 하였습니다. (당장은 깊이있게 학습하지 못하더라도, 나중에 문제를 해결 혹은 공부하기 위해 이러한 것들이 있다는 것을 알게 되고, 필요한 시점에 깊이있게 학습을 하면 될 것 같습니다.)

 

공부할수록, 디자인 패턴의 중요성에 대해서 깨닫게 되는데요. 저는 디자인 패턴을 공부했었지만, 마주칠 때마다 어려워해서 공부를 잘못해왔다고 생각합니다.(물론 안 한 것보다는 낫긴 합니다.) 그 대신 저는 가장 강력한 디자인 패턴 학습법을 알게 되었습니다. 우테코에 합격하게 된다면 해당 경험을 통해 디자인 패턴 스터디 모임을 리딩하고 싶다는 생각이 듭니다.

 

마지막으로 열심히 정리한 만큼 많은 사람들에게 도움이 되었으면 좋겠습니다!😊 

 

9. Github Repository Link

https://github.com/LimHeeSang/java_exception_handling

공유하기

facebook twitter kakaoTalk kakaostory naver band