문서 읽는 데 60분 · H2

H-2: 에러 모니터링 ② — 나머지 절반과 읽을 수 있게

목차 58
전체 59강 중 52강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구 튜터입니다.

지난 시간 마지막에 제가 이렇게 말씀드렸죠.

오늘 이어둔 두 줄로 다 됐다고 생각하기 쉬운데, 실제로 재보면 우리 앱의 오류 중 상당수가 여전히 안 나갑니다.

오늘은 그 문장을 회수하는 시간입니다.

지난 시간에 우리는 오류를 우리에게 오게 만들었는데, createRoot에 두 줄을 이어두니 오류 화면이 뜰 때마다 봉투가 날아와서 기분 좋게 끝났다 싶었죠. 알고 보니 그게 끝이 아니었어요.

오늘 확인할 것은 두 가지인데, 하나는 아직 안 오고 있는 오류들이고 다른 하나는 와도 못 읽는다는 문제예요. 둘 다 조용해서 화면은 멀쩡하고 빌드는 성공하고 아무도 우리에게 알려주지 않습니다.

텍스트
 오늘의 여정

 1. 아직 절반이다        loader 가 실패한 오류는 여전히 0건
 2. 경계 안에서 잡는다     404 는 빼고
 3. 그런데 두 번 올라간다   보내는 일을 그리는 일에서 떼어낸다
 4. 올라왔는데 못 읽는다    38행 49,029칸
 5. 지도를 만든다         번들의 5.34배
 6. 이름표를 달아둔다      Debug ID
 7. 반쪽만 됐다           지도에는 그 이름표가 없다
 8. 그 지도를 어디에 둘까

💡 오늘 수업의 핵심 — "구멍을 메우면 다른 구멍이 보입니다. 오류가 도착하게 만드는 일과, 도착한 것을 읽을 수 있게 만드는 일은 따로 해야 합니다"


🎯 학습 목표

  • 지난 시간의 배선이 못 보는 오류가 무엇인지 직접 재보고, 그것을 오류 화면 안에서 메웁니다.
  • 무엇을 보내고 무엇을 안 보낼지 스스로 정하고, 같은 오류가 두 번 올라가는 상황을 만들어본 뒤 고칩니다.
  • 뭉개진 배포본 코드를 원래 줄로 되돌리는 지도를 만들고, 그 지도를 어디에 두어야 하는지 판단합니다.

Step 1: "아직 절반입니다"

지난 시간 그대로 한 번 더

먼저 지난 시간이 끝난 그 상태를 다시 확인해봅시다.

연습용 오류 서비스를 띄우고, 연습용 API 서버는 끕니다.

Bash
node apps/api-stub/sentry-stub.mjs

앱을 띄워 홈 화면을 열면 오류 화면이 뜨면서 연습용 오류 서비스 터미널에 봉투가 하나 찍히는데, 지난 시간에 확인한 그대로예요.

이제 주소를 하나 바꿔봅니다. 게시물 상세 화면으로 가보세요.

텍스트
http://localhost:5173/p/999

화면에 이렇게 뜹니다.

텍스트
 문제가 생겼어요
 서버에 연결할 수 없어요
 홈으로

그런데 이번엔 아무것도 안 왔습니다

연습용 오류 서비스 터미널을 보세요.

비어 있습니다.

조금 이상하지 않나요? 방금 홈 화면에서는 똑같이 생긴 오류 화면이 떴고 봉투가 왔습니다. 이번에도 똑같이 생긴 오류 화면이 떴는데 봉투가 안 옵니다.

두 화면을 나란히 놓고 보겠습니다.

텍스트
 홈 화면 (서버 끔)              /p/999 (서버 끔)

   문제가 생겼어요                 문제가 생겼어요
   서버에 연결할 수 없어요          서버에 연결할 수 없어요
   홈으로                        홈으로

   봉투 1건                      봉투 0건

글자가 하나도 안 다르지만, 하나는 우리에게 오고 하나는 안 옵니다.

지난 시간에 저는 "화면만 봐서는 어느 쪽이 도착하고 어느 쪽이 사라지는지 구분이 안 됩니다"라고 예고했어요. 지금 보고 계신 게 정확히 그 상황입니다.

왜 하나만 안 올까요

두 화면이 오류를 만나는 방식이 다릅니다.

홈 화면은 게시물 목록을 그리는 도중에 실패합니다. C-7에서 throwOnError를 켜뒀기 때문에 읽기 실패가 렌더 중에 던져지고, React가 그것을 경계가 잡았다고 인식하니까 지난 시간에 이어둔 onCaughtError가 불립니다.

상세 화면은 다릅니다. C-2에서 우리는 /p/:postIdloader를 달았죠. 화면을 그리기 전에 데이터를 먼저 가져오는 그 함수요. 그 loader가 실패하면 라우터는 그것을 React 오류로 던지지 않고 데이터처럼 다뤄서 오류 화면에 넘깁니다.

텍스트
 그리다가 터진다
   컴포넌트  터짐  React 가 "경계가 잡았다" 고 알림  onCaughtError

 loader 가 실패한다
   loader  터짐  라우터가 받아서 데이터로 넘김  (React 는 조용)

React 입장에서는 아무 일도 안 일어난 겁니다. 화면 하나가 다른 화면으로 바뀌었을 뿐이에요. 그러니 "경계가 잡았다" 고 알릴 이유가 없죠.

그래서 얼마나 안 오고 있나

오류가 날 수 있는 곳을 세 갈래로 갈라 재봤습니다.

오류가 난 곳 화면 봉투
그리다가 터짐 문제가 생겼어요 1건
loader가 실패 문제가 생겼어요 0건
loader가 404 를 던짐 없는 페이지예요 0건

가운데 줄이 오늘 메울 구멍입니다.

맨 아래 줄은 조금 다릅니다. 없는 게시물 번호로 들어갔을 때 우리가 일부러 던진 404예요. 이건 버그가 아니라 그냥 없는 주소입니다. 이것까지 메워야 할지는 Step 2에서 따로 이야기하겠습니다.

💡 한 줄 정리

지난 시간의 배선은 그리는 도중에 터진 오류만 봅니다. loader가 실패한 오류는 라우터가 React 오류가 아니라 데이터로 넘기기 때문에 React가 조용하고, 그래서 봉투가 0건입니다. 화면은 두 경우가 똑같이 생겨서 눈으로는 못 가릅니다.

🙋 학생 질문 — "튜터님, 라우터한테 오류 났을 때 알려달라고 부탁하면 안 되나요?"

좋은 발상이라 실제로 그런 훅이 있으면 가장 깔끔하겠지만, 우리가 쓰는 react-router 8.3.0의 createBrowserRouter에는 그런 옵션이 없어서 라우터가 오류를 만났을 때 부를 함수를 건네줄 곳이 없어요.

대신 라우터는 이미 우리에게 그 오류를 주고 있습니다. 오류 화면 안에서요. C-2에서 배운 useRouteError()가 바로 그것이죠. 라우터가 잡아낸 오류가 그 함수를 통해 우리 손에 들어오는데, 알려달라고 부탁할 곳은 없지만 이미 받고 있었던 셈입니다. 다음 Step에서 그걸 씁니다.


Step 2: "경계 안에서 잡습니다"

지난 시간에 미뤄둔 방법

지난 시간 마지막 학생 질문 기억하시나요? "오류 화면 컴포넌트 안에서 직접 보내면 안 되나요?"라는 질문에 제가 "됩니다, 그리고 사실 그 방법이 필요한 경우가 곧 나옵니다"라고 답했었죠.

지금이 그때입니다.

RootErrorBoundary는 이미 오류를 손에 쥐고 있습니다. C-2에서 만들 때부터 그랬어요.

tsx
// apps/web-spa/src/routes/RootErrorBoundary.tsx
const error = useRouteError();

error 안에 방금 우리가 못 보던 loader 오류가 들어 있습니다. 홈 화면이든 상세 화면이든, 오류 화면이 떴다는 것은 라우터가 무언가를 잡았다는 뜻이고 그것이 여기로 옵니다.

밖에서 기다릴 필요가 없었어요. 안에서 이미 받고 있었습니다.

무엇을 보낼지 먼저 정합니다

그런데 받은 것을 전부 보내면 안 됩니다.

Step 1의 표 맨 아래 줄을 다시 보세요. 없는 게시물 번호로 들어가면 404가 뜹니다. 이건 우리가 고칠 게 없는 상황이에요. 사용자가 오래된 링크를 눌렀거나 주소를 잘못 입력한 거니까요.

이런 걸 전부 보내기 시작하면 어떻게 될까요? 검색 로봇 한 대가 없는 주소를 훑고 지나가면 그날 하루치 오류 목록이 404로 가득 차서, 그 사이에 낀 진짜 버그 한 건은 아무도 못 봅니다. 잡음이 신호를 덮는 셈이라서, 보내는 판단을 따로 떼어 함수로 만듭니다.

TypeScript
// apps/web-spa/src/lib/monitoring.ts
// 라우터가 잡아낸 오류를 보낼지 말지 여기서 정한다.
//
// 라우터는 두 가지를 같은 자리로 넘긴다. 코드가 터진 것과, 우리가 일부러 던진 Response 다.
// 앞엣것은 우리가 고쳐야 할 버그이고, 뒤엣것 중 404 는 고칠 것이 없다 —
// 없는 주소를 요청한 것뿐이라 보내봐야 진짜 버그를 덮는 잡음이 된다.
// 다른 번호(예: 500)는 서버 쪽에서 뭔가 잘못된 것이니 그대로 보낸다.
export function reportRouteError(error: unknown): void {
  if (isRouteErrorResponse(error) && error.status === 404) {
    return;
  }

  Sentry.captureException(error);
}

isRouteErrorResponse는 C-2에서 이미 만난 함수죠. 우리가 던진 Response인지 아닌지를 갈라주는 그 함수요. 그때는 화면에 무슨 글자를 띄울지 정하려고 썼는데, 이번엔 무엇을 보낼지 정하는 데 씁니다.

