Spring + Java

[Java] Enum NPE 문제 빠르게 해결하기 (feat. equals, switch, AttributeConverter)

Stark97 2024. 11. 3. 01:13
반응형

안녕하세요. 기록이 즐거운 개발자 stark입니다!

이번 포스팅은 제가 직접 겪은 Enum 오류와 그 해결 과정을 기록해 두고자 작성했습니다. 스프링 프로젝트에서는 DB에 저장되는 타입을 문자열(String)이 아닌 Enum으로 관리하는 것을 권장합니다. Enum을 사용하면 임의의 값이 DB에 저장되는 것을 방지할 수 있으며, 코드 내에서 타입을 쉽게 검색해 빠르게 확인할 수 있는 장점이 있기 때문입니다.

 

하지만, Enum을 사용한다고 해서 문제가 없는 것은 아닙니다. 프로젝트를 진행하면서 예상치 못한 문제에 직면할 수 있습니다. 제가 겪은 문제 상황은 다음과 같습니다. 비즈니스 로직에서 Enum을 사용해 DB에 저장된 특정 타입 값을 확인하고, 일치할 경우 데이터를 처리하는 로직을 작성했습니다. 그러나, 예상치 못하게 DB에 해당 타입 값이 null로 저장된 상황이 발생했습니다. 이로 인해 null 값을 인지하지 못한 상태에서 Enum의 equals() 메서드를 사용하게 되었고, NullPointerException이 발생했습니다.

 

또한, switch 문에서 Enum을 사용할 때도 비슷한 문제가 발생했습니다. Enum이 null인 상태로 switch 문에 전달되면, 내부적으로 ordinal() 메서드를 호출하게 되는데, 이때 null.ordinal()을 호출하려 시도하면서 NullPointerException이 발생합니다. switch 문은 Enum 값을 처리할 때 해당 값의 순서를 기반으로 분기하는데, null일 경우 이를 처리하지 못하기 때문입니다.

 

이러한 상황을 해결하기 위해 다양한 접근 방법이 필요합니다. equals() 메서드의 호출 순서를 바꾸는 것과 switch 문에서 Enum이 null인지 사전에 확인하여 예외를 던지는 방법이 있습니다. 또한, DB에서 엔티티로 데이터를 받아올 때 null 값인 경우 기본 Enum 값을 세팅해 주는 컨버터를 사용하는 방법도 있습니다.

 

 

Enum값이 null인데 equals() 메서드를 사용했다.


제가 실무에서 사용한 코드를 보여드리면서 설명할 수는 없습니다. 그렇지만 원활한 설명을 위해서는 예시 코드가 필요하다고 생각했습니다. 그래서 실무 비즈니스와는 하나도 관계가 없는 주문과 관련된 새로운 비스니스 상황을 설계하였고 내부의 ENUM을 사용한 오류 상황만 비슷하게 재현해 봤습니다.

 

먼저 주문과 관련된 도메인 코드를 작성했습니다.

도메인 내부에는 2개의 Enum값이 존재하며 이것으로 주문을 관리합니다.

@AllArgsConstructor
@NoArgsConstructor
@Getter
public class Order {

    private Long id;
    private String name;
    private OrderType orderType;
    private OrderStatus orderStatus;

    public static Order of(Long id, String name, OrderType orderType, OrderStatus orderStatus) {
        return new Order(id, name, orderType, orderStatus);
    }

}

주문 도메인에서 사용 중인 Enum 코드는 다음과 같습니다.

// 주문 타입
@AllArgsConstructor
@Getter
public enum OrderType {

    DELIVERY("DELIVERY", "배달 주문"),
    TAKEOUT("TAKEOUT", "포장 주문"),
    IN_STORE("IN_STORE", "매장 주문");

    private final String value;
    private final String description;

}

// 주문 상태
@AllArgsConstructor
@Getter
public enum OrderStatus {

    ORDERED("ORDERED", "주문 완료"),
    SHIPPING("SHIPPING", "배송 중"),
    DELIVERED("DELIVERED", "배송 완료");

    private final String value;
    private final String description;

}

주문 비즈니스 로직은 다음과 같습니다.

예시 코드에서 db연결을 한 것이 아니기에 도메인을 생성할 때 OrderStatus값에 null을 넣어주었습니다. (상황을 재현하기 위함)

비즈니스 로직을 살펴보면 도메인을 생성할 때 넣어준 주문 타입과 주문 상태값을 꺼내서 사용하고 있습니다.

public class OrderMain {

