
인트로
레거시 시스템을 Spring Boot 3 기반으로 옮기면서
Controller 응답 방식과 세션 접근 방식을 함께 정리했다.
처음에는 요청 파라미터만 잘 넘기면 기능이 그대로 동작할 거라고 생각했다.
그런데 일부 기능에서 NullPointerException이 발생했다.
이상했던 점은 화면에서 넘기는 값은 빠진 게 없어 보였다는 점이다.
화면에는 없지만 서버 로직에는 꼭 필요한 값이 있었다.
바로 세션에서 꺼내 쓰던 사용자 식별값이었다.
이번 글은 레거시 코드에서 암묵적으로 사용하던 세션 기반 값을 어떻게 추적했고
마이그레이션 후에는 어떻게 NPE를 방어했는지 정리한 기록이다.
개발환경
| AS-IS | TO-BE |
|---|---|
| Java 1.7 | Java 17 |
| Spring Framework 4 계열 | Spring Boot 3 계열 |
| JSP + Spring MVC | JSP + Spring MVC |
ModelAndView + jsonView |
Map<String, Object> 직접 반환 |
| 레거시 세션 핸들러 직접 호출 | 세션 유틸리티 기반 접근 |
이 글의 예제 코드는 회사 소스코드가 아니다.
실제 업무에서 겪은 문제 흐름만 비슷하게 재구성한 독립 실행 예제다.

문제 상황

마이그레이션 중 특정 등록 API를 테스트했다.
요청에는 deviceName 같은 화면 입력값이 들어왔다.
그런데 서비스 로직에서는 요청값뿐 아니라userSeq, empId 같은 사용자 식별값도 필요했다.
처음에는 프론트에서 파라미터를 누락한 줄 알았다.
하지만 JSP와 JavaScript를 확인해도 해당 값은 화면에서 보내는 값이 아니었다.
레거시 구조에서는 Controller가 세션에서 사용자 정보를 꺼내 DTO에 직접 채우고 있었다.
즉, 이 값들은 요청 파라미터가 아니라 세션 기반 파라미터였다.
내가 헷갈렸던 지점은 여기였다.
| 구분 | 화면에서 보이는가 | 서버에서 필요한가 |
|---|---|---|
deviceName |
보인다 | 필요하다 |
userSeq |
보이지 않는다 | 필요하다 |
empId |
보이지 않는다 | 필요하다 |
companyCode |
보이지 않는다 | 일부 로직에서 필요하다 |
결국 문제는 “파라미터가 안 넘어왔다”가 아니라
“세션에서 채워야 하는 값을 마이그레이션 과정에서 놓쳤다”에 가까웠다.

1번째 시도: 요청 파라미터만 확인하기 (실패)

시도한 이유
API가 실패하면 가장 먼저 요청값을 의심하게 된다.
나도 브라우저 네트워크 탭과 서버 로그에서 요청 파라미터를 먼저 확인했다.
화면에서 입력한 값은 정상적으로 넘어오고 있었다.
그래서 처음에는 name 속성 오타나 JavaScript 객체 생성 문제를 찾으려고 했다.
적용 방법
요청 파라미터를 기준으로만 보면 코드는 대략 이런 흐름이었다.
Map<String, String> requestParams = Map.of("deviceName", "phone");
String deviceName = requestParams.get("deviceName");
이 값은 정상적으로 존재했다.
문제점/한계
문제의 핵심은 요청 파라미터가 아니었다.
서버 로직은 deviceName만으로 동작하지 않았다.
등록 로직에는 “누가 등록했는가”가 필요했다.
이 정보는 화면에서 보내지 않고 로그인 이후 서버 세션에 저장된 값을 사용하고 있었다.
요청 파라미터만 보면 원인을 찾을 수 없었다.

배운 점
마이그레이션 QA에서 입력값을 확인할 때는 request parameter만 보면 안 된다. session attribute, request attribute, interceptor에서 주입하는 값까지 함께 봐야 한다.
2번째 시도: 세션에서 암묵적으로 채우던 값 추적하기 (성공)

시도한 이유
레거시 Controller를 다시 보니 요청값을 DTO에 넣은 뒤
세션 사용자 정보도 함께 DTO에 넣고 있었다.
즉, 화면에서는 보이지 않지만 Controller 안에서 조립되는 값이 있었다.
적용 방법
레거시 구조를 공개용 예제로 단순화하면 아래와 같다.
SessionUser sessionUser = LegacySessionHandler.getLoginInfo(request);
RegisterCommand command = new RegisterCommand(
sessionUser.userSeq(),
sessionUser.empId(),
requestParams.get("deviceName")
);
이 코드는 세션이 항상 있다고 가정한다.
로그인 상태에서 정상 흐름만 테스트하면 문제가 없어 보인다.
하지만 세션이 없거나 만료된 상태에서 접근하면
sessionUser가 null이 되고, 바로 NPE가 발생한다.
Cannot invoke "SessionUser.userSeq()" because sessionUser is null
실제 업무에서도 이와 비슷하게
“화면에서 보이지 않는 값”이 세션에서 채워지고 있었다.

