Mini Project 5 09

Supabase Auth ID와 백엔드 사용자 관계

Supabase Auth를 붙였다고 하면 backend가 인증 서버가 되었거나 JWT token을 직접 검증한다고 생각하기 쉽다. 하지만 이 repo의 구현은 다르게 읽어야 한다.

근거 · GitHub Supabase Auth 사용자 식별 구현

Supabase Auth ID와 백엔드 사용자 관계 대표 이미지

1장: 프런트엔드가 사용자 ID를 획득하는 과정

솔라의 손가락이 모니터 위의 한 문장에서 멈췄다. 프로젝트의 전체 아키텍처를 설명하는 README 파일의 한 구절이었다. 몇 번을 다시 읽어봐도 어딘가 매끄럽게 연결되지 않는 느낌이었다.

“5차 frontend는 Supabase Auth의 getUseronAuthStateChange로 현재 사용자를 얻고, backend favorite API는 그 userId를 users/favorite relation에 맞춘다.”

“언니, 이거 좀 이상하지 않아?”

솔라의 부름에 옆에서 코드를 보던 루나가 고개를 들었다.

“프런트엔드가 사용자를 ‘얻는다’는 부분이 마음에 걸려. 보통 사용자 인증은 백엔드의 역할이잖아. 프런트엔드가 로그인 요청을 보내면, 백엔드 서버가 데이터베이스를 확인하고 토큰을 발급해 주지. 그리고 프런트엔드는 그저 API를 호출할 때마다 토큰을 헤더에 실어 보내는 거고. 최종적으로 사용자가 누군지 판단하는 건 항상 백엔드라고 생각했는데…”

솔라는 자신이 당연하게 여겼던 인증 흐름을 허공에 그려 보였다. 프런트엔드는 사용자의 입력을 받아 전달하는 창구, 진짜 인증 로직은 견고한 성벽 뒤의 백엔드에 존재하는 그림이었다. 하지만 눈앞의 문장은 프런트엔드가 주체적으로 사용자를 ‘얻는다’고 말하고 있었다. 마치 프런트엔드가 인증의 주체인 것처럼.

루나는 솔라가 짚은 문장을 잠시 바라보았다. 그리고는 솔라의 생각에 반박하는 대신, 질문의 방향을 살짝 틀었다.

“솔라 네 말이 맞아. 보통은 그렇지. 그런데 이 문장은 ‘백엔드 API’가 호출되기 전의 상황을 먼저 설명하고 있어. 백엔드가 userId를 받기 전에, 프런트엔드는 그 userId를 어디서 처음 가져왔을까? 그걸 먼저 따라가 보는 거야. Frontend/src/App.jsx 파일을 한번 열어볼래?”

솔라는 의아한 표정으로 App.jsx 파일을 열었다. 화면 가득 익숙한 리액트 코드가 펼쳐졌다. 루나가 파일의 윗부분을 가리켰다.

“사용자가 우리 웹사이트에 처음 접속했다고 상상해 봐. App.jsx 컴포넌트가 실행되면서 가장 먼저 뭘 하지?”

솔라의 눈이 코드를 따라갔다. useEffect 훅 안에서 비동기로 실행되는 함수가 눈에 들어왔다.

supabase.auth.getUser()

”…supabase.auth.getUser()를 호출하네.”

“맞아. 그 함수가 뭘 할까? 만약 사용자가 이전에 로그인해서 브라우저에 유효한 세션 정보가 남아있다면, 함수는 사용자 정보를 담은 객체를 돌려줄 거야. 그 객체 안에는 Supabase가 발급한 고유한 user.id가 들어있겠지. 우리 백엔드 DB의 users.user_id에 저장될 바로 그 ID 말이야. 그리고 화면에는 책 목록이 보일 거야. 반대로, 로그인한 적이 없다면? 함수는 null을 반환하고, 화면에는 AuthPanel, 즉 로그인 창이 나타나.”

루나의 설명에 맞춰 솔라는 머릿속으로 UI의 변화를 그렸다. 로그인한 사용자와 그렇지 않은 사용자가 보게 될 첫 화면의 갈림길이 supabase.auth.getUser() 함수 하나로 결정되고 있었다.

