프로그래밍/디자인패턴

널 객체 패턴(Null Object Pattern) 총정리

Jinwookoh 2025. 12. 7. 19:25

"고객 등급을 조회했는데 고객이 탈퇴해서 정보가 없으면(null) 어떡하지?"

"DB에서 조회했는데 데이터가 없으면 에러가 나나?"

자바 개발자의 영원한 숙적 **NPE(NullPointerException)**를 피하기 위해 우리는 습관적으로 방어 로직을 짭니다. 하지만 코드 곳곳에 if (obj != null)이 도배되면 가독성이 떨어집니다.

이를 해결하기 위해 **"아무 일도 하지 않는 객체"**를 만들어 null 대신 반환하는 것이 널 객체 패턴입니다.


1. 문제 상황: 방어적 복사 (Defensive Check)

가장 흔하지만, 가장 지저분한 방식입니다. null 체크를 호출하는 쪽(Client)에게 떠넘깁니다.

Java
public void printCustomerGrade(String name) {
    Customer customer = repository.findByName(name);

    // 호출할 때마다 null 체크를 해야 함 (지옥의 시작)
    if (customer != null) {
        System.out.println(customer.getGrade());
    } else {
        System.out.println("등급 없음"); // 예외 처리 로직
    }
}

만약 customer를 사용하는 곳이 100군데라면, if (customer != null)도 100번 등장해야 합니다.


2. 방식 1: 클래식 널 객체 (Polymorphic Null Object) - "객체지향의 정석"

인터페이스를 구현하는 **가짜 객체(Null Object)**를 만들어서, null 대신 리턴하는 방식입니다. 클라이언트는 이게 진짜인지 가짜인지 신경 쓰지 않고 메서드를 호출하면 됩니다.

특징

  • **다형성(Polymorphism)**을 활용합니다.
  • 널 객체의 메서드는 **"아무것도 하지 않거나(do nothing)", "기본값(default)을 반환"**하도록 구현합니다.

코드 예시

Java
 
// 1. 인터페이스
interface Customer {
    String getGrade();
    boolean isNull(); // 널 객체인지 확인할 수 있는 식별자 정도는 둠
}

// 2. 실제 객체 (Real)
class RealCustomer implements Customer {
    private String name;
    public RealCustomer(String name) { this.name = name; }
    
    @Override
    public String getGrade() { return "VIP"; }
    
    @Override
    public boolean isNull() { return false; }
}

// 3. 널 객체 (Null Object) - 핵심!
class NullCustomer implements Customer {
    @Override
    public String getGrade() { return "Basic"; } // 기본값 반환
    
    @Override
    public boolean isNull() { return true; }
}

// 4. 팩토리 (Repository)
class CustomerFactory {
    public static Customer getCustomer(String name) {
        if ("James".equals(name)) {
            return new RealCustomer(name);
        }
        // null을 리턴하는 대신 널 객체를 리턴!
        return new NullCustomer();
    }
}

// 5. 사용 (Client)
// null 체크가 사라짐! (Happy Path)
Customer customer = CustomerFactory.getCustomer("Unknown");
System.out.println(customer.getGrade()); // "Basic"

장단점

  • 장점: 클라이언트 코드에서 if (obj != null)이 싹 사라져 코드가 매우 깔끔해집니다.
  • 단점: 관리해야 할 클래스(NullCustomer)가 하나 늘어납니다.

3. 방식 2: 모던 래퍼 (Java 8 Optional) - "함수형 스타일"

Java 8부터는 굳이 NullCustomer 클래스를 만들지 않고, **Optional<T>**이라는 컨테이너(Wrapper)로 감싸서 처리하는 방식이 표준이 되었습니다.

특징

  • 값이 있을 수도 있고 없을 수도 있다는 것을 **타입(Type)**으로 명시합니다.
  • Null Object 클래스를 따로 정의할 필요 없이, 메서드 체이닝으로 빈 값 처리를 수행합니다.

코드 예시

Java
 
public class CustomerRepository {
    // 반환 타입으로 "이건 없을 수도 있어"라고 명시
    public Optional<Customer> findByName(String name) {
        if ("James".equals(name)) {
            return Optional.of(new RealCustomer(name));
        }
        return Optional.empty(); // null 대신 빈 상자 반환
    }
}

// 사용 (Client)
CustomerRepository repo = new CustomerRepository();

// 1. 기본값 처리 (orElse) - 널 객체 패턴의 현대적 구현
String grade = repo.findByName("Unknown")
    .map(Customer::getGrade)
    .orElse("Basic"); // 없으면 "Basic"

// 2. 예외 처리 (orElseThrow)
repo.findByName("Unknown")
    .orElseThrow(() -> new IllegalArgumentException("사용자 없음"));

장단점

  • 장점: 별도의 널 객체 클래스를 만들 필요가 없고, API 명세만 보고도 null 가능성을 인지할 수 있습니다.
  • 단점: Optional 자체를 생성하는 비용(객체 래핑)이 들며, 필드 변수로 사용하기엔 직렬화(Serialization) 문제가 있어 권장되지 않습니다. (주로 반환값으로만 사용)

4. 요약: 클래스를 만들까, Optional을 쓸까?

구분 Null Object Pattern (Class) Optional (Java 8+)
구현 방식 인터페이스 구현체 (class NullXxx) 제네릭 래퍼 (Optional<T>)
의도 "없어도 있는 것처럼 행동해라" "없을 수 있으니 조심해서 꺼내라"
코드 복잡도 클래스 파일 추가됨 메서드 체이닝 사용
추천 상황 도메인 로직에서 **'기본 행동'**이 명확할 때 (로그인 안 한 사용자 등) 단순히 값을 **조회(Return)**하는 경우

결론

DB 엔티티나 DTO를 단순히 조회해서 반환하는 경우라면 Java 8 Optional이 압도적으로 편리합니다.

하지만 "로그인하지 않은 사용자도 '게시글 읽기' 버튼은 누를 수 있어야 한다(권한 없음 메시지 출력)" 처럼, 객체 자체가 **복잡한 행위(Behavior)**를 가져야 한다면, Optional로 도배하기보다 **클래식 널 객체 패턴(AnonymousUser)**을 구현하는 것이 객체지향적인 설계를 완성하는 길입니다.