실습 로그 / 커리어 내비 · 채용공고 기반 역량 갭 진단

커리어 내비 2단계 · 개발 환경과 프로젝트 뼈대

GitHub 레포를 만들고, 팀원과 맞출 진단 결과 JSON 형식을 문서로 정한 뒤, 그 형식대로 가짜 결과를 돌려주는 FastAPI 서버와 React 화면을 만든다. 서비스를 올릴 홈랩 VM을 준비하고 도메인으로 외부에 공개한다.

진행 과정은 실습 내용을 AI와 함께 정리했고, 배운 점은 그걸 보고 제가 이해한 대로 직접 썼습니다. 배운 점 바로가기 ↓

FastAPIReact홈랩배포트러블슈팅

목표

GitHub 레포를 만들고, 팀원과 맞출 진단 결과 JSON 형식을 문서로 정한 뒤, 그 형식대로 가짜 결과를 돌려주는 FastAPI 서버와 React 화면을 만든다. 서비스를 올릴 홈랩 VM을 준비하고 도메인으로 외부에 공개한다.

A. GitHub 레포와 뼈대 파일

  • 레포 career-navi 생성 (Private, README·.gitignore·license 자동 생성 끔)
  • 개발 중 API 키가 실수로 올라가도 노출되지 않게 Private으로 만듦. 발표 끝나면 Public으로 전환
  • Owner는 내 계정, 팀원은 Collaborator로 초대. 팀 Organization은 필요하면 Transfer로 옮김
    • Collaborator: 레포에 초대된 팀원
    • Transfer: 레포 소유자를 다른 계정·조직으로 옮기는 기능
  • 폴더는 frontend/(React) · backend/(FastAPI) · docs/(API 명세) 세 개로 나눔
  • docs/api.md : 팀원(AI)과 나(웹) 사이의 약속 문서. 진단 결과 JSON 형식과 API 목록, 회의에서 정할 것
    • 엔드포인트: 서버에서 요청을 받는 주소 (예: POST /api/diagnose)
  • backend/main.py : JSON 형식을 Pydantic 클래스로 옮기고, /api/diagnose가 고정된 가짜 결과(적합도 54%)를 돌려주게 함 → AI 엔진 없이도 화면 개발을 바로 시작
    • 가짜 응답(mock): 진짜 기능 대신 형식만 맞춘 고정 데이터
    • Pydantic: 들어오고 나가는 데이터 형식을 클래스로 정의하고 검사하는 라이브러리
    • CORS: 다른 주소(React 5173 → FastAPI 8000)에서 오는 요청을 브라우저가 막는 규칙
  • React 주소(5173)는 CORS 허용을 미리 설정해둠
  • .gitignore에 .env, node_modules, .venv, *.pdf 추가 → API 키·개인정보 커밋 방지
  • 맥북에서 git init → commit → push로 파일 7개 첫 커밋 (init: 프로젝트 뼈대)

코드

docs/api.md : 진단 결과 형식

{
  "diagnosis_id": "d_001",
  "posting_title": "제조 데이터 분석 (신입)",
  "fit_score": 54,
  "competencies": [
    {
      "id": "c1",
      "name": "SQL 활용",
      "type": "필수",
      "ncs_code": "2001010101",
      "status": "미충족",
      "evidence": {
        "posting": "SQL을 활용한 데이터 추출 경험",
        "experience": null
      }
    }
  ],
  "recommendations": [
    {
      "id": "r1",
      "competency_id": "c1",
      "kind": "교과목",
      "title": "데이터베이스",
      "semester": "3-2",
      "url": "https://example.com",
      "score_gain": 12
    }
  ]
}
  • type: 필수 / 우대 / 직무
  • status: 충족 / 부분충족 / 미충족 / 판단보류
  • kind: 교과목 / 비교과 / 현장실습 / 자격증
  • 근거가 없으면 null

backend/main.py

"""커리어 내비 백엔드 — 1단계: 가짜 데이터로 형식만 맞춰 둔 뼈대."""
import os
from typing import Literal, Optional

from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

load_dotenv()

app = FastAPI(title="Career Navi API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=[os.getenv("FRONTEND_ORIGIN", "http://localhost:5173")],
    allow_methods=["*"],
    allow_headers=["*"],
)


# ---- docs/api.md 형식을 그대로 옮긴 것 ----
class Evidence(BaseModel):
    posting: Optional[str] = None
    experience: Optional[str] = None


class Competency(BaseModel):
    id: str
    name: str
    type: Literal["필수", "우대", "직무"]
    ncs_code: Optional[str] = None
    status: Literal["충족", "부분충족", "미충족", "판단보류"]
    evidence: Evidence


class Recommendation(BaseModel):
    id: str
    competency_id: str
    kind: Literal["교과목", "비교과", "현장실습", "자격증"]
    title: str
    semester: Optional[str] = None
    url: Optional[str] = None
    score_gain: int = 0


class DiagnosisResult(BaseModel):
    diagnosis_id: str
    posting_title: str
    fit_score: int
    competencies: list[Competency]
    recommendations: list[Recommendation]


class DiagnoseRequest(BaseModel):
    experience_text: str
    posting_text: str


# ---- 가짜 응답 (AI 엔진 붙기 전까지) ----
MOCK = DiagnosisResult(
    diagnosis_id="d_001",
    posting_title="제조 데이터 분석 (신입)",
    fit_score=54,
    competencies=[
        Competency(id="c1", name="SQL 활용", type="필수", status="미충족",
                   evidence=Evidence(posting="SQL을 활용한 데이터 추출 경험")),
        Competency(id="c2", name="Python 데이터 분석", type="필수", status="충족",
                   evidence=Evidence(posting="Python 기반 데이터 분석 역량",
                                     experience="수업 프로젝트에서 pandas로 매출 데이터 분석")),
        Competency(id="c3", name="현장 데이터 프로젝트", type="우대", status="미충족",
                   evidence=Evidence(posting="제조 현장 데이터 분석 프로젝트 경험자 우대")),
    ],
    recommendations=[
        Recommendation(id="r1", competency_id="c1", kind="교과목", title="데이터베이스",
                       semester="3-2", score_gain=12),
        Recommendation(id="r2", competency_id="c3", kind="현장실습", title="동계 현장실습",
                       semester="겨울", score_gain=12),
    ],
)


