문서 읽는 데 67분 · D10

D-10: 배포 & 최적화 ② — 밖으로 내보냅니다

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

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

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

시작하기 전에 명령 하나만 같이 돌려볼게요, 저장소 뿌리에서요.

Bash
ls node_modules | wc -l
텍스트
564

우리가 설치한 꾸러미는 564개, 용량으로는 684MB인데, React 하나 Next 하나 깔았을 뿐인데도 그 안에서 딸려 온 것들까지 합쳐지면 이렇게 커집니다.

오늘은 이 앱을 컨테이너 하나에 담는데, 그 컨테이너 안에 실제로 들어가는 꾸러미는 열한 개뿐이에요.

564개에서 11개로 줄어드는 이 숫자가 어디서 갈리는지가 오늘 수업의 절반이고, 나머지 절반은 그 뒤에 옵니다.

담아서 띄웠는데 화면이 뜨긴 뜨는데 벌거벗어 있고, 그걸 고쳤더니 이번엔 피드가 영영 안 옵니다. 둘 다 응답은 200이라서, 지난 시간에 배운 그 문장이 오늘 두 번 더 나옵니다.

텍스트
 지금 상태       내 노트북에서만 돈다
   │             node_modules 684MB · 꾸러미 564개
     output: 'standalone' 한 줄
 골라낸 덩어리   50MB · 꾸러미 12개
   │             그런데 띄우면 화면이 벌거벗는다
     Dockerfile 3단계
 컨테이너        222MB · 어디서든 명령 하나로 뜬다
   │             그런데 컨테이너 안에서 localhost 는 컨테이너 자신이다
     주소를 밖에서 받게
 완성            같은 이미지 하나 · 환경마다 다른 값

💡 오늘 수업의 핵심 — "실행에 필요한 것만 골라 담고, 환경마다 달라지는 값은 밖에서 받는다"

🎯 학습 목표

  • output: 'standalone'이 무엇을 골라내고 무엇을 일부러 빼는지 알고, 빠진 것을 직접 채웁니다.
  • 멀티스테이지 Dockerfile로 인스타그램 클론을 컨테이너 하나에 담아 실행합니다.
  • 코드에 적어둔 값 중 밖으로 나가면 뜻이 달라지는 것을 찾아, 환경 변수로 분리합니다.

Step 1: "아직 제 노트북에서만 돕니다"

지금까지 우리가 앱을 띄운 방법은 둘 중 하나였습니다.

Bash
npx next dev     # 개발할 때
npx next start   # 빌드한 걸 확인할 때

next start는 프로덕션용 서버가 맞아서 진짜 서버에 그대로 올려도 돌아가는데, 그러려면 무엇을 들고 가야 할까요.

두 덩어리가 필요합니다

next start는 두 가지에 기대고 있는데, 하나는 빌드 결과인 .next 폴더고 다른 하나는 node_modules입니다. 둘 다 재봅시다.

Bash
du -sh node_modules
du -sh apps/web-next/.next
텍스트
684M   node_modules
 71M   apps/web-next/.next

755MB 를 서버에 올려야 한다는 뜻인데, 여기서 두 가지가 걸려요.

첫째, node_modules는 올린다고 되는 게 아닙니다. 그 안에는 우리가 지금 쓰는 컴퓨터에 맞춰 설치된 것들이 섞여 있어서, 제 맥에서 만든 폴더를 리눅스 서버에 복사하면 어떤 꾸러미는 그대로 안 돌아가다 보니, 보통은 서버에서 npm install을 다시 돌리는데 그러면 서버에도 빌드 도구가 전부 깔려야 하죠.

둘째, 684MB 중에 실행에 진짜 필요한 것은 얼마나 될까요. typescript, eslint, tailwindcss는 빌드할 때 쓰는 도구라서 빌드가 끝난 뒤에는 한 번도 안 불리는데, 폴더에는 그대로 남아 있습니다.

.next 안도 마찬가지입니다

Bash
du -sh apps/web-next/.next/server apps/web-next/.next/static apps/web-next/.next/cache
텍스트
 12M   .next/server
4.3M   .next/static
 54M   .next/cache

server는 서버가 화면을 그릴 때 읽는 것이고 static은 브라우저가 받아가는 JS·CSS·글꼴이라서, 둘 다 실행에 필요해요.

server·static과 달리 cache는 다음 빌드를 빠르게 하려고 두는 것이라 서버에서는 한 번도 안 읽히는데, .next의 절반을 넘게 차지하고 있어요. (이 숫자는 빌드를 몇 번 돌렸느냐에 따라 달라지는데, 제 노트북에서는 54MB 였다가 여러 번 돌린 뒤에는 62MB 까지 늘었어요.)

정리하면 이렇습니다.

크기 서버에서 읽히나
node_modules 684MB 일부만
.next/server 12MB 읽힌다
.next/static 4.3MB 읽힌다
.next/cache 54MB 안 읽힌다

들고 가야 할 것과 안 들고 가도 될 것이 섞여 있으니, 누가 이걸 대신 골라주면 좋겠는데요.

💡 한 줄 정리

next startnode_modules.next 둘 다 필요한데, 그 안에는 빌드에만 쓰이고 실행에는 한 번도 안 읽히는 것이 섞여 있다.

🙋 학생 질문 — "튜터님, 그냥 통째로 다 올리면 안 되나요? 요즘 디스크 싸잖아요."

디스크값 문제가 아니라 세 가지가 걸려요.

시간. 배포는 하루에 몇 번씩 하는데, 755MB 를 매번 올리고 받는 것과 50MB 를 올리고 받는 것은 배포 한 번에 몇 분 차이가 나서 하루 다섯 번이면 그게 그대로 쌓입니다.

빌드 도구가 서버에 남는 것. typescript·eslint 같은 것이 실행 서버에 있으면 나중에 누가 그 서버에 들어와서 코드를 고치고 다시 빌드하는 일이 벌어지니까, 실행하는 서버와 만드는 기계는 분리해두는 편이 안전해요.

소스 코드가 서버에 남는 것. 우리 .tsx 파일들은 빌드가 끝나면 이미 JS로 변환돼 있어서 원본을 서버에 둘 이유가 없어요.

Step 2에서 이 셋을 한 줄로 정리합니다.


Step 2: "담을 것만 골라냅니다"

next.config.ts에 한 줄을 더합니다.

TypeScript
// apps/web-next/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // 실행에 진짜 필요한 것만 골라 한 덩어리로 내보낸다.
  output: 'standalone',
  typedRoutes: true,
  // 굳힐 것과 흘려보낼 것을 우리가 직접 고르겠다고 선언하는 스위치.
  cacheComponents: true,
  images: {
    // 여기 적힌 주소에서 온 사진만 다시 만들어 준다. 나머지는 400 으로 거절한다.
    remotePatterns: [new URL('https://picsum.photos/seed/**')],
  },
};

export default nextConfig;

다시 빌드합니다.

Bash
rm -rf .next
npx next build

화면이 달라진 게 아니라서 라우트 표는 지난 시간과 한 글자도 안 다른데, 대신 폴더가 하나 생겼습니다.

Bash
du -sh apps/web-next/.next/standalone
ls apps/web-next/.next/standalone/node_modules | wc -l
텍스트
50M
12

684MB 에서 50MB, 564개에서 12개로 줄었는데, importrequire를 하나씩 따라가면서 Next가 우리 코드를 훑어 "이건 실행할 때 실제로 불린다"를 따라간 결과입니다.

무엇이 살아남았는지 직접 봅시다.

Bash
ls apps/web-next/.next/standalone/node_modules
텍스트
@emnapi  @img  @next  @swc  client-only  detect-libc
next  react  react-dom  semver  sharp  styled-jsx

typescripteslinttailwindcss도 없는데, Tailwind는 빌드할 때 CSS 파일을 만들고 나면 할 일이 끝나기 때문이고, 만들어진 CSS는 static에 이미 들어 있습니다.

sharp는 남았는데, 지난 시간에 사진 크기를 줄이고 형식을 바꿔주던 그 도구라 요청이 올 때마다 실제로 일하니까 실행에 필요하죠.

우리 server.js는 한 겹 안쪽에 있습니다

폴더 구조를 봅시다.

Bash
find apps/web-next/.next/standalone -maxdepth 3 -not -path '*/node_modules/*'
텍스트
.next/standalone/
.next/standalone/node_modules/
.next/standalone/apps/
.next/standalone/apps/web-next/
.next/standalone/apps/web-next/.next/
.next/standalone/apps/web-next/package.json
.next/standalone/apps/web-next/server.js

공식 문서나 블로그 예제를 보면 node server.js로 띄우라고 하는데, 우리 것은 뿌리에 server.js가 없고 apps/web-next/ 안쪽에 있습니다.

우리 저장소가 한 뿌리에 여러 앱을 담고 apps/web-spaapps/web-nextnode_modules를 같이 쓰고 있다 보니, Next가 그 구조를 그대로 복제해서 실행 명령이 이렇게 됩니다.

Bash
node .next/standalone/apps/web-next/server.js

이 한 줄을 못 찾아서 헤매는 경우가 꽤 많은데, 예제를 그대로 베끼면 "그런 파일 없다"는 소리만 듣거든요. 베끼기 전에 find로 어디 있는지 한 번 보는 게 빠릅니다.

💡 한 줄 정리

output: 'standalone'import를 따라가며 실행에 실제로 쓰이는 것만 골라 .next/standalone에 모은다. 우리 저장소는 앱이 여러 개라 server.jsapps/web-next/ 안쪽에 생긴다.

🙋 학생 질문 — "튜터님, Next가 잘못 골라내면 어떡하나요? 필요한 게 빠지면요."

