바이브코딩으로 Next.js 앱 만들었는데, AWS Lightsail 배포까지 해버린 이야기 (초보자 삽질기)

TL;DR
AI한테 바이브코딩으로 Next.js 앱을 뚝딱 만들었는데, 진짜 전쟁은 배포할 때 시작됐다. Tailwind + shadcn/ui 빌드 에러, Prisma 타입 에러, GitHub 세팅, AWS Lightsail에 SCP로 수동 배포, Apache 리버스 프록시, Google OAuth까지 — 하루 만에 겪은 삽질을 초보자 눈높이에서 정리했다. 코드 한 줄 안 써본 사람도 이해할 수 있게 비유 듬뿍 넣었으니 편하게 읽어봐.


오늘의 미션: “로컬에서만 돌아가는 앱”을 세상에 공개하기

바이브코딩으로 앱을 만드는 건 정말 재밌다. Claude한테 “이런 기능 만들어줘”라고 말하면 뚝딱 코드가 나오니까. 근데 문제는 그 앱이 내 컴퓨터에서만 돌아간다는 거다. 친구한테 “야 이거 봐봐” 하려면 결국 서버에 올려야 하는데 — 여기서부터 진짜 모험이 시작된다.

오늘은 Next.js 프로젝트를 AWS Lightsail에 배포하면서 만난 온갖 에러와 삽질을 기록해보려고 한다. Lightsail 배포 초보자라면, 이 글 하나로 전체 흐름을 잡을 수 있을 거다.


Tailwind CSS + shadcn/ui 빌드 에러: “border-border가 없다고?”

배포의 첫 단계는 npm run build로 프로덕션 빌드를 만드는 건데, 여기서 바로 첫 번째 벽에 부딪혔다.

Error: The `border-border` class does not exist.

뭔 소린가 싶었는데, 원인을 알고 나면 꽤 단순하다.

왜 이런 에러가 나냐면

shadcn/ui 같은 UI 라이브러리는 색상을 하드코딩하지 않는다. 대신 CSS 변수라는 걸 쓰는데, 이걸 색상 이름표라고 생각하면 된다.

