JOIN을 했는데 데이터가 안 와요 (Foreign Key와 Supabase)
1. "작성자 정보가 왜 비어있죠?"
게시판을 만들고 있습니다.
posts 테이블에서 데이터를 가져오면서, users 테이블에 있는 작성자의 닉네임과 프로필 사진도 같이 가져오고 싶습니다 (JOIN).
await supabase.from('posts').select('*, users(*)');
그런데 결과는 참담합니다.
{
"id": 1,
"title": "안녕하세요",
"users": null // 👈 여기가 왜 null이지?
}
DB에는 분명히 데이터가 있고, user_id 컬럼도 잘 들어가 있는데 말이죠.
2. 원리 이해 - 포스트그스트(PostgREST)의 문법
Supabase는 내부적으로 PostgREST라는 도구를 써서 API를 만듭니다.
이 친구가 JOIN을 하려면 딱 하나, Foreign Key (외래키)가 명확해야 합니다.
"이 컬럼이 저 테이블의 저 컬럼을 가리킨다"는 사실을 DB 레벨에서 보증해줘야 합니다. 이름이 비슷하다고 해서 자동으로 연결해주지 않습니다.
3. 문제 1 - Foreign Key 누락
가장 흔한 실수입니다. user_id라는 컬럼을 텍스트(uuid) 타입으로만 만들어놓고, 정작 Relation(관계) 설정을 안 한 경우입니다.
해결책:
Supabase 대시보드 -> Table Editor -> posts 테이블 -> user_id 컬럼 편집 -> Add Foreign Key Relation을 눌러서 users.id와 연결해야 합니다.
4. 문제 2 - 모호한 관계 (Ambiguous Foreign Key)
만약 한 테이블이 다른 테이블을 두 번 참조한다면?
예를 들어 messages 테이블에 sender_id와 receiver_id가 둘 다 users 테이블을 가리킨다면?
Supabase는 "어느 키로 조인해야 해?"라고 헷갈려하며 에러를 뱉습니다.
// ❌ 에러 발생: 어떤 키를 쓸지 몰라서 실패
await supabase.from('messages').select('*, users(*)');
해결책: 명시적으로 외래키 이름을 지정해줘야 합니다. !외래키이름 문법을 사용합니다.
// ✅ 명시적 지정 (user_id: sender_id)
// "sender라는 이름으로 users 테이블을 sender_id 키를 이용해 가져와라"
await supabase.from('messages').select('''
*,
sender:users!sender_id(*),
receiver:users!receiver_id(*)
''');
5. Nested Join (꼬리에 꼬리를 무는 조인) 깊이 들여다보기
댓글(comments)을 가져오면서 -> 작성자(users)를 가져오고 -> 그 유저의 소속 팀(teams)까지 한방에 가져오고 싶다면?
중괄호 {}가 아니라 괄호 ()를 중첩해서 씁니다.
await supabase.from('comments').select('''
id,
content,
users (
name,
avatar_url,
teams (
name,
logo_url
)
)
''');
6. Count와 Inner Join 더 알아보기
데이터는 안 가져오고 개수(Count)만 가져오고 싶을 때가 있습니다. (예: 댓글 수)
// 🌟 댓글 내용 대신 개수만 가져오기 (count)
await supabase.from('posts').select('*, comments(count)');
"댓글이 있는 게시글만" 가져오고 싶다면? (Inner Join)
// 🌟 !inner를 붙이면 해당 조건에 맞는 행만 남깁니다.
// 즉, 댓글이 하나라도 있는 게시글만 필터링됩니다.
await supabase.from('posts').select('*, comments!inner(*)');
7. M:N 관계 (Many to Many) 파헤치기
posts와 tags 처럼 다대다 관계는 중간 테이블(post_tags)이 필요합니다.
Supabase는 중간 테이블을 거쳐서 한 번에 조인하는 것을 지능적으로 지원하지 않을 때가 많습니다.
하지만 뷰(View)를 만들거나, 중간 테이블을 명시적으로 거쳐야 합니다.
// posts -> post_tags -> tags 순서로 가져오기
await supabase.from('posts').select('''
title,
post_tags (
tags (
name
)
)
''');
데이터 구조가 post_tags 배열 안에 tags가 들어있는 형태가 되므로, 프론트엔드에서 flatMap으로 펴줘야 합니다.
8. 요약
Foreign Key 연결 없이는 JOIN도 없다. 이름이 헷갈리면 !fk_name으로 명시하고, 깊은 관계는 table(sub_table())로 파고들어라.
9. Cascade Delete (자동 삭제) 제대로 파보기
"유저를 삭제했는데, 그 유저가 쓴 글(Posts)은 어떻게 되나요?" 설정을 안 했다면, Foreign Key Constraint Violation 에러가 나면서 유저 삭제가 실패합니다. (글이 유저를 잡고 놔주지 않아서)
해결책: Foreign Key 설정에서 Action on Delete를 선택해야 합니다.
- No Action (기본): 자식 데이터가 있으면 부모 삭제 불가. (안전 제일)
- Cascade: 부모(User)가 죽으면 자식(Post)도 같이 죽는다. (추천: 깔끔함)
- Set Null: 부모가 죽으면 자식의
user_id를null로 바꾼다. (추천: "알 수 없음"으로 남길 때)
10. 성능 최적화 (Index) 제대로 이해하기
많은 분들이 모르는 사실: Postgres는 Foreign Key에 자동으로 인덱스(Index)를 걸어주지 않습니다. Primary Key에는 자동으로 걸리지만, FK는 아닙니다.
만약 select * from posts where user_id = '...' 쿼리를 자주 쓰는데, posts 데이터가 100만 건이라면?
인덱스가 없으면 Sequential Scan(전체 훑기)을 해서 엄청 느려집니다.
해결책: 꼭 인덱스를 따로 만들어주세요.
CREATE INDEX idx_posts_user_id ON posts (user_id);
이거 한 줄로 조회 속도가 0.1초에서 0.001초가 됩니다.