“그럼 바로 밑에 있는 onAuthStateChange는?”

“그건 일종의 감지선 같은 거야. 사용자가 페이지를 새로고침하지 않고, 바로 그 자리에서 로그인을 하거나 로그아웃 버튼을 눌렀을 때를 위한 거지. 세션 상태가 변하는 모든 순간을 프런트엔드가 알아채고 UI를 즉시 바꿔주는 역할이야. 로그인하면 책 목록을, 로그아웃하면 다시 로그인 창을 보여주는 식으로.”

“아…”

솔라의 입에서 짧은 탄성이 터져 나왔다. 그제야 모든 조각이 맞춰지는 기분이었다. 자신이 상상했던 그림은 틀리지 않았지만, 이 프로젝트의 현실과 달랐을 뿐이다.

“그러니까… 우리 프런트엔드는 백엔드 서버에 ‘지금 접속한 사용자가 누구야?‘라고 물어볼 필요가 없는 거였어. 프런트엔드가 직접 Supabase의 클라이언트 라이브러리를 통해 ‘로그인된 사용자가 있나?‘를 확인하고, 있다면 그 사용자의 ID를 확보하는 거구나. 사용자 인증 상태를 확인하고 관리하는 첫 번째 책임이, 일단 프런트엔드에 있었던 거네.”

고개가 저절로 끄덕여졌다. 프런트엔드는 그저 UI 컴포넌트 덩어리가 아니었다. 이 구조에서는 Supabase Auth와 연동하여 사용자의 세션을 책임지는, 독립적인 인증 상태 관리자였다. 첫 번째 매듭은 그렇게 풀렸다. 하지만 곧바로 다음 질문이 꼬리를 물고 이어졌다.

“알겠어. 프런트엔드가 Supabase 덕분에 사용자 ID를 스스로 아는 건 이해했어. 그런데… 그 다음은? 즐겨찾기 추가 같은 기능을 사용하려면 결국 우리 백엔드에 요청을 보내야 하잖아. 그때 이 ID를 그냥 보내준다고? 우리 백엔드는 그 ID가 진짜인지, 혹시 클라이언트가 값을 위조해서 보낸 건 아닌지 어떻게 알아? 아무런 검증 없이 그냥 믿어도 되는 건가?“

2장: 프런트엔드에서 백엔드로 사용자 ID 전달

솔라는 잠시 생각에 잠겨 있다가, 들고 있던 펜으로 노트 위에 네모 두 개를 그렸다. 각각 ‘프런트엔드’와 ‘백엔드’라고 이름을 붙였다. 프런트엔드가 사용자 ID를 스스로 획득한다는 사실을 막 이해한 참이었다. 이제 남은 질문은 그 ID가 어떻게 백엔드까지 안전하게 전달되느냐였다. 솔라는 두 네모 상자 사이에 화살표를 그리고는, 자신이 가장 익숙하고 당연하게 여기는 방식을 화살표 위에 상징적으로 그려 넣었다. 바로 Authorization 헤더와 암호처럼 긴 토큰 문자열이었다.

이 짧은 문구야말로 클라이언트와 서버 사이의 신뢰를 상징하는 징표였다. 프런트엔드는 Supabase로부터 받은 인증 토큰을 이 헤더에 실어 보낸다. 그러면 백엔드는 그 토큰의 서명을 검증하고, 유효하다면 토큰 안에 담긴 사용자 ID를 ‘신뢰’할 수 있게 된다. 그게 솔라가 알고 있는 상식이었다.

“언니, 내 생각이 맞는지 봐봐.”

솔라는 자신만만한 표정으로 루나에게 노트를 보여주었다.

“프런트엔드가 Supabase에서 사용자 세션을 얻으면, 인증 토큰도 같이 받을 거야. 그럼 즐겨찾기 추가 API를 호출할 때, 이렇게 Authorization 헤더에 토큰을 담아서 보내는 거지. 우리 백엔드는 이 토큰을 받아서 공개키로 검증한 뒤에야 ‘아, 이 요청은 진짜 그 사용자가 보낸 게 맞구나’ 하고 안심하고 userId를 꺼내 쓰는 거야. 이게 제일 안전한 방법이잖아. 맞지?”

