Categories
웹개발/Java
Java Optional 사용 기준, null 처리를 더 복잡하게 만들지 않는 방법
Java에서 null 처리를 줄이려고 Optional을 도입했는데, 막상 코드를 보면 Optional.ofNullable()이 곳곳에 흩어지고 isPresent() 뒤에 get()을 호출하는 코드만 늘어나는 경우가 있다. 이런 상태라면 null 체크가 줄어든 것이 아니라, null 체크가 Optional 모양으로 바뀐 것이다.
Optional은 null을 완전히 없애는 문법이 아니다. "이 메서드의 결과는 없을 수 있다"는 사실을 호출하는 쪽에 명확히 알려주는 도구에 가깝다. 그래서 어디에 쓰느냐가 중요하다. 반환값에는 도움이 되지만, DTO 필드나 메서드 파라미터에 무분별하게 넣으면 오히려 코드가 더 읽기 어려워진다.
핵심 결론: Optional은 null 체크를 예쁘게 감싸는 도구가 아니라, 결과가 없을 수 있는 메서드의 계약을 드러내는 도구다.
확인 순서: 반환값인가 → 결과 없음이 정상 흐름인가 → 기본값이 필요한가 → 예외로 끊어야 하는가 → DTO/필드/파라미터에 들어가 있지는 않은가.
Optional을 쓰기 전에 먼저 정리할 것
Optional을 쓰기 전에 먼저 정해야 하는 것은 "이 값이 없을 수 있는가"가 아니다. 더 중요한 질문은 "값이 없을 때 정상 흐름인가, 오류인가"이다. 검색 결과가 없는 것은 정상 흐름일 수 있지만, 주문 상세 조회에서 주문번호가 없는 것은 요청 오류일 수 있다.
이 구분 없이 모든 null을 Optional로 감싸면 코드가 복잡해진다. 예를 들어 반드시 있어야 하는 회원 ID가 null인데 Optional.empty()로 넘기면, 호출하는 쪽은 정상적으로 값이 없는 상황인지 잘못된 요청인지 다시 판단해야 한다. 결국 오류를 늦게 발견하게 된다.
반대로 조회 결과가 없을 수 있는 메서드는 Optional이 잘 맞는다. DB에서 회원을 찾았는데 없을 수 있고, 캐시에서 값을 꺼냈는데 없을 수 있으며, 설정값을 읽었는데 등록되지 않았을 수 있다. 이런 경우에는 반환 타입에 Optional을 쓰면 호출자가 "없을 수 있음"을 무시하기 어렵다.
Optional이 잘 맞는 위치
조회 결과가 없을 수 있는 반환값
Optional이 가장 자연스러운 위치는 메서드 반환값이다. 특히 단건 조회처럼 결과가 있을 수도 있고 없을 수도 있는 경우에 잘 맞는다.
public Optional<User> findUser(String userId) {
User user = userMapper.selectUser(userId);
return Optional.ofNullable(user);
}
이렇게 반환하면 호출하는 쪽은 결과 없음에 대해 명시적으로 처리해야 한다.
User user = userService.findUser(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
이 코드는 단순히 NPE를 피하는 코드가 아니다. 사용자가 없으면 이후 로직으로 넘어가지 않겠다는 정책을 코드에 남긴 것이다. 실무에서는 이 차이가 중요하다. null을 조용히 통과시키면 나중에 더 먼 위치에서 예외가 나지만, 반환 직후에 끊으면 원인과 위치가 명확하다.
MyBatis 단건 조회는 결과가 없을 때 null을 반환하는 방식으로 작성되는 경우가 많다. 이때 Mapper 결과를 그대로 서비스 밖으로 넘기지 말고, 서비스 계층에서 Optional 또는 명시적 예외로 정책을 정하는 편이 좋다. 조회 객체의 필드 매핑까지 함께 확인해야 하는 경우는 MyBatis 매핑 차이를 먼저 보면 원인을 좁히기 쉽다.
캐시나 설정값처럼 없을 수 있는 값
캐시 조회, 환경 설정, 선택 옵션처럼 값이 없어도 이상하지 않은 경우에도 Optional이 잘 맞는다. 이런 값은 null 자체가 오류라기보다 "등록된 값이 없음"이라는 상태를 의미한다.
public Optional<String> findDisplayName(String userId) {
return Optional.ofNullable(displayNameCache.get(userId));
}
호출하는 쪽에서는 기본값을 줄 수 있다.
String displayName = profileService.findDisplayName(userId)
.orElse("이름 없음");
이런 코드는 읽는 사람이 바로 의도를 알 수 있다. 표시 이름이 없으면 "이름 없음"으로 보여준다는 정책이 한 줄에 드러난다. 반면 null을 반환하고 호출부에서 따로 if문을 쓰면 같은 의미라도 코드가 흩어진다.
실무 팁: Optional은 "없어도 되는 값"과 "없으면 안 되는 값"을 구분한 뒤에 써야 한다. 이 구분 없이 Optional을 붙이면 null 체크보다 더 복잡한 코드가 된다.
Optional을 피하는 것이 좋은 위치
DTO 필드에 Optional을 넣는 경우
DTO 필드에 Optional을 넣는 코드는 대부분 피하는 편이 낫다. DTO는 계층 사이에서 데이터를 옮기는 구조인데, 필드마다 Optional이 들어가면 직렬화, 바인딩, 템플릿 처리, MyBatis 매핑에서 불필요한 복잡도가 생긴다.
public class UserResponse {
private Optional<String> email; // 권장하지 않음
}
이런 구조는 사용하는 쪽마다 getEmail().orElse("")처럼 Optional을 풀어야 한다. 화면 응답 DTO라면 값이 없을 때 빈 문자열로 보낼지, 필드를 생략할지, null로 보낼지 API 기준을 정하는 편이 더 명확하다.
public class UserResponse {
private String email; // 응답 정책에 따라 null 또는 빈 문자열 사용
}
Optional은 반환 타입에서 의미가 크다. 필드에 들어가면 "이 필드는 없을 수 있음"이라는 의미보다 "이 필드를 꺼낼 때마다 한 번 더 포장을 벗겨야 함"이라는 부담이 더 커지는 경우가 많다.
메서드 파라미터에 Optional을 받는 경우
메서드 파라미터로 Optional을 받는 것도 조심해야 한다. 호출하는 쪽이 매번 Optional.ofNullable(value)을 만들어 넘겨야 하기 때문이다.
public List<User> searchUsers(Optional<String> keyword) {
String condition = keyword.orElse("");
return userMapper.selectUsers(condition);
}
이 코드는 호출부를 더 번거롭게 만든다.
List<User> users = service.searchUsers(Optional.ofNullable(keyword));
검색 조건처럼 입력값이 선택 사항이라면 파라미터는 일반 타입으로 받고, 메서드 내부의 경계에서 정책을 정하는 편이 낫다.
public List<User> searchUsers(String keyword) {
String condition = keyword == null ? "" : keyword.trim();
return userMapper.selectUsers(condition);
}
입력 경계에서 값을 정리하면 서비스 내부 흐름이 단순해진다. 특히 Spring MVC 컨트롤러에서 요청 파라미터를 받을 때는 Optional 파라미터보다 기본값, 필수 여부, 검증 규칙을 명확히 두는 것이 유지보수에 유리하다.
컬렉션을 Optional로 감싸는 경우
목록 조회 결과를 Optional<List<T>>로 감싸는 것도 보통은 필요하지 않다. 목록은 결과가 없으면 빈 리스트를 반환하면 된다.
public Optional<List<User>> findUsers() {
return Optional.ofNullable(userMapper.selectUsers());
}
이렇게 만들면 호출하는 쪽은 Optional도 풀고 리스트도 확인해야 한다. 대부분의 경우 아래처럼 빈 리스트를 반환하는 편이 낫다.
public List<User> findUsers() {
List<User> users = userMapper.selectUsers();
return users == null ? Collections.emptyList() : users;
}
목록 조회에서 "결과 없음"은 예외가 아니라 빈 결과다. Optional은 단건 조회처럼 값 하나의 존재 여부가 의미를 가질 때 더 어울린다.
orElse, orElseGet, orElseThrow 차이
Optional을 쓰다 보면 가장 자주 헷갈리는 메서드가 orElse, orElseGet, orElseThrow이다. 셋은 모두 값이 없을 때의 처리지만, 비용과 의미가 다르다.
| 메서드 | 용도 | 주의할 점 |
|---|---|---|
orElse | 값이 없을 때 즉시 준비된 기본값 사용 | 기본값 생성 코드가 항상 실행될 수 있음 |
orElseGet | 값이 없을 때만 기본값을 늦게 생성 | 계산 비용이 있거나 DB/API 호출이면 이쪽이 적합 |
orElseThrow | 값이 없으면 예외로 흐름 중단 | 필수 데이터 조회 실패처럼 정상 진행이 불가능할 때 사용 |
ifPresent | 값이 있을 때만 처리 | 복잡한 분기 로직에는 어울리지 않음 |
map | 값이 있을 때 변환 | 중첩 Optional이 생기지 않게 반환 타입을 확인 |
orElse는 기본값이 가벼울 때 쓴다
orElse는 기본값이 이미 정해져 있고 비용이 거의 없을 때 쓰기 좋다.
String name = user.getNickname()
.orElse("이름 없음");
이런 문자열 기본값은 부담이 없다. 하지만 기본값을 만드는 과정에서 메서드를 호출한다면 조심해야 한다.
User user = findUser(userId)
.orElse(createGuestUser());
이 코드는 Optional 안에 값이 있어도 createGuestUser()가 먼저 실행될 수 있다. 기본값 생성 비용이 크거나 부작용이 있는 코드라면 문제가 된다.
orElseGet은 값이 없을 때만 실행한다
기본값을 만드는 데 비용이 있거나, 값이 없을 때만 실행해야 하는 코드라면 orElseGet을 쓴다.
User user = findUser(userId)
.orElseGet(() -> createGuestUser());
이 경우 createGuestUser()는 Optional이 비어 있을 때만 실행된다. 실무에서는 기본값을 만들기 위해 DB를 한 번 더 조회하거나, 외부 API를 호출하거나, 로그를 남기는 코드가 들어갈 수 있다. 이런 코드는 orElse보다 orElseGet이 안전하다.
orElseThrow는 필수값 누락을 빠르게 드러낸다
값이 없으면 이후 로직이 의미가 없는 경우에는 orElseThrow가 적합하다.
Order order = orderService.findOrder(orderId)
.orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));
주문 상세 화면에서 주문이 없는데 빈 주문 객체를 만들어 계속 진행하면 더 큰 혼란이 생긴다. 이때는 예외로 끊는 편이 낫다. 이 기준은 null 처리와 예외 처리를 나누는 기준이기도 하다. 잘못된 요청인지, 결과 없음이 정상인지, 기본값으로 대체 가능한지를 먼저 정해야 한다.
주의할 점: Optional을 썼다고 해서 예외 처리가 사라지는 것은 아니다. 값이 없을 때 기본값으로 갈지, 예외로 끊을지를 더 명확하게 써야 한다.
Optional 안티패턴
isPresent 후 get을 바로 호출하는 코드
가장 흔한 안티패턴은 isPresent()로 확인한 뒤 get()을 호출하는 코드다.
Optional<User> userOpt = findUser(userId);
if (userOpt.isPresent()) {
User user = userOpt.get();
return user.getName();
}
return "이름 없음";
이 코드는 동작은 하지만 Optional을 쓰는 장점이 크지 않다. 기존 null 체크와 구조가 거의 같다. 단순 변환이라면 map과 orElse로 의도를 더 짧게 표현할 수 있다.
String name = findUser(userId)
.map(User::getName)
.orElse("이름 없음");
다만 모든 if문을 map 체인으로 바꿔야 하는 것은 아니다. 분기가 복잡하거나 로그, 검증, 여러 단계의 처리가 들어가면 일반 if문이 더 읽기 좋다. Optional을 쓰는 목적은 한 줄로 줄이는 것이 아니라 의도를 분명히 하는 것이다.
Optional을 null로 반환하는 코드
Optional을 반환한다고 선언해 놓고 null을 반환하면 더 위험하다.
public Optional<User> findUser(String userId) {
if (userId == null) {
return null; // 절대 피해야 함
}
return Optional.ofNullable(userMapper.selectUser(userId));
}
이 코드는 호출하는 쪽이 Optional을 믿고 사용하다가 다시 NPE를 만난다. Optional 반환 메서드는 값이 없으면 반드시 Optional.empty()를 반환해야 한다.
public Optional<User> findUser(String userId) {
if (userId == null || userId.trim().isEmpty()) {
return Optional.empty();
}
return Optional.ofNullable(userMapper.selectUser(userId));
}
필수 파라미터라면 Optional.empty()보다 예외가 더 맞을 수도 있다. 여기서도 핵심은 정책이다. userId가 없어도 정상 흐름인지, 잘못된 호출인지 판단해야 한다.
Optional 체인이 너무 길어지는 코드
Optional의 map, filter, flatMap을 이어 붙이면 짧아 보이지만, 체인이 길어지면 디버깅이 어렵다.
String result = findUser(userId)
.map(User::getProfile)
.map(Profile::getAddress)
.map(Address::getCity)
.filter(city -> city.length() > 1)
.orElse("미등록");
단순 표시값을 만드는 정도라면 괜찮다. 하지만 중간 단계마다 다른 예외 처리, 로그, 검증이 필요하다면 일반 코드로 풀어 쓰는 편이 낫다.
Optional<User> userOpt = findUser(userId);
if (userOpt.isEmpty()) {
return "미등록";
}
Profile profile = userOpt.get().getProfile();
if (profile == null) {
return "미등록";
}
이 예제처럼 다시 get()을 쓰는 것이 항상 좋다는 뜻은 아니다. 복잡한 업무 흐름에서는 Optional 체인을 무리하게 유지하는 것보다 중간 변수와 명시적 분기가 더 읽기 쉽다는 뜻이다.
Spring과 MyBatis에서 Optional을 어떻게 볼 것인가
Spring, MyBatis 기반 프로젝트에서는 Optional을 도메인 전체에 퍼뜨리기보다 서비스 경계에서 정리하는 편이 좋다. Mapper는 DB 결과를 가져오는 역할에 집중하고, 서비스가 조회 결과 없음의 의미를 판단한다.
public Optional<User> findUser(String userId) {
return Optional.ofNullable(userMapper.selectUser(userId));
}
Mapper XML이나 인터페이스 설정이 잘못되면 Optional 이전에 조회 자체가 실패하거나, 객체는 만들어졌지만 필드가 비는 문제가 생긴다. Mapper가 Bean으로 잡히지 않는 문제는 MapperScan을 먼저 확인하고, 전체 연동 흐름은 MyBatis 연동 설정에서 의존성, SqlSessionFactory, Mapper 스캔 순서를 함께 보는 것이 좋다. MyBatis를 왜 쓰는지부터 헷갈린다면 MyBatis 비교처럼 JDBC, JPA와 역할을 나눠 보는 편이 이해가 빠르다.
또 하나 주의할 점은 컨트롤러 응답이다. API 응답 DTO에 Optional 필드를 그대로 노출하면 JSON 결과가 예상과 다르게 나가거나, 프론트에서 다루기 어려운 구조가 될 수 있다. API 경계에서는 Optional을 풀어서 명확한 응답 정책으로 바꾸는 편이 낫다.
실무에서 바로 쓰는 기준
- 단건 조회 반환값에는 Optional을 고려한다. 결과가 없을 수 있다는 사실을 호출자에게 드러낼 수 있다.
- 목록 조회는 빈 리스트를 반환한다.
Optional<List<T>>는 대부분 불필요하다. - DTO 필드에는 Optional을 넣지 않는다. 응답 정책이나 입력 검증 정책으로 해결하는 편이 단순하다.
- 파라미터로 Optional을 받지 않는다. 호출부가 불필요하게 복잡해진다.
- 기본값 생성 비용이 있으면 orElseGet을 쓴다. 값이 있을 때 불필요한 코드가 실행되는 일을 막을 수 있다.
- 필수 데이터가 없으면 orElseThrow로 빨리 끊는다. 잘못된 흐름을 뒤로 미루지 않는다.
자주 묻는 질문
- Q. Optional을 쓰면 NullPointerException이 없어지나요? A. 없어지는 것이 아니라 null 가능성을 더 명확하게 표현할 수 있습니다. Optional을 반환해도 호출자가 잘못 사용하면 여전히 오류가 날 수 있습니다.
- Q. Optional은 어디에 쓰는 것이 가장 적절한가요? A. 결과가 없을 수 있는 메서드 반환값에 가장 적절합니다. 단건 조회, 캐시 조회, 선택 설정값 조회처럼 부재가 의미 있는 경우에 잘 맞습니다.
- Q. DTO 필드에 Optional을 넣어도 되나요? A. 일반적으로 권장하지 않습니다. DTO는 데이터 전달 구조이므로 Optional 필드보다 응답 정책, 기본값, 검증 규칙으로 처리하는 편이 좋습니다.
- Q. orElse와 orElseGet은 무엇이 다른가요? A. orElse는 기본값 표현식이 먼저 평가될 수 있고, orElseGet은 Optional이 비었을 때만 Supplier가 실행됩니다. 기본값 생성 비용이 있으면 orElseGet이 적합합니다.
- Q. Optional에서 get을 쓰면 안 되나요? A. 무조건 금지는 아니지만,
isPresent()후get()만 반복하면 Optional의 장점이 줄어듭니다.map,orElse,orElseThrow로 의도를 표현할 수 있는지 먼저 봅니다. - Q. MyBatis Mapper 반환 타입을 Optional로 바로 선언해도 되나요? A. 프로젝트 설정과 MyBatis 버전에 따라 다르게 봐야 합니다. 실무에서는 Mapper는 기존 방식으로 두고 서비스 계층에서 Optional로 감싸 정책을 정하는 방식이 더 예측 가능할 때가 많습니다.
정리
Optional은 null 체크를 없애는 마법 문법이 아니다. 결과가 없을 수 있는 메서드의 의미를 반환 타입으로 드러내고, 호출자가 기본값·예외·무시 중 하나를 선택하게 만드는 도구다.
실무에서는 단건 조회 반환값, 캐시 조회, 선택 설정값처럼 부재가 의미 있는 곳에 제한적으로 쓰는 편이 좋다. DTO 필드, 메서드 파라미터, 목록 조회 결과까지 Optional로 감싸면 오히려 코드가 더 복잡해진다.
Optional을 잘 쓰는 기준은 많이 쓰는 것이 아니라, 값이 없을 수 있다는 계약을 가장 읽기 쉬운 위치에 남기는 것이다.
'웹개발 > Java' 카테고리의 다른 글
| JSP pagination page link 만들기, 게시판 페이지 번호 처리 기준 (0) | 2026.07.11 |
|---|---|
| Java equals hashCode 차이, HashMap과 Set에서 문제가 되는 이유 (0) | 2026.07.07 |
| Java NullPointerException 원인과 해결 방법, 실무에서 먼저 확인할 부분 (0) | 2026.07.06 |
| Java 기초 핵심 개념, 실무에서 자주 헷갈리는 부분 정리 (1) | 2026.07.06 |
| 자바 MapReduce로 대용량 데이터 추출하기: 날짜·도시 필터 + 사용자 합계 실전 예제 (1) | 2025.12.03 |