자동화 slecs

Spring MVC 내부 클래스 비즈니스 로직 개선

목차

Spring MVC 기반 레거시 코드에서 내부 클래스를 손보는 작업은 겉으로 보면 단순 수정처럼 보이지만, 파고들면 꽤 신경 쓸 포인트가 나온다. 이번 작업도 그랬음. 사용자가 실제로 쓰는 흐름에서 필요한 기능이었고, 운영 중에 발견된 개선 포인트를 반영했다. 작업 규모 자체는 크지 않았지만, 이런 수정이 쌓여서 시스템 신뢰도를 만드는 방식이기도 함.

내부 클래스 구조와 역할 분리

Spring MVC에서 내부 클래스(inner class 혹은 static nested class)를 쓰는 패턴은 요청 파라미터 바인딩용 DTO를 Controller 안에 같이 두거나, 특정 처리 단위를 묶을 때 자주 등장한다. 이 구조의 장점은 응집도다. 같은 기능에 속하는 코드가 한 파일 안에 있어서 맥락 파악이 쉽고, 어디 가서 찾을 필요가 없다. 단점은 파일이 커질수록 가독성이 떨어지고, 테스트 작성 시 외부에서 접근이 번거로워진다는 점.

이번에 수정한 내부 클래스는 요청 파라미터 검증과 바인딩, 그 결과를 가지고 비즈니스 로직을 처리하는 부분이었다. 구조를 단순하게 표현하면 이렇다:

@Controller
public class SomeController {

    @Autowired
    private SomeService someService;

    @RequestMapping("/some-path")
    public String handle(SomeForm form, BindingResult result, Model model) {
        if (result.hasErrors()) {
            return "error-view";
        }
        someService.process(form.toCommand());
        return "success-view";
    }

    // 내부 클래스: 요청 파라미터 바인딩 + 변환 책임만
    public static class SomeForm {
        private String param1;
        private Integer param2;

        public SomeCommand toCommand() {
            // null, 빈 값 처리를 여기서 끝내고 Service로 넘기면
            // Service는 순수 비즈니스 로직만 담당할 수 있음
            return new SomeCommand(
                param1 != null ? param1.trim() : "",
                param2 != null ? param2 : 0
            );
        }
    }
}

폼 객체를 내부 클래스로 두면 Controller와 같은 맥락에서 변경 관리가 쉬운 반면, toCommand() 같은 변환 메서드를 통해 Service 계층과 파라미터 객체를 분리할 수 있다. 이 경계를 처음부터 명확히 해두면 Service 레이어 단위 테스트를 작성할 때 훨씬 편해짐. 파라미터 처리와 비즈니스 로직이 뒤섞이면 테스트 케이스가 복잡해지고, 나중에 수정할 때 어디를 건드려야 하는지 헷갈린다.

null, 빈 값, 비정상 입력 처리는 여기서 끝내는 게 맞다. Service까지 넘어갔다가 NPE가 터지는 패턴은 오류 발생 위치를 찾기도 어렵고, 메시지를 사용자에게 돌려주는 흐름도 복잡해진다.

트랜잭션 범위, 예외 처리 - 선택의 연속

DB를 건드리는 로직이 포함되면 트랜잭션 범위를 어떻게 잡느냐가 핵심이다. Spring에서는 @Transactional을 Service 메서드에 붙이는 게 일반적인데, 범위를 어디까지 묶을지는 케이스마다 판단이 달라진다.

기존 로직과 호환성을 유지하면서 기능을 추가하는 경우라면, 신규 로직을 기존 트랜잭션 안에 포함시킬 건지 별도로 가져갈 건지 먼저 결정해야 함. 같은 비즈니스 단위면 묶는 게 맞다. 다만 외부 연동이나 메일 발송처럼 롤백이 안 되는 사이드 이펙트가 끼어 있으면 트랜잭션 경계 밖으로 빼야 한다. 이 부분을 놓치면 DB 롤백은 됐는데 메일은 이미 나간 상황이 생김.

