JSP 화면과 내부 클래스 로직 개선으로 운영 안정성 강화
목차
4월 1일 작업은 규모가 크지 않았다. JSP 화면 하나와 내부 클래스 몇 줄 손보는 수준. 그런데 이런 작업이 오히려 더 신경 쓰인다. 건드리는 범위는 작은데 기존 흐름에 영향을 주는 지점이 의외로 많아서.
이번엔 enum 스캔이 핵심이었다. 화면에서 특정 상태값을 드롭다운으로 보여줘야 하는데, 하드코딩 배열 대신 enum을 순회해서 뷰에 넘기는 방식으로 바꿨다. 사용자 흐름에서 자연스럽게 필요해진 기능이기도 하고, 운영 중에 "이 값 왜 없어요?"라는 피드백이 나올 타이밍이 보여서 선제적으로 반영한 것.
Spring MVC 내부 클래스와 enum 스캔 패턴
Spring MVC 기반 레거시 프로젝트에서 내부 클래스(inner class)를 쓰는 패턴은 꽤 흔하다. 컨트롤러 안에 VO나 DTO를 정적 내부 클래스로 두면 요청 바인딩 대상을 해당 컨트롤러 파일 하나에서 다 볼 수 있어서 구조 파악이 편하고, 파일 수도 줄어든다. 처음엔 합리적으로 보이는데, 기능이 쌓이면서 클래스가 같이 비대해진다는 게 문제다. 이번 작업에서도 내부 클래스가 점점 역할을 흡수하고 있는 게 보였다. 지금 당장 분리하진 않았지만 노트에는 남겨뒀다. 시간 날 때 별도 파일로 빼는 게 맞는 방향.
enum 스캔 자체는 간단하다. 컨트롤러에서 values()로 순회해서 모델에 담고, JSP에서 JSTL <c:forEach>로 <option> 태그를 그리면 된다. 하드코딩 배열 대신 enum을 직접 참조하면 이후에 상태값이 추가되더라도 JSP를 건드릴 필요가 없어진다. 문제는 enum의 name()을 그대로 노출하면 화면에 영어 상수가 그대로 나온다는 것. 그래서 이번에 enum에 한국어 레이블 필드를 추가해서 같이 넘기도록 처리했다.
public enum StatusType {
ACTIVE("활성"),
INACTIVE("비활성"),
PENDING("대기중");
private final String label;
StatusType(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
// 컨트롤러에서 모델에 담는 부분
model.addAttribute("statusList", Arrays.asList(StatusType.values()));
JSP에서 item.label로 레이블을 찍고, item.name()을 value로 보내면 백엔드에서 enum으로 바인딩할 때도 깔끔하게 처리된다. 이 구조가 정착되면 화면 코드와 도메인 코드가 각각 자기 역할에만 집중할 수 있다.
요청 파라미터 바인딩은 @ModelAttribute나 커맨드 객체로 받는 방식을 유지했다. 검증 로직은 서비스 레이어로 내려가기 전에 컨트롤러에서 null과 빈 값을 먼저 걸러내고, 비정상 입력은 커스텀 익셉션으로 올려서 공통 핸들러가 잡게 했다. SQL은 MyBatis XML에서 관리 중이고, 추가한 쿼리는 기존 네임스페이스 안에 넣었다. resultMap은 재사용 가능한 걸 그대로 끌어다 써서 신규 컬럼만 extends로 추가했는데, 나중에 수정할 때 훨씬 편하다.
트랜잭션 범위와 예외 처리 결정
화면 개선이 섞인 작업에서 트랜잭션 범위 설정이 애매해지는 경우가 있다. 조회만 하는 메서드에 @Transactional을 붙이는 건 비용 낭비고, 그렇다고 쓰기 로직이 섞인 메서드를 readOnly로 두면 예외 상황에서 롤백이 안 된다. 이번 작업에서 정한 기준:
| 상황 | 처리 방식 |
|---|---|
| 조회 전용 메서드 | @Transactional(readOnly = true) 또는 미적용 |
| 쓰기 포함 메서드 | @Transactional 기본(REQUIRED) |
| 외부 API 호출 포함 | 트랜잭션 밖으로 분리, 실패 시 보상 로직 별도 |
| 복수 테이블 수정 | 단일 서비스 메서드 안에서 묶어서 원자성 확보 |
예외 처리는 RuntimeException 전파를 기본으로 했다. 서비스에서 잡아서 응답 변환하는 방식도 있는데, 그렇게 하면 공통 ExceptionHandler가 제대로 동작 안 하는 경우가 생겨서 이번엔 전파 쪽을 선택했다. 로그는 중요한 입출력값을 INFO로 남겼다. 운영에서 디버깅할 때 DEBUG 레벨은 보통 꺼져 있어서 INFO가 현실적이다. 다만 INFO 로그가 너무 많으면 운영 로그가 지저분해지니까, 실제로 추적이 필요한 값만 찍는 게 맞다.
기존 로직과의 호환성은 수정한 내부 클래스가 다른 화면에서도 참조되는지 IDE에서 추적하고, 영향 범위 안에 있는 흐름은 로컬에서 직접 눌러봤다. 자동화 테스트가 촘촘하게 있으면 좋겠지만, 레거시 영역이라 수동으로 케이스 확인하는 게 현실이다. 이번에도 null 케이스, 빈 문자열, 경계값 몇 가지를 직접 입력해서 확인했다.
CI/CD에서 자꾸 걸리는 것들
배포 자체보다 파이프라인 잡음이 더 피곤할 때가 있다. 이번 배포에서 챙긴 포인트:
- SSH 타임아웃: 빌드가 길어지면 연결이 끊겨서 배포 실패로 뜬다.
command_timeout을 명시하거나 빌드 스텝을 분리해서 각 스텝이 독립적으로 실행되게 해야 한다. 빌드 로그를 보면 마지막 출력 이후 조용해지다가 타임아웃 에러가 나는 패턴이라 구분은 어렵지 않다. - 키 인증 오류: 개행 문자가 섞여서 키 파싱 실패하는 케이스가 있다. Secrets에 붙여넣을 때 trailing newline 포함 여부 확인이 기본.
cat -A key_file로 미리 확인하면^M같은 문자가 보인다. - Gradle 캐시 충돌: CI 환경에서 캐시가 꼬이면
--no-daemon옵션이나 캐시 디렉터리 클리어로 해결한다. 로컬에선 잘 되는데 CI에서만 빌드 실패하면 캐시를 먼저 의심하는 게 빠르다. - 환경변수 누락: 배포 로그는 성공인데 앱이 안 뜨는 케이스. Secrets 설정을 빠뜨린 경우가 많다. 배포 후 헬스체크 엔드포인트 확인을 파이프라인 마지막 스텝에 넣으면 이 유형 사고를 조기에 잡을 수 있다.
작업 후 순서는 로컬 기동 확인 → 개발 DB 반영 → 배포였다. 작은 변경일수록 이 순서를 지키는 게 중요하다. 빨리 올리고 싶어서 개발 DB 검증을 건너뛰면 꼭 운영에서 이상한 케이스가 나온다. 팀원들한테도 계속 하는 얘기인데, 규모가 작다고 프로세스를 생략하는 순간 작은 수정이 큰 사고의 시작점이 된다. 이번 작업도 결과적으로는 큰 변경이 아니었지만, 이런 소소한 수정들이 쌓여서 전체 시스템의 신뢰도가 올라간다고 생각한다.
댓글 0
첫 댓글 달아줘.