결과
원인은 프론트 파라미터 누락이 아니었다.
마이그레이션하면서 세션 접근 방식을 바꾸는 과정에서
세션 사용자 정보가 필요한 기능과 그렇지 않은 기능을 구분해야 했다.
배운 점
레거시 코드는 Controller 안에서 요청값과 세션값을 섞어 DTO를 만드는 경우가 많다. 이때 세션값은 화면에 보이지 않기 때문에 QA 목록에 따로 적어두지 않으면 놓치기 쉽다.
최종 해결: 세션 유틸리티로 접근을 통일하고 NPE를 먼저 막기

최종적으로는 세션 접근을 한 곳으로 모으고
세션이 없을 때 바로 명확한 응답을 반환하는 방식으로 정리했다.
핵심은 두 가지였다.
- Controller마다 세션을 직접 꺼내지 않고
SessionUtils같은 유틸리티를 통해 접근한다. - 세션이 없으면 서비스 로직으로 내려가기 전에
LOGIN_REQUIRED같은 명확한 응답을 반환한다.
아래 코드는 회사 소스코드가 아니라 실행 가능한 예제다. Java 17 기준으로 작성했다.
실행 방법
javac SessionHiddenParameterExample.java
java -ea SessionHiddenParameterExample
-ea 옵션은 Java assertion을 활성화하는 옵션이다.
실행 가능한 예제 코드
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class SessionHiddenParameterExample {
public static void main(String[] args) {
legacyControllerThrowsNullPointerExceptionWhenSessionIsMissing();
migratedControllerReturnsUnauthorizedWhenSessionIsMissing();
migratedControllerMergesRequestParameterAndSessionValue();
System.out.println("All tests passed");
}
static void legacyControllerThrowsNullPointerExceptionWhenSessionIsMissing() {
LegacyController controller = new LegacyController(new RegisterService());
try {
controller.registerDevice(Map.of("deviceName", "phone"), RequestContext.empty());
throw new AssertionError("NullPointerException이 발생해야 한다");
} catch (NullPointerException expected) {
assert expected.getMessage().contains("null") : expected.getMessage();
}
}
static void migratedControllerReturnsUnauthorizedWhenSessionIsMissing() {
MigratedController controller = new MigratedController(new RegisterService());
ApiResponse response = controller.registerDevice(
Map.of("deviceName", "phone"),
RequestContext.empty()
);
assert response.status() == 401 : response;
assert "LOGIN_REQUIRED".equals(response.body().get("message")) : response;
}
static void migratedControllerMergesRequestParameterAndSessionValue() {
MigratedController controller = new MigratedController(new RegisterService());
RequestContext request = RequestContext.withSession(
new SessionUser(10L, "E1001", "C01")
);
ApiResponse response = controller.registerDevice(
Map.of("deviceName", "phone"),
request
);
assert response.status() == 200 : response;
assert Long.valueOf(10L).equals(response.body().get("userSeq")) : response;
assert "E1001".equals(response.body().get("empId")) : response;
assert "phone".equals(response.body().get("deviceName")) : response;
}
static class LegacyController {
private final RegisterService registerService;
LegacyController(RegisterService registerService) {
this.registerService = registerService;
}
ApiResponse registerDevice(Map<String, String> requestParams, RequestContext request) {
SessionUser sessionUser = LegacySessionHandler.getLoginInfo(request);
RegisterCommand command = new RegisterCommand(
sessionUser.userSeq(),
sessionUser.empId(),
requestParams.get("deviceName")
);
return ApiResponse.ok(registerService.register(command));
}
}
static class MigratedController {
private final RegisterService registerService;
MigratedController(RegisterService registerService) {
this.registerService = registerService;
}
ApiResponse registerDevice(Map<String, String> requestParams, RequestContext request) {
Optional<SessionUser> sessionUser = SessionUtils.currentUser(request);
if (sessionUser.isEmpty()) {
return ApiResponse.of(401, Map.of("message", "LOGIN_REQUIRED"));
}
String deviceName = requestParams.get("deviceName");
if (deviceName == null || deviceName.isBlank()) {
return ApiResponse.of(400, Map.of("message", "DEVICE_NAME_REQUIRED"));
}
RegisterCommand command = RegisterCommand.from(requestParams, sessionUser.get());
return ApiResponse.ok(registerService.register(command));
}
}
static class LegacySessionHandler {
static SessionUser getLoginInfo(RequestContext request) {
return request.sessionUser();
}
}
static class SessionUtils {
static Optional<SessionUser> currentUser(RequestContext request) {
return Optional.ofNullable(request.sessionUser());
}
}
static class RegisterService {
Map<String, Object> register(RegisterCommand command) {
Map<String, Object> result = new HashMap<>();
result.put("userSeq", command.userSeq());
result.put("empId", command.empId());
result.put("deviceName", command.deviceName());
return result;
}
}
record RegisterCommand(Long userSeq, String empId, String deviceName) {
static RegisterCommand from(Map<String, String> requestParams, SessionUser sessionUser) {
return new RegisterCommand(
sessionUser.userSeq(),
sessionUser.empId(),
requestParams.get("deviceName")
);
}
}
record ApiResponse(int status, Map<String, Object> body) {
static ApiResponse ok(Map<String, Object> body) {
return new ApiResponse(200, body);
}
static ApiResponse of(int status, Map<String, Object> body) {
return new ApiResponse(status, body);
}
}
record SessionUser(Long userSeq, String empId, String companyCode) {
}
record RequestContext(SessionUser sessionUser) {
static RequestContext empty() {
return new RequestContext(null);
}
static RequestContext withSession(SessionUser sessionUser) {
return new RequestContext(sessionUser);
}
}
}
실행 결과는 아래와 같다.
All tests passed
이 예제에서 중요한 부분은 MigratedController다.