실제로 그런 경우가 있는데, Next는 importrequire를 글자로 훑어서 따라가다 보니 이름을 변수로 조립해서 불러오는 것은 못 봅니다.

TypeScript
const name = someCondition ? 'ko' : 'en';
const messages = await import(`./locales/${name}.json`);

이런 코드가 있으면 locales 폴더가 통째로 빠질 수 있는데, 그럴 때 쓰라고 outputFileTracingIncludes라는 설정이 따로 있어서 "이 주소를 그릴 때는 이 파일들도 같이 담아라"라고 직접 적어주는 거예요.

우리 앱은 그런 코드가 없어서 안 쓰는데, 이건 미리 걱정한다고 알 수 있는 게 아니라 띄워보고 없다고 하면 그때 채웁니다. 지난 시간 마지막에 이야기한 원칙이 여기서도 그대로예요.


Step 3: "띄웠는데 화면이 벌거벗었습니다"

골라냈으니 띄워봅시다. 개발 서버가 3000번을 쓰고 있을 테니 다른 번호를 줄게요.

Bash
PORT=3300 node .next/standalone/apps/web-next/server.js
텍스트
 Next.js 16.3.1
- Local: http://localhost:3300
✓ Ready in 0ms

떴으니 브라우저로 열어봅니다.

Bash
curl -s -o /dev/null -b 'me=minji' -w "%{http_code} %{size_download}\n" http://localhost:3300/
텍스트
200 25499

200이고 HTML도 25KB 나 왔는데, 브라우저에서 보면 이렇게 생겼습니다.

글자만 세로로 주르륵 쌓여 있고 배경도 흰색, 글꼴도 기본 글꼴이라 하트 버튼은 눌러도 아무 일이 없는데, 지난 시간에 그렇게 공들여 붙인 글꼴이 사라져 있습니다.

무엇이 안 왔는지 세어보는데, HTML 안에서 브라우저가 받아가야 할 주소를 뽑아서 하나씩 불러보는 거예요.

Bash
curl -s -b 'me=minji' http://localhost:3300/ \
  | grep -o '/_next/static/[^"]*' | sort -u \
  | while read u; do
      echo "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3300$u)  $u"
    done
텍스트
404  /_next/static/chunks/0ay527mnq1105.js
404  /_next/static/chunks/0cz1d0mv5g_q7.js
404  /_next/static/chunks/2a9batolpjr0x.css
...
404  /_next/static/chunks/turbopack-2hdzkb-n4145y.js
404  /_next/static/media/fca78c2ee6cec166-s.p.1sn26-iqxrgtp.woff2

11개 전부 404인데, JS 아홉 개, CSS 하나, 글꼴 하나요.

페이지는 200 인데 그 페이지가 부르는 것이 전부 없다 보니, 브라우저는 시키는 대로 받으러 갔다가 전부 빈손으로 돌아와서 스타일이 없고, 글꼴이 없고, 버튼이 안 눌립니다.

일부러 뺀 것입니다

버그가 아니라 공식 문서가 이렇게 적어뒀어요.

This minimal server does not copy the public or .next/static folders by default as these should ideally be handled by a CDN instead.

static은 요청이 올 때마다 서버가 계산해서 만드는 게 아니라 한 번 만들어두면 안 바뀌는 파일들이라서, 전 세계에 복사본을 뿌려두는 별도 서비스가 대신 나눠주는 편이 빠르다는 뜻이라 기본으로는 안 담습니다.

우리는 그런 서비스를 안 쓰니까 직접 넣어줍니다.

Bash
cp -r apps/web-next/.next/static apps/web-next/.next/standalone/apps/web-next/.next/static

복사했으니 다시 불러볼까요.

텍스트
404  /_next/static/chunks/2a9batolpjr0x.css
404  /_next/static/media/fca78c2ee6cec166-s.p.1sn26-iqxrgtp.woff2

파일은 분명히 거기 있는데도 여전히 404입니다.

서버가 부팅할 때 "내가 나눠줄 수 있는 정적 파일 목록"을 한 번 읽고 기억해두기 때문에 서버를 껐다 켜면 그때 200이 되는데, 그 뒤에 폴더에 파일을 넣어도 목록에는 안 들어갑니다.

Bash
# 서버를 끄고 다시
PORT=3300 node .next/standalone/apps/web-next/server.js
텍스트
11개  200 이 11개

이제 화면이 제대로 나옵니다. 배경색도, 글꼴도, 하트 버튼도요.

💡 한 줄 정리

standalone.next/staticpublic을 일부러 안 담는다. 안 담긴 상태로 띄우면 페이지는 200 인데 그 페이지가 부르는 파일이 전부 404 다. 복사한 뒤에는 반드시 서버를 다시 띄워야 한다.

🙋 학생 질문 — "튜터님, 이게 왜 이렇게 헷갈리죠? 200이면 됐다고 생각하게 되는데요."

우리가 200을 확인하는 방식 때문입니다.

curl로 주소를 하나 불러서 200이 나오면 대개 넘어가지만, 브라우저는 그 200을 받은 뒤에 그 안에 적힌 주소를 다시 열 개 넘게 부르는데, curl 한 번은 그 뒷이야기를 안 보여줘요.

위에서 한 것처럼 HTML 안의 주소를 뽑아 한 번 더 돌려보거나, 브라우저 개발자 도구의 네트워크 탭을 열어두고 새로고침해야 확인되는데, 404가 빨갛게 줄줄이 찍혀요.

지난 시간에 사진이 안 나올 때도 똑같았는데, 페이지는 200이고 사진 바이트만 400 이었습니다. 200은 "요청이 도착했다"는 뜻이지 "화면이 제대로 나왔다"는 뜻이 아닙니다. 오늘 이 문장이 한 번 더 나옵니다.


Step 4: "컨테이너 하나에 담습니다"

50MB 짜리 덩어리를 만들었고 빠진 것도 채웠는데, 이걸 서버에 올려도 아직 문제가 남아요.

그 서버에는 Node.js가, 그것도 우리와 같은 버전으로 깔려 있어야 하는데, 버전이 다르면 어떤 문법은 돌고 어떤 문법은 안 되다 보니 팀에 새로 온 사람이 자기 노트북에서 띄우려 해도 같은 이야기가 반복돼요.

컨테이너는 이걸 통째로 묶는 방법인데, 우리 앱만 담는 게 아니라 리눅스 한 조각과 Node.js까지 같이 담다 보니 그걸 받은 사람은 자기 컴퓨터에 Node가 몇 버전이든 상관없이 명령 하나로 띄웁니다.

무엇을 어떻게 담을지는 Dockerfile이라는 파일에 적습니다.

세 단계로 나눕니다

dockerfile
# apps/web-next/Dockerfile

# 1단계 — 설치. 여기서만 npm 을 쓴다.
FROM node:24-alpine AS deps
WORKDIR /repo
COPY package.json package-lock.json ./
COPY apps/web-next/package.json apps/web-next/
RUN npm ci --workspace web-next --include-workspace-root

# 2단계 — 빌드. 소스를 넣고 next build 를 돌린다.
FROM node:24-alpine AS builder
WORKDIR /repo
COPY --from=deps /repo/node_modules ./node_modules
COPY . .
WORKDIR /repo/apps/web-next
RUN npx next build

# 3단계 — 실행. 앞 두 단계에서 만든 결과만 가져온다. 소스도 개발 도구도 안 따라온다.
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /repo/apps/web-next/.next/standalone ./
COPY --from=builder /repo/apps/web-next/.next/static ./apps/web-next/.next/static
EXPOSE 3000
CMD ["node", "apps/web-next/server.js"]

FROM이 세 번 나오는데, 각각이 별도의 작업 공간입니다. 그리고 마지막 FROM만 실제 결과물이 됩니다. 앞의 둘은 재료를 만드는 데만 쓰이고 버려져요.

COPY --from=builder가 그 다리인데, "버려질 2단계에서 이것만 꺼내 와라"는 뜻이에요.

여기서 Step 3의 교훈이 그대로 반영돼 있어서, standalone을 복사하는 줄 바로 밑에 static을 복사하는 줄이 따로 있죠. 저 한 줄이 없으면 컨테이너를 띄울 때마다 벌거벗은 화면을 보게 됩니다.

텍스트
 1단계 deps      npm ci 로 꾸러미를 받는다
                   │ node_modules
                   
 2단계 builder   소스를 넣고 next build
                   │ .next/standalone · .next/static
                   
 3단계 runner    받아온 결과만 있다  이것만 이미지가 된다

저장소 뿌리에는 안 담을 것 목록도 하나 둡니다.

텍스트
node_modules
**/node_modules
**/.next
**/scratch
.git

이걸 안 두면 1단계에서 새로 받을 건데도 우리 노트북의 684MB 짜리 node_modules가 통째로 컨테이너 안으로 복사되는데, .git도 마찬가지고요.

만들어봅시다

Bash
docker build -f apps/web-next/Dockerfile -t insta-next:d10 .

맨 끝의 점이 중요한데, "어느 폴더를 재료로 쓸 거냐"를 가리키는 것이니 저장소 뿌리를 줘야 해요. package-lock.json이 거기 있고 두 앱이 node_modules를 나눠 쓰니까요.

텍스트
#18 exporting to image
#18 naming to docker.io/library/insta-next:d10 done

docker build ...  57.272 total

57초 걸렸고 종료 코드는 0입니다. 크기를 봅시다.

Bash
docker images insta-next:d10
텍스트
REPOSITORY   TAG   SIZE
insta-next   d10   222MB

안에 뭐가 들었나 열어봅시다

