어드민 내부 클래스 비즈니스 로직 개선으로 운영 안정성 향상
목차
어드민 코드는 사용자 대면 서비스와 달리 "일단 돌아가면 됨" 기조로 오래 방치되는 경우가 많다. 그게 쌓이면 어느 순간 누가 건드려도 무서운 덩어리가 된다. 이번 작업은 그 덩어리의 한 귀퉁이를 제대로 정리한 것이다.
Spring MVC + MyBatis 조합에서 어드민 기능의 핵심은 내부 클래스(inner class 또는 컨트롤러 하위에 정의된 커맨드/폼 클래스 계열)다. 요청 파라미터를 받아서 바인딩하고, 그 값을 들고 서비스 레이어나 매퍼로 넘기고, 결과를 JSP로 던지거나 JSON으로 직렬화하는 역할. 구조 자체는 단순한데 문제는 이 내부 클래스에 비즈니스 로직이 조금씩 끼어들기 시작할 때다. 파라미터 변환인 줄 알았는데 그 안에 조건 분기가 있고, 거기서 DB 접근이 일어나고. 그게 어느 날 null 이 들어오거나 예상 밖 입력이 생기면 스택 트레이스 한 줄만 남기고 조용히 실패한다.
이번에 손댄 것들
파라미터 검증을 내부 클래스 바깥으로 빼고, 비즈니스 로직 흐름을 명확히 분리했다. 구체적으로는 세 가지 포인트를 중심으로 작업했다.
요청 바인딩 단계 검증. 빈 값이나 null이 그대로 쿼리로 흘러가는 경로를 차단했다. Spring MVC의 @ModelAttribute 바인딩은 편리하지만 검증을 따로 하지 않으면 SQL에 WHERE col = '' 같은 게 그대로 실행된다. MyBatis XML에서 <if test="param != null and param != ''"> 조건을 다는 것만으로는 부족하고, 컨트롤러 진입 전에 한 번 걸러야 운영에서 이상한 결과 조회를 막을 수 있다.
트랜잭션 범위. 내부 클래스에서 로직을 처리하다 보면 @Transactional을 어디다 걸어야 하는지 애매해진다. 서비스 메서드에 걸어야 하는데 내부 클래스 메서드에 직접 박아놓거나, 혹은 아예 빠져 있거나. 이번 작업에서는 DB를 변경하는 흐름 전체를 하나의 서비스 메서드로 감싸고 거기에만 @Transactional을 명시했다. 프록시 기반 AOP 특성상 같은 빈 안에서 자기 자신을 호출하면 트랜잭션이 적용 안 되는 함정도 있어서, 내부 호출 구조를 한 번 더 점검했다.
예외 처리 방향. RuntimeException을 그냥 위로 던질지, 잡아서 응답으로 변환할지. 어드민은 일반 사용자 대상이 아니라 운영자가 쓰기 때문에 예외 메시지가 날것으로 노출돼도 큰 문제는 없다. 하지만 그렇다고 스택 트레이스를 화면에 뿌리는 건 별로다. 이번엔 비즈니스 예외는 잡아서 모델에 에러 메시지를 담아 JSP로 돌려보내고, 예상 못한 시스템 예외는 그냥 전파하는 방향으로 정리했다.
try {
service.process(param);
model.addAttribute("result", "처리 완료");
} catch (IllegalArgumentException e) {
// 비즈니스 예외: 화면에 메시지 표시
model.addAttribute("error", e.getMessage());
return "admin/error-view";
}
// RuntimeException 외 나머지는 전파 → ControllerAdvice 또는 web.xml 에러 페이지
SQL 쿼리 쪽은 MyBatis XML에서 결과 매핑 컬럼명 불일치 문제를 하나 잡았다. resultMap을 쓰지 않고 resultType에 VO를 그냥 박아놨는데, DB 컬럼 네이밍(snake_case)과 Java 필드(camelCase)가 맞지 않아서 일부 필드가 조용히 null로 내려오고 있었다. MyBatis 설정에서 mapUnderscoreToCamelCase를 켜거나 resultMap을 명시하는 두 가지 선택지 중 기존 매퍼들과 일관성을 위해 설정 옵션으로 해결했다.
배포까지 흐름과 CI/CD 잡음
로컬 기동 확인 → 개발 DB 반영 → 배포 순으로 처리했는데, CI/CD 파이프라인에서 몇 가지 잡음이 있었다.
| 문제 | 원인 | 대응 |
|---|---|---|
| SSH 타임아웃 | 빌드 시간이 길어지면 연결 끊김 | command_timeout 명시 |
| 키 인증 오류 | 개행 문자 포함 여부 | Secret 값 재등록, trailing newline 확인 |
| Gradle 캐시 충돌 | CI 환경 캐시 오염 | --no-daemon + 캐시 클리어 |
| 앱 미기동 | 환경변수 누락 | Secrets 체크리스트 재확인 |
SSH 타임아웃은 특히 초반에 삽질했다. 빌드 자체는 성공했는데 배포 단계에서 연결이 끊겨서 결과를 알 수 없는 상태가 반복됐다. command_timeout을 명시하고 나서야 안정됐다. Gradle 캐시 문제는 로컬에서는 멀쩡한데 CI에서만 이상한 오류가 나올 때 의심해볼 것. --no-daemon 붙이고 캐시 디렉토리 날리면 대부분 해결된다.
작게 보이는 작업이 실제로 하는 일
이번 커밋 자체는 크지 않다. 파일 몇 개, 로직 흐름 정리, 검증 추가, 트랜잭션 범위 명확화. 그런데 이런 작업이 쌓이면 나중에 기능을 추가할 때 겁이 덜 난다. 기존 코드가 어떻게 동작하는지 추적하기 어렵지 않고, 어디까지가 트랜잭션인지 명확하고, 예외가 어디서 어떻게 처리되는지 예측 가능한 상태가 된다.
로그도 챙겼다. 중요한 입출력 값을 INFO로 남겨두는 거, 귀찮아 보여도 운영에서 문제 생겼을 때 로그 없으면 손이 안 나간다. DEBUG로 남기면 운영 환경에서 안 찍히는 경우 많으니 꼭 봐야 하는 값은 INFO 레벨로 박아두는 게 맞다.
코드 리뷰 단계에서는 수정한 로직이 기존 흐름에 영향을 주는지 추적하는 데 시간을 좀 썼다. 어드민 내부 클래스는 여러 액션에서 공유되는 경우가 있어서, 하나 건드렸을 때 다른 기능이 조용히 깨지는 상황이 발생할 수 있다. 변경 범위를 좁게 잡고, 영향받는 경로를 전부 테스트하는 게 기본이지만, 이번처럼 규모가 작을 때도 그 루틴을 유지하는 게 습관이 된다.
댓글 0
첫 댓글 달아줘.