본문 바로가기
AI 개발자 성장기/AI 오케스트레이션 캠프 회고

SK네트웍스 Family 엔코아 멀티 AI 에이전트 개발자 국비지원 8일차 회고|FastAPI·Supabase CRUD 연동과 Render 배포, GitHub 팀 협업

by 랩보다 AI 더 잘해지기 2026. 7. 21.
728x90

오늘은 FastAPI 프로젝트를 Render에 배포하고, Supabase의 PostgreSQL 데이터베이스를 Python 코드와 연결하는 방법을 배웠다. 그동안 메모리 리스트나 가짜 데이터로 구현했던 CRUD를 실제 데이터베이스에 저장할 수 있도록 테이블 생성과 조회·수정·삭제 문법을 연습했다.

이후 Product API를 Router, Service, Scheme으로 나누고 가짜 데이터를 이용해 전체 CRUD 구조를 먼저 완성했다. 오후에는 전날 진행한 팀 프로젝트를 오늘 배운 구조로 다시 정리하고, 팀원별 기능을 Supabase에 연결한 뒤 GitHub에 병합해 Render로 배포하는 작업 방향을 맞췄다.

Render를 이용한 FastAPI 배포

로컬에서 FastAPI를 실행하면 보통 다음 주소를 이용한다.

http://127.0.0.1:8000
 

이 주소는 내 컴퓨터에서만 접속할 수 있다. 다른 사람이 API를 사용하려면 서버를 인터넷에 배포해야 한다.

이번에는 GitHub 저장소를 Render와 연결해 FastAPI 프로젝트를 배포했다. 배포가 완료되면 다음처럼 인터넷 주소를 이용해 서버에 접속할 수 있다.

https://프로젝트이름.onrender.com
 

Swagger 문서는 배포 주소 뒤에 /docs를 붙여 확인한다.

https://프로젝트이름.onrender.com/docs
 

Render에는 프로젝트 설치와 실행을 위한 명령어를 설정했다.

Build Command
pip install -r requirements.txt
 
Start Command
uvicorn app.main:app --host 0.0.0.0 --port $PORT
 

로컬에서는 직접 포트를 정할 수 있지만 Render에서는 서비스가 제공하는 $PORT 값을 사용해야 한다.

배포 이후에는 로컬 프로젝트를 직접 전달하지 않아도 팀원들이 배포 주소로 접속해 API를 테스트할 수 있다.

배포 환경에서 환경변수 관리하기

프로젝트에서 사용하는 API Key나 데이터베이스 접속 정보는 코드에 직접 작성하면 안 된다.

로컬에서는 .env 파일에 다음과 같이 저장한다.

 
SUPABASE_URL=Supabase_프로젝트_URL
SUPABASE_SERVICE_ROLE_KEY=Supabase_서비스_키
GEMINI_API_KEY=Gemini_API_키
 

.env에는 외부에 공개되면 안 되는 값이 들어 있으므로 .gitignore에 등록한다.

 
.env
.venv/
__pycache__/
 

GitHub에는 실제 값이 없는 .env.example만 올린다.

 
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=
GEMINI_API_KEY=
 

Render에서는 .env 파일을 직접 올리는 대신 대시보드의 Environment Variables에 같은 값을 등록한다.

이를 통해 로컬과 배포 서버에서 같은 코드를 사용하면서도 중요한 값은 안전하게 분리할 수 있다.

Supabase와 PostgreSQL 시작하기

그동안 CRUD 데이터를 파이썬 리스트에 저장했다면, 오늘부터는 Supabase에 실제로 저장하는 방법을 배웠다.

FastAPI
→ 요청을 받고 기능을 처리하는 서버

Supabase
→ 데이터를 보관하는 데이터베이스
 

파이썬 리스트에 저장한 데이터는 서버를 종료하면 사라진다. 반면 Supabase에 저장한 데이터는 서버를 다시 실행하거나 Render를 재배포해도 유지된다.

Supabase는 PostgreSQL을 기반으로 동작한다. 프로젝트를 생성한 뒤 API URL과 Key를 확인하고 Python 프로젝트의 환경변수에 등록했다.

Supabase Client 공통 함수 만들기

