포스트

Todo 앱을 SQLite에서 Supabase로 옮기며 배운 것

Todo 앱을 SQLite에서 Supabase로 옮기며 배운 것

“내 컴퓨터에서 도는 Todo 앱”이라는 요구사항 한 장으로 시작해서, 로컬 SQLite로 만들고, 클라우드 Postgres(Supabase)로 옮기고, 행 수준 보안(RLS)과 로그인·비밀번호 찾기까지 붙였다.

결과부터 적으면 이렇다.

항목결과
커밋19개
파일54개
테스트434개 통과
저장소.env 한 줄로 로컬 ↔ 클라우드 전환

가장 크게 배운 건 기능을 만드는 법이 아니라 확인하는 법이었다. 순서대로 정리한다.

1. DB 설계부터 — 태그를 왜 별도 표로 뺐나

코드를 쓰기 전에 표 구조부터 그렸다. 처음엔 이렇게 하고 싶었다.

1
2
todos: id | title | done | due_date | tags
                                       ↑ "공부,집안일" 처럼 한 칸에

간단해 보이는데 문제가 네 가지 있다.

 한 칸에 몰아넣기별도 표로 빼기
태그로 찾기LIKE '%공부%' → 전체 훑기. “공부방”도 걸림인덱스 타는 정확한 JOIN
오타“공부”와 “공부 “가 따로 쌓임UNIQUE로 원천 차단
이름 바꾸기모든 행의 문자열 수정태그 표 한 줄만 수정
색상 추가넣을 자리가 없음열 하나 추가

그래서 표를 셋으로 나눴다.

1
2
todos ──< todo_tags >── tags
  1        N     N        1

todo_tags는 연결만 담당한다. 할 일 하나에 태그 여럿, 태그 하나에 할 일 여럿. 이걸 N:M 관계라고 부른다.

SQLite에서 넘어진 곳

마감일이 2026-02-31 같은 없는 날짜로 들어오는 걸 막으려고 이렇게 썼다.

1
CHECK (due_date GLOB '____-__-__')

동작하지 않았다. SQLite의 GLOB에서 _는 와일드카드가 아니다. 그건 LIKE 문법이고, GLOB에서 _는 그냥 밑줄 문자다. 정상 날짜가 전부 거부됐다.

고친 방법은 이렇다.

1
2
CHECK (due_date IS NULL OR
       (date(due_date) IS NOT NULL AND due_date = date(due_date)))

date()로 한 번 돌린 결과와 원본이 같은지 비교한다. 이러면 형식뿐 아니라 실제로 존재하는 날짜인지까지 걸러진다. 2026-02-31은 date()가 2026-03-03으로 바꿔버리므로 원본과 달라져서 거부된다.

스키마를 실제로 실행해봤기 때문에 잡은 버그다. 눈으로만 봤으면 그럴듯해 보여서 그냥 넘어갔을 것이다.

2. 계층을 넷으로 나눈 이유

처음엔 과해 보였는데, 나중에 이게 전부를 살렸다.

1
2
3
4
5
models.py       값 객체 + 입력 검증     ← DB를 모른다
database.py     커넥션·설정
repository.py   SQL 전담               ← SQL은 여기와 스키마 파일에만
service.py      유스케이스·트랜잭션     ← CLI와 웹이 공유
cli.py / web/   화면                   ← SQL도 sqlite3도 모른다

Supabase로 옮길 때 위쪽 네 개 중 세 개를 한 줄도 안 고쳤다. models.py, repository.py, database.py, 그리고 SQLite 스키마 파일까지 그대로 남았다. 저장소만 갈아끼웠다.

화면 코드에 SQL이나 sqlite3가 새지 않았는지를 테스트로 검사하게 만들어뒀다.

1
2
3
def test_화면_계층에_SQL이_없다():
    for path in pathlib.Path("todoapp/web").rglob("*.py"):
        assert "SELECT" not in path.read_text().upper()