Bash
docker run -d --name insta-d10 -p 3400:3000 insta-next:d10
docker exec insta-d10 sh -c 'ls /app/node_modules; find /app/apps -name "*.ts*" | wc -l'
텍스트
@img  @next  @swc  client-only  detect-libc
next  react  react-dom  semver  sharp  styled-jsx
0

꾸러미 11개, 소스 파일 0개인데, 우리가 쓴 .tsx 파일은 한 개도 안 들어갔습니다. 이미 JS로 변환돼서 .next 안에 들어가 있으니까요.

(제 노트북의 standalone은 12개였는데 컨테이너 안은 11개인 것은 sharp가 운영체제마다 다른 조각을 딸려 오기 때문이라, 여러분 화면의 숫자가 하나쯤 달라도 정상이에요.)

한 가지 더 짚고 갈 게 있습니다. 이 빌드는 백엔드 API 서버가 안 떠 있는 상태에서 돌았는데, 컨테이너 안에서는 우리 스텁 서버에 닿을 방법이 아예 없는데도 종료 코드는 0 이었어요.

지난 시간들에 우리가 캐시를 손보면서 화면에 필요한 데이터를 미리 받아두는 대신 요청이 올 때 받아오도록 바꿔뒀기 때문에, 빌드하는 기계는 API 서버를 몰라도 되는데, 배포 관점에서 이건 꽤 큰 이야기예요.

💡 한 줄 정리

멀티스테이지 Dockerfile은 설치·빌드·실행을 갈라, 마지막 단계에 앞 단계의 결과만 가져온다. 완성된 이미지 222MB 안에는 꾸러미 11개가 있고 우리 소스 파일은 0개다.

🙋 학생 질문 — "튜터님, 왜 굳이 세 단계로 나누죠? 한 번에 하면 안 되나요?"

한 단계로도 되는 대신 이미지 크기가 확 커져요.

FROM 하나로 쓰면 그 안에서 한 모든 일이 이미지에 남는데, npm ci로 받은 684MB 도, 우리 소스 코드도, 빌드하다 나온 중간 파일도 전부요. 중간에 rm -rf로 지워도 크게 안 줄어드는데, 컨테이너 이미지는 명령 하나하나를 층으로 쌓아 올리다 보니 지운다는 것도 하나의 층이라 앞 층은 그대로 남아 있기 때문이에요.

세 단계로 나누면 3단계는 완전히 새 공간에서 시작해서, 앞 두 단계에서 우리가 손으로 꺼내 온 것만 있어요. 실제로 층을 보면 이렇습니다.

Bash
docker history insta-next:d10
텍스트
4.24MB   COPY .next/static ...
55.7MB   COPY .next/standalone ...

우리가 얹은 건 60MB 남짓이고, 나머지는 리눅스와 Node.js 본체입니다.


Step 5: "컨테이너 안에서 localhost는 자기 자신입니다"

컨테이너가 떴으니 3400번으로 열어봅시다.

Bash
curl -s -o /dev/null -b 'me=minji' -w "%{http_code} %{size_download}\n" http://localhost:3400/
텍스트
200 10680

또 200이고, 화면을 보면 머리말은 제대로 나오고 글꼴도 맞고 색도 맞아서 Step 3의 그 문제는 아니지만, 피드 자리에는 이 글자가 떠 있고 영영 안 바뀝니다.

텍스트
피드를 불러오는 중…

컨테이너 로그를 봅시다.