여러 Service 파일에서 Supabase 연결 코드를 반복하지 않도록 core 폴더에 공통 Client 함수를 만들었다.

app/
├── core/
│   └── supabase_client.py
├── routers/
├── schemes/
├── services/
└── main.py
 

연결 함수는 다음과 같은 역할을 한다.

 
import os

from dotenv import load_dotenv
from supabase import Client, create_client

load_dotenv()


def get_supabase() -> Client:
    url = os.getenv("SUPABASE_URL")
    key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")

    if not url or not key:
        raise ValueError("Supabase 환경변수를 확인해주세요.")

    return create_client(url, key)
 

Service에서는 필요한 순간에 get_supabase()를 호출한다.

 
from app.core.supabase_client import get_supabase

supabase = get_supabase()
 

공통 함수로 분리하면 연결 방식이 바뀌어도 여러 Service를 전부 수정할 필요가 없다.

Supabase CRUD 기본 문법

Supabase Python Client를 이용해 생성, 조회, 수정, 삭제를 연습했다.

데이터 생성

 
result = (
    supabase
    .table("products")
    .insert(data)
    .execute()
)
 

전체 데이터 조회

 
result = (
    supabase
    .table("products")
    .select("*")
    .execute()
)
 

특정 데이터 조회

 
result = (
    supabase
    .table("products")
    .select("*")
    .eq("id", product_id)
    .execute()
)
 

데이터 수정

 
result = (
    supabase
    .table("products")
    .update(update_data)
    .eq("id", product_id)
    .execute()
)
 

데이터 삭제

 
result = (
    supabase
    .table("products")
    .delete()
    .eq("id", product_id)
    .execute()
)
 

실행 결과에 포함된 실제 데이터는 다음과 같이 확인한다.

 
result.data
 

특정 데이터 한 개를 조회해도 결과는 리스트 형태로 반환될 수 있다.

 
result.data[0]
 

따라서 첫 번째 데이터를 바로 사용하기 전에 조회 결과가 비어 있는지도 확인해야 한다.

 
if not result.data:
    return None
 

조회 조건과 정렬 사용하기

필요한 컬럼만 조회하려면 select()에 컬럼 이름을 작성한다.

 
.select("id, name, price")
 

특정 조건에 맞는 데이터는 eq()로 조회한다.

 
.eq("id", product_id)
 

최신 데이터부터 정렬하려면 order()를 사용한다.

 
.order("created_at", desc=True)
 

결과 개수를 제한하려면 limit()을 사용한다.

 
.limit(3)
 

이를 연결하면 최신 상품 세 개만 가져올 수 있다.

 
result = (
    supabase
    .table("products")
    .select("*")
    .order("created_at", desc=True)
    .limit(3)
    .execute()
)
 

메서드를 순서대로 연결하면 어떤 테이블에서 무엇을 조회하고, 어떤 조건으로 정렬하는지 한눈에 확인할 수 있었다.

날짜와 시간으로 ID 만들기

상품 ID와 생성 시간을 서버에서 직접 만들기 위해 datetime을 사용했다.

 
from datetime import datetime
from zoneinfo import ZoneInfo

now = datetime.now(ZoneInfo("Asia/Seoul"))
 

생성 시간은 다음처럼 문자열로 바꿀 수 있다.

 
created_at = now.strftime("%Y-%m-%d %H:%M:%S")
 

날짜와 시간을 이어 붙여 ID로 사용할 수도 있다.

 
product_id = now.strftime("%Y%m%d%H%M%S%f")
 

결과는 다음과 같은 형태가 된다.

20260721153025123456
 

%f는 마이크로초를 의미한다. 같은 초에 데이터가 여러 번 생성되더라도 ID가 겹칠 가능성을 줄일 수 있다.

배포 서버는 한국 시간이 아닌 다른 시간대를 사용할 수도 있으므로 다음처럼 시간대를 직접 지정했다.

 
ZoneInfo("Asia/Seoul")
 

Product Scheme 나누기

상품을 등록할 때 사용자가 입력하는 값과 조회 결과로 나가는 값은 서로 다르다.

