개발 slecs

매뉴얼 화면에서 사이드바 메뉴가 뒤섞이던 모델 키 충돌 해결

목차

매뉴얼 자동 생성 화면을 손보다 이상한 현상을 만났다. 본문에 떠야 할 항목 리스트가 가끔 사이드바 메뉴로 둔갑하는 것. 새로고침하면 또 달라진다. 개발 환경에서는 재현이 되는데 일정하지 않은, 그 특유의 짜증스러운 류의 버그였다.

처음엔 당연히 캐시를 의심했다. 브라우저 캐시, 서버 사이드 캐시 다 날려봤는데 증상이 그대로였다. 다음은 비동기 타이밍 문제인가 싶어 그쪽 흐름을 뒤졌고, 거기도 아니었다. 결국 컨트롤러에서 모델에 실제로 뭐가 들어가는지 로그로 직접 찍었을 때 바로 보였다.

원인은 단순했다. 공통 인터셉터에서 모든 화면에 menuList라는 이름으로 GNB/사이드바용 메뉴를 모델에 꽂아주고 있었고, 매뉴얼 생성기 컨트롤러에서도 본문에 출력할 가이드 항목을 똑같이 menuList로 담고 있었다. 같은 키, 다른 데이터. 어느 쪽이 마지막에 들어가느냐에 따라 렌더링 결과가 달라지는 후행 덮어쓰기 충돌이었다.

Spring MVC 기준으로 얘기하면, HandlerInterceptorpostHandle은 컨트롤러가 반환한 이후, 뷰가 렌더링되기 직전에 모델을 수정할 수 있다. 컨트롤러가 먼저 menuList를 넣고 인터셉터가 같은 키로 덮어쓰면 컨트롤러 값은 사라진다. 반대로 인터셉터 등록 순서가 달라지거나 preHandle에서 먼저 값을 넣는 구조라면 컨트롤러 값이 살아남는다. 이 순서가 환경이나 인터셉터 등록 방식에 따라 미묘하게 달라질 수 있어서, 새로고침할 때마다 결과가 달랐던 것. "재현은 되는데 일정하지 않다"는 건 보통 실행 순서 의존성이나 비결정적 타이밍이 끼어있다는 신호다.

이 종류의 버그는 단위 테스트로 잡기가 애매하다. 컨트롤러 단독 테스트에서는 인터셉터가 개입하지 않으니 항상 컨트롤러 값만 보인다. 통합 테스트에서도 인터셉터 체인을 정확히 구성하지 않으면 재현이 안 된다. 코드 리뷰 때 menuList라는 이름을 보고 "이게 공통 인터셉터 키랑 겹치겠네"를 알아채려면, 팀 전체가 공통 키 목록을 머릿속에 갖고 있어야 한다. 대개 그렇지 않으니 그냥 지나친다.

해결: 이름을 갈라놓는 것으로 끝

이름만 바꾸면 끝나는 문제였다. 매뉴얼 본문용 변수를 manualMenuList로 변경하고, 템플릿에서 참조하는 쪽도 동일하게 수정했다.

변수 의미 주입 위치
menuList GNB/사이드바 공통 메뉴 공통 인터셉터
manualMenuList 매뉴얼 본문 항목 매뉴얼 생성 컨트롤러
// 공통 인터셉터 (변경 없음)
model.addAttribute("menuList", gnbMenuService.getMenuList());

// 매뉴얼 생성 컨트롤러
// Before: model.addAttribute("menuList", manualItems);
model.addAttribute("manualMenuList", manualItems);

템플릿에서 ${menuList} 참조를 ${manualMenuList}로 교체하고 매뉴얼 화면 렌더링 정상 확인. PR 사이즈는 작지만, 인터셉터와 컨트롤러 사이의 충돌 경로 자체를 끊어낸 변경이라 효과는 사이즈에 비해 컸다. 리뷰어 입장에서도 이름이 분리돼 있으면 두 변수의 역할이 코드에서 바로 읽힌다. menuList 두 개가 섞인 코드를 읽을 때와, menuList/manualMenuList로 갈라진 코드를 읽을 때 피로감이 다르다.

회고: 공통 인터셉터 키는 예약어처럼 관리해야 한다

공통 인터셉터에서 자동 주입되는 키는 예약어처럼 취급해야 한다. 프로젝트 전체에 menuList, userInfo, siteConfig 같은 키가 전역으로 돌고 있다면, 그 이름들은 컨트롤러에서 다른 용도로 쓸 수 없는 이름이다. 근데 이걸 어디 적어두지 않으면 새 화면을 추가할 때 자연스럽게 겹친다. 특히 list, data, items, menuList처럼 일반 명사 계열이 위험하다. 설명 없이 봐도 "이런 걸 담겠구나" 싶은 직관적인 이름들이 오히려 충돌 빈도가 높다.

몇 가지 가드레일을 추리면 이렇다.

  • 공통 모델 키 목록을 팀 어딘가에 적어둔다. README든 위키든 형식보다 존재가 중요하다. 알아야 피할 수 있다.
  • 컨트롤러 레벨 변수에는 기능 접두어를 붙이는 규칙을 정한다. manualXxx, partnerXxx, dashboardXxx처럼. 일반 명사를 그대로 쓰면 충돌 확률이 올라간다.
  • 화면이 이상하게 렌더링되고 원인이 안 보일 때, 컨트롤러 로직보다 모델 상태를 먼저 찍는다.

인터셉터가 개입하는 시스템에서는 컨트롤러 코드만 봐서는 모델의 최종 상태를 알 수 없다. 인터셉터 개수와 실행 순서에 따라 컨트롤러가 넣은 값이 살아남을 수도, 덮어써질 수도 있다. 모델 전체를 한 번에 보는 게 이 불확실성을 없애는 가장 빠른 방법이다.

// 디버깅용: 컨트롤러 또는 인터셉터에 임시 추가
model.asMap().forEach((k, v) ->
    log.debug("[ModelDump] key={}, type={}", k, v.getClass().getSimpleName())
);

값 내용보다 타입만 확인해도 의심 키를 빠르게 추릴 수 있다. menuListList<GnbMenuItem>이 있어야 하는데 List<ManualItem>이 들어와 있으면 바로 드러난다. 컨트롤러 로직만 뒤졌다면 한참 더 걸렸을 거다.

이 버그의 본질은 전역 상태와 로컬 상태가 같은 네임스페이스를 공유한다는 점이다. 비슷한 문제가 Redux 스토어, 세션 속성, HTTP 헤더 이름처럼 다양한 형태로 반복된다. 해결 방향은 항상 비슷하다. 네임스페이스를 명시적으로 분리하고, 공통 키를 어디선가 선언해두는 것. 기술보다 규칙의 문제다. 작은 가드레일 하나 쳤다.

댓글 0

첫 댓글 달아줘.