비유하자면 이렇다:
– 일반 방식: “이 테두리는 회색(#d1d5db)으로 칠해!” (색을 직접 지정)
– CSS 변수 방식: “이 테두리는 --border라는 이름표에 적힌 색으로 칠해!” (이름표만 참조)

다크 모드에서는 이름표에 적힌 색만 바꿔주면 전체 테마가 한 번에 바뀌니까 훨씬 유연한 거다. 하드코딩했다면 수십 개 파일을 일일이 수정해야 했을 텐데, CSS 변수 방식이면 .dark 클래스 하나만 토글하면 끝이다.

근데 Tailwind CSS가 border-border라는 클래스를 만들려면, tailwind.config.ts에 “이 이름표는 이 CSS 변수를 가리킨다”는 연결 고리가 있어야 한다. 이게 빠져 있으면 Tailwind 입장에서는 “border-border? 그런 색 나 모르는데?” 하고 에러를 뱉는 거다.

해결 방법

tailwind.config.tstheme.extend.colors에 CSS 변수 매핑을 추가해주면 된다:

// tailwind.config.ts
theme: {
  extend: {
    colors: {
      border: "hsl(var(--border))",
      input: "hsl(var(--input))",
      ring: "hsl(var(--ring))",
      background: "hsl(var(--background))",
      foreground: "hsl(var(--foreground))",
      primary: {
        DEFAULT: "hsl(var(--primary))",
        foreground: "hsl(var(--primary-foreground))",
      },
      // ... 나머지 shadcn/ui 색상들
    },
  },
},

그리고 globals.css에도 실제 색상값이 들어있는 CSS 변수가 정의되어 있어야 한다:

@layer base {
  :root {
    --border: 214.3 31.8% 91.4%;
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    /* ... */
  }
  .dark {
    --border: 217.2 32.6% 17.5%;
    /* 다크모드용 색상 */
  }
}

바이브코딩할 때 AI가 shadcn/ui 컴포넌트를 자동으로 설치해줬는데, 이 설정 파일 연결을 깜빡한 거였다. npx shadcn-ui@latest init을 처음에 제대로 돌렸다면 자동으로 세팅됐을 텐데, 수동으로 컴포넌트를 붙이다 보니 빠진 것. 바이브코딩할 때 자주 생기는 함정이니까 빌드 전에 꼭 확인하자.


Prisma $transaction 타입 추론: “TypeScript야, 네가 알아서 해”

두 번째 빌드 에러는 Prisma에서 터졌다.

// 이렇게 쓰면 에러 난다
prisma.$transaction(async (tx: typeof prisma) => {
  await tx.user.create({ ... })
})

에러 메시지가 Overload matches라면서 타입이 안 맞는다는 건데, 처음 보면 진짜 외계어 같다.

비유로 이해하기

이걸 택배 비유로 설명하면:
– Prisma가 $transaction이라는 특수 작업방에 “트랜잭션 전용 직원(TransactionClient)”을 보내준다
– 그런데 우리가 “아니 정규직원(typeof prisma)이 올 거야!” 라고 미리 선언해버린 거다
– 실제로 온 건 계약직 직원(TransactionClient)인데, 정규직 명찰을 달라고 하니까 시스템이 “이 사람 맞아?” 하면서 에러를 뱉은 것

정규직(PrismaClient)과 트랜잭션 전용 직원(TransactionClient)은 같은 회사 소속이지만 할 수 있는 업무 범위가 다르다. 트랜잭션 클라이언트는 $connect, $disconnect, $transaction 같은 연결 관리 메서드가 빠져있다. 당연히 작업방 안에서 또 작업방을 만들 필요는 없으니까.

해결 방법: 그냥 타입을 안 쓰면 된다

// 이렇게 타입을 빼면 TypeScript가 알아서 추론한다
prisma.$transaction(async (tx) => {
  const user = await tx.user.create({ data: userData })
  const account = await tx.account.create({
    data: { userId: user.id, telegram_id: userData.telegram_id }
  })
  return { user, account }
})

TypeScript는 생각보다 똑똑하다. 프레임워크가 콜백 함수로 넘겨주는 인자의 타입은 컴파일러의 추론에 맡기는 게 베스트 프랙티스다. 특히 Prisma처럼 내부 타입이 복잡한 라이브러리는 더더욱.

바이브코딩할 때 AI가 타입을 과도하게 명시하는 경우가 있는데, “프레임워크 콜백의 인자 타입은 추론에 맡겨줘”라고 한 마디 해주면 된다.


GitHub 초기화: .gitignore가 왜 중요한지 모르면 큰일 난다

빌드 에러를 다 잡고 나면 이제 Git을 세팅할 차례다.

Git이랑 GitHub, 뭐가 다른 건데?

이 둘을 헷갈려하는 사람이 정말 많다:

  • Git = 내 코드의 타임머신. 파일을 언제, 뭘 바꿨는지 기록해주는 프로그램. 내 컴퓨터에서 돌아간다.
  • GitHub = 그 타임머신 기록을 클라우드에 백업해주는 온라인 저장소. 인터넷 서비스다.

쉽게 말해, Git이 일기장이면 GitHub는 일기장을 보관해주는 구글 드라이브 같은 거다.

.gitignore = “이건 절대 올리지 마” 목록

.gitignore 파일은 GitHub에 올리면 안 되는 파일 목록이다. 왜 중요하냐면:

파일/폴더 왜 올리면 안 되나
.env, .env.local DB 비밀번호, API 키가 들어있다. 올리면 해킹당한다
*.pem 서버 접속 열쇠. 이게 털리면 남이 서버에 들어온다
node_modules/ npm 패키지 수만 개. 용량만 수백MB인데 npm install로 재생성 가능
.next/ 빌드 결과물. 소스코드만 있으면 다시 만들 수 있다
.claude/ AI 작업 히스토리. 올릴 필요 없음
# 기본 세팅 순서
git init
git branch -m main

# 첫 커밋
git add .
git commit -m "Initial commit"

# GitHub 원격 저장소 연결 & Push
git remote add origin https://github.com/your-id/your-repo.git
git push -u origin main

바이브코딩할 때 이 단계를 AI한테 시키더라도, .gitignore에 뭐가 들어가는지는 꼭 직접 확인하자. 보안은 AI한테 100% 맡기면 안 되는 영역이다.


AWS Lightsail 배포: SCP로 빌드 파일 올리기

이제 진짜 배포다. Next.js AWS 배포 방법은 여러 가지가 있는데, Vercel이 제일 편하긴 하다. 근데 DB랑 서버를 직접 만지고 싶으면 Lightsail 같은 VPS(가상 서버)가 낫다. 월 $5짜리부터 시작할 수 있어서 Lightsail 배포 초보자한테 부담도 적다.

왜 서버에서 빌드하지 않고 로컬에서 빌드할까?

npm run build는 생각보다 리소스를 많이 먹는다. Lightsail 소형 인스턴스(월 $5~$10짜리)에서 빌드를 돌리면 메모리 부족(OOM, Out Of Memory)으로 서버가 뻗을 수 있다. 그래서 전략을 바꿔야 한다:

  1. 내 PC에서 npm run build (고사양 로컬에서 빌드)
  2. 빌드 결과물(.next 폴더)만 서버로 복사
  3. 서버에서는 npm start만 실행

이게 마치 집에서 도시락을 싸서 가져가는 것과 같다. 회사(서버) 주방(CPU/RAM)이 허접하면 집(로컬 PC)에서 요리해서 완성품만 들고 가는 게 낫잖아.

SCP 명령어로 파일 전송

SCP(Secure Copy Protocol)는 SSH 기반으로 파일을 안전하게 전송하는 명령어다:

# 1단계: 로컬에서 빌드
npm run build

# 2단계: 빌드 결과물을 서버로 전송
scp -i ~/my-key.pem -r .next user@서버IP:/home/user/app/my-app/

# package.json 등 필요한 파일도 함께 전송
scp -i ~/my-key.pem package.json user@서버IP:/home/user/app/my-app/
옵션 의미
-i SSH 키 파일(.pem) 지정 (Identity file)
-r 폴더 전체를 재귀적으로 복사 (Recursive)
-P SSH 포트가 22가 아닐 때 포트 지정
-v 전송 과정을 상세 출력 (디버깅용)

참고: 매번 SCP 치는 게 귀찮으면 나중에 rsync(변경된 파일만 전송)나 GitHub Actions(자동 배포)로 업그레이드하면 된다. 지금은 기본기부터 잡자.

PM2 사용법: 앱의 생명유지장치

PM2 사용법을 모르면 배포가 의미 없다. 왜냐하면 터미널에서 npm start를 치고 SSH 연결을 끊으면 앱도 같이 죽거든.

PM2는 앱의 생명유지장치다. 병원에서 환자한테 붙이는 심장 모니터 같은 거라고 생각하면 된다. 24시간 앱을 백그라운드에서 살려두고, 혹시 앱이 죽으면(크래시) 자동으로 되살려준다.

# PM2 설치 (한 번만 하면 됨)
sudo npm install pm2 -g

# Next.js 앱을 PM2로 시작
pm2 start npm --name "my-app" -- start

# 서버 재부팅해도 자동 시작되도록 설정 (중요!)
pm2 save
pm2 startup

자주 쓰는 PM2 명령어 모음:

명령어 설명
pm2 list 실행 중인 앱 목록 확인
pm2 logs 실시간 로그 확인
pm2 restart my-app 앱 재시작 (배포 후 필수)
pm2 stop my-app 앱 중지
pm2 delete my-app 앱 완전 삭제
pm2 monit CPU/메모리 모니터링 대시보드

프로덕션 배포를 좀 더 하나씩 관리하고 싶으면 ecosystem.config.js 파일을 만들자:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'my-app',
    script: 'npm',
    args: 'start',
    cwd: '/home/user/app/my-app',
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    autorestart: true,        // 크래시 시 자동 재시작
    watch: false,             // 프로덕션에서는 파일 감시 끄기
    max_memory_restart: '1G'  // 메모리 1GB 초과 시 재시작
  }]
};
# ecosystem 파일로 시작
pm2 start ecosystem.config.js