상품 생성 시에는 상품명과 가격만 받는다.

 
{
  "name": "키보드",
  "price": 50000
}
 

하지만 조회 결과에는 ID와 생성 시간도 포함된다.

 
{
  "id": "20260721153025123456",
  "name": "키보드",
  "price": 50000,
  "created_at": "2026-07-21 15:30:25"
}
 

따라서 하나의 Scheme을 모든 상황에 사용하지 않고 역할별로 분리했다.

 
from pydantic import BaseModel


class ProductCreate(BaseModel):
    name: str
    price: int


class ProductUpdate(BaseModel):
    name: str
    price: int


class ProductPublic(BaseModel):
    id: str
    name: str
    price: int
    created_at: str
 

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

  • ProductCreate: 상품을 생성할 때 받는 데이터
  • ProductUpdate: 상품을 수정할 때 받는 데이터
  • ProductPublic: 사용자에게 반환하는 상품 데이터

요청과 응답 Scheme을 나누니 어떤 값을 사용자가 입력하고, 어떤 값이 서버에서 만들어지는지 구분하기 쉬워졌다.

Router와 Service로 Product CRUD 구성하기

Product API는 다음과 같은 경로로 구성했다.

POST   /products
GET    /products
GET    /products/{product_id}
PUT    /products/{product_id}
DELETE /products/{product_id}
 

각 기능의 입력과 출력은 다음과 같다.

상품 생성
입력: ProductCreate
출력: ProductPublic

전체 상품 조회
입력: 없음
출력: list[ProductPublic]

상품 한 개 조회
입력: product_id
출력: ProductPublic

상품 수정
입력: product_id, ProductUpdate
출력: ProductPublic

상품 삭제
입력: product_id
출력: ProductPublic
 

Router는 요청을 받은 뒤 Service에 필요한 값을 전달한다.

 
@router.get("/{product_id}", response_model=ProductPublic)
def get_product_router(product_id: str):
    return get_product(product_id)
 

product_id처럼 URL 경로에 포함되어 전달되는 값은 Path Parameter다.

/products/{product_id}
 

수정 기능은 경로에서 ID를 받고 요청 본문에서 수정할 값을 받는다.

 
@router.put("/{product_id}", response_model=ProductPublic)
def update_product_router(
    product_id: str,
    product: ProductUpdate,
):
    return update_product(product_id, product)
 

Router는 요청을 연결하는 역할만 하고, 실제 데이터 처리와 Supabase 연동은 Service가 담당한다.

가짜 데이터로 API 구조 먼저 테스트하기

처음부터 Supabase까지 연결하면 오류가 발생했을 때 원인을 찾기 어렵다.

문제가 생길 수 있는 부분이 많기 때문이다.

Router
Service
Scheme
환경변수
Supabase Client
테이블명
컬럼명
데이터 타입
 

따라서 먼저 Service에서 Scheme에 맞는 가짜 데이터를 반환했다.

 
fake_product = ProductPublic(
    id="20260721153025123456",
    name="키보드",
    price=50000,
    created_at="2026-07-21 15:30:25",
)
 

전체 조회는 리스트로 반환해야 한다.

 
return [fake_product]
 

처음에는 response_model을 ProductPublic로 설정한 상태에서 Service가 None을 반환해 응답 검증 오류가 발생했다.

FastAPI가 예상한 값
→ ProductPublic

실제로 반환한 값
→ None
 

데이터베이스 연결 전이라도 응답 Scheme과 같은 형태의 데이터를 반환해야 한다는 점을 확인했다.

전체 개발 순서는 다음처럼 진행하는 것이 안전했다.

Scheme 작성
→ Service 함수 작성
→ Router 연결
→ 가짜 데이터 반환
→ Swagger 테스트
→ 실제 Supabase 코드로 교체
 

한 번에 모든 기능을 연결하기보다 단계별로 정상 작동 여부를 확인하면 오류 범위를 줄일 수 있다.

Product 테이블 생성 SQL 작성하기

FastAPI에서 products 테이블을 사용하려면 Supabase에도 실제 테이블이 존재해야 한다.

Product Scheme을 기준으로 필요한 컬럼을 정리했다.

id
name
price
created_at
 

