Categories
웹개발/Java
Java NullPointerException 원인과 해결 방법, 실무에서 먼저 확인할 부분
Java 코드를 실행했는데 NullPointerException이 뜨면 대부분은 "어디가 null인지"부터 찾기 시작한다. 그런데 줄 번호만 보고 바로 값을 넣어 버리면 같은 오류가 다른 위치에서 다시 난다. 이 오류는 단순히 null 체크를 빠뜨려서 생기는 경우도 있지만, 실무에서는 요청 파라미터, DB 조회 결과, Map 조회, MyBatis 매핑, Spring 의존성 주입처럼 값이 넘어오는 경로가 끊겼을 때 더 자주 발생한다.
이 글은 NullPointerException을 만났을 때 무엇을 먼저 확인해야 하는지, 어떤 코드가 위험한지, 어디까지 방어하고 어디서 예외로 처리해야 하는지를 실무 흐름에 맞춰 정리한다. 목표는 모든 곳에 if (value == null)을 붙이는 것이 아니라, null이 들어올 수 있는 지점을 알고 흐름을 끊어 주는 것이다.
핵심 결론: NullPointerException은 "값이 없다"보다 "값이 없을 수 있는 흐름을 그대로 믿었다"는 신호에 가깝다.
확인 순서: 예외 줄 번호 → 점(.) 앞의 참조 → 외부 입력 여부 → DB/Map 조회 결과 → Spring Bean 주입 → MyBatis 매핑 순서로 좁힌다.
NullPointerException은 왜 발생하는가
NullPointerException은 null인 참조로 필드나 메서드에 접근할 때 발생한다. 기본형인 int, boolean 같은 값은 null이 될 수 없지만, String, DTO, List, Map, 직접 만든 클래스 같은 참조형은 값이 없으면 null이 될 수 있다.
초급 단계에서는 "객체를 만들지 않았다"는 이유로만 이해하기 쉽다. 하지만 실무 코드는 내가 직접 new로 만든 객체보다 외부에서 넘어온 객체가 훨씬 많다. 컨트롤러의 요청 값, 서비스에서 받은 DTO, Mapper가 돌려준 조회 결과, 설정 파일로 등록되는 Bean이 모두 실행 시점에 연결된다. 이 연결 중 하나가 비어 있으면 코드는 컴파일되어도 실행 중에 터진다.
그래서 NPE를 볼 때는 "이 변수에 값을 넣었나?"보다 "이 값은 어디에서 들어오기로 되어 있었나?"를 먼저 봐야 한다. 값의 출처를 찾으면 null 체크를 붙일 위치도 자연스럽게 정해진다. 반대로 출처를 보지 않고 문제가 난 줄에만 방어 코드를 넣으면, 원인은 그대로 둔 채 증상만 가리는 코드가 된다.
에러가 났을 때 먼저 볼 위치
줄 번호에서 점 앞의 참조를 찾는다
스택 트레이스에 내 코드의 파일명과 줄 번호가 찍히면 그 줄을 연다. 그 다음에는 . 앞에 있는 참조를 하나씩 본다. 아래 코드를 예로 보자.
String cityName = order.getCustomer().getAddress().getCity().trim();
이 한 줄에서 NPE가 날 수 있는 지점은 하나가 아니다. order, getCustomer(), getAddress(), getCity() 중 어디든 null일 수 있다. 줄 번호만 보고 cityName을 의심하면 늦다. 점 앞의 참조를 왼쪽부터 끊어서 확인해야 한다.
실무에서는 이런 체이닝 코드가 DTO 변환, 화면 응답 생성, 엑셀 다운로드 코드에서 자주 보인다. 처음에는 짧아 보여도 장애가 나면 원인을 좁히기 어렵다. 중간 값이 비어 있을 수 있다면 한 줄로 이어 쓰기보다 중간 변수로 나누는 편이 디버깅에 좋다.
외부 입력은 비어 있을 수 있다고 본다
요청 파라미터, 파일 입력, API 응답, 세션 값은 항상 비어 있을 수 있다. 사용자가 화면에서 입력하지 않았거나, 프론트에서 필드명을 다르게 보냈거나, 외부 API가 특정 상황에서 필드를 내려주지 않을 수 있기 때문이다.
String page = request.getParameter("page");
int pageNo = Integer.parseInt(page.trim()); // page가 null이면 NPE
이 코드는 page가 반드시 온다는 전제에 기대고 있다. 검색 조건 화면에서 페이지 번호가 빠졌거나 처음 진입 요청이면 바로 NPE가 난다. 이럴 때는 값이 없을 때의 기본값을 먼저 정해야 한다.
String page = request.getParameter("page");
int pageNo = 1;
if (page != null && !page.trim().isEmpty()) {
pageNo = Integer.parseInt(page.trim());
}
중요한 것은 null 체크 자체가 아니라 기본 정책이다. 페이지 번호가 없으면 1페이지로 볼 것인지, 잘못된 요청으로 볼 것인지, 사용자에게 메시지를 보여줄 것인지가 먼저 정해져야 한다. 정책 없이 null 체크만 늘리면 코드가 길어지고 동작 기준은 더 흐려진다.
DB 조회 결과는 없을 수 있다
DB 조회 결과에서 가장 흔한 NPE는 "조회는 항상 한 건 나온다"는 전제에서 생긴다. 회원 번호, 주문 번호, 게시글 번호처럼 키로 조회하더라도 실제 운영에서는 삭제된 데이터, 권한이 없는 데이터, 잘못된 파라미터가 들어올 수 있다.
User user = userMapper.selectUser(userId);
String userName = user.getName(); // 조회 결과가 없으면 NPE
이 코드에서 문제는 getName()이 아니다. selectUser()가 null을 반환할 수 있다는 점을 호출하는 쪽이 처리하지 않은 것이다. 조회 결과가 없을 수 있는 메서드는 반환 직후에 흐름을 갈라야 한다.
User user = userMapper.selectUser(userId);
if (user == null) {
throw new IllegalArgumentException("사용자를 찾을 수 없습니다. userId=" + userId);
}
String userName = user.getName();
목록 조회와 단건 조회도 구분해야 한다. 목록 조회는 빈 리스트가 자연스럽지만, 단건 조회는 없을 때 null을 반환할지 예외를 던질지 팀 기준이 필요하다. 이 기준이 없으면 어떤 코드는 null 체크를 하고, 어떤 코드는 바로 필드에 접근하면서 오류가 흩어진다.
자주 놓치는 원인: NPE가 난 줄은 대부분 결과 지점이다. 실제 원인은 그 값이 들어오기로 한 요청, 조회, 매핑, 주입 단계에 있는 경우가 많다.
자주 발생하는 NPE 패턴
Map get 결과를 바로 사용하는 경우
Map.get()은 키가 없으면 null을 반환한다. 이 점을 잊고 바로 메서드를 호출하면 NPE가 난다.
Map<String, Object> param = new HashMap<>();
String status = param.get("status").toString(); // 키가 없으면 NPE
특히 레거시 프로젝트에서는 DTO 대신 Map<String, Object>로 파라미터를 넘기는 코드가 많다. 이 방식은 빠르게 만들 수 있지만, 키 오타와 타입 오류가 컴파일 단계에서 잡히지 않는다. "status"가 "stats"로 잘못 들어가도 실행 전까지 모른다.
Object statusValue = param.get("status");
String status = statusValue == null ? "" : statusValue.toString();
조회 조건처럼 값이 없을 수 있는 필드는 기본값을 정해도 된다. 반대로 업무적으로 반드시 있어야 하는 값이라면 빈 문자열로 넘기지 말고 예외를 던지는 편이 낫다. 필수값 누락을 조용히 빈 값으로 바꾸면 나중에 잘못된 조회 결과나 대량 처리 오류로 이어질 수 있다.
문자열 비교에서 null을 고려하지 않는 경우
문자열 비교에서도 NPE가 자주 난다. 아래 코드는 status가 null이면 바로 예외가 발생한다.
if (status.equals("APPROVED")) {
approve();
}
상수 문자열과 비교할 때는 상수를 앞에 두면 null에 안전하다.
if ("APPROVED".equals(status)) {
approve();
}
이 패턴은 간단하지만 실무에서 효과가 크다. 요청 파라미터, DB 코드값, Map에서 꺼낸 문자열은 null일 수 있다. 상태 코드 비교처럼 자주 반복되는 곳에서는 상수를 앞에 두는 습관만으로도 많은 NPE를 줄일 수 있다. 문자열 비교 자체가 헷갈린다면 Java 기초 글에서 정리한 ==와 equals() 차이도 함께 잡아두는 편이 좋다.
Spring Bean 주입이 실패한 경우
Spring을 쓰는 코드에서 서비스나 Mapper 필드가 null인 경우도 있다. 대부분은 의존성 주입이 되지 않았는데 객체를 직접 만들었거나, 컴포넌트 스캔 범위에 들어가지 않았거나, 테스트 코드에서 Spring 컨텍스트를 올리지 않은 경우다.
public class UserController {
private UserService userService;
public String detail(String userId) {
return userService.findName(userId); // userService가 null이면 NPE
}
}
Spring이 관리하는 객체라면 생성자 주입을 사용하는 편이 안전하다. 생성자 주입은 필요한 의존성이 없으면 객체를 만들 수 없어서 문제를 더 빨리 드러낸다.
@Controller
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
}
Mapper가 주입되지 않는 문제라면 Java 코드만 보지 말고 스캔 범위를 확인해야 한다. MyBatis Mapper 인터페이스가 Bean으로 안 잡히는 상황은 MapperScan에서 자주 발생한다. Spring과 MyBatis를 처음 연결하는 단계라면 MyBatis 연동 설정의 의존성, SqlSessionFactory, Mapper 스캔 순서도 같이 확인하는 편이 빠르다.
MyBatis 매핑 결과가 예상과 다른 경우
MyBatis에서 조회 결과 객체는 만들어졌는데 특정 필드만 null인 경우도 있다. 이때는 Java 객체 생성 문제가 아니라 컬럼명과 필드명 매핑 문제일 수 있다. 예를 들어 DB 컬럼은 user_name인데 Java 필드는 userName이면 설정이나 resultMap에 따라 값이 들어오지 않을 수 있다.
User user = userMapper.selectUser(userId);
System.out.println(user.getUserName().trim()); // userName 필드가 null이면 NPE
이 상황에서는 user 자체가 null인지, userName 필드만 null인지 구분해야 한다. 객체는 있는데 필드만 비어 있으면 SQL 결과 컬럼, alias, resultMap, camelCase 설정을 본다. 이 구분은 MyBatis 매핑 차이를 확인하면 더 명확해진다.
null 처리 방식 비교
| 상황 | 위험한 코드 | 권장 접근 |
|---|---|---|
| 요청 파라미터 | value.trim() | 필수값 검증 또는 기본값 지정 |
| 단건 DB 조회 | result.getName() | 조회 직후 null 여부 확인 |
| Map 조회 | map.get("key").toString() | 키 존재 여부와 타입 확인 |
| 문자열 비교 | status.equals("Y") | "Y".equals(status) |
| Spring 의존성 | 필드가 null인 상태로 호출 | 생성자 주입과 스캔 범위 확인 |
| MyBatis 매핑 | 필드 값이 항상 들어온다고 가정 | SQL alias, resultMap, camelCase 확인 |
방어 코드를 어디에 둘 것인가
입력 경계에서 막는다
null 처리는 가능한 한 입력 경계에서 하는 것이 좋다. 컨트롤러, API 수신부, 배치 파일 읽기, 외부 시스템 응답 처리처럼 값이 처음 들어오는 곳에서 검증하면 서비스 내부 코드가 단순해진다.
public UserSearchCondition buildCondition(String keyword, String page) {
int pageNo = parsePage(page);
String normalizedKeyword = keyword == null ? "" : keyword.trim();
return new UserSearchCondition(normalizedKeyword, pageNo);
}
이렇게 경계에서 기본값을 정하면 이후 서비스는 keyword가 null인지 계속 확인하지 않아도 된다. 반대로 입력 경계를 지나서도 null 가능성이 계속 남아 있으면, 각 계층이 같은 검사를 반복하게 된다.
필수값은 조용히 기본값으로 바꾸지 않는다
모든 null을 기본값으로 바꾸는 것은 좋은 방어가 아니다. 검색어처럼 없어도 되는 값은 빈 문자열로 처리할 수 있지만, 주문번호나 회원번호처럼 업무적으로 반드시 필요한 값은 누락되었을 때 즉시 실패시키는 편이 안전하다.
public Order findOrder(String orderId) {
if (orderId == null || orderId.trim().isEmpty()) {
throw new IllegalArgumentException("orderId is required");
}
return orderMapper.selectOrder(orderId);
}
필수값을 빈 문자열로 바꿔 DB 조회에 넘기면 결과가 없을 뿐 아니라, 조건이 잘못 조립된 경우 전체 데이터 조회 같은 더 큰 문제로 이어질 수 있다. null을 막는 목적은 오류를 숨기는 것이 아니라 잘못된 흐름을 빨리 끊는 것이다.
Optional은 반환 의미를 분명히 할 때 쓴다
Optional은 null을 완전히 없애는 도구가 아니다. "결과가 없을 수 있음"을 메서드 시그니처에 드러내는 도구에 가깝다. 내부 필드나 파라미터마다 Optional을 무분별하게 쓰면 코드가 더 복잡해진다.
public Optional<User> findUser(String userId) {
return Optional.ofNullable(userMapper.selectUser(userId));
}
User user = userService.findUser(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
조회 결과가 없을 수 있다는 사실을 호출하는 쪽이 반드시 처리하게 만들고 싶다면 Optional이 도움이 된다. 하지만 DTO 필드에 Optional을 넣거나 모든 null 체크를 Optional 체인으로 바꾸는 것은 실익이 크지 않다. 팀에서 Optional을 어디까지 쓸지 기준을 정해 두는 편이 낫다.
실무 팁: null 처리는 "허용되는 빈 값"과 "흐름을 끊어야 하는 누락"을 나누는 일이다. 두 경우를 구분하지 않으면 방어 코드가 많아져도 오류는 줄지 않는다.
Spring과 MyBatis 프로젝트에서 확인할 순서
프레임워크가 들어오면 NPE 원인이 Java 코드 한 줄에만 있지 않은 경우가 많다. 컴파일은 되지만 실행 중에 null이 들어온다면 설정과 매핑을 함께 봐야 한다.
- 스택 트레이스에서 내 코드가 처음 나오는 줄을 찾는다.
- 그 줄에서 null일 수 있는 참조를 왼쪽부터 분리한다.
- 그 값이 요청, 세션, DB, Map, Bean 중 어디에서 왔는지 찾는다.
- Spring Bean이면 컴포넌트 스캔과 생성자 주입 여부를 본다.
- Mapper 결과면 SQL 실행 결과와 resultMap/resultType 매핑을 확인한다.
- 화면 경로와 정적 리소스 문제처럼 요청 자체가 다른 곳으로 빠지는 상황도 확인한다.
정적 리소스나 URL 경로가 잘못되어 예상한 컨트롤러가 호출되지 않는 문제는 NPE와 직접 원인이 다르지만, "요청이 내가 생각한 코드까지 도달했는가"를 확인하는 데 중요하다. 이런 경우는 정적 리소스 404처럼 경로 설정을 별도로 점검해야 한다.
실무에서 바로 쓰는 점검 팁
- 스택 트레이스는 맨 위만 보지 않는다. 라이브러리 코드보다 내 패키지가 처음 나오는 줄을 기준으로 확인한다.
- 한 줄 체이닝을 줄인다. null 가능성이 있는 호출은 중간 변수로 나누면 어떤 값이 비었는지 빨리 보인다.
- Map보다 DTO를 우선한다. 키 오타와 타입 오류를 컴파일 단계에서 잡을 수 있다.
- 조회 결과 없음과 시스템 오류를 구분한다. 데이터가 없는 것은 정상 흐름일 수 있지만, 매핑 실패는 수정해야 할 오류다.
- 생성자 주입을 사용한다. 필수 의존성이 비어 있는 상태로 객체가 동작하는 일을 줄일 수 있다.
자주 묻는 질문
- Q. NullPointerException은 무조건 null 체크를 추가하면 해결되나요? A. 증상은 줄어들 수 있지만 근본 해결은 아닐 수 있습니다. null이 들어온 경로가 요청 값인지, DB 조회 결과인지, Bean 주입 실패인지 먼저 확인해야 같은 오류가 반복되지 않습니다.
- Q. DB 조회 결과가 없을 때 null을 반환해도 되나요? A. 가능하지만 팀 기준이 필요합니다. 단건 조회에서 결과 없음이 정상 흐름이면 Optional이나 명시적인 예외 처리를 고려하고, 목록 조회는 빈 리스트를 반환하는 편이 다루기 쉽습니다.
- Q. 문자열 비교에서 왜 "Y".equals(value) 형태를 쓰나요? A. value가 null이어도 예외가 나지 않고 false로 처리되기 때문입니다. 요청 파라미터나 DB 코드값처럼 null 가능성이 있는 문자열 비교에 유용합니다.
- Q. Optional을 쓰면 NPE가 사라지나요? A. Optional은 null 가능성을 표현하는 도구이지 모든 NPE를 없애는 도구는 아닙니다. 조회 결과처럼 없을 수 있는 반환값에 제한적으로 쓰는 편이 좋습니다.
- Q. Spring에서 주입받은 Service가 null이면 무엇을 봐야 하나요? A. 객체를 직접 new로 만들었는지, 해당 클래스가 Spring Bean인지, 컴포넌트 스캔 범위에 포함되는지, 테스트에서 Spring 컨텍스트가 올라갔는지 확인해야 합니다.
- Q. MyBatis 조회 객체의 특정 필드만 null이면 어디를 확인하나요? A. SQL 결과 컬럼명, alias, resultMap, resultType, camelCase 매핑 설정을 확인합니다. 객체 자체가 null인지 필드만 null인지 먼저 구분하는 것이 중요합니다.
정리
NullPointerException은 Java에서 가장 흔하지만, 가장 대충 처리하기 쉬운 오류이기도 하다. 문제 줄에 null 체크를 붙이는 것보다 값이 어디에서 들어왔고, 어느 지점에서 비어도 되는지부터 정리해야 한다.
요청 값, DB 조회, Map, 문자열 비교, Spring Bean, MyBatis 매핑은 실무에서 NPE가 자주 생기는 위치다. 이 지점들을 확인 순서로 잡아두면 같은 오류를 매번 처음부터 찾지 않아도 된다.
NPE를 줄이는 가장 좋은 방법은 null을 무조건 숨기는 것이 아니라, null이 허용되는 경계와 허용되지 않는 경계를 코드에 분명히 남기는 것이다.
'웹개발 > Java' 카테고리의 다른 글
| Java equals hashCode 차이, HashMap과 Set에서 문제가 되는 이유 (0) | 2026.07.07 |
|---|---|
| Java Optional 사용 기준, null 처리를 더 복잡하게 만들지 않는 방법 (0) | 2026.07.07 |
| Java 기초 핵심 개념, 실무에서 자주 헷갈리는 부분 정리 (1) | 2026.07.06 |
| 자바 MapReduce로 대용량 데이터 추출하기: 날짜·도시 필터 + 사용자 합계 실전 예제 (1) | 2025.12.03 |
| Java 파일 입출력(File I/O) 완전 정복: 실무 예제부터 인코딩·복사까지 (5) | 2025.07.31 |