@app.get("/api/health")
def health():
    return {"status": "ok"}


@app.post("/api/diagnose", response_model=DiagnosisResult)
def diagnose(req: DiagnoseRequest):
    # TODO: 팀원 엔진 연결
    return MOCK

.gitignore

.env
*.env
!.env.example
__pycache__/
*.pyc
.venv/
venv/
node_modules/
dist/
.DS_Store
.vscode/
.idea/
data/private/
*.pdf

첫 push (맥북)

cd ~/career-navi
git init
git add .
git commit -m "init: 프로젝트 뼈대"
git branch -M main
git remote add origin [email protected]:<내 계정>/career-navi.git
git push -u origin main
  • 이후 작업은 main에 바로 올리지 않고 브랜치 → PR → squash merge로 진행 (PR #1 인수인계 문서부터)
    • 브랜치: main을 건드리지 않고 따로 작업하는 갈래
    • PR(Pull Request): 브랜치 작업을 main에 합쳐달라는 요청. 팀원이 리뷰할 수 있음
    • squash merge: 브랜치의 커밋 여러 개를 하나로 합쳐서 main에 올리는 방식 → main 기록이 PR 단위로 깔끔하게 남음
  • 인수인계는 docs/handoff/날짜_이름.md로 레포에 남기기로 팀원과 합의 (첫 문서 2026-10-28_haewon.md)

B. 로컬에서 FastAPI 서버 실행

  • backend/에서 가상환경 만들고 패키지 설치
    • venv: 프로젝트마다 따로 쓰는 파이썬 환경. 다른 프로젝트와 패키지 버전이 안 섞임
    • requirements.txt: 필요한 패키지 목록. pip install -r로 한 번에 설치
  • .env.example을 .env로 복사 (API 키 같은 비밀값은 여기에만)
  • uvicorn main:app --reload로 서버 실행 → Application startup complete.
    • uvicorn: FastAPI 앱을 실제로 띄워주는 서버 프로그램 (Django의 runserver 역할)
    • --reload: 코드를 저장하면 서버가 자동으로 다시 켜짐
  • 서버는 끝나는 명령이 아니라 계속 켜져 있음. 끄려면 Ctrl + C
  • localhost:8000/docs에서 Swagger 화면 확인 (GET /api/health, POST /api/diagnose, Schemas)
    • Swagger: 코드에서 자동으로 만들어지는 API 문서 겸 테스트 화면

명령어 (맥북)

cd ~/career-navi/backend
python3 -m venv .venv
source .venv/bin/activate        # 윈도우: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
uvicorn main:app --reload        # http://localhost:8000/docs
  • 터미널 앞에 (.venv)가 붙으면 가상환경이 켜진 상태
  • Swagger에서 POST /api/diagnose → Try it out → Execute → 응답 200, fit_score: 54 확인
  • GET /api/health → {"status": "ok"} : 서버가 살아 있는지 확인하는 용도. 배포 후 점검에도 계속 씀
    • source: 현재 터미널에 가상환경 설정을 적용하는 명령
    • 200: 요청이 정상 처리됐다는 HTTP 상태 코드

requirements.txt

fastapi
uvicorn[standard]
pydantic
python-dotenv
  • python-dotenv : .env 파일 값을 환경변수로 읽어옴 (load_dotenv())

C. 홈랩에 서비스용 VM 준비

1. 왜 VM을 새로 만들었나

  • 팀원이 SSH로 들어와서 작업할 서버라, 기존 VM(portfolio·ecg)과 분리해 프로젝트 전용 VM을 따로 만듦
  • 팀원에게 docker 권한을 주면 그 VM에선 사실상 관리자라서, 내 다른 서비스가 있는 VM에 같이 두면 안 됨
  • 사양은 들어갈 것 기준으로 잡음 : FastAPI·Nginx·PostgreSQL(1GB 안쪽) + 한국어 임베딩 모델(약 2GB) + 여유 → RAM 4GB
  • 미니PC RAM 16GB를 다른 VM과 나눠 쓰는 중이라 필요한 만큼만 배정. 임베딩 모델이 정해지면 6GB로 늘릴지 다시 판단

2. VM 생성

  • Proxmox 웹 UI → 오른쪽 위 Create VM
  • ISO는 홈랩 1단계에서 올려둔 ubuntu-26.04.1-live-server-amd64.iso 재사용
항목 값
VM ID / Name 103 / careernavi
OS Ubuntu Server 26.04
Disk 30GB
CPU 2 cores
Memory 4GB
  • 메모리를 정할 때 Proxmox Summary에서 다른 VM들이 쓰는 메모리를 먼저 확인하고, 남는 범위 안에서 4GB로 정함 (ecg는 곧 끌 예정이라 여유 계산에 넣음)

3. Ubuntu 설치

  • Type of install : Ubuntu Server
  • Network : DHCP로 집 공유기에서 IP 자동 할당
  • Server name : careernavi
  • Install OpenSSH server 체크 → 맥북 터미널에서 SSH로 접속하기 위함
  • 설치 끝나고 Proxmox → 103 (careernavi) → Console에서 로그인 확인

4. 루트 디스크 확장

  • 30GiB를 줬는데 df -h /로 보니 루트가 13.67GB만 잡혀 있음
  • Ubuntu 설치 기본값이 LVM 영역의 절반 정도만 루트에 할당해서 생긴 일 (홈랩 portfolio VM 때와 같은 현상)
  • 남은 공간을 전부 루트에 붙여서 28GB로 늘림
sudo lvextend -l +100%FREE -r /dev/ubuntu-vg/ubuntu-lv
df -h /
  • 결과 : 13.67GB → 28GB
    • LVM: 디스크를 나중에 늘리거나 나눌 수 있게 논리적으로 관리하는 방식
    • lvextend: LVM 영역 크기를 늘리는 명령
    • +100%FREE: 남은 공간 전부
    • -r: 파일시스템(ext4) 크기까지 같이 늘림

5. Tailscale 연결

  • Proxmox 콘솔(noVNC)은 복붙이 안 돼서, Tailscale부터 깔고 이후 작업은 맥북 터미널에서 SSH로 함
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale ip -4
  • tailscale up에서 나온 로그인 링크로 내 Tailscale 계정에 기기 등록
  • 맥북에서 ssh <사용자>@careernavi로 접속 성공 → 집 밖에서도 접속 가능
    • Tailscale: 내 기기들끼리만 묶는 사설 네트워크(VPN). 공유기 포트를 열지 않아도 밖에서 접속 가능
    • MagicDNS: IP 대신 기기 이름(careernavi)으로 접속하게 해주는 Tailscale 기능

6. SSH 키 등록 (맥북·데스크톱)

  • 맥북에 이미 있던 키(~/.ssh/id_ed25519)를 VM에 등록 → 비밀번호 없이 접속
ssh-copy-id <사용자>@careernavi
  • 중간에 맥북 → 데스크톱으로 자리를 옮겨서, 데스크톱에서도 키를 새로 만들어 같은 방식으로 등록
ssh-keygen -t ed25519
ssh-copy-id <사용자>@careernavi
  • 데스크톱도 같은 Tailscale 계정이라 바로 VM 이름으로 접속됨
    • 공개키: VM에 걸어두는 자물쇠 (.pub)
    • 개인키: 내 컴퓨터에만 두는 열쇠. 절대 밖으로 보내지 않음
    • authorized_keys: VM에 들어올 수 있는 공개키 목록 파일 (~/.ssh/authorized_keys)
    • ssh-copy-id: 내 공개키를 서버의 authorized_keys에 자동으로 추가해주는 명령

7. 업데이트와 Docker 설치

sudo apt update && sudo apt upgrade -y
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
exit
  • 다시 접속한 뒤 확인
groups
docker run hello-world
  • groups에 docker가 보이고, Hello from Docker! 출력 → Docker 설치·권한 정상
    • usermod -aG docker: 사용자를 docker 그룹에 추가 (a: 기존 그룹 유지하고 추가, G: 그룹)
    • docker 그룹: sudo 없이 docker 명령을 쓸 수 있는 권한. 컨테이너로 호스트 파일을 건드릴 수 있어서 사실상 관리자 권한
    • exit: 그룹 권한은 로그인할 때 읽어서 재접속해야 적용됨

8. 팀원 계정 만들기

  • 내 계정을 같이 쓰지 않고 팀원 전용 계정 teammate을 따로 만듦 → 누가 무엇을 했는지 구분되고, 필요하면 이 계정만 막으면 됨
  • 비밀번호 없이 SSH 키로만 들어오게 만듦
sudo adduser --disabled-password --gecos "" teammate
sudo usermod -aG docker teammate
sudo mkdir -p /home/teammate/.ssh
echo "팀원 공개키 한 줄" | sudo tee -a /home/teammate/.ssh/authorized_keys
sudo chmod 700 /home/teammate/.ssh
sudo chmod 600 /home/teammate/.ssh/authorized_keys
sudo chown -R teammate:teammate /home/teammate/.ssh
  • 팀원에게는 개인키가 아니라 공개키(.pub 내용 한 줄)만 받음
    • --disabled-password: 비밀번호 로그인이 불가능한 계정으로 생성
    • --gecos "": 이름·전화번호 같은 추가 정보 질문 건너뛰기
    • tee -a: 파일 끝에 덧붙이기. -a 없이 쓰면 기존 키를 덮어씀
    • chmod 700 / 600: .ssh 폴더와 키 파일을 본인만 읽게 제한. 권한이 넓으면 SSH가 키를 무시함
    • chown: 파일 주인을 teammate로 바꿈 (sudo로 만들어서 주인이 root로 돼 있음)

9. Tailscale Share로 팀원 접속

  • 내 Tailscale 관리 화면 → Machines → careernavi → Share → 팀원 이메일로 초대
  • 내 Tailscale 네트워크 전체가 아니라 careernavi 한 대만 공유됨 (다른 VM·pve는 안 보임)
  • 팀원이 자기 노트북에 Tailscale을 깔고 초대 수락 → ssh teammate@careernavi 접속 성공
  • 처음엔 비밀번호를 물어보는 문제가 있었는데, 키를 다시 만들어 지문이 달라졌던 것 → 새 공개키로 다시 등록해서 해결 (트러블슈팅 1번 참고)
    • Tailscale Share: 내 네트워크의 특정 기기 하나만 다른 Tailscale 계정에 공유하는 기능
    • 지문(fingerprint): 키마다 고유한 짧은 식별값. 서로 비교하면 같은 키인지 확인 가능

10. 최종 상태

  • careernavi VM : Ubuntu Server 26.04, 루트 28GB, Docker 설치
  • 접속 가능한 사람 : 이해원(맥북·데스크톱 키), 팀원(노트북 키). 모두 Tailscale 안에서만, 키로만
  • 공유기 포트는 하나도 열지 않음. 외부 공개는 이후 Cloudflare Tunnel로 함 (D 참고)

D. 뼈대 배포와 도메인 연결

  • 구조 : 방문자 → https://careernavi.ihaewon.com → Cloudflare(HTTPS·DNS) ⇄ 터널 ⇄ cloudflared(careernavi VM) → localhost:8000 → FastAPI 컨테이너
  • 도메인은 새로 사지 않고 갖고 있는 ihaewon.com의 서브도메인 careernavi.ihaewon.com 사용 → DNS 레코드만 추가하면 돼서 추가 비용 없음
    • 서브도메인: 도메인 앞에 이름을 붙여 만드는 하위 주소
  • VM에 GitHub 배포 키(읽기 전용) 등록 → Private 레포를 git clone. write 권한을 안 줘서 VM은 코드를 받기만 하고 올리지 못함
    • 배포 키(Deploy key): 레포 하나에만 쓰는 SSH 키
  • ~/.ssh/config에 Host github.com → IdentityFile ~/.ssh/github_deploy로 어떤 키를 쓸지 지정
  • FastAPI를 python:3.12-slim 컨테이너로 실행 (8000번, --restart unless-stopped)
  • 코드 폴더를 -v로 컨테이너에 연결 → git pull 후 docker restart api만 하면 반영
  • 첫 curl은 컨테이너가 아직 pip install 중이라 실패 → docker logs -f api로 Uvicorn running 확인 후 성공
    • -v(볼륨): VM 폴더를 컨테이너 안 폴더에 연결
    • --restart unless-stopped: VM이 재부팅돼도 컨테이너 자동 재시작
  • cloudflared 설치 → 터널 careernavi 생성 → /etc/cloudflared/config.yml 작성 → route dns → systemd 서비스 등록 (포폴 사이트와 같은 방식, 터널만 별도)
    • ingress: 들어온 주소별로 어디로 보낼지 정하는 규칙 (여기선 careernavi.ihaewon.com → http://localhost:8000, 나머지는 404)
  • 터널 인증 파일(.json)은 VM에만 두고 Git에 올리지 않음
  • 외부에서 https://careernavi.ihaewon.com/docs 접속 성공
  • 발표용 QR 생성 : https://careernavi.ihaewon.com (오류 보정 H단계, 인쇄물이 일부 가려져도 인식)

명령어·설정

1. 배포 키 만들기 (VM)

ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N ""
cat ~/.ssh/github_deploy.pub
  • 출력된 공개키를 GitHub 레포 → Settings → Deploy keys → Add deploy key에 등록. Allow write access는 체크 안 함
    • -f: 키 파일 이름 지정 (기존 id_ed25519와 안 겹치게)
    • -N "": 키 비밀번호 없이 생성 (서버가 자동으로 pull해야 해서)

2. ~/.ssh/config

Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/github_deploy
ssh -T [email protected]     # 인증 확인
git clone [email protected]:<내 계정>/career-navi.git ~/career-navi

3. FastAPI 컨테이너 (api)

docker run -d --name api --restart unless-stopped \
  -p 8000:8000 \
  -v ~/career-navi/backend:/app -w /app \
  python:3.12-slim \
  sh -c "pip install -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8000"

docker logs -f api                 # Uvicorn running 뜰 때까지 대기
curl localhost:8000/api/health     # {"status":"ok"}
  • 이미지를 따로 만들지 않고, 공식 파이썬 이미지에 코드 폴더를 연결해서 시작할 때 패키지 설치 → 구조가 단순하고 코드 수정은 재시작만으로 반영
  • 대신 컨테이너를 켤 때마다 pip install을 해서 첫 응답까지 수십 초 걸림 (첫 curl 실패 원인)
    • -d: 백그라운드 실행
    • -w /app: 컨테이너 안 작업 폴더
    • --host 0.0.0.0: 컨테이너 밖(VM)에서 들어오는 요청도 받게 함. 기본값(127.0.0.1)이면 컨테이너 안에서만 접속됨
    • slim: 꼭 필요한 것만 넣은 가벼운 이미지

4. Cloudflare Tunnel

cloudflared tunnel login
cloudflared tunnel create careernavi
cloudflared tunnel route dns careernavi careernavi.ihaewon.com

/etc/cloudflared/config.yml

tunnel: <터널 ID>
credentials-file: /etc/cloudflared/<터널 ID>.json

ingress:
  - hostname: careernavi.ihaewon.com
    service: http://localhost:8000      # E단계에서 80(Nginx)으로 바꿈
  - service: http_status:404
sudo cloudflared service install
sudo systemctl status cloudflared
  • route dns가 Cloudflare DNS에 CNAME 레코드를 자동으로 만들어줌 → 대시보드에서 직접 안 만져도 됨
  • 공유기 포트를 열지 않고, VM이 Cloudflare 쪽으로 먼저 연결을 맺는 방식이라 집 IP가 밖에 드러나지 않음
    • CNAME: 이 주소는 저 주소를 따라가라는 DNS 레코드
    • credentials-file: 이 터널을 쓸 수 있다는 인증 파일. 유출되면 남이 내 터널을 쓸 수 있어서 Git에 안 올림
    • http_status:404: 위 규칙에 안 맞는 주소는 404로 막음
    • systemd 서비스: VM 부팅 시 자동 실행되는 백그라운드 프로그램

5. 코드 반영 방법

cd ~/career-navi && git pull
docker restart api

E. React 화면과 Nginx 배포

  • 구조 : 방문자 → careernavi.ihaewon.com → Cloudflare ⇄ 터널 → localhost:80 → Nginx 컨테이너(web) → /는 React 빌드 파일(dist), /api/는 FastAPI 컨테이너(api, 8000)
  • 맥북에서 npm create vite로 React 프로젝트 생성 → 입력창 2개 + 진단하기 버튼 + 결과(적합도·역량·추천) 화면 초안
    • Vite: React 프로젝트를 만들고 개발 서버(npm run dev, 5173)·빌드(npm run build)를 해주는 도구
    • proxy: 개발 중 화면(5173)의 /api 요청을 백엔드(8000)로 넘기는 설정
  • Vite proxy를 써서 화면 코드에 서버 주소를 박지 않음
  • 맥북 localhost:5173에서 진단하기 → 54% 확인 후 push
  • VM에는 Node를 설치하지 않고 node:22 컨테이너로 한 번만 빌드 → frontend/dist 생성
    • 빌드: React 코드를 브라우저가 바로 읽는 html·js·css 파일로 묶는 것
  • Nginx 컨테이너 web(80번) : /는 dist 파일, /api/는 FastAPI로 넘김
    • 리버스 프록시: 받은 요청을 뒤쪽 다른 서버로 대신 전달하는 것 (proxy_pass)
    • host.docker.internal: 컨테이너 안에서 VM 자신을 가리키는 이름. --add-host로 지정
    • try_files $uri /index.html: 없는 경로는 React 첫 화면으로 보냄
  • 터널 대상을 8000(FastAPI) → 80(Nginx)으로 변경 → 외부에서 화면·API 모두 접속 (트러블슈팅 2번 참고)
  • 확인 : curl localhost → <!doctype html>, curl localhost/api/health → {"status":"ok"}
  • VM에서 git push 시도 → 배포 키가 읽기 전용이라 거부됨 (의도한 대로 동작)
  • 데스크톱(윈도우) 공개키도 authorized_keys에 >>로 추가 → 집 안에서는 공유기 내부 IP로도 접속

명령어·설정

1. React 프로젝트 (맥북)

cd ~/career-navi
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm run dev          # http://localhost:5173

frontend/vite.config.js

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:8000',
    },
  },
})
  • 화면 코드는 fetch('/api/diagnose')처럼 상대 주소만 씀 → 개발 땐 Vite가, 배포 땐 Nginx가 /api를 FastAPI로 넘겨서 코드 수정 없이 둘 다 동작
  • 같은 주소(도메인)에서 화면과 API가 같이 나가서 배포 환경에선 CORS 문제도 안 생김

