Backend

방어적 프로그래밍

Hyogu 2024. 11. 6. 12:40
728x90

1️⃣ 안전한 프로그램

최근 우아한테크코스 프리코스에 참여하면서 ‘안전한 프로그램’에 대해서 고민하게 되었다.
본인이 주요 과제로 삼았던 안전한 프로그램의 원칙은 다음과 같다.

  1. 어떤 경우에서도 로직이 안전하게 유지된다.
  2. null을 사용하지 않는다. → 사용하더라도 범위를 최소화 한다.
  3. 사용자 input을 신뢰하지 않는다.
  4. 외부 라이브러리를 신뢰하지 않는다.

이를 토대로 스터디를 진행하며, 더욱 심화된 내용인 방어적 프로그래밍에 대해 알아보았다.
 
 

2️⃣ 방어적 프로그래밍이 뭘까?

입력값, 외부 데이터, 환경 설정, 사용자 행동 등에서 발생할 수 있는 예외 상황을 대비하고,
잠재적인 오류로 인해 프로그램이 비정상적으로 종료되지 않도록 방어벽을 구축하는 것이 핵심
 
 

3️⃣ 방어적 프로그래밍을 위해 도입한 규칙들

이를 위해서 프로젝트 중 도입한 규칙은 아래와 같다!
 

사용자의 입력을 어떻게 검증할 것인가?

가장 중요한 대전제는 사용자를 신뢰하지 않는 것이다.
→ 사용자는 Input을 통해 함수의 정의역에 해당하지 않는 얼마든지 이상한 값을 보낼 수 있다.
3가지 layer를 통해 사용자의 input을 검증하고자 했다.
 
1. input layer ⇒ 사용자가 null을 입력하는지 검사

public <T> T inputWithValidationAndParse(Function<String, T> parser) {
    String input = inputProvider.readLine();
    checkInputIsNull(input);
    return parser.apply(input);
}

private void checkInputIsNull(String input) {
    if (input == null || input.isEmpty()) {
        throw new IllegalArgumentException(INPUT_NULL);
    }
}

input을 받으면 그 자리에서 공백인지 null인지 확인하도록 메서드를 구현했다.
 
2. controller parser layer ⇒ 원하는 type형태로 input이 이뤄졌는지 검사

public static List<Integer> parseIntegerList(String input) {
    try {
        return Arrays.stream(input.split(LIST_DELIMITER)).map(InputParser::parseInt).toList();
    } catch (NumberFormatException e) {
        throw new IllegalArgumentException(PARSE_INT);
    } catch (PatternSyntaxException e) {
        throw new IllegalArgumentException(PARSE_LIST_DELIMITER);
    }
}

예를 들어 String을 List로 변환 시 발생할 수 있는 경우에 대해서 exception을 던졌다.
try-catch가 좀 많이 사용되는 것 같아 이를 줄일 수 있는 방법을 알아보아야 할 것이다.
 
3. domain value layer ⇒ domain value에 적합한 범위, 형태인지 검사

public class LottoWinning {

    private final Lotto lotto;
    private final int bonusNumber;

    private LottoWinning(Lotto lotto, int bonusNumber) {
        this.lotto = lotto;
        this.bonusNumber = bonusNumber;
    }

    public static LottoWinning of(Lotto lotto, int bonusNumber) {
        validateLottoWinning(lotto, bonusNumber);
        return new LottoWinning(lotto, bonusNumber);
    }

    private static void validateLottoWinning(Lotto lotto, int bonusNumber) {
        if (bonusNumber < LOTTO_MINIMUM_NUMBER || bonusNumber > LOTTO_MAXIMUM_NUMBER) {
            throw new IllegalArgumentException(LOTTO_NUMBER_DOMAIN);
        }
        if (lotto.getNumbers().contains(bonusNumber)) {
            throw new IllegalArgumentException(LOTTO_BONUS_NUMBER);
        }
    }

		...
}

domain 객체를 생성할 때 static factory method를 이용하고, 메서드 안에 validation로직을 추가했다.
 

