진짜로 돈이 오가던 데이터베이스를 뜯어보고 12개의 문제점을 수정했습니다.

실제 표와 함께 잘 정리된 리뷰 : https://chatsaedu-db-review.vercel.app/


# [2주차 과제] 백지에 DB를 그리는 대신, 운영 중인 서비스를 에어테이블에 거울로 떠서 뜯어봤습니다


2주차 과제가 "내 일로 에어테이블 DB 설계하기"였는데, 저는 새로 설계하지 않았습니다.

이미 돌아가는 서비스가 있어서요. AI 교육 사업을 하면서 강의 결제·녹화본 판매·교보재 배포·강사 정산까지 자동화한 사이트를 Supabase(Postgres)로 운영 중입니다. 테이블 12개, 칸 161개, 외래키 19개.

그래서 과제를 이렇게 바꿨습니다.

> **지금 돌아가는 DB를 에어테이블에 그대로 복제해서, 내 눈으로 보고, 뭐가 잘못됐는지 찾는다.**

결과부터 말하면 **문제 12건**을 찾았고, 그중 **아무도 안 쓰면서 거짓 값을 들고 있던 칸 2개**와 **같은 이름이 두 가지 뜻으로 쓰이던 이름 충돌 1건**이 나왔습니다. 1년 가까이 못 보고 있던 것들입니다.

---

## 1. 왜 백지 설계를 안 했나

교재 STEP 2가 "노닥빌리지가 에어테이블에 어떻게 들어 있는지 뜯어보기"였잖아요. 남의 잘 만든 DB를 구경하는 단계요.

그 자리에 **제 서비스를 넣으면** 구경이 아니라 감사(audit)가 된다고 생각했습니다. 백지 설계는 제가 상상한 구조를 확인하는 거고, 거울 복제는 **제가 실제로 만들어 놓은 구조를 확인**하는 거니까요. 후자가 훨씬 무섭고 그래서 배울 게 많았습니다.

---

## 2. 뭘 했나

**① 오늘 18:00 시점으로 데이터를 떠냈습니다.** "지금"이 계속 변하면 비교가 안 되니까 시각을 못 박았습니다. 379행.

**② 개인정보는 전부 마스킹했습니다.** 이름은 성만 남기고, 전화번호는 뒤 4자리만, 이메일은 해시로 치환, 후기·질문 본문은 `(후기 105자)`처럼 길이만, **강사 계좌번호와 시청 토큰은 값을 통째로 비웠습니다.** 구조를 보는 데 값은 필요 없고, 계좌번호가 에어테이블에 올라가는 것 자체가 리스크라서요.

마스킹하고 끝내지 않고 **누출 검사를 따로 돌렸습니다** — 11자리 전화번호 패턴 0건, gmail/naver 이메일 0건, 계좌 전부 빈값. 봇이 "마스킹했어"라고 말하는 걸 그냥 믿으면 안 됩니다.

**③ 에어테이블에 2개 세트를 올렸습니다.**
- 현재 구조 그대로 (12테이블 / 379행)
- 문제를 고친 개선안 (14테이블 / 538행, `v2_` 접두사)

한 베이스 안에 둘을 나란히 두면 **같은 데이터가 두 구조에서 어떻게 다르게 보이는지**가 한 화면에서 비교됩니다. 무료 한도가 베이스당 1,000행인데 합쳐서 917행으로 간신히 들어갔습니다.

**④ 칸 161개 전부에 한글 설명을 달았습니다.** 이게 뒤에서 핵심이 됩니다.

---

## 3. 찾은 것 — 대표 4개

### 🔴 아무도 안 쓰는데 거짓말을 하는 칸

회원 테이블에 `role`(권한)과 `is_admin`(관리자 여부)이 **둘 다** 있었습니다. 레거시죠.

세어 봤더니 **`role='admin'`인 계정이 4명인데, `is_admin`은 71명 전원 `false`**였습니다.