Bash
docker logs insta-d10
텍스트
⨯ [TypeError: fetch failed] {
  [cause]: AggregateError:
    code: 'ECONNREFUSED',

연결을 거부당했습니다. 어디에 연결하려다가요? 우리가 코드에 적어둔 그 주소죠.

TypeScript
const API_BASE = 'http://localhost:8090/api';

오늘의 함정이 여기 있는데, localhost는 "이 컴퓨터"라는 뜻이라 제 노트북에서 돌 때는 제 노트북이었지만, 컨테이너 안에서 도는 코드에게 "이 컴퓨터"는 컨테이너 자신이라서 8090번에서 듣고 있는 게 아무것도 없고요.

같은 글자가 실행되는 곳에 따라 다른 곳을 가리켜서, 옮기기 전에는 알 수가 없어요.

한 원인이 네 가지 얼굴로 나타납니다

주소가 틀렸다는 원인은 하나인데, 화면마다 다르게 드러납니다. 전부 재봤어요.

요청 결과
로그인 후 / 200 · 영원한 로딩 껍데기를 먼저 보내고 피드는 흘려보내는 구조라, 실패가 응답 상태에 못 실린다
로그인 후 /jaehoon 500 문지기가 API에 물어보다 터진다
로그인 후 /zzzzz 500 없는 사람인데 404 가 아니라 500 이다
로그아웃 상태 /jaehoon 307 멀쩡하다
로그인 후 /explore 200 이 화면은 서버를 안 부른다

세 번째 줄을 보면 D-7에서 405밀리초를 주고 되찾았던 404가 500이 됐는데, 없는 사람을 알아보려면 API에 물어봐야 하다가 터지니까요.

네 번째 줄은 멀쩡합니다. 그런데 로그아웃한 사람은 여전히 307로 돌려보내져요.

이게 왜 갈릴까요. D-8에서 문지기의 검사 순서를 정할 때 우리는 쿠키 확인을 API 요청보다 앞에 뒀는데, 그때는 네트워크를 타기 전에 끝나는 검사가 제일 싸다는 속도 때문이었어요.

그 순서가 지금 다른 값을 하는데, API 서버가 통째로 죽어도 로그인 안 한 사람은 여전히 못 들어오고, 뒤에 뒀으면 서버가 죽는 순간 문이 열렸을 거예요.

성능을 위해 고른 순서가 서버 장애 상황에서 보안을 지켜준 셈이라, 이런 건 설계할 때 다 내다보고 고르기 어렵지만, 싼 검사를 앞에 두는 습관이 이런 이득을 자주 데려옵니다.

💡 한 줄 정리

localhost는 실행되는 곳을 따라간다. 컨테이너 안에서는 컨테이너 자신을 가리켜서, 같은 원인이 200·500·307로 서로 다르게 드러난다.

🙋 학생 질문 — "튜터님, 그럼 컨테이너에서 제 노트북의 서버를 부르려면 뭐라고 적어야 하나요?"

host.docker.internal이라고 적는데, "나를 실행시킨 그 컴퓨터"라는 뜻으로 도커가 만들어둔 이름이에요.

이건 개발할 때 잠깐 쓰는 이름이라서, 진짜 서버에 올릴 때는 API 서버도 어딘가에 따로 떠 있을 테니 그 주소를 적어야 해요.

그러니까 우리에게 필요한 건 "맞는 주소를 찾아 코드에 다시 적는 것"이 아닙니다. 주소가 세 곳에서 다 다르잖아요.

어디서 도나 백엔드 주소
내 노트북 http://localhost:8090/api
내 노트북의 컨테이너 http://host.docker.internal:8090/api
진짜 서버 http://api.우리회사.com/api

셋 다 맞는 값이고 셋 다 다르니, 코드에는 어느 것도 적으면 안 된다는 뜻이에요. Step 6이 그 이야기입니다.


Step 6: "주소를 밖에서 받습니다"

고치기 전에 먼저 세어봅시다. 그 주소가 코드에 몇 번 적혀 있을까요.

Bash
grep -rn "localhost:8090" apps/web-next --include="*.ts"
텍스트
apps/web-next/lib/api.ts:7:const API_BASE = 'http://localhost:8090/api';
apps/web-next/proxy.ts:5:const API_BASE = 'http://localhost:8090/api';

두 군데인데, 하나만 고치면 절반만 고쳐져요.

실제로 그런 일이 벌어지는데, api.ts만 고치고 띄우면 피드는 정상으로 나오는데 프로필 화면은 여전히 500이에요. 화면 하나가 되니까 다 된 줄 알고 넘어가기 딱 좋습니다.

한 곳으로 모읍니다

새 파일을 하나 만듭니다.

TypeScript
// apps/web-next/lib/config.ts

// 백엔드 주소는 실행하는 곳마다 다르다.
// 내 노트북에서는 localhost 지만, 컨테이너 안에서 localhost 는 컨테이너 자신이다.
// 그래서 값을 코드에 적어두지 않고 밖에서 받는다 — 못 받으면 개발용 주소를 쓴다.
export const API_BASE = process.env.API_BASE ?? 'http://localhost:8090/api';

process.env는 앱을 실행시킬 때 밖에서 건네주는 값들이 담긴 곳인데, 없으면 ?? 뒤의 값을 쓰다 보니 개발할 때는 아무것도 안 줘도 지금까지처럼 돌아가라는 뜻이에요.

두 파일이 이 값을 읽게 바꿉니다.

TypeScript
// apps/web-next/lib/api.ts
import { cacheLife, cacheTag } from 'next/cache';
import type { Post } from './posts';
// 브라우저가 아니라 서버가 부른다. 서버에는 "지금 보고 있는 주소" 같은 게 없어서
// 상대 경로(/api/posts)로는 어디로 갈지 정할 수 없다 — 주소를 통째로 적는다.
// 그 주소를 어디서 받아오는지는 config.ts 한 곳에서 정한다.
import { API_BASE } from './config';
TypeScript
// apps/web-next/proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { API_BASE } from './lib/config';

두 파일 모두 const 선언이 사라지고 import 한 줄로 바뀌면서, 이제 주소가 코드에 한 번만 적혀 있는데, 그 한 번도 "못 받았을 때 쓸 값" 이지 진짜 주소가 아닙니다.

같은 이미지, 다른 값

이미지를 다시 만들고 이번엔 값을 주면서 띄웁니다.

Bash
docker build -f apps/web-next/Dockerfile -t insta-next:d10 .
docker run -d --name insta-d10 -p 3400:3000 \
  -e API_BASE=http://host.docker.internal:8090/api \
  insta-next:d10

-e가 그 값을 건네는 부분입니다.

텍스트
로그인 후 /          200  (0.42s)   피드 정상
로그인 후 /explore   200  (0.01s)
로그인 후 /jaehoon   200  (1.64s)
로그인 후 /zzzzz     404  (0.42s)
로그아웃  /jaehoon   307

전부 돌아왔습니다.

여기서 중요한 건 이미지를 안 바꿨다는 것인데, 방금 만든 그 이미지 하나로 값을 안 주면 안 되던 것이 값을 주니까 됐어요.

이 성질이 실무에서 크게 쓰이는데, 개발용·테스트용·진짜 서비스용 이미지를 따로 만들지 않고 하나를 만들어서 값만 바꿔가며 단계마다 올립니다. 테스트에서 확인한 것과 서비스에 올라간 것이 글자 하나까지 같은 물건이라는 뜻이니까요.

💡 한 줄 정리

실행되는 곳마다 달라지는 값은 코드에 적지 않고 밖에서 받는다. 그러면 이미지 하나로 환경마다 다르게 띄울 수 있고, 단계마다 똑같은 물건을 올릴 수 있다.

🙋 학생 질문 — "튜터님, 브라우저에서 쓰는 값도 이렇게 하면 되나요?"

여기가 갈리는 곳인데, 브라우저에서 읽는 값은 이름 앞에 NEXT_PUBLIC_을 붙여야 하고, 그 순간 성질이 달라집니다.

직접 재봤는데, 클라이언트 컴포넌트가 process.env.NEXT_PUBLIC_PROBE를 화면에 찍게 해두고 빌드할 때와 띄울 때 서로 다른 값을 줬습니다.

Bash
NEXT_PUBLIC_PROBE="빌드때값" npx next build
NEXT_PUBLIC_PROBE="실행때값" node .next/standalone/apps/web-next/server.js

화면에 뜬 것은 빌드때값이었는데, 띄울 때 준 값은 아무 데도 안 나타나요. 실제로 JS 파일 안을 찾아보면 빌드때값이라는 글자가 그대로 들어 있습니다.

브라우저가 받아가는 JS는 빌드할 때 이미 만들어져 파일로 굳어 있어서, 그때 값이 글자째로 박힙니다.

언제 정해지나 이미지 하나로 환경 여러 개
서버에서 읽는 값 (API_BASE) 띄울 때 된다
NEXT_PUBLIC_ 빌드할 때 안 된다

백엔드 주소 같은 건 되도록 서버에서만 읽게 두는 편이 좋은데, 우리 앱이 그렇게 돼 있고요. 그리고 하나 더, NEXT_PUBLIC_ 값은 JS 파일 안에 글자로 남아서 누구나 열어볼 수 있으니, 비밀번호나 열쇠 종류를 여기에 넣으면 안 됩니다.


Step 7: "앞 회차가 산 것이 여기서도 삽니까"

컨테이너가 잘 뜬다는 건 확인했는데, 우리가 지난 회차들에서 시간과 성능을 주고 산 것들이 있어서 그것들이 컨테이너 안에서도 살아 있는지는 따로 확인해야 합니다.

옮기는 작업은 조용히 무언가를 떨어뜨리기 쉬운데, 화면이 뜨니까 다 된 것 같아도 실은 하나가 죽어 있는 식이에요. Step 5에서 404가 500으로 바뀐 걸 이미 봤죠.

하나씩 재봅니다.

404

Bash
curl -s -o /dev/null -b 'me=minji' -w "%{http_code}\n" http://localhost:3400/zzzzz
텍스트
404

살아 있는데, 이건 D-7에서 첫 바이트를 2밀리초에서 405밀리초로 늦추면서까지 되찾은 것이었고, 컨테이너 안에서도 그 값을 그대로 치르고 그대로 얻습니다.

문지기

Bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3400/jaehoon
텍스트
307

쿠키 없이 남의 프로필을 열려고 하면 여전히 돌려보내집니다.

사진

Bash
curl -s -o /dev/null -b 'me=minji' \
  -w "%{http_code} %{content_type} %{size_download}\n" \
  "http://localhost:3400/_next/image?url=https%3A%2F%2Fpicsum.photos%2Fseed%2Fpost1%2F640%2F640&w=384&q=75"
텍스트
200 image/jpeg 24644

원본이 74KB 였던 사진이 24KB 로 줄어서 오는 건 sharp가 컨테이너 안에 함께 담겼기 때문이에요. Step 2에서 열두 개 목록에 그 이름이 있는 걸 봤죠. 저게 안 담겼으면 이 요청이 실패했을 겁니다.

글꼴

Bash
docker exec insta-d10 find /app -name "*.woff2" | head -3
텍스트
/app/apps/web-next/.next/static/media/e46336d0fe4d243f-s.1ihe76kx1l647.woff2
/app/apps/web-next/.next/static/media/2ea9328f7c652dae-s.2_zjfs_06xy5e.woff2
/app/apps/web-next/.next/static/media/5882eb9fe44d78e2-s.2zlo_5j6rno3q.woff2

글꼴 파일이 컨테이너 안에 들어 있는 건, 지난 시간에 글꼴을 우리 서버가 직접 나눠주도록 바꿔뒀기 때문이라 컨테이너는 인터넷이 끊겨도 글꼴을 줄 수 있어요.

주소마다 다른 제목

Bash
curl -s -b 'me=minji' http://localhost:3400/minji | grep -o '<title>[^<]*</title>'
텍스트
<title>@minji · 인스타그램 클론</title>

이것도 살아 있습니다.

전부 모아보면

어느 회차에서 산 것 컨테이너에서 확인한 방법
없는 사람에게 404 산다 /zzzzz → 404
로그인 안 하면 못 들어옴 산다 쿠키 없이 /jaehoon → 307
사진 크기 줄이기 산다 74KB → 24KB
우리 서버가 주는 글꼴 산다 컨테이너 안에 woff2
주소마다 다른 제목 산다 @minji · 인스타그램 클론

전부 무손상입니다. 다만 이건 당연한 게 아니라 확인한 결과예요. Step 5에서 봤듯이 설정 하나가 틀리면 이 표의 첫 줄이 조용히 죽어 있을 수 있습니다.

💡 한 줄 정리

옮기고 나면 앞에서 얻어둔 것들이 그대로인지 따로 확인한다. 화면이 뜨는 것과 전부 살아 있는 것은 다른 이야기다.

🙋 학생 질문 — "튜터님, 이걸 매번 손으로 확인하나요? 배포할 때마다요?"

손으로는 두어 번이 한계입니다. 그래서 실무에서는 이걸 자동으로 돌리는 목록으로 만들어둡니다.

"컨테이너를 띄우고, 이 주소들을 불러서, 이 상태 코드가 나오는지 확인한다"를 스크립트로 적어두는 거예요. 배포할 때마다 그게 먼저 돌고, 하나라도 어긋나면 배포를 멈춥니다.

방금 우리가 손으로 한 다섯 줄이 그대로 그 목록의 초안인데, curl로 주소를 부르고 상태 코드를 비교하는 것뿐이니까요.

이 방식은 한계가 있습니다. 그런데 화면이 제대로 나오는지 버튼을 누르면 실제로 좋아요가 올라가는지까지는 못 보는데, 그걸 다루는 방법이 다음 카테고리의 주제예요. 오늘은 "확인할 목록을 만들어두면 자동화할 수 있다"까지만 알아두면 충분합니다.


Step 8: "남이 대신 해주는 것과 그 대가"

오늘 우리가 한 일을 되짚어봅시다.

설정 한 줄을 넣고, 빠진 폴더를 복사하고, Dockerfile을 세 단계로 쓰고, 컨테이너를 띄우고, 주소를 밖으로 빼고, 다섯 가지를 다시 확인했는데, 대략 여기까지가 직접 굴린다는 것의 내용이에요.

이 과정을 통째로 안 해도 되는 방법도 있습니다. 그런데 Next.js를 만든 회사인 Vercel에 저장소를 연결하면 코드를 올릴 때마다 알아서 빌드하고 알아서 띄워줘서 설정 파일을 하나도 안 써도 돼요. 흔히 zero-config라고 부릅니다.

우리가 오늘 손으로 한 것들이 거기서는 이렇게 됩니다.

오늘 우리가 한 것 맡기면
output: 'standalone' 설정 안 쓴다
static 복사 전 세계에 뿌려주는 서비스가 대신 나눠준다
Dockerfile 3단계 안 쓴다
docker build · docker run 코드를 올리면 알아서
환경 변수 넣기 화면에서 입력한다

그런데 이건 마법이 아니었습니다

여기가 최근에 바뀐 부분이라 짚고 갑니다.

예전에는 Vercel이 Next 앱을 어떻게 빌드해서 어떻게 띄우는지가 밖에서 안 보였습니다. Next 안쪽에 문서화되지 않은 통로가 있었고 그 통로를 쓰는 코드가 공개돼 있지 않아서, "다른 곳에 올리면 어떤 기능이 안 될까"를 미리 알기 어려웠습니다.

Next 16부터 그 통로가 어댑터라는 이름으로 정식 공개되면서, 어떤 회사든 자기 서비스에 맞는 어댑터를 만들어 붙일 수 있고 Vercel 자신도 이제 그 공개된 통로를 쓰는 어댑터로 동작합니다.

공식 문서는 어댑터 중 검증된 것을 따로 표시하는데, 소스가 공개돼 있고 Next 팀이 만든 시험을 전부 통과했고 주요 버전이 나오기 전에 함께 시험을 돌린 것들이에요. 지금 이 목록에 있는 건 Vercel과 Bun 두 곳이고, Cloudflare와 Netlify는 작업 중이고요.

학생 입장에서 이게 왜 중요할까요. "어디에 올릴지"가 되돌리기 어려운 결정이 아니게 됐다는 뜻이라서인데, 예전에는 한 곳에 맞춰 만들면 옮길 때 상당 부분을 다시 손봐야 했습니다.

그래도 값은 있습니다

맡기면 편한 대신 내는 것이 있어요.

돈. 방문자가 늘면 청구서가 따라 느는데, 우리 컨테이너는 서버 한 대 값만 내면 그 위에서 얼마가 오든 같은 값이에요.

갈 수 있는 곳. 회사에 따라 데이터가 자기 나라 밖으로 나가면 안 되거나 아예 인터넷과 끊긴 곳에서만 돌려야 하는 경우가 있는데, 이럴 때는 선택지가 없어요.

옆에 있는 것들. 데이터베이스나 백엔드 API가 이미 회사 서버에 있으면 화면만 밖에 두는 게 오히려 복잡해지는데, 우리 백엔드가 그렇죠.

그래서 어떻게 고르나

🌟 개인 프로젝트·포트폴리오·빠르게 확인할 것 → 맡깁니다. 오늘 배운 걸 하나도 안 쓰고 5분이면 세상에 나가요.

회사 서비스·백엔드가 이미 어딘가에 있는 경우·규제가 있는 경우 → 컨테이너를 직접 굴립니다. 오늘 만든 것이 그 출발점이에요.

중요한 건 오늘 배운 것이 어느 쪽을 골라도 안 버려진다는 점인데, 맡기더라도 무슨 일이 대신 벌어지고 있는지 알게 됐으니까요. 배포가 실패했을 때 로그를 읽을 수 있는 사람과 못 읽는 사람의 차이가 거기서 갈립니다.

💡 한 줄 정리

맡기는 방식은 오늘 한 일을 대신 해주는 것이지 없애주는 것이 아니다. Next 16부터 그 과정이 어댑터로 공개돼서, 어디에 올릴지가 되돌리기 어려운 결정은 아니게 됐다.

🙋 학생 질문 — "튜터님, 그럼 실무에서는 보통 어느 쪽인가요?"

둘 다 씁니다. 그리고 한 회사 안에서도 갈려요.

제가 본 흔한 모양은 이런데, 마케팅용 소개 페이지나 문서 사이트는 방문자 수를 예측하기 쉽고 데이터가 민감하지 않으니까 맡기고, 반면 로그인해서 쓰는 본 서비스는 백엔드가 이미 회사 서버에 있고 그 옆에 두는 편이 여러모로 간단해서 컨테이너로 직접 굴립니다.

결정 시점도 다른데, 처음에는 사람이 몇 명 없는데 서버 관리에 시간을 쓰는 건 손해니까 맡기고 시작하고, 서비스가 커지고 청구서가 눈에 띄기 시작하면 그때 옮기는 걸 검토합니다.

그때 오늘 배운 것이 필요해집니다. 그리고 그 검토를 할 수 있는 상태로 두는 것 자체가 값이에요. 옮길 방법을 아예 모르면 청구서가 얼마가 나오든 그냥 내는 수밖에 없거든요.


마무리

오늘 배운 핵심 세 가지

💡 하나 — 실행에 필요한 것만 골라 담습니다. 우리가 설치한 564개 중 실제로 실행에 쓰이는 건 열두어 개였습니다. 684MB 가 50MB 가 됐고, 컨테이너로는 222MB 가 됐어요. 골라내는 기준은 import를 따라가는 것이고, 그래서 이름을 변수로 조립해 부르는 코드는 못 따라갑니다.

💡 둘 — 200은 오늘 두 번 더 거짓말을 했습니다. 한 번은 정적 파일이 안 담겨서 페이지만 200이고 그 안의 열한 개가 전부 404 일 때, 또 한 번은 컨테이너 안에서 백엔드에 못 닿아 피드가 영영 안 올 때요. 둘 다 응답 상태로는 멀쩡해 보였습니다. 하지만 만든 것은 띄워서 화면까지 봐야 합니다.

💡 셋 — 실행되는 곳마다 달라지는 값은 코드에 적지 않습니다. localhost는 옮기는 순간 다른 곳을 가리켰고, 게다가 두 파일에 따로 적혀 있어서 하나만 고치면 절반만 고쳐졌어요. 한 곳으로 모으고 밖에서 받게 하면, 이미지 하나로 환경마다 다르게 띄울 수 있습니다.

다음 시간 예고

여기까지가 Next.js입니다. 폴더로 주소를 만드는 것부터 시작해서, 서버와 브라우저의 경계를 긋고, 무엇을 굳히고 무엇을 흘려보낼지 정하고, 요청을 받고, 사진과 글꼴을 붙이고, 오늘 컨테이너에 담아 밖으로 내보냈어요.

우리가 지금까지 무언가를 고칠 때마다 무슨 일을 했는지 떠올려보면, 매번 손으로 확인했습니다. 화면을 열어보고, curl을 날려보고, 숫자를 세어봤죠. Step 7에서도 다섯 줄을 손으로 확인했고요.

앱이 커지면 이게 안 되는데, 좋아요 버튼 하나를 고쳤는데 그것 때문에 댓글 삭제가 망가졌는지를 매번 손으로 다 눌러볼 수는 없어요.

다음 시간부터는 그 확인을 코드로 적습니다. 우리 대신 앱을 눌러보고 결과를 비교해주는 코드를요.

무대도 잠깐 바뀝니다. 브라우저에서만 도는 앱으로 돌아가요. 다만 다음 시간에 우리가 처음 할 일은 그런 코드를 쓰는 게 아니라 이 저장소를 열어서 그런 코드가 이미 몇 개나 있는지 세어보는 것부터예요. 직접 쓰기 시작하는 건 그다음 시간이고요.


과제

[구현] static을 안 담고 컨테이너를 띄워보세요

Step 4의 Dockerfile에서 .next/static을 복사하는 줄을 지우고 이미지를 다시 만든 뒤, 띄워서 확인합니다.

  1. 페이지 응답 상태는 몇인가요.
  2. HTML 안의 /_next/static/... 주소들은 몇 개고 각각 몇 번을 돌려주나요.
  3. 브라우저로 열었을 때 화면이 어떻게 보이나요. 지난 시간에 붙인 글꼴은 어떻게 됐나요.

확인한 뒤 줄을 되돌리고 다시 만들어 정상으로 돌아오는 것까지 보세요.

이 과제의 목적은 일부러 깨뜨려서 증상을 외워두는 것인데, 나중에 실무에서 "화면이 벌거벗었다"를 만나면 3초 만에 원인을 떠올릴 수 있게요.

[구현] 주소를 한 군데만 고쳐보세요

lib/config.ts를 만들기 전 상태로 돌아가서, lib/api.ts 환경 변수를 읽게 고치세요. proxy.ts는 그대로 두고요.

그 상태로 이미지를 만들고 -e API_BASE=...를 주며 띄운 뒤, 아래 네 가지를 확인하세요.

요청 상태 코드
로그인 후 / ?
로그인 후 /jaehoon ?
로그인 후 /zzzzz ?
로그아웃 상태 /jaehoon ?

답해보세요. 피드 화면만 열어보고 "고쳤다" 고 결론 냈다면 무엇을 놓쳤을까요. 이런 종류의 절반짜리 수정을 배포 전에 잡으려면 무엇을 확인해야 할까요.

[탐구] NEXT_PUBLIC_ 값이 어디에 박히는지 직접 찾으세요

클라이언트 컴포넌트 하나를 골라 process.env.NEXT_PUBLIC_PROBE를 화면에 찍게 만든 뒤, 빌드할 때와 띄울 때 서로 다른 값을 주고 무엇이 화면에 뜨는지 확인합니다.

그다음이 진짜 과제인데, 빌드할 때 준 그 값이 어느 파일 안에 들어 있는지 찾아보세요.

Bash
grep -rl "빌드할때준값" apps/web-next/.next/static

찾았으면 그 파일이 브라우저에 어떻게 전달되는지, 그리고 방문자가 그것을 열어볼 수 있는지 생각해보세요.

확인한 뒤에는 찍는 코드를 지우고 원래대로 돌려놓습니다.

[탐구] 안 담을 것 목록을 비우면 어떻게 되나요

.dockerignore의 내용을 전부 지우고(파일은 남겨두고 빈 채로) 이미지를 다시 만드세요. 두 번 다 --no-cache --progress=plain을 붙이면 숫자가 잘 보입니다.

  1. transferring context로 출력되는 재료 크기가 얼마나 달라지나요.
  2. 빌드에 걸리는 시간은요.
  3. 완성된 이미지 크기는 달라졌나요.

3번이 이 과제의 본체인데, 달라졌다면 무엇이 늘었는지 컨테이너 안을 열어서 찾아내세요.

Bash
docker run --rm --entrypoint sh insta-next:비운쪽 -c 'du -sh /app/*'
docker run --rm --entrypoint sh insta-next:비운쪽 -c 'du -sh /app/node_modules/* | sort -h | tail -4'

가장 커진 폴더를 찾았으면 그 안을 한 번 더 열어보고, 거기 있는 것들의 이름을 소리 내어 읽어보세요. Step 1에서 이야기한 문제가 눈앞에 있을 겁니다.


생각해볼 주제

1. 빌드하는 기계는 백엔드를 몰라도 되는가

Step 4에서 우리 컨테이너 빌드는 백엔드 API 서버 없이 성공했는데, 앞선 회차들에서 데이터를 요청이 올 때 받아오도록 바꿔둔 결과였어요.

만약 반대로, 빌드할 때 API를 불러서 화면을 미리 만들어두는 구조라면 어떨까요. 빌드하는 기계가 백엔드에 닿을 수 있어야 하고, 그 순간 백엔드가 살아 있어야 합니다. 대신 방문자는 이미 만들어진 화면을 즉시 받아요.

두 방식은 무엇을 얻고 무엇을 잃으며, 우리 인스타그램 클론의 각 화면(피드·탐색·프로필)에는 어느 쪽이 맞을까요.

2. 이미지 하나를 여러 환경에 올린다는 원칙

Step 6에서 "이미지 하나를 만들어 값만 바꿔가며 단계마다 올린다" 고 했는데, 테스트에서 확인한 것과 서비스에 올라간 것이 같은 물건이어야 하니까요.

NEXT_PUBLIC_ 값은 빌드할 때 박히는데, 만약 테스트 환경과 서비스 환경에서 브라우저가 부를 주소가 서로 달라야 한다면 이 원칙이 깨집니다.

이 상황을 어떻게 풀 수 있을지, 애초에 브라우저가 백엔드 주소를 알아야 하는 상황을 안 만들 수는 없을지 생각해보고, 지금 우리 앱이 그 주소를 브라우저에 안 넘기고 있는 이유를 떠올려보세요.

3. 되돌릴 수 있는 결정과 되돌리기 어려운 결정

Step 8에서 Next 16의 어댑터 이야기를 했는데, 예전에는 한 서비스에 맞춰 만들면 옮길 때 상당 부분을 다시 손봐야 했지만 이제는 그 통로가 공개돼서 옮기기가 전보다 수월해졌어요.

이걸 조금 더 넓혀 생각해보면, 개발하면서 내리는 결정 중에는 나중에 쉽게 바꿀 수 있는 것과 한 번 정하면 되돌리는 데 몇 달이 걸리는 것이 섞여 있습니다.

지금까지 이 과목에서 우리가 내린 결정들을 떠올려보고, 어느 것이 쉽게 되돌릴 수 있는 결정이었고 어느 것이 그렇지 않았는지, 되돌리기 어려운 결정 앞에서는 무엇을 다르게 해야 할지 생각해보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] static을 안 담고 컨테이너를 띄워보세요

