운영 안정성을 위한 Spring MVC 내부 클래스 개선
목차
Spring MVC 기반 프로젝트에서 내부 클래스 쪽 로직을 건드리는 작업은 겉으로는 단순해 보여도 은근히 신경 쓸 게 많다. 이번에 수정한 것도 규모 자체는 크지 않았는데, 손대기 전에 꽤 많이 따졌다.
작업 배경을 짧게 설명하면, 실제 사용자 흐름에서 특정 요청이 들어왔을 때 파라미터 처리나 비즈니스 로직 분기가 매끄럽지 않은 지점이 있었다. 운영 중에 발견된 거라 "나중에 보자"가 아니라 지금 처리해야 하는 케이스였다. 기능 자체가 동작하지 않는 건 아니었지만, 예외 상황에서 처리가 누락되거나 의도와 다른 응답이 내려갈 가능성이 있었다.
Spring MVC 내부 클래스 구조에서 흔히 놓치는 것들
Spring MVC + MyBatis + JSP 스택에서 내부 클래스는 주로 폼 데이터를 받는 Command 객체나 응답 DTO 역할로 쓰인다. Controller 클래스 안에 static inner class로 선언해두면 요청 바인딩 구조를 한눈에 볼 수 있어서 편한데, 이게 쌓이다 보면 클래스 하나가 무거워지는 문제가 생긴다.
이번 작업에서 주로 챙긴 포인트:
- 요청 파라미터 바인딩: null이나 빈 문자열이 들어왔을 때 NPE로 튀지 않도록 바인딩 단계에서 방어 처리
- 비즈니스 로직 분기: 내부 클래스에서 직접 로직을 처리하느냐, Service 계층으로 위임하느냐 경계를 다시 정리
- SQL 결과 매핑: MyBatis resultMap에서 필드명이 실제 컬럼과 맞는지, 신규로 추가된 필드가 누락 없이 매핑되는지 확인
- JSP 렌더링: Model에 담은 값이 화면에서 제대로 노출되는지, EL 표현식이 null일 때 표시 처리
트랜잭션 범위 설정은 항상 헷갈리는 부분이다. @Transactional을 Service 메서드에 걸어두면 보통은 충분한데, 여러 Service를 호출하는 흐름에서 어느 시점에 commit/rollback이 일어나는지 정확히 알고 있어야 한다. 이번에도 DB를 건드리는 로직이 포함돼 있어서 트랜잭션 경계를 명시적으로 확인했다.
// 내부 커맨드 클래스 예시
public class SomeController {
@PostMapping("/some-action")
public String handle(@ModelAttribute RequestForm form, BindingResult result, Model model) {
if (result.hasErrors()) {
return "error/badRequest";
}
someService.process(form.toCommand());
return "redirect:/some-list";
}
@Data
static class RequestForm {
private String itemId;
private String value;
public SomeCommand toCommand() {
return new SomeCommand(
StringUtils.trimToNull(this.itemId),
StringUtils.trimToNull(this.value)
);
}
}
}
toCommand() 같은 변환 메서드를 내부 클래스에 두면 Controller 메서드가 간결해지고 테스트하기도 편하다. 다만 내부 클래스가 Service 레이어의 Command 객체를 직접 참조하게 되면 Controller-Service 간 결합이 생기니까, 그 선을 어떻게 그을지는 팀 컨벤션에 따라 다르다.
예외 처리: 전파 vs. 변환
RuntimeException을 그냥 올려버릴지, Controller 단에서 잡아서 응답으로 변환할지는 케이스마다 다르게 판단했다.
| 상황 | 선택 | 이유 |
|---|---|---|
| 입력값 오류 (비즈니스 validation) | Controller에서 잡아서 응답 변환 | 사용자에게 의미 있는 메시지가 필요 |
| 예상치 못한 시스템 오류 | 전파 → @ControllerAdvice에서 처리 |
단일 진입점에서 로깅·응답 일관성 확보 |
| 외부 연동 오류 | 잡아서 fallback 처리 또는 전파 결정 | 서비스 영향도에 따라 다름 |
프로젝트 초기에 이 기준을 세워두지 않으면 Controller마다 try-catch 패턴이 제각각이 된다. 나중에 로그를 봤을 때 어디서 에러가 났는지 추적하기 어렵고, 동일한 에러에 대해 응답이 다르게 내려가는 상황도 생긴다.
로그 레벨도 신경 썼다. 운영 환경에서 DEBUG는 거의 꺼두는 경우가 많아서, 중요한 입출력값은 INFO로 남겼다. 다만 개인정보성 데이터가 로그에 그대로 찍히지 않도록 마스킹 여부도 체크했다.
배포 순서와 무중단 전략
코드 수정이 끝나면 로컬에서 기동 확인 먼저 했다. 당연한 것 같아도 간혹 빌드 자체가 깨진 상태로 개발 서버에 올리는 경우가 있어서, 로컬 기동이 정상인지 확인하는 게 첫 번째다.
그 다음 개발 DB에 반영하고, 화면에서 실제 시나리오대로 동작하는지 손으로 확인했다. 자동화 테스트가 있으면 좋지만 없다면 직접 주요 흐름을 태워봐야 한다. 특히 이번처럼 파라미터 처리 변경이 있었을 땐 정상 케이스뿐 아니라 빈 값, null, 비정상 입력까지 넣어봤다.
배포 자체는 신규 버전 기동 완료 후 프록시 설정을 바꾸는 방식으로 처리했다. 헬스체크 엔드포인트가 정상 응답을 반환하는 시점을 기준으로 트래픽을 전환하지 않으면 일부 요청에서 에러가 날 수 있다. 기동은 됐는데 내부 초기화가 덜 된 상태에서 요청을 받는 게 생각보다 자주 생기는 문제다.
롤백 준비도 해뒀다. 이전 버전 JAR를 보관해두고 문제가 생기면 빠르게 되돌릴 수 있는 상태를 유지하는 게 배포 전략의 마지막 단계다. 배포 자체보다 "잘못됐을 때 얼마나 빨리 복구하느냐"가 실제 운영 안정성을 결정하는 경우가 많다.
작업 규모가 크지 않아도 이 순서를 생략하지 않는 게 중요하다. 작은 수정이 예상치 못한 흐름에 영향을 주는 경우를 몇 번 겪고 나면, 체크리스트가 귀찮게 느껴지지 않게 된다.
댓글 0
첫 댓글 달아줘.