본문 바로가기
Software Tech/Spring (feat.JAVA)

[클린코드] 3. 예외처리

by SuperDev 2025. 5. 19.

1. 예외 처리의 좋은 예를 먼저 살펴보자.

public class DeviceController {
    
    public void sendShutDown() {
    	try {
        	tryToShutDown();
        } catch (DeviceShutDownError e) {
        	logger.log(e);
        }
    }
    
    private void tryToShutDown() throws DeviceShutDownError {
    	DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);
        
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
    }
    
    private DeviceHandle getHandle(DeviceId id) {
    	throw new DeviceShutDownError("Invalid handle for: " + id.toString());
    }
}

 

1) 예외를 상위로 전파

  • tryToShutDown() 에서 예외를 던지고, sendShutDown() 에서 처리합니다.
  • 예외가 발생한 위치와 처리 위치가 분리되어 있어, 코드의 책임이 명확합니다.

2) 로깅 처리

  • 예외가 발생했을 때, 로그로 남기고 있습니다.
  • 예외 상황을 추적할 수 있습니다.

 

2. Exception

 

1) Exception을 상속하면 checked Exception.

  • 명시적인 예외 처리가 필요하다.
  • 예 : IOException, SQLException

2) RuntimeException을 상속하면 Unchecked Exception

  • 명시적인 예외 처리가 필요하지 않습니다.
  • 예 : NullPointException, IllegalArgumentException, IndexOutOfBoundException

 

3. Checked Exception은 쓰지 말자

1) 나쁜 예시

// 상위 메서드에서 finally catch
void highLevel() {
    try {
    	middleLevel();
    } catch (IOException e) {
    	System.out.println("처리 불가: " + e.getMessage());
    }
}

// 중단 단계 메서드도 throws 선언 필요
void middleLevel() throws IOException {
	lowLevel();
}

// 하위 메서드에서 checked exception을 throw
void lowLevel() throws IOException {
	throw new IOException("Disk error");
}
  • 위 코드를 보면 IOException이 lowLevel에서 발생하지만, middleLevel도 throw를 써야 합니다. 그런데 highLevel에서만 실제로 처리됩니다.
  • 만약 lowLevel의 예외 타입이 변경되면 middleLevel, highLevel 모두 변경을 유발할 수 있습니다.
  • 코드의 유연성과 유지보수성이 떨어집니다.

 

2) 예외 변환(Wrapper) 패턴

void highLevel() {
    try {
    	middleLevel();
    } catch (RuntimeException e) {
    	System.out.println("처리 불가: " + e.getMessage());
    }
}

// checked exception을 unchecked로 변환
void middleLevel() {
    try {
    	lowLevel();
    } catch (IOException e) {
    	throw new RuntimeException("저수준 오류", e);
    }
}

void lowLevel() throws IOException {
	throw new IOException("Disk error");
}
  • 중간 단계에서 예외를 unchecked로 변환하여, 상위 메서드가 하위 예외의 세부사항에 의존하지 않게 합니다.
  • 예외가 의미 있는 수준에서만 처리되며, 시그니처 오염이 없습니다.

 

3) 결론

  • checked exception은 복구 가능한 상황에서만 제한적으로 사용합니다.
  • 불필요한 경우에는 uncheckd exception을 사용하거나 예외 변환 패턴을 활용하는 것이 더 좋은 설계 입니다.
  • 이렇게 하면 상위 코드가 하위 세부 구현에 의존하지 않으므로 OCP 위배 가능성을 줄일 수 있습니다.

 

4. 예외 변환(Wrapper) 다른 케이스

1) 기존 코드의 문제점

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("Unlock exception", e);
} catch (GMXError e) {
  reportPortError(e);
  logger.log("Device response exception", e);
}
  • 예외가 여러 개로 분리되어 있어, 포트 열기와 관련된 모든 예외를 각각 처리해야 합니다.
  • 새로운 예외가 생기면 try-catch 블록에 또 추가해야 해서 코드가 계속 길어집니다.
  • 비슷한 예외 처리 코드가 반복되어 중복이 많아집니다.
  • 상위 코드가 하위(ACMEPort)에서 발생하는 구체적인 예외를 모두 알아야 하므로, 결합도가 높아집니다. (유지보수 어려움)

 

2) 개선된 코드

LocalPort port = new LocalPort(12);

try {
  port.open();
} catch (PortDeviceFailure e) {
  reportError(e);
  logger.log(e.getMessage(), e);
} finally {
  ...
}

public class LocalPort {
    private ACMEPort innerPort;
 
    public LocalPort(int portNumber) {
        innerPort = new ACMEPort(portNumber);
    }
 
