우아하게 예외 처리하기
클린코드를 다루는 책에서 오류처리를 왜 논하는가?
오류처리는 프로그램에 반드시 필요한 요소 중 하나일 뿐이다.
클린코드와 오류처리는 확실히 연관성이 있다.
7장에서는 깨끗하고 튼튼한 코드에 한걸음 더 다가가는 단계로 우아하고 고상하게 오류를 처리하는 방법을 다룬다.
예외 처리
오류 코드보다 예외(Exception)를 사용하라
요즘은 예외를 던지는 것이 당연한 시대이지만, 과거에는 오류코드를 만들어서 던지는 방법을 사용했었다.
예외를 던지는 방법이 훨씬 명확하고, 처리 흐름이 깔끔해진다
예외를 던지고, 처리하는 방식
public class DeviceController {
public void sendShutDown(){
try{
tryToShutDown();
}catch(DeviceShutDownError e){
// ---(3)---
logger.log(e);
}
}
// ---(2)---
private void tryToShutDown() throws DeviceShutDownError{
Devicehandle handle = getHandle(DEV1);
DeviceRecord record = retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
private DeviceHandle getHandle(DeviceID id){
//...
// ---(1)---
throw new DeviceShutDownError("Invalid handle for : " + id.toString());
//...
}
}
(1) 오류가 발생한 부분에서 예외를 던진다. (별도의 처리가 필요한 예외라면 checked 예외로 던진다.)
(2) checked Exception에 대한 예외처리를 하지 않는다면 메서드 선언부에 throws를 명시한다.
(3) 예외를 처리할 수 있는 곳에서 catch로 예외를 처리한다.
Try - Catch - Finally 문부터 작성하라
try - catch - finally 문에서 try 블록의 코드를 실행하면, 언제든지 실행이 중단될 때, catch블록으로 넘어갈 수 있다.
어떤 면에서 try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 따라서 예외가 발생할 코드를 짤 때는 try - catch - finally문으로 시작하는 편이 낫다.
그렇게 하면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.
unchecked 예외를 사용하라
Java Exception
Checked Exception
Exception을 상속하면 Checked Exception이다.
Checked Exception은 명시적인 예외처리가 필요하다.
ex) IOException, SQLException 등
Unchecked Exception
Runtime Exception을 상속하면 Unchecked Exception이다.
Unchecked Exception은 명시적인 예외처리가 필요하지 않다.
ex) NullPointerException, IllegalArgumentException, IndexOutOfBoundException 등
직접 구현하는 예외
개발자가 직접 구현하는 Exception은 모두 Runtime Exception의 하위 클래스여야 한다.
Checked Exception을 사용하면 안 되는 이유
위의 [예외를 던지고, 처리하는 방식]의 예제를 보자.
(1)의 메서드에서 checked Exception을 throw 하고, 상위 메서드인 (2)를 타고 올라가
결국 (3)에서 예외가 처리되고 있다.
하위 단계에서 최상위에 예외를 처리하는 메서드의 사이의 모든 메서드에서 해당 예외를 정의해야 한다.
여기서는 고작 세 단계 위의 메서드에서 처리하고 있지만, 그 수가 훨씬 커질 수도 있다.
또한, 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 모두 고쳐야 한다는 말이다.
즉, OCP(개방 폐쇄 원칙)를 위배한다.
상위 레벨 메서드에서 하위 레벨 메서드의 디테일을 알아야 하기 때문에 OCP를 위배하는 것이다.
꼭 필요한 경우 checked exception을 사용해야 하지만, 일반적으로 득 보다 실이 많다.
Unchecked Exception을 이용해라.
Exception을 잘 사용하는 방법
예외에 의미를 제공하라
예외를 던질 때는 전후 상황을 충분히 덧붙인다. 그렇게 하면 오류가 발생한 원인과 위치를 찾기 쉬워진다.
오류 메시지에 정보를 담아 예외와 함께 던지고, 실패한 연산 이름과 실패 유형도 언급한다.
logging을 사용한다면 catch 블록에서 오류를 기록하도록 충분한 정보를 넘겨준다.
호출자를 고려해 예외 클래스를 정의하라.
애플리케이션에서 오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야 한다.
아래는 오류를 형편없이 분류한 사례다.
ACMEPort port = new ACMEPort(12);
try{
port.open();
}catch(DeviceResponseException e){
reportPortError(e);
logger.log("Device response exception", e);
}catch(ATM1212UnlockedException e){
reportPortError(e);
logger.log("Unlocked exception", e);
}catch(GMXError e){
reportPortError(e);
logger.log("Device response exception", e);
}finally{
// ...
}
이 코드는 로그를 찍을 뿐, 할 수 있는 일이 없다.
따라서 예외를 감싸는 클래스를 만들어서 개선하자.
exception wrapper
LocalPort port = new LocalPort(12);
try{
port.open();
}catch(PortDeviceFailure e){
reportError(e);
logger.log(e.getMessage(), e);
}finally{
// ...
}
위의 LocalPort 클래스는 단순히 ACMEPort 클래스가 던지는 예외를 잡아 변환하는 wrapper 클래스 일 뿐이다.
위의 반복해서 예외 처리하는 코드를 wrapper 클래스 LocalPort를 이용해서 개선해 보자.
port.open() 시 발생하는 checked exception들을 감싸도록 port를 가지는 LocalPort 클래스를 만들고,
여러 예외들을 하나의 PortDeviceFailure Exception으로 감싸서 던진다.
public class LocalPort{
private ACMEPort innerPort;
public LocalPort(int portNumber){
this.innerPort = new ACMEPort(portNumber);
}
public void open(){
try{
port.open();
}catch(DeviceResponseException e){
throw new PortDeviceFailure(e);
}catch(ATM1212UnlockedException e){
throw new PortDeviceFailure(e);
}catch(GMXError e){
throw new PortDeviceFailure(e);
}
}
// ...
}
정상 흐름을 정의하라 - 예외 처리 패턴
좋은 코드는 null을 잘 다뤄야 한다.
null을 반환하거나, 인수로 null을 받으면 안 된다.
getOrElse - 예외 대신 기본값을 반환해라.
null이 아닌 데이터타입의 기본 값을 리턴해라.
아래와 같이 null을 반환한다면 이후 코드에 모두 null 체크가 필요하다.
List<Employee> employees = getEmployees();
if(employees != null){
for(Employee e : employees){
totalPay += e.getPay();
}
}
위의 코드를 아래와 같이 바꿔서 getEmployees()가 빈 list를 반환하게 한다면 null 처리가 필요 없게 된다.
List<Employee> employees = getEmployees();
for(Employee e : employees){
totalPay += e.getPay();
}
//수정된 getEmployees()
public List<Employee> getEmployees(){
if(/*employee가 없을 때*/){
return Collections.emptyList();
}
}
위와 같이 null을 리턴하는 것보다 size = 0인 리스트를 리턴하는 것이 훨씬 안전하다.
이렇게 하면 NullPointerException이 발생하지 않는다.
getOrElse - 기본 값이 없는 경우
기본값이 존재하지 않는 도메인의 경우 메서드 자체에서 도메인에 맞는 기본값을 반환할 수 있다.
아래 코드는 호출부에서 예외 처리를 통해 userLevel을 처리한다.
이때 예외처리를 하며 논리적인 흐름이 끊긴다.
try{
User user = userRepository.findByUserId(userId);
userLevel = user.getUserLevel(userId);
}catch(UserNotFoundException e){
userLevel = UserLevel.BASIC;
}
위의 코드를 아래와 같이 getUserLevel()을 getUserLevelOrDefault()로 바꿔주면,
default 값이 기본값의 역할을 하여 당장 호출부에서 예외처리를 하지 않아도 된다.
//호출부
userLevel = userService.getUserLevelOrDefault(userId);
//데이터 제공
public class UserService{
public UserLevel getUserLevelOrDefault(Long userId){
try{
User user = userRepository.findByUserId(userId);
return user.getUserLevel();
}catch(UserNotFoundException e){
return UserLevel.BASIC;
}
}
}
데이터를 제공하는 UserService 측에서 예외를 처리하여 호출부의 코드가 심플해진다.
호출부에서 예외처리를 하지 않아도 돼서 코드를 읽어나갈 때 논리적인 흐름이 끊기지 않는다.
getOrElseThrow - null 대신 예외를 던진다.
데이터 타입에 기본값이 없고, 도메인에도 기본 값이 없을 경우 null 대신에 예외를 던져라.
이렇게 하면 위와 마찬가지로 호출부에서 예외처리를 할 필요가 없다.
//호출부
User user = userService.getUserOrElseThrow(userId);
//데이터 제공
public class UserService{
public User getUserOrElseThrow(Long userId){
User user = userRepository.findByUserId(userId);
if(user == null){
throw new IllegalArgumentException("User is not found. userId = " + userId);
}
return user;
}
}
결론
깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다. 이 둘은 상충하는 목표가 아니다.
오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려하면 튼튼하고 깨끗한 코드를 작성할 수 있다.
오류처리를 프로그램 논리와 분리하면 독립적인 추론이 가능해지며 코드의 유지보수성도 크게 높아진다.
'기타 > Book Review' 카테고리의 다른 글
[Effective Java] Item 15-17 : 클래스의 접근 권한 설정과 불변 클래스 (1) | 2024.03.19 |
---|---|
[Effective Java] Item1 : 생성자 vs 정적 팩터리 메서드 (0) | 2024.03.14 |
[CleanCode] 클린코드 리뷰_6장 : 객체와 자료구조 (0) | 2023.06.21 |
[Clean Code] 클린코드 리뷰_ 5장 : 형식 맞추기 (1) | 2023.06.17 |
[CleanCode] 클린코드 리뷰_ 4장 : 주석 (0) | 2023.06.16 |