클린 코드 읽기 7 - 오류 처리

toastedEevee·2024년 7월 5일
0

클린 코드 읽기

목록 보기
7/7
post-thumbnail

앞선 3장 함수 파트에서 오류 코드 대신 예외를 사용하고 try-catch 블록을 사용해서 원래의 코드와 오류 처리 코드를 분리해야 하는 이유에 대해 설명했다.

상당수 코드 기반은 전적으로 오류 처리 코드에 좌우된다.

좌우된다?

여기저기 흩어진 오류 처리 코드 때문에 실제로 코드가 하는 일을 파악하기가 거의 불가능하다는 의미다.

그렇다면 프로그램의 논리를 이해하는데에 방해되지 않도록, 우아하고 고상하게 오류를 처리하려면 어떻게 해야 할까?

Try-Catch-Finally 문부터 작성하라


어떤 면에서 try 블록은 트랜잭션과 비슷하다.

try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 그러므로 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 낫다.

그러면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.

[예시 코드]

다음은 파일이 없으면 예외를 던지는지 알아보는 단위 테스트이다.

@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
	sectionStore.retrieveSection("invalid - file");
}

단위 테스트에 맞춰, 잘못된 파일에 접근을 시도해서 예외를 던지도록 코드를 구현한다.

public List<RecordedGrip> retrieveSection(String sectionName) {
	try {
		FileInputStream stream = new FileInputStream(sectionName);
	} catch (Exception e) {
		throw new StorageException("retrieval error", e);
	}
	return new ArrayList<RecordedGrip>();
}

코드가 예외를 던지므로 테스트가 성공한다.

이 시점에서 리팩터링을 해보자. catch 블록에서 예외 유형을 좁혀 실제로 FileInputStream 생성자가 던지는 FileNotFoundException을 잡아낸다.

public List<RecordedGrip> retrieveSection(String sectionName) {
	try {
		FileInputStream stream = new FileInputStream(sectionName);
		stream.close();
	} catch (FileNotFoundException e) {
		throw new StorageException("retrieval error", e);
	}
	return new ArrayList<RecordedGrip>();
}

try-catch 구조로 범위를 정의했으므로 TDD를 사용해 필요한 나머지 논리를 FileInputStream을 생성하는 코드와 close 호출문 사이에 추가한다.

💡 이렇게 먼저 강제로 예외를 일으키는 테스트 케이스를 작성한 후에 테스트를 통과하게 코드를 작성하는 방법이 좋다.

그러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되므로 범위 내에서 트랜잭션 본질을 유지하기 쉬워진다.

확인되지 않은 예외를 사용하라

확인된 예외의 OCP(Open-Closed Principle) 위반


자바에서 예외는 크게 두 가지로 나눌 수 있다.

확인된 예외(Checked Exception)와 확인되지 않은 예외(Unchecked Exception)

확인된 예외는 컴파일 시점에 체크되는 예외로, 메서드 선언부에 명시적으로 선언되어야 한다.

💡 예외가 발생할 가능성이 있는 메서드는 ‘throws’ 키워드를 사용하여 해당 예외를 선언해야 하며, 이를 호출하는 메서드도 예외를 처리하거나 다시 던져야 한다.
public void readFile() throws IOException {
	// 파일 읽기 코드
}

예를 들어 위의 코드에서 ‘IOException’은 확인된 예외이다. ‘readFile’ 메서드를 호출하는 모든 메서드는 ‘IOException’을 처리해야 한다.

만약 메서드에서 확인된 예외를 던졌는데, catch 블록이 세 단계 아래에 있다고 해보자.

그 사이의 메서드 모두가 선언부에 해당 예외를 정의해야 한다.

import java.io.IOException;

public class CheckedExceptionExample {
    
    // 메서드 1: IOException을 던지는 메서드
    public void methodA() throws IOException {
        throw new IOException("Checked exception thrown");
    }
    
    // 메서드 2: methodA를 호출하는 메서드
    public void methodB() throws IOException {
        methodA();
    }
    
    // 메서드 3: methodB를 호출하는 메서드
    public void methodC() throws IOException {
        methodB();
    }
    
    // 메서드 4: methodC를 호출하고 예외를 처리하는 메서드
    public void methodD() {
        try {
            methodC();
        } catch (IOException e) {
            System.out.println("Exception caught: " + e.getMessage());
        }
    }
    
    public static void main(String[] args) {
        CheckedExceptionExample example = new CheckedExceptionExample();
        example.methodD();
    }
}