루나는 솔라가 그린 다이어그램을 잠시 들여다보았다. 프런트엔드, 백엔드, 그리고 그 사이를 오가는 암호 같은 토큰 문자열. 매우 표준적이고 훌륭한 설계였다. 루나는 고개를 끄덕였다.

“응, 아주 일반적이고 안전한 방식이야. 많은 서비스가 그렇게 만들어져. 그런데 지금 우리가 보는 이 프로젝트가 정말 그 방식을 택했을까? API 명세부터 한번 확인해 보자. ‘즐겨찾기 추가’ 요청이 어떻게 생겼는지 말이야.”

루나의 말에 솔라는 README 파일의 API 문서를 다시 스크롤했다. ‘즐겨찾기’ 섹션을 찾아 눈으로 훑었다.

POST /books/{bookId}/favorites

그리고 그 아래에 있는 요청(Request) 본문 예시에서 솔라의 눈이 동그래졌다. 헤더에 대한 언급은 어디에도 없었다. 대신, 요청의 몸통, 즉 body에 담기는 데이터 구조가 명확하게 적혀 있었다.

{
  "userId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "email": "user@example.com"
}

“어?”

솔라의 입에서 의아한 소리가 나왔다. Authorization 헤더가 없었다. 대신 userId가 이메일 주소와 함께 body 안에 평범한 데이터처럼 담겨 있었다. 인증 토큰의 흔적은 어디에도 보이지 않았다. 자신이 그렸던 견고한 인증 절차 다이어그램이 순식간에 머쓱해지는 순간이었다.

루나는 솔라의 표정을 보고 가볍게 미소 지으며 말했다.

“프런트엔드에서 저 요청을 보내는 코드를 상상해서 한번 적어볼래? fetch 함수를 쓴다고 생각하고.”

솔라는 잠시 망설이다 펜을 들어 노트의 빈 공간에 코드를 끄적였다.

// POST /books/some-book-id/favorites

fetch('/api/books/some-book-id/favorites', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    userId: 'the-uuid-from-supabase',
    email: 'user@example.com'
  })
});

코드를 다 쓰고 나자 솔라는 허탈한 웃음을 터뜨렸다.

“정말이네. 인증을 위한 헤더를 보내는 게 아니었어. 그냥… 그냥 userId 문자열을 body에 담아서 보내는 거였구나. 마치 백엔드 데이터베이스의 users.user_id 컬럼에 들어갈 값을 그대로 넘겨주는 것처럼.”

자신이 그린 첫 번째 다이어그램 옆에, 솔라는 두 번째 다이어그램을 그렸다. 이번에 화살표 위에는 ‘Authorization 헤더’ 대신 ’{ “userId”: ”…” }‘라고 적힌 body 덩어리가 그려졌다. 인증 정보가 아니라, 단순한 데이터처럼 보였다.

백엔드가 인증 헤더를 확인하리라는 단단한 가정은 그렇게 무너졌다. 이 프로젝트에서 프런트엔드와 백엔드는 다른 방식으로 소통하고 있었다. 프런트엔드는 Supabase를 통해 얻은 userId를, 마치 사용자가 입력한 데이터인 것처럼 API 요청 본문에 직접 실어 보내고 있었다. 두 번째 매듭이 풀리는 순간이었다.

하지만 동시에 원래의 질문이 훨씬 더 날카로운 형태로 되돌아왔다.

“알겠어. ID를 그냥 데이터처럼 넘겨준다는 건 이제 알겠는데… 그럼 더 이상한 거 아니야? 이렇게 보내면 백엔드는 이 userId를 대체 어떻게 믿어? 내가 개발자 도구를 열어서 userId를 다른 사람 ID로 바꿔서 요청을 보내면, 그냥 그대로 처리되는 거 아니야? 이건 너무 위험한데… 백엔드는 이 ‘날것의’ ID를 받아서 도대체 뭘 하는 거지?“

3장: 백엔드의 사용자 ID 활용과 책임 분담

