frontend
컴포넌트가 조용히 사라졌다 — 관례를 절반만 지킨 대가
· Ascendy Engineering
TL;DR
- 초대 QR이 빈 흰 박스로 나왔다. 에러도 아니고 빈 화면도 아닌 — 자리는 차지하는데 내용이 없는 상태. dev에서도 화면은 똑같았고, 다른 건 콘솔에 경고 한 줄이 찍히느냐뿐이었다(그리고 아무도 그 줄을 보지 않았다).
- 범인은 QR 라이브러리도, 직전에 들어간 보안 패치도 아니었다. 컴포넌트 자동임포트의 이름 규칙이었다. 특정 디렉토리 아래 컴포넌트는 디렉토리명이 앞에 붙은 이름으로 등록되는데, 템플릿 몇 곳이 명시 임포트 없이 파일명 그대로 태그를 썼다.
- 그래서 빌드가 그 태그를 정적 임포트로 치환하지 못하고 런타임 조회로 남겼고, 찾지 못한 이름은 미해석 커스텀 엘리먼트로 조용히 렌더됐다.
- 방향을 튼 건 번들이었다. 빌드 산출물에서 QR 라이브러리의 고유 문자열을 grep했더니 안 나왔다. 그 자체가 증명은 아니지만, “라이브러리가 실패했다”는 가설을 흔들어 컴파일 결과와 등록명을 직접 보게 만들었다.
- 그런데 이 글이 하고 싶은 말은 그 규칙이 아니다. 이 저장소는 대부분 명시 임포트 관례를 따랐다. 그래서 자동임포트가 실제로 어떻게 이름을 등록하는지 아무도 검증한 적이 없었다. 관례를 절반만 지키면, 안 쓰는 절반은 미검증 상태로 남는다.
이 글에 대하여. 프론트엔드 팀 인테이크를 정제한 회고이며, 언급된 결함은 모두 수정·배포됐다. 같은 팀의 인접 사례로 조용한 폴백이 만들어낸 오진 세 개가 있는데, 그 글의 논지(신호 부재가 오진을 공급한다)와 이 글의 논지(관례의 미사용 절반이 미검증으로 남는다)는 다르다.
증상 — 에러도 아니고 빈 화면도 아니다
친구 초대 화면에 QR 코드가 떠야 하는데, 빈 흰 박스가 나왔다.
먼저 짚어둘 게 있다. 이건 프로덕션 전용 버그가 아니었다. dev에서도 화면은 똑같이 빈 박스였다. 다른 건 하나뿐 — dev에서는 콘솔에 “컴포넌트를 해석하지 못했다”는 경고가 한 줄 찍혔고, 프로덕션에서는 그것마저 없었다. 그리고 그 한 줄을 아무도 보지 않았다. 제보가 프로덕션에서 올라온 건 거기가 실제 사용자가 있는 곳이었기 때문이지, 거기서만 깨져서가 아니다.
이 “빈 흰 박스”라는 모양이 이 사건의 성격을 잘 보여준다. 에러 화면이면 스택이 있다. 아예 아무것도 안 그려지면 레이아웃이 무너져서 눈에 띈다. 그런데 자리는 정확히 차지하고 내용만 없으면, 그건 렌더는 됐는데 안이 비었다처럼 보인다. 자연히 의심은 그 안을 채우는 로직 쪽으로 간다.
그럴듯했던 오진
마침 직전에 QR 생성 방식을 바꾸는 패치가 있었다. 외부 서비스로 QR을 만들던 걸 로컬 생성으로 옮기는 보안 개선이었다.
시간 순서가 완벽했다. 그 패치 이후로 QR이 안 보인다. 그러면 그 패치가 뭔가 깨뜨렸다가 가장 합리적인 추론이다. 운영자의 최초 가설도 그거였고, 나였어도 거기서 시작했을 것이다.
여기에 하나가 더 겹쳤다. QR 컴포넌트 안에는 생성 실패에 대비한 조용한 catch가 있었다 — 실패하면 이미지 데이터를 빈 문자열로 두고 넘어간다. 이 코드의 존재가 “라이브러리가 프로덕션에서 실패했고, catch가 그걸 삼켜서 빈 박스가 됐다” 는 서사를 완성시켜 준다. 코드를 읽으면 읽을수록 그 가설이 맞아 보인다.
번들이 방향을 틀었다
돌파구는 증상을 더 파는 게 아니라 빌드 산출물을 보는 것이었다.
먼저 dev에서 QR 라이브러리를 직접 불러 생성해 봤다. 정상 동작. 라이브러리 가설이 여기서 한 번 흔들렸다.
그다음, 프로덕션 빌드를 하고 QR 라이브러리에만 있는 고유 에러 문자열을 클라이언트 번들에서 grep했다.
npm run build
grep -r "too big to be stored in a QR Code" dist/ # 라이브러리 고유 문자열
# → 결과 없음
여기서 결론으로 건너뛰지 않는 게 중요하다. 문자열 하나가 안 나온 것은 “라이브러리가 번들에 없다”의 증명이 아니다. 미니파이·데드코드 제거로 그 문자열만 사라졌을 수도, 서버 전용 청크에 들어갔을 수도, 외부 의존성으로 빠졌을 수도, 내가 엉뚱한 출력 범위를 뒤졌을 수도 있다.
이 grep의 값어치는 증명이 아니라 방향 전환에 있었다. “라이브러리가 실패했다” 는 서사가 흔들렸고, 그러면 다음에 볼 곳이 정해진다 — 그 컴포넌트가 실제로 어떻게 컴파일됐는가.
여기서 배울 것 하나. 환경 차이로 갈리는 문제는 대개 빌드에 답이 있다. 그리고 라이브러리 고유 문자열은 좋은 출발점이다 — 버전이나 경로와 달리 번들러가 잘 바꾸지 않는다. 다만 그건 단서지 판정이 아니다.
진짜 원인 — 이름이 달랐다
사용처의 컴파일 결과를 따라가니 이렇게 남아 있었다.
// 정적 임포트로 치환되지 않고, 런타임에 이름으로 찾는 코드로 남았다
const N = resolveComponent("QrCodeImage")
자동임포트 등록 목록을 확인했다. 그 컴포넌트의 등록명은 QrCodeImage가 아니라 CommonQrCodeImage 였다.
여기서 확정됐다. 컴파일 결과에 정적 임포트가 없다는 사실과, 템플릿이 쓴 이름이 등록돼 있지 않다는 사실 — 이 둘이 맞물리면서 앞의 grep 결과도 설명이 된다. 임포터가 없으니 번들러가 그 라이브러리를 끌어올 이유가 없었던 것이다.
규칙은 이렇다. 컴포넌트가 들어 있는 디렉토리 이름이 등록명 앞에 붙는다. common/ 아래 QrCodeImage.vue는 CommonQrCodeImage가 된다. 그런데 템플릿은 명시 임포트 없이 <QrCodeImage>라고 썼다. 그런 이름은 등록돼 있지 않다.
찾지 못한 이름은 미해석 커스텀 엘리먼트로 렌더된다. 브라우저는 모르는 태그를 만나면 에러를 내지 않는다 — 그냥 인라인 요소로 취급하고 넘어간다. 그래서 빈 흰 박스다.
그리고 이 함정이 왜 특정 디렉토리에만 있었는지도 여기서 풀린다. 파일명이 이미 디렉토리명으로 시작하는 컴포넌트들(예: mobile/MobileXxx.vue)은 프레임워크가 중복을 접어서 MobileXxx로 등록한다. 그래서 그런 곳에서는 bare 태그를 써도 우연히 맞았다. common/ 아래만 지뢰였다.
요점 — 관례를 절반만 지킨 대가
여기가 이 사건의 본론이다.
이 저장소는 대부분 명시 임포트 관례를 따르고 있었다. 컴포넌트를 쓰면 위에 import를 적는다. 좋은 관례고, 대부분의 파일이 지키고 있었다.
문제는 자동임포트를 끄지 않았다는 것이다. 즉 저장소의 상태는 “명시 임포트를 쓴다”가 아니라 “명시 임포트를 주로 쓰고, 몇 군데는 자동임포트에 의존하고 있다” 였다. 그리고 그 몇 군데는 아무도 의식하지 못했다.
여기서 나오는 결과가 핵심이다. 자동임포트를 거의 안 썼기 때문에, 자동임포트가 실제로 어떻게 이름을 등록하는지 아무도 확인한 적이 없었다. 매일 쓰는 기능이었다면 첫 주에 누군가 프리픽스 규칙에 부딪히고 팀 지식이 됐을 것이다. 거의 안 쓰니까 틀린 채로 몇 달을 갔다.
이게 “관례를 절반만 지키는” 상태의 위험이다. 두 방식이 공존할 때 적게 쓰는 쪽이 더 위험하다. 검증이 사용량에서 나오기 때문이다. 많이 쓰는 경로는 매일 밟혀서 자연히 검증되고, 적게 쓰는 경로는 밟히지 않아서 틀린 줄도 모른다.
그래서 선택지는 둘 중 하나다. 관례를 완전히 지키거나(자동임포트를 아예 끄거나), 아니면 두 방식이 공존한다는 사실을 알고 덜 쓰는 쪽을 명시적으로 검증하거나. 가장 나쁜 건 지금처럼 “우리는 명시 임포트를 쓴다”고 믿으면서 몇 군데는 아닌 상태다.
전수 스캔에서 나온 형제들
원인을 알고 나면 같은 패턴을 기계적으로 찾을 수 있다. 명시 임포트 없이 bare 태그를 쓰는 곳을 전부 대조하는 스크립트를 돌렸다.
두 건이 더 나왔다. 하나는 가입 페이지의 오류 알림이었고, 다른 하나는 동의 다이얼로그였다 — 같은 패턴으로 조용히 렌더되지 않고 있었다. 둘 다 QR과 마찬가지로 아무도 신고하지 않았다. 안 보이는 것이 에러를 내지 않기 때문이다.
수리는 전부 명시 임포트 한 줄씩이었다. 원인 규명에 며칠, 수정에 세 줄. 이런 비율은 이 종류의 버그에서 흔하다.
방어 계층도 같이 넣었다. QR 컴포넌트의 조용한 catch를 로그와 에러 emit으로 바꾸고, 생성이 실패하면 최소한 추천 코드 텍스트라도 보이도록 폴백을 달았다. QR이 안 나와도 사용자가 할 일은 할 수 있게.
후속 과제는 bare 컴포넌트 사용을 CI에서 잡는 정적 스캔이다. 사람이 관례를 기억하는 대신 기계가 어긋남을 잡는 쪽으로.
가져갈 것
- 관례를 절반만 지키면 안 쓰는 절반이 미검증으로 남는다. 두 방식이 공존할 때 적게 쓰는 쪽이 더 위험하다 — 검증은 사용량에서 나오기 때문이다.
- “우리는 X를 쓴다”는 믿음과 저장소의 실제 상태를 대조하라. 이 저장소는 명시 임포트를 쓴다고 믿었지만, 실제로는 몇 군데가 자동임포트에 의존하고 있었다.
- 환경으로 갈리는 문제는 빌드 산출물을 봐라. 라이브러리 고유 문자열은 좋은 출발점이다 — 다만 단서지 판정이 아니다. 문자열 부재에는 미니파이·서버 전용 청크·외부화 등 다른 설명이 얼마든지 있다. 확정은 컴파일 결과와 등록 정보로 한다.
- “실패했다”와 “애초에 실행되지 않았다”를 구분하라. 이 구분이 가설의 방향을 바꿨다.
- 원인을 알면 같은 패턴을 기계적으로 전수 조사하라. 한 건에서 시작해 두 건을 더 찾았고, 그중 하나는 QR보다 무거운 것이었다.
- 사람이 기억하는 관례는 CI가 검사하는 규칙으로 옮겨라. 관례는 절반만 지켜지는 순간부터 관례가 아니다.
가장 위험한 코드는 틀린 코드가 아니라, 틀렸는지 확인해 본 적 없는 코드다.
저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.
Tags: nuxt, vue, debugging, auto-import, silent-failure, postmortem, bundle-analysis