번호까지 함께 보는 것에 주목해주세요. 404만 거르고 500은 보냅니다. 서버가 500을 돌려준 것은 진짜로 뭔가 잘못된 거니까요.

잡는 일과 보내는 일은 다릅니다

여기서 한 가지가 갈렸습니다.

RootErrorBoundary잡습니다. 오류가 무엇이든 받아서 사용자에게 정중한 화면을 보여줘요. 404 든 서버 오류든 코드 버그든, 전부 잡습니다. 하나도 빠뜨리면 안 돼요.

reportRouteError고릅니다. 잡은 것 중에서 우리가 알아야 할 것만 보냅니다.

이 둘이 같은 함수 안에 섞여 있으면 나중에 헷갈립니다. "404는 안 보내니까 화면도 안 띄워도 되나?" 같은 생각이 들어요. 아니죠. 사용자에게는 여전히 화면을 보여줘야 합니다. 우리가 안 볼 뿐입니다.

💡 한 줄 정리

오류 화면은 이미 useRouteError()로 오류를 쥐고 있어서, 밖에서 기다릴 필요 없이 그 안에서 보내면 되는데, 잡은 것을 전부 보내지는 않아서 404처럼 고칠 게 없는 것은 걸러야 진짜 버그가 잡음에 안 묻힙니다.

🙋 학생 질문 — "튜터님, 404를 아예 안 보내면 링크가 깨진 건 어떻게 알아요?"

정확한 지적입니다. 404가 전부 무의미하지는 않아요.

우리 앱 안의 링크가 없는 주소를 가리키고 있다면 그건 우리 버그이니 고쳐야 하지만, 그것을 오류 추적 도구로 알아내는 건 잘 안 맞습니다. 오류 도구는 "예상 못 한 일이 벌어졌다"를 다루는 곳이고, 404는 예상 가능한 일이거든요. 사용자가 오래된 링크를 누르는 것은 정상적인 웹의 일부입니다.

깨진 링크는 보통 다른 방법으로 봅니다. 서버 접근 기록에서 404 응답이 어느 주소에서 얼마나 나오는지 세거나, 어느 화면에서 그 링크를 눌렀는지(referrer)를 함께 보는 식이죠.

굳이 오류 도구로 보고 싶다면 방법이 없지는 않습니다. 우리 앱 안에서 넘어온 404만 골라 보내면 바깥에서 주소를 직접 친 경우와 갈리는데, 그건 오늘 범위를 넘는 이야기예요.


Step 3: "그런데 두 번 올라갑니다"

이어붙이고 확인해봅시다

이제 오류 화면에서 reportRouteError를 부르면 되겠죠. 가장 단순하게 쓰면 이렇게 됩니다.