솔라는 노트 위에 그려놓은 다이어그램을 노려보고 있었다. 왼쪽의 프런트엔드 상자에서 나온 화살표는 body: { "userId": "..." }라는 꼬리표를 달고 오른쪽의 백엔드 상자로 향했다. 하지만 백엔드 상자 안은 텅 비어 있었다. 아니, 텅 빈 것이 아니었다. 그 안에는 솔라가 직접 그려 넣은, 거대한 물음표가 전부였다.

그녀의 펜 끝이 물음표의 둥근 부분을 초조하게 톡톡 건드렸다. 프런트엔드가 인증을 책임지고, 백엔드에는 ID를 데이터처럼 넘겨준다는 것까지는 이해했다. 하지만 그 다음 단계, 백엔드가 그 ‘날것의’ ID를 받고 무엇을 하는지에 대한 상상이 전혀 되지 않았다. 개발자 도구에서 userId를 다른 사람 것으로 슬쩍 바꾸어 보내면, 시스템은 속수무책으로 당해야만 하는 걸까? 이토록 명백해 보이는 보안 허점을 그대로 두었을 리는 없는데.

“언니, 우리 백엔드는 바보가 아니잖아.”

혼잣말처럼 중얼거리는 솔라의 목소리에 루나가 고개를 들었다. 솔라의 노트에 그려진 커다란 물음표를 본 루나는 조용히 물었다.

“그 상자 안에 어떤 코드가 있을 거라고 생각해, 솔라?”

“음…” 솔라는 잠시 망설이다가 자신의 추측을 털어놓았다. “아마도 FavoriteService 같은 곳에서 userId를 받으면, 그걸 가지고 다시 Supabase 서버에 ‘이 사용자 진짜 있는 거 맞아?’ 하고 물어보는 로직이 있지 않을까? 아니면 숨겨진 API 키 같은 걸로 유효성을 검증하거나. 아무튼 그냥 userId 문자열만 덜렁 믿고 데이터베이스에 작업을 하진 않을 거야. 뭔가… 재확인 절차가 분명히 있을 거라고.”

솔라의 설명은 합리적이었다. 전달받은 정보의 출처를 다시 한번 확인하는 것, 그것은 서버 개발의 기본적인 원칙 중 하나였으니까.

루나는 솔라의 말을 가만히 듣고 나서, 백엔드 프로젝트의 README 파일과 소스 코드 구조를 가리켰다.

“솔라, 여기 FavoriteServiceaddFavorite 메소드가 하는 일을 설명하는 문장을 봐. 그리고 그 다음 호출되는 FavoriteRepository의 함수 이름도.”

솔라는 화면으로 시선을 옮겼다.

  • FavoriteService.addFavoriteuserIdbookId를 받아, 유효성 검사를 거친 후 favoriteRepository.upsertUser(userId, email)favoriteRepository.addFavorite(userId, bookId)를 차례로 호출한다.
  • README의 데이터베이스 스키마 설명: users 테이블의 user_id 컬럼은 Supabase Auth에서 발급된 user.id (UUID 문자열)를 저장한다.

솔라의 눈이 upsertUser라는 이름에서 멈췄다. ‘Upsert’. UPDATEINSERT의 조합어. 데이터베이스에 해당 데이터가 있으면 업데이트하고, 없으면 새로 삽입하는 작업을 의미하는, 지극히 ‘데이터 중심적인’ 용어였다. validateUserFromSupabase 같은 이름이 아니었다.

“잠깐만… upsertUser라고? 사용자를 ‘검증’하는 게 아니라, 그냥 우리 데이터베이스에 ‘넣거나 업데이트’한다고? 그래서 READMEusers.user_id 컬럼이 Supabase의 ID를 저장한다고 쓰여 있었구나.”