2. 빌드 (VM)

cd ~/career-navi && git pull
docker run --rm -v ~/career-navi/frontend:/app -w /app node:22 \
  sh -c "npm install && npm run build"
ls frontend/dist      # index.html, assets/
  • VM에 Node를 깔지 않고 빌드할 때만 컨테이너를 잠깐 띄움 → 서버에 도구가 쌓이지 않음
    • --rm: 실행이 끝나면 컨테이너 자동 삭제

3. Nginx 설정 (~/nginx/careernavi.conf)

server {
    listen 80;
    root /usr/share/nginx/html;

    location / {
        try_files $uri /index.html;
    }

    location /api/ {
        proxy_pass http://host.docker.internal:8000;
    }
}

4. Nginx 컨테이너 (web)

docker run -d --name web --restart unless-stopped \
  -p 80:80 \
  --add-host host.docker.internal:host-gateway \
  -v ~/career-navi/frontend/dist:/usr/share/nginx/html \
  -v ~/nginx/careernavi.conf:/etc/nginx/conf.d/default.conf \
  nginx

curl localhost                 # <!doctype html>
curl localhost/api/health      # {"status":"ok"}
  • Nginx 설정 파일은 서버 환경에 따라 달라서 레포가 아니라 VM에만 둠
    • host-gateway: 컨테이너에서 본 VM(호스트)의 주소
    • conf.d/default.conf: Nginx가 기본으로 읽는 설정 파일 자리. 여기에 내 설정을 덮어씌움

