개발 slecs

어드민 하드코드 점검으로 운영 안정성 개선

목차

어드민은 항상 뒷전이다. 기능 개발이 밀려있다 보면 어드민 코드는 "돌아가면 됨" 상태로 오래 방치된다. 그러다 어느 날 코드를 다시 열어보면 - 매직 넘버, 고정 문자열, 환경에 따라 달라져야 하는데 그냥 박혀있는 값들이 군데군데 나온다. 이번 작업이 그런 점검이었다.

Spring MVC + MyBatis 구조에서 어드민 코드가 흘러가는 방식은 대체로 비슷하다. 컨트롤러(또는 내부 커맨드 클래스)가 요청을 받아 파라미터를 바인딩하고, 서비스/DAO 레이어로 넘기고, SQL XML에서 쿼리를 돌리고, JSP가 결과를 렌더링한다. 이 흐름 자체가 문제는 아닌데, 각 레이어에 하드코드가 박혀있으면 이야기가 달라진다.

하드코드의 흔한 패턴을 보면:

유형 예시 문제
매직 넘버 if (status == 1) 1이 뭔지 코드 밖에선 알 수 없음
고정 문자열 role.equals("ADMIN") 값 변경 시 전체 탐색 필요
페이지 사이즈 limit = 20 여러 곳 중복 일괄 변경이 어려움
XML 인라인 조건 AND status = 'Y' 직접 삽입 동적 처리 불가

운영 중에 이런 값이 터지면 추적이 생각보다 오래 걸린다. 특히 상태값이나 코드값이 DB에서 바뀌었는데 코드에 구 값이 남아있는 케이스가 가장 흔하다.

내부 클래스 정리하면서 챙긴 것들

Spring MVC에서 어드민 페이지는 컨트롤러 하나에 내부 커맨드 클래스를 붙여쓰는 구조가 많다. 작은 화면 기준으로는 실용적인 방식인데, 내부 클래스가 단순 VO를 넘어 비즈니스 로직까지 들고 있으면 관리가 어려워진다.

이번에 수정한 패턴 중 하나:

// 수정 전 - 검증 값이 하드코드
if (param.getType() == null || param.getType().equals("01")) {
    throw new IllegalArgumentException("타입 오류");
}
// 수정 후 - 상수 분리 + null 안전한 비교 순서
private static final String DEFAULT_TYPE = "01";

if (param.getType() == null || DEFAULT_TYPE.equals(param.getType())) {
    throw new IllegalArgumentException("타입 오류");
}

상수화 자체도 중요한데, null 체크 순서도 같이 잡았다. param.getType().equals("01") 이 순서면 getType이 null일 때 NPE가 난다. 상수를 앞에 두는 것만으로 방어가 된다. 어드민 코드에서 이런 부분이 생각보다 많이 나온다.

예외 처리 방향도 이번 기회에 다시 정리했다. RuntimeException을 그냥 전파할지, 잡아서 응답으로 변환할지는 호출 맥락에 따라 다르다. 어드민 화면 중심이면 에러 원인을 모델에 담아 JSP로 내려보내는 방식이 자연스럽다. 반면 어드민에서 내부 API를 호출하는 구조라면 응답 형식이 고정돼야 하니 변환이 필요함. 어느 쪽이든 예외 처리 방식이 화면마다 들쭉날쭉한 건 피해야 하는데, 이번 점검에서 흐름을 통일하는 작업도 같이 했다.

로그는 어드민 특성상 INFO 레벨로 주요 입출력값을 남겨두는 게 낫다. 어드민 기능은 사용 빈도가 낮고 문제가 생기면 재현이 어려운 경우가 많다. DEBUG로 남기면 운영 환경 설정에 따라 아예 안 보이는 경우가 있으니까.

MyBatis XML과 트랜잭션

MyBatis를 쓰면 SQL을 XML에서 관리하기 때문에 쿼리 자체는 분리돼 있다. 근데 XML 안에도 하드코드가 들어가는 경우가 있다. 동적 쿼리 조건에서 특히 많이 나온다:

<!-- 수정 전 - 조건값이 XML에 직접 박힘 -->
<if test="status != null and status == 'A'">
    AND use_yn = 'Y'
</if>

<!-- 수정 후 - 파라미터로 분리 -->
<if test="useYn != null and useYn != ''">
    AND use_yn = #{useYn}
</if>

XML 안에 값을 직접 박으면 나중에 조건이 바뀌거나 화면에서 동적으로 처리해야 할 때 XML까지 건드려야 한다. 파라미터화해두면 자바 쪽에서 유연하게 조립할 수 있고, 같은 SQL을 다른 조건으로 재활용하기도 쉬워진다.

결과 매핑은 resultType으로 Map을 쓰면 편한 것 같지만, 나중에 어느 키가 들어오는지 파악이 어려워진다. resultMap으로 명시적으로 연결해두면 컬럼이 추가되거나 이름이 바뀌어도 추적이 쉽다.

트랜잭션 범위는 어드민 코드에서 느슨하게 처리된 경우가 종종 있었다. 이번에 챙긴 포인트:

  • 쓰기가 포함된 서비스 메서드에 @Transactional 명시 여부
  • 조회 전용 메서드에 readOnly = true 옵션 - 커넥션 최적화에도 영향
  • 외부 API 호출과 DB 쓰기가 섞인 경우 - 트랜잭션 범위를 어디까지 잡을지

마지막 케이스가 제일 까다롭다. 외부 호출이 성공하고 DB 쓰기가 실패하면 롤백이 되는데, 반대로 DB는 됐는데 외부 API가 실패하면 이미 커밋된 상태라 되돌리기 어렵다. 이 케이스는 어드민에서 어디까지 원자성을 보장할지 팀 규칙으로 정해두는 게 낫다.

CI/CD 배포 파이프라인 짚고 넘어가기

로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 진행했는데, CI 환경에서 반복적으로 나오는 문제들은 배포할 때마다 머릿속에 같이 돌아간다:

  • SSH 타임아웃: 빌드가 길어지면 연결이 끊긴다. command_timeout을 명시해야 안전하다.
  • 키 인증 오류: 개행 문자 포함 여부를 확인해야 한다. 환경변수로 키를 넘길 때 trailing newline이 들어가면 인증이 조용히 실패한다.
  • Gradle 캐시 충돌: CI 환경에서 캐시가 꼬이면 --no-daemon으로 돌리거나 캐시를 날려야 한다. 빌드는 성공하는데 런타임 오류가 나면 캐시부터 의심하는 게 맞다.
  • 환경변수 누락: Secrets 설정을 빠뜨리면 배포는 성공해도 앱이 안 뜬다. 배포 후 헬스체크를 파이프라인에 넣어두면 이 케이스를 빠르게 잡을 수 있다.

이번 배포에서 특이한 이슈는 없었다. 다만 설정 변경이 끼어있을 때는 위 항목들을 한 번씩 훑는 습관이 자연스럽게 생겼다.

작업 규모는 크지 않았다. 하드코드 하나가 운영 중에 터지면 원인 추적하는 데 드는 시간이 훨씬 크니까, 이런 점검 작업들이 쌓이면서 코드베이스 전체의 예측 가능성이 조금씩 올라간다고 생각한다.

댓글 0

첫 댓글 달아줘.