시나리오: 포털에 업무 로직을 입히다
1편에서 24시간 죽지 않는 점검 포털을 만들었습니다. 그런데 공장장이 다시 찾아옵니다:
"포털은 잘 돌아가는데... 지금은 그냥 '서버 상태: 정상' 페이지밖에 없잖아요? 현장에서 설비 이상을 발견하면 점검 요청을 접수하고, 담당자를 배정하고, 처리가 완료되면 이력이 남아야 합니다. 진짜 업무 시스템을 만들어 주세요!"
이번 미니 프로젝트에서는 점검 포털의 두뇌인 REST API를 설계하고 구현합니다.
FastAPI를 선택한 이유:
- Python 기반으로 학습 곡선이 낮음
- Swagger UI가 자동 생성되어 API 문서화와 테스트가 즉시 가능
- 비동기 지원과 타입 힌트로 프로덕션급 품질 확보 가능
이 실습을 마치면 여러분은:
- 비즈니스 요구사항을 데이터 모델과 API 엔드포인트로 설계할 수 있습니다
- FastAPI로 CRUD API를 구현하고 Swagger에서 테스트할 수 있습니다
- 1편에서 만든 EC2 위에 API 서버를 배포할 수 있습니다
이 프로젝트는 스마트 설비 운영 지원 플랫폼 시리즈의 두 번째입니다. 1편의 ALB + EC2 인프라 위에 API를 배포합니다. 1편을 완료하지 않았다면, 단독 EC2 인스턴스에서도 진행할 수 있습니다.
실습을 시작하기 전에 AWS 콘솔에 로그인되어 있는지 확인하세요. 리전은 ap-northeast-2 (서울) 을 사용합니다.
아키텍처 개요

데이터 흐름
비용 예측
비용 계산기
시간
* 실제 비용은 AWS 요금 정책에 따라 달라질 수 있습니다.
데이터 모델 설계

업무 요구사항을 분석하면 4개의 핵심 엔티티가 필요합니다.
상태 전이 다이어그램
점검 요청은 다음 상태를 순서대로 거칩니다:
API 엔드포인트 설계
Step 1: EC2에 Python 환경 설정
1편에서 만든 EC2에 SSH로 접속하여 FastAPI 개발 환경을 준비합니다.
- 1EC2 콘솔 → 인스턴스 → 1편에서 생성된 인스턴스의 퍼블릭 IP 확인
- 2SSH 접속: ssh -i key.pem ec2-user@<퍼블릭-IP>
- 3Python 3.11 설치 확인:
# Amazon Linux 2023에는 Python 3.9+가 기본 설치됨
python3 --version
# pip 업그레이드
python3 -m pip install --upgrade pip
# 프로젝트 디렉토리 생성
mkdir -p ~/inspection-api && cd ~/inspection-api
# 가상환경 생성 및 활성화
python3 -m venv venv
source venv/bin/activate
# 필요 패키지 설치
pip install fastapi uvicorn[standard] pydantic sqlalchemy가상환경(venv)을 사용하면 시스템 Python과 프로젝트 패키지를 분리할 수 있습니다.
실무에서도 프로젝트마다 가상환경을 만드는 것이 좋은 습관입니다.
Step 2: 데이터 모델 구현