채점 포인트

항목 배점 확인
페이지 상태 코드를 정확히 적었는가 5 200
정적 파일 개수와 상태 코드를 셌는가 7 11개 전부 404
파일 종류를 갈라서 봤는가 4 JS 9 · CSS 1 · 글꼴 1
글꼴이 사라진 것을 확인했는가 4 지난 시간에 붙인 그 글꼴이다
데이터는 왔다는 것을 알아챘는가 5 여기가 이 과제의 핵심
합계 25

풀이 예시

Dockerfile의 마지막 COPY 한 줄을 지웁니다.

dockerfile
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /repo/apps/web-next/.next/standalone ./
EXPOSE 3000
CMD ["node", "apps/web-next/server.js"]

다시 만들어 띄우고 확인합니다.

Bash
docker build -f apps/web-next/Dockerfile -t insta-next:hw1 .
docker run -d --name insta-hw1 -p 3401:3000 \
  -e API_BASE=http://host.docker.internal:8090/api insta-next:hw1
  1. 페이지 상태 코드는 200인데, 본문 크기도 25,499바이트로 정상일 때와 똑같아요.

  2. 정적 파일은 11개고 전부 404입니다.

텍스트
정적 파일 11개  {'404': 11}   종류: js 9 · css 1 · woff2 1
  1. 화면은 글자만 세로로 쌓여 있고 배경색도 여백도 없고 하트 버튼은 눌러도 반응이 없는데, 지난 시간에 붙인 글꼴도 사라졌습니다. 글꼴 파일이 static 안에 들어 있었거든요.

