
JOIN을 했는데 데이터가 안 와요 (Foreign Key와 Supabase)
게시글(Post)을 가져올 때 작성자(User) 정보도 같이 보고 싶은데 `null`만 뜹니다. Foreign Key 설정부터 `select(*, users(*))` 문법, 그리고 M:N 관계, Inner Join, Count까지 완벽하게 파헤칩니다.

게시글(Post)을 가져올 때 작성자(User) 정보도 같이 보고 싶은데 `null`만 뜹니다. Foreign Key 설정부터 `select(*, users(*))` 문법, 그리고 M:N 관계, Inner Join, Count까지 완벽하게 파헤칩니다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

안드로이드는 오는데 iOS는 조용합니다. 혹은 앱이 켜져 있을 때만 옵니다. Background/Terminated 상태 처리, APNs 인증서, 그리고 Notification Channel 설정까지 완벽하게 해결합니다.

게시판을 만들고 있습니다.
posts 테이블에서 데이터를 가져오면서, users 테이블에 있는 작성자의 닉네임과 프로필 사진도 같이 가져오고 싶습니다 (JOIN).
await supabase.from('posts').select('*, users(*)');
그런데 결과는 참담합니다.
{
"id": 1,
"title": "안녕하세요",
"users": null // 👈 여기가 왜 null이지?
}
DB에는 분명히 데이터가 있고, user_id 컬럼도 잘 들어가 있는데 말이죠.
Supabase는 내부적으로 PostgREST라는 도구를 써서 API를 만듭니다.
이 친구가 JOIN을 하려면 딱 하나, Foreign Key (외래키)가 명확해야 합니다.
"이 컬럼이 저 테이블의 저 컬럼을 가리킨다"는 사실을 DB 레벨에서 보증해줘야 합니다. 이름이 비슷하다고 해서 자동으로 연결해주지 않습니다.
가장 흔한 실수입니다. user_id라는 컬럼을 텍스트(uuid) 타입으로만 만들어놓고, 정작 Relation(관계) 설정을 안 한 경우입니다.
해결책:
Supabase 대시보드 -> Table Editor -> posts 테이블 -> user_id 컬럼 편집 -> Add Foreign Key Relation을 눌러서 users.id와 연결해야 합니다.
만약 한 테이블이 다른 테이블을 두 번 참조한다면?
예를 들어 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(*)
''');
댓글(comments)을 가져오면서 -> 작성자(users)를 가져오고 -> 그 유저의 소속 팀(teams)까지 한방에 가져오고 싶다면?
중괄호 {}가 아니라 괄호 ()를 중첩해서 씁니다.
await supabase.from('comments').select('''
id,
content,
users (
name,
avatar_url,
teams (
name,
logo_url
)
)
''');
데이터는 안 가져오고 개수(Count)만 가져오고 싶을 때가 있습니다. (예: 댓글 수)
// 🌟 댓글 내용 대신 개수만 가져오기 (count)
await supabase.from('posts').select('*, comments(count)');
"댓글이 있는 게시글만" 가져오고 싶다면? (Inner Join)
// 🌟 !inner를 붙이면 해당 조건에 맞는 행만 남깁니다.
// 즉, 댓글이 하나라도 있는 게시글만 필터링됩니다.
await supabase.from('posts').select('*, comments!inner(*)');
posts와 tags 처럼 다대다 관계는 중간 테이블(post_tags)이 필요합니다.
Supabase는 중간 테이블을 거쳐서 한 번에 조인하는 것을 지능적으로 지원하지 않을 때가 많습니다.
하지만 뷰(View)를 만들거나, 중간 테이블을 명시적으로 거쳐야 합니다.
// posts -> post_tags -> tags 순서로 가져오기
await supabase.from('posts').select('''
title,
post_tags (
tags (
name
)
)
''');
데이터 구조가 post_tags 배열 안에 tags가 들어있는 형태가 되므로, 프론트엔드에서 flatMap으로 펴줘야 합니다.
!fk_name으로 명시하고, 깊은 관계는 table(sub_table())로 파고들어라.
"유저를 삭제했는데, 그 유저가 쓴 글(Posts)은 어떻게 되나요?" 설정을 안 했다면, Foreign Key Constraint Violation 에러가 나면서 유저 삭제가 실패합니다. (글이 유저를 잡고 놔주지 않아서)
해결책: Foreign Key 설정에서 Action on Delete를 선택해야 합니다.
user_id를 null로 바꾼다. (추천: "알 수 없음"으로 남길 때)많은 분들이 모르는 사실: 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초가 됩니다.