각 layer의 예외는 어디서 처리할 것인가?

특정 layer(ex: repository)에서 exception이 발생한다면, 해당 layer에서 error handling하도록 했다.
예를 들어, repository 에서 findById 메서드를 실행중에 발생할 수 있는 예외는 해당 메서드 안에서 try-catch로 잡는다.

@Override
public LottoResults findById(String id) {
    try {
        return Optional.ofNullable(lottoResultsMap.get(id))
                .orElseThrow(() -> new IllegalArgumentException(REPOSITORY_NOT_FOUND));
    } catch (NullPointerException e) {
        throw new IllegalArgumentException(REPOSITORY_ID_NULL);
    }
}

→ check / uncheck exception과 handling, logic ↔ check exception 에 대해서는 김영한님의 스프링 강의 고급편을 참고하면 좋을 것 같다. (추후 포스팅 예정)
 

변수 활용 범위는 어떻게 설계할 것인가?

변수(객체)를 활용하게 되면 같은 객체에 접근하여 값을 변경할 수 있다.

이를 방지하기 위해 변수의 모든 field는 private final로 설정하고

return 되는 list는 unmodifiable하게 설정했다.
→ 더 나아가 공유 메모리를 최소화하고, 스레드에 대해서도 자원 할당과 헤제를 적절히 시켜줘야 할 것이다.
 

null은 어떻게 처리할 것인가?

null을 사용하고 싶지 않았다. null을 사용하게 되면 한 형태로도 여러 값을 처리할 수 있기에 너무 편하지만, 그 side effect가 어디까지 영향을 미칠 지 알 수 없기 때문이다.
하지만 사용하게 됐다..(그렇게 됐다)

public class LottoResult {

    private final Lotto lotto;
    private final LottoRank lottoRank;
    private final LottoState lottoState;

    private LottoResult(Lotto lotto, LottoState lottoStatus) {
        this.lotto = lotto;
        this.lottoRank = null;
        this.lottoState = lottoStatus;
    }

    public static LottoResult create(Lotto lotto) {
        return new LottoResult(lotto, LottoState.PENDING);
    }

        ...

    public LottoRank getLottoRank() {
        if (this.lottoRank == null || this.lottoState.equals(LottoState.PENDING)) {
            throw new IllegalStateException(LOTTO_RESULT_UNDETERMINED);
        }
        return this.lottoRank;
    }

        ...

}

아직 정해지지 않은 lottoRank에 값을 넣을 수 없었다.

이제 와서 생각해보면 lottoRank를 다형성으로 생성하여 값을 넣어도 될 것 같다고 생각이 드는데, 그래도 값을 확인하는 로직이 추가로 들어가야 할 것 같다.

객체 생성 시에는 lottoRank를 null로 설정하였다.

대신에 lottoState값을 추가하여, lottoStatePENDING이면 lottoRank값을 get할 수 없도록 로직을 추가했다.

이렇게 null value가 전파되지 않도록 했다.

 

객체들은 신뢰할 수 있는가?

  1. 모든 기능들에 대한 unit-test를 통해서 예외 발생과, 기능 동작에 대해 검증하도록 했다.
  2. (물론 100% 신뢰할 수 없지만)
  3. 기능들의 동작 범위에 대해서 명확히하고, 범위가 벗어나는 input이나 예외 사항에 대해서 exception을 만들도록 했다.

 

4️⃣ 생각해볼만한 장단점

Pros

  1. 안정성과 신뢰성 향상
  2. 예외 상황 처리에 대한 신뢰, 유지 보수 용이성 향상

Cons

  1. 코드 및 구조 복잡성 증가 → 어떻게 하면 간결하게 예외 처리와 검증을 할 것인가
  2. 성능에 민감한 로직에서 오버헤드 증가 → 어떻게 하면 검증 로직을 줄이면서 안정성을 유지할 수 있을까

안전한 프로그램을 만드는 것은 개발자로서 꼭 생각해야하는 부분이다.
어떤 프로그램을 설계하더라도 core logic이 망가지는 것은 상상하기도 싫다.

728x90