비어 있는 게 아니라 **틀린 값**입니다. 지금은 아무 코드도 `is_admin`을 안 보니까 조용한데, 누군가(또는 AI가) 나중에 "관리자 확인은 `is_admin`으로 하면 되겠네" 하고 쓰는 순간 관리자 권한이 통째로 막힙니다. 시한폭탄이 조용히 들어 있었습니다.

### 🔴 같은 이름이 두 가지 뜻

- 주문 테이블의 `order_id` = `CS-20261005-2225-0UHH5C` 같은 **주문 코드(글자)**
- 후기 테이블의 `order_id` = 주문 테이블의 번호를 가리키는 **연결 번호(숫자)**

같은 이름인데 하나는 글자, 하나는 숫자입니다. 이게 왜 위험하냐면 — 잘못 연결해도 **에러가 안 나고 결과가 0건으로 나옵니다.** "후기가 없네?" 하고 넘어가게 되는 종류의 버그예요.

### 🟡 안 막는 자물쇠 3개

강의가 공개됐는지를 정하는 칸이 `published`, `listed`, `content_status` **3개**였습니다.

확인해 보니 **강의 6개 전부 `content_status='draft'`(작성 중)인데 `published=true`로 실제로 팔리고 있었습니다.** `content_status`는 아무것도 막지 않는 장식이었어요. 실제로 노출을 제어하는 건 `listed` 하나뿐이었습니다.

개선안에서는 `visibility` 한 칸으로 접었습니다 — `hidden`(안 보임) / `link_only`(주소 아는 사람만) / `listed`(목록에 뜸). 자물쇠가 3개면 어느 게 잠긴 건지 아무도 모릅니다.

### 🟡 결제 1건을 두 가지 방법으로 표현하고 있었습니다

녹화본 하나만 사면 주문이 **1줄**로 들어갑니다. 녹화본 + 교보재를 같이 사면 **2줄**로 들어가고, 두 줄이 한 결제였다는 걸 `order_group_id`라는 칸으로 묶습니다. 묶는 값은 **두 줄 중 첫 줄의 주문코드**를 그대로 씁니다.

즉 "결제 1건"을 가리키는 방법이 **단건이면 주문코드, 묶음이면 대표 주문코드** 두 가지입니다. 매출을 셀 때마다 어느 쪽으로 세야 하는지 매번 생각해야 하고, 한쪽을 빼먹으면 조용히 틀린 숫자가 나옵니다. 실제로 묶음 결제가 14건 있었습니다.

개선안에서는 **결제 1건 = 1줄**인 표를 따로 만들고, 그 안에 품목을 붙였습니다. 실제 데이터 한 건이 이렇게 달라집니다.

```
[ 현재 ]  주문 #1283  녹화본  20,000원   group = CS-20261002-1283-VDMN1N
          주문 #1284  교보재   5,000원   group = CS-20261002-1283-VDMN1N
                                          ↑ 자기 주문코드가 남의 줄에 묶는 값으로 쓰인다

[ 개선 ]  결제 CS-20261002-1283-VDMN1N   25,000원   ← 결제는 한 줄
            └ 품목  녹화본  20,000원
            └ 품목  교보재   5,000원
```

결제를 세는 방법이 하나가 되고, "이 사람이 뭘 살 수 있게 됐는지(권한)"는 또 다른 표로 빠집니다. 지금은 그 권한이 주문 줄에 체크박스로 얹혀 있어서, **거래 기록이 운영 중에 계속 수정됩니다.** 거래 기록은 원래 안 바뀌어야 하는 것인데요.

이 외에 중복된 칸 4개, 오프라인 강의의 **장소·준비물이 칸이 아니라 커리큘럼 텍스트 안 `[장소]` 태그**로 들어 있어서 검색이 안 되는 문제 등 총 12건을 정리했습니다.

---

## 4. 교재가 경고한 함정 하나는 다행히 피하고 있었습니다

수업에서 닿님이 든 예시가 기억에 남았어요.

