방어적 프로그래밍
1️⃣ 안전한 프로그램
최근 우아한테크코스 프리코스에 참여하면서 ‘안전한 프로그램’에 대해서 고민하게 되었다.
본인이 주요 과제로 삼았던 안전한 프로그램의 원칙은 다음과 같다.
- 어떤 경우에서도 로직이 안전하게 유지된다.
- null을 사용하지 않는다. → 사용하더라도 범위를 최소화 한다.
- 사용자 input을 신뢰하지 않는다.
- 외부 라이브러리를 신뢰하지 않는다.
이를 토대로 스터디를 진행하며, 더욱 심화된 내용인 방어적 프로그래밍에 대해 알아보았다.
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
값을 추가하여, lottoState
가 PENDING
이면 lottoRank
값을 get할 수 없도록 로직을 추가했다.
이렇게 null
value가 전파되지 않도록 했다.
객체들은 신뢰할 수 있는가?
- 모든 기능들에 대한 unit-test를 통해서 예외 발생과, 기능 동작에 대해 검증하도록 했다.
- (물론 100% 신뢰할 수 없지만)
- 기능들의 동작 범위에 대해서 명확히하고, 범위가 벗어나는 input이나 예외 사항에 대해서 exception을 만들도록 했다.
4️⃣ 생각해볼만한 장단점
Pros
- 안정성과 신뢰성 향상
- 예외 상황 처리에 대한 신뢰, 유지 보수 용이성 향상
Cons
- 코드 및 구조 복잡성 증가 → 어떻게 하면 간결하게 예외 처리와 검증을 할 것인가
- 성능에 민감한 로직에서 오버헤드 증가 → 어떻게 하면 검증 로직을 줄이면서 안정성을 유지할 수 있을까
안전한 프로그램을 만드는 것은 개발자로서 꼭 생각해야하는 부분이다.
어떤 프로그램을 설계하더라도 core logic이 망가지는 것은 상상하기도 싫다.