Item 49
매개변수가 유효한지 검사하라
메서드나 생성자에 입력되는 매개변수들이 정상적인 데이터인지 검사하는 것은 중요합니다.
매개변수 검사를 제대로 하지 않는다면 다음과 같은 결과가 발생할 수 있습니다.
- 예상치 못한 동작이 발생할 수 있다.
- 잘못된 결과를 반환할 수 있다.
- 예외를 던지면서 애플리케이션 동작이 중단된다.
잘못될 수 있는 상황들을 예시로 보겠습니다.
🟧 예상치 못한 동작이 발생할 수 있다.
- 메서드는 정상적으로 실행됐으나 다른 객체를 잘못된 상태로 바꿔놓을 수 있습니다.
Deck에서 카드를 뽑을 때 문제 없이 카드를 가져와야 정상적인 동작입니다.
하지만 덱을 생성할 때 빈 컬렉션이 매개변수로 들어갔습니다.
Deck deck = new Deck(initData());
아무런 문제가 없다고 생각했지만 카드를 뽑을 때 "카드를 모두 소비하였습니다."라는 예외 문장과 함께 IllegalStateException이 던져지는 예상하지 못한 동작이 발생합니다.
사실 Deck을 생성할 때 넘긴 initData()는 빈 컬렉션을 반환하고 있었습니다.
사용자는 덱에서 카드를 뽑으려 했지만 실제로는 덱은 비어있기 때문에 예외가 발생했습니다.
@Test
void 예상하지_못한_상황() {
// 01. Deck에 카드가 들어온다.
Deck deck = new Deck(initData());
// 02. Deck에서 카드를 뽑는다.
String card = deck.draw(); // === 카드가 비어있기 때문에 예외가 발생한다. ===
}
class Deck {
private final List<String> cards;
public Deck(final List<String> cards) {
this.cards = cards;
}
public String draw() {
// === 예외가 발생한다. ===
if (cards.isEmpty()) {
throw new IllegalStateException("카드를 모두 소비하였습니다.");
}
return cards.remove(0);
}
}
private List<String> initData() {
// === 빈 컬렉션을 반환한다. ===
return List.of();
}
🟧 잘못된 결과를 반환할 수 있다.
- 매개변수를 검사하지 않는다면 메서드는 잘 동작하지만 잘못된 결과를 반환할 수도 있습니다.
각각 메서드를 호출 할 때 마다 "기존값 + 1"을 수행하려고 합니다.
잘못된_결과_반환 테스트의 예상 기댓값은 "sum = 5" 입니다.
하지만 테스트를 호출했을 때 실제 반환값은 "sum = 3" 로 잘못된 결과를 반환합니다.
@Test
void 잘못된_결과_반환() {
// 01. MyObject의 value를 0으로 초기화한다.
MyObject myObject = new MyObject(0);
// 02. method_A를 호출한다. 매개변수로 myObject와, 더해질 값 1을 넘긴다.
// method_A -> method_B -> method_C -> method_D -> method_EXCEPTION
method_A(myObject, 1);
Integer sum = myObject.getValue(); // === 예상 결과 : 5 ===
System.out.println("sum = " + sum); // === 실제 결과 : 3 ===
}
private void method_A(MyObject value, Integer addValue) {
value.add(addValue); // 1을 더한다.
method_B(value, addValue); // 매개변수 1을 넘긴다.
}
private void method_B(MyObject value, Integer addValue) {
value.add(addValue); // 1을 더한다.
method_C(value, addValue); // 매개변수 1을 넘긴다.
}
private void method_C(MyObject value, Integer addValue) {
value.add(addValue); // 1을 더한다.
method_D(value, addValue); // 매개변수 1을 넘긴다.
}
private void method_D(MyObject value, Integer addValue) {
value.add(addValue); // 1을 더한다.
method_EXCEPTION(value, -1); // === 잘못된 매개변수 -1을 넘긴다. ===
}
private MyObject method_EXCEPTION(MyObject value, Integer addValue) {
value.add(addValue); // === -1을 더한다. ===
return value; // === 잘못 계산된 MyObject를 반환한다. ===
}
private static class MyObject {
private Integer value;
public MyObject(final Integer value) {
this.value = value;
}
public void add(Integer value) {
this.value += value;
}
public Integer getValue() {
return value;
}
}
실제로 잘못된 부분은 method_D 메서드 내부에서 method_Exception의 매개변수로 "1"이 아닌 "-1"을 넘겨줬습니다.
메서드는 정상적으로 동작했지만 사용자는 예상하지 못한 결과값을 가지게 됩니다.
method_A를 호출하면 다음과 같이 메서드가 순차적으로 호출됩니다.
method_A -> method_B -> method_C -> method_D -> method_EXCEPTION
이 중에서 어디서 오작동 했는지 확인하기도 어려운 상황입니다.
🟧 예외를 던지면서 애플리케이션 동작이 종료한다.
- 잘못된 매개변수로 인해 예상하지 못한 예외 발생으로 애플리케이션 동작이 종료될 수도 있습니다.
Deck을 생성할 때 10개의 데이터를 넣어준다는 네이밍을 갖는 메서드가 있습니다. => initData_Size10
DeckV2 deck = new DeckV2(initData_Size10);
사용자는 카드를 모두 뽑고 각각의 카드를 출력하려 합니다.
당연히 카드 10장이 있을거라 판단하고 매개변수로 deck과 10을 넘깁니다.
하지만 예상하지 못한 IndexOutOfBoundsException 예외가 발생합니다.
@Test
void 예외를_던지면서_애플리케이션_동작이_중단된다() {
// === DeckV2_Size10 카드 10장으로 초기화한다. ===
DeckV2_Size10 deck = new DeckV2_Size10(initData_Size10());
// === DeckV2_Size10에 있는 카드 10장을 각각 뽑으면서 출력한다. ===
printDrawCards(deck, 10);
class DeckV2_Size10 {
private final List<String> cards;
public DeckV2_Size10(final List<String> cards) {
this.cards = cards;
}
public String draw() {
return cards.remove(0);
}
}
private List<String> initData_Size10() {
// === 실제로 카드는 10장이 아닌 9장 존재한다. ===
return new ArrayList<>(List.of("카드1", "카드2", "카드3", "카드4", "카드5", "카드6", "카드7", "카드8", "카드9"));
}
private void printDrawCards(DeckV2_Size10 deck, int deckSize) {
for (int count = 0; count < deckSize; count++) {
// === count : 9일 때 예외가 발생한다. ===
// === IndexOutOfBoundsException ===
System.out.println("card = " + deck.draw());
}
}
🟧 세 가지 케이스의 공통점, 매개변수를 검사하지 않았다.
처음부터 매개변수를 검사했다면 더 빨리 알아챌 수 있었습니다.
해당 예외들이 발생하지 않도록 검사 했다면 세 가지 케이스 모두 수정할 수 있었습니다.
- 예상치 못한 동작이 발생할 수 있다. - IllegalStateException, "카드를 모두 소비하였습니다."
- 잘못된 결과를 반환할 수 있다. - 메서드 정상 동작, 실제값 != 예상값
- 예외를 던지면서 애플리케이션 동작이 중단된다. - IndexOutOfBoundsException
이제부터라도 매개변수 검사를 진행해서 최대한 에러를 예방합시다.
매개변수 유효성을 위해서는 다음과 같이 여러가지 방법이 존재합니다.
- 예외를 문서화한다.
- 메서드 몸체 실행 전 매개변수를 검사하자.
- 잘못된 값을 반환하지 말고 예외를 발생시키자.
- 매개변수를 저장할 경우 검사를 철저히 하자.
- Objects 클래스에 있는 유틸리티 메서드를 이용하자.
- assert 단언문을 이용해보자.
🟧 예외를 문서화한다.
public, protected 메서드에서 매개변수 값이 잘못됐을 때 예외를 문서화합니다.
API 문서화를 통해서 사용자가 조건을 잘 지킬 가능성을 높일 수 있습니다.
- @throws 태그를 사용합니다.
- 예외 설명을 작성합니다.
/**
* 이 메서드는 넘겨받은 문자열을 정수로 변환합니다.
* 만약 문자열이 null이거나 정수로 변환할 수 없는 경우
* {@link NumberFormatException}을 던집니다.
*
* @param value 변환할 문자열
* @return 정수로 변환된 값
* @throws NumberFormatException 문자열이 정수로 변환할 수 없는 경우
*/
public static int parseInt(String value) throws NumberFormatException {
// ...
}
🟧 메서드 몸체 실행 전 매개변수를 검사하자.
메서드 몸체가 실행되기 이전에 매개변수를 검사합니다.
덕분에 다른 동작을 수행하지 않고 빠르게 예외를 던질 수 있습니다.
@Test
void 메서드_몸체_실행_전_매개변수를_검사한다() {
// === 예외가 발생한다. ===
// === IllegalArgumentException, "카드가 비어있을 수 없습니다." ===
DeckV3 deck = new DeckV3(initData());
// 호출되지 않는다.
String card = deck.draw();
}
class DeckV3 {
private final List<String> cards;
public DeckV3(final List<String> cards) {
// === 예외가 발생한다. ===
if (cards.isEmpty()) {
throw new IllegalArgumentException("카드가 비어있을 수 없습니다.");
}
this.cards = cards;
}
public String draw() {
if (cards.isEmpty()) {
throw new IllegalStateException("카드를 모두 소비하였습니다.");
}
return cards.remove(0);
}
}
🟧 잘못된 값을 반환하지 말고 예외를 발생시키자.
중간에 매개변수 검사가 없을 경우에 메서드가 정상적으로 동작했습니다.
하지만 잘못된 값을 반환했었고 사용자는 어디서 문제가 발생했는지 알기 어려웠습니다.
현재는 각 메서드의 인자가 "1"인지 검사함으로서 미래에 나올 수 있는 문제를 예외를 발생시켜 알아챌 수 있습니다.
@Test
void 매개변수가_잘못되면_예외가_발생한다() {
MyObject myObject = new MyObject(0);
// 메서드 호출마다 1씩 더해질거라고 예상한다.
// method_A_V2 -> method_B_V2 -> method_C_V2 -> method_D_V2 -> method_EXCEPTION_V2 (예외 발생)
method_A_V2(myObject, 1);
}
private void method_A_V2(MyObject value, Integer addValue) {
validateValueIsOne(addValue);
value.add(addValue); // 1을 더한다.
method_B_V2(value, addValue); // 매개변수 1을 넘긴다.
}
private void method_B_V2(MyObject value, Integer addValue) {
validateValueIsOne(addValue);
value.add(addValue); // 1을 더한다.
method_C_V2(value, addValue); // 매개변수 1을 넘긴다.
}
private void method_C_V2(MyObject value, Integer addValue) {
validateValueIsOne(addValue);
value.add(addValue); // 1을 더한다.
method_D_V2(value, addValue); // 매개변수 1을 넘긴다.
}
private void method_D_V2(MyObject value, Integer addValue) {
validateValueIsOne(addValue);
value.add(addValue); // 1을 더한다.
method_EXCEPTION_V2(value, -1); // 잘못된 매개변수 -1을 넘긴다.
}
private MyObject method_EXCEPTION_V2(MyObject value, Integer addValue) {
validateValueIsOne(addValue); // === 예외가 발생한다. ===
value.add(addValue); // [X] -1을 더한다.
return value; // [X] 잘못 계산된 MyObject를 반환한다.
}
private void validateValueIsOne(Integer value) {
if (value != 1) {
throw new IllegalArgumentException("value는 1이어야 합니다.");
}
}
🟧 매개변수를 저장할 경우 검사를 철저히 하자.
잘못된 값을 가진 매개변수를 검사하지 않고 저장할 경우 미래에 어떤 일이 발생될지 예측하기 어렵습니다.
아래 "생성은_성공하지만_정상적인_동작을_하지_않는다" 테스트를 보시면
DeckV4를 생성하는 시점에는 에러가 발생할지 알 수 없습니다.
draw 메서드가 호출되고나서야 비로서 예외가 발생하고 잘못된 것을 알 수 있습니다.
@Test
void 생성은_성공하지만_정상적인_동작을_하지_않는다() {
System.out.println("=== DeckV4 생성 이전 ===");
DeckV4 deck = new DeckV4(initDataV4()); // 생성에 성공한다.
System.out.println("=== DeckV4 생성 이후 ===");
System.out.println("=== draw 이전 ===");
deck.draw(); // === 동작 실패한다. ===
System.out.println("=== draw 이후 ===");
}
class DeckV4 {
private final List<String> cards;
public DeckV4(final List<String> cards) {
this.cards = cards;
}
public String draw() {
return cards.remove(0); // === 예외가 발생한다. ===
}
}
private List<String> initDataV4() {
return null; // [X] null을 반환한다.
}
DeckV4를 생성할 때 매개변수를 검사해서 이후에 발생할 에러를 미리 예방합시다.
DeckV5 는 생성 시점에 예외가 발생하기 때문에 곧 바로 수정할 수 있다는 장점을 갖게 됩니다.
@Test
void 생성_실패한다() {
System.out.println("=== DeckV5 생성 이전 ===");
DeckV5 deck = new DeckV5(initDataV5()); // === 생성 실패한다. ===
System.out.println("=== DeckV5 생성 이후 ===");
System.out.println("=== draw 이전 ===");
deck.draw(); // 앞단에서 생성 실패하여 메서드가 호출되지 않는다.
System.out.println("=== draw 이후 ===");
}
class DeckV5 {
private final List<String> cards;
public DeckV5(final List<String> cards) {
if (cards == null) {
throw new IllegalArgumentException("cards는 null일 수 없습니다."); // === 예외가 발생한다. ===
}
this.cards = cards;
}
public String draw() {
return cards.remove(0);
}
}
private List<String> initDataV5() {
return null; // [x] null을 반환한다.
}
🟧 Objects 클래스에 있는 유틸리티 메서드를 이용하자.
Objects 클래스에 있는 유틸리티 메서드를 이용하면 보다 편리하게 예외 검사가 가능합니다.
대표적으로 Objects.requireNonNull 메서드를 이용해보겠습니다.
💡 requireNonNull()
- 객체가 null인 경우, NullPointerException 예외를 발생시킵니다.
public static <T> T requireNonNull(T t)
DeckV5보다 더 간편하게 null 검사를 진행할 수 있습니다.
DeckV5와 DeckV6는 똑같이 null 검사를 진행하고 있지만 DeckV6가 가독성이 더 좋습니다.
// === 직접 null 검사를 진행한다. ===
public DeckV5(final List<String> cards) {
if (cards == null) {
throw new IllegalArgumentException("cards는 null일 수 없습니다."); // === 예외가 발생한다. ===
}
this.cards = cards;
}
// === Objects 유틸리티 메서드를 이용한다. ===
public DeckV6(final List<String> cards) {
// 더 간결한 표현이 가능하다.
this.cards = Objects.requireNonNull(cards); // === 예외가 발생한다. ===
}
Objects.requireNonNull 을 사용한 DeckV6 전체 코드를 보면 다음과 같습니다.
실행 결과는 DeckV5와 마찬가지로 생성하는 순간에 예외가 발생합니다.
@Test
void 생성_실패한다_V2() {
System.out.println("=== DeckV6 생성 이전 ===");
DeckV6 deck = new DeckV6(initDataV6()); // === 생성 실패한다. ===
System.out.println("=== DeckV6 생성 이후 ===");
System.out.println("=== draw 이전 ===");
deck.draw(); // 앞단에서 생성 실패하여 메서드가 호출되지 않는다.
System.out.println("=== draw 이후 ===");
}
class DeckV6 {
private final List<String> cards;
// === Objects 유틸리티 메서드를 이용한다. ===
public DeckV6(final List<String> cards) {
this.cards = Objects.requireNonNull(cards); // === 예외가 발생한다. ===
}
public String draw() {
return cards.remove(0);
}
}
private List<String> initDataV6() {
return null; // [X] null을 반환한다.
}
Objects 유틸리티 메서드는 편리하다.
Objects.requireNonNull 메서드는 다양한 방식으로 사용할 수 있습니다
- 예외 메시지를 지정할 수 있다.
- null 검사 후에 입력값을 반환 할 수 있다.
- null 검사 목적으로만 사용할 수도 있다.
// === null 검사를 하고 입력값을 반환한다. ===
public DeckV6(final List<String> cards) {
this.cards = Objects.requireNonNull(cards);
}
// === null 검사를 하고 입력값을 반환한다. ===
// === 예외 메시지를 지정한다. ===
public DeckV6(final List<String> cards) {
this.cards = Objects.requireNonNull(cards, "카드가 null일 수 없습니다.");
}
// === null 검사만 진행한다. ===
public DeckV6(final List<String> cards) {
Objects.requireNonNull(cards, "카드가 null일 수 없습니다.");
this.cards = cards;
}
이러한 편리함과 가독성 덕분에
직접 null 체크를 수행하는 것보다 Objects.requireNonNull을 이용하는 것을 권장하고 있습니다.
🟧 assert 단언문
런타임 시 발생 가능한 조건을 확인하는 assert문
Java에서 Assert문은 런타임 시에 조건이 만족되지 않을 시에 프로그램을 중지 시키는 기능입니다.
조건이 false이라면 AssertionError를 발생시키고 프로그램을 종료합니다.
@Test
void assert는_조건에_충족하지_않으면_프로그램을_종료한다() {
// assert 조건문 : 출력 메시지
assert false : "프로그램이 진행되지 않습니다.";
}
마찬가지로 assert를 DeckV7에 활용하면 다음과 같은 코드가 완성됩니다.
class DeckV7 {
private final List<String> cards;
public DeckV7(final List<String> cards) {
// === assert문을 이용한다 ===
// === assert 그렇게 사용하는거 아닌데 ===
assert cards != null : "카드는 null일 수 없습니다.";
assert !cards.isEmpty() : "카드는 비어있을 수 없습니다.";
this.cards = cards;
}
public String draw() {
return cards.remove(0);
}
}
assert를 이용하니 간단해보이지만 의도했던 사용법과는 다릅니다.
그렇다면 assert는 언제 사용하는 걸까 ?
AssertionError는 회복 불가능한 상태를 의미하기 때문에 디버깅이나 테스트할 때 용이합니다.
Java는 절대적으로 코드가 실패하지 않는다고 할 수 없습니다.
외부 상태를 참조하는 객체지향 프로그래밍이기 때문입니다.
만약 "핵폭탄 발사 버튼"을 Java로 구현해야 한다면 그 로직은 절대 실패해서는 안됩니다.
이때서야 assert문을 이용합니다. 하나라도 조건에 부합하면 프로그램을 종료시켜버리기 때문입니다.
이런식으로 assert는 버그 없는 코드를 작성해야할 때 이용합니다.
🟧 정리하자면
- 메서드나 생성자의 매개변수를 검사해야 합니다.
- 예외에 대한 문서화를 통해서 잘못 사용될 가능성을 줄여야 합니다.
'💬 언어 > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] Item 23 - 태그 달린 클래스보다는 클래스 계층구조를 활용하라 (0) | 2023.04.14 |
---|---|
[이펙티브 자바] Item 22 - 인터페이스 타입을 정의하는 용도로만 사용하라 (1) | 2023.03.20 |
[이펙티브 자바] Item 20 - 추상 클래스보다는 인터페이스를 우선하라 (0) | 2023.03.13 |
[이펙티브 자바] Item 32 - 제네릭과 가변인수를 함께 쓸 때는 신중하라 (0) | 2023.03.05 |
[이펙티브 자바] Item 26 - Raw Type은 사용하지 말라 (0) | 2023.02.22 |