Apache 리버스 프록시: 건물 안내데스크 이해하기

여기가 Next.js AWS 배포에서 초보자들이 가장 헷갈려하는 부분이다. 리버스 프록시 설명을 최대한 쉽게 해보겠다.

리버스 프록시가 뭔데?

리버스 프록시는 건물 안내데스크 같은 거다.

  • 손님(사용자)이 건물 정문(80번 포트)으로 들어온다
  • 안내데스크(Apache)가 “어디 가세요?” 하고 받는다
  • “3000번 방(Next.js 앱)에 볼 일이 있어요”
  • 안내데스크가 직접 3000번 방에 연락해서 응답을 받아 손님한테 전달해준다

손님은 안내데스크하고만 대화하고, 3000번 방이 어디 있는지는 전혀 모른다. 이게 “리버스” 프록시인 이유다 — 클라이언트가 아니라 서버 쪽에서 중개해주는 거니까.

왜 이렇게 하냐고? 세 가지 이유가 있다:

  1. 보안: 3000번 방을 직접 외부에 열어두면 해킹에 취약하다. 안내데스크(Apache)가 앞에서 수상한 손님을 걸러준다.
  2. HTTPS: SSL 인증서 처리를 Apache가 전담한다. Next.js가 직접 할 필요 없다.
  3. 유연성: 나중에 앱을 4000번 방으로 옮겨도 안내데스크 설정만 바꾸면 된다. 손님(사용자)은 아무것도 안 바꿔도 됨.