이 예제에서 IOException이라는 확인된 예외가 처음 methodA에서 던져진다. 그러나 IOException을 실제로 처리하는 코드는 methodD이다. 이때, methodD까지 예외가 전파되는 동안 methodA, methodB, methodC 모두가 선언부에 throws IOException을 추가해야 한다.

이러한 방식은 다음과 같은 문제를 야기한다.

  • 새로운 확인된 예외를 추가하거나 기존 예외를 변경할 때, 관련된 모든 메서드 선언부를 수정해야 한다.
  • 이는 코드의 유지보수성을 떨어뜨리고, 시스템을 확장하기 어렵게 만든다.

이러한 이유로 “확장에는 열려 있고, 수정에는 닫혀 있어야 한다.”는 OCP를 위반한다는 것이다.

💡 아래와 같이 확인되지 않은 예외(런타임 예외)를 사용한다면, 컴파일 시점에 체크되지 않기 때문에 메서드 선언부에 예외를 선언할 필요가 없어진다.
public void methodA() {
    // 예외 발생 시 RuntimeException 던지기
    throw new RuntimeException("An error occurred");
}

public void methodB() {
    methodA();
}

public void methodC() {
    methodB();
}

public void methodD() {
    try {
        methodC();
    } catch (RuntimeException e) {
        // 예외 처리 코드
    }
}

예외에 의미를 제공하라


오류가 발생한 원인과 위치를 찾기 쉽도록, 오류 메시지에 정보를 담아 예외와 함께 던진다.

실패한 연산 이름과 실패 유형도 언급한다.

호출자를 고려해 예외 클래스를 정의하라


호출자가 예외를 쉽게 이해하고 적절히 처리할 수 있도록 예외 클래스를 정의해야 한다.

  • 예외 클래스를 정의할 때는 그 예외를 던지는 코드가 아닌 예외를 처리할 호출자의 관점에서 생각해야 한다.
  • 의미 있는 예외 클래스
    • 예외 클래스의 이름과 내용이 예외 상황을 명확히 나타내야 한다.
    • 호출자가 예외를 보고 어떤 문제가 발생했는지 즉시 이해할 수 있어야 한다.
  • 이를 통해 코드의 가독성과 유지보수성이 높아진다.

[호출자의 관점을 고려하지 않은 예외]

public class DataAccess {
    public void getData() throws Exception {
        // 데이터베이스 연결 시도
        throw new Exception("데이터베이스 연결 실패");
    }
}

public class BusinessLogic {
    public void processData() {
        DataAccess dataAccess = new DataAccess();
        try {
            dataAccess.getData();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            // 예외 처리 로직
        }
    }
}

위의 코드의 예외 메시지만으로는 호출자인 ‘BusinessLogic’ 클래스가 정확한 예외 상황을 이해하기 어렵다.

[호출자의 관점을 고려한 예외]

// 사용자 정의 예외 클래스
public class DatabaseConnectionException extends Exception {
    public DatabaseConnectionException(String message) {
        super(message);
    }
}

public class DataAccess {
    public void getData() throws DatabaseConnectionException {
        // 데이터베이스 연결 시도
        throw new DatabaseConnectionException("Unable to connect to the database");
    }
}

public class BusinessLogic {
    public void processData() {
        DataAccess dataAccess = new DataAccess();
        try {
            dataAccess.getData();
        } catch (DatabaseConnectionException e) {
            System.out.println(e.getMessage());
            // 예외 처리 로직
        }
    }
}

외부 API 감싸기 기법

외부 라이브러리나 API를 직접 호출하는 대신, 해당 API를 호출하는 자체 래퍼 클래스를 만들어 사용하는 방법

  • 외부 API를 감싸면 외부 라이브러리와 프로그램 사이의 의존성이 크게 줄어든다.
  • 또한 래퍼 클래스에서 외부 API를 호출하는 대신 테스트 코드를 넣어주는 방법으로 프로그램을 테스트하기도 쉬워진다.

아래의 코드는 외부 라이브러리가 던질 예외를 모두 잡아낸다.

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");
} finally {
	...
}

LocalPort 클래스는 단순히 ACMEPort 클래스가 던지는 예외를 잡아 변환하는 래퍼 클래스이다.

public class LocalPort {
	private ACMEPort innerPort;
	
	public LocalPort(int portNumber) {
		innerPort = new ACMEPort(portNumber);
	}
	
	public void open() {
		try {
			innerPort.open();
		} catch (DeviceResponseException e) {
			throw new PortDeviceFailure(e);
		} catch (ATM1212UnlockedException e) {
			throw new PortDeviceFailure(e);
		} catch (GMXError e) {
			throw new PortDeviceFailure(e);
		}
	}
	...
}