이미지 크기는 222MB 에서 218MB 로 4MB 줄었는데, static 층이 4.24MB 였으니 계산이 맞아요.

여기가 진짜 포인트입니다

화면에 글자가 안 보이는 게 아니라, 데이터는 다 왔어요.

Bash
curl -s -b 'me=minji' http://localhost:3401/ | grep -c "한강 노을"
텍스트
1

게시물 내용이 HTML 안에 그대로 있는데, 서버는 자기 할 일을 다 해서 백엔드에서 데이터를 받아 화면을 그려서 보냈습니다.

없는 것은 그 화면을 예쁘게 만드는 파일들뿐이라서, 증상이 이렇게 갈려요.

증상 원인 오늘 어느 Step
글자는 있는데 스타일이 없다 정적 파일이 안 담겼다 Step 3
스타일은 멀쩡한데 피드가 영영 안 온다 백엔드에 못 닿는다 Step 5

둘 다 응답은 200인데, 화면을 한 번이라도 눈으로 보면 두 증상이 완전히 다르게 생겼다는 걸 알 수 있습니다. 상태 코드만 보면 구분이 안 됩니다.

자주 나오는 실수

"데이터를 못 가져왔나 보다"로 결론 내는 것. 화면이 허전하니까 그렇게 짐작하기 쉬운데, 소스 보기를 하면 게시물 내용이 다 있어요. 방향을 잘못 잡으면 백엔드를 몇 시간 뒤지게 됩니다.

개발자 도구를 안 여는 것. 404 열한 개가 빨갛게 줄줄이 찍혀 있는데, 화면만 보면 그걸 못 봅니다.

💡 튜터의 한마디

일부러 깨뜨려보는 건 시간 낭비처럼 느껴지지만 제일 값싼 학습인데, 실무에서 이 증상을 처음 만나면 원인을 찾는 데 반나절이 걸려요. 한 번 일부러 만들어보면 다음엔 화면을 보는 순간 압니다. 증상을 외워두면 그게 곧 진단 속도입니다.

🎯 [과제 2 예시답안] 주소를 한 군데만 고쳐보세요

채점 포인트

항목 배점 확인
네 가지 상태 코드를 정확히 적었는가 8 200 / 500 / 500 / 307
피드 화면이 정상이라는 것을 확인했는가 4 여기가 함정이다
무엇을 놓쳤는지 답했는가 6 프로필과 404
로그아웃이 307 인 이유를 설명했는가 3 검사 순서
배포 전 확인 방법을 제안했는가 4 주소 목록 확인
합계 25

풀이 예시

lib/api.ts만 고치고 proxy.ts는 옛날 그대로 둡니다.

TypeScript
// apps/web-next/proxy.ts — 안 고친 상태
const API_BASE = 'http://localhost:8090/api';

이미지를 만들고 값을 주며 띄운 결과입니다.

요청 상태 코드
로그인 후 / 200 (피드 정상)
로그인 후 /explore 200
로그인 후 /jaehoon 500
로그인 후 /zzzzz 500
로그아웃 상태 /jaehoon 307

컨테이너 로그에는 연결 거부가 두 건 찍혀 있습니다.

피드만 열어봤다면 무엇을 놓쳤을까요

첫 화면이 완벽하게 동작하는데, 게시물이 다 뜨고 하트도 있고 좋아요 숫자도 맞아요. Step 5에서 봤던 "불러오는 중…"이 사라졌습니다.

그런데 사람 이름을 누르는 순간 500이 뜨고, 없는 사람을 찾을 때 나와야 할 404도 500이 됐는데, D-7에서 되찾은 404가 다시 죽어 있는 상태입니다.

원인은 간단한데, 화면을 그리는 쪽(api.ts)과 문 앞에서 검사하는 쪽(proxy.ts)이 각자 주소를 들고 있다가 한쪽만 고쳤기 때문이에요.

이게 위험한 이유는 가장 자주 열어보는 화면이 가장 먼저 고쳐지기 때문인데, 확인하는 순서가 보통 홈부터거든요. 홈이 되면 "됐다" 하고 넘어가기 딱 좋습니다.

로그아웃은 왜 307 일까요

문지기가 검사를 두 단계로 하는데 순서가 이렇습니다.

텍스트
 요청 도착
   
 쿠키가 있나           네트워크를 안 탄다
    없으면 307 로 끝
 이 사람이 실제로 있나   여기서 백엔드에 물어본다

로그아웃한 요청은 첫 단계에서 끝나서 백엔드에 물어볼 일이 없다 보니, 백엔드가 죽어 있어도 영향을 안 받아요.

배포 전에 어떻게 잡을까요

주소를 하나만 확인하지 않는 것입니다. 최소한 이 네 가지는 봐야 해요.

확인할 주소 기대 무엇을 지키나
/ 200 + 게시물 있음 데이터 경로
/jaehoon 200 문지기의 백엔드 경로
/zzzzz 404 없는 사람 처리
쿠키 없이 /jaehoon 307 로그인 검사

Step 7에서 손으로 확인한 그 목록입니다. 이걸 배포할 때마다 돌리면 절반짜리 수정은 못 지나갑니다.

자주 나오는 실수

grep을 안 돌려보고 고치는 것. 같은 값이 몇 군데 적혀 있는지부터 세면 이 과제 자체가 안 생기는데, 값을 하나로 모으기 전에 몇 개인지부터 세는 습관이 진짜 해법이에요.

💡 튜터의 한마디

같은 값이 여러 곳에 적혀 있는 것을 중복이라고 부르는데, 진짜 문제는 글자가 겹치는 게 아니라 고칠 때 하나를 빠뜨릴 수 있다는 것이에요. 빠뜨렸다는 사실은 가장 안 보는 화면에서만 드러나는데, 값을 한 곳에 모으는 이유가 여기 있습니다.