Apache 설정 방법

Bitnami LAMP 스택 기준으로 vhosts 설정 파일을 수정한다:

<VirtualHost *:80>
    ServerName bokchi.dev

    ProxyRequests Off
    ProxyPreserveHost On

    <Proxy *>
        Require all granted
    </Proxy>

    # 핵심 두 줄: 80포트로 들어오는 요청을 3000포트로 넘긴다
    ProxyPass / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>

# HTTPS (443포트)도 같은 방식
<VirtualHost *:443>
    ServerName bokchi.dev

    ProxyPreserveHost On
    ProxyPass / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/

    SSLEngine on
    SSLCertificateFile /path/to/cert.pem
    SSLCertificateKeyFile /path/to/key.pem
</VirtualHost>
# 필요한 Apache 모듈 활성화
sudo a2enmod proxy proxy_http rewrite headers
# a2enmod = Apache2 Enable Module의 약자

# 설정 파일 문법 검증
sudo apachectl configtest

# Apache 재시작
sudo apachectl restart

핵심은 ProxyPassProxyPassReverse 딱 두 줄이다. “이 도메인으로 들어오는 모든 요청을 localhost:3000으로 넘겨라”는 뜻이다. 참고로 Bitnami 스택에 Apache가 기본 내장되어 있어서 이걸 쓴 건데, Nginx를 쓰고 싶으면 proxy_pass http://127.0.0.1:3000; 한 줄이면 같은 역할을 한다.


Google OAuth 2.0: “대리 로그인” 세팅하기

소셜 로그인은 직접 회원가입 시스템을 만드는 것보다 훨씬 안전하고 편하다. OAuth 2.0은 우리 앱이 사용자의 비밀번호를 직접 보관하지 않고, 구글 같은 신뢰할 수 있는 회사의 로그인 화면을 빌려 쓰는 기술이다.

OAuth 흐름을 비유로 설명하면

아파트 택배함을 생각해보자:

  1. 택배기사(사용자)가 아파트에 도착한다 (“구글로 로그인” 버튼 클릭)
  2. 경비실(구글)에서 “누구세요?” 하고 본인 확인을 한다 (구글 로그인 화면)
  3. 확인 끝나면 경비실이 출입증(Access Token)을 발급해준다
  4. 택배기사가 출입증을 들고 우리 앱에 들어온다
  5. 우리 앱은 출입증만 확인하고, 비밀번호는 한 번도 보지 않는다

비밀번호 관리를 구글이라는 거대 보안회사한테 아웃소싱한 거다. 우리가 직접 비밀번호를 저장하면 해킹 위험이 있는데, 구글한테 맡기면 훨씬 안전하다.

삽질 포인트: IP 주소로 넣으면 차단당한다

