TIL/[TIL]

[TIL]FastAPI REST API, Router 분리, File Upload, Spring 연동

namerong 2026. 7. 27. 16:09

1. 학습 주제

FastAPI로 REST API를 구성하고, 기능별 Router 분리, 파일 입출력, 파일 업로드, Spring 서버와 FastAPI 서버를 HTTP로 연결하는 흐름을 학습했다.

핵심 내용은 다음과 같다.

  • REST CRUD API 구현
  • GET, PUT, PATCH 차이 이해
  • APIRouter로 기능별 API 분리
  • Pydantic Schema 재사용
  • Python Path를 활용한 파일 입출력
  • multipart/form-data 기반 파일 업로드
  • 파일 확장자, Content-Type, 용량 검증
  • Spring RestClient로 FastAPI 호출
  • Spring 서버가 FastAPI 서버를 중간에서 연결하는 구조

2. REST CRUD API

REST API는 URL과 HTTP Method를 조합해서 자원을 다루는 방식이다.

이번 실습에서는 Task 자원을 기준으로 목록 조회, 단건 조회, 전체 수정, 부분 수정을 구현했다.

GET /api/v1/tasks
GET /api/v1/tasks/{task_id}
PUT /api/v1/tasks/{task_id}
PATCH /api/v1/tasks/{task_id}

각 Method의 역할은 다음과 같다.

Method 역할
GET 데이터 조회
POST 새 데이터 생성
PUT 기존 데이터를 전체 교체
PATCH 기존 데이터 일부 수정
DELETE 데이터 삭제

3. GET 목록 조회와 필터링

목록 조회에서는 Query Parameter를 사용해 조건 검색을 구현했다.

def list_tasks(
    completed: bool | None = None,
    priority: Literal["low", "normal", "high"] | None = None,
    keyword: Annotated[str | None, Query(min_length=2)] = None
):

요청 예시는 다음과 같다.

GET /api/v1/tasks?completed=true
GET /api/v1/tasks?priority=high
GET /api/v1/tasks?keyword=FastAPI

Query Parameter는 특정 자원 하나를 찾기보다는 목록에서 조건을 걸 때 자주 사용한다.


4. PUT과 PATCH 차이

PUT은 자원 전체를 교체하는 방식이다.

replaced = TaskResponse(id=task_id, **request.model_dump())
tasks[task_id] = replaced

즉, 클라이언트가 title, description, priority, completed를 모두 보내야 한다.
기존 값 중 일부만 유지하고 싶어도 전체 데이터를 다시 보내는 방식에 가깝다.

반면 PATCH는 일부 필드만 수정한다.

changed_fields = request.model_dump(exclude_unset=True)
updated = task.model_copy(update=changed_fields)

여기서 핵심은 exclude_unset=True이다.
클라이언트가 실제로 보낸 필드만 추려서 수정하기 때문에, 보내지 않은 필드는 기존 값이 유지된다.

PUT = 전체 교체
PATCH = 일부 수정

5. APIRouter로 API 분리

API가 많아지면 하나의 main.py에 모든 코드를 넣기 어렵다.
그래서 FastAPI에서는 APIRouter를 사용해 기능별로 API를 나눌 수 있다.

router = APIRouter(prefix="/students", tags=["students"])

그리고 메인 앱에서 router를 조립한다.

app.include_router(courses.router, prefix="/api/v1")
app.include_router(students.router, prefix="/api/v1")

이 구조를 사용하면 다음처럼 역할을 나눌 수 있다.

main.py
-> FastAPI 앱 생성
-> router 등록
-> 공통 설정 관리

students.py
-> 학생 API 담당

courses.py
-> 강좌 API 담당

schemas.py
-> 공통 요청/응답 모델 관리

Spring에서 Controller를 기능별로 나누는 것과 비슷한 느낌으로 이해할 수 있다.


6. Schema 분리

schemas.py에는 여러 router에서 함께 사용하는 Pydantic 모델을 분리했다.

class StudentCreate(BaseModel):
    name: str = Field(min_length=2, max_length=30)
    level: Level = "beginner"
class CourseResponse(BaseModel):
    id: int
    title: str
    level: Level
    hours: int

Schema를 분리하면 API 파일에서는 요청 처리 흐름에 집중할 수 있고, 데이터 구조는 한 곳에서 관리할 수 있다.


7. Python File I/O

파일 입출력에서는 pathlib.Path를 사용했다.

BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"

파일 읽기와 쓰기는 다음처럼 처리했다.

path.read_text(encoding="utf-8")
path.write_text(content, encoding="utf-8")

바이너리 파일은 read_bytes(), write_bytes()를 사용한다.

target.write_bytes(source.read_bytes())

텍스트 파일과 이미지, PDF 같은 바이너리 파일은 처리 방식이 다르다.

구분 사용 메서드
텍스트 read_text, write_text
바이너리 read_bytes, write_bytes

8. 안전한 파일 경로 처리

파일명을 그대로 조합하면 사용자가 ../ 같은 경로를 넣어 data 폴더 밖 파일에 접근할 위험이 있다.

그래서 resolve()를 사용해 실제 경로를 계산하고, 기준 폴더 안에 있는지 확인했다.

candidate = (DATA_DIR / filename).resolve()