5. 터널 대상 변경

sudo sed -i 's#http://localhost:8000#http://localhost:80#' /etc/cloudflared/config.yml
sudo systemctl restart cloudflared
  • 반드시 VM 안에서 curl localhost가 html을 돌려주는 걸 확인한 뒤에 바꿈 (트러블슈팅 2번 참고)

6. 이후 반영 방법

cd ~/career-navi && git pull
docker restart api                 # 백엔드만 바뀐 경우
docker run --rm -v ~/career-navi/frontend:/app -w /app node:22 sh -c "npm install && npm run build"
docker restart web                 # 화면까지 바뀐 경우

최종 컨테이너 구성

컨테이너 이미지 포트 역할
web nginx 80 화면 파일 + /api 전달
api python:3.12-slim 8000 FastAPI + AI 엔진
db pgvector/pgvector:pg16 127.0.0.1:5432 교내 자원 검색용 DB (3단계 A)

남은 작업

  • Swagger에서 POST /api/diagnose 실행해 fit_score: 54 확인
  • 홈랩에 careernavi VM 만들기 (Tailscale, SSH 키, Docker)
  • 팀원 계정 만들고 SSH 키 등록, Tailscale로 VM 공유
  • 팀원 GitHub 아이디 받아서 Collaborator 초대
  • careernavi VM에 뼈대 배포, careernavi.ihaewon.com 연결, QR 생성
  • React 화면 만들고 Nginx로 배포 → 도메인에서 진단 화면 확인
  • Claude API 전용 워크스페이스(career-navi) + 키(careernavi-server, 30일) → VM backend/.env에만 (chmod 600)
  • career-navi 워크스페이스 지출 한도 $30
  • pgvector DB 컨테이너(db, 127.0.0.1:5432) + vector 확장
  • 팀원 엔진(diagnose()) 연결 + /docs 외부 차단 + 요청 횟수 제한(IP당 분당 5회, 하루 300회) + 입력 3000자 제한 (PR #4)

트러블슈팅

1. 팀원 SSH 접속 시 비밀번호를 물어봄

  • 증상 : 팀원 노트북(윈도우)에서 ssh teammate@careernavi 접속 시 비밀번호 입력 창이 뜨고 들어가지지 않음. 계정은 비밀번호 없이(--disabled-password) 만들어서 키로만 들어갈 수 있음
  • 확인한 것
    • 서버 로그 sudo journalctl -u ssh -n 20 : Failed password for teammate 만 있고 Accepted publickey 없음 → 연결·아이디는 정상, 키 인증만 실패
    • 양쪽 키 지문 비교 ssh-keygen -lf : 팀원 노트북 SHA256:ajaX... vs 서버 등록 키 SHA256:UzSn... → 다름
  • 원인 : 키를 보낸 뒤 팀원 노트북에서 ssh-keygen이 다시 실행돼 키가 새로 바뀜. 서버에는 예전 공개키가 등록돼 있었음
  • 해결 : 현재 공개키를 다시 받아 authorized_keys를 덮어쓰고, 지문이 같은지 확인 후 접속 성공
  • 재발 방지 : 키를 등록한 뒤에는 바로 양쪽 지문(ssh-keygen -lf)을 비교해서 맞는지 확인한다
    • 지문(fingerprint): 긴 공개키를 짧게 요약한 값. 같으면 같은 키
    • journalctl: systemd 서비스 로그를 보는 명령. -u ssh는 SSH 서버 로그만

2. 터널을 Nginx로 바꾼 뒤 사이트가 500 에러

  • 증상 : 터널 대상을 8000 → 80(Nginx)으로 바꾸자 careernavi.ihaewon.com이 500 Internal Server Error
  • 확인한 것
    • VM git pull → Already up to date, ls frontend → 비어 있음 → GitHub에 React 코드가 없음
    • npm ci 실패(package-lock.json 없음) → frontend/dist 생성 안 됨 → Nginx가 보낼 파일이 없어 500
  • 원인 : React 코드를 맥북에서 만들고 push하지 않은 상태로 VM 배포를 진행. 빌드 결과를 확인하기 전에 터널부터 전환함
  • 해결 : 터널을 8000으로 되돌려 기존 화면(Swagger) 복구 → 맥북에서 push → VM에서 git pull·npm install && npm run build·docker restart web → curl localhost로 html 확인 후 터널 80으로 전환
  • 재발 방지 : 정문(터널)을 바꾸기 전에 새 대상이 VM 안에서 정상 응답하는지(curl localhost) 먼저 확인한다
    • npm ci: package-lock.json대로 정확히 설치하는 명령. lock 파일이 없으면 실패. npm install은 없어도 설치하면서 새로 만듦

배운 점

직접 작성

A. GitHub 레포와 뼈대 파일

  • 폴더 구조

    • frontend/ : react
    • backend/ : FastAPI
    • docs/ : API명세
  • API 명세 : 서버가 어떤 주소로 어떤 형식의 데이터를 주고 받는지 적는 문서 —> 진단 결과 한 건

    {
      "fit_score": 54,
      "competencies": [
        { "id": "c1", "name": "SQL 활용", "type": "필수", "status": "미충족",
          "evidence": { "posting": "SQL을 활용한 데이터 추출 경험", "experience": null } }
      ],
      "recommendations": [
        { "id": "r1", "competency_id": "c1", "kind": "교과목", "title": "데이터베이스",
          "semester": "3-2", "score_gain": 12 }
      ]
    }
    
    • competencies : 역량 목록
      • type : 필수 / 우대 / 직무
      • status : 충족 / 부분충족 / 미충족 / 판단보류
      • posting : 공고에서 찾은 문장
        • experience : 내 경험에서 찾은 문장
    • recommendations : 추천 목록
      • competency_id : 이 추천이 어느 역량을 위한 건지 (r1 → c1). 체크하면 그 역량이 충족으로 바뀜
      • kind : 교과목 / 비교과 / 현장실습 / 자격증
      • score_gain : 이걸 시행하면 몇 점 오르는지
    • null 규칙 : 값이 없을 때 문자열이나 “”이나 “없음”으로 쓰지 않고 null 쓰기로 함
    • 엔드포인트 : 서버에서 요청을 받는 주소 (ex : POST /api/diagnose)
  • 서버 뼈대 (backend/main.py)

    class Competency(BaseModel):          # api.md 형식을 파이썬 클래스로 옮김
        id: str
        type: Literal["필수", "우대", "직무"]  # 이 셋 중 하나만 허용
        status: Literal["충족", "부분충족", "미충족", "판단보류"]
    
    @app.post("/api/diagnose", response_model=DiagnosisResult)
    def diagnose(req: DiagnoseRequest):
        return MOCK                       # 고정된 가짜 결과 (54%)
    
    @app.get("/api/health")               # GET: 살아있는지 확인용
    def health():
        return {"status": "ok"}
    
    • 팀원 엔진 나오기 전 화면 개발 할 수 있게 고정된 가짜 결과 돌려주게 만듦
      • Literal : 정해진 값 중 하나만 허용
    • response_model 걸어두면 형식에 안 맞는 결과 나올 때 FastAPI가 에러나게 해줌
      • MOCK : 가짜 기능 대신 형식만 맞춘 고정 데이터 : 가짜 응답
    • Pydantic : 데이터 형식을 클래스로 정의하고 들어오고 나가는 값이 형식에 맞는지 검사해주는 라이브러리
    • HTTP 메서드 : 요청의 종류
      • GET : 조회 (데이터 안 실음)
        • /api/health
      • POST : 데이터 실어서 보냄
        • /api/diagnose
  • CORS 설정

    app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"])
    
    • 개발중인 화면 (5173)과 서버 (8000)가 주소가 달라서 서버가 5173에서 오는 요청 받겠다는 허용문
    • CORS : 브라우저가 다른 주소로 가는 요청을 막는 규칙
      • 서버가 허용 목록을 주면 통과
    • 미들웨어 : 모든 요청이 거쳐 가는 중간 처리 단계
  • .gitignore / 첫 push

    git init
    git add .
    git commit -m "init: 프로젝트 뼈대"
    git push -u origin main
    
    • .gitignore
      • .env , node_modules , .venv , *.pdf
        • API 키 , 개인정보, 설치 파일이 레포에 올라가지 않게 설정
      • 이후에 main에 바로 올리지 않고 브랜치 → PR → merge로 진행
        • PR (Pull Request) : 브랜치 작업을 main에 합쳐달라는 요청. 팀원 리뷰 가능
        • squash merge : 브랜치 커밋 여러 개를 하나로 합쳐서 main으로 넣는 방식

B. 로컬에서 FastAPI 서버 실행

  • 목적
    • 맥북에서 서버를 직접 띄워 뼈대가 돌아가고 가짜 결과 나오는지 확인
  • 설명
    • 로컬에서 실행

      • FastAPI 서버의 서버는 요청 받아주는 프로그램이란 뜻이지 VM이 아님.
        • 주소가 localhost:8000
    • 같은 코드가 맥북과 VM에 각각 있음

      • 맥북 → git push → GitHub → git pull → VM 순서
    • 가상환경 , 패키지 설치

      cd ~/career-navi/backend
      python3 -m venv .venv            # 이 프로젝트 전용 파이썬 환경
      source .venv/bin/activate        # 켜기 (터미널 앞에 (.venv) 뜸)
      pip install -r requirements.txt  # 목록대로 한 번에 설치
      
      • career-navi 폴더 다운 (클로드 이용)
        • .venv는 python3 -m venv 명령이 폴더까지 만들어줌
      • 프로젝트마다 환경 따로 두면 프로젝트랑 패키지 버전이랑 섞이지 않음
        • venv : 프로젝트 전용 파이썬 환경
          • 설치한 패키지가 이 폴더 안에만 들어감
        • requirements.txt : 필요한 패키지 이름 목록.
          • 팀원도 같은 걸로 설치하면 환경이 같아짐
    • 환경변수 파일

      cp .env.example .env
      
      • API 키 같은 비밀값은 .env에만
        • .env.example은 이런 키가 필요하다는 빈 양식
      • 환경변수 : 코드 밖에서 넣어주는 설정값
        • 코드를 바꾸지 않고 값만 바꿀 수 있음
    • 서버 실행

      uvicorn main:app --reload        # main.py의 app을 띄움
      
      • Application startup complete —> 뜨면 켜짐
      • uvicorn : FastAPI 앱을 실제로 실행해주는 서버 프로그램
        • Django의 runserver과 같은 역할
      • —reload : 코드를 저장하면 서버가 자동으로 켜짐.
    • Swagger로 확인

      • localhost:8000/docs를 열면 API 목록 화면에 뜸
      • POST /api/diagnose —> Try it out → execute → 응답 200
        • fit_score : 54 확인
        • 응답 200 : 요청이 정상 처리됐다는 HTTP 상태코드
        • POST : 데이터 실어서 보냄
      • GET /api/health → {”status”: “ok”}내
        • 서버가 살아있는지만 보는 용도
        • 배포 후에도 점검에 사용
        • GET : 데이터 조회 , 실어 보내진 않음
      • Swagger
        • 코드에서 자동으로 만들어지는 API 문서 겸 테스트 화면
        • 버튼으로 요청 보내볼 수 있음

C. 홈랩에 서비스용 VM 준비

  • 목적
    • 팀원이 들어와서 같이 쓸 서버를 홈랩 미니 PC에 VM으로 새로 만들고 SSH 세팅
  • 설명
    • 사양

      • 사양: 103 / careernavi / 2코어 / 4GB / 30GB / Ubuntu Server 26.04
        • RAM은 FAST API , Nginx , DB + 임베딩 모델 (약 2GB) 예상
    • 디스크 확장

      sudo lvextend -l +100%FREE -r /dev/ubuntu-vg/ubuntu-lv   # 남은 공간 전부 루트에
      df -h /                                                   # 확인
      
      • 30GB를 줬는데 설치 후 루트가 13.67GB만 잡혀 있어서 28GB로 늘림. Ubuntu 설치 기본값이 LVM 절반만 쓰는 게 원인
        • LVM : 디스크를 나중에 늘리거나 나눌 수 있게 논리적으로 관리하는 방식
          • lvextend : LVM 영역 늘리는 명령어
          • +100%FREE : 남은 공간 전부
          • -r : 파일 시스템까지 다 같이
    • Tailscale 연결

      curl -fsSL https://tailscale.com/install.sh | sh
      sudo tailscale up                 # 로그인 링크 뜸 → 내 계정에 등록
      
      • MagicDNS : IP 대신 기기 이름 (careernavi)으로 접속하게 해주는 기능
    • SSH 키 등록

      ssh-copy-id <사용자>@careernavi    # 내 공개키를 VM에 자동 등록
      
      • 비밀번호 없이 접속하게 해줌.
      • ssh-copy-id : 공개키를 서버의 authorized_keys에 자동 추가
        • authorized_keys : VM에 들어올 수 있는 공개키 목록 파일
    • Docker 설치

      curl -fsSL https://get.docker.com | sudo sh
      sudo usermod -aG docker $USER     # 나를 docker 그룹에 추가
      exit                              # 재접속해야 그룹 적용
      docker run hello-world            # 확인
      
      • usermod -aG : 사용자를 그룹에 추가
        • a : 기존 그룹 유지
        • G : 그룹 지정
      • hello-world : Docker가 이미지 받고 → 컨테이너 만들고 → 실행까지 되는지 보는 테스트

D. 뼈대 배포와 도메인 연결

  • 목적
    • 맥북에서 돌던 FastAPI를 VM에 올리고 내 도메인으로 밖에서 접속되게 만듦
  • 설명
    • 구조

      • 방문자 → careernavi.ihaewon.com → Cloudflare → tunnel → VM의 Cloudflare → localhost:8000 (FastAPI 컨테이너)
      • 서브도메인 : 도메인 앞에 이름을 붙인 하위 주소
    • 배포 키로 코드 받기

      ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N ""   # VM 전용 키
      cat ~/.ssh/github_deploy.pub                            # → GitHub 레포 Deploy keys에 등록
      git clone [email protected]:<내 계정>/career-navi.git ~/career-navi
      
      • 내 개인 GitHub 키를 VM에 두지 않고, 이 레포 하나만 읽을 수 있는 키를 따로 만듦
      • Allow write access 체크하지 않음
        • VM에서 push하지 못 함 —> VM에 직접 코드 고쳐서 push하면 맥북이랑 어긋나기 때문에 받기만 하는 곳으로 고정
          • 코드 수정은 맥북에서 push
          • VM은 git pull 로 받기만 하고 실행
      • ~/.ssh/config 에 IdentityFile ~/.ssh/github_deploy를 적어 깃허브 접속할 때 이 키를 쓰게 함
    • FastAPI 컨테이너

      docker run -d --name api --restart unless-stopped \
        -p 8000:8000 \
        -v ~/career-navi/backend:/app -w /app \
        python:3.12-slim \
        sh -c "pip install -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8000"
      
      • 이미지를 따로 만들지 않고 공식 파이썬 이미지에 코드 폴더 연결해서 시작할 때 설치
        • 코드 바꾸면 git pull + docker restart api 로 변경
        • 대신 킬 때마다 설치해서 첫 응답까지 좀 걸림
      • -v : 볼륨, VM 폴더를 컨테이너 안 폴더에 연결
      • — restart unless-stopped : VM 재부팅돼도 자동 재시작
      • --host 0.0.0.0 : 컨테이너 밖에서 오는 요청도 받음.
        • 기본값이면 컨테이너 안에서만 됨
    • Cloudflare Tunnel

      cloudflared tunnel create careernavi
      cloudflared tunnel route dns careernavi careernavi.ihaewon.com   # DNS 자동 등록
      sudo cloudflared service install                                 # 부팅 시 자동 실행
      

      /etc/cloudflared.config.yml

      ingress:
        - hostname: careernavi.ihaewon.com
          service: http://localhost:8000
        - service: http_status:404
      
      • VM이 Cloudflare 쪽으로 먼저 연결 맺는 방식
      • 터널 인증 파일 (.json)은 VM에서만 실행
      • CNAME : 이 주소는 저 주소를 따라가라는 DNS 레코드
        • route dns가 자동으로 만듦
        • cloudflare 대시보드 → ihaewon.com → DNS에 존재
          • <터널ID>.cfargotunnel.com으로 가라고 적어두고 cloudflare가 터널 통해 VM으로 연결

E. React 화면과 Nginx 배포

  • 목표
    • 입력창 , 진단 버튼, 결과가 있는 화면을 만들고 Nginx로 화면과 API를 한 주소에서 서비스
  • 설명
    • 구조

      • 방문자 → 터널 → localhost:80 (nginx) → /는 react 빌드 파일, /api/는 FastAPI(8000)
      • Nginx 는 정문 역할 , 화면 파일은 직접 줌
      • /api/는 뒤의 FastAPI로 넘김
      • 리버스 프록시 : 받은 요청은 뒤쪽 다른 서버로 대신 전달
    • React 프로젝트

      npm create vite@latest frontend -- --template react
      cd frontend && npm install
      npm run dev                          # localhost:5173
      

      vite.config.js

      server: { proxy: { '/api': 'http://localhost:8000' } }
      
      • 화면 코드는 fetch(’/api/diagnose’) 처럼 상대 주소만 사용
        • 개발땐 Vite, 배포 때는 Nginx가 넘겨줘서 코드 수정없이 둘 다 동작
          • Vite : 리엑트 프로젝트 생성, 개발 서버, 빌드 도구
          • proxy : 개발중 /api 요청을 백엔드로 넘기는 설정
      • 화면이 서버 주소를 몰라도 실행되게 해둠
        • 화면은 /api/… 만 부르고 어디로 보낼지는 Vite(개발), Nginx(배포)가 결정 → 화면 코드 수정 없이 둘 다 동작
    • 빌드 (VM)

      docker run --rm -v ~/career-navi/frontend:/app -w /app node:22 \
        sh -c "npm install && npm run build"      # → frontend/dist
      
      • VM에 Node 깔지 않고 빌드할 때만 컨테이너를 잠깐 띄움 → 서버에 도구가 쌓이지 않음
        • 빌드 : 리엑트 코드를 브라우저가 바로 읽는 html, js, css로 묶기
        • —rm : 끝나면 컨테이너 자동 삭제
    • Nginx 설정 (~/nginx/careernavi.conf)

      server {
          listen 80;
          root /usr/share/nginx/html;
          location / { try_files $uri /index.html; }
          location /api/ { proxy_pass http://host.docker.internal:8000; }
      }
      
      docker run -d --name web --restart unless-stopped -p 80:80 \
        --add-host host.docker.internal:host-gateway \
        -v ~/career-navi/frontend/dist:/usr/share/nginx/html \
        -v ~/nginx/careernavi.conf:/etc/nginx/conf.d/default.conf nginx
      
      • try_files : 없는 경로는 리엑트 첫 화면 —> /demo 같은 주소도 리엑트가 받음
      • host.docker.internal : 컨테이너 안에서 자기를 가르킴
      • 설정 파일은 서버마다 달라서 레포가 아닌 VM에서만
    • 터널 대상 변경

      sudo sed -i 's#localhost:8000#localhost:80#' /etc/cloudflared/config.yml
      sudo systemctl restart cloudflared
      
      • 8000(FastAPI) → 80(Nginx)으로 바꿈.
        • 바꾸기 전에 VM 안에서 curl localhost로 html 나오는지 먼저 확인
        • D 과정에서는 화면이 없었고 FastAPI만 있어서 터널을 8000에 꽂았는데 E 과정에서 리엑트 화면이 생기면서 80이 정문이 됨
          • /api만 FastAPI로 넘기므로 터널 목적지를 바꿈
    • 반영

      cd ~/career-navi && git pull
      docker restart api                                  # 백엔드
      docker run --rm ... node:22 sh -c "npm run build"   # 화면
      docker restart web