| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 프로그래머스
- 멀티스레드
- 코딩공부
- 객체지향
- 코딩인터뷰
- 코딩테스트
- 자바
- 개발자취업
- 프로그래밍기초
- 코딩테스트준비
- 코딩테스트팁
- 백준
- 자바공부
- 자료구조
- HashMap
- 메모리관리
- 정렬
- 개발자팁
- Java
- JVM
- 클린코드
- 개발공부
- 알고리즘
- 예외처리
- 가비지컬렉션
- 자바프로그래밍
- 자바기초
- 알고리즘공부
- 자바개발
- 파이썬
- Today
- Total
코드 한 줄의 기록
Java Optional로 NPE를 잡아보자 - 실무 활용 가이드 본문
개발을 하다 보면 정말 자주 마주치는 에러가 있다. 바로 NullPointerException, 즉 NPE다. 처음에는 이게 뭐 하는 에러인가 싶겠지만, 경력이 쌓이면서 "아, 또 이거네?"라고 한숨이 나오는 그런 에러다. 특히 배포된 서버에서 갑자기 NPE가 뜨면 정말 답답하다.
나도 PHP에서 Java로 넘어올 때 처음에는 계속 NPE를 만났다. 약 3년간의 Java 개발 경험 동안, 이 문제를 해결하기 위해 여러 방법을 시도해봤는데, 그 중에서 가장 우아하고 효율적인 방법이 바로 Optional이다. 이번 글에서는 내가 실제로 프로젝트에서 경험한 Optional 활용법을 공유해보겠다.
NPE, 정말 뭐가 문제일까?
먼저 NPE가 정확히 어떤 상황에서 발생하는지 알아보자. 간단한 예제부터 시작하겠다.
public class User {
private String name;
private String email;
// getter 생략
}
// 사용 코드
User user = findUserById(1L); // DB에서 사용자 조회, 없을 수도 있음
String name = user.getName(); // 여기서 NPE 발생 가능!
만약 데이터베이스에서 사용자를 찾지 못했다면 findUserById()는 null을 반환할 것이고, null 객체의 getName() 메서드를 호출하려니 NPE가 터진다. 이 상황을 해결하려면 어떻게 해야 할까?
기존의 방식: if-null 체크
많은 개발자들은 이런 상황에서 다음과 같이 처리했다.
User user = findUserById(1L);
String name = "Unknown"; // 기본값
if (user != null) {
if (user.getProfile() != null) {
if (user.getProfile().getName() != null) {
name = user.getProfile().getName();
}
}
}
이 코드를 보면 어떤 느낌이 들까? 맞다, 너무 지저분하다. 이걸 개발자들 사이에선 "null 지옥"이라고 부른다. 유지보수하기도 힘들고, 코드도 길어지고, 실수하기도 쉽다.
Optional이 뭐길래?
Java 8에서 나온 Optional은 이 문제를 해결하기 위한 클래스다. 생각해보면 간단한데, 값이 있을 수도 없을 수도 있는 상황을 명확하게 표현하자는 거다. 즉, 메서드를 보면 이게 null을 반환할 가능성이 있구나, 처리해야겠구나 하고 한눈에 알 수 있다는 뜻이다.
// 기존: 반환값이 null일 수도 있는데 코드로는 알 수 없음
public User findUserById(Long id) {
// ...
}
// Optional 사용: 아, 이건 null일 수도 있겠네!
public Optional findUserById(Long id) {
// ...
}
이게 Optional을 쓰는 첫 번째 이유다. 명시적이다.
Optional 만드는 방법
Optional을 효과적으로 쓰려면 먼저 Optional 객체를 만드는 방법부터 알아야 한다.
Optional.of() - 값이 확실히 있을 때
Optional optional = Optional.of("hello");
이 방법은 값이 100% 있다는 것을 확신할 때만 사용한다. 만약 null을 넣으면 바로 NPE가 터진다. 즉, NPE를 방지하려고 Optional을 쓰는데, Optional 생성할 때 NPE가 나면 본말이 전도되는 거다.
Optional.empty() - 값이 없을 때
Optional optional = Optional.empty();
명시적으로 "여기는 값이 없어"라고 표현하는 방법이다. 특히 Optional을 반환하는 메서드에서 중요하다. 절대 null을 반환하지 말고 이걸 쓰자.
// 잘못된 사용
public Optional findUserById(Long id) {
User user = userRepository.findById(id);
return null; // 이렇게 하면 안 됨!
}
// 올바른 사용
public Optional findUserById(Long id) {
User user = userRepository.findById(id);
return Optional.empty(); // 이렇게 해야 함
}
Optional.ofNullable() - null일 수도, 아닐 수도 있을 때
String value = getUserInput(); // null일 수도 있고 아닐 수도 있음
Optional optional = Optional.ofNullable(value);
이게 가장 현실적이고 가장 자주 쓰는 방법이다. API 응답, 데이터베이스 조회, 사용자 입력 등 언제 null이 올지 모를 때 이걸 쓴다.
Optional 제대로 쓰는 방법
이제 Optional을 만드는 방법은 알았다. 근데 만든 이후에 어떻게 사용할지가 중요하다.
isPresent() - 값이 있는지 확인
Optional user = findUserById(1L);
if (user.isPresent()) {
System.out.println(user.get().getName());
}
하지만 이 방법은 여전히 if-null 체크와 다를 게 없다. 좀 더 functional한 방법을 쓰는 게 낫다.
ifPresent() - 값이 있으면 실행
Optional user = findUserById(1L);
user.ifPresent(u -> System.out.println(u.getName()));
이건 값이 있을 때만 람다식을 실행한다. 훨씬 깔끔하다.
map() - 값 변환하기
Optional user = findUserById(1L);
// null 체크 없이 안전하게 변환
Optional userName = user.map(User::getName);
map()은 값이 있으면 변환하고, 없으면 빈 Optional을 반환한다. 따라서 NPE 걱정이 없다.
orElse() - 값이 없으면 기본값 반환
String name = findUserById(1L)
.map(User::getName)
.orElse("Unknown User");
값이 있으면 그 값을 반환하고, 없으면 "Unknown User"를 반환한다. 깔끔하지 않은가?
orElseGet() - 값이 없으면 함수로 생성
String name = findUserById(1L)
.map(User::getName)
.orElseGet(() -> getDefaultUserName());
orElse()와의 차이점은 뭘까? orElse()는 조건에 관계없이 항상 인자를 평가한다. 하지만 orElseGet()은 값이 없을 때만 함수를 호출한다. 따라서 비용이 큰 작업(DB 조회, API 호출 등)을 하려면 orElseGet()을 써야 한다.
// 좋지 않은 사용
public String getUserName(Long id) {
return findUserById(id)
.map(User::getName)
.orElse(loadDefaultUserNameFromDatabase()); // 항상 DB 조회됨
}
// 좋은 사용
public String getUserName(Long id) {
return findUserById(id)
.map(User::getName)
.orElseGet(() -> loadDefaultUserNameFromDatabase()); // 필요할 때만 DB 조회
}
orElseThrow() - 값이 없으면 예외 던지기
User user = findUserById(1L)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
필수적인 데이터가 없을 때는 예외를 던져서 상위 레이어에서 처리하게 한다.
filter() - 조건으로 필터링
Optional activeUser = findUserById(1L)
.filter(user -> user.isActive()); // 활성화된 사용자만
String name = activeUser
.map(User::getName)
.orElse("Unknown or Inactive User");
filter()에 조건을 주면 조건을 만족할 때만 Optional 값이 유지되고, 만족하지 않으면 빈 Optional이 된다.
flatMap() - Optional을 반환하는 메서드와 연결
// Optional을 반환하는 메서드
public Optional getProfileByUser(User user) {
return Optional.ofNullable(user.getProfile());
}
// flatMap() 사용 - Optional을 평탄화
Optional profile = findUserById(1L)
.flatMap(this::getProfileByUser);
map()과 flatMap()의 차이는 중요하다. map()으로 Optional을 반환하는 메서드를 호출하면 Optional<Optional<T>>가 되지만, flatMap()을 쓰면 Optional<T>로 평탄화된다.
실무 예제: 함께 해보자
이제 좀 더 현실적인 예제를 봐보자. 사용자 정보를 조회해서 이름을 출력하고, 없으면 기본값을 쓰는 시나리오다.
Before: 전통적인 방식
public class UserService {
private UserRepository userRepository;
public String getUserDisplayName(Long userId) {
User user = userRepository.findById(userId);
if (user == null) {
return "Unknown User";
}
if (user.getName() == null || user.getName().isEmpty()) {
return "Anonymous";
}
return user.getName();
}
}
코드가 길고, 중첩된 if문이 있고, 유지보수하기 힘들다.
After: Optional 방식
public class UserService {
private UserRepository userRepository;
public String getUserDisplayName(Long userId) {
return userRepository.findById(userId) // Optional
.map(User::getName) // Optional
.filter(name -> !name.isEmpty()) // 빈 문자열 제외
.orElse("Unknown User"); // 기본값
}
}
확실히 더 깔끔하다. 한눈에 로직이 들어온다.
더 복잡한 예제를 보자. 사용자의 프로필에서 프로필 이미지 URL을 가져오는데, 각 단계마다 null일 수 있는 경우다.
// Before
public String getUserProfileImageUrl(Long userId) {
User user = userRepository.findById(userId);
if (user == null) {
return getDefaultImageUrl();
}
Profile profile = user.getProfile();
if (profile == null) {
return getDefaultImageUrl();
}
String imageUrl = profile.getImageUrl();
if (imageUrl == null || imageUrl.isEmpty()) {
return getDefaultImageUrl();
}
return imageUrl;
}
// After
public String getUserProfileImageUrl(Long userId) {
return userRepository.findById(userId) // Optional
.flatMap(user -> Optional.ofNullable(user.getProfile()))
.map(Profile::getImageUrl)
.filter(url -> !url.isEmpty())
.orElseGet(this::getDefaultImageUrl);
}
아, 이게 바로 Optional의 진정한 가치다.
Optional 쓸 때 주의할 점
하지만 Optional을 마냥 좋다고만 쓸 순 없다. 실수하기 쉬운 부분들이 있다.
Optional.get()을 무지막지하게 쓰지 마
// 위험한 사용
Optional user = findUserById(1L);
String name = user.get().getName(); // 값이 없으면 NoSuchElementException!
이건 null 체크 안 하는 것과 똑같다. 차이점은 NPE 대신 NoSuchElementException이 터진다는 것뿐이다.
메서드 파라미터로 Optional을 쓰지 말자
// 좋지 않음
public void processUser(Optional user) {
// ...
}
// 좋은 방식
public void processUser(User user) {
if (user == null) {
throw new IllegalArgumentException("User cannot be null");
}
// ...
}
메서드 파라미터는 호출하는 쪽이 생각해서 null을 처리해야 한다. 메서드 내에서 Optional로 감싸는 건 혼란만 야기한다. 대신 null이 오면 안 된다는 것을 명확하게 하기 위해 @NonNull 애노테이션이나 Objects.requireNonNull()을 쓰는 게 낫다.
public void processUser(User user) {
Objects.requireNonNull(user, "User cannot be null");
// ...
}
Optional을 인스턴스 변수로 쓰지 말자
// 좋지 않음
public class User {
private Optional name;
}
// 좋은 방식
public class User {
private String name;
}
Collection이나 Array를 Optional로 감싸지 말자
// 좋지 않음
public Optional<List> findUsers() {
// ...
}
// 좋은 방식
public List findUsers() {
// 빈 리스트 반환
}
Optional 변수에 null을 넣지 말자
// 최악의 상황
Optional user = null; // 절대 하지 말 것!
// 올바른 사용
Optional user = Optional.empty();
이렇게 하면 Optional을 쓰는 의미가 완전히 없다. null 대신 Optional.empty()를 쓰자.
프리미티브 타입 Optional
요즘 프로젝트에서는 거의 안 쓰지만, 알아둬서 나쁠 건 없다.
Optional age = Optional.of(25); // 박싱된 Integer
OptionalInt age = OptionalInt.of(25); // 박싱 없는 프리미티브
OptionalLong count = OptionalLong.of(100L);
OptionalDouble average = OptionalDouble.of(95.5);
프리미티브 타입용 Optional을 쓰면 박싱/언박싱 오버헤드가 없어서 성능이 좀 더 좋다. 성능이 중요한 부분에서는 이걸 쓸 가치가 있다.
실제 프로젝트 패턴
내가 실제로 프로젝트에서 자주 쓰는 패턴들을 공유하겠다.
패턴 1: Repository 패턴
public interface UserRepository {
Optional findById(Long id);
Optional findByEmail(String email);
List findAll(); // 여러 개는 List로
}
DB 조회는 항상 Optional로 반환한다. 조회 결과가 없을 수 있으니까.
패턴 2: Service 계층
@Service
public class UserService {
private final UserRepository userRepository;
public UserDto getUserInfo(Long id) {
return userRepository.findById(id)
.map(this::convertToDto)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
}
public UserDto updateUserProfile(Long id, UpdateProfileRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
user.updateProfile(request);
return convertToDto(userRepository.save(user));
}
}
패턴 3: Controller 계층
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity getUser(@PathVariable Long id) {
try {
UserDto user = userService.getUserInfo(id);
return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
return ResponseEntity.notFound().build();
}
}
}
요청받은 데이터가 Optional이 필요 없고, Service 계층에서 Optional을 처리한다. 그리고 예외를 던져서 상위에서 처리하게 한다.
간단한 체크리스트
Optional을 쓸 때마다 이 체크리스트를 봐보자.
- Optional이 정말 필요한가? 값이 없을 가능성이 있는가?
- 메서드 반환값으로만 Optional을 쓰는가?
Optional.get()을 쓰기 전에isPresent()체크를 했는가? (아니, 차라리orElse()등을 쓰자)- Optional을 반환하는 메서드에서 null을 반환하진 않는가?
- 값이 없는 경우를 명확하게 처리했는가?
NPE는 자바 개발자가 피할 수 없는 숙제다. 하지만 Optional을 제대로 쓰면 훨씬 안전하고 깔끔한 코드를 만들 수 있다. 특히 체이닝 방식으로 map(), filter(), flatMap() 등을 조합하면 정말 강력하다.
처음에는 Optional이 이상하게 느껴질 수 있다. 하지만 몇 번 써보면 "아, 이렇게 하면 훨씬 낫겠네"라고 깨닫게 된다. 그리고 나중에 코드를 다시 봤을 때도 "어? 내가 null 체크를 안 했는데도 터지지 않네?"라고 놀라게 된다.
Optional을 마스터하면 Java 코드 품질이 한 단계 업그레이드되는 경험을 할 거다. 이번 기회에 프로젝트에 적용해보길 추천한다!
Java 스트림 API 완벽 정리: 중간연산·최종연산·파이프라인 총정리
Java를 사용하다 보면 컬렉션 데이터를 처리할 일이 정말 많다. 예전에는 for문이나 Iterator를 써서 하나씩 처리했는데, Java 8부터 도입된 Stream API를 알게 된 후로는 코드가 훨씬 간결해지고 읽기도
byteandbit.tistory.com
'JAVA' 카테고리의 다른 글
| 자바 파일 및 디렉터리 완벽 가이드: Path와 Files로 배우는 실전 활용법 (0) | 2025.11.06 |
|---|---|
| Java Stream Collectors 완전 정복: groupingBy와 partitioningBy로 데이터 그룹화하기 (0) | 2025.11.06 |
| Java 스트림 API 완벽 정리: 중간연산·최종연산·파이프라인 총정리 (0) | 2025.11.01 |
| Java 함수형 인터페이스와 메서드 레퍼런스 완벽 가이드 (0) | 2025.10.29 |
| Java 제네릭 완벽 가이드: 타입 파라미터부터 와일드카드까지 실전 총정리 (0) | 2025.10.28 |