이런 테스트가 있으면 “다음에 급할 때 여기 SQL 한 줄만 넣자”는 유혹이 구조적으로 막힌다.

3. Supabase로 옮기며 만난 함정 세 가지

트랜잭션이 없다

SQLite에서는 이렇게 썼다.

1
2
3
with conn:                       # 실패하면 통째로 롤백
    todo_id = add_todo(...)
    link_tags(todo_id, tags)

Supabase는 REST API(PostgREST)로 통신한다. 요청 하나가 문장 하나라서 클라이언트 쪽 트랜잭션이 없다. “할 일 추가 + 태그 3개 연결”을 REST로 하면 왕복이 서너 번이고, 중간에 끊기면 태그 없는 할 일이 남는다.

해결은 Postgres 함수로 서버에서 처리하는 것이었다.

1
2
3
4
create or replace function public.create_todo_with_tags(...)
returns bigint
language plpgsql security invoker   -- ← 이게 핵심
as $$ ... $$;

security invoker가 중요하다. 기본값이긴 하지만 명시했다. security definer로 만들면 함수가 소유자 권한으로 돌아 RLS를 통과해버린다.

와일드카드가 다르다

SQLite의 LIKE는 %가 와일드카드다. PostgREST의 ilike는 *다. 그리고 or_() 필터는 쉼표로 조건을 나눈다.

이스케이프하지 않으면 이렇게 된다.

  • %를 검색 → SQLite에서 전체 일치
  • *를 검색 → PostgREST에서 전체 일치
  • 우유,계란을 검색 → 필터가 쉼표에서 쪼개져 엉뚱한 조건이 됨

둘 다 같은 계열의 함정인데 막아야 할 글자가 다르다. 저장소마다 따로 처리했다.

태그 유일성 범위가 바뀐다

SQLite에서는 태그 이름이 전역으로 유일했다. 혼자 쓰니까 문제없다. 클라우드에서 여러 사람이 쓰면 내가 “공부”를 먼저 만들면 남이 못 만든다.

1
unique (user_id, name)   -- 전역이 아니라 사용자별로

혼자 쓸 때는 안 보이던 문제다.

4. RLS — 앱이 우회할 수 없게 만들기

RLS(Row Level Security)는 “이 사람이 어느 행을 볼 수 있는가”를 DB가 강제하는 기능이다.

1
2
3
4
create policy todos_own on public.todos
    for all to authenticated
    using (user_id = auth.uid())
    with check (user_id = auth.uid());

여기서 중요하게 정한 게 하나 있다. service_role 키를 앱 어디에도 두지 않았다.

Supabase는 키를 두 종류 준다.

키성격
anon / publishable공개돼도 되는 키. RLS를 그대로 받는다
service_role / secretRLS를 우회한다. 서버 전용

service_role을 앱에 넣는 순간 RLS는 방어선이 아니라 장식이 된다. 그래서 anon 키만 쓰고, 환경변수에 SERVICE_ROLE 이름이 있으면 앱이 아예 시작을 거부하게 만들었다.

1
2
3
4
def assert_no_service_role() -> None:
    offenders = [n for n in os.environ if "SERVICE_ROLE" in n.upper()]
    if offenders:
        raise RuntimeError(f"{', '.join(offenders)} 가 환경에 있습니다. ...")

보안이 2층이라는 것도 이번에 알았다.

1
2
1층  GRANT   어느 역할이 이 표에 손댈 수 있나  → anon에는 아무 권한도 안 줌
2층  RLS     그 역할이 어느 행을 볼 수 있나    → user_id = auth.uid() 인 행만

실제로 막히는지 확인

말로만 “안전하다”고 하면 의미가 없어서, 계정 두 개를 만들어 실제로 시도해봤다.

B 계정이 A의 할 일에 시도한 것결과
목록에서 보기안 보임
id로 직접 조회None
제목 수정0건 변경
삭제0건 삭제
A의 할 일에 태그 붙이기거부

