설계 전략

Spring Data JPA에서 NPE를 방지하는 Optional 활용법

rnaster 2026. 1. 28. 10:16

들어가며

public String getBookTitle(Long id) {
    Book book = bookRepository.findById(id);
    return book.getTitle();  // book이 null이면?
}

 

Spring Data JPA의 findById()는 Optional<T>를 반환합니다. 위 코드처럼 직접 엔티티를 반환받으려 하면 컴파일 에러가 나죠. 이 글에서는 왜 JPA가 Optional을 선택했는지, 그리고 이를 어떻게 활용해야 하는지 정리합니다.

 

Optional이란?

Optional은 "값이 있을 수도 있고 없을 수도 있다"를 명시적으로 표현하는 컨테이너입니다. 맞습니다. 슈뢰딩거의 고양이입니다. 컴파일러는 값의 존재 여부를 알 수 없으니, 런타임에 조치하라고 강제하는 셈입니다.

// null을 직접 다루는 방식
Book book = findBook(id);
if (book != null) {
    return book.getTitle();
}
return "Unknown";

// Optional 방식
return findBook(id)
    .map(Book::getTitle)
    .orElse("Unknown");

 

또 코드를 읽는 사람에게 메서드 시그니처만으로 "이 값은 없을 수 있다"는 걸 알릴 수 있습니다. null 체크를 빼먹는 실수를 컴파일러가 잡아주지는 않지만, 코드 자체가 인지를 전달합니다.

 
// null 반환 가능성이 숨겨져 있음
Book findByIsbn(String isbn);

// null 가능성이 명시됨
Optional<Book> findByIsbn(String isbn);

 

Spring Data JPA에서의 활용

기본 패턴

@Service
@RequiredArgsConstructor
public class BookService {
    
    private final BookRepository bookRepository;
    
    public Book findBook(Long id) {
        return bookRepository.findById(id)
            .orElseThrow(() -> new BookNotFoundException(id));
    }
    
    public BookResponse findBookResponse(Long id) {
        return bookRepository.findById(id)
            .map(this::toResponse)
            .orElseThrow(() -> new BookNotFoundException(id));
    }
}

 

orElseThrow()를 통해 null 일 경우에 대한 조치를 지시합니다.

 

orElse vs orElseGet

이 둘의 차이를 모르면 의도치 않은 성능 문제가 발생합니다.

// orElse: 값이 있어도 항상 실행됨
Book book = bookOpt.orElse(createDefaultBook());

// orElseGet: 값이 없을 때만 실행됨
Book book = bookOpt.orElseGet(() -> createDefaultBook());

 

orElse()는 인자를 항상 평가합니다. 대체값 생성에 비용이 든다면(DB 조회, 객체 생성 등) orElseGet()을 사용해야 합니다.

 

map과 filter

연관 엔티티를 안전하게 탐색하거나 조건을 걸 때 유용합니다.

// 저자 이름 안전하게 가져오기
Optional<String> authorName = bookRepository.findById(id)
    .map(Book::getAuthor)
    .map(Author::getName);

// 재고가 있는 책만 반환
public Optional<Book> findAvailableBook(Long id) {
    return bookRepository.findById(id)
        .filter(book -> book.getStock() > 0);
}

 

주의사항

필드로 사용하지 않기

Optional은 반환 타입으로 설계되었습니다. JPA 엔티티 필드로는 사용할 수 없습니다.

// ❌ 잘못된 사용
@Entity
public class Book {
    private Optional<Author> author;
}

// ✅ 올바른 사용
@Entity
public class Book {
    @ManyToOne
    private Author author;
    
    public Optional<Author> getAuthorOptional() {
        return Optional.ofNullable(author);
    }
}

 

컬렉션에는 사용하지 않기

빈 컬렉션을 반환하는 것이 더 좋은 설계입니다.

// 불필요
Optional<List<Book>> findByAuthor(String author);

// 빈 리스트 반환
List<Book> findByAuthor(String author);

 

이 경우에는 리스트를 다루는 일관된 설계 원칙이 필요합니다. 일반적으로 JPA에서 반환시에는 null 리스트를 반환하지 않습니다. 따라서 이를 사용하는 비즈니스 로직에서도 일관성을 유지해야 합니다.

 

isPresent() + get() 안티패턴

Optional의 이점을 무시하는 코드입니다.

// null 체크와 다를 바 없음
if (bookOpt.isPresent()) {
    return bookOpt.get().getTitle();
}
return "Unknown";

// 직관적 사용법
return bookOpt.map(Book::getTitle).orElse("Unknown");

 

of() vs ofNullable()

Optional.of(book);          // book이 null이면 NPE
Optional.ofNullable(book);  // book이 null이면 Optional.empty()

값이 확실히 null이 아닐 때만 of()를 사용합니다.

 

마치며

Optional은 개발자에게 아주 큰 지표가 되지만 만능은 아닙니다. 핵심은 "값이 없을 수 있다"를 명시적으로 표현하는 것입니다. 그렇기 때문에 Optional을 사용하는 개발자의 일관된 철학이 필요합니다.

  • Repository 조회 결과는 Optional로 반환
  • 서비스 계층에서 orElseThrow()로 예외 처리
  • 필드나 파라미터에는 사용 금지
  • 컬렉션은 빈 리스트로 반환
  • isPresent() + get() 대신 map(), orElse() 활용

 

참고 자료