    public static void main(String[] args) {
        // Order 생성 및 OrderStatus에 null 값 넣기
        Order order = Order.of(1L, "배달 주문", OrderType.DELIVERY, null);

        // OrderType 가져오기
        OrderType orderType = order.getOrderType();

        // OrderStatus 가져오기
        OrderStatus orderStatus = order.getOrderStatus();

        // 문제 상황을 재현하기
        switch (orderType) {
            case DELIVERY -> {
                if (orderStatus.equals(OrderStatus.ORDERED)) {
                    System.out.println("배달 주문이 완료되었습니다.");
                }
            }
            case TAKEOUT -> {
                if (OrderStatus.ORDERED.equals(orderStatus)) {
                    System.out.println("포장 주문이 완료되었습니다.");
                }
            }
            case IN_STORE -> {
                if (OrderStatus.ORDERED.equals(orderStatus)) {
                    System.out.println("매장 주문이 완료되었습니다.");
                }
            }
            default -> {
                System.out.println("주문 타입이 없습니다.");
            }
        }
    }

}

위 코드를 실행하면 다음과 같은 오류가 발생합니다.

Exception in thread "main" java.lang.NullPointerException: 
    Cannot invoke "com.example.blog.test.OrderStatus.equals(Object)" 
    because "orderStatus" is null

이 오류는 orderStatus가 null인 상태에서 .equals() 메서드를 호출하려고 할 때 발생합니다. equals() 메서드를 호출할 객체가 null이면, 메서드를 호출할 수 없기 때문에 NullPointerException이 발생합니다. 이 문제는 메서드를 호출하는 객체 (orderStatus)가 null이어서 발생합니다.

 

참고사항 (null 값의 안전한 반환)

Order 객체 내부에 null로 설정된 enum 필드를 getter 메서드(getOrderStatus() 등)로 가져오는 것은 NullPointerException을 발생시키지 않습니다. getter 메서드는 단순히 해당 필드의 값을 반환할 뿐이므로, 필드가 null일 경우에도 안전하게 null 값을 반환합니다. NullPointerException은 지금 예시처럼 반환된 null 값으로 메서드를 호출하거나(equals) 연산을 수행할 때 발생합니다.

 

따라서 Order 객체 내부의 orderStatus가 null이더라도 getOrderStatus()를 호출하는 과정에서는 문제가 발생하지 않습니다. 하지만, 그 반환된 null 값에 대해 .equals()나 다른 메서드를 호출하려고 할 때 NullPointerException이 발생할 수 있으므로, 이러한 상황을 사전에 처리해야 합니다.

 

 

equals() 메서드를 수정해서 문제를 해결해 봅시다.


이런 경우 문제 해결 방법은 정말 단순합니다. null값이 들어가 있는 orderStatus에 .equals()를 사용하는 것이 아니라 이미 상수로 존재하는 OrderStatus의 ORDERED에 .equals()를 사용해서 null값이 들어간 orderStatus를 매개변수로 받아서 Enum값을 비교하면 됩니다.

// 문제 상황을 재현하기
switch (orderType) {
    case DELIVERY -> {
    
        // 기존 코드는 orderStatus값에 equals()를 사용했었지만 수정한 코드는 null값을 매개변수로 받습니다.
        
        if (OrderStatus.ORDERED.equals(orderStatus)) {
            System.out.println("배달 주문이 완료되었습니다.");
        }
    }
    
    // 코드 생략
}

이대로 메서드를 실행하면 NPE 문제가 해결된 것을 확인할 수 있습니다. 근데 여기서 또 다른 문제가 있습니다. 만약 switch문에서 비교하는 orderType이 null인 상황에는 어떻게 될까요? switch문은 equals() 메서드처럼 순서를 바꿀 수도 없습니다.

 

 

또 다른 문제상황: switch문에 사용되는 Enum이 null인 경우


이제 OrderStatus는 값이 null이어도 비즈니스 로직에서 NPE가 발생하지 않습니다. 근데 우리는 switch문에서도 OrderType에 대한 비교를 할 수 있다는 것을 알아야만 합니다. 심지어 저는 if문보다 switch문을 선호하고 즐겨 사용하기에 이것이 더 큰 문제였습니다. 이전에 발생했던 equals() 메서드의 문제는 순서를 바꿔서 해결했습니다. switch 내부에서 바로 Enum을 비교하는 경우는 어떻게 해야 할까요?

 

먼저 switch에서 사용될 OrderType이 null인 코드를 작성해 봅시다.

public class OrderMain {

