목표
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: 코드를 저장하면 서버가 자동으로 다시 켜짐
- uvicorn: FastAPI 앱을 실제로 띄워주는 서버 프로그램 (Django의
- 서버는 끝나는 명령이 아니라 계속 켜져 있음. 끄려면
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)
- ingress: 들어온 주소별로 어디로 보낼지 정하는 규칙 (여기선
- 터널 인증 파일(.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: React 프로젝트를 만들고 개발 서버(
- 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확인 - 홈랩에
careernaviVM 만들기 (Tailscale, SSH 키, Docker) - 팀원 계정 만들고 SSH 키 등록, Tailscale로 VM 공유
- 팀원 GitHub 아이디 받아서 Collaborator 초대
- careernavi VM에 뼈대 배포,
careernavi.ihaewon.com연결, QR 생성 - React 화면 만들고 Nginx로 배포 → 도메인에서 진단 화면 확인
- Claude API 전용 워크스페이스(
career-navi) + 키(careernavi-server, 30일) → VMbackend/.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
- VM
- 원인 : 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)
- competencies : 역량 목록
-
서버 뼈대 (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
- GET : 조회 (데이터 안 실음)
- 팀원 엔진 나오기 전 화면 개발 할 수 있게 고정된 가짜 결과 돌려주게 만듦
-
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으로 넣는 방식
- .env , node_modules , .venv , *.pdf
- .gitignore
B. 로컬에서 FastAPI 서버 실행
- 목적
- 맥북에서 서버를 직접 띄워 뼈대가 돌아가고 가짜 결과 나오는지 확인
- 설명
-
로컬에서 실행
- FastAPI 서버의 서버는 요청 받아주는 프로그램이란 뜻이지 VM이 아님.
- 주소가 localhost:8000
- FastAPI 서버의 서버는 요청 받아주는 프로그램이란 뜻이지 VM이 아님.
-
같은 코드가 맥북과 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 : 필요한 패키지 이름 목록.
- 팀원도 같은 걸로 설치하면 환경이 같아짐
- venv : 프로젝트 전용 파이썬 환경
- career-navi 폴더 다운 (클로드 이용)
-
환경변수 파일
cp .env.example .env- API 키 같은 비밀값은 .env에만
- .env.example은 이런 키가 필요하다는 빈 양식
- 환경변수 : 코드 밖에서 넣어주는 설정값
- 코드를 바꾸지 않고 값만 바꿀 수 있음
- API 키 같은 비밀값은 .env에만
-
서버 실행
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) 예상
- 사양: 103 / careernavi / 2코어 / 4GB / 30GB / Ubuntu Server 26.04
-
디스크 확장
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 : 파일 시스템까지 다 같이
- LVM : 디스크를 나중에 늘리거나 나눌 수 있게 논리적으로 관리하는 방식
- 30GB를 줬는데 설치 후 루트가 13.67GB만 잡혀 있어서 28GB로 늘림. Ubuntu 설치 기본값이 LVM 절반만 쓰는 게 원인
-
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가 이미지 받고 → 컨테이너 만들고 → 실행까지 되는지 보는 테스트
- usermod -aG : 사용자를 그룹에 추가
-
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 로 받기만 하고 실행
- VM에서 push하지 못 함 —> VM에 직접 코드 고쳐서 push하면 맥북이랑 어긋나기 때문에 받기만 하는 곳으로 고정
- ~/.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:5173vite.config.js
server: { proxy: { '/api': 'http://localhost:8000' } }- 화면 코드는 fetch(’/api/diagnose’) 처럼 상대 주소만 사용
- 개발땐 Vite, 배포 때는 Nginx가 넘겨줘서 코드 수정없이 둘 다 동작
- Vite : 리엑트 프로젝트 생성, 개발 서버, 빌드 도구
- proxy : 개발중 /api 요청을 백엔드로 넘기는 설정
- 개발땐 Vite, 배포 때는 Nginx가 넘겨줘서 코드 수정없이 둘 다 동작
- 화면이 서버 주소를 몰라도 실행되게 해둠
- 화면은 /api/… 만 부르고 어디로 보낼지는 Vite(개발), Nginx(배포)가 결정 → 화면 코드 수정 없이 둘 다 동작
- 화면 코드는 fetch(’/api/diagnose’) 처럼 상대 주소만 사용
-
빌드 (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 : 끝나면 컨테이너 자동 삭제
- VM에 Node 깔지 않고 빌드할 때만 컨테이너를 잠깐 띄움 → 서버에 도구가 쌓이지 않음
-
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로 넘기므로 터널 목적지를 바꿈
- 8000(FastAPI) → 80(Nginx)으로 바꿈.
-
반영
cd ~/career-navi && git pull docker restart api # 백엔드 docker run --rm ... node:22 sh -c "npm run build" # 화면 docker restart web
-