> 상품 값을 🌰1 → 🌰2로 올렸는데, 주문이 상품의 값을 룩업으로만 보고 있으면 **이미 🌰1에 산 사람도 🌰2에 산 것처럼 보인다.** 그래서 주문에는 "그때 실제로 낸 값"을 따로 남겨야 한다.

바로 우리 DB를 확인했습니다. 다행히 주문 행에 `amount`(실제 결제액)가 따로 있고, 상품 쪽 `price`는 "지금 값"으로 분리돼 있었습니다. **이미 맞게 되어 있었어요.**

근데 중요한 건 "운이 좋았다"가 아니라, **이 질문을 던질 수 있게 된 것**입니다. 교재를 안 봤으면 "주문에 금액이 왜 또 있지, 중복 아닌가" 하고 지울 뻔했습니다. 실제로 제가 찾은 중복 칸 4개 중 하나를 지우기로 결정할 때, 이 기준이 그대로 쓰였습니다 — **"지금 값"이냐 "그때 값"이냐.**

같은 이유로, 교재의 또 다른 경고도 그대로 받아 적었습니다.

> 필드 이름을 손으로 바꾸면 사이트가 멈춘다. 코드가 칸 이름으로 찾고 있다.

제 개선안 1단계가 하필 **이름 바꾸기**입니다. 그래서 "이름만 바꾸는 건 쉽다"가 아니라 "이름과 코드를 **같이** 바꿔야 한다"로 계획을 고쳤습니다.

---

## 5. 그래서 — 에어테이블 덕분에 찾은 건가?

봇한테 이걸 직접 물어봤습니다. 돌아온 답이 가장 배울 만했어요.

**"12건 중 8건은 SQL만 있으면 찾을 수 있었습니다. 도구가 알려준 게 아닙니다."**

맞는 말입니다. `is_admin`이 전원 false인 것도, 중복 칸이 100% 일치하는 것도 쿼리 몇 줄이면 나옵니다.

**그런데 4건은 에어테이블로 옮기는 행위 자체가 강제로 드러냈습니다.**

**① 칸마다 설명을 써야 했습니다.** 161개 전부요. 그러니까 칸마다 "이거 왜 있지?"를 한 번씩 물은 셈이 됩니다. 그리고 **설명이 안 써지는 칸**이 나왔습니다. `is_admin`, `content_status`. 설명을 쓸 수 없는 칸이 바로 죽은 칸이었어요.

평소에 코드를 읽을 때는 **쓰는 칸만** 봅니다. 안 쓰는 칸은 영원히 안 보여요. 에어테이블은 "전부 한 번씩 만지기"를 강제했습니다.

교재가 "필드마다 설명 쓰기"를 유난히 강조한 이유를 여기서 알았습니다. 설명은 남을 위한 친절이 아니라 **내 구조를 검사하는 도구**였습니다.

**② 외래키 19개를 전부 명시적으로 Link로 바꿔야 했습니다.** 하나하나 "이게 뭘 가리키나"를 적다 보니, 글자 `order_id`와 숫자 `order_id`가 나란히 놓였습니다. SQL로 조인할 때는 쓰는 쪽만 보니까 둘을 나란히 볼 일이 없었습니다.

**③ "사람이 뭘 보고 이 줄을 알아보나"는 별개 문제였습니다.** 에어테이블은 링크 칸에 **그 표의 첫 번째 칸 값**이 보입니다. 제 DB의 첫 칸은 전부 `1`, `2`, `133` 같은 번호라서, 링크를 걸어 놓으니 아무것도 읽을 수가 없었어요.

그래서 표마다 `key` 칸을 새로 만들어 맨 앞에 꽂았습니다 — `#2225 CS-20261005-2225-0UHH5C`, `offline-01/offline`, `#38 → Q39` 처럼요. **기계가 식별하는 값(번호)과 사람이 식별하는 값은 다른 것**이라는 걸, Postgres만 쓸 때는 질문조차 해 본 적이 없었습니다.