    public void open() {
        try {
            innerPort.open();
        } catch (DeviceResponseException | ATM1212UnlockedException | GMXError e) {
        	// 예외 추상화
            throw new PortDeviceFailure(e);
        }
    }
    // ..
}
  • 예외 추상화 : LocalPort에서 하위 예외(DeviceResponseException, ATM1212UnlockedException, GMXError)를 하나의 상위 예외 PortDeviceFailure로 감쌉니다.
  • 중복 제거 및 코드 간결화 : catch 블록이 하나로 줄어들었고, 새로운 하위 예외가 추가되어도 LocalPort 내부에서만 처리하면 되니, 상위 코드에는 영향이 없습니다.
  • 결합도 감소, 유지보수성 향상 : 상위 코드가 하위 라이브러리(ACMEPort)의 세부 변경에 영향을 받지 않고, 예외 처리 정책을 LocalPort 클래스 내부에서 통일적으로 관리할 수 있습니다.
  • OCP 준수 : 하위 라이브러리 예외가 바뀌어도, LocalPort만 수정하면 되고, 상위 코드는 그대로 둘 수 있습니다. 즉, 확장에는 열려 있고, 변경에는 닫혀 있는 구조가 됩니다.

 

5. 실무 예외 처리 패턴

1) getOrElse

  • 예외 대신 기본값을 리턴합니다. null이 아닌 기본값, 도메인에 맞는 기본 값
  • null을 리턴한다면 이후 코드에서도 모두 null 체크가 있어야 하므로 디버깅이 어렵습니다.
  • null 보다 size가 0인 컬렉션을 사용하는 것이 안전하고, 빈 컬렉션이 for문에 들어가면 안전하게 종료됩니다.

2) getOrElseThrow

  • 기본 값이 없다면 null 대신 예외를 던집니다.
  • 데이터를 제공하는 쪽에서 null 체크를 하여, 데이터가 없을 경우엔 예외를 던집니다. 그러면 호출부에서 매번 null 체크를 할 필요 없이 안전하게 데이터를 사용할 수 있습니다.
  • null을 리턴하는 것도 나쁘지만 null을 메서드로 넘기는 것은 더 나쁘다. null을 메서드의 파라미터로 넣어야 하는 API를 사용하는 경우가 아니면 null을 메서드로 넘기지 않아야 합니다.

2-1) 예제 1

// 좋은 예 - throw new ~ 사용
// null이 들어오면 unchecked exception을 발생시킵니다.
public class MetricsCalculator {
    public double xProjection(Point p1, Point p2) {
    	if (p1 == null || p2 == null) {
        	throw InvalidArgumentException("Invalid argument for MetricsCalculator.xProjection");
        }
        return (p2.x - p1.x) * 1.5;
    }
}

public class MetricsCalculator {
    public double xProjection(Point p1, Point p2) {
    	assert p1 != null : "p1 should not be null";
    	assert p2 != null : "p2 should not be null";
    	return (p2.x - p1.x) * 1.5;
    }
}
  • 실무에서는 assert보다 예외를 던지는 방법을 훨씬 많이 사용합니다.

2-2) 예제 2

//호출부
if (request.getUserName() == null) {
  throw new MyProjectException(ErrorCode.INVALID_REQUEST, "userName is null");
}

public class MyProjectException extends RuntimeException {
  private MyErrorCode errorCode;
  private String errorMessage;
  
  public MyProjectException(MyErrorCode errorCode) {
    //
  }
  
  public MyProjectException(MyErrorCode errorCode, String errorMessage) {
    //
  }
}

// enum에 메세지를 넣으면 공통적으로 사용하는 표준 메세지를 관리할 수 있습니다.
// 개발자가 실수로 메세지를 빼먹거나, 표현이 다르게 들어가도 표준 메세지 확인이 가능합니다.
// 여러 곳에서 같은 에러코드를 사용할 때, 매번 메세지를 입력하면 코드 중복이 발생합니다.
// enum에 메시지를 두면 나중에 다국어 메세지로 확장하기 좋습니다.
public enum MyErrorCode {
  private String defaultErrorMessage;
  
  INVALID_REQUEST("잘못된 요청입니다."),
  DUPLICATED_REQUEST("기존 요청과 중복되어 처리할 수 없습니다."),
  // ...
  INTERNAL_SERVER_ERROR("처리 중 에러가 발생했습니다.");
}
  • 에러 로그에서 stacktrace 해봤을 때, 우리가 발생시킨 예외라는 것을 바로 인지할 수 있습니다.
  • 다른 라이브러리에서 발생한 에러와 섞이지 않습니다. IllegalArgumentException을 던지는 것보다 우리 예외로 던지는게 어느 부분에서 에러가 났는지 파악하기가 용이합니다.

 

[참고]

  • enum에 메세지를 넣으면 공통적으로 사용하는 표준 메세지를 관리할 수 있습니다.
  • 개발자가 실수로 메세지를 빼먹거나, 표현이 다르게 들어가도 표준 메세지 확인이 가능합니다.
  • 여러 곳에서 같은 에러코드를 사용할 때, 매번 메세지를 입력하면 코드 중복이 발생합니다.
  • enum에 메시지를 두면 나중에 다국어 메세지로 확장하기 좋습니다.
// 기본 메시지 사용
throw new MyProjectException(ErrorCode.INVALID_REQUEST);

// 상세 메시지 추가
throw new MyProjectException(ErrorCode.INVALID_REQUEST, "userName is null");
  • 이렇게 하면, 기본 메시지 + 상세 메시지 조합도 가능합니다.
728x90