"고객 등급을 조회했는데 고객이 탈퇴해서 정보가 없으면(null) 어떡하지?"
"DB에서 조회했는데 데이터가 없으면 에러가 나나?"
자바 개발자의 영원한 숙적 **NPE(NullPointerException)**를 피하기 위해 우리는 습관적으로 방어 로직을 짭니다. 하지만 코드 곳곳에 if (obj != null)이 도배되면 가독성이 떨어집니다.
이를 해결하기 위해 **"아무 일도 하지 않는 객체"**를 만들어 null 대신 반환하는 것이 널 객체 패턴입니다.
1. 문제 상황: 방어적 복사 (Defensive Check)
가장 흔하지만, 가장 지저분한 방식입니다. null 체크를 호출하는 쪽(Client)에게 떠넘깁니다.
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)을 반환"**하도록 구현합니다.
코드 예시
// 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 클래스를 따로 정의할 필요 없이, 메서드 체이닝으로 빈 값 처리를 수행합니다.
코드 예시
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)**을 구현하는 것이 객체지향적인 설계를 완성하는 길입니다.
'프로그래밍 > 디자인패턴' 카테고리의 다른 글
| 동시성 제어 패턴: 비관적 락(Pessimistic) vs 낙관적 락(Optimistic) (1) | 2025.12.07 |
|---|---|
| 의존성 주입(DI) 패턴 완벽 정리 (0) | 2025.12.07 |
| 인터프리터 패턴(Interpreter Pattern) 완벽 정리 (0) | 2025.12.07 |
| 메멘토 패턴(Memento Pattern) 완벽 정리 (0) | 2025.12.07 |
| 플라이웨이트 패턴(Flyweight Pattern) 완벽 정리 (0) | 2025.12.07 |