호출하는 라이브러리 API를 감싸면서 예외 유형 하나를 반환하면 된다.

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

정상 흐름을 정의하라


오류 처리 지침을 충실히 따른다면 비즈니스 논리와 오류 처리가 잘 분리되어 깨끗하고 간결하게 보이기 시작할 것이다.

그런데 때로는 예외가 논리를 따라가기 어렵게 만드는 경우가 있다.

아래의 코드는 계좌 잔액을 조회하는 메서드이다. 계좌가 존재하지 않는 경우 예외를 던진다.

// 사용자 정의 예외 클래스
public class AccountNotFoundException extends Exception {
    public AccountNotFoundException(String message) {
        super(message);
    }
}

// 계좌 클래스
public class Account {
    private String accountNumber;
    private double balance;

    public Account(String accountNumber, double balance) {
        this.accountNumber = accountNumber;
        this.balance = balance;
    }

    public double getBalance() {
        return balance;
    }
}

// 계좌 서비스 클래스
public class AccountService {
    private Map<String, Account> accounts = new HashMap<>();

    public AccountService() {
        accounts.put("12345", new Account("12345", 1000.0));
    }

    public double getBalance(String accountNumber) throws AccountNotFoundException {
        Account account = accounts.get(accountNumber);
        if (account == null) {
            throw new AccountNotFoundException("Account not found: " + accountNumber);
        }
        return account.getBalance();
    }
}

// 사용 예제
public class Main {
    public static void main(String[] args) {
        AccountService service = new AccountService();
        try {
            System.out.println(service.getBalance("12345")); // 1000.0
            System.out.println(service.getBalance("67890")); // 예외 발생
        } catch (AccountNotFoundException e) {
            System.out.println(e.getMessage()); // "Account not found: 67890"
        }
    }
}

특수 사례 패턴(Special Case Pattern)

💡 예외 상황을 처리하기 위해 특수한 경우를 분리하여 일반적인 코드 흐름을 단순화하는 디자인 패턴.

이 패턴은 예외 상황을 예외로 처리하지 않고, 정상적인 흐름으로 처리되도록 설계함으로써 코드의 가독성과 유지보수성을 높이는 데 목적이 있다.

특수 사례 패턴은 특정 조건이나 상황에 대해 별도의 객체나 로직을 사용하여 예외 처리를 회피하는 방법이다.

이렇게 하면 코드가 더 깔끔하고, 예외 처리가 분산되지 않으며, 메인 로직을 더 쉽게 이해할 수 있다.

위의 예시 코드에 특수 사례 패턴을 적용해본다면?

// 계좌 인터페이스
public interface Account {
    double getBalance();
}

// 실제 계좌 클래스
public class RealAccount implements Account {
    private String accountNumber;
    private double balance;

    public RealAccount(String accountNumber, double balance) {
        this.accountNumber = accountNumber;
        this.balance = balance;
    }

    @Override
    public double getBalance() {
        return balance;
    }
}

// 특수 사례: NullAccount 클래스
public class NullAccount implements Account {
    @Override
    public double getBalance() {
        return 0.0;
    }
}

// 계좌 서비스 클래스
public class AccountService {
    private Map<String, Account> accounts = new HashMap<>();

    public AccountService() {
        accounts.put("12345", new RealAccount("12345", 1000.0));
    }

    public Account getAccount(String accountNumber) {
        return accounts.getOrDefault(accountNumber, new NullAccount());
    }
}

// 사용 예제
public class Main {
    public static void main(String[] args) {
        AccountService service = new AccountService();
        System.out.println(service.getAccount("12345").getBalance()); // 1000.0
        System.out.println(service.getAccount("67890").getBalance()); // 0.0
    }
}

AccountService를 사용하여 계좌 잔액을 조회하고 결과를 출력한다. 예외 처리를 할 필요 없이 계좌가 존재하지 않는 경우를 처리할 수 있다.

이렇게 하면 예외 처리 로직이 분산되지 않고, 코드의 흐름이 더 명확해진다.

null을 반환하지도 전달하지도 마라


null을 확인하는 코드의 반복은 흔히 오류를 유발하는 행위 중 하나이다.

메서드에서 null을 반환하고 싶은 생각이 든다면, 그 대신 예외를 던지거나 특수 사례 객체를 반환하는 방식을 고려해야 한다.

메서드로 null을 전달하는 방식 또한 나쁘다. 대다수의 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없기 때문이다.

그렇다면 애초에 null을 넘기지 못하도록 하는 것이 합리적일 것이다.

인수로 null이 넘어온다면 코드에 문제가 있다는 것을 유념하자.

profile
내가그린솜뭉치

0개의 댓글