    public static void main(String[] args) {
        // Order 생성 및 OrderType에 null 값 넣기
        Order order = Order.of(1L, "배달 주문", null, OrderStatus.ORDERED);

        // OrderType 가져오기
        OrderType orderType = order.getOrderType();

        // OrderStatus 가져오기
        OrderStatus orderStatus = order.getOrderStatus();

        // 문제 상황을 재현하기
        switch (orderType) {
            case DELIVERY -> {
                if (OrderStatus.ORDERED.equals(orderStatus)) {
                    System.out.println("배달 주문이 완료되었습니다.");
                }
            }
            case TAKEOUT -> {
                if (OrderStatus.ORDERED.equals(orderStatus)) {
                    System.out.println("포장 주문이 완료되었습니다.");
                }
            }
            case IN_STORE -> {
                if (OrderStatus.ORDERED.equals(orderStatus)) {
                    System.out.println("매장 주문이 완료되었습니다.");
                }
            }
            default -> {
                System.out.println("주문 타입이 없습니다.");
            }
        }
    }

}

위 코드를 실행하면 다음과 같은 오류가 발생합니다.

Exception in thread "main" java.lang.NullPointerException: 
    Cannot invoke "com.example.blog.test.OrderType.ordinal()" 
    because "orderType" is null

이 오류는 switch 문에 전달된 orderType이 null일 때 발생합니다. Java의 switch 문은 enum 값을 다룰 때 내부적으로 ordinal() 메서드를 호출하여 enum의 순서 값을 사용해 분기 처리를 합니다. ordinal() 메서드는 enum 객체에 대해만 호출될 수 있기 때문에, orderType이 null인 경우 null.ordinal()이 호출되어 NullPointerException이 발생합니다.

 

내부 동작을 조금 더 알아봅시다.

switch 문이 enum을 분기할 때, Java는 enum의 ordinal() 메서드를 호출하여 enum이 정의된 순서를 기반으로 매칭을 합니다. 예를 들어, OrderType.DELIVERY가 첫 번째 값이면 ordinal()은 0을 반환합니다. 그러나 switch에 null이 전달되면 null.ordinal() 호출이 시도되기 때문에 NullPointerException이 발생하게 됩니다.

 

 

switch에서 발생한 ordinal 문제를 해결해 봅시다.


orderType(Enum) 값이 null일 때 switch문에서 NullPointerException이 발생하지 않도록 하기 위해, orderType이 null인지 확인하고 예외를 던지는 로직을 추가하는 것이 필요합니다.

 

이를 위해, orderType이 null인지 확인하는 로직을 switch문 이전에 추가하여 문제가 발생하기 전에 명확한 예외를 던질 수 있도록 코드를 수정해 봤습니다. 이렇게 하면 orderType이 null일 때 문제가 발생하는 위치와 이유를 명확히 알 수 있습니다.

// OrderType이 null인지 확인한 후 예외를 던짐
if (orderType == null) {
    throw new IllegalArgumentException("OrderType이 null입니다.");
} else {
    switch (orderType) {
        case DELIVERY -> {
            if (OrderStatus.ORDERED.equals(orderStatus)) {
                System.out.println("배달 주문이 완료되었습니다.");
            }
        }
        case TAKEOUT -> {
            if (OrderStatus.ORDERED.equals(orderStatus)) {
                System.out.println("포장 주문이 완료되었습니다.");
            }
        }
        case IN_STORE -> {
            if (OrderStatus.ORDERED.equals(orderStatus)) {
                System.out.println("매장 주문이 완료되었습니다.");
            }
        }
        default -> {
            System.out.println("주문 타입이 없습니다.");
        }
    }
}

근데 저는 이 로직이 마음에 들지 않습니다. 비즈니스 로직에서 매번 ENUM을 다 확인하고 예외처리 해야만 하는 걸까요? 코드가 우아하지 않게 느껴집니다. switch문을 사용하기도 전에 if문으로 ENUM분기처리를 해야 하므로 복잡해진 기분도 듭니다.

 

 

AttributeConverter로 비즈니스 외부에서 문제 해결하기


조금 생각해 보면 애초에 db에 값이 null로 들어갈 수 없도록 정책을 정하는 것이 가장 좋을 것입니다. 그러나 정책을 정했다고 해서 항상 값이 채워진 채로 저장된다고 보장할 수는 없습니다. 누군가 직접 db에 접근해서 수정할 수도 있고 오류로 인해 null로 저장이 될 수도 있습니다. 그렇기에 비즈니스 로직에서 데이터를 검증하고 보정하는 처리를 해줘야만 합니다.

 

이것은 제 직속 선배님께 업무 중 배운 내용입니다. 이런 null값 보정 작업을 비즈니스 로직에서 처리하기보단 데이터를 db에서 받을 때 바로 처리하면 비즈니스 로직에서는 Enum이 절대 null이 아니라고 보장해 줄 수 있습니다. 예를 들면 db에서 엔티티를 가져올 때 Enum에 null 값이 있다면 Converter를 통해 기본 Enum 값을 넣어주도록 해서 null에 대한 문제를 사전에 방지할 수 있습니다.

 

