본문 바로가기

Programming/JAVA

Java Optional orElse() 함정: 값이 있어도 DB 저장 코드가 실행되는 이유

추천캐릭터 2026. 7. 31. 08:00
728x90

Java Optional orElse() 함정: 값이 있어도 DB 저장 코드가 실행되는 이유

회원 조회 코드에 Optional을 적용했는데, 이미 회원이 존재해도 신규 회원 생성 로그가 남거나 INSERT 쿼리가 실행되는 경우가 있습니다. 원인은 대개 Optional 자체가 아니라 orElse()에 전달한 표현식의 실행 시점을 잘못 이해한 데 있습니다.

orElse()orElseGet()은 빈 값일 때 대체 값을 반환한다는 점은 같지만, 대체 값을 준비하는 시점이 다릅니다. 이 차이는 단순한 성능 문제를 넘어 DB 저장, 외부 API 호출, 파일 생성 같은 부수 효과를 일으킬 수 있습니다.

Optional은 무엇을 표현하는가

Optional<T>은 값이 있거나 없을 수 있음을 표현하는 컨테이너입니다. null을 그대로 반환하는 대신 호출자에게 “결과가 없을 수 있다”는 사실을 타입으로 알려 줍니다.

public interface MemberRepository {
    Optional<Member> findByEmail(String email);
}

Oracle 문서도 Optional을 결과 없음이 분명한 메서드의 반환 타입으로 주로 사용하도록 설명합니다. Optional 변수 자체에 null을 넣어서는 안 됩니다.

orElse와 orElseGet의 핵심 차이

메서드 대체 값의 형태 대체 로직 실행 시점 적합한 상황
orElse(value) 이미 계산된 값 메서드를 호출하기 전에 평가 상수, 가벼운 기본값
orElseGet(supplier) 값을 만드는 함수 Optional이 비었을 때만 실행 DB 저장, 객체 생성, 외부 호출
orElseThrow(supplier) 예외 생성 함수 Optional이 비었을 때만 실행 값이 없으면 오류인 조회

Java는 메서드를 호출하기 전에 인수를 먼저 평가합니다. 따라서 아래 코드에서 Optional 안에 "member"가 있어도 createGuest()는 실행됩니다.

import java.util.Optional;

public class OptionalEvaluationExample {

    private static int createCount;

    public static void main(String[] args) {
        String eager = Optional.of("member")
                .orElse(createGuest());

        System.out.println(eager);       // member
        System.out.println(createCount); // 1

        createCount = 0;

        String lazy = Optional.of("member")
                .orElseGet(OptionalEvaluationExample::createGuest);

        System.out.println(lazy);        // member
        System.out.println(createCount); // 0
    }

    private static String createGuest() {
        createCount++;
        return "guest";
    }
}

orElseGet()에는 값이 아니라 Supplier가 전달되므로, 위 메서드 참조는 Optional이 비어 있을 때만 호출됩니다.

실무에서 문제가 되는 코드

이메일로 회원을 찾고, 없으면 가입시키는 코드를 다음처럼 작성했다고 가정해 보겠습니다.

@Transactional
public Member findOrCreate(String email) {
    return memberRepository.findByEmail(email)
            .orElse(memberRepository.save(new Member(email)));
}

조회 결과가 있어도 save()orElse()를 호출하기 전에 실행됩니다. 반환값은 기존 회원이지만 신규 엔티티 저장은 이미 시도된 상태입니다. 이메일에 UNIQUE 제약이 있다면 예외가 발생하고, 제약이 없다면 중복 데이터까지 생길 수 있습니다.

대체 로직을 람다로 감싸야 합니다.

@Transactional
public Member findOrCreate(String email) {
    return memberRepository.findByEmail(email)
            .orElseGet(() ->
                    memberRepository.save(new Member(email))
            );
}

이제 save()는 조회 결과가 없을 때만 실행됩니다. 객체 생성 비용이 작더라도 생성자에서 시간 조회, UUID 발급, 이벤트 기록 등을 한다면 같은 기준을 적용하는 편이 안전합니다.

다만 orElseGet()이 동시성 문제까지 해결하지는 않습니다. 두 요청이 동시에 빈 결과를 보고 저장할 수 있습니다. 이메일에는 DB UNIQUE 제약을 두고 충돌 처리 전략도 마련해야 합니다.

값이 반드시 있어야 한다면 orElseThrow

없는 회원을 새로 만들지 않고 404 오류로 처리해야 한다면 orElseGet()보다 orElseThrow()가 의도를 잘 드러냅니다.

public Member getMember(Long memberId) {
    return memberRepository.findById(memberId)
            .orElseThrow(() ->
                    new MemberNotFoundException(memberId)
            );
}

get()도 빈 Optional에서 NoSuchElementException을 던지지만, 어떤 비즈니스 상황에서 실패했는지 표현하기 어렵습니다. Oracle 문서 역시 get()보다 orElseThrow()를 우선적인 대안으로 안내합니다.

자주 하는 실수

첫째, 모든 기본값에 무조건 orElseGet()을 쓰는 것입니다. "UNKNOWN"처럼 계산이 필요 없는 값은 orElse()가 더 간결합니다.

둘째, 엔티티나 DTO의 모든 필드에 Optional을 사용하는 것입니다. JPA 매핑과 직렬화에서는 복잡도만 높일 수 있습니다.

셋째, Optional을 반환하면서 실제로는 null을 반환하는 것입니다. 결과가 없으면 Optional.empty(), 값이 null일 수 있다면 Optional.ofNullable(value)를 사용해야 합니다.

넷째, 지연 실행을 성능 최적화로만 보는 것입니다. save(), 메시지 발행, 외부 API 호출처럼 상태를 바꾸는 작업이라면 실행 여부가 곧 데이터 정합성 문제입니다.

핵심 요약

  • orElse()의 인수는 Optional에 값이 있어도 먼저 평가됩니다.
  • 비용이 들거나 부수 효과가 있는 대체 로직은 orElseGet()으로 감쌉니다.
  • 값이 반드시 필요하면 get() 대신 의미 있는 예외를 만드는 orElseThrow()를 사용합니다.
  • findOrCreate에는 DB UNIQUE 제약과 동시성 충돌 처리도 필요합니다.
  • Optional은 주로 “결과가 없을 수 있는 메서드 반환값”을 표현하는 데 사용합니다.

Java의 작은 API 차이는 운영 데이터에 큰 차이를 만들 수 있습니다. 반환값만 같다고 두 메서드를 바꿔 써서는 안 됩니다. 대체 값이 단순 상수인지, 실제 작업을 수행하는 코드인지 먼저 확인하면 orElse()로 인한 예상 밖의 실행을 피할 수 있습니다.

함께 읽으면 좋은 글:

최종 확인: 2026-07-30

출처: Oracle Java SE 25 Optional API

728x90