Codex에 SQL을 요청할 때는 사용하는 데이터베이스와 조건을 구체적으로 알려줬다.

Supabase의 PostgreSQL에서 사용할 products 테이블 생성 SQL을 작성해줘.

id는 text 타입의 primary key,
name은 null을 허용하지 않는 text,
price는 0 이상의 integer,
created_at은 timestamp로 만들어줘.

id와 created_at은 자동 생성하지 않고
FastAPI에서 직접 입력할 거야.
 

완성된 SQL은 다음과 같다.

 
CREATE TABLE products (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    price INTEGER NOT NULL CHECK (price >= 0),
    created_at TIMESTAMP NOT NULL
);
 

각 조건의 의미는 다음과 같다.

  • PRIMARY KEY: 상품을 구분하는 중복되지 않는 값
  • NOT NULL: 반드시 입력해야 하는 값
  • CHECK (price >= 0): 음수 가격이 저장되는 것을 방지
  • TIMESTAMP: 날짜와 시간을 저장하는 타입

AI가 SQL을 만들어 주더라도 테이블명, 컬럼명, 데이터 타입과 자동 생성 여부를 직접 확인해야 한다.

Codex에 구체적으로 요청하기

처음에는 데이터베이스 종류를 말하지 않고 SQL 생성을 요청해 원하는 결과와 다른 코드가 나왔다.

이후 Supabase에서 PostgreSQL을 사용한다는 점과 컬럼별 요구사항을 자세히 전달하자 필요한 형태의 SQL을 받을 수 있었다.

프로젝트 전체를 수정할 때도 바로 코드를 변경하게 하기보다 먼저 계획을 요청하는 것이 안전하다.

Product 기능을 Router, Service, Scheme으로 분리하려고 해.

현재 프로젝트를 분석해서
수정해야 할 파일과 작업 순서를 알려줘.

아직 코드는 수정하지 마.
 

계획을 확인한 뒤 실제 작업을 요청한다.

확인한 계획대로 수정해줘.
 

AI가 빠르게 코드를 작성해 줄 수는 있지만, 원하는 구조와 입출력은 개발자가 먼저 정리해야 한다는 점을 다시 확인했다.

팀 프로젝트 구조 다시 정리하기

오후에는 전날 만든 블로그 팀 프로젝트를 오늘 배운 구조에 맞게 다시 정리했다.

팀원별 담당 기능은 다음과 같이 나뉘어 있었다.

공통 구조 및 LLM API
User CRUD
Post CRUD
Comment CRUD
 

각 담당자는 자신의 기능을 다음과 같은 파일로 분리한다.

user_router.py
user_service.py
user_scheme.py
 
post_router.py
post_service.py
post_scheme.py
 
comment_router.py
comment_service.py
comment_scheme.py
 

기존 코드를 그대로 이어갈지 새로운 기본 구조에서 다시 만들지 논의했지만, 오늘 배운 구조를 기준으로 새롭게 정리하고 기존 코드는 필요한 부분만 복사하기로 했다.

최신 main 브랜치를 기준으로 작업하기

일부 팀원의 코드가 아직 main 브랜치에 병합되지 않은 상태에서 다른 팀원이 코드를 내려받아 파일이 비어 있거나 기능이 빠지는 문제가 있었다.

안전한 협업 순서는 다음과 같다.

팀원 작업 완료
→ 개인 브랜치에 Push
→ Pull Request 생성
→ 팀 리더가 main에 Merge
→ 병합 완료 안내
→ 다른 팀원들이 최신 main Pull
 

기존 로컬 파일은 바로 삭제하지 않고 백업해 두기로 했다. 아직 GitHub에 올라가지 않은 코드가 있을 수 있기 때문이다.

최신 main을 받은 뒤에는 담당 기능을 위한 새로운 브랜치를 만든다.

 
git switch main
git pull origin main
git switch -c feature/user-supabase
 

새 브랜치를 최신 main에서 만들면 이전 작업의 불필요한 변경사항이 섞이는 것을 줄일 수 있다.

main.py는 팀 리더가 통합하기

여러 팀원이 동시에 main.py를 수정하면 병합 충돌이 발생할 가능성이 높다.