컨버터가 무엇이길래 이런 것이 가능한 걸까요? 컨버터의 동작 방식을 이해해 봅시다.

클래스에 @Converter(autoApply = true)를 선언하면 JPA는 해당 컨버터를 같은 타입의 모든 필드에 자동으로 적용합니다. 예를 들어, OrderType 타입의 모든 엔티티 필드는 이 컨버터를 통해 DB와 애플리케이션 간의 변환을 거치게 됩니다. 이 설정을 통해 별도의 코드 변경이나 애노테이션 없이 DB의 null 값을 자동으로 기본 enum 값으로 변환할 수 있습니다.

 

코드를 작성해 봅시다. 먼저 기존 Enum값들에 기본 타입을 추가합니다.

Enum 내부에 NONE이라는 타입을 추가했습니다. 만약 null일 경우에만 컨버터를 통해 NONE이 추가된다면 앞으로는 비즈니스 로직에서 NONE일 경우에 대한 추가 처리 로직을 작성해 두면 될 것입니다.

@AllArgsConstructor
@Getter
public enum OrderStatus {

    NONE("NONE", "기본값"),
    ORDERED("ORDERED", "주문 완료"),
    SHIPPING("SHIPPING", "배송 중"),
    DELIVERED("DELIVERED", "배송 완료");

    private final String value;
    private final String description;

}

@AllArgsConstructor
@Getter
public enum OrderType {

    NONE("NONE", "주문 없음"),
    DELIVERY("DELIVERY", "배달 주문"),
    TAKEOUT("TAKEOUT", "포장 주문"),
    IN_STORE("IN_STORE", "매장 주문")
    ;

    private final String value;
    private final String description;

}

이제 AttributeConverter 인터페이스를 구현해서 메서드를 오버라이드 해봅시다.

이제부터 아래의 인터페이스를 구현할 것입니다.

package jakarta.persistence;

public interface AttributeConverter<X, Y> {
    Y convertToDatabaseColumn(X var1);

    X convertToEntityAttribute(Y var1);
}

AttributeConverter를 구현하는 타입 컨버터 클래스를 작성합니다.

@Converter(autoApply = true)
public class OrderTypeConverter implements AttributeConverter<OrderType, String> {

    @Override
    public String convertToDatabaseColumn(OrderType orderType) {
        return orderType != null ? orderType.getValue() : null;
    }

    @Override
    public OrderType convertToEntityAttribute(String dbData) {
        if (dbData == null) {
            // 기본 값 설정 (예: NONE)
            return OrderType.NONE;
        }
        return OrderType.valueOf(dbData);
    }
    
}

실제로 컨버터를 적용하면 다음과 같이 동작할 것입니다.

  1. DB에서 데이터 읽기
    • 엔티티 매니저가 DB에서 데이터를 읽어올 때, OrderType 필드의 값이 null이면 convertToEntityAttribute() 메서드가 호출됩니다. 이 메서드가 null일 경우 기본 enum 값을 반환하도록 구현되어 있으면, 자동으로 엔티티 필드에 기본 값이 매핑됩니다.
  2. 엔티티를 DB에 저장하기
    • OrderType 필드의 enum 값을 DB에 저장할 때는 convertToDatabaseColumn() 메서드가 호출됩니다.

 

이렇게 컨버터가 설정되면 별도의 코드 수정 없이 JPA가 엔티티 매핑 시 이 컨버터를 사용하여 DB 데이터와 엔티티 필드 값을 변환합니다. 참 편리하지 않나요? 저도 선배님께 컨버터에 대해 배운 뒤 큰 깨달음을 얻을 수 있었습니다.

 

 

마무리하며


사실 저는 지금까지 Enum에 잘못된 값이 들어오거나 데이터 자체가 null일 경우는 고려한 적이 없었습니다.

그렇다 보니 갑자기 이런 Enum에 대한 에러 메시지가 제 메신저에 울리게 되었을 때 참 별의별 에러들이 다 존재하는구나 생각됨과 동시에 굉장히 들떴습니다. 왜냐하면 제가 예외를 만나는 것을 즐기기 때문입니다. 저는 처음 보는 예외를 처리하면서 제 실력이 향상되는 것을 자주 느끼곤 합니다. 이런 생각이 드는 걸 보면 개발자는 제게 천직일 수도 있겠습니다.

 

사실 이런 오류는 자바의 근본을 잘 알고 있다면 금방 해결할 수 있는 문제입니다. 그래도 저는 제가 주니어 때 이런 문제를 만나서 이렇게 해결하고자 했다는 것을 기록으로 남겨두고 싶었습니다.

 

긴 글 읽어주신 독자분들께 감사의 인사를 올리며 마무리하겠습니다.

읽어주셔서 감사합니다 :)

 

 

반응형