Google Cloud Console에서 OAuth 클라이언트를 만들 때, 리디렉션 URI에 IP 주소(http://123.45.67.89/callback)를 넣으면 invalid_request 에러가 난다.

구글 입장에서 생각해보면 당연하다. “이 출입증을 돌려줄 목적지”가 안전한 곳인지 확인해야 하는데, IP 주소는 언제든 바뀔 수 있으니까 신뢰할 수 없는 거다. 반드시 도메인으로 등록해야 한다.

설정 순서:

  1. Google Cloud Console > APIs & Services > Credentials
  2. OAuth 2.0 클라이언트 ID 생성
  3. Authorized JavaScript Origins: https://bokchi.dev
  4. Authorized Redirect URIs: https://bokchi.dev/api/auth/callback/google
# 서버의 .env 파일에 추가
GOOGLE_CLIENT_ID=발급받은_클라이언트_ID
GOOGLE_CLIENT_SECRET=발급받은_시크릿
NEXTAUTH_URL=https://bokchi.dev

주의: 리디렉션 URI는 글자 하나라도 틀리면 안 된다. 끝에 슬래시(/) 하나 차이, http vs https 차이만으로도 에러가 나니까 구글 콘솔에 등록한 URI와 코드의 URI가 정확히 일치하는지 꼭 확인하자.


트러블슈팅 Q&A

배포하면서 만날 수 있는 흔한 문제들을 모았다. 나도 다 겪었던 것들이다.

Q: “왜 Apache/Nginx를 앞에 두나요? Next.js가 직접 80포트 쓰면 안 되나요?”

안 되는 건 아닌데, 보안상 안 좋다. Apache/Nginx가 해킹 방어, HTTPS 암호화, 트래픽 분산을 전문적으로 처리해준다. Next.js 서버를 직접 인터넷에 노출하는 건, 경비원 없이 건물 문을 열어두는 거랑 같다.

Q: “PM2에서 EADDRINUSE: address already in use :3000 에러가 나요”

3000번 포트를 다른 프로세스가 이미 차지하고 있는 거다. 유령처럼 남아있는 프로세스를 죽여야 한다:

# 3000번 포트 쓰는 프로세스 찾기
lsof -i :3000

# PM2에서 기존 앱 전부 삭제 후 재시작
pm2 delete all
pm2 start npm --name "my-app" -- start

Q: “서버 파일이 날아가면 어떡하죠?”

서버에 올린 건 빌드 결과물(.next)이지 소스코드가 아니다. 소스코드는 GitHub에 있으니까 언제든 다시 빌드해서 올리면 된다. 그래서 GitHub Push를 빼먹으면 절대 안 된다. 서버는 언제든 날아갈 수 있다고 가정하자.

Q: “Cloudflare beacon 스크립트가 CSP에 차단돼요”

브라우저의 Content Security Policy(CSP)가 외부 스크립트를 차단하는 거다. next.config.mjs에서 CSP 헤더의 script-src에 Cloudflare CDN 주소를 화이트리스트로 추가하면 된다. 브라우저가 “이 외부 스크립트 수상한데?” 하고 막는 건데, “아니야 이건 괜찮아” 라고 등록해주는 거다.

Q: “구글 로그인만으로 보안이 충분한가요?”

비밀번호 관리를 구글이라는 거대 보안회사한테 아웃소싱한 거니까 1차적으로 매우 안전하다. 추가로 HTTPS(Cloudflare 등)만 잘 유지해서 세션 토큰이 중간에 탈취당하지 않도록 하면 된다.


마무리: 바이브코딩의 진짜 실력은 배포에서 드러난다

오늘 거친 여정을 정리하면:

단계 한 일 핵심
빌드 에러 Tailwind + shadcn/ui CSS 변수 매핑 tailwind.config.ts에 연결 고리 추가
타입 에러 Prisma $transaction 타입 제거 프레임워크 콜백은 추론에 맡기기
형상 관리 Git 초기화 + GitHub Push .gitignore로 보안 파일 보호
서버 배포 로컬 빌드 후 SCP 전송 서버에서 빌드하면 OOM 위험
프로세스 관리 PM2로 24시간 운영 pm2 save + pm2 startup 필수
리버스 프록시 Apache가 80 -> 3000 전달 ProxyPass 두 줄이 핵심
소셜 로그인 Google OAuth 2.0 연동 리디렉션 URI는 도메인으로, 글자 하나도 정확히

배포가 처음이라 겁먹었는데, 하나씩 해보니까 결국 다 해결됐다. 에러 메시지가 무섭게 생겨도 하나씩 읽어보면 답이 다 들어있다. 바이브코딩 하는 여러분도 배포까지 도전해보자. 내 앱이 인터넷에 뜨는 순간의 쾌감은 코딩 시작하고 제일 짜릿한 순간이니까.


이 글은 2026년 3월 9일 실제 배포 작업 세션을 기반으로 작성되었습니다.