예외 처리는 두 갈래로 나뉜다:

  • RuntimeException을 그대로 전파해서 상위 공통 핸들러에서 처리
  • 잡아서 의미 있는 응답으로 변환 후 반환

운영 환경에서는 전파보다 변환 쪽을 선호하는 편이다. 어디서 터진 예외인지 로그에서 추적 가능하고, 사용자 입장에서 의미 없는 500 응답 대신 명확한 에러 메시지를 줄 수 있어서. 이번 작업에서도 해당 케이스에 맞춰 선택하고, 중요 입출력값은 INFO 레벨로 남겨뒀다. 나중에 운영 이슈가 생겼을 때 로그만 봐도 어느 단계에서 무슨 값이 들어왔는지 파악하기 위해서임.

MyBatis XML 쪽은 쿼리 결과 매핑 시 컬럼명과 결과 타입 불일치가 조용히 NPE나 데이터 오염으로 이어지는 경우가 있다. resultType 단순 매핑보다 resultMap을 명시적으로 선언해서 쓰는 편이 안전함. 특히 null 처리나 기본값이 필요한 컬럼이 있을 때. DB 컬럼이 스네이크 케이스고 Java 필드가 카멜 케이스일 때 자동 변환에만 의존하다가 특정 컬럼이 빠지는 경우도 있어서, 쿼리 수정이 있을 때는 매핑 정의를 함께 확인하는 습관이 있다.

수정한 로직이 기존 흐름에 영향을 주지 않는지 추적하는 것도 중요하다. 같은 Service 메서드를 호출하는 다른 Controller나 배치가 있으면, 거기에도 영향이 가는지 확인해야 함. 레거시 코드에서 이게 의외로 잘 빠지는 부분이다.

CI/CD 배포 과정에서 마주친 것들

로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 진행했는데, 파이프라인 쪽에서 체크가 필요한 부분이 있었다.

문제 원인 대응
SSH 타임아웃 빌드가 길어지면 연결 끊김 ServerAliveInterval / command_timeout 명시
키 인증 오류 비밀키 파일 개행 문자 혼입 키 파일 생성 시 개행 포함 여부 확인
Gradle 캐시 충돌 CI 환경 캐시 꼬임 --no-daemon 또는 캐시 클리어 스텝 추가
환경변수 누락 Secrets 설정 빠짐 배포 후 헬스체크 엔드포인트 파이프라인 포함

SSH 타임아웃은 빌드가 길어질 때 자주 나온다. 연결이 끊기면 배포 스크립트가 중간에 죽는데, 설정을 명시해두지 않으면 원인 파악이 오래 걸림. 키 인증 오류도 마찬가지 - 에러 메시지가 직관적이지 않아서 처음 만나면 시간을 잡아먹는다.

환경변수 누락은 배포 자체는 성공으로 찍혀도 앱이 실제로 안 뜨는 상황을 만든다. 배포 성공 여부와 서비스 정상 기동 여부는 다른 문제라는 걸 경험으로 배웠음. 헬스체크 엔드포인트를 파이프라인 마지막 스텝에 넣어두면 이런 케이스를 배포 직후에 잡을 수 있다.

이번 작업 자체는 규모가 크지 않았다. 그런데 검증 로직, 트랜잭션 범위, 예외 처리 선택, 기존 코드 영향 범위 확인, 배포 검증까지 체크포인트는 큰 작업이랑 다를 게 없음. 오히려 작은 작업일수록 "이 정도야" 하고 리뷰를 줄이는 경향이 생기는데, 그게 나중에 조용히 이슈로 돌아오는 패턴이었다. 작은 수정도 같은 기준으로 짚고 넘어가는 게 결국 운영 안정성을 유지하는 방식이라고 생각함.

댓글 0

첫 댓글 달아줘.