개발 slecs

JSP 화면 렌더링과 내부 클래스 로직 안정성 개선

목차

Spring MVC + MyBatis 프로젝트에서 JSP 화면 렌더링과 내부 클래스 로직을 손질했다. 커밋 단위로는 작았지만, 이 작업이 왜 필요했고 어떤 선택들을 했는지 정리해 두는 게 나중에 참조하기 좋을 것 같아서.

작업 배경은 system jsp enum scan 쪽이었다. 화면에 뿌려지는 값들이 enum 기반으로 관리되는데, 해당 scan 로직이 내부 클래스 안에 흩어져 있어서 흐름 파악이 쉽지 않았다. 운영 중에 발견된 개선 포인트를 실제 사용자 흐름에 맞게 정비한 케이스.

내부 클래스 구조 선택과 트레이드오프

Spring MVC에서 내부 클래스(inner class, 또는 static nested class)로 요청/응답 모델을 잡는 패턴은 Controller 파일 하나에서 관련 DTO를 모아두는 방식이라 응집도가 높아 보인다. 작은 기능 단위엔 확실히 편하다.

@RequestMapping("/system/scan")
public class SystemScanController {

    // static nested class — 외부 인스턴스 없이 직접 생성 가능
    public static class ScanRequest {
        private String scanType;
        private String targetCode;
        // getter/setter 생략
    }

    public static class ScanResponse {
        private List<ScanResultItem> items;
        private String status;
    }

    @PostMapping
    public String scan(@ModelAttribute ScanRequest req, Model model) {
        // 파라미터 null·빈값 선검증
        if (req.getScanType() == null || req.getScanType().isBlank()) {
            model.addAttribute("errorMsg", "scanType 필수");
            return "system/scanError";
        }
        // 비즈니스 로직 위임
        ScanResponse resp = scanService.execute(req.getScanType(), req.getTargetCode());
        model.addAttribute("result", resp);
        return "system/scanResult"; // JSP 뷰
    }
}

문제는 내부 클래스가 많아지면 파일이 길어지고, MyBatis XML과 대응하는 resultMap을 추적할 때 왔다 갔다 하는 거리가 늘어난다는 점. 이번 작업 범위에서는 기존 구조를 뒤집지 않고 내부 클래스 안의 로직 처리만 정비하는 걸로 결론냈다. 큰 리팩터링보다 지금 당장 안정성을 확보하는 게 우선이었다.

이번 작업에서 실제로 챙긴 것들

파라미터 검증과 바인딩

@ModelAttribute로 바인딩할 때 null 체크를 컨트롤러에서 직접 하느냐, Validator를 붙이느냐 선택이 생긴다. 화면 연동이 단순한 경우엔 컨트롤러에서 직접 처리하는 게 코드 흐름을 한눈에 파악하기 좋다. 다만 조건이 복잡해지면 같은 검증 로직이 여러 곳에 복사되기 시작하므로, 그 시점이 Validator 분리 기점이 된다.

트랜잭션 범위

DB를 건드리는 서비스 메서드엔 @Transactional 범위를 명확히 했다. 조회만 하는 메서드엔 readOnly = true를 붙여 커넥션 낭비를 줄였고, 상태 변경이 있는 쪽엔 propagation 기본값(REQUIRED)을 그대로 뒀다. 범위를 지나치게 넓게 잡으면 락 경합이 생기고, 너무 좁게 잡으면 부분 실패 시 데이터 불일치가 난다. 이번엔 로직 단위로 서비스 메서드가 명확히 분리되어 있어서 판단이 어렵지 않았다.

예외 처리

RuntimeException을 그냥 위로 전파할지 컨트롤러에서 잡아서 뷰/응답으로 변환할지 선택이 필요했다. JSP 화면 연동인 경우엔 @ExceptionHandler 또는 ModelAndView로 에러 화면을 보여주는 방식을 택했다. API 응답이었다면 @RestControllerAdvice로 공통 처리하는 게 맞는데, 이쪽은 뷰 컨트롤러라 지역 처리로도 충분했다.

로그

운영에서 문제가 생겼을 때 추적 가능하도록 주요 입력값과 분기 결과를 INFO로 남겼다. DEBUG로 두면 운영 로그레벨에서 안 보이니 의미가 없고, WARN은 에러 맥락이 아닌 곳에 남기면 알람 노이즈가 된다. 패턴은 단순하게 유지:

log.info("[SystemScan] scanType={}, targetCode={}, resultCount={}", 
    req.getScanType(), req.getTargetCode(), resp.getItems().size());

실제 장애 상황에서 이 한 줄이 결정적인 단서가 되는 경우가 꽤 있다.

기존 로직과의 호환성

수정한 내부 클래스가 다른 컨트롤러나 서비스에서 참조되는지 IDE에서 추적했다. 이 부분이 생각보다 놓치기 쉽다. 정적 분석 도구나 컴파일 에러가 잡아주지 못하는 런타임 의존도가 있을 수 있어서, 로컬 기동 확인을 반드시 하는 이유가 여기 있다.

배포 순서는 항상 같다. 로컬 기동 확인 → 개발 DB 반영 → 스테이징 없으면 바로 운영 배포. 이 순서가 당연해 보여도 급하면 건너뛰게 되는데, 로컬 기동을 생략한 커밋이 운영에서 ClassNotFound 나는 경험을 한 번 하고 나면 다시는 안 건너뛰게 된다.

CI/CD 배포 파이프라인 메모

이번 작업을 배포하면서 CI 환경에서 자주 마주치는 문제들도 다시 확인했다. 따로 정리해 두는 게 좋을 것 같아서.

문제 원인 해결
SSH 타임아웃 빌드가 길어지면 연결 유지 실패 ServerAliveInterval, command_timeout 명시
키 인증 오류 개인키에 개행 문자 포함 키 파일 말미 개행 여부 확인 후 재등록
Gradle 캐시 충돌 CI 환경에서 캐시 디렉터리 꼬임 --no-daemon 또는 캐시 디렉터리 클리어 스텝 추가
환경변수 누락 Secrets 설정 빠뜨림 배포 성공해도 앱이 뜨지 않는 증상으로 확인

SSH 키 개행 문제는 처음엔 원인 파악이 잘 안 된다. Permission denied (publickey) 메시지만 보이고 키가 정상인 것 같아 보이는데, cat -A로 파일 끝 확인하거나 ssh-keygen -l -f 로 파싱 가능 여부 체크하면 금방 잡힌다.

환경변수 누락은 더 사악하다. CI 파이프라인 자체는 초록불인데 앱 기동이 실패하거나 특정 기능이 안 된다. 배포 후 항상 헬스체크 엔드포인트 + 로그 첫 줄을 확인하는 습관이 이 문제를 빨리 잡는 가장 확실한 방법이다.


작업 규모가 작았어도 이런 것들을 정리해 두는 건 의미가 있다. 6개월 뒤에 같은 파일을 건드릴 때, 왜 이렇게 짜여 있는지를 주석보다 회고 글에서 더 잘 기억하게 된다.

댓글 0

첫 댓글 달아줘.