모든 내용은 정답에 대한 내용이 아닙니다
개인적인 생각들이 추가 되어있으며,책의 내용에 대한 의문도 적어두었습니다
Null 반환 대신 예외던지기
null 반환 메소드 클라이언트에서는 “null 방어 코드" 를 작성해야만 한다.
- 반환 결과를 확인하는 코드를 작성해야만 한다 → 객체지향세계에서 객체는 “(자신만의 행동, 상태, 생명주기 를 가지는 )자율적인 객체" 이고, 우리는 이 “객체를 신뢰”하며 사용해야 한다. 그런데 객체가 반환하는 결과를 확인해야만 한다면 “신뢰 할 수 없는 객체" 가 되는 것이나 다름없다.
- 객체는 자신의 행동을 책임져야 한다. 심지어 내부적으로 예외를 발생시키는 것도 괜찮은데, 그 사실에 대해 알려줘야 한다.
null 을 반환하는 객체를 신뢰할 수가 없으니 우리는 반환된 결과를 다시 확인해야만하게 되고, 이거를 까먹기라도 한 날이면 “어디선가..!!” NPE 가 발생한다.
뿐만 아니라, 신뢰하지 못한 결과를 확인하는 장황한 코드들이 더해지며 유지보수성이 떨어진다( 반환결과를 확인하는 코드들이 비즈니스 로직보다 많아질 수도 있겠죠 ).
따라서 책에서는 null 을 던지느니 “예외를 던져라" 라고 하고 있다.
( 이에 대해 동의하시는지??? 저는 어느정도 동의하긴 함.. 코드가 깔끔해지긴 하는 것 같음.. 그런데 내가 발생시킨 예외들에 대한 책임을 잘 져야 할 것 같다.)
158p 예시에서.. 예외처리 코드는 어디로 사라졌을까…
안전하게 실패하기, 빠르게 실패하기
런타임 에러, 컴파일타임 에러가 생각나네요. 컴파일 타임 에러는 빠르게 실패하는 것 만큼, 에러 상황을 빠르게 파악해서 대처할 수 있다는 장점이 있다고 생각하는데, 안전하게 실패하기와 빠르게 실패하기도 그와 비슷하지 않나 생각이 듭니다.
안전하게 실패하는 것은, 예외상황이 발생해도 어떻게든 어플리케이션이 종료되지 않고 계속 실행되도록 노력하는 거라고 합니다. 따라서 예외상황에서도 “예외"를 발생시키는 게 아니라,어떻게든 상황으로부터 구조하려고 하는거라고 하네요. 의미가 있나..? 잘 모르겠네요 . 예외를 발생시키지 않고 질질 끌게 되는 것 같네요. 예시로 “예외를 발생시키는 대신 NULL 을 반환하는 메소드” 를 들고 있습니다.
저 역시 책의 저자처럼 “빠르게 실패하기" 처럼 예외 상황이 발생하면 바로 예외를 던지는 게 좋다고 생각됩니다.
- 그런데 여기서 말하는 빠르게 실패하기가, 예외가 발생했을 때 , 아예 프로그램이 종료되도록 하는 것을 의미하는지 ?
- 스프링으로 개발하고 있다보니 (???이게 왜 스프링 때문이니…? → 서버는 죽으면 안되는거아냐? 그래서 우리는 예외가 발생해도 그에 대한 예외응답을 보내주는 거 아냐? ) 이 부분에 대해 좀 혼란스럽게 받아들여지네요
“예외가 발생하는 지점" 이 명확 하게 추적이 되고 문서화 되어있는 것이 중요할 것 같습니다.
Null 의 대안?
User getUser(Long id) 라는 메소드에서 “찾고 있는 객체가 존재하지 않는다" 는 상황을 알려주려면 어떻게 하는 것이 가장 좋을까요?
그냥 Null 을 반환하면 될까요?
책에서는 Null 을 반환하는 방식은 “안전하게 실패하기" 철학과 유사하기에 다른 방법들을 제시하고 있는데요.
- 분기 처리
- 먼저 “존재하는지 여부"를 확인 → 존재하는 경우에만 실제로 User 를 데리고 오기
- DB 에 User 정보가 저장되어있는 상황이라면 1. 먼저 DB 에 존재하는지 확인 쿼리 2.DB 에서 실제 User 를 가져오는 쿼리 ( 2번의 쿼리를 날려 비효율적이라 볼 수도 있겠습니다 )
- 컬렉션 반환
- 없다면 EmptyCollection 을 반환하면된다. 그러면 Null 을 확인하는게 아니라, 반환된 객체에게 “너 비어있니" 라고 물어볼 수 있는 메소드를 호출 하는 방식으로 “객체와의 소통"을 할 수 있게 된다.
- 사실 null 인지 확인하는 것이나, 비어있는지 확인하는 것이나 둘 다 “반환결과를 확인하는 코드" 가 들어간다는 것은 같다고 생각되어 그다지 좋은지 잘 모르겠습니다만, 객체를 통해 대화 한다는 것이 좀 더 객체지향 스럽다고는 볼 수 있지 않을까 생각했습니다.
(user == null) (collection.isEmpty())
- 없다면 EmptyCollection 을 반환하면된다. 그러면 Null 을 확인하는게 아니라, 반환된 객체에게 “너 비어있니" 라고 물어볼 수 있는 메소드를 호출 하는 방식으로 “객체와의 소통"을 할 수 있게 된다.
- Optional 사용
- 책에서는 Optional 이 OOP 와 대립한다고 얘기하네요. 그리고는 Null 참조와 다를바 없다고 사용하지 말라고 얘기하네요..❓❓❓
- 생각해보면 그런것 같기도….. 하면서 Optional 의 최대 장점은 null 과 달리, 하나의 객체로서 메소드 체이닝을 통해 어떤 분기처리도 가능하고, 다양한 처리가 가능하지 않나. if 문으로 null 에 대한 방어코드를 작성하지 않고 좀 더 깔끔한 방식으로 작성 가능하다는 생각이 듭니다.
- 책에서는 Optional 이 OOP 와 대립한다고 얘기하네요. 그리고는 Null 참조와 다를바 없다고 사용하지 말라고 얘기하네요..❓❓❓
- null 객체 디자인 패턴
- 원래의 객체 같이 생긴 null 객체를 리턴해주는 겁니다!!
- 얘의 특징은 “일부 행동은 정상적으로 처리" 하지만, 나머지 작업들은 처리하지 않는다는 것입니다. ( 아마 나머지 작업들에 대해서는 예외를 던지는 방식으로 행동할 수 있을 겁니다 )
대충 생각해보자면 아래와 같은 코드가 되겠네요
Optional<User> getUSer(Long id) {
..
return Optional.nullable(..);
}
User getUser(Long id) {
...
return EmptyUser();
}
getUser(id)
.ifPresentOrElse(
() -> user.getName(),
() -> NotFoundException::new
);
getUser(id).getName(); // 알아서 NotFoundException 을 던져주도록 EmptyUser 를 구현
null 객체를 구현하고 사용하는 것이 더 깔끔할 것 같기는 하지만, 그에 해당 하는 클래스를 매 번 정의 해 줘야 할 것 같네요.
범용적으로 사용하기에는 Optional 이 더 좋을 것 같습니다.
4.2 체크 예외만 던지세요(?)
뭔가 최근 경향과는 조금 다른 얘기를 하는 것 같습니다… 최근에는 Unchecked 예외를 선호하는 것으로 알고 있는데 책에서는 그러지 말라고 하네요 . 그러면서 커스텀 예외도 만들지 말라는 얘기를 하고 있습니다.
그런데 그 이유에 대해서는 뭔가 안나와 있는 것 같습니다..
체크 예외는, 문제 발생 상황에 대한 처리 책임을 호출자에게 넘기고, 이를 명시적으로 표시하고 있다는 점에 대해서는 얘기하고 있는데, 이것을 장점이라고 생각했기 때문일까요?? 저 역시 명시적인것을 좋아하지만, 체크 예외는 예외를 전파하는 과정에서 너무 많은 강제적인 코드들이 수반되기 때문에, 언체크 예외를 사용하고 이를 관리하는데 신경쓰는 것이 더 좋다고 생각됩니다.
4.2.1 꼭 필요한 경우가 아니면 예외를 잡지 마세요
오잉.. 체크 예외를 꼭 던지라면서, “상위로 예외를 전파하는 방식"을 선호하신다고 하네요.
- 여러 레벨에 걸쳐서 throws XXXException 이 항상 수반 될 것 같네요.
상위로 예외를 전파하라는 그 이유는 이해가 갑니다.
앞에서 말했던 “빠르게 실패하기, 안전하게 실패하기" 내용을 기억하시나요?
사실상 문제가 발생한 곳에서 문제를 해결해서 s/w 를 해결하겠다는 것은 “안전하게 실패하기"와 통하는 철학이라고 생각할 수 있다고 합니다.. 이 때 발생하는 문제점에는 공감합니다. 안전하게 실패하려는 코드를 짜다보면, 결국 실제 문제지점으로부터 멀리 떨어진 곳에서 시스템 종료가 되고, 그 이유인 지점을 찾는 디버깅이 어렵다는 것이죠.
하지만 저는 이렇게 생각합니다.
- 해결해야하는 문제라면, 문제가 발생한 곳과 최대한 가까운 곳에서 해결하기
- 그렇지 않다면, 빠르게 실패하기
그리고 이는 정책을 어떻게 잡느냐에 따라 너무나도 달라지는 문제라고 생각합니다.
예를들어, “좋아요"에 실패한다면, 이는 치명적인 문제일까요? 저는 고객에게까지 알려주지 않고, 서버 내부의 에러 로그로만 출력할 것 같습니다. 따라서 예외를 catch 하여 로그로 잡고, 고객에게는 이전 좋아요 상태를 넘겨줄 것 같아요.
try {
성공 로직
} catch {
실패로직
}
은 예외를 사용한 분기처리와도 같다고 말합니다. 예외는 “분기처리를 위한 것"이 아니라는 얘기를 합니다.
정상적인 흐름은 종료시키기 위해 설계되었다고…하네요? 복구 가능성은 언제 살펴본다는 거지.
- catch 를 하고 예외를 다시 throw 하지 않는 것에 대해서 별로 좋지 않게 보고 있는 것 같습니다.
- 그런 의미에서 catch 문에서 로깅만 하는 것 역시 좋지 않다고 보고 있네요.
예전에 저는 Null 대신, 제가 정한 어떤 값 (ex. 실패하면 -1을 리턴) 을 리턴하는 것으로 작성하면 되지 않나?? 라는 생각도 했었는데요, 사실상 이 경우 Null 을 리턴할 때와 코드가 같아지긴 합니다.
책 내용을 바탕으로 제 생각을 정리하자면,
- 어떤 문제가 발생했는지 파악하기 어려우며 ( -1 은 IllegalArgument, 1 은 NoSuchFile 이런식으로 우리끼리 약속을 할 수야 있겠죠.. 효용성이..아주 작다고 생각됩니다. 까먹으면 어떡하죠? 이 약속이 강제화 될 방법이 있나요? 예외를 발생시키는게 더 낫다는 생각이 듭니다 )
- 신뢰할 수 없기에 확인하는 로직이 필요하며, 확인을 까먹는 경우, 문제 있는 객체를 사용하여 더 큰, 원인 파악이 어려운 문제가 발생할 것입니다.
4.2.2 항상 예외를 체이닝 하세요
예외 체이닝은, “문제가 발생했다는 사실" 을 무시하지 않기 때문에 좋은 방법이며 이 때 근본 원인(cause) 를 무시해서는 안된다는 것에 대해 공감하며 읽었습니다.
근본 원인은 매우 가치 있는 저수준의 정보고 , 이것이 사라졌다면 예외의 근본 원인을 파악하는 것이 매우 어렵겠다고 생각이 듭니다.
- 저는 항상 근본 예외를 감싸야 한다고 생각하는데, 사실 이 근본 예외를 항상 사용하게 될것인지? 에 대해서는 의문스럽긴 합니다. 사용하지 않을 것이라면 애초에 감싸지 않아도 될지? 아니면 사용하지 않게 되더라도 무조건 감싸줘야할지.. 애초에 감싸주지 않는다면 정보를 아예 잃어버리는 것이 되기 때문에 감싸줘야 하는게 맞지 않나 생각이 드는데 .. 어렵네요
이렇게 예외를 감싸는 것은, 저 수준의 예외에다가 문맥을 덧붙여 문맥을 풍부하게 하기 위함이라고 하는데요, 책의 예시는 사실 공감하기 어렵습니다.
프로필 사진을 열 수 없는 상황에서 “이미지 내용을 읽을 수 없다" 는 예외나 “열린 파일이 너무 많습니다" 나 “파일의 길이를 계산할 수 없습니다" 나 뭐가 더 좋은 예외인지 저는 잘 모르겠습니다..
저는 “먼저 현재 상황에 가장 가까운 - 이미지 내용을 읽을 수 없습니다" 를 최상위 예외로 하여, 내부에는 “열린 파일이 너무 많습니다" 를 감싸 볼 것 같네요.
메서드에서 발생할 수 있는 모든 예외를 잡은 후, 예외를 체이닝 하여 다시 던지는 것이 예외처리의 최선의 방법이라고 얘기하고 있습니다.
4.2.3 단 한번만 복구하세요
“예외 후 복구" 하는 것은
위에서 봤던 예외를 사용한 분기 처리 같은 것이라고 말하고 있다.
catch 문에서 복구 로직을 작성하다보니, 분리처리 하는 것과 유사한 모습을 띠는 것 같기는 하다.
그렇다고, 무조건 예외를 잡지 말라는 것은 아니고, “높은 레벨에서 단 한 번"은 복구 해야한다고 얘기합니다.
Voucher 미션 때 처럼, 콘솔 어플리케이션을 작성하는 경우라면, 실행 로직을 가진 측에서 try - catch 를 주로 작성했던 기억이 있는데요. 그와 같이, catch 하여 문제가 발생했음을 알려주고(이왕이면 사용자 친화적인 에러 메시지를 출력해주면 좋겠죠?) rethrow 하지 않는 “예외 후 복구" 를 얘기합니다.
결국에는 가장 높은 레벨에서 예외를 잡아 처리 해주기 때문에 어플리케이션은 종료되지 않습니다.
전체적으로 다음과 같은 이야기를 합니다.
복구에 적합한 장소에서만 복구 하자 .
- 책에서는 특히 “최상위 수준" 에서만 오직 한 번만 복구하자!!!! 라는 얘기를 하고 있다.
- 이 지점들에서는, rethrow 를 해서는 안된다.
- 이 지점 외의 곳들에서는, 예외를 catch 해서는 안된다.
생각해보면, 교육에 들어와서 예외 처리 로직을 작성하는 연습을 하면서 , 저는 예외를 분기처리를 위한 것으로 자주 사용한 것 같습니다.
그러면서 Spring 은 ControllerAdvice 가 존재하다보니, 높은 곳에서 에러 처리하는 곳이 있으니 마음 놓고 Runtime 예외를 던져주기도 했는…데요…
마음 놓고 에러를 던지는 것은 그다지 좋은 것 같지 않지만,
책에서 말하는 것 처럼 , 스프링에서는 ControllerAdvice 라는 “높은 레벨에 위치한 지점에서, 일괄적으로 예외를 잡아서 처리하며, 여기서 말하는 복구 라는 것을 해 주고 있음" 을 생각 해 볼 수 있었습니다.
4.2.4 관점 지향 프로그래밍을 하세요
실패한 오퍼레이션을 재시도 하기 위해선
- 사용자에게 에러 출력하고, 다시 시도해달라고 요청하는 것
- 프로그램이 알아서 재시도 하는것
이 있는데, 프로그램이 알아서 재시도 하는게 더 좋다고 하네요.
그러면서, 프로그램이 알아서 재시도 하는 것은 결국 “ 예외를 잡아서 복구" 하는 과정이 필요하다고 합니다.
생각해보면 바우처 미션에서, 아래와 같은 코드를 작성하던 것 역시,
- 프로그램을 종료시키고 다시 사용자에게 실행시켜달라고 하는 것이 아니라
- 프로그램 실행 자체는 알아서 재시도 .. 한다는 의미에서 예외를 잡아서 복구한 것이 아닌가? 생각이 들었습니다
public void run() {
boolean onRunning = true;
while (onRunning) {
try {
outputView.showMenu();
MenuSelection selectedMenu = MenuSelection.from(
inputView.inputSelectedMenu()
);
switch (selectedMenu) {
case EXIT:
onRunning = false;
break;
case CREATE:
createVoucher();
break;
case LIST:
showAllVouchers();
break;
}
} catch (NoMappingOneException e) {
logger.info("INPUT {} --> {}", e.getInput(), e.getMessage(), e);
} catch (Exception e) {
logger.error("예기치 못한 오류로 프로그램을 종료합니다", e);
return;
}
}
logger.info("EXIT PROGRAM");
}
관점 지향 프로그래밍 → AOP
단 한번의 메소드 호출을 재시도하기 위해, 예외를 잡아서 복구하는 장황한 코드들이 추가되어있는 모습입니다.
이런 코드를 위에서 제거할 수 있으면 얼마나 좋을까요?
책에서는 “실패 재시도 코드" 를 AOP 를 사용하여, 기존 로직 에서 분리할 수 있다고 합니다. ( 하지만 실패 재시도 코드를 AOP 를 사용하여 .예외를 복구하는건 → 성급한 예외복구고 → 안좋음!!! 이라고 얘기하네요….. ! )
예를들어 아래와 같은 경우라면, 컴파일러는 @RetryOnFailure(attempts = 3) 어노테이션을 보고, content() 메소드를 “실패 재시도 코드로 “ 둘러싼다.
- 실패 지시도 코드 블록을 → aspect 라고 부른다.
- aspect 에서는, 제어를 위임받아, content() 메소드 호출에 관한 “시점, 방법" 을 결정합니다.
- 흐름을 제어하는 것이군요
- content 앞에 위치한 어댑터 같은 것이네요.
- 이를 사용하면, 핵심 클래스에서 “덜 중요한 기술" 과 “ 비즈니스적인 로직" 을 분리할 수 있고, “중복 로직을 제거" 할수도 있어요.
@RetryOnFailure(attempts = 3)
public String content() throws IOException {
return http();
}
4.2.5하나의 예외 타입만으로 충분하다
어차피 우리는 “높은 레벨에서만, 예외를 한 번만 잡을 것" 이고 이 때는 rethrow 를 하는게 아닐 것이다.
우리는 예외 체이닝을 통해서, 어떤 예외를 특정 예외로 감싸서, 특정 예외 타입으로 던지는게 가능하다.
- 책에서는, “높은 레벨에서 예외를 잡는 것" 외에 예외를 잡는 경우는 오직!! 다른 예외로 감싸서 rethrow 하는 경우여야 한다고 얘기 하고 있다
그렇다면…
높은레벨에서는 결국 하나의 예외타입으로만 잡아서 복구하는 로직을 두면 된다. 라는 결론을 내리고 있다.
- Spring 의 ControllerAdvice 를 생각하면.. 어느정도 공감이 되는 말 이라는 생각이 들었다.
- 물론 이 때도 타입들을 나누는 기준이 필요하다고 생각되는데
- 현재로서는 어떤 로그 레벨이 필요한가에 따라, 타입을 다르게 할 것 같다.
- 비즈니스 예외라면, 어느정도 예측된 에러들일 것이고
- Exception, RuntimeException 으로 잡힌 애들은 진짜 얘기치 못한 것들이라 ERROR 레벨이 될 것 같다.
- 이런식으로 타입을 나눠볼 것 같음.
final 이거나 abstract 이거나
상속을 왜 이제와서 얘기하지??
아무튼..
상속을 아예 사용하지 않는 것보다는, “올바르게 사용" 하는 것이 중요하다
아무래도 상속을 사용할 경우에는 “계층 관계" 가 형성되다보니, 객체들간의 관계가 복잡해지는 경향이 존재한다. (이펙티브자바에서도 비슷한 설명이 나온다. 물론 거기서는 기능의 확장을 위해 , 계층적인 구조로 되는 것이 안좋다는 얘기를 하지만 )
뿐만 아니라, 문제는 메소드에서 발생합니다.
메소드 오버라이딩은, “부모"에서 “자식"에 접근하는 것이나 다름없습니다.
리스코프 치환원칙에 따르면, 우리는 자식 인스턴스를 부모 타입으로 사용하면서도 “행동의 호환"이 됨을 기대합니다.
그런데 메소드 오버라이딩을 잘못 한다면 이것이 깨져버리는 것입니다. 이미 부모 클래스에서 “ 그 행동은 ~~~ 한 결과를 낼 거야 “ 라고 “정의를 했었다" 면, 하위 클래스에서 재정의한 행동 역시 그러한 결과를 내는 것이 당연합니다.
하지만 “상속" 자체로는 그렇게 하도록 제한을 두고 있지는 않습니다.. 따라서 잘못 재정의한 메소드들로 인해, 우리는 상속을 사용하면 OOP 가 깨진다..는 얘기를 하게 되는 것입니다.
따라서 책에서 제시한 방법은 아래와 같습니다.
클래스나 메소드를 final 또는 abstract 로 제한하자
- 파이널 클래스 → 상속이 불가능
- 파이널 메소드 → 재정의가 불가능
- abstract 클래스 → 부모에는 추상 메소드가 존재함 ( 부모에서 정의한 부분이 없음)
- abstract 메소드 → 부모에서는 해당 메소드에 대해 정의한 내용이 없음.
이는 결국, 클래스의 “행동을 확장" 하기 위한 상속이 아닌 “행동을 정제" 하기 위한 상속을 해야한다는 것입니다.
- 즉, 기존의 행동에, 새로운 행동을 추가하는 것이 아니라
- 이는 , 상위객체에 침입하는 것이 포함되기에, 캡슐화를 깨트리는 것이기도 합니다.
- 애초에 불완전하던 행동을 완전하게 만드는 때에! 사용해야 한다는 것 입니다.
- 따라서, 추상 클래스를 개선하는 방식이어야 합니다.
저는 예전에 상위클래스에는 “공통적인, 그리고 기본으로 사용되는 행동" 을 두고, 하위 클래스들에서는 그 행동을 확장하여, 자신들만의 행동을 넣어두는 것이 상속이라고 생각했었습니다.
하지만 다형성을 적극 활용하면서, 예상하지 못한 결과를 받기도 했는데, 이는 결국 하위클래스에서 새로운 기능을 추가하며 생기는 예상하지 못한 결과들이었습니다.
그래서 이번 챕터 내용이 상당히 인상 깊었습니다.
'책 > 엘레강트 오브젝트' 카테고리의 다른 글
[elegant] ch03 (정리중) (0) | 2022.06.23 |
---|---|
[elegant] ch02 - 2.8 (0) | 2022.06.15 |
[elegant]ch02_ ~ 2.6.4 (0) | 2022.06.09 |
[elegant] 1. Birth 와 관련 자료 발표 영상 (0) | 2022.06.05 |