어드민 내부 클래스 비즈니스 로직 안정성 개선
목차
어드민 내부 클래스 쪽 비즈니스 로직을 손봤다. 작업 규모 자체는 크지 않았는데, 정작 건드리면서 챙겨야 할 게 생각보다 많았다.
Spring MVC + MyBatis 구조에서 어드민 기능은 대체로 이런 흐름으로 돌아간다. 컨트롤러 내부에 정적 이너 클래스를 두고 요청 파라미터를 바인딩·검증한 뒤, 서비스 레이어나 내부 클래스에서 비즈니스 로직을 처리하고, SQL XML로 쿼리를 날려 결과를 매핑한다. 마지막에 JSP가 HTML을 렌더링하거나, REST 엔드포인트면 JSON으로 응답을 구성한다. 구조 자체는 단순한데, 이 단순한 구조 안에서 예외 케이스나 트랜잭션 경계를 제대로 안 잡으면 운영에서 조용히 터진다.
이번 작업에서 실제로 고민한 지점들
파라미터 검증과 바인딩
어드민 화면은 사용자가 직접 치는 값들이라 null이나 빈 문자열, 예상 밖의 형식이 그냥 들어온다. 프론트에서 막는다고 믿으면 안 된다. 내부 클래스에서 받을 때 방어적으로 처리해야 한다.
// 내부 클래스 예시 (Spring MVC 컨트롤러 안)
@InitBinder
protected void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}
// 서비스 진입 전 필수 파라미터 검증
private void validate(AdminRequestDto dto) {
if (dto.getTargetId() == null || dto.getTargetId().isBlank()) {
throw new IllegalArgumentException("targetId는 필수입니다");
}
}
StringTrimmerEditor를 등록해두면 공백 문자열을 자동으로 null로 변환해줘서 이후 로직이 깔끔해진다. 이걸 안 해두면 " " 같은 값이 들어와서 조건 분기가 예상대로 안 타는 경우가 생긴다.
트랜잭션 범위
DB를 건드리는 로직은 @Transactional 범위를 명확히 설정해야 한다. 특히 어드민 기능에서 여러 테이블을 한 번에 수정하는 케이스가 있으면 하나라도 실패했을 때 롤백이 일관되게 돼야 한다. 이번 작업에서도 트랜잭션 경계를 다시 확인했다.
문제는 내부 클래스에서 @Transactional을 걸면 Spring AOP 프록시가 작동 안 할 수 있다는 점이다. 내부 클래스는 Spring 빈이 아니라서 프록시 대상에서 빠진다. 비즈니스 로직이 실제로 트랜잭션 안에서 실행되게 하려면 서비스 레이어에 위임하거나, 아니면 직접 TransactionTemplate을 주입해서 쓰는 방법을 선택해야 한다.
// 서비스 레이어에 위임하는 패턴
@Service
@RequiredArgsConstructor
public class AdminService {
private final SomeMapper mapper;
@Transactional
public void process(AdminRequestDto dto) {
mapper.updateMain(dto);
mapper.updateDetail(dto);
// 여기서 예외 나면 둘 다 롤백
}
}
예외 처리 전략
RuntimeException을 그냥 위로 전파할지, 잡아서 응답으로 변환할지는 상황에 따라 다르다. 어드민 기능이라면 크게 두 가지 케이스로 나뉜다.
| 케이스 | 처리 방식 |
|---|---|
| 입력값 오류 (validation fail) | 잡아서 400 또는 에러 메시지로 변환 |
| 예상 밖 시스템 에러 | 전파 → 글로벌 핸들러에서 500 처리 |
| 비즈니스 규칙 위반 | 잡아서 사용자 친화적 메시지로 변환 |
어드민은 내부 사용자가 쓰는 화면이라 에러 메시지에 어느 정도 구체적인 내용을 담아도 된다. 사용자 향 서비스였으면 에러 내용을 노출하면 안 되지만.
로그
운영에서 이슈 생겼을 때 디버깅하려면 중요 입출력값을 INFO로 남겨둬야 한다. DEBUG로 찍으면 운영 환경에서는 꺼져 있어서 아무 소용이 없다. 어드민 요청 기준으로는 최소한 어떤 액션이 들어왔고, 처리 결과가 어떻게 됐는지는 남긴다.
log.info("[AdminProcess] action={}, targetId={}, result={}", dto.getAction(), dto.getTargetId(), result);
너무 많이 남기면 로그가 노이즈가 되니까 "이 값이 없었으면 트래킹 못 했다" 싶은 것만.
배포까지 가는 흐름에서 챙긴 것
로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 처리했는데, CI/CD 단에서 매번 비슷한 문제들이 끼어든다. 기록해두는 차원에서 정리하면:
- SSH 타임아웃: 빌드가 길어지면 파이프라인 중간에 연결이 끊긴다.
command_timeout을 명시적으로 설정해야 한다. 안 하면 빌드가 완료돼도 배포 step에서 조용히 실패한다. - 키 인증 오류: SSH 키에 개행 문자가 끼어 있으면 인증이 안 된다. Secret 값 붙여넣을 때 trailing newline 확인이 필요하다.
- Gradle 캐시 충돌: CI 환경에서 캐시가 꼬이면
--no-daemon옵션 주거나 캐시 디렉토리를 클리어해서 해결한다. 로컬에서는 잘 되는데 CI에서만 안 되는 경우 대부분 여기서 난다. - 환경변수 누락: Secrets 설정을 빠뜨리면 배포 자체는 성공해도 앱이 안 뜬다. 빌드 로그만 보면 멀쩡한데 앱 로그 보면 NPE 또는 설정 못 읽었다는 에러가 나 있는 패턴.
이번 배포는 별 문제 없이 나갔다.
작게 챙겨야 하는 것들이 쌓인다
어드민 내부 클래스 수정은 비교적 작은 단위 작업이다. 근데 이런 작업들이 방치되면 나중에 "왜 이게 가끔 이상하게 동작하지?" 싶은 케이스들이 생긴다. 파라미터 검증 빠진 곳, 트랜잭션 범위 불명확한 곳, 예외 그냥 삼킨 곳. 운영에서 발견되기 전에 코드 리뷰 단에서 잡는 게 낫고, 수정한 로직이 기존 흐름에 영향 없는지도 직접 트래킹하는 습관이 중요하다.
기존 로직과의 호환성 확인은 특히 MyBatis 쿼리 쪽에서 빠트리기 쉽다. SQL XML 건드리면 해당 쿼리를 호출하는 다른 경로가 있는지도 같이 보는 편이 안전하다.
댓글 0
첫 댓글 달아줘.