솔라는 마치 암호를 해독하듯 코드의 흐름을 머릿속으로 다시 그려보았다.

  1. 프런트엔드에서 {"userId": "abc-123", "email": "a@a.com"} 와 함께 즐겨찾기 추가 요청이 백엔드로 들어온다.
  2. 백엔드의 FavoriteServiceuserId인 “abc-123”을 인자로 받는다.
  3. FavoriteService는 Supabase에 “이 사용자 진짜야?”라고 묻지 않는다.
  4. 대신, 백엔드의 데이터베이스를 관리하는 FavoriteRepository에게 “users 테이블에 ‘abc-123’ ID를 가진 사용자가 있어? 없으면 이 이메일 주소랑 같이 새로 등록해줘.” 라고 말한다. (upsertUser)
  5. 그 다음, “favorites 테이블에 ‘abc-123’ 사용자와 이 책을 연결해줘.” 라고 말한다. (addFavorite)

그 순간, 솔라는 거대한 착각을 하고 있었음을 깨달았다. 백엔드는 사용자 ‘인증’에 전혀 관여하지 않고 있었다.

“아! 우리 백엔드는 출입국 심사관이 아니었어! 도서관 사서였구나!”

엉뚱한 비유에 루나가 의아한 표정을 짓자, 솔라는 신이 나서 설명을 이어갔다.

“백엔드는 ‘당신이 누구요?’ 하고 신분증을 검사하는 게 아니었던 거야. 그냥 프런트엔드가 ‘이 책은 회원번호 abc-123 앞으로 등록해주세요’ 하고 건넨 쪽지를 보고, 조용히 ‘abc-123’ 회원의 대출 목록에 책을 추가하거나, 그런 회원이 없으면 신규 회원으로 등록한 뒤에 책을 대출시키는 일을 하고 있었던 거지.”

백엔드의 책임은 ‘인증된 사용자인가’를 판단하는 것이 아니라, ‘전달받은 사용자 ID’를 기준으로 우리 시스템의 데이터를 어떻게 구성하고 관계 맺을 것인가에 있었다. 인증의 책임은 Supabase와 프런트엔드가 연대하여 이미 완수한 상태였다. 백엔드는 그 결과를 신뢰하고, 전달받은 ID를 데이터베이스의 primary key처럼 활용하여 ‘어떤 사용자가 어떤 책을 좋아하는지’라는 ‘관계’를 기록할 뿐이었다.

솔라의 펜이 다시 움직였다. 노트 위 백엔드 상자 안에 그려져 있던 거대한 물음표를 시원하게 지웠다. 그리고 그 자리에 새로운 흐름을 간결하게 적어 넣었다.

userId 수신 → users 테이블에 upsert → favorites 테이블에 관계 저장

모든 것이 명확해졌다. 이제 이 구조의 위험성은 ‘누군가 다른 사람의 ID를 위조해서 요청을 보내면 어떡하지?‘가 아니었다. 이 시스템은 ‘프런트엔드와 Supabase가 인증을 제대로 처리했을 것’이라는 신뢰를 전제로 설계된 것이다. 그 전제가 깨지는 상황(예: 공개된 클라이언트에서 악의적인 API 호출)은 이 구조의 책임 분담 경계 밖에서, 별도의 API 게이트웨이나 다른 보안 계층으로 다뤄야 할 문제였다.

루나는 만족스럽게 바뀐 다이어그램을 바라보는 솔라에게, 이 모든 혼란의 시작이었던 README의 첫 문장을 다시 보여주었다.

“5차 frontend는 Supabase Auth의 getUseronAuthStateChange로 현재 사용자를 얻고, backend favorite API는 그 userId를 users/favorite relation에 맞춘다.”

솔라는 그 문장을 한참 동안 바라보았다. 처음에는 안개처럼 흐릿하고 모순적으로 느껴졌던 문장이었다. 하지만 이제는 더없이 명료하고 정확한 아키텍처의 청사진으로 보였다. 솔라는 문장의 한가운데에 펜으로 길게 수직선을 그었다.

그리고 선의 왼쪽, frontend는 ... 사용자를 얻고 부분 위에 인증 책임 (FE + Supabase)이라고 적었다. 선의 오른쪽, backend ... relation에 맞춘다 부분 위에는 데이터 관계 책임 (BE)이라고 적었다.

하나의 문장이 두 개의 명확한 책임 영역으로 나뉘었다. 더 이상 헷갈리지 않을 것 같았다.