코드를 보다 보면 아래와 같은 코드를 계속해서 마주친다.
try {
if (user != null) {
if (user.profile != null) {
if (user.profile.email != null) {
sendEmail(user.profile.email)
}
}
}
} catch (Exception e) {
e.printStackTrace()
}
안정성을 위한 코드이지만, 시간이 지나면 로직이 눈에 잘 안들어오고, 오류 처리 흐름과 비즈니스 흐름이 섞이며 디버깅조차 어려워진다.
이런 방어적 코딩의 지옥에서 벗어나기 위해선 어떤 개선 전략들이 필요할까?
1. 왜 null 체크와 try-catch가 많아지는가?
원인 1 : 도메인 설계 부족
- 객체 간 관계를 강하게 표현하지 못함 → null 가능성이 많아짐
- ex) user.profile?.email → 왜 profile이 null일 수 있지?
원인 2 : 예외의 범위를 너무 넓게 잡음
- catch(Exception e)로 모든 예외를 흡수 → 예외의 원인이 불명확해진다
원인 3 : 디폴트 값 처리 전략 부재
- null을 피하려고 임시 디폴트 값을 억지로 넣는 코드가 증가할 수 있다.
실전에서 이런 코드가 만들어낸 문제들은 다음과 같다.
문제 상황 | 실제 결과 |
try-catch가 5겹 중첩 | 로그만 쌓이고, 실패 이유 알기 어려움 |
모든 예외를 캐치 후 무시 | 장애는 발생했는데 아무도 모름 |
null일 때 기본값을 return | 버그가 감춰짐 → 나중에 더 큰 문제 발생 |
2. 실제 개선 전략
2-1 try-catch는 최소한의 범위로 설정한다
잘못된 예시를 살펴보자
try {
// DB 호출
// 로직 처리
// 다른 서비스 호출
// 응답 생성
} catch (Exception e) {
// 어디서 실패했는지 모름
}
위 코드를 개선해보자
User user;
try {
user = userRepository.findById(id);
} catch (DataAccessException e) {
logger.error("DB 오류", e);
throw new ServiceException("사용자 조회 실패");
}
이처럼 실패 구간을 분리하면 디버깅, 로그 추적, 예외 처리가 쉬워진다. 필요하다면 사용자 정의 예외를 만드는 것도 도움이 될 수 있다.
2-2 예외를 로직 처리 수단으로 사용하지 않는다.
잘못된 예시
try {
Product p = findProduct(code);
} catch (ProductNotFoundException e) {
return defaultProduct;
}
존재 여부 판단은 로직으로, 예외는 "진짜 예외'로만 써야 유지보수성이 높아진다.
개선된 코드
Product product = productRepository.findByCode(code);
if (product == null) {
return defaultProduct;
}
2-3 서비스 레이어에서 흐름을 단순화한다.
개선 전
if (user != null) {
if (user.profile != null) {
try {
sendEmail(user.profile.email);
} catch (MessagingException e) {
...
}
}
}
개선 후
fun trySendEmail(user: User?) {
val email = user?.profile?.email ?: return
runCatching { sendEmail(email) }
.onFailure { log.error("이메일 전송 실패", it) }
}
Java에서 null 을 안전하게 처리하는 방법에 대해서도 알아보자
Java에서 null은 피할 수 없는 동료이다. Java는 오랫동안 null을 처리하기 위한 명시적 키워드나 구문을 제공하지 않았다.
그래서 다음과 같은 코드가 자주 등장한다.
if (user != null && user.getProfile() != null && user.getProfile().getEmail() != null) {
sendEmail(user.getProfile().getEmail());
}
그럼 이러한 null 체크가 만들어내는 문제점들은 없을까? 있다..
- 방어코드 증가 : 코드 복잡도가 상승해 가독성이 떨어지고 의도 파악도 하기 힘들어진다.
- 예외 누락 : NPE 외의 다른 에러를 삼킨다. 이렇게 되면 장애 추적에 어려움이 있다.
- 책임 분산 실패 : 데이터가 왜 null인지 명확하게 파악할 수 없다.
개선하기 위한 방법은 다음과 같다.
1. 명확한 설계
예시: 사용자의 프로필은 필수다 vs 선택이다.
class User {
private Profile profile; // null 가능
private String name; // null 불가
}
→ 가능 여부를 명확히 주석 혹은 도메인 명세에 기록한다. 그에 따라 Getter 생성자, API Response를 구분한다.
2. Optional을 반환 값에 사용
Java 8부터 등장한 Optional<T>은 null을 직접 반환하지 않기 위한 Wrapper이다.
개선 전
public User findUser(String id) {
User user = userRepository.findById(id);
if (user == null) {
return new User(); // 무의미한 디폴트
}
return user;
}
개선 후
public Optional<User> findUser(String id) {
return Optional.ofNullable(userRepository.findById(id));
}
Optional을 사용시 주의사항은 return value에만 사용해야 한다는 것이다. 필드에서 사용하게 되면 JPA가 처리하지 못하고 성능 저하를 유발할 수 있다.
3. Null Object 패턴 - 빈 객체를 대신 리턴
null 대신 기본 동작을 하는 객체를 리턴한다.
class NullUser extends User {
public String getName() { return "알 수 없음"; }
public Profile getProfile() { return new NullProfile(); }
}
User user = userService.findOrNullUser();
log.info(user.getProfile().getEmail()); // NPE 발생 X
장점 : 호출자에서 null 체크 생략이 가능하다.
단점 : NullObject가 진짜 의미를 오해할 수 있다.
4. 유틸리티 함수 사용 (Objects 클래스)
if (Objects.nonNull(user) && Objects.nonNull(user.getProfile())) {
...
}
- Objects.nonNull()
- Objects.requireNonNull(obj, "null 불가")
- Objects.equals(a, b) → a가 null일 때도 안전하다.
'Back-End > Java' 카테고리의 다른 글
기능은 됐는데 성능이 안 나오는 코드의 특징 5가지 (0) | 2025.06.20 |
---|---|
HTTP 요청 하나당 스레드는 몇 개나 동작할까? (0) | 2025.05.12 |
할머니도 이해할 수 있는 자바 Thread (0) | 2025.05.01 |
try-catch (0) | 2024.07.06 |
스레드 (1) | 2024.07.05 |