**④ 무료 1,000행 한도에 부딪혔습니다.** 그래서 알게 된 건데, 제 DB 전체 1,747행 중 **1,467행(84%)이 이벤트 로그**였습니다. 운영 데이터가 아니라 로그가 DB의 대부분을 먹고 있었어요. 한도라는 제약이 비율을 보게 만들었습니다.

**정리하면 — 인사이트를 도구가 준 게 아니라, 도구가 161칸을 빠짐없이 한 번씩 만지게 만들었습니다.** 제가 게을러서 안 보던 곳을 보게 한 거죠. 좋은 도구는 답을 주는 게 아니라 질문을 강제하는 것 같습니다.

---

## 6. 삽질 기록 (같은 걸 하실 분들께)

- **Link 칸은 만들 때 `linkedTableId`만 받습니다.** `prefersSingleRecordLink`나 `isReversed`를 같이 보내면 거절당합니다. 만든 뒤에 수정으로 바꿔야 합니다.
- **테이블은 2패스로 만들어야 합니다.** Link를 걸려면 상대 테이블 ID가 먼저 있어야 해서, 테이블 12개를 먼저 다 만들고 → 그 다음에 링크 19개를 거는 순서가 됩니다.
- **에어테이블 API는 테이블·칸 삭제가 안 됩니다.** 처음 생기는 `Table 1`도 손으로 지워야 해요. 그래서 "일단 만들고 나중에 정리"가 안 통하고, 스키마를 미리 확정해야 합니다. 역설적으로 설계를 더 진지하게 만듭니다.
- **UI가 바뀌었습니다.** 베이스를 이제 "app"이라고 부르고, `Start from scratch`가 `Build an app on your own`으로 바뀌었고, `Create` 버튼이 왼쪽 아래로 갔습니다. 가이드와 화면이 다르면 그냥 화면을 찍어서 봇한테 보여주면 됩니다.
- **시각 데이터가 조용히 깨집니다.** DB의 시각 칸이 `"20:00:00"`(초 포함)으로 오는데 제가 `:00`을 또 붙여서, 온라인 강의 4개의 시작 시각이 **에러 없이 빈값**이 됐습니다. 숫자가 비면 눈에 띄는데 날짜는 안 띕니다. 올린 다음에 **몇 줄 눈으로 확인**하는 단계를 꼭 넣으세요.
- **봇이 "마스킹했어"라고 하면 검사하세요.** 전화번호 정규식으로 다시 긁어서 0건 확인하는 데 1분 걸립니다.

---

## 7. 다음 단계

개선안을 난이도로 3단계로 나눴습니다.

**① 이름·죽은 칸 정리** — 데이터 이동이 없고 코드 참조만 바꾸면 됩니다. `is_admin` 삭제, 이름 충돌 해소, "입금 대기"로 오해받던 `pending`을 `initiated`로 개명. **이걸 먼저 실제 운영 DB에 적용합니다.** 이름 문제는 규모가 커질수록 고치는 비용만 오르니까요.

**② 중복 칸 제거** — 마이그레이션이 필요하지만 행 구조는 그대로. 다음 강의 기수 준비할 때 묶어서.

**③ 구조 분리** — 사람·결제·권한·발송을 각각 독립 테이블로. 지금 379행 규모에서는 과설계이고, 수강생이 수백 단위로 늘면 그때.

가장 크게 배운 건 **"잘 돌아가는 것"과 "잘 설계된 것"은 다르다**는 겁니다. 제 사이트는 결제도 되고 문자도 가고 정산도 됩니다. 그런데 안에는 거짓 값을 들고 있는 칸과 두 가지 뜻으로 쓰이는 이름이 있었어요. 돌아가니까 안 봤던 거죠.

구조를 다른 도구로 한 번 옮겨 보는 것만으로 이게 다 드러났습니다. 운영 중인 서비스가 있으신 분들께 추천합니다.
2개의 답글
지피터스 뉴스레터

이번 주 AI, 딱 쓸 것만

매주 월요일 오후,
읽고 바로 써먹을 AI 소식만