로그인 없이 anon 키만으로는 표 3개와 함수 3개 전부 거부됐다.

5. 키를 잘못 넣었다가 배운 것

.env에 키를 넣었는데 64자리 16진수 문자열이었다. 검사 스크립트를 만들어뒀더니 이렇게 나왔다.

1
2
❌ SUPABASE_ANON_KEY : JWT Secret 으로 보임 (64자 hex)
   이건 API 키가 아니라 토큰 서명 비밀입니다.

JWT Secret을 복사한 것이었다. 이건 anon 키와 같은 화면, 조금 아래에 있어서 헷갈리기 쉽다. 그런데 성격이 완전히 다르다.

  • service_role: RLS를 우회한다
  • JWT Secret: 토큰에 서명하는 열쇠. 이걸 가지면 “나는 아무개다”라는 토큰을 직접 만들 수 있다

즉 service_role보다 위험하다. 구분법은 단순했다.

가려져 있고 Reveal 버튼이 있으면 그건 앱에 넣을 키가 아니다. anon 키는 브라우저에 노출되는 게 정상이라 가려두지 않는다.

SUPABASE_URL도 틀렸었다. 브라우저 주소창(supabase.com/dashboard/org/...)을 복사했는데, 앱이 접속할 주소는 https://<프로젝트ID>.supabase.co 형식이다.

6. 테스트 종류마다 잡는 버그가 다르다

이번에 가장 확실하게 배운 점이다. 같은 버그를 여러 방법으로 잡은 게 아니라, 각 방법만 잡을 수 있는 버그가 따로 있었다.

버그왜 그것만 잡았나잡은 방법
upsert가 색상을 기본값으로 덮어씀로직만 봐도 드러남유닛 테스트
is_(col, "not.null")이 거부됨가짜 클라이언트는 아무 값이나 받아줌실제 서버 연결
--due -2d가 옵션으로 오인됨함수를 직접 부르는 테스트는 argparse를 우회함직접 실행
태그 목록 한글 정렬 어긋남눈으로 봐야 보임직접 실행

두 번째가 특히 인상적이었다. PostgREST에서 “값이 NULL이 아님”을 표현하는 문법은 is_(col, "not.null")이 아니라 not_.is_(col, "null")이다.

1
2
is.not.null   → PGRST100 파싱 오류
not.is.null   → 정상

가짜 클라이언트를 쓰는 유닛 테스트는 아무 문자열이나 받아주므로 구조적으로 이걸 못 잡는다. 실제 서버에 붙어봐야 드러난다.

세 번째도 같은 종류다. resolve_due_input("-2d")를 직접 부르는 테스트는 통과하는데, 터미널에서 --due -2d를 치면 argparse가 -2d를 옵션 이름으로 오인해서 실패한다. --due=-2d처럼 등호로 붙여야 한다. 손으로 돌려보기 전까지 몰랐다.

7. 확인하니까 내가 틀렸던 것들

작업 중에 “당연히 이럴 것이다”라고 생각했다가 확인해보니 아니었던 게 네 번 있었다.

① 안 쓰는 표가 비어 있는 줄 알았다

이전에 만들어둔 tasks 표를 지우려고 했는데, 지우기 전에 확인해보니 할 일 1건이 들어 있었다. 앱에서 조회했을 때 0행으로 보였던 건 RLS가 가리고 있었기 때문이다. 대시보드는 RLS를 통과하는 권한이라 실제 행이 보였다. 확인 안 했으면 남의(내) 데이터를 날릴 뻔했다.

② 메일 템플릿을 고칠 수 있는 줄 알았다

비밀번호 찾기를 6자리 코드 방식으로 만들었다. 메일 템플릿에 `` 한 줄만 넣으면 된다고 생각했는데, 대시보드를 열어보니 이렇게 쓰여 있었다.

Set up custom SMTP to edit templates.

