코드를 보다 보면 아래와 같은 코드를 계속해서 마주친다.

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 체크가 만들어내는 문제점들은 없을까? 있다..

 

  1. 방어코드 증가 : 코드 복잡도가 상승해 가독성이 떨어지고 의도 파악도 하기 힘들어진다.
  2. 예외 누락 : NPE 외의 다른 에러를 삼킨다. 이렇게 되면 장애 추적에 어려움이 있다.
  3. 책임 분산 실패 : 데이터가 왜 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일 때도 안전하다.

 

 

 

+ Recent posts