Optional<SessionUser> sessionUser = SessionUtils.currentUser(request);
if (sessionUser.isEmpty()) {
return ApiResponse.of(401, Map.of("message", "LOGIN_REQUIRED"));
}
세션이 없으면 DTO를 만들지 않는다.
서비스도 호출하지 않는다.
먼저 인증 상태를 확인하고, 실패 응답을 명확하게 반환한다.
그 다음 요청 파라미터와 세션값을 합쳐 Command 객체를 만든다.
RegisterCommand command = RegisterCommand.from(requestParams, sessionUser.get());
이렇게 정리하면 “화면에서 넘어온 값”과 “서버 세션에서 채운 값”의 경계가 분명해진다.
마이그레이션 QA에서 추가한 체크 포인트
이번 일을 겪고 나서 QA 시나리오에 세션 기반 값을 별도로 적었다.

| 검증 항목 | 확인 내용 |
|---|---|
| 정상 세션 | 로그인 후 userSeq, empId 같은 사용자 값이 정상 추출되는지 확인한다 |
| 세션 없음 | NPE가 아니라 명확한 인증 실패 응답이 내려오는지 확인한다 |
| 세션 만료 | 만료된 세션으로 접근했을 때 로그인 페이지 또는 인증 실패 응답으로 흐르는지 확인한다 |
| 요청값 누락 | 화면 입력값이 없을 때 400 계열 응답 또는 명확한 메시지가 내려오는지 확인한다 |
| 세션값 + 요청값 조합 | DTO나 Command 객체에 두 종류의 값이 모두 들어가는지 확인한다 |
이 체크리스트를 만들고 나니 “기능이 된다”와
“기능이 같은 방식으로 된다”를 더 구분해서 볼 수 있었다.
레거시 마이그레이션에서는 이 차이가 중요했다.
화면에서 같은 버튼을 눌렀을 때 결과가 같아 보여도
내부에서 필요한 값이 누락되면 특정 예외 케이스에서 바로 깨질 수 있기 때문이다.
배운 점
- 요청 파라미터에 보이지 않는 값도 서버 로직에서는 필수값일 수 있다.
- 레거시 Controller는 요청값, 세션값, 공통 유틸 값을 한 번에 조립하는 경우가 많다.
- 마이그레이션할 때는
request parameter뿐 아니라session attribute도 QA 항목으로 분리해야 한다. - 세션이 없을 때 NPE가 나는 코드는 정상 케이스 테스트만으로는 발견하기 어렵다.
- 세션 접근을 유틸리티로 통일하면 null 방어와 응답 정책을 한 곳에서 맞추기 쉬워진다.
이번 작업을 하면서 “화면에 없는데 서버에는 필요한 값”을 보는 눈이 조금 생겼다.
처음에는 프론트에서 값을 안 넘긴 줄 알고 한참을 요청 파라미터만 봤다.
그런데 실제 원인은 세션에서 암묵적으로 채우던 값을 놓친 것이었다.
레거시 마이그레이션은 코드를 새 문법으로 바꾸는 작업만은 아니었다.
기존 코드가 어디에서 값을 가져오고 있었는지, 그 값이 어떤 전제 위에서 동작했는지 다시 확인하는 작업이었다.