무료 플랜은 커스텀 SMTP 없이 메일 템플릿을 수정할 수 없다. 기본 템플릿에는 링크만 있고 코드가 없다. 만들어둔 코드 방식을 버리고 링크 방식으로 전면 교체했다.

③ 링크가 안 열리는 게 코드 문제인 줄 알았다

메일의 링크를 눌렀더니 ERR_CONNECTION_REFUSED가 떴다. 확인해보니 Supabase의 Site URL 기본값이 http://localhost:3000인데 내 앱은 다른 포트를 쓰고 있었다. 코드 문제가 아니라 설정 한 줄이었다.

④ 프레임워크 기본 포트가 안전한 줄 알았다

이건 며칠 뒤에 발견했다. 앱을 다시 켰는데 localhost:5000으로는 안 들어가지고 127.0.0.1:5000으로는 들어가졌다. 같은 서버인데 주소에 따라 결과가 달랐다.

lsof로 확인해보니 이랬다.

1
2
3
$ lsof -nP -iTCP:5000 -sTCP:LISTEN
ControlCe  1216  ...  TCP *:5000 (LISTEN)     ← macOS AirPlay 수신기
python3.1 22394  ...  TCP 127.0.0.1:5000 (LISTEN)  ← 내 앱

macOS Monterey부터 AirPlay 수신기가 5000번 포트를 상시 점유한다. 그런데 Flask의 app.run() 기본 포트가 하필 5000이다. 그래서 둘이 자리를 나눠 갖게 됐다.

주소응답
127.0.0.1:5000내 앱 (302)
localhost:5000AirPlay (403, Server: AirTunes)

localhost는 브라우저가 IPv6(::1)로 먼저 해석하는데, 내 앱은 IPv4에만 붙고 AirPlay는 양쪽 다 잡고 있어서 그렇다.

포트를 5001로 옮기니 둘 다 정상이 됐다. .env의 PORT로 바꿀 수 있게 만들고, 이 실수가 되돌아오지 않게 테스트를 하나 걸어뒀다.

1
2
3
def test_5000을_기본값으로_쓰지_않는다(self, monkeypatch):
    monkeypatch.delenv("PORT", raising=False)
    assert get_port() != 5000

프레임워크의 기본값이 내 환경에서도 안전하다는 보장은 없다. 포트를 정하기 전에 lsof -nP -iTCP:<포트> -sTCP:LISTEN 한 줄이면 될 일이었다.

남은 숙제

  • CSRF 토큰이 없다. 127.0.0.1 단독 실행 전제라 지금은 괜찮지만, 로그인이 생긴 순간 실제 위험이 됐다. 외부에 노출하려면 Flask-WTF를 붙여야 한다.
  • 무료 플랜 메일은 시간당 2통이고 프로젝트 팀원 주소로만 나간다. 다른 사람도 쓰게 하려면 외부 SMTP가 필요하다.
  • 무료 플랜은 1주일 비활성이면 프로젝트가 일시정지된다.

정리하며

기능을 만드는 것보다 확인하는 습관이 훨씬 중요하다는 걸 배웠다.

  • 스키마는 실행해봐야 GLOB 함정이 보인다
  • 저장소 이음새는 실제로 갈아끼워봐야 인터페이스가 어긋난 게 보인다
  • RLS는 계정 두 개로 시도해봐야 진짜 막히는지 알 수 있다
  • 키는 판정해봐야 JWT Secret을 넣었다는 걸 안다
  • 포트는 점유 여부를 봐야 프레임워크 기본값이 안전한지 안다

그리고 계층을 나눠둔 게 결정적이었다. 처음엔 파일이 늘어나서 번거로웠는데, 저장소를 통째로 바꾸는 상황에서 화면 코드를 한 줄도 안 고쳐도 됐다. 나중에 바뀔 것 같은 곳에 미리 선을 그어두는 것이 무슨 뜻인지 이제 알겠다.


코드는 GitHub의 ToDoApp 폴더에 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.