SQLAlchemy ORM으로 데이터 모델을 정의합니다. SQLite를 사용하여 별도의 DB 서버 없이 진행합니다.
# models.py - SQLAlchemy ORM 모델
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from database import Base
from datetime import datetime
import enum
class PriorityLevel(str, enum.Enum):
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"
CRITICAL = "CRITICAL"
class RequestStatus(str, enum.Enum):
PENDING = "PENDING"
ASSIGNED = "ASSIGNED"
IN_PROGRESS = "IN_PROGRESS"
ON_HOLD = "ON_HOLD"
COMPLETED = "COMPLETED"
class Equipment(Base):
__tablename__ = "equipments"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
location = Column(String, nullable=False)
type = Column(String, nullable=False)
status = Column(String, default="ACTIVE")
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
role = Column(String, nullable=False) # "worker" | "manager" | "engineer"
department = Column(String, nullable=False)
class InspectionRequest(Base):
__tablename__ = "inspection_requests"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(String)
equipment_id = Column(Integer, ForeignKey("equipments.id"))
requester_id = Column(Integer, ForeignKey("users.id"))
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
priority = Column(String, default=PriorityLevel.MEDIUM)
status = Column(String, default=RequestStatus.PENDING)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
equipment = relationship("Equipment")
histories = relationship("StatusHistory", back_populates="request")
class StatusHistory(Base):
__tablename__ = "status_histories"
id = Column(Integer, primary_key=True, index=True)
request_id = Column(Integer, ForeignKey("inspection_requests.id"))
from_status = Column(String)
to_status = Column(String, nullable=False)
changed_by = Column(Integer, ForeignKey("users.id"))
changed_at = Column(DateTime, default=datetime.utcnow)
comment = Column(String)
request = relationship("InspectionRequest", back_populates="histories")# database.py - DB 연결 설정
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
SQLALCHEMY_DATABASE_URL = "sqlite:///./inspection.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # SQLite 전용
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()# schemas.py - Pydantic 스키마 (요청/응답 검증)
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional
class RequestCreate(BaseModel):
title: str = Field(..., min_length=2, max_length=200, example="1라인 컨베이어 이상 소음")
description: Optional[str] = Field(None, example="가동 시 좌측 롤러에서 비정상 소음 발생")
equipment_id: int = Field(..., example=1)
requester_id: int = Field(..., example=1)
priority: str = Field(default="MEDIUM", example="HIGH")
class StatusUpdate(BaseModel):
status: str = Field(..., example="ASSIGNED")
changed_by: int = Field(..., example=2)
comment: Optional[str] = Field(None, example="김기사에게 배정")
assignee_id: Optional[int] = Field(None, example=3)
class RequestResponse(BaseModel):
id: int
title: str
description: Optional[str]
equipment_id: int
requester_id: int
assignee_id: Optional[int]
priority: str
status: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class EquipmentResponse(BaseModel):
id: int
name: str
location: str
type: str
status: str
class Config:
from_attributes = TrueSQLite는 학습용입니다. 프로덕션에서는 RDS(PostgreSQL/MySQL)를 사용해야 합니다. SQLite는 동시 쓰기가 제한되고, EC2가 종료되면 데이터가 사라집니다. 4편 통합 프로젝트에서 RDS로 전환하는 확장 아이디어를 다룹니다.
Step 3: API 라우트 구현
FastAPI로 5개의 엔드포인트를 구현합니다.
- 1~/inspection-api/main.py 파일을 생성합니다
- 2아래 코드를 입력합니다
- 3초기 데이터(seed data)를 포함하여 테스트가 바로 가능하도록 합니다
# main.py
from fastapi import FastAPI, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from database import engine, get_db, Base
from models import Equipment, User, InspectionRequest, StatusHistory
from schemas import RequestCreate, StatusUpdate, RequestResponse, EquipmentResponse
from typing import Optional
# DB 테이블 생성
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="설비 점검 요청 관리 API",
description="대한설비(주) 현장 설비 점검 요청을 관리하는 REST API",
version="1.0.0",
)
# ──────────────────────────────────────
# 초기 데이터 (앱 시작 시 한 번 실행)
# ──────────────────────────────────────
@app.on_event("startup")
def seed_data():
db = next(get_db())
if db.query(Equipment).count() == 0:
equipments = [
Equipment(name="1라인 컨베이어", location="A동 1층", type="컨베이어", status="ACTIVE"),
Equipment(name="2라인 프레스기", location="A동 2층", type="프레스", status="ACTIVE"),
Equipment(name="냉각 시스템 A", location="B동 지하", type="냉각장치", status="ACTIVE"),
Equipment(name="자동 용접 로봇", location="C동 1층", type="로봇", status="MAINTENANCE"),
]
users = [
User(name="박현장", role="worker", department="생산1팀"),
User(name="이관리", role="manager", department="관리팀"),
User(name="김기사", role="engineer", department="설비팀"),
]
db.add_all(equipments + users)
db.commit()
db.close()
# ──────────────────────────────────────
# 1. GET /api/v1/equipments — 설비 목록
# ──────────────────────────────────────
@app.get("/api/v1/equipments", response_model=list[EquipmentResponse], tags=["설비"])
def list_equipments(db: Session = Depends(get_db)):
return db.query(Equipment).all()
# ──────────────────────────────────────
# 2. POST /api/v1/requests — 점검 요청 접수
# ──────────────────────────────────────
@app.post("/api/v1/requests", response_model=RequestResponse, status_code=201, tags=["점검 요청"])
def create_request(req: RequestCreate, db: Session = Depends(get_db)):
equipment = db.query(Equipment).filter(Equipment.id == req.equipment_id).first()
if not equipment:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다")
new_request = InspectionRequest(**req.model_dump())
db.add(new_request)
db.commit()
db.refresh(new_request)
# 상태 이력 기록
history = StatusHistory(
request_id=new_request.id,
from_status=None,
to_status="PENDING",
changed_by=req.requester_id,
comment="점검 요청 접수"
)
db.add(history)
db.commit()
return new_request
# ──────────────────────────────────────
# 3. GET /api/v1/requests — 요청 목록 (필터링)
# ──────────────────────────────────────
@app.get("/api/v1/requests", response_model=list[RequestResponse], tags=["점검 요청"])
def list_requests(
status: Optional[str] = Query(None, description="상태 필터 (PENDING, ASSIGNED 등)"),
priority: Optional[str] = Query(None, description="우선순위 필터 (LOW, MEDIUM, HIGH, CRITICAL)"),
db: Session = Depends(get_db),
):
query = db.query(InspectionRequest)
if status:
query = query.filter(InspectionRequest.status == status)
if priority:
query = query.filter(InspectionRequest.priority == priority)
return query.order_by(InspectionRequest.created_at.desc()).all()
# ──────────────────────────────────────
# 4. GET /api/v1/requests/{id} — 요청 상세
# ──────────────────────────────────────
@app.get("/api/v1/requests/{request_id}", response_model=RequestResponse, tags=["점검 요청"])
def get_request(request_id: int, db: Session = Depends(get_db)):
req = db.query(InspectionRequest).filter(InspectionRequest.id == request_id).first()
if not req:
raise HTTPException(status_code=404, detail="점검 요청을 찾을 수 없습니다")
return req
# ──────────────────────────────────────
# 5. PUT /api/v1/requests/{id}/status — 상태 변경
# ──────────────────────────────────────
VALID_TRANSITIONS = {
"PENDING": ["ASSIGNED"],
"ASSIGNED": ["IN_PROGRESS"],
"IN_PROGRESS": ["COMPLETED", "ON_HOLD"],
"ON_HOLD": ["IN_PROGRESS"],
}
@app.put("/api/v1/requests/{request_id}/status", response_model=RequestResponse, tags=["점검 요청"])
def update_status(request_id: int, update: StatusUpdate, db: Session = Depends(get_db)):
req = db.query(InspectionRequest).filter(InspectionRequest.id == request_id).first()
if not req:
raise HTTPException(status_code=404, detail="점검 요청을 찾을 수 없습니다")
allowed = VALID_TRANSITIONS.get(req.status, [])
if update.status not in allowed:
raise HTTPException(
status_code=400,
detail=f"'{req.status}'에서 '{update.status}'로 변경할 수 없습니다. 가능한 상태: {allowed}"
)
# 상태 이력 기록
history = StatusHistory(
request_id=req.id,
from_status=req.status,
to_status=update.status,
changed_by=update.changed_by,
comment=update.comment
)
req.status = update.status
if update.assignee_id:
req.assignee_id = update.assignee_id
db.add(history)
db.commit()
db.refresh(req)
return req
# ──────────────────────────────────────
# Health Check (ALB용)
# ──────────────────────────────────────
@app.get("/health", tags=["시스템"])
def health_check():
return {"status": "healthy", "service": "inspection-api"}VALID_TRANSITIONS 딕셔너리로 상태 전이 규칙을 명시적으로 관리합니다.
이렇게 하면 유효하지 않은 상태 변경(예: PENDING에서 바로 COMPLETED)을 API 레벨에서 차단합니다.
실무에서는 상태 머신(State Machine) 패턴이라고 합니다.
Step 4: Swagger UI에서 API 테스트
FastAPI의 가장 큰 장점인 자동 생성 Swagger UI를 사용하여 API를 테스트합니다.
- 1EC2에서 FastAPI 서버를 시작합니다:
cd ~/inspection-api
source venv/bin/activate
# 모든 네트워크 인터페이스에서 접속 가능하도록 0.0.0.0으로 바인딩
uvicorn main:app --host 0.0.0.0 --port 8000 --reload- 1브라우저에서 http://<EC2-퍼블릭-IP>:8000/docs 접속
- 2Swagger UI가 열리면 5개 API 엔드포인트가 나열된 것을 확인합니다
- 3GET /api/v1/equipments → Try it out → Execute → 4개 설비 데이터 확인
- 4POST /api/v1/requests → Try it out → Request Body에 아래 입력 후 Execute:
{
"title": "1라인 컨베이어 이상 소음",
"description": "가동 시 좌측 롤러에서 비정상 소음 발생. 즉시 점검 필요.",
"equipment_id": 1,
"requester_id": 1,
"priority": "HIGH"
}응답 코드 201 Created와 함께 생성된 점검 요청 데이터가 반환되면 성공입니다.
id, status: "PENDING", created_at 등이 자동으로 채워진 것을 확인하세요.
Step 5: 업무 워크플로 데모

실제 업무 흐름을 시뮬레이션합니다. Swagger UI 또는 curl로 진행하세요.
- 1점검 요청 접수 (박현장이 1라인 컨베이어 이상 발견):
# 1. 점검 요청 접수 (현장 근무자)
curl -X POST http://localhost:8000/api/v1/requests \
-H "Content-Type: application/json" \
-d '{"title":"1라인 컨베이어 이상 소음","equipment_id":1,"requester_id":1,"priority":"HIGH"}'
# 2. 담당자 배정 (관리자 → 김기사에게 배정)
curl -X PUT http://localhost:8000/api/v1/requests/1/status \
-H "Content-Type: application/json" \
-d '{"status":"ASSIGNED","changed_by":2,"comment":"김기사에게 배정","assignee_id":3}'
# 3. 작업 시작 (김기사가 점검 시작)
curl -X PUT http://localhost:8000/api/v1/requests/1/status \
-H "Content-Type: application/json" \
-d '{"status":"IN_PROGRESS","changed_by":3,"comment":"현장 도착, 점검 시작"}'
# 4. 점검 완료 (김기사가 수리 완료)
curl -X PUT http://localhost:8000/api/v1/requests/1/status \
-H "Content-Type: application/json" \
-d '{"status":"COMPLETED","changed_by":3,"comment":"롤러 베어링 교체 완료"}'
# 5. 요청 상세 조회 (모든 이력 포함)
curl http://localhost:8000/api/v1/requests/1 | python3 -m json.toolEC2 배포: ALB 연동
1편에서 만든 ALB와 연동하여 외부에서 접근할 수 있도록 설정합니다.
EC2가 재시작되어도 FastAPI가 자동으로 실행되도록 systemd 서비스를 등록합니다:
sudo tee /etc/systemd/system/inspection-api.service > /dev/null <<'EOF'
[Unit]
Description=Inspection API (FastAPI)
After=network.target
[Service]
Type=simple
User=ec2-user
WorkingDirectory=/home/ec2-user/inspection-api
Environment=PATH=/home/ec2-user/inspection-api/venv/bin:/usr/bin
ExecStart=/home/ec2-user/inspection-api/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable inspection-api
sudo systemctl start inspection-api
# 상태 확인
sudo systemctl status inspection-api1편의 ALB에서 포트 8000으로 트래픽을 보내도록 대상 그룹을 수정합니다:
- EC2 콘솔 → 대상 그룹 →
inspection-tg선택 - 속성 탭 → 편집 → 포트를 8000으로 변경
- Health Check 경로를
/health로 변경 - 또는 새 대상 그룹을 만들어 ALB에 경로 기반 라우팅 규칙을 추가:
/api/*→ 새 대상 그룹(포트 8000)/docs→ 새 대상 그룹(포트 8000)/*→ 기존 대상 그룹(포트 80, 정적 포털)
EC2 보안 그룹 확인! 포트 8000을 사용하는 경우, EC2 보안 그룹에 포트 8000 인바운드 규칙을 추가해야 합니다. 소스는 ALB 보안 그룹 ID로 지정합니다.
직접 설명해 보기
본인의 말로 설명해 보세요
REST API에서 HTTP 메서드(GET, POST, PUT, DELETE)를 어떤 기준으로 선택하는지 설명해 보세요.
💡 각 메서드의 '의미'와 '멱등성' 관점에서 생각해 보세요.
완성 후 테스트 가이드
- 1Swagger UI 접속: http://<EC2-IP>:8000/docs에서 모든 엔드포인트가 표시되는지 확인
- 2설비 목록 조회: GET /api/v1/equipments → 4개 설비 반환 확인
- 3점검 요청 생성: POST /api/v1/requests → 201 Created 응답 확인
- 4요청 목록 조회: GET /api/v1/requests → 방금 생성한 요청이 목록에 있는지 확인
- 5상태 필터링: GET /api/v1/requests?status=PENDING → PENDING 요청만 반환되는지 확인
- 6상태 변경 워크플로: PENDING → ASSIGNED → IN_PROGRESS → COMPLETED 순서대로 변경
- 7유효하지 않은 전이: PENDING → COMPLETED 직접 변경 시도 → 400 에러 확인
- 8Health Check: GET /health → {"status": "healthy"} 응답 확인
트러블슈팅
uvicorn: command not found 오류:
가상환경이 활성화되지 않았습니다. source ~/inspection-api/venv/bin/activate를 실행하세요.
또는 전체 경로로 실행: ~/inspection-api/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
ModuleNotFoundError: No module named 'fastapi' 오류:
가상환경 안에서 패키지를 설치했는지 확인하세요: pip install fastapi uvicorn[standard] pydantic sqlalchemy
포트 8000으로 접속이 안 되는 경우:
- EC2 보안 그룹에 포트 8000 인바운드 규칙이 있는지 확인합니다
sudo ss -tlnp | grep 8000으로 uvicorn이 실제로 리스닝 중인지 확인합니다--host 0.0.0.0옵션이 포함되었는지 확인합니다 (기본값은 127.0.0.1로 외부 접근 불가)
Swagger UI에서 "Internal Server Error" 발생:
터미널에서 uvicorn 로그를 확인하세요. 대부분 import 오류나 파일명 불일치가 원인입니다.
--reload 옵션을 사용하면 코드 변경 시 자동으로 서버가 재시작됩니다.
DB 초기화가 필요한 경우:
rm ~/inspection-api/inspection.db 후 서버를 재시작하면 seed_data가 다시 실행됩니다.
확장 아이디어
- 인증 추가: FastAPI의 OAuth2 + JWT를 사용하여 API 인증을 구현해 보세요. 역할별 접근 제어(worker는 생성만, manager는 배정도 가능)를 추가합니다.
- 파일 첨부: 현장 사진을 S3에 업로드하고 URL을 점검 요청에 첨부하는 기능을 추가해 보세요.
- RDS 전환: SQLite 대신 RDS PostgreSQL을 사용하여 데이터 영속성을 확보해 보세요.
- 상태 변경 알림: SNS를 연동하여 상태가 변경될 때마다 관련자에게 이메일/SMS 알림을 보내 보세요.
- API 버전 관리:
/api/v2를 만들어 기존 클라이언트와의 호환성을 유지하면서 새 기능을 추가해 보세요.
다음 단계 미리보기
다음 미니 프로젝트 "서버리스 이상징후 신고 서비스"에서는 같은 도메인(설비 점검)을 서버리스 방식으로 구현합니다. API Gateway + Lambda + DynamoDB로 만든 후, 이번 프로젝트의 EC2 기반 API와 비교하여 어떤 상황에서 어떤 접근법이 더 적합한지 분석합니다.
학습 정리
핵심 치트시트
이 프로젝트에서는 비즈니스 요구사항을 데이터 모델과 REST API로 설계하고, FastAPI로 구현하여 EC2에 배포했습니다. 핵심은 데이터 모델 설계, 상태 머신 패턴, Swagger UI 활용입니다.
리소스 정리
다음 미니 프로젝트를 바로 진행하지 않는다면, 아래 순서대로 정리하세요.
- EC2에서 FastAPI 서비스 중단:
sudo systemctl stop inspection-api - 1편의 리소스 정리 순서를 따릅니다 (ASG → ALB → EC2 등)