따라서 팀원은 담당 기능의 Router, Service, Scheme을 작성하고, 최종 main.py는 팀 리더가 관리하기로 했다.

팀원
→ 자신의 기능 구현과 테스트

팀 리더
→ 모든 Router를 main.py에 등록
 

최종적으로는 다음과 같이 각 Router가 하나의 FastAPI 앱에 연결된다.

 
app.include_router(user_router.router)
app.include_router(post_router.router)
app.include_router(comment_router.router)
app.include_router(chat_router.router)
 

각 팀원은 자신의 기능을 별도의 실행 파일로 테스트할 수도 있다. 최종 병합 단계에서는 리더가 전체 API가 함께 실행되는지 확인한다.

Supabase와 Render까지 연결하는 팀 프로젝트 흐름

각 팀원은 자신이 맡은 기능의 Scheme, Router, Service만 작성하는 것에서 끝나지 않고 실제 Supabase 테이블과 연결해야 한다.

담당 기능의 Scheme 작성
→ Router와 Service 구현
→ 가짜 데이터로 Swagger 테스트
→ Supabase 테이블 생성
→ 실제 CRUD 코드 연결
→ 개인 브랜치 Push
→ Pull Request
→ main 브랜치 통합
→ Render 배포
 

모든 기능이 병합되면 팀 리더가 Render에 최종 프로젝트를 배포한다.

배포 전에 다음 사항을 확인해야 한다.

  • 모든 Router가 main.py에 등록됐는가?
  • Service에서 사용하는 테이블명이 실제 Supabase 테이블명과 같은가?
  • 필요한 패키지가 requirements.txt에 포함됐는가?
  • .env가 GitHub에 올라가지 않았는가?
  • Render에 환경변수가 등록됐는가?
  • 로컬 Swagger에서 전체 CRUD가 작동하는가?

배포가 완료되면 Render 주소의 /docs에서 팀원 모두가 최종 API를 확인할 수 있다.

오늘의 회고

오늘은 FastAPI 프로젝트를 단순히 로컬에서 실행하는 단계를 넘어 Supabase에 실제 데이터를 저장하고, Render를 이용해 외부에서 접근할 수 있는 서버로 배포하는 전체 흐름을 배웠다.

특히 Router, Service, Scheme을 먼저 가짜 데이터로 연결한 뒤 실제 데이터베이스 코드를 붙이는 순서가 기억에 남았다. 처음에는 가짜 데이터를 만드는 과정이 번거롭게 느껴졌지만, API 구조와 데이터베이스 문제를 나누어 확인할 수 있어 오류를 찾기 쉬운 방식이라는 것을 이해했다.

Supabase 문법 자체는 비교적 단순했지만 테이블명, 컬럼명, 데이터 타입이 FastAPI의 Scheme과 정확히 맞아야 했다. 코드가 맞더라도 데이터베이스 구조가 다르면 실행되지 않기 때문에, 앞으로는 API를 만들기 전에 요청값과 응답값뿐 아니라 테이블 구조까지 함께 정리해야겠다.

오후 팀 프로젝트에서는 코드 작성보다 먼저 팀원 모두가 같은 저장소, 폴더와 브랜치를 보고 있는지 확인하는 것이 중요했다. 일부 기능이 아직 병합되지 않은 상태에서 main을 내려받거나 여러 명이 main.py를 수정하면 불필요한 혼란과 충돌이 생길 수 있었다.

앞으로는 다음 순서를 먼저 확인하는 습관을 들여야겠다.

최신 main 확인
→ 담당 브랜치 생성
→ 작업 범위 확인
→ 담당 파일만 수정
→ 로컬 테스트
→ Push와 Pull Request
→ 리더가 통합
 

오늘 배운 내용들이 연결되면서 FastAPI로 만든 API가 실제 데이터베이스에 값을 저장하고, 인터넷 주소를 통해 실행되는 과정이 조금씩 보이기 시작했다. 다음 수업에서는 가짜 데이터를 반환하던 Service를 실제 Supabase CRUD 코드로 변경하며 데이터베이스 연동을 더 구체적으로 연습할 예정이다.

댓글