tsx
// apps/web-spa/src/routes/RootErrorBoundary.tsx  (이렇게 쓰면 안 됩니다)
export function RootErrorBoundary() {
  const error = useRouteError();

  reportRouteError(error);

  let title = '문제가 생겼어요';

/p/999를 다시 열어보세요. 봉투가 옵니다. Step 1의 구멍이 메워졌지만, 여기서 멈추지 말고 아까 잘 되던 쪽도 다시 확인해봅시다. 홈 화면을 서버 끈 채로 열어보세요.

연습용 오류 서비스 터미널에 이렇게 찍힙니다.

텍스트
[오류받음] … · 3b1c9e4a…
          Error: 서버에 연결할 수 없어요   …
[오류받음] … · 8f27d05b…
          Error: 서버에 연결할 수 없어요  

두 개입니다.

왜 두 번일까요

지난 시간에 이어둔 onCaughtError가 아직 살아 있습니다. 홈 화면의 오류는 그리는 도중에 터진 것이라 그쪽으로도 올라가는데, 오류 화면이 뜨면서 우리가 방금 넣은 줄로도 올라갑니다.

한 번 터진 오류가 두 경로로 나가는 셈인데, 두 번이라는 게 이상합니다. 화면은 한 번만 떴는데요. 실제로 무슨 순서로 도는지 재보면 이렇습니다.

텍스트
 오류 화면 그림    오류 화면 다시 그림    onCaughtError 불림
      보냄              보냄                   보냄

오류 화면이 두 번 그려집니다. React가 오류를 만나면 화면을 되살리려고 다시 시도하기 때문인데, 렌더 본문에 보내는 코드를 두면 그릴 때마다 보내게 됩니다.

이 중복이 조용한 이유

두 봉투를 열어 나란히 놓고 비교해봤습니다. 오류 종류도 같고, 메시지도 같고, 스택 트레이스의 길이와 맨 위 몇 줄까지 같습니다. 목록에서 보면 완전히 같은 오류 두 건으로 보여요.

⚠️ 사실 이 도구에는 똑같은 오류가 연달아 오면 하나로 합쳐주는 기능이 켜져 있습니다. 같은 오류를 두 번 보내면 실제로 한 건으로 합쳐지는 걸 확인했지만, 이 두 건은 안 합쳐집니다. 겉보기엔 같은데 안쪽 스택 정보가 미세하게 갈려서 그 그물을 빠져나가거든요.

결국 화면도 정상이고 도구도 정상 동작하는데 숫자만 두 배가 되면서, 오류가 두 배로 나는 것처럼 보이는 앱이 되는 거예요.

보내는 일을 그리는 일에서 떼어냅니다

고치는 방법은 React를 배울 때 이미 배운 것입니다.

B-4에서 우리는 "화면을 그리는 것 말고 바깥에 영향을 주는 일은 effect로"라는 원칙을 배웠죠. 봉투를 보내는 것은 명백히 바깥에 영향을 주는 일입니다. 그리는 일이 아니에요.

tsx
// apps/web-spa/src/routes/RootErrorBoundary.tsx
import { useEffect } from 'react';
import { Link, isRouteErrorResponse, useRouteError } from 'react-router';
import { reportRouteError } from '@/lib/monitoring';

export function RootErrorBoundary() {
  // 무엇이 던져질지 라우터도 모른다. 그래서 돌려주는 타입이 unknown 이다.
  // unknown 은 좁히기 전에는 아무것도 못 꺼낸다 — 점 하나도 못 찍는다.
  const error = useRouteError();

  // 보내는 일은 그리는 일이 아니라서 effect 로 옮긴다.
  // 이 화면은 한 번 뜨는 동안 두 번 그려지므로, 렌더 본문에서 보내면 같은 오류가 두 번 올라간다.
  useEffect(() => {
    reportRouteError(error);
  }, [error]);

화면이 두 번 그려져도 effect는 한 번만 돕니다. 그게 effect가 하는 일이니까요.

세 경로를 다시 재봅니다

바꾸기 전과 후를 나란히 두면 이렇습니다.

보내는 코드를 둔 곳 그리다가 터짐 loader 실패 404
렌더 본문 2건 1건 0건
useEffect 1건 1건 0건

가운데 열이 Step 1에서 메우려던 구멍이고, 왼쪽 열이 메우다가 새로 생길 뻔한 구멍입니다. 오른쪽 열은 Step 2의 판단이 계속 지켜지고 있다는 확인이고요.

세 열이 동시에 맞아야 끝난 겁니다.

💡 한 줄 정리

구멍을 메우려고 오류 화면에서 보내기 시작하면 지난 시간의 배선과 겹쳐서 같은 오류가 두 번 올라갑니다. 오류 화면은 한 번 뜨는 동안 두 번 그려지기 때문인데, 보내는 일을 effect로 옮기면 한 번이 됩니다.

🙋 학생 질문 — "튜터님, 그럼 지난 시간에 이어둔 두 줄을 지우면 되는 거 아닌가요?"

그것도 하나의 답이라 실제로 그렇게 정리하는 팀도 있지만, 지우면 잃는 게 있습니다. 지난 시간 두 줄 중 아래쪽은 아무도 못 잡은 오류를 받는 것이었죠. 오류 화면조차 안 뜨고 앱이 통째로 사라지는 경우요. 그건 오류 화면 안에서는 잡을 수가 없습니다. 오류 화면이 안 떴으니까요.

위쪽 줄도 마찬가지예요. 우리 앱에는 지금 오류 화면이 하나뿐이지만, C-2에서 봤듯 화면마다 다른 오류 화면을 달 수 있습니다. 나중에 누가 새 오류 화면을 만들면서 보내는 줄을 빠뜨리면 그 화면만 조용히 눈이 머는데, 지난 시간에 위쪽 배선을 고른 이유가 바로 그거였어요. 오늘은 둘 다 남겨두고 겹치는 부분만 없앴습니다. 넓게 치는 그물 하나와, 그물이 못 잡는 것을 받는 손 하나를 함께 두는 셈이에요.


Step 4: "올라왔는데 못 읽습니다"

이제 배포본을 만들어봅니다

지금까지는 전부 개발 서버에서 확인했는데, 개발 서버는 우리가 쓴 코드를 거의 그대로 브라우저에 보내줍니다. 사용자에게 나가는 것은 개발 서버가 아니라 빌드한 결과물이에요. 그걸 한번 만들어봅시다.

Bash
npm run build

그리고 만들어진 파일을 열어봅니다.

Bash
ls -la dist/assets/
텍스트
index-CxYRxVfV.js   617,804
index-DiZ4qHXo.css   43,981
SignUpPage-*.js     100,007

우리가 쓴 파일은 수백 개인데 결과물은 몇 개뿐입니다. 전부 합쳐지고 압축된 거예요.

열어보면 이렇게 생겼습니다

.js 파일 안에서 오류 화면 글자를 찾아봅시다. 그 주변이 이렇게 생겼습니다.

텍스트
=(0,J.c)(11),t=Er(),n,r;e[0]===t?(n=e[1],r=e[2]):(n=()=>{HC(t)},
r=[t],e[0]=t,e[1]=n,e[2]=r),(0,N.useEffect)(n,r);let i=`문제가 생겼어요`,a;Ue

우리가 방금 쓴 그 코드입니다. 알아보시겠어요?

이름을 하나씩 맞춰보면 이렇습니다.

우리가 쓴 이름 배포본에서
useRouteError Er
reportRouteError HC
isRouteErrorResponse Ue
RootErrorBoundary (이름 자체가 사라짐)

압축 도구가 이름을 최대한 짧게 줄인 겁니다. 이름이 길어봐야 파일만 커지고, 브라우저는 이름 따위 신경 안 쓰니까요. useRouteError 열세 글자가 Er 두 글자가 되면 그만큼 사용자가 내려받을 게 줄어듭니다.

그래서 스택 트레이스가 쓸모없어집니다

문제는 오류가 났을 때입니다.

오류 봉투에는 "어디서 났는지"가 스택 트레이스로 실려 오는데, 배포본에서 난 오류의 스택 트레이스는 이 뭉개진 이름과 위치를 가리킵니다.

방금 그 코드가 파일 안에서 어디쯤인지 재보면 이렇습니다.

텍스트
 38행 49,029칸

파일 전체가 38줄입니다. 압축하면서 줄바꿈을 거의 다 없앴거든요. 그러니 줄 번호는 아무것도 안 알려줍니다. 몇 번째 칸인지가 오만 번째쯤이라는 것만 알 수 있어요.

새벽에 오류 알림을 받았다고 해봅시다. 봉투를 열었더니 index-CxYRxVfV.js의 38행 49,029칸에서 HC라는 함수가 터졌다고 적혀 있어요. 이걸로 뭘 할 수 있을까요.

오류는 도착했는데 어디서 났는지 모릅니다. 절반만 온 거예요.

💡 한 줄 정리

빌드하면 이름이 짧게 뭉개지고 줄바꿈이 사라져서 파일 전체가 38줄이 되는데, 배포본에서 난 오류의 스택 트레이스는 뭉개진 이름과 오만 번째 칸을 가리켜서 그대로는 어디서 났는지 알 수 없습니다.

🙋 학생 질문 — "튜터님, 그럼 압축을 안 하면 되지 않나요?"

되긴 되고 실제로 잠깐 그렇게 두는 경우도 있지만, 값이 큽니다. 지금 우리 번들이 617 KB 인데 압축을 끄면 훨씬 커집니다. 그만큼을 모든 사용자가 매번 내려받아야 해요. C-8에서 우리가 번들을 쪼개가며 100 KB를 줄이려고 애썼던 걸 생각하면, 읽기 편하자고 그걸 되돌리는 건 앞뒤가 안 맞습니다.

그리고 압축을 끄면 우리 코드가 그대로 노출됩니다. 어차피 브라우저로 나가는 코드라 완전히 숨길 수는 없지만, 굳이 읽기 좋게 차려서 내보낼 이유도 없죠.

다행히 둘 중 하나를 고를 필요가 없습니다. 압축은 그대로 두고, 뭉갠 것을 원래대로 되돌리는 방법이 따로 있어요. 다음 Step이 그겁니다.


Step 5: "지도를 만듭니다"

뭉갠 기록을 남겨둡니다

압축 도구는 useRouteErrorEr로 바꿀 때, 무엇을 무엇으로 바꿨는지 알고 있습니다. 그 기록을 파일로 남겨둘 수 있어요.

그 파일을 소스맵(source map)이라고 부릅니다. 우리말로 하면 원본 지도쯤 되겠네요. 배포본의 어느 위치가 원본의 어느 줄이었는지를 적어둔 대응표입니다.

만드는 방법은 한 줄입니다.

TypeScript
// apps/web-spa/vite.config.ts
  build: {
    // 배포본은 이름이 뭉개져 있어 그대로는 못 읽는다.
    // 뭉갠 것을 원래 줄로 되돌리는 지도를 함께 만들어 남긴다.
    sourcemap: true,
  },

기본값이 꺼져 있어서 켜기 전에는 지도가 아예 안 만들어졌습니다. 이제 다시 빌드해보세요.

Bash
npm run build
ls -la dist/assets/
텍스트
index-CxYRxVfV.js         617,847
index-CxYRxVfV.js.map   3,296,229
index-DiZ4qHXo.css         43,981

.map이 새로 생겼습니다.

⚠️ 번들 파일 이름이 Step 4와 같은데 크기가 43 바이트 늘어난 것을 눈치채셨나요? 지도를 켜면 번들 맨 끝에 "지도는 여기 있다"는 주석이 한 줄 붙거든요. 그 43 바이트입니다. 파일 이름 해시는 그 주석이 붙기 전에 정해져서 그대로고요. 이 43 바이트는 Step 8에서 다시 만납니다.

지도가 번들보다 훨씬 큽니다

크기를 보세요. 번들이 617 KB 인데 지도는 3,296 KB입니다. 번들의 5.34배예요.

왜 이렇게 클까요? 안을 열어보면 답이 나옵니다.

텍스트
 지도 안에 든 것

   sources           422개   어느 파일에서 왔는지 (파일 이름 목록)
   sourcesContent    422개   그 파일들의 내용
   mappings                  배포본 위치  원본 위치 대응표

sourcesContent를 보세요. 파일 이름만 적어둔 게 아니라 그 파일들의 내용을 통째로 담고 있습니다. 422개 파일의 원본 코드가 전부 들어 있는 거예요.

실제로 꺼내보면 이렇습니다.

텍스트
sources[418] = ../../src/routes/RootErrorBoundary.tsx
  그 원본 9번째 줄 = "  const error = useRouteError();"

우리가 방금 쓴 그 줄입니다. 주석까지 그대로 들어 있어요.

⚠️ 이건 나중에 중요해집니다. 지도를 아무 데나 두면 우리 소스가 통째로 공개된다는 뜻이거든요. Step 8에서 이 이야기를 정면으로 다루겠습니다.

💡 한 줄 정리

build.sourcemap을 켜면 배포본 위치와 원본 위치를 잇는 지도가 함께 만들어집니다. 지도는 번들의 5.34배로 큰데, 대응표뿐 아니라 원본 파일 422개의 내용을 통째로 담고 있기 때문입니다.

🙋 학생 질문 — "튜터님, 지도가 3 MB 면 사용자가 그걸 다 받는 건가요?"

아닙니다. 지도는 브라우저가 평소에 안 받습니다.

브라우저는 개발자 도구를 열었을 때만 지도를 받으러 가고, 도구를 안 연 일반 사용자는 지도가 있는지도 모르니까 번들이 5배 커진 것과는 전혀 다른 이야기입니다.

"안 받는다"와 "못 받는다"는 다릅니다. 지도가 배포본 옆에 올라가 있으면 주소를 아는 사람은 언제든 받을 수 있어요. 개발자 도구를 여는 것도 누구나 할 수 있는 일이라서, 진짜 질문은 크기가 아니라 어디에 두느냐입니다. Step 8에서 답합니다.


Step 6: "이름표를 달아둡니다"

지도와 번들을 어떻게 짝지을까요

지도를 만들었으니 이제 오류가 왔을 때 그 지도로 되돌리면 되는데, 한 가지 문제가 있어요.

오류 추적 도구 입장에서 생각해봅시다. 봉투가 하나 도착했는데 index-CxYRxVfV.js의 38행 49,029칸이라고 적혀 있어요. 이걸 되돌리려면 그 파일에 맞는 지도를 찾아야 합니다.

파일 이름으로 찾으면 되지 않을까요? 그게 잘 안 됩니다. 파일 이름의 저 해시는 내용이 조금만 바뀌어도 달라지거든요. 우리가 배포를 여러 번 하면 지도도 여러 개가 쌓이는데, 어느 지도가 어느 배포본 것인지 이름만으로는 헷갈려서 확실한 방법을 씁니다. 번들과 지도에 같은 이름표를 달아두는 것이죠. 이 이름표를 Debug ID라고 부릅니다.

도구를 하나 더 끼웁니다

Debug ID를 붙여주는 빌드 도구가 따로 있습니다.

TypeScript
// apps/web-spa/vite.config.ts
import { sentryVitePlugin } from '@sentry/vite-plugin';

    // 번들과 지도에 같은 이름표(Debug ID)를 붙여 짝을 맞춘다.
    // 파일 이름은 빌드할 때마다 바뀌지만 이 이름표는 둘을 확실히 이어준다.
    sentryVitePlugin({ telemetry: false }),

계정 정보를 하나도 안 줬다는 것에 주목해주세요. 어느 회사 어느 프로젝트인지도, 인증 토큰도 안 줬습니다. 그래도 Debug ID는 붙습니다.

빌드하고 번들 안을 찾아보면 이런 게 새로 들어와 있어요.

텍스트
_sentryDebugIds[t] = `7ef0eb62-3747-4e58-9020-951f3ee57a65`

번들이 617,847에서 618,262 바이트가 됐습니다. 415 바이트 늘었어요. Debug ID 하나 값입니다.

파일 이름과 Debug ID는 따로 움직입니다

왜 파일 이름으로 짝을 맞추면 안 되는지, 직접 재보면 분명해집니다.

먼저 아무것도 안 바꾸고 다시 빌드했습니다. 셋 다 그대로였어요. 여기까지는 예상대로입니다.

그다음이 재미있습니다. 커밋만 하나 더 만들고 다시 빌드했어요. 코드는 한 글자도 안 고쳤습니다.

텍스트
 파일 이름   index-SeYjW6Qr    index-DoBk_e2q     바뀜
 Debug ID    7ef0eb62…         7ef0eb62…          그대로

파일 이름이 바뀌었습니다. 코드를 안 고쳤는데요.

이유는 방금 본 그 배포 표시입니다. 커밋 번호가 번들 안에 글자로 들어가 있으니, 커밋을 하면 번들 내용이 달라지고 그러면 파일 이름 해시도 달라져요. 반면 Debug ID는 그 표시가 들어가기 전에 정해져서 그대로입니다.

반대 경우도 재봤습니다. 이번엔 커밋 없이 화면에 뜨는 글자 하나만 고쳤어요.

텍스트
 파일 이름   index-DoBk_e2q    index-nL54u7Rp     바뀜
 Debug ID    7ef0eb62…         edaed267…          바뀜

이번엔 둘 다 바뀌었습니다. 코드가 진짜로 달라졌으니 지도도 달라져야 하고, 그러면 Debug ID도 새것이어야 맞죠.

정리하면 이렇습니다.

파일 이름 해시 Debug ID
코드가 바뀌었다 바뀜 바뀜
코드는 그대로인데 커밋만 했다 바뀜 그대로

파일 이름은 코드와 상관없는 일에도 흔들립니다. Debug ID는 코드가 실제로 달라졌을 때만 움직여요. 짝을 맞추는 데 앞엣것을 쓰면 배포할 때마다 어긋날 여지가 생깁니다.

덤으로 딸려온 것 하나

번들을 더 뒤져보면 이런 것도 들어와 있습니다.

텍스트
SENTRY_RELEASE = { id: `59715ab3acdc210651ff8f2c090c16aa5bb8ba6e` }

이 긴 문자열, 어디서 본 것 같지 않나요? 우리가 방금 만든 커밋의 번호입니다.

우리는 이 도구에게 git 이야기를 한 적이 없는데, 빌드할 때 스스로 저장소를 읽어서 지금 어느 커밋인지 알아낸 거예요. 이 값은 "이 배포본이 어느 시점 코드인가"를 표시하는 데 쓰입니다. 오류가 어느 배포부터 늘어났는지 같은 걸 볼 때 필요하죠.

Debug ID와 이 표시(릴리스)는 하는 일이 다릅니다. 앞엣것은 번들과 지도를 잇고, 뒤엣것은 배포 시점을 가리켜요.

💡 한 줄 정리

번들과 지도에 같은 Debug ID를 달아두면 파일 이름이 바뀌어도 짝이 안 어긋납니다. 계정 없이도 Debug ID는 붙고, 덤으로 빌드 도구가 git을 스스로 읽어 커밋 번호를 배포 표시로 함께 넣어줍니다.

🙋 학생 질문 — "튜터님, 파일 이름 해시도 내용에서 뽑는다면서 왜 둘이 다르게 움직여요?"

같은 내용에서 뽑는데 뽑는 시점이 다릅니다.

빌드는 여러 단계로 진행됩니다. 코드를 합치고, 압축하고, 그다음에 이 도구가 Debug ID와 배포 표시를 넣고, 마지막에 완성된 내용으로 파일 이름 해시를 뽑아요.

Debug ID는 배포 표시가 들어가기 내용에서 정해지고, 파일 이름은 그 표시까지 다 들어간 내용에서 정해지다 보니, 커밋 번호만 달라져도 파일 이름은 바뀌는데 Debug ID는 안 바뀝니다.

파일 이름 해시가 그렇게 만들어진 데는 이유가 있습니다. 그건 브라우저 캐시 때문이거든요. 내용이 조금이라도 다르면 새 이름이어야 사용자가 옛날 파일을 계속 쓰지 않습니다. 그 목적에는 잘 맞아요.

다만 목적이 다른 일에 갖다 쓰면 안 맞습니다. "어느 지도를 볼까"는 캐시 문제가 아니니까요.


Step 7: "반쪽만 됐습니다"

지도에도 Debug ID가 붙었을까요

Debug ID를 붙였으니 번들과 지도가 이어졌겠죠. 확인해봅시다.

번들 쪽은 방금 봤습니다. 7ef0eb62-…가 들어 있었어요. 이번엔 지도 파일을 열어봅니다.

텍스트
 지도 안의 항목들

   version
   file
   sources
   sourcesContent
   mappings
   names
   ignoreList

debug_id가 없습니다.

번들에는 Debug ID가 붙었는데 지도에는 안 붙었어요. 짝을 맞추려고 붙인 Debug ID인데 한쪽에만 있으면 짝이 안 맞습니다. 반쪽만 된 겁니다.

그런데 빌드는 성공했습니다

여기가 오늘 가장 조심해야 할 곳입니다.

방금 빌드를 다시 보세요. 종료 코드가 0입니다. 성공했어요. 화면도 멀쩡하고, 타입 검사도 통과하고, 아무것도 안 깨졌습니다.

빌드 기록을 자세히 보면 경고가 두 줄 있긴 합니다.

텍스트
[sentry-vite-plugin] Warning: No auth token provided. Will not create release.
[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps.

인증 토큰이 없어서 릴리스를 못 만들고 소스맵을 못 올린다는 이야기예요. 경고 두 줄이 전부입니다. 빌드는 계속 진행됐고 결과물도 나왔어요.

⚠️ 빌드 기록은 보통 수십 줄이고, 성공했다는 초록 글씨가 마지막에 뜹니다. 이 두 줄을 그 사이에서 알아채는 사람은 많지 않은데, 못 알아채도 아무 일도 안 일어납니다. 배포는 되고 앱은 돌아가니까요.

지난 시간에도 비슷한 걸 봤습니다. 주소를 잘못 적어도 앱이 멀쩡했고, 보냈는지 확인하는 함수마저 참을 돌려줬죠. 조용한 것은 잘 됐다는 뜻이 아닙니다. 오늘도 같은 이야기가 반복됩니다.

무엇이 되고 무엇이 안 됐나

계정 없이 여기까지 온 결과를 정리하면 이렇습니다.

하려던 것 됐나
지도 만들기
번들에 Debug ID 붙이기
배포 시점 표시하기
지도에 Debug ID 붙이기 안 됨
지도를 도구에 올리기 안 됨

아래 두 줄은 인증 토큰이 있어야 합니다. 실제 서비스라면 계정을 만들고 토큰을 받아서 빌드 도구에 알려주는데, 그러면 빌드할 때 지도에도 Debug ID가 붙고 지도가 도구 쪽으로 올라가고 오류가 오면 도구가 알아서 되돌려 보여줍니다.

오늘 우리는 계정 없이 갑니다. 그래도 지도는 손에 있으니 되돌리는 것 자체는 할 수 있어요. 그건 다음 Step에서 직접 해봅니다.

💡 한 줄 정리

인증 토큰이 없으면 번들에는 Debug ID가 붙는데 지도에는 안 붙어서 짝의 반쪽만 되는데도, 빌드는 경고 두 줄만 남기고 성공하기 때문에 알아채지 못하면 짝이 안 맞는 채로 배포됩니다.

🙋 학생 질문 — "튜터님, 반쪽만 된 걸 나중에 어떻게 알아채나요?"

가장 확실한 방법은 결과 쪽에서 확인하는 것입니다. 지난 시간에도 같은 이야기를 했죠. 보냈다는 사실은 보내는 쪽이 아니라 받는 쪽에서 확인해야 한다고요.

여기서도 같습니다. 배포한 다음에 일부러 오류를 하나 내보고, 도구에 도착한 그 오류의 스택 트레이스가 우리 파일 이름과 줄 번호로 보이는지 눈으로 확인하면 됩니다. HC라고 나오면 안 된 거고, reportRouteError라고 나오면 된 거예요.

빌드 쪽에서 막고 싶다면 방법이 있긴 합니다. 만들어진 지도 파일을 열어서 debug_id가 있는지 확인하고, 없으면 배포를 멈추게 하는 거죠. 몇 줄이면 됩니다.

어느 쪽이든 핵심은 같아요. 경고를 읽어주기를 기대하지 말고, 확인하는 단계를 만들어두는 것입니다. 사람은 초록 글씨를 보면 넘어가거든요.


Step 8: "그 지도를 어디에 둘까요"

지도가 있으면 이렇게 읽힙니다

먼저 지도가 실제로 일을 하는지 봅시다. Step 4에서 못 읽던 그 위치를 그대로 가져와서 되돌려볼게요.

우리는 계정이 없어서 도구가 대신 되돌려주지는 못하니까 직접 해봅니다. 마침 Node에 그 일을 하는 것이 들어 있어요. 따로 설치할 게 없습니다.

JavaScript
// apps/web-spa/scratch/h2-unminify.mjs
import { SourceMap } from 'node:module';

const map = JSON.parse(readFileSync(file + '.map', 'utf8'));
const sourceMap = new SourceMap(map);

const entry = sourceMap.findEntry(line, column);

돌려보면 이렇게 나옵니다.

텍스트
배포본  : dist/assets/index-BnVhwqn0.js:38:49029
되돌리면: ../../src/routes/RootErrorBoundary.tsx : 17행 15칸
그 줄   :   let title = '문제가 생겼어요';

Step 4에서 우리를 막막하게 하던 38행 49,029칸이 그대로 돌아왔는데, 이제는 파일 이름과 줄 번호가 나오고 그 줄에 뭐라고 쓰여 있는지까지 보여줍니다.

새벽에 받은 알림이 HC가 아니라 RootErrorBoundary.tsx 17행이라고 말해주는 거예요. 이게 지도가 하는 일입니다.

그런데 그 지도에 우리 소스가 다 들어 있습니다

여기서 Step 5의 경고로 돌아갑니다.

방금 출력의 마지막 줄을 보세요. 우리 원본 코드가 그대로 나왔습니다. 지도가 대응표만 들고 있는 게 아니라 원본 파일 422개를 통째로 담고 있기 때문이죠.

그러면 이런 일이 벌어집니다. 배포본을 올리면서 .js 옆에 .map도 같이 올리고, 배포본 끝에 "지도는 여기 있다"는 주석까지 남기면, 누구나 그 주소로 지도를 받아서 우리 소스 전체를 열어볼 수 있습니다.

숨겨야 할 비밀이 코드에 없다면 큰일은 아닐 수도 있지만, 어차피 숨길 수 없는 것과 굳이 차려서 내놓는 것은 다릅니다. G-6에서 우리가 "비밀은 어디에 두는가"를 이야기했던 결과 이어지는 문제예요.

세 가지 선택지

정리하면 이렇습니다.

텍스트
 (가) 지도를 아예 안 만든다
        읽을 수 없다. Step 4 로 되돌아간다.

 (나) 지도를 만들어 배포본과 함께 올린다
        읽을 수 있다. 소스가 공개된다.

 (다) 지도를 만들되 배포본에 안 올린다
        읽을 수 있다. 소스도 안 나간다.

(다)를 고릅니다. 지도는 우리 손에만 있으면 되니까요. 실제 서비스라면 빌드할 때 지도를 오류 추적 도구 쪽으로만 보내고 배포본에서는 지워버립니다. Step 7에서 본 두 번째 경고가 바로 그 일을 못 했다는 이야기였죠.

우리는 계정이 없으니 지도를 올리지는 않습니다. 대신 배포본이 지도를 가리키지 않게 만들어둡니다.

TypeScript
// apps/web-spa/vite.config.ts
  build: {
    // 배포본은 이름이 뭉개져 있어 그대로는 못 읽는다.
    // 뭉갠 것을 원래 줄로 되돌리는 지도를 함께 만들어 남긴다.
    //
    // 'hidden' 은 지도를 만들되 배포본이 그것을 가리키는 주석을 안 남긴다.
    // 지도에는 sourcesContent 로 원본이 통째로 들어 있어서,
    // 배포본 옆에 그대로 올리면 누구나 우리 소스를 내려받을 수 있다.
    sourcemap: 'hidden',
  },

true'hidden'으로 바꿨습니다. 빌드해서 확인해보세요.

텍스트
 지도 파일          그대로 만들어진다
 배포본의 지도 주석   사라진다
 되돌리기            그대로 된다

지도는 여전히 dist/assets/에 있고, 되돌리기도 방금처럼 잘 됩니다. 달라진 것은 배포본이 더 이상 "지도는 여기 있다" 고 말하지 않는다는 것뿐이에요. 번들도 43 바이트 줄었습니다. 그 주석 길이만큼요.

⚠️ 다만 이건 주소를 안 알려주는 것이지 지도를 안 올리는 게 아닙니다. dist/를 통째로 올리면 지도도 같이 올라가요. 배포할 때 .map을 빼는 것까지 해야 완전합니다. 어느 쪽이든 "지도가 배포본 옆에 있으면 안 된다"는 규칙은 같아요.

오늘 만든 것을 한 줄로

지난 시간과 오늘을 이어보면 이렇게 됩니다.

텍스트
 지난 시간   오류가 우리에게 오게 만들었다
             단, 그리다가 터진 것만

 오늘 앞쪽   loader 가 실패한 것도 오게 했다
             404 는 빼고 · 두 번 안 오게

 오늘 뒤쪽   와도 못 읽던 것을 읽을 수 있게 했다
             지도는 만들고 배포본 옆에는 안 두고

💡 한 줄 정리

지도가 있으면 뭉개진 위치가 파일 이름과 줄 번호로 되돌아오는데, 지도에는 원본이 통째로 들어 있어서 배포본 옆에 두면 소스가 공개되므로 지도는 만들되 배포본이 그것을 가리키지 않게 합니다.

🙋 학생 질문 — "튜터님, 어차피 코드는 브라우저로 나가는데 소스가 공개되는 게 왜 문제인가요?"

맞는 말이라 프런트엔드 코드는 원래 사용자 기계에서 도는 거라 완전히 숨길 수 없는데, G-6에서 확인했듯 비밀을 코드에 넣으면 안 되는 이유도 그거였죠.

압축된 배포본과 원본은 읽기 난이도가 크게 다릅니다. 압축본에서 우리 앱의 구조를 파악하려면 시간이 꽤 걸리는데, 원본은 주석까지 붙어 있어서 몇 분이면 됩니다.

이게 왜 중요하냐면, 공격을 준비하는 쪽 입장에서 생각해보면 됩니다. 우리 앱이 어떤 주소로 무엇을 보내는지, 어디를 어떻게 확인하는지가 주석과 함께 펼쳐져 있으면 약한 곳을 훨씬 빨리 찾습니다. G-4에서 살균을 넣고 G-6에서 문을 잠갔던 그 코드가 어떻게 생겼는지 다 보이는 거예요.

완벽한 방어는 아니라서 마음먹으면 압축본도 읽을 수 있지만, 굳이 쉽게 만들어줄 이유는 없습니다. 비용이 거의 안 드는 조치라 안 할 이유도 없어요.


마무리

오늘 배운 핵심 세 가지

💡 하나. 구멍은 하나가 아니었습니다. 지난 시간에 배선을 잇고 오류가 도착하는 걸 확인했는데, 그것은 그리다가 터진 오류만이었어요. loader가 실패한 오류는 라우터가 데이터로 넘기는 바람에 여전히 0건이었고, 화면이 똑같이 생겨서 눈으로는 알 수 없었습니다. 한 경로가 되는 걸 확인했다고 전부 되는 게 아닙니다.

💡 둘. 메우다가 새로 만들 수도 있습니다. 구멍을 메우려고 오류 화면에서 보내기 시작하니 지난 시간의 배선과 겹쳐 같은 오류가 두 번 올라갔어요. 두 봉투는 겉보기에 구별이 안 되고 도구의 중복 제거도 빠져나갑니다. 고친 뒤에는 고치기 전에 잘 되던 것까지 다시 재봐야 합니다.

💡 셋. 도착하는 것과 읽을 수 있는 것은 다릅니다. 배포본은 이름이 뭉개져 있어 스택 트레이스가 아무것도 안 알려줍니다. 지도를 만들면 되돌릴 수 있는데, 그 지도에는 우리 원본이 통째로 들어 있어요. 그래서 지도는 만드는 것과 어디에 두는 것이 별개의 결정입니다.

다음 시간 예고

지금까지 우리는 앱이 터졌을 때 그것을 아는 방법을 만들었습니다. 오류가 우리에게 오고, 와서 읽히기까지요.

그런데 사용자가 앱을 떠나는 이유가 오류뿐일까요?

아무것도 안 터졌는데 그냥 느린 앱을 생각해보세요. 버튼을 눌렀는데 반응이 반 박자 늦고, 화면을 열었더니 글자가 뜨고 나서 사진이 밀고 들어와 읽던 줄을 놓칩니다. 오류는 한 건도 안 나서 우리 오류 목록은 깨끗한데도, 사람들은 조용히 떠납니다.

다음 시간에는 그 "느림"을 숫자로 잡습니다. 느낌으로 "좀 느린 것 같다" 고 말하는 대신, 무엇을 어떻게 재야 사용자가 실제로 겪는 것과 맞는 숫자가 나오는지부터 이야기할게요.

그리고 재보면 놀라실 겁니다. 우리 앱은 봐야 할 숫자 세 개 중에 하나가 이미 기준을 한참 넘겨 있거든요. 그게 어느 것인지, 왜 지금까지 아무도 몰랐는지를 다음 시간에 찾아내서 고칩니다.

우리 노트북에서 재면 훌륭한데 실제 사용자 쪽 숫자는 전혀 다르더라는 이야기는 그다음 시간에 이어갈게요.


과제

[구현] 새 오류 화면을 하나 달아보세요

지금 우리 앱에는 오류 화면이 하나뿐입니다. C-2에서 배웠듯 라우트마다 다른 오류 화면을 달 수 있죠.

게시물 상세 화면(/p/:postId)에만 쓰이는 오류 화면을 새로 하나 만들어 그 라우트에 달아보세요. 화면 글자는 자유롭게 정하시면 됩니다.

달았으면 연습용 API 서버를 끈 채로 /p/999를 열어보세요. 새 화면이 뜨는지, 그리고 봉투가 오는지 두 가지를 모두 확인하세요.

⚠️ 결과가 예상과 다를 수 있습니다. 그럴 때 "내가 뭘 잘못했나"로 넘어가지 말고, 왜 그렇게 되는지 오늘 Step 3의 학생 질문을 다시 읽어보고, 어떻게 고칠지 나아가 앞으로 오류 화면을 만드는 사람이 같은 실수를 안 하게 하려면 무엇을 어디에 두어야 할지 여러분 생각을 적어보세요.

[구현] 안 보낼 것을 하나 더 골라보세요

지금은 404만 거르고 있습니다.

우리 앱에서 "잡기는 하되 보낼 필요는 없는" 경우가 또 있을지 생각해보고, 있다면 하나를 골라 거르게 만들어보세요. 없다고 판단해도 됩니다. 그 경우엔 왜 없는지를 적어주세요.

고른 뒤에는 두 가지를 확인해야 합니다. 그 경우에 봉투가 안 나가는 것, 그리고 거르지 말아야 할 것들은 여전히 나가는 것이요. 두 번째를 빠뜨리면 조건을 너무 넓게 잡아서 진짜 버그까지 막아버릴 수 있습니다.

[탐구] 세 값이 각각 무엇 때문에 바뀌는지 재보세요

Step 6에서 우리는 파일 이름 해시, Debug ID, 릴리스 세 값을 봤습니다. 본문에서는 그중 두 경우만 재봤어요.

나머지도 직접 채워보세요. 매번 빌드하고 세 값을 기록하면 됩니다.

무엇을 했나 파일 이름 해시 Debug ID 릴리스
아무것도 안 바꾸고 다시 빌드
주석만 한 줄 고치고 빌드
화면에 뜨는 글자를 고치고 빌드
코드는 그대로 두고 커밋만 하나 더

⚠️ 둘째 줄에서 예상과 다른 결과가 나올 겁니다. 왜 그런지 설명해보세요. 힌트를 하나 드리면, 빌드 결과물에서 그 주석을 찾아보시면 됩니다.

표가 채워지면 이렇게 답해보세요. 세 값 중 "우리 코드가 실제로 달라졌다"를 가장 정확히 알려주는 것은 어느 것인가요? 그리고 나머지 둘은 각각 무엇을 알려주나요?

[탐구] 지도 없이 읽어보고, 있는 채로 읽어보세요

Step 8의 되돌리기 도구를 그대로 써서 실험해보세요.

먼저 배포본에서 아무 위치나 하나 골라 되돌려봅니다. 그다음 지도 파일 이름을 잠깐 바꾸거나 다른 곳으로 옮겨두고 같은 것을 시도해보세요.

두 경우에 각각 무엇을 알 수 있고 무엇을 알 수 없는지 적어보세요. 그리고 이렇게 생각해보시면 좋겠습니다. 지도를 잃어버린 배포본이 하나 있다면, 그 배포본에서 온 오류 보고를 우리는 어떻게 다뤄야 할까요? 되살릴 방법이 있을까요?


생각해볼 주제

1. 오류를 안 보내기로 하는 결정은 누가 해야 할까요

오늘 우리는 404를 안 보내기로 정했습니다. 잡음이 신호를 덮는다는 이유였죠.

그런데 이 결정에는 대가가 있습니다. 안 보내기로 한 순간부터 그 종류의 문제는 우리 눈에 영영 안 보입니다. 나중에 우리 앱 안의 링크가 깨져서 404가 쏟아져도 오류 목록은 조용해요.

무엇을 안 볼지 정하는 것은 무엇을 볼지 정하는 것만큼 중요한 결정입니다. 이런 결정을 팀에서 어떻게 다루면 좋을까요? 코드에 조건 한 줄 추가하고 넘어가도 괜찮은 종류의 일일까요, 아니면 다르게 다뤄야 할까요?

2. 조용한 실패를 어디까지 막아야 할까요

오늘 Step 7에서 우리는 빌드가 경고 두 줄만 남기고 성공하는 것을 봤습니다. 짝이 반쪽만 맞았는데도요.

이런 일은 오늘만의 이야기가 아닙니다. 지난 시간에는 주소를 잘못 적어도 앱이 멀쩡했고, G 카테고리에서는 문지기를 세웠는데 다른 문이 열려 있었죠. 도구는 대체로 "일단 진행" 쪽을 고릅니다.

그런데 모든 경고를 오류로 바꿔 빌드를 멈추게 하면 어떻게 될까요? 그것도 그것대로 문제가 생길 겁니다. 어떤 것은 멈춰야 하고 어떤 것은 넘어가도 될까요? 그 기준을 여러분 말로 정해보세요.

3. 지도를 올리는 것과 소스를 공개하는 것 사이

오늘 우리는 지도를 배포본 옆에 두지 않기로 했습니다.

그런데 세상에는 반대로 하는 곳도 있습니다. 소스맵을 그대로 공개해두는 서비스가 실제로 있어요. 오픈소스가 아닌데도요.

그 선택에도 이유가 있을 겁니다. 어떤 이유가 있을지 생각해보세요. 그리고 여러분이 만드는 앱이라면 어느 쪽을 고르시겠어요? 그 판단이 앱의 성격에 따라 달라질까요, 아니면 어떤 앱이든 같은 답일까요?

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 새 오류 화면을 하나 달아보세요

채점 포인트

항목 배점 확인 내용
새 오류 화면을 라우트에 달았다 5 p/:postIdErrorBoundary를 짝지어 달았는가
화면이 바뀌는 것을 확인했다 5 서버를 끈 채 /p/999를 열어 새 글자가 뜨는 것을 봤는가
봉투가 0건인 것을 확인했다 8 화면만 보고 끝내지 않고 받는 쪽을 봤는가
왜 그런지 설명했다 4 보내는 줄이 그 화면에 없다는 것을 짚었는가
재발을 막을 방법을 제안했다 3 다음 사람이 같은 실수를 안 하게 할 방법을 적었는가

풀이 예시

라우트 표에서 상세 화면 줄에 ErrorBoundary를 짝지어 답니다.

tsx
// apps/web-spa/src/routes/routes.ts
{
  path: 'p/:postId',
  loader: postLoader,
  Component: PostDetailPage,
  ErrorBoundary: PostErrorBoundary,
},

화면은 이렇게 만들었다고 해봅시다.

tsx
// apps/web-spa/src/routes/PostErrorBoundary.tsx
export function PostErrorBoundary() {
  return <p>이 게시물을 불러오지 못했어요</p>;
}

연습용 API 서버를 끄고 /p/999를 열면 화면은 바뀌어서 새로 만든 글자가 뜹니다. 그런데 연습용 오류 서비스는 조용해요.

텍스트
 새 화면을 달기 전    화면: 문제가 생겼어요 …        봉투 1건
 새 화면을 단 뒤      화면: 이 게시물을 불러오지…    봉투 0건

⚠️ 화면이 잘 바뀌었으니 다 됐다고 생각하기 딱 좋은 상황이지만, 이 라우트의 오류는 이제 아무 데도 안 남습니다.

왜 그럴까요

C-2에서 배운 대로 오류는 가장 가까운 오류 화면이 잡는데, 상세 화면에 자기 오류 화면이 생겼으니 RootErrorBoundary까지 안 올라갑니다.

보내는 줄은 RootErrorBoundary에만 있습니다. 새 화면에는 없죠. 그래서 잡히기는 하는데 안 보내집니다.

지난 시간 마지막 학생 질문에서 제가 걱정했던 게 정확히 이거였어요. "새 오류 화면을 만드는 사람이 그 줄을 빠뜨리면 그 화면에서 난 오류만 조용히 사라진다" 고요. 직접 겪어보신 겁니다.

고치는 건 간단합니다. 같은 두 줄을 넣으면 돼요.

tsx
// apps/web-spa/src/routes/PostErrorBoundary.tsx
export function PostErrorBoundary() {
  const error = useRouteError();

  useEffect(() => {
    reportRouteError(error);
  }, [error]);

  return <p>이 게시물을 불러오지 못했어요</p>;
}

덤으로 보이는 것 하나

새 화면을 달았을 때 화면을 자세히 보면 머리말이 그대로 남아 있습니다.

RootErrorBoundary는 껍데기(Layout) 자리에 달려 있어서 그것이 뜨면 머리말까지 통째로 사라지는데, 새 오류 화면은 그 아래 라우트에 달렸으니 껍데기 안쪽만 갈립니다.

C-2에서 "가까운 쪽이 잡는다"를 배울 때 봤던 그 차이인데, 사용자 입장에서는 머리말이 남는 쪽이 덜 당황스럽습니다. 막다른 길에 갇힌 느낌이 안 나니까요.

자주 나오는 실수

화면만 확인하고 끝내기. 이 과제에서 가장 흔한 실수인데, 화면이 잘 바뀌면 성공한 것 같기 때문입니다. 지난 시간부터 계속 나오는 이야기인데, 확인은 받는 쪽에서 해야 합니다.

reportRouteError를 렌더 본문에서 부르기. Step 3에서 다뤘던 그 문제가 새 화면에서도 똑같이 일어나지만, 이번엔 조금 다르게 나타납니다. 이 화면은 loader 오류만 받으니 지난 시간의 배선과 안 겹쳐서, 두 배가 아니라 화면이 그려진 횟수만큼 올라갑니다.

재발을 막으려면

가장 단순한 방법은 오류 화면을 만드는 공용 함수를 하나 두는 것입니다. 새 오류 화면이 그것을 쓰게 하면 보내는 줄을 따로 안 써도 되죠.

더 확실한 방법은 라우트 표를 만들 때 검사하는 것입니다. loader가 있는 라우트에 ErrorBoundary가 달렸는데 그것이 보내는 함수를 안 쓴다면 알려주게요.

어느 쪽이든 핵심은 같은데, 빠뜨릴 수 있는 것을 빠뜨릴 수 없게 만드는 것입니다. 사람의 주의력에 기대면 언젠가 새는 날이 옵니다.

💡 튜터의 한마디 — 이 과제의 진짜 답은 "두 줄을 넣으면 된다"가 아닙니다. 화면이 잘 바뀐 것과 우리가 알게 되는 것이 아무 상관이 없다는 걸 몸으로 겪는 겁니다. 앞으로 여러분이 오류 화면을 만들 때마다 이 감각이 한 번씩 떠오르면 그걸로 충분해요.

🎯 [과제 2 예시답안] 안 보낼 것을 하나 더 골라보세요

채점 포인트

항목 배점 확인 내용
후보를 찾아보고 근거를 댔다 8 앱에서 실제로 날 수 있는 오류를 뒤져봤는가
"더 없다" 도 정답으로 인정 근거가 있으면 감점 없음
고른 것을 실제로 걸렀다 6 조건을 넣고 봉투가 안 나가는 것을 확인했는가
안 걸러야 할 것이 여전히 나가는지 확인 8 조건을 너무 넓게 잡지 않았는가
판단 기준을 말로 정리했다 3 무엇을 안 볼지 정하는 기준을 세웠는가

풀이 예시

먼저 우리 앱에서 라우터가 잡는 것이 무엇인지 세어봅니다.

지금 오류 화면으로 오는 것은 두 종류뿐인데, postLoader가 실패해서 던진 오류와 없는 게시물일 때 우리가 던지는 404 Response입니다. 500 같은 다른 번호는 연습용 서버가 안 던지다 보니, 답은 이렇게 될 수 있습니다.

"지금은 더 거를 것이 없다." 이것도 만점인데, 근거가 분명하기 때문입니다.

다만 조금 더 생각해보면 후보가 하나 보이는데, 바로 사용자가 연결이 끊긴 상태입니다.

지하철에서 터널에 들어갔거나 와이파이가 잠깐 끊겼을 때도 우리 화면에는 "서버에 연결할 수 없어요"가 뜨는데, 이건 우리 버그가 아니라서 우리가 고칠 수 있는 게 없습니다.

거른다면 이렇게

TypeScript
// apps/web-spa/src/lib/monitoring.ts
export function reportRouteError(error: unknown): void {
  if (isRouteErrorResponse(error) && error.status === 404) {
    return;
  }

  // 브라우저가 아예 연결이 끊겼다고 말하는 경우는 우리 쪽 문제가 아니다.
  if (!navigator.onLine) {
    return;
  }

  Sentry.captureException(error);
}

두 번째 확인이 중요합니다

조건을 넣었으면 반드시 반대쪽도 확인해야 합니다.

텍스트
 연결이 끊긴 상태에서 오류        봉투 0건이어야 함
 연결은 멀쩡한데 서버가 죽음       봉투 1건이어야 함   이걸 꼭 확인

두 번째를 안 보면 큰일이 나는데, 조건을 잘못 써서 모든 오류를 막아버려도 첫 번째 확인만으로는 성공한 것처럼 보이기 때문입니다. 지난 시간 과제에서도 똑같은 함정이 있었는데, 안 보내게 만드는 건 쉽고 너무 많이 안 보내게 만드는 것도 쉽습니다.

자주 나오는 실수

"서버에 연결할 수 없어요"라는 글자로 거르기. 메시지 문자열을 보고 거르면 나중에 그 문구를 다듬는 순간 조건이 조용히 풀리는데, 화면 글자는 사용자를 위한 것이라 언제든 바뀔 수 있어서 판단 근거로 쓰기엔 약합니다.

너무 많이 거르기. "네트워크 관련은 다 우리 잘못이 아니다"로 넓히면 우리가 주소를 잘못 적어서 나는 오류까지 막히는데, 그건 우리 버그입니다.

판단 기준

무엇을 안 볼지 정할 때 이 질문이 도움이 됩니다.

"이게 왔을 때 우리가 할 수 있는 일이 있나?"

404는 없고 연결 끊김도 없지만, 500은 있습니다. 백엔드 팀에 알려야 하니까요. 코드가 터진 것도 당연히 있고요.

할 수 있는 일이 없는데 계속 오는 것은 알림이 아니라 소음입니다.

💡 튜터의 한마디 — "더 거를 것이 없다"를 만점으로 둔 이유가 있습니다. 실무에서 이 판단은 앱을 실제로 굴려보고 무엇이 쏟아지는지 본 다음에 하는 게 맞아요. 아직 배포도 안 한 앱에서 미리 거르기 시작하면, 정작 봐야 할 것을 안 보게 될 수 있습니다.

🎯 [과제 3 예시답안] 세 값이 각각 무엇 때문에 바뀌는지 재보세요

채점 포인트

항목 배점 확인 내용
네 경우를 모두 재고 표를 채웠다 8 빌드를 네 번 하고 값을 기록했는가
주석 줄에서 놀랐고 이유를 찾았다 7 빌드 결과물에서 주석을 찾아봤는가
세 값의 역할을 갈라 설명했다 7 각각이 무엇을 알려주는지 정리했는가
어느 것이 코드 변경을 알려주는지 답했다 3 Debug ID라고 답하고 근거를 댔는가

풀이 예시

빌드를 네 번 하고 값을 기록하면 이렇게 나오는데, 파일 이름과 Debug ID는 앞부분만 적었습니다.

무엇을 했나 파일 이름 해시 Debug ID 릴리스
아무것도 안 바꾸고 재빌드 SeYjW6Qr 7ef0eb62 a3d858ce
주석만 한 줄 고침 SeYjW6Qr 7ef0eb62 a3d858ce
화면에 뜨는 글자를 고침 nL54u7Rp edaed267 a3d858ce
코드는 그대로 두고 커밋만 DoBk_e2q 7ef0eb62 263c2495

여러분 값은 다르게 나올 텐데, 봐야 할 것은 값 자체가 아니라 어느 칸이 바뀌고 어느 칸이 그대로인가예요.

둘째 줄 — 주석을 고쳤는데 아무것도 안 바뀝니다

주석을 한 줄 넣고 빌드했는데 세 값이 전부 그대로입니다. 파일 이름 해시까지요.

빌드 결과물에서 그 주석을 찾아보면 답이 나오는데, 없습니다.

압축 도구가 주석을 전부 지우는데, 브라우저는 주석을 안 읽으니 사용자에게 내려보낼 이유가 없기 때문입니다. 주석만 고친 빌드는 결과물이 한 바이트도 안 달라져서 내용이 같으니 해시도 같습니다.

이건 좋은 소식입니다. 주석을 다듬었다고 모든 사용자가 파일을 다시 받아야 한다면 곤란하잖아요.

셋째·넷째 줄 — 두 값이 따로 움직입니다

여기가 이 과제의 본체입니다.

화면 글자를 고쳤을 때는 파일 이름과 Debug ID가 둘 다 바뀌고 릴리스는 그대로입니다. 코드가 진짜 달라졌으니 당연하고, 커밋을 안 했으니 릴리스는 그대로인 겁니다.

커밋만 했을 때는 파일 이름과 릴리스가 바뀌는데 Debug ID는 그대로입니다. 코드는 한 글자도 안 고쳤는데 파일 이름이 바뀐 거예요.

이유는 커밋 번호가 번들 안에 글자로 들어가기 때문입니다. 그 글자가 달라지면 결과물 내용이 달라지고, 그러면 해시도 달라집니다.

세 값이 각각 알려주는 것

알려주는 것 바뀌는 조건
파일 이름 해시 이 파일을 다시 받아야 하나 결과물 내용이 조금이라도 다르면
Debug ID 이 배포본에 맞는 지도가 어느 것인가 우리 코드가 실제로 달라지면
릴리스 이게 어느 시점 코드인가 커밋이 달라지면

"우리 코드가 실제로 달라졌다"를 가장 정확히 알려주는 것은 Debug ID입니다. 주석 변경에는 안 움직이고, 커밋에도 안 움직이고, 결과물이 진짜 달라졌을 때만 움직이니까요.

파일 이름 해시는 코드와 상관없는 일에도 흔들려서, "어느 지도를 볼까"를 파일 이름으로 정하면 어긋날 여지가 생깁니다.

자주 나오는 실수

한 번만 빌드하고 표를 채우기. 네 경우를 각각 빌드해야 합니다. 특히 둘째 줄과 넷째 줄은 안 해보면 절대 예상 못 해요.

빌드 사이에 커밋이 끼는 것을 모르고 재기. 셋째 줄을 재려고 코드를 고쳤는데 그전에 커밋을 하나 했다면, 릴리스도 함께 바뀌어서 무엇 때문에 바뀐 건지 못 가리는데, 한 번에 하나만 바꿔야 원인이 갈립니다.

💡 튜터의 한마디 — 이 과제가 진짜로 가르치는 건 Debug ID 지식이 아닙니다. 한 번에 하나만 바꾸고 재는 습관이에요. 두 개를 동시에 바꾸고 결과를 보면 무엇 때문인지 영영 모릅니다. 앞으로 원인을 찾아야 할 때마다 쓰게 될 방법입니다.

🎯 [과제 4 예시답안] 지도 없이 읽어보고, 있는 채로 읽어보세요

채점 포인트

항목 배점 확인 내용
지도가 있을 때 되돌려봤다 6 파일 이름과 줄 번호를 얻었는가
지도를 치우고 다시 해봤다 6 어떻게 실패하는지 봤는가
각각 무엇을 알 수 있는지 정리했다 7 두 경우를 갈라 적었는가
지도를 잃은 배포본을 어떻게 다룰지 답했다 6 되살릴 수 있는지 판단했는가

풀이 예시

지도가 있을 때는 이렇게 나옵니다.

텍스트
배포본  : dist/assets/index-BnVhwqn0.js:38:49029
되돌리면: ../../src/routes/RootErrorBoundary.tsx : 17행 15칸
그 줄   :   let title = '문제가 생겼어요';

파일, 줄 번호, 그리고 그 줄의 내용까지 나옵니다.

지도 파일을 다른 곳으로 옮기고 같은 것을 시도하면 이렇게 됩니다.

텍스트
Error: ENOENT: no such file or directory,
  open 'dist/assets/index-BnVhwqn0.js.map'

없다고 말해주는데, 어느 파일이 없는지까지 알려줍니다.

두 경우에 알 수 있는 것

지도 있음 지도 없음
오류가 났다는 사실 안다 안다
어느 파일에서 났나 안다 모른다
몇 번째 줄인가 안다 38행이라는 쓸모없는 답만
그 줄에 뭐라고 쓰여 있나 안다 모른다

지도가 없어도 오류가 났다는 것 자체는 알고, 메시지도 오고 언제 났는지도 오지만 어디서가 빠집니다.

좋은 실패입니다

여기서 한 가지 짚고 갈 게 있어요.

지도가 없을 때 이 도구는 조용히 틀린 답을 주지 않습니다. 없다고 말하고 멈추는데, 그것도 어느 파일이 없는지 이름을 대면서요.

오늘 우리가 본 것 중에 이런 게 흔하지 않았는데, Step 7에서 짝이 반쪽만 맞았는데 빌드는 성공했고 Step 3에서 봉투가 두 배로 나가는데 아무도 안 알려줬습니다. 지난 시간에는 주소를 틀렸는데 확인 함수가 참을 돌려줬고요.

모르면 모른다고 말하는 것이 얼마나 고마운 일인지, 안 그런 경우를 여럿 보고 나면 알게 됩니다.

지도를 잃어버린 배포본은

이게 이 과제의 진짜 질문입니다.

되살릴 수 없습니다. 지도는 빌드할 때 만들어지는 것이고 그 빌드가 어떤 코드였는지를 담고 있어서, 나중에 만들 수 없습니다.

그럼 다시 빌드해서 만들면 되지 않냐고요? 안 됩니다. 그때 그 코드로 정확히 되돌린다 해도 도구 버전이나 의존성이 조금만 달라져도 압축 결과가 달라져서 위치가 안 맞다 보니, 맞는 것처럼 보이는데 틀린 줄을 가리킬 수도 있습니다. 그게 아예 없는 것보다 나쁩니다.

그래서 실무에서는 이렇게 다룹니다.

  • 지도는 배포와 한 몸으로 보관합니다. 배포할 때 지도도 함께 어딘가에 올려두고, 배포본을 지울 때까지 같이 둡니다.
  • 지도가 없는 배포본에서 온 오류 보고는 위치를 안 믿습니다. 메시지와 발생 빈도만 씁니다.
  • 그리고 그런 배포본이 남아 있다면 되도록 빨리 새 배포로 밀어냅니다.

⚠️ Step 7에서 본 그 경고 두 줄이 여기서 다시 떠오르셨으면 좋겠습니다. 그 경고를 놓친 채로 배포를 스무 번 하면, 지도 없는 배포본이 스무 개 쌓입니다.

💡 튜터의 한마디 — 오류 추적을 붙여두고도 정작 필요할 때 못 쓰는 가장 흔한 이유가 이겁니다. 도구가 안 붙어 있어서가 아니라 지도가 없어서예요. 붙이는 날 한 번 확인하고 끝낼 게 아니라, 배포 과정에 확인하는 단계를 넣어두셔야 합니다.


🤔 [생각해볼 주제 1] 오류를 안 보내기로 하는 결정은 누가 해야 할까요

문제 상황 요약

오늘 우리는 404를 안 보내기로 정했는데, 코드로는 조건문 세 줄이었지만 그 세 줄의 효과는 그 종류의 문제가 우리 눈에서 영영 사라지는 것입니다.

나중에 앱 안의 링크가 깨져서 404가 쏟아져도 오류 목록은 조용합니다. 아무도 그 조건문을 기억 못 하는 상태로요.

튜터의 가이드 및 해설

이 결정이 까다로운 이유는 효과가 음(陰)으로 나타나기 때문입니다.

기능을 추가하면 화면에 뭔가 생기고 잘못 만들면 티가 나지만, "안 보기로 하는" 결정은 아무것도 안 생깁니다. 잘못 정해도 티가 안 나고, 오히려 목록이 깨끗해져서 잘한 것처럼 보입니다.

그래서 이런 결정은 기록을 남기는 쪽으로 다루는 게 맞습니다. 몇 가지 방법이 있어요.

코드에 이유를 남깁니다. 오늘 우리가 reportRouteError 위에 주석을 길게 단 이유가 그건데, 조건문만 있으면 나중에 보는 사람이 "이거 왜 있지?" 하다가 지워버릴 수 있기 때문입니다.

거른 것을 세어둡니다. 아예 안 보내는 대신 보내지는 않되 개수만 따로 세두는 방법이 있는데, 그러면 "404가 평소보다 열 배 많다"를 알아챌 수 있습니다. 목록은 안 더러워지고 신호는 남습니다.

주기적으로 다시 봅니다. 앱이 자라면 판단 기준도 달라집니다. 처음엔 무시해도 되던 것이 나중엔 중요해질 수 있어요.

누가 정하느냐에 대해서는, 저는 혼자 정하지 않는 것이 핵심이라고 봅니다. 이 결정은 코드 스타일 문제가 아니라 "우리 팀이 무엇을 볼 것인가"라는 운영 문제거든요. 오류를 보고 대응하는 사람이 함께 정해야 합니다.

프런트엔드 개발자가 조용히 조건문을 넣고, 나중에 운영하는 사람이 "왜 이게 안 잡혔지?" 하는 상황이 가장 나쁩니다.

🎯 면접관을 홀리는 핵심 멘트

"오류를 안 보내기로 하는 결정은 코드 세 줄이지만 효과는 '그 문제가 영영 안 보이는 것'입니다. 기능 추가와 달리 잘못 정해도 티가 안 나고 오히려 목록이 깨끗해져서 잘한 것처럼 보이죠. 그래서 저는 이런 결정을 반드시 이유와 함께 코드에 남기고, 아예 버리는 대신 개수만 따로 세두는 쪽을 선호합니다. 그러면 목록은 깨끗하게 유지하면서 '평소보다 열 배 많다'는 신호는 잃지 않습니다."

🤔 [생각해볼 주제 2] 조용한 실패를 어디까지 막아야 할까요

문제 상황 요약

Step 7에서 빌드는 경고 두 줄만 남기고 성공했습니다. 짝이 반쪽만 맞았는데도요.

이런 일이 이번이 처음도 아닌데, 지난 시간에는 주소를 잘못 적어도 앱이 멀쩡했고 확인 함수마저 참을 돌려줬습니다. G 카테고리에서는 문을 잠갔는데 다른 문이 열려 있었죠.

도구는 대체로 "일단 진행"을 고릅니다. 그렇다고 모든 경고를 오류로 바꿔 빌드를 멈추게 하면 그것도 문제가 생길 겁니다.

튜터의 가이드 및 해설

기준을 세우는 데 도움이 되는 질문이 하나 있습니다.

"이게 잘못된 채로 배포되면, 우리가 언제 알게 되나?"

이 질문으로 갈라보면 꽤 깔끔하게 나뉩니다.

금방 알게 되는 것은 넘어가도 됩니다. 화면이 안 뜨거나 버튼이 안 눌리는 문제는 배포하고 몇 분이면 누군가 알아챕니다. 빌드까지 멈춰가며 막을 필요는 없어요.

영영 모르는 것은 반드시 막아야 합니다. 오늘 본 것들이 전부 여기 속합니다. 지도가 안 올라간 것, 오류가 안 나가는 것, 문이 열려 있는 것. 이런 것들은 문제가 생겼을 때 비로소 알게 되는데, 그때는 이미 늦습니다.

여기에 하나를 더 얹으면 좋습니다. 되돌리기 비용이요.

지도가 안 올라간 것은 다시 배포하면 해결되어서 비용이 낮은데, 소스맵을 공개해버린 것은 되돌릴 수 없습니다. 이미 받아간 사람이 있을 수 있으니까요.

그래서 저는 이렇게 정리합니다.

텍스트
 영영 모른다 + 되돌리기 어렵다     빌드를 멈춘다
 영영 모른다 + 되돌릴 수 있다       배포 뒤 확인하는 단계를 만든다
 금방 안다                         경고로 둔다

한 가지 덧붙이면, 경고를 늘리는 것은 대책이 아닙니다. 경고가 스무 줄이면 아무도 안 읽는데, 오늘 그 두 줄을 우리가 못 알아챌 뻔했던 것도 같은 이유입니다. 정말 중요한 것은 멈추게 하고, 나머지는 오히려 줄이는 쪽이 낫습니다.

🎯 면접관을 홀리는 핵심 멘트

"저는 '이게 잘못된 채로 배포되면 우리가 언제 알게 되나'를 기준으로 씁니다. 금방 아는 건 경고로 두고, 영영 모르는 건 막습니다. 오류 추적이 안 붙은 것이나 소스맵이 안 올라간 것은 문제가 터졌을 때 비로소 알게 되는데 그때는 이미 늦거든요. 여기에 되돌리기 비용을 더 봅니다. 되돌릴 수 없는 것은 빌드를 멈추게 하고요. 그리고 경고를 늘리는 건 대책이 아니라고 봅니다. 스무 줄이면 아무도 안 읽으니까요."

🤔 [생각해볼 주제 3] 지도를 올리는 것과 소스를 공개하는 것 사이

문제 상황 요약

오늘 우리는 지도를 배포본 옆에 두지 않기로 했는데, 원본이 통째로 들어 있기 때문입니다. 세상에는 반대로 하는 곳도 있어서, 오픈소스가 아닌데도 소스맵을 그대로 공개해두는 서비스가 실제로 있어요.

튜터의 가이드 및 해설

그 선택에도 이유가 있습니다. 몇 가지 짚어볼게요.

개발자 도구에서 디버깅이 됩니다. 소스맵이 공개돼 있으면 누구든 브라우저에서 원본 코드를 보면서 문제를 찾을 수 있어요. 개발자를 대상으로 하는 서비스라면 이게 오히려 장점입니다. 사용자가 직접 원인을 찾아 알려주기도 하니까요.

어차피 못 숨긴다고 판단한 경우입니다. 프런트엔드 코드는 사용자 기계에서 돌기 때문에 마음먹으면 압축본도 읽을 수 있는데, 시간이 좀 더 걸릴 뿐입니다. 그 시간 차이를 방어라고 부를 수 있느냐는 논쟁거리입니다.

설정이 하나 줄어듭니다. 별것 아닌 것 같지만 실무에서는 큽니다. 오늘 우리가 본 것처럼 지도를 따로 올리려면 토큰이 필요하고, 그게 없으면 조용히 반쪽만 돼요. 그냥 다 공개해버리면 그 실패 경로가 사라집니다.

그럼 어느 쪽을 골라야 할까요? 저는 앱의 성격에 따라 갈린다고 봅니다.

갈림길에 놓고 볼 질문은 이겁니다. 우리 코드를 읽는 것이 공격자에게 얼마나 도움이 되나?

정적인 소개 페이지라면 별 도움이 안 되지만, 우리 인스타그램 클론처럼 로그인이 있고 권한이 있고 서버와 주고받는 것이 많은 앱은 다릅니다. G 카테고리에서 우리가 세운 방어들이 어떻게 생겼는지 다 보이거든요. 어느 주소가 무엇을 확인하는지, 어디를 안 막았는지가 주석과 함께 펼쳐집니다.

하나 더 짚자면, 팀이 그 결정을 알고 있느냐가 중요합니다.

공개하기로 정했다면 그건 정당한 선택인데, 문제는 정한 적 없이 공개되는 경우입니다. 지도를 만들었는데 배포에서 빼는 걸 아무도 안 챙겨서 그냥 올라가는 상황이면, 이건 선택이 아니라 사고입니다.

저는 이렇게 말하고 싶습니다. 두 답 다 맞을 수 있는데, 모르고 그렇게 된 것만은 아니어야 한다고요.

🎯 면접관을 홀리는 핵심 멘트

"소스맵 공개는 앱 성격에 따라 갈린다고 봅니다. 개발자 대상 서비스는 공개해서 얻는 게 있고, 인증과 권한이 얽힌 앱은 방어 코드가 주석까지 다 보이는 게 부담이죠. 다만 제가 진짜 중요하게 보는 건 어느 쪽을 골랐느냐가 아니라 고른 적이 있느냐입니다. 지도를 만들어놓고 배포에서 빼는 걸 아무도 안 챙겨서 올라간 거라면 그건 선택이 아니라 사고니까요."

전체 목록 리액트