🎯 [과제 3 예시답안] NEXT_PUBLIC_ 값이 어디에 박히는지 직접 찾으세요

채점 포인트

항목 배점 확인
화면에 뜬 값을 정확히 확인했는가 5 빌드할 때 준 값
그 값이 든 파일을 찾았는가 7 .next/static/chunks/*.js
미리 그려둔 HTML에도 있는 것을 봤는가 4 두 곳 다
방문자가 열어볼 수 있음을 설명했는가 5 브라우저가 받아가는 파일이다
서버 값과의 차이를 정리했는가 4 정해지는 시점이 다르다
합계 25

풀이 예시

클라이언트 컴포넌트에 한 줄을 심습니다.

tsx
const probe = process.env.NEXT_PUBLIC_PROBE ?? "(없음)";
// ...
<span data-probe>{probe}</span>

서로 다른 값을 주고 빌드와 실행을 갈라서 합니다.

Bash
NEXT_PUBLIC_PROBE="빌드때값" npx next build
NEXT_PUBLIC_PROBE="실행때값" PORT=3500 node .next/standalone/apps/web-next/server.js

화면에 뜬 것은 빌드때값인데, 띄울 때 준 값은 어디에도 안 나타나요.

어느 파일에 들어 있나

Bash
grep -rl "빌드때값" apps/web-next/.next
텍스트
apps/web-next/.next/static/chunks/20i6joh7skygl.js
apps/web-next/.next/standalone/.../server/app/index.html
apps/web-next/.next/standalone/.../server/app/explore.html

두 종류인데, 브라우저가 받아가는 JS 파일 안에 글자로 들어 있고 미리 그려둔 HTML에도 들어 있어요.

반대로 실행때값을 찾으면 0건인데, 실행할 때 준 값은 아예 쓰이지 않았습니다.

방문자가 열어볼 수 있습니다

static/chunks/*.js는 브라우저가 받아가는 파일이라 우리 사이트를 여는 사람 누구나 그 파일을 받는데, 개발자 도구의 소스 탭에서 열면 그 안의 글자가 그대로 보입니다.

이런 것들은 여기 넣으면 안 됩니다.

  • API 열쇠·비밀번호 종류
  • 관리자용 주소
  • 내부에서만 쓰는 서버 주소

이름에 PUBLIC이 들어가 있는 게 경고인데, 공개된다는 뜻이에요.

서버 값과 나란히 놓으면

값이 정해지는 때 이미지 하나로 환경 여러 개 방문자가 볼 수 있나
서버에서 읽는 값 (API_BASE) 띄울 때 된다 못 본다
NEXT_PUBLIC_ 빌드할 때 안 된다 본다

우리 앱이 백엔드 주소를 서버에서만 읽게 해둔 이유가 두 칸 다입니다. 환경마다 바꿀 수 있고, 밖으로도 안 새요.

자주 나오는 실수

띄울 때 값을 안 준 채로 실험하는 것. 빌드 때 값이 뜨는 게 당연해 보여서 아무것도 못 배우는데, 서로 다른 두 값을 줘야 어느 쪽이 이기는지 보여요.

💡 튜터의 한마디

"환경 변수니까 실행할 때 정해지겠지"라는 짐작이 여기서 깨지는데, 브라우저에서 도는 코드는 빌드가 끝나는 순간 파일로 굳어버리니까요. 어디서 도는 코드인지가 그 값이 언제 정해지는지를 결정하는 것이고, 이 과목에서 서버와 브라우저의 경계를 계속 이야기해온 이유가 여기서도 이어집니다.

🎯 [과제 4 예시답안] 안 담을 것 목록을 비우면 어떻게 되나요

채점 포인트

항목 배점 확인
재료 크기 두 값을 적었는가 5 수십 kB ↔ 수백 MB
빌드 시간 차이를 쟀는가 3 늘어난다
이미지가 커졌다는 것을 확인했는가 5 예상과 반대다
커진 폴더를 찾아냈는가 7 node_modules/@img
그 안의 이름이 뜻하는 것을 알아챘는가 5 다른 운영체제용 파일이다
합계 25

풀이 예시

Bash
docker build --no-cache --progress=plain -f apps/web-next/Dockerfile -t insta-next:A .
: > .dockerignore
docker build --no-cache --progress=plain -f apps/web-next/Dockerfile -t insta-next:B .
목록 있음 목록 비움
transferring context 31.32 kB 748.15 MB
빌드 시간 38.62초 49.44초
이미지 크기 222MB 249MB
  1. 재료 크기가 약 24,000배 차이나는데, 31킬로바이트와 748메가바이트인 셈입니다. 목록이 있으면 도커에게 넘기는 것이 소스 파일 몇십 개뿐이지만, 없으면 node_modules.next.git이 통째로 넘어갑니다.

  2. 시간은 11초 늘었는데, 재료를 넘기는 데만 9.2초가 걸렸어요.

  3. 이미지가 27MB 커졌습니다.

여기서 멈추면 안 되는데, "재료가 컸으니 결과도 컸겠지"는 틀린 설명이에요. 3단계는 새 공간에서 시작하고 우리가 손으로 꺼내 온 것만 담기니까요. 그런데도 커졌습니다.

무엇이 커졌나

Bash
docker run --rm --entrypoint sh insta-next:B -c 'du -sh /app/*'
텍스트
6.9M    /app/apps
80.9M   /app/node_modules       목록이 있을 때는 54.9M 이었다

한 번 더 들어갑니다.

Bash
docker run --rm --entrypoint sh insta-next:B -c 'du -sh /app/node_modules/* | sort -h | tail -2'
텍스트
17.1M   /app/node_modules/next
61.3M   /app/node_modules/@img     목록이 있을 때는 35.3M

@img 안을 열어봅니다.

목록 있음 목록 비움
sharp-libvips-linux-arm64 왼쪽 전부 +
sharp-libvips-linuxmusl-arm64 sharp-darwin-arm64
sharp-linux-arm64 sharp-libvips-darwin-arm64
sharp-linuxmusl-arm64 sharp-wasm32

darwin은 macOS를 가리키는 이름입니다.

우리 컨테이너는 리눅스인데, 그 안에 macOS 전용 파일이 26MB 들어앉았습니다. 그 컨테이너에서 영원히 한 번도 안 돌아갈 파일이요.

왜 들어갔나

목록을 비우니까 COPY . .가 우리 노트북의 node_modules를 통째로 빌더 단계에 넣었는데, 그 안에는 우리 맥에 맞춰 설치된 것들이 있죠.

그다음 next build가 "실행에 필요한 것"을 골라낼 때 눈앞에 있는 그 폴더를 훑는데, macOS 용 파일도 거기 있으니 필요한 것으로 집어서 standalone에 담았고, 3단계가 그 standalone을 그대로 가져왔습니다.

Step 1에서 이런 이야기를 했었는데, "제 맥에서 만든 폴더를 리눅스 서버에 복사하면 어떤 꾸러미는 그대로 안 돌아갑니다." 그 일이 실제로 벌어진 겁니다.

이번에는 운이 좋아서, 리눅스용 파일도 함께 들어가 있어 앱은 정상으로 뜹니다. 하지만 리눅스용이 빠지고 macOS 용만 들어간 상황이었다면 컨테이너가 켜지자마자 죽었을 거예요.

자주 나오는 실수

"멀티스테이지니까 안 늘어난다"로 결론 내는 것. 저도 처음엔 그렇게 예상했는데, 3단계가 새 공간에서 시작하는 건 맞아요. 하지만 2단계가 무엇을 보고 골라냈느냐가 3단계에 그대로 전달되다 보니, 재료가 오염되면 결과도 오염돼요.

세 숫자만 적고 끝내는 것. 크기와 시간은 눈에 띄지만 정작 중요한 건 세 번째 숫자의 정체입니다.

💡 튜터의 한마디

안 담을 것 목록은 보통 "빌드를 빠르게 하는 설정"으로 설명되지만, 방금 본 건 속도가 아니라 정확성이에요. 무엇을 재료로 줄지 정하는 일이 곧 무엇이 결과물에 들어갈지를 정하는 일인데, 컨테이너로 배포하는 이유가 "어디서든 똑같이 돈다"인 이상, 목록 하나를 안 두면 그 약속의 절반이 조용히 깨집니다.


🤔 [생각해볼 주제 1] 빌드하는 기계는 백엔드를 몰라도 되는가

문제 상황 요약

Step 4에서 컨테이너 빌드가 백엔드 API 서버 없이 성공했는데, 컨테이너 안에서는 우리 노트북의 8090번에 닿을 방법이 없는데도요.

반대로 빌드할 때 API를 불러 화면을 미리 만들어두는 구조라면, 빌드하는 기계가 백엔드에 닿아야 하고 그 순간 백엔드가 살아 있어야 합니다.

튜터의 가이드 및 해설

두 방식이 무엇을 바꾸는지부터 정리해봅시다.

요청이 올 때 받아온다 빌드할 때 미리 만든다
방문자가 보는 속도 기다림이 있다 즉시
데이터 신선도 항상 최신 빌드 시점에 멈춰 있다
빌드 기계가 백엔드에 닿아야 하나 아니오
백엔드가 죽으면 화면도 못 뜬다 옛 데이터로 계속 뜬다

마지막 두 줄이 배포 관점에서 중요합니다.

빌드 기계가 백엔드를 몰라도 된다는 것의 값. 빌드는 보통 우리 서비스와 다른 곳에서, 코드를 올리면 자동으로 돌아가는 기계에서 도는데, 그 기계가 우리 데이터베이스나 API에 닿을 수 있어야 한다면 그 기계에 접속 권한과 열쇠를 줘야 합니다. 닿을 필요가 없으면 줄 것도 없는데, 보안 면에서 이게 꽤 큽니다.

거꾸로 백엔드가 잠깐 죽어 있으면 배포 자체가 실패한다는 문제도 있는데, 급하게 고쳐서 배포해야 하는데 백엔드 장애 때문에 못 나가는 상황이 생겨요.

그럼 미리 만드는 게 나쁜가요? 아닙니다. 안 바뀌는 것에는 그쪽이 훨씬 좋아요.

우리 앱의 화면을 하나씩 봅시다.

화면 데이터 성격 맞는 쪽
피드 계속 바뀐다. 방금 올라온 게시물이 보여야 한다 요청이 올 때
프로필 머리 팔로워 수. 초 단위로 안 바뀐다 굳혀두고 가끔 갱신
탐색 지금은 고정 목록 미리 만들어도 된다

우리가 실제로 그렇게 해뒀는데, 피드는 흘려보내고 프로필 집계는 굳혔고 탐색은 정적입니다. 한 앱 안에서 화면마다 다르게 고를 수 있다는 것이 요즘 방식의 핵심이에요.

한 줄로 정리하면 이렇습니다. 빌드 시점에 확정되는 것이 많을수록 방문자는 빨라지고 배포는 까다로워지는데, 그 둘의 균형을 화면 단위로 고르는 것이지 앱 전체를 한쪽으로 몰 이유가 없어요.

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

"빌드 시점에 데이터를 확정하면 방문자는 빨라지지만, 빌드 기계에 백엔드 접속 권한을 줘야 하고 백엔드 장애가 배포 장애가 됩니다. 저는 화면 단위로 갈랐어요. 피드는 요청 때 받아오고 팔로워 집계는 굳혔습니다. 그 결과 빌드 파이프라인이 API를 전혀 몰라도 돼서, 컨테이너 이미지를 백엔드와 완전히 분리해서 만들 수 있었습니다."

🤔 [생각해볼 주제 2] 이미지 하나를 여러 환경에 올린다는 원칙

문제 상황 요약

Step 6에서 이미지 하나를 만들어 값만 바꿔가며 단계마다 올린다고 했는데, NEXT_PUBLIC_ 값은 빌드할 때 박히죠. 테스트 환경과 서비스 환경에서 브라우저가 부를 주소가 달라야 한다면 이 원칙이 깨집니다.

튜터의 가이드 및 해설

먼저 원칙이 왜 중요한지 짚고 갑시다.

테스트 환경에서 확인을 마치고 서비스에 올릴 때 그 둘이 다른 물건이면 확인한 의미가 줄어드는데, "테스트에서는 됐는데요"라는 말이 나오는 대부분의 상황이 여기서 시작해요. 같은 이미지를 그대로 올리면 적어도 코드가 다를 가능성은 0이 됩니다.

NEXT_PUBLIC_은 이 원칙과 정면으로 부딪힙니다. 환경마다 값이 달라야 하면 환경마다 다른 이미지를 만들어야 하니까요.

풀 수 있는 방법이 몇 가지 있습니다.

첫째, 환경마다 빌드를 따로 합니다. 원칙을 포기하는 대신 가장 단순한데, 테스트에서 확인한 것과 다른 물건이 나갑니다.

둘째, 값을 서버에서 읽어 화면에 실어 보냅니다. 서버 컴포넌트가 값을 읽어서 클라이언트 컴포넌트에 넘기면 그 값은 실행할 때 정해지는데, 우리가 이 과목에서 계속 다뤄온 경계 넘기기예요.

셋째, 컨테이너를 띄울 때 파일을 고칩니다. 시작 스크립트가 JS 파일 안의 자리표시 글자를 진짜 값으로 바꿔치기하는 방식인데, 실무에서 꽤 쓰이지만 손이 많이 가고 다루기 까다롭습니다.

그런데 더 나은 질문이 있습니다. 브라우저가 백엔드 주소를 알아야 하는 상황을 안 만들 수는 없을까요.

우리 앱이 지금 그렇게 돼 있어서, 브라우저는 백엔드 주소를 모르는데, 백엔드에 물어보는 일을 전부 서버가 대신 하니까요. 화면을 그릴 때는 서버 컴포넌트가 받아오고, 무언가 바꿀 때는 서버로 요청이 갔다가 결과만 돌아옵니다.

그래서 우리에게는 이 문제 자체가 없어요. NEXT_PUBLIC_ 값이 하나도 없습니다.

이건 우연이 아니라 구조의 결과입니다. 그리고 덤이 따라와요.

브라우저가 백엔드 주소를 모르면
환경마다 이미지를 따로 안 만들어도 된다
백엔드 주소가 밖으로 안 샌다
백엔드를 인터넷에 열어두지 않아도 된다

세 번째가 특히 큰데, 브라우저가 직접 백엔드를 부르는 구조면 백엔드가 인터넷에서 닿을 수 있어야 하죠. 우리 구조에서는 백엔드가 우리 서버에서만 닿으면 됩니다.

정리하면 이렇습니다. NEXT_PUBLIC_을 어떻게 다룰지 고민하는 것보다 그것이 필요 없는 구조인지를 먼저 보는 편이 나은데, 필요한 경우가 분명히 있지만(지도 서비스 열쇠처럼 브라우저가 직접 써야 하는 것) 백엔드 주소는 대개 그 경우가 아니에요.

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

"브라우저에 노출되는 환경 변수는 빌드 시점에 코드로 굳어서, 한 이미지를 여러 환경에 올린다는 원칙을 깨뜨립니다. 저는 그걸 우회하는 대신 애초에 필요 없게 만들었어요. 백엔드 호출을 전부 서버가 대신하게 해서 브라우저는 백엔드 주소를 모릅니다. 결과적으로 이미지 하나로 모든 환경에 올릴 수 있고, 백엔드를 인터넷에 열어둘 필요도 없어졌습니다."

🤔 [생각해볼 주제 3] 되돌릴 수 있는 결정과 되돌리기 어려운 결정

문제 상황 요약

Step 8에서 Next 16의 어댑터 이야기를 했는데, 예전에는 한 서비스에 맞춰 만들면 옮길 때 상당 부분을 다시 손봐야 했지만 그 통로가 공개되면서 옮기기가 전보다 수월해졌어요.

개발하면서 내리는 결정 중에는 쉽게 바꿀 수 있는 것과, 한 번 정하면 되돌리는 데 몇 달이 걸리는 것이 섞여 있습니다.

튜터의 가이드 및 해설

갈리는 기준은 "얼마나 많은 코드가 그 결정을 알고 있느냐"입니다.

이 과목에서 내린 결정들을 그 기준으로 세워봅시다.

결정 되돌리기
색·여백 같은 화면 값 쉽다 한 곳만 고치면 전부 따라온다
어떤 컴포넌트 묶음을 쓸까 중간 파일을 우리가 들고 있어서 고칠 수 있다
서버 상태를 다루는 도구 어렵다 데이터를 부르는 모든 곳이 그 모양을 안다
브라우저에서만 돌지, 서버도 쓸지 매우 어렵다 코드 전체의 구조가 달라진다
어디에 배포할까 전보다 쉬워졌다 통로가 공개돼서

가장 아래 두 줄이 흥미롭습니다.

"어디에 배포할까"는 원래 가장 되돌리기 어려운 쪽에 있었는데, 한 서비스에만 있는 기능에 기대어 만들면 옮길 때 그 부분을 전부 다시 짜야 했으니까요. 그게 어댑터 공개로 조금 내려왔는데, 기술이 결정의 무게를 바꾼 사례입니다.

반면 "서버도 쓸지"는 여전히 무겁습니다. 우리가 이 과목에서 브라우저에서만 도는 앱을 만들다가 서버까지 쓰는 앱으로 넘어올 때, 화면 하나가 아니라 데이터를 가져오는 방식 전체가 바뀌었죠.

그래서 무엇을 다르게 해야 할까요.

첫째, 무거운 결정 앞에서만 시간을 씁니다. 모든 결정을 신중하게 하려 들면 아무것도 못 만드는데, 색을 고르는 데 회의를 하고 구조를 고르는 데 30분 쓰는 것이 흔한 실수입니다. 가벼운 결정은 빠르게 하고 틀리면 고치는 게 맞습니다.

둘째, 무거운 결정은 되돌릴 여지를 남깁니다. 오늘 한 일이 좋은 예인데, 백엔드 주소를 한 곳으로 모아뒀으니 주소가 바뀌어도 한 파일만 봅니다. 신원을 확인하는 일도 한 함수에 모아뒀는데, 결정 자체를 미룰 수는 없어도 그 결정을 아는 코드의 개수는 줄일 수 있습니다.

셋째, 결정을 아예 안 하고 미루는 것도 방법입니다. 배포 방식이 그런데, 처음에는 맡기고 시작해도 됩니다. 나중에 직접 굴려야 할 때 옮기면 되고, 그 옮기는 비용이 예전보다 낮아졌으니까요.

한 줄로 정리하면 이렇습니다. 결정의 무게를 먼저 재고, 무거운 것에만 시간을 쓰고, 무거운 것은 아는 코드를 최소로 줄여둡니다.

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

"저는 결정을 그것을 아는 코드가 몇 곳이냐로 나눕니다. 색상 값은 한 곳이라 언제든 바꾸고, 서버를 쓸지 말지는 코드 전체가 알기 때문에 신중하게 정합니다. 그리고 무거운 결정일수록 아는 코드를 줄여두려고 해요. 백엔드 주소를 두 파일에서 한 파일로 모은 것도 그 작업이었고, 덕분에 배포 환경이 바뀌어도 고칠 곳이 한 줄입니다."

전체 목록 리액트