if DATA_DIR.resolve() not in candidate.parents:
    raise ValueError("data폴더 밖의 경로는 사용할 수 없습니다.")

파일 API에서는 단순히 저장하고 읽는 것뿐 아니라, 허용된 폴더 밖으로 나가지 못하게 막는 것이 중요하다.


9. Form과 File Upload

파일 업로드는 일반 JSON 요청과 다르게 multipart/form-data 형식을 사용한다.

FastAPI에서는 파일은 File, 일반 폼 값은 Form으로 받는다.

async def upload_file(
    file: Annotated[UploadFile, File()],
    description: Annotated[str, Form(min_length=1, max_length=100)]
):

요청에는 다음 값이 함께 들어간다.

file = 업로드 파일
description = 파일 설명

즉, 파일과 일반 문자열 값을 하나의 요청으로 함께 보낼 수 있다.


10. UploadFile 검증

파일 업로드에서는 다음 검증을 처리했다.

  • 허용된 Content-Type인지 확인
  • 파일 확장자와 Content-Type이 일치하는지 확인
  • 파일 크기가 1MB 이하인지 확인
  • 원본 파일명에서 경로 정보를 제거
  • UUID로 저장 파일명 생성
content = await file.read(MAX_FILE_SIZE + 1)

if len(content) > MAX_FILE_SIZE:
    raise HTTPException(status_code=413, detail="파일은 1MB 이하여야 합니다.")

파일명은 사용자가 보낸 값을 그대로 저장하지 않고 UUID를 사용했다.

file_id = str(uuid4())
stored_name = f"{file_id}{suffix}"

이렇게 하면 같은 이름의 파일이 업로드되어도 충돌을 줄일 수 있고, 사용자가 보낸 파일명에 의존하지 않아도 된다.


11. 파일 Metadata 저장

파일 본문은 .txt 또는 .pdf로 저장하고, 파일 정보는 .json으로 따로 저장했다.

metadata_path(file_id).write_text(
    json.dumps(info.model_dump(), ensure_ascii=False, indent=2),
    encoding="utf-8"
)

저장되는 정보는 다음과 같다.

id
original_name
content_type
size
description
stored_name

파일 자체와 파일 설명 정보를 분리해서 저장하면, 나중에 목록 조회나 다운로드 기능을 만들 때 활용하기 쉽다.


12. Spring과 FastAPI 연동

Spring 서버에서 FastAPI 서버를 직접 호출하는 구조도 학습했다.

FastAPI 쪽에는 상태 확인 API가 있다.

@app.get("/health")
def health():
    return {"status": "ok", "service": "fastapi-file-service"}

Spring에서는 RestClient를 사용해 FastAPI 서버로 HTTP 요청을 보낸다.

return restClient.get()
        .uri("/health")
        .retrieve()
        .body(HealthResponse.class);

흐름은 다음과 같다.

클라이언트
-> Spring Controller
-> Spring Service 또는 Client
-> FastAPI 서버 HTTP 호출
-> FastAPI 응답
-> Spring 응답 반환

중요한 점은 Spring이 Python 함수를 직접 실행하는 것이 아니라, HTTP API로 FastAPI 서버를 호출한다는 것이다.


13. Spring에서 외부 API 장애 처리

FastAPI 서버가 꺼져 있거나 연결할 수 없는 경우를 대비해 예외 처리를 추가했다.

try {
    return ResponseEntity.ok(fastApiFileClient.checkHealth());
} catch (RestClientException e) {
    return unavailable("FastAPI서버에 연결할 수 없습니다.");
}

외부 서버 호출은 실패할 수 있기 때문에, 예외를 그대로 터뜨리지 않고 명확한 응답으로 바꿔주는 것이 좋다.

503 Service Unavailable
{
  "code": "FASTAPI_UNAVAILABLE",
  "message": "FastAPI서버에 연결할 수 없습니다."
}

14. 핵심 정리

  1. REST API는 HTTP Method와 URL을 조합해 자원을 다룬다.
  2. GET은 조회, PUT은 전체 교체, PATCH는 일부 수정에 사용한다.
  3. PATCH에서는 exclude_unset=True를 사용하면 클라이언트가 보낸 필드만 수정할 수 있다.
  4. API가 많아지면 APIRouter로 기능별 파일을 분리하는 것이 좋다.
  5. 공통 요청/응답 모델은 schemas.py처럼 따로 분리하면 재사용하기 쉽다.
  6. 파일 입출력에서는 Path를 사용하면 경로를 안전하고 명확하게 다룰 수 있다.
  7. 파일 API에서는 data 폴더 밖 접근을 막는 경로 검증이 필요하다.
  8. 파일 업로드는 multipart/form-data 형식으로 처리한다.
  9. FastAPI에서는 UploadFile, File, Form을 사용해 파일과 일반 값을 함께 받을 수 있다.
  10. 업로드 파일은 Content-Type, 확장자, 크기를 검증해야 한다.
  11. 저장 파일명은 UUID를 사용하면 파일명 충돌을 줄일 수 있다.
  12. Spring과 FastAPI는 직접 함수 호출이 아니라 HTTP 요청으로 연동한다.
  13. Spring RestClient는 외부 API 호출을 담당할 수 있다.
  14. 외부 API 호출은 실패 가능성이 있으므로 예외 처리와 503 응답 같은 장애 처리가 필요하다.