[외주] 배포하기 및 로그인 및 Supabase - 4/18

https://wo-dbs.tistory.com/383\

 

[외주] 어떤 데이터를 관리해야할지 선정 필요 && DB 설계 - 4/5

이력 데이터 선정고시원 관리 프로그램에서 모든 변동 사항을 이력으로 남기기보다는 실제 운영에 반복적으로 확인되거나 분쟁 및 정산 근거로 활용될 수 있는 데이터만 이력으로 관리할 필요

wo-dbs.tistory.com

 

  • 위에서 DB설계를 진행했었는데 기능들이 바뀌게 되면서 또 DB설계고 바뀌게 되었다.
  • 이번 글에서는 내가 만드는 것을 배포하는 것까지 해볼 것이다.
  • 또한, 배포를 하더라도 이건 누구나 사용하면 안되고 인증된 사용자면 사용할 수 있어야한다. 그 부분을 어떻게 했는지 작성해보자
  • 또한, Supabase까지 해보자.

 

Supabase 연동 

 

다음과 같은 SQL 실행

 

Next.js랑 연동

npm install @supabase/supabase-js @supabase/ssr

project Settings → Project ID

 

Publishable key

 

.env.local

NEXT_PUBLIC_SUPABASE_URL=http....
NEXT_PUBLIC_SUPABASE_ANON_KEY=...

 

로그인 요구사항

  • 고시원에 소속된 사람만 이 장부를 쓸 수 있도록 해야한다. 그래서 이것을 어떻게 할지 고민을 해보았다.

 

로그인 방법

[1. 직접 구현 (DB + 세션)]

직접 users 테이블 만들고 bcrypt로 비밀번호 해시, 세션 관리까지 전부 구현

장점

  • 완전한 커스터마이징 가능
  • 외부 의존성 없음

단점

  • 구현량이 매우 많음
  • 보안 취약점 직접 관리해야 함 (SQL injection, 브루트포스 등)
  • 세션 스토리지 별도 필요 (Redis 등)

 

[2. NextAuth.js]

Next.js 전용 인증 라이브러리

장점

  • Google, Kakao 등 소셜 로그인 쉽게 추가 가능
  • Next.js와 완벽히 통합

단점

  • 설정이 복잡함
  • DB 어댑터 별도 연결 필요
  • Supabase랑 같이 쓰면 중복

 

[3. Supabase Auth]

장점

  • 이미 Supabase를 DB로 쓰고 있어서 별도 인증 서버 불필요
  • RLS와 자동 연동 — 로그인한 사용자만 DB 접근 가능
  • JWT 발급/갱신/만료 전부 자동 처리
  • 구현량 최소 (로그인 폼 + 미들웨어만 만들면 끝)
  • 나중에 소셜 로그인 추가도 쉬움

단점

  • Supabase 의존성 생김
  • Supabase 서비스 장애 시 로그인도 불가

 

선택 이유 한 줄 요약

  • 이 앱은 DB도 Supabase인데, 인증까지 Supabase에서 처리하면 RLS와 자동 연동되어 별도 권한 체크 코드 없이 DB 보안이 해결되기 때문

 

로그인 만들기 → 1. 미들웨어 (모든 요청의 시작점)

사용자가 다음과 같은 URI의 접속

<uri> 접속
  • middleware.ts 실행 (Next.js가 모든 요청 전에 가로챔)
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  // 로그인 안 된 상태에서 /login 외 페이지 접근 시 → /login 리다이렉트
  if (!user && !request.nextUrl.pathname.startsWith('/login')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // 로그인된 상태에서 /login 접근 시 → / 리다이렉트
  if (user && request.nextUrl.pathname.startsWith('/login')) {
    return NextResponse.redirect(new URL('/', request.url));
  }

  return supabaseResponse;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};

  • Supabase에 "이 요청에 세션(로그인 쿠키)이 있나?" 확인
  • 없음 → /login으로 리다이렉트, 있음 → 요청 통과

 

자세한 설명

사용자가 예를 들어

  • /
  • /rooms
  • /contracts

이런 페이지로 들어오려고 하면 → 페이지를 보여주기 전에 먼저 middleware가 실행돼서 확인함.

  1. 이 요청에 로그인 쿠키가 있나?
  2. 그 쿠키로 Supabase가 사용자를 확인할 수 있나?
  3. 확인되면 통과
  4. 아니면 /login으로 보냄

 

import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

의미

  • createServerClient → 서버 환경에서 Supabase 클라이언트를 만드는 함수
  • NextRequest → 지금 들어온 요청 정보
  • NextResponse → 이 요청에 대해 어떻게 응답할지 결정하는 객체

즉,

  • 요청을 받고
  • Supabase로 사용자 확인하고
  • 통과 또는 리다이렉트 응답을 만드는 준비를 하는 것

 

[middleware 함수 시작]

export async function middleware(request: NextRequest) {

브라우저가 어떤 페이지를 요청할 때마다 이 함수가 실행됨.

예 → 사용자가 https://사이트주소/ 접속, 그러면 이 함수가 먼저 실행, request 안에는 이런 정보가 들어 있음

  • 요청 경로 (/, /login 등), 쿠키, 헤더, URL

 

[기본 응답 준비]

let supabaseResponse = NextResponse.next({ request });

이건 일단 기본값으로 “특별한 문제 없으면 다음 단계로 그냥 통과시킬게” 라는 응답을 미리 만들어 두는 거 즉 아직은 막지 않고, 기본적으로는 통과가 기본값

 

[Supabase 서버 클라이언트 생성]

const supabase = createServerClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,

여기서 Supabase 프로젝트에 연결할 클라이언트를 만든다.

필요한 값 → Supabase URL, Supabase anon key 이걸로 “어느 Supabase 프로젝트에 물어볼 건지”가 정해짐.

 

[cookie 설정 부분]

{
  cookies: {
    getAll() {
      return request.cookies.getAll();
    },

Supabase가 로그인 상태를 확인하려면 현재 요청에 붙어 있는 쿠키를 읽어야 함. 왜냐면 로그인 세션 정보가 보통 쿠키에 들어 있기 때문

즉 이 부분은 → “Supabase야, 현재 요청에 포함된 쿠키들을 여기서 읽으면 됨” 라는 뜻이야. “이 요청에세션(로그인 쿠키)이 있나?” 이 부분이 바로 여기랑 연결됨.

 

로그인 만들기 → 2. 로그인 페이지

[/login 접속 → page.tsx]

  • 이메일 + 비밀번호 입력 후 제출 후 다음 실행
  • 밑은 로그인 페이지에 이 함수가 있는 거임
supabase.auth.signInWithPassword({ email, password }) 

 

[Supabase 서버로 요청 전송]

  • Supabase가 Authentication → Users에 등록된 계정과 대조

 

일치 → JWT 토큰 발급 → 브라우저 쿠키에 저장

불일치 → 에러 반환 → "이메일 또는 비밀번호가 올바르지 않습니다" 표시

 

로그인 만들기 → 3. 로그인 성공 후

[쿠키에 JWT 토큰 저장됨]

  • router.push(’/’)로 이동 == 이건 로그인 페이지에 있는 거
  • 미들 웨어 재실행

 

[쿠키의 JWT로 Supabase에 이 토큰 유효한가 확인]

  • 유효 → 대시 보드 진입

 

로그인 만들기 → 4. 이후 모든 페이지 이동

  • 페이지 이동할 때 마다 미들웨어 실행

쿠키에 JWT 있으면 통과

쿠키 만료되거나 없으면 /login으로 다시 리다이렉트 == middleware가 항상 확인함.

 

 

로그인 만들기 → 5. Supabase RLS와의 연결

[로그인된 상태에서 DB 쿼리 실행]

  • JWT 토큰을 함께 전송
  • Supabase RLS 정책 확인, RLS는 DB 접근 시 인증 여부 검사

"authenticated 사용자인가?" → 맞음 → 데이터 반환

"authenticated 사용자인가?" → 아님 → 거부

 

핵심 구성요소 정리

구성요소 역할

구성요소 역할
middleware.ts 모든 요청 가로채서 세션 확인
app/lib/supabase/client.ts 브라우저에서 Supabase 연결 (로그인 폼)
app/lib/supabase/server.ts 서버에서 Supabase 연결 (세션 확인)
Supabase JWT 로그인 증명서, 쿠키에 저장
Supabase RLS DB 접근 시 인증 여부 검사

 

 

만들면서 궁금했던 점 → 1. 궁금즘 생김 → 쿠키, 헤더, 요청 경로 같은 거 보고 싶어짐

로그인 전

다음은 로거를 한 번찍어본 거임

===== middleware start =====
pathname: /login
full url: <http://localhost:3000/login>
method: GET
headers: {
  accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
  accept-encoding: 'gzip, deflate, br, zstd',
  accept-language: 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
  cache-control: 'max-age=0',
  connection: 'keep-alive',
  cookie: 'Idea-27414554=61842936-c0a5-4b19-a1b7-8c52286deeec',
  host: 'localhost:3000',
  referer: '<http://localhost:3000/login>',
  sec-ch-ua: '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
  sec-ch-ua-mobile: '?0',
  sec-ch-ua-platform: '"macOS"',
  sec-fetch-dest: 'empty',
  sec-fetch-mode: 'navigate',
  sec-fetch-site: 'same-origin',
  upgrade-insecure-requests: '1',
  user-agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
  x-forwarded-for: '::1',
  x-forwarded-host: 'localhost:3000',
  x-forwarded-port: '3000',
  x-forwarded-proto: 'http'
}
cookies: [ { name: 'Idea-27414554', value: '61842936-c0a5-4b19-a...' } ]
 GET /login 200 in 508ms (compile: 254ms, proxy.ts: 65ms, render: 189ms)
===== middleware start =====
pathname: /sw.js
full url: <http://localhost:3000/sw.js>
method: GET
headers: {
  accept: '*/*',
  accept-encoding: 'gzip, deflate, br, zstd',
  accept-language: 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
  cache-control: 'max-age=0',
  connection: 'keep-alive',
  cookie: 'Idea-27414554=61842936-c0a5-4b19-a1b7-8c52286deeec',
  host: 'localhost:3000',
  referer: '<http://localhost:3000/sw.js>',
  sec-fetch-dest: 'serviceworker',
  sec-fetch-mode: 'same-origin',
  sec-fetch-site: 'same-origin',
  service-worker: 'script',
  user-agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
  x-forwarded-for: '::1',
  x-forwarded-host: 'localhost:3000',
  x-forwarded-port: '3000',
  x-forwarded-proto: 'http'
}
cookies: [ { name: 'Idea-27414554', value: '61842936-c0a5-4b19-a...' } ]

분석 한 번 해보자

 

  1. 로그 /login 요청
pathname: /login
full url: <http://localhost:3000/login>
method: GET

이건 브라우저가 로그인 페이지 자체를 요청했다는 뜻이야

즉 사용자가 http://localhost:3000/login → 이 주소로 들어았고, 그 요청이 middleware를 먼저 통과한 거임

 

헤더에서 볼 수 있는 것

host: 'localhost:3000'
referer: '<http://localhost:3000/login>'
user-agent: 'Mozilla/5.0 ... Chrome ...'
cookie: 'Idea-27414554=...'
  • host → 현재 요청이 어디로 갔는지
  • referer → 어디에서 이 요청이 발생했는지
  • user-agent → 어떤 브라우저인지
  • cookie → 이 요청에 브라우저가 같이 보낸 쿠키

그런데 지금 쿠키를 보면 Supabase 로그인 쿠키는 없고, IntelliJ/IDE 관련 쿠키만 있어 보임

 

현재 상태는 다음과 같다.

  • 브라우저가 /login 페이지를 요청했고, 로그인 세션으로 보이는 Supabase 쿠키는 아직 안 붙어 있다.
  • 그래서 /login은 로그인 아 해도 들어갈 수 있으니 그냥 200으로 렌더링 된거임

 

2. 2번째 로그 /sw.js 요청

pathname: /sw.js
full url: <http://localhost:3000/sw.js>
method: GET

이건 브라우저가 페이지를 연 뒤에 서비스 워커 파일도 요청했다는 뜻 sw.js는 보통 PWA, 알림, 캐시 같은 기능에서 쓰는 service worker script임.

 

헤더를 보면

sec-fetch-dest: 'serviceworker'
service-worker: 'script'

즉, 브라우저가 말함 → /login 페이지만 가져온 게 아니라, 그 페이지가 참조하는 sw.js도 같이 가져오려고 했음.

 

왜 middleware가 /sw.js에도 실행됐냐 아까 우리가 만든 코드에서

matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)']

이건

  • _next/static 제외
  • _next/image 제외
  • favicon.ico 제외
  • 이미지 파일 제외

근데 sw.js는 제외 대상이 아님 그래서 /sw.js 요청도 middleware를 타버린 거임

 

[이번에는 Supabase 로그인해서 보자]

로그인 후

===== middleware start =====
pathname: /
full url: <http://localhost:3000/>
method: GET
headers: {
  accept: '*/*',
  accept-encoding: 'gzip, deflate, br, zstd',
  accept-language: 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
  connection: 'keep-alive',
  cookie: 'Idea-27414554=61842936-c0a5-4b19-a1b7-8c52286deeec; sb-ynqdssfpngzzbqlvrvvo-auth-token=base64-eyJhY2Nlc3NfdG9rZW4iOiJleUpoYkdjaU9pSkZVekkxTmlJc0ltdHBaQ0k2SWpZeU1HWXpOMk5oTFdGbE1USXRORFJsWVMxaE9XWTRMVEprT1RkbVpUQXhZbU5qTVNJc0luUjVjQ0k2SWtwWFZDSjkuZXlKcGMzTWlPaUpvZEhSd2N6b3ZMM2x1Y1dSemMyWndibWQ2ZW1KeGJIWnlkblp2TG5OMWNHRmlZWE5sTG1OdkwyRjFkR2d2ZGpFaUxDSnpkV0lpT2lJMk5qQXhaRFkyWmkwMk1EVmlMVFE1T0RFdFlUWmxaaTB4TTJZek5HRXlOemswWTJRaUxDSmhkV1FpT2lKaGRYUm9aVzUwYVdOaGRHVmtJaXdpWlhod0lqb3hOemMyTkRRMk5EazJMQ0pwWVhRaU9qRTNOelkwTkRJNE9UWXNJbVZ0WVdsc0lqb2ljM0JoWTJWb2IzSnBiV2R2Ym1kQVoyMWhhV3d1WTI5dElpd2ljR2h2Ym1VaU9pSWlMQ0poY0hCZmJXVjBZV1JoZEdFaU9uc2ljSEp2ZG1sa1pYSWlPaUpsYldGcGJDSXNJbkJ5YjNacFpHVnljeUk2V3lKbGJXRnBiQ0pkZlN3aWRYTmxjbDl0WlhSaFpHRjBZU0k2ZXlKbGJXRnBiRjkyWlhKcFptbGxaQ0k2ZEhKMVpYMHNJbkp2YkdVaU9pSmhkWFJvWlc1MGFXTmhkR1ZrSWl3aVlXRnNJam9pWVdGc01TSXNJbUZ0Y2lJNlczc2liV1YwYUc5a0lqb2ljR0Z6YzNkdmNtUWlMQ0owYVcxbGMzUmhiWEFpT2pFM056WTBOREk0T1RaOVhTd2ljMlZ6YzJsdmJsOXBaQ0k2SW1Ka1lUY3pNVEExTFdVM1kyVXRORGM1T1MwNE5tUTJMVGM1WkRNMk1qQTVNekEzTXlJc0ltbHpYMkZ1YjI1NWJXOTFjeUk2Wm1Gc2MyVjkuckU5cWppMzlqYTZEWWk2cnRUZDZsWTVhZ1FtWVdiMnNGbmVvZUJEVk5RQ3RPQ0ttTGlCMl9PczNTRlV6c3FfeUhuaWNZbFdzcnNsa3RlaUNjazNaN3ciLCJ0b2tlbl90eXBlIjoiYmVhcmVyIiwiZXhwaXJlc19pbiI6MzYwMCwiZXhwaXJlc19hdCI6MTc3NjQ0NjQ5NiwicmVmcmVzaF90b2tlbiI6InRoNmpvcXdsMzVjbiIsInVzZXIiOnsiaWQiOiI2NjAxZDY2Zi02MDViLTQ5ODEtYTZlZi0xM2YzNGEyNzk0Y2QiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJlbWFpbCI6InNwYWNlaG9yaW1nb25nQGdtYWlsLmNvbSIsImVtYWlsX2NvbmZpcm1lZF9hdCI6IjIwMjYtMDQtMTdUMTM6MjI6MjcuNTU1MTM3WiIsInBob25lIjoiIiwiY29uZmlybWVkX2F0IjoiMjAyNi0wNC0xN1QxMzoyMjoyNy41NTUxMzdaIiwibGFzdF9zaWduX2luX2F0IjoiMjAyNi0wNC0xN1QxNjoyMTozNi44ODIwNTcyMDlaIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnsiZW1haWxfdmVyaWZpZWQiOnRydWV9LCJpZGVudGl0aWVzIjpbeyJpZGVudGl0eV9pZCI6ImU5ZmY5ZmE2LTRjYWItNGJjMy1hNjVkLWE2ZTVjYTEyN2ZhYyIsImlkIjoiNjYwMWQ2NmYtNjA1Yi00OTgxLWE2ZWYtMTNmMzRhMjc5NGNkIiwidXNlcl9pZCI6IjY2MDFkNjZmLTYwNWItNDk4MS1hNmVmLTEzZjM0YTI3OTRjZCIsImlkZW50aXR5X2RhdGEiOnsiZW1haWwiOiJzcGFjZWhvcmltZ29uZ0BnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInBob25lX3ZlcmlmaWVkIjpmYWxzZSwic3ViIjoiNjYwMWQ2NmYtNjA1Yi00OTgxLWE2ZWYtMTNmMzRhMjc5NGNkIn0sInByb3ZpZGVyIjoiZW1haWwiLCJsYXN0X3NpZ25faW5fYXQiOiIyMDI2LTA0LTE3VDEzOjIyOjI3LjU1MzI2MloiLCJjcmVhdGVkX2F0IjoiMjAyNi0wNC0xN1QxMzoyMjoyNy41NTMzMTlaIiwidXBkYXRlZF9hdCI6IjIwMjYtMDQtMTdUMTM6MjI6MjcuNTUzMzE5WiIsImVtYWlsIjoic3BhY2Vob3JpbWdvbmdAZ21haWwuY29tIn1dLCJjcmVhdGVkX2F0IjoiMjAyNi0wNC0xN1QxMzoyMjoyNy41NDI3ODdaIiwidXBkYXRlZF9hdCI6IjIwMjYtMDQtMTdUMTY6MjE6MzYuOTAwMzgzWiIsImlzX2Fub255bW91cyI6ZmFsc2V9LCJ3ZWFrX3Bhc3N3b3JkIjpudWxsfQ',
  host: 'localhost:3000',
  next-url: '/login',
  referer: '<http://localhost:3000/login>',
  sec-ch-ua: '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
  sec-ch-ua-mobile: '?0',
  sec-ch-ua-platform: '"macOS"',
  sec-fetch-dest: 'empty',
  sec-fetch-mode: 'cors',
  sec-fetch-site: 'same-origin',
  user-agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
  x-forwarded-for: '::1',
  x-forwarded-host: 'localhost:3000',
  x-forwarded-port: '3000',
  x-forwarded-proto: 'http',
  x-nextjs-html-request-id: '2WKFvWGG5ByjvxykFdk-0',
  x-nextjs-request-id: '1328f718'
}
cookies: [
  { name: 'Idea-27414554', value: '61842936-c0a5-4b19-a...' },
  {
  name: 'sb-ynqds...',
  value: 'base64......'
}
]
 GET / 200 in 371ms (compile: 67ms, proxy.ts: 260ms, render: 44ms)
===== middleware start =====
pathname: /
full url: <http://localhost:3000/>
method: GET
headers: {
  accept: '*/*',
  accept-encoding: 'gzip, deflate, br, zstd',
  accept-language: 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
  connection: 'keep-alive',
  cookie: 'Idea-27414554=61842936-c0a5-4b19-a1b7-8c52286deeec; sb-ynqdssfpngzzbqlvrvvo-auth-token=base64-eyJhY2Nlc3NfdG9rZW4iOiJleUpoYkdjaU9pSkZVekkxTmlJc0ltdHBaQ0k2SWpZeU1HWXpOMk5oTFdGbE1USXRORFJsWVMxaE9XWTRMVEprT1RkbVpUQXhZbU5qTVNJc0luUjVjQ0k2SWtwWFZDSjkuZXlKcGMzTWlPaUpvZEhSd2N6b3ZMM2x1Y1dSemMyWndibWQ2ZW1KeGJIWnlkblp2TG5OMWNHRmlZWE5sTG1OdkwyRjFkR2d2ZGpFaUxDSnpkV0lpT2lJMk5qQXhaRFkyWmkwMk1EVmlMVFE1T0RFdFlUWmxaaTB4TTJZek5HRXlOemswWTJRaUxDSmhkV1FpT2lKaGRYUm9aVzUwYVdOaGRHVmtJaXdpWlhod0lqb3hOemMyTkRRMk5EazJMQ0pwWVhRaU9qRTNOelkwTkRJNE9UWXNJbVZ0WVdsc0lqb2ljM0JoWTJWb2IzSnBiV2R2Ym1kQVoyMWhhV3d1WTI5dElpd2ljR2h2Ym1VaU9pSWlMQ0poY0hCZmJXVjBZV1JoZEdFaU9uc2ljSEp2ZG1sa1pYSWlPaUpsYldGcGJDSXNJbkJ5YjNacFpHVnljeUk2V3lKbGJXRnBiQ0pkZlN3aWRYTmxjbDl0WlhSaFpHRjBZU0k2ZXlKbGJXRnBiRjkyWlhKcFptbGxaQ0k2ZEhKMVpYMHNJbkp2YkdVaU9pSmhkWFJvWlc1MGFXTmhkR1ZrSWl3aVlXRnNJam9pWVdGc01TSXNJbUZ0Y2lJNlczc2liV1YwYUc5a0lqb2ljR0Z6YzNkdmNtUWlMQ0owYVcxbGMzUmhiWEFpT2pFM056WTBOREk0T1RaOVhTd2ljMlZ6YzJsdmJsOXBaQ0k2SW1Ka1lUY3pNVEExTFdVM1kyVXRORGM1T1MwNE5tUTJMVGM1WkRNMk1qQTVNekEzTXlJc0ltbHpYMkZ1YjI1NWJXOTFjeUk2Wm1Gc2MyVjkuckU5cWppMzlqYTZEWWk2cnRUZDZsWTVhZ1FtWVdiMnNGbmVvZUJEVk5RQ3RPQ0ttTGlCMl9PczNTRlV6c3FfeUhuaWNZbFdzcnNsa3RlaUNjazNaN3ciLCJ0b2tlbl90eXBlIjoiYmVhcmVyIiwiZXhwaXJlc19pbiI6MzYwMCwiZXhwaXJlc19hdCI6MTc3NjQ0NjQ5NiwicmVmcmVzaF90b2tlbiI6InRoNmpvcXdsMzVjbiIsInVzZXIiOnsiaWQiOiI2NjAxZDY2Zi02MDViLTQ5ODEtYTZlZi0xM2YzNGEyNzk0Y2QiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJlbWFpbCI6InNwYWNlaG9yaW1nb25nQGdtYWlsLmNvbSIsImVtYWlsX2NvbmZpcm1lZF9hdCI6IjIwMjYtMDQtMTdUMTM6MjI6MjcuNTU1MTM3WiIsInBob25lIjoiIiwiY29uZmlybWVkX2F0IjoiMjAyNi0wNC0xN1QxMzoyMjoyNy41NTUxMzdaIiwibGFzdF9zaWduX2luX2F0IjoiMjAyNi0wNC0xN1QxNjoyMTozNi44ODIwNTcyMDlaIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnsiZW1haWxfdmVyaWZpZWQiOnRydWV9LCJpZGVudGl0aWVzIjpbeyJpZGVudGl0eV9pZCI6ImU5ZmY5ZmE2LTRjYWItNGJjMy1hNjVkLWE2ZTVjYTEyN2ZhYyIsImlkIjoiNjYwMWQ2NmYtNjA1Yi00OTgxLWE2ZWYtMTNmMzRhMjc5NGNkIiwidXNlcl9pZCI6IjY2MDFkNjZmLTYwNWItNDk4MS1hNmVmLTEzZjM0YTI3OTRjZCIsImlkZW50aXR5X2RhdGEiOnsiZW1haWwiOiJzcGFjZWhvcmltZ29uZ0BnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInBob25lX3ZlcmlmaWVkIjpmYWxzZSwic3ViIjoiNjYwMWQ2NmYtNjA1Yi00OTgxLWE2ZWYtMTNmMzRhMjc5NGNkIn0sInByb3ZpZGVyIjoiZW1haWwiLCJsYXN0X3NpZ25faW5fYXQiOiIyMDI2LTA0LTE3VDEzOjIyOjI3LjU1MzI2MloiLCJjcmVhdGVkX2F0IjoiMjAyNi0wNC0xN1QxMzoyMjoyNy41NTMzMTlaIiwidXBkYXRlZF9hdCI6IjIwMjYtMDQtMTdUMTM6MjI6MjcuNTUzMzE5WiIsImVtYWlsIjoic3BhY2Vob3JpbWdvbmdAZ21haWwuY29tIn1dLCJjcmVhdGVkX2F0IjoiMjAyNi0wNC0xN1QxMzoyMjoyNy41NDI3ODdaIiwidXBkYXRlZF9hdCI6IjIwMjYtMDQtMTdUMTY6MjE6MzYuOTAwMzgzWiIsImlzX2Fub255bW91cyI6ZmFsc2V9LCJ3ZWFrX3Bhc3N3b3JkIjpudWxsfQ',
  host: 'localhost:3000',
  referer: '<http://localhost:3000/login>',
  sec-ch-ua: '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
  sec-ch-ua-mobile: '?0',
  sec-ch-ua-platform: '"macOS"',
  sec-fetch-dest: 'empty',
  sec-fetch-mode: 'cors',
  sec-fetch-site: 'same-origin',
  user-agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
  x-forwarded-for: '::1',
  x-forwarded-host: 'localhost:3000',
  x-forwarded-port: '3000',
  x-forwarded-proto: 'http',
  x-nextjs-html-request-id: '2WKFvWGG5ByjvxykFdk-0',
  x-nextjs-request-id: 'e75b8428'
}
cookies: [
  { name: 'Idea-27414554', value: '61842936-c0a5-4b19-a...' },
  {
  name: '...',
  value: 'base64..'
}
]
 GET / 200 in 180ms (compile: 4ms, proxy.ts: 157ms, render: 18ms)
  1. /요청

즉 사용자가 로그인 후 / 메인 페이지로 이동하려고 했고, middleware가 그 요청을 먼저 본 거

pathname: /
full url: <http://localhost:3000/>

 

여기서 핵심 헤더/쿠키

sb-ynqd.....

이건 Supabase가 로그인 성공 후 브라우저에 저장해둔 세션 쿠키임

이 안에는 보통:

  • access token
  • refresh token
  • user 정보 일부
  • expires_at

같은 게 들어 있음

즉 middleware가 지금 보고 있는 건 단순 문자열이 아니라, “이 브라우저는 이 사용자가 로그인했고, 이 토큰으로 인증 가능하다” 는 정보 묶음임

 

[그래서 middleware 안에서는 실제로 무슨 일이 일어나냐]

코드 기준으로 이 줄이 실행됨

const { data: { user } } = await supabase.auth.getUser();

이때 Supabase는

  1. request에 들어온 쿠키 읽음
  2. sb-...-auth-token 확인
  3. 그 안의 토큰으로 사용자 확인
  4. 유효하면 user 반환

즉 지금은 로그인 이후라서 user가 존재하는 상태일 가능성이 높음.

 

[왜 그렇게 볼 수 있냐]

마지막 결과가 이거임

GET / 200

네 middleware 로직상 / 요청에서

  • 로그인 안 되어 있으면 /login으로 리다이렉트해야 함
  • 로그인 되어 있으면 통과

그런데 지금 /가 200으로 끝났다는 건 → middleware가 이 요청을 로그인된 사용자 요청으로 판단해서 통과시켰다는 뜻이야. 즉 내부적으로는 아마 이런 흐름

  • pathname = /
  • sb-...-auth-token 쿠키 있음
  • getUser() 성공
  • user 있음
  • /login으로 보낼 필요 없음
  • return supabaseResponse
  • 페이지 정상 렌더링

 

[쿠키 안 문자열이 왜 저렇게 길고 지저분하냐]

base64...

이건 base64로 인코딩된 JSON 세션 정보라고 보면 됨 → 안쪽을 풀면 대충 이런 구조가 들어 있음.

{
  "access_token": "...",
  "refresh_token": "...",
  "user": {
    "id": "...",
    "email": "<이메일>"
  },
  "expires_at": ...
}

즉 브라우저는 이걸 쿠키로 들고 있고, middleware는 그걸 통해 로그인 여부를 확인하는 거

 

만들면서 궁금했던 점 → 2. 쿠키란?

  • 쿠키는 브라우저가 저장해 두는 작은 데이터 조각
  • 서버가 브라우저에게 → 이 값 좀 저장해뒀다가 다음 요청할 때 같이 보내줘 라고 맡기는 메모 같은 거

 

쿠키가 왜 필요하냐

HTTP 요청은 원래 매번 독립적임 예를 들어

  1. 사용자가 로그인 요청을 보냄
  2. 서버가 로그인 성공 판단
  3. 그런데 다음 요청에서 서버는 원래 그 사용자를 기억 못함

그래서 서버가 브라우저에게 쿠키를 줌

 

예 → 세션ID, 로그인 토큰 관련 값, 사용자 설정, 최근 본 항목

브라우저는 그걸 저장했다가 다음에 같은 사이트로 요청할 때 자동으로 같이 보낸다.

 

쿠키 흐름 예시

로그인 할때

서버가 응답하면서 이런 느낌으로 보냄

Set-Cookie: session=abc123

브라우저는 이걸 저장함

 

다음 요청 때 → 브라우저가 자동으로 이렇게 보냄

Cookie: session=abc123

그러면 서버는 보고 아 이 브라우저는 아까 로그인했던 사용자구나 하고 알 수 있음

 

그래서 아까 코드를 보면 다음 코드는 현재 요청에 브라우저가 같이 보낸 쿠키들을 꺼내는 거임

request.cookies.getAll()

즉 middleware는

  1. 브라우저가 보낸 쿠키를 읽고
  2. Supabase에 전달해서
  3. 로그인 세션인지 확인하는 거임

 

쿠키 안에는 뭐가 들어갈 수 있을까

  • 꼭 로그인만 있는 거는 아님

→ 로그인 세션 식별자, 언어 설정, 다크모드 여부, 장바구니 정보 일부, 팝업 다시 보지 않기 값 이런 것도 들어갈 수 있음

 

쿠키 안에는 중요한 정보 넣으면 안된다!

쿠키는 브라우저에 저장되니까 민감한 값을 그대로 넣는 건 조심해야함

 

그래서 보통은

  • 진짜 사용자 정보 전체를 넣는 게 아니라 세션ID나 토큰 같은 식별자 값만 넣고 실제 사용자 정보는 서버나 인증 시스템이 확인함

내가 본 쿠키는 이건 Supabase 로그인 쿠키가 아니라 개발 환경 쪽 쿠키일 가능성이 크다.

Idea-27414554=...

 

만들면서 궁금했던 점 → 3. 서비스 워커 파일이란?

서비스 워커 파일은 브라우저 뒤에서 따로 돌아가는 자바스크립트 파일임 보통 웹페이지 코드와 다르게 페이지 안에서만 실행되는 게 아니라 브라우저 레벨에서 백그라운드처러 동작함

 

대표적으로 하는 일이 다음과 같음.

  • 오프라인 캐시, 푸시 알림, 네트워크 요청 가로채기, PWA 동작 지원

왜 이름이 sw.js냐 → 보통 파일명을 많이 다음과 같이 씀

sw.js
service-worker.js

즉 sw.js는 거의 늘 service worker script를 뜻함

 

일반 JS랑 다른 점

  • 일반 JS는 보통 페이지가 열려 있어야 동작함
  • 예를 들어 → 버튼 클릭, 화면 렌더링, 입력 처리

반면 서비스 워커는 페이지 바깥쪽에서 요청을 가로채거나 캐시를 관리할 수 있음

예를 들면

  1. 사용자가 사이트 접속
  2. 브라우저가 sw.js를 등록
  3. 이후 요청을 서비스 워커가 중간에서 볼 수 있음
  4. 인터넷이 없어도 캐시된 파일을 보여줄 수 있음

 

[비유]

웹페이지 JS가 가게 안 직원이라면, 서비스 워커는 건물 경비실 같은 느낌

  • 손님이 들어오는 요청을 먼저 볼 수 있고
  • 이전에 저장한 걸 꺼내줄 수도 있고
  • 네트워크 연결이 없어도 일부 대응 가능

 

[어디에 쓰이냐]

1. 오프라인 동작 → 인터넷이 잠깐 끊겨도 예전에 받아둔 HTML, CSS, JS를 캐시에서 꺼내줄 수 있음

2. 푸시 알림 → 브라우저 푸시 알림 기능은 보통 서비스 워커를 통해 동작

3. 네트워크 요청 제어 → 어떤 요청은 서버로 보내고, 어떤 요청은 캐시에서 바로 응답하게 만들 수 있음

 

[내 로그에 /sw.js가 뜨는 이유]

  • 브라우저가 로그인 페이지를 열면서 추가로 sw.js도 요청한 거임

즉 /login 페이지 요청, 그 페이지와 관련된 서비스 워커 파일 /sw.js도 요청

 

만들면서 궁금했던 점 → 4. 웹이 폰에서도 똑같이 동작?

같은 점

  • 휴대폰 브라우저도 웹 브라우저라서 요청 보냄, 쿠키 저장함, 로그인 세션 유지, service worker 지원, middleware 거침

이 구조는 비슷함 예를 들어 아이폰 Safari나 안드로이드 Chrome으로 접속해도

  • /login 요청, 쿠키 붙음, middleware에서 로그인 검사, 필요하면 /login으로 리다이렉트

이 흐름은 같음

차이점

문제는 브라우저마다 지원 범위와 동작 방식이 조금씩 다르다는 거

특히 모바일은 다음과 같은 차이가 있음

 

1. 쿠키 처리

  • 모바일 브라우저도 쿠키를 쓰지만 브라우저 설정이나 보안 정책 때문에 데스크탑 보다 더 민감하게 동작할 수 있음

2. Service worker 지원 차이

  • 안드로이드 Chrome은 비교적 잘 지원하는 편이고 IPhone Safari는 지원은 하지만 일부 제약이 더 있을 수 있음

3. PWA 설치/백그라운드 동작

  • 데스크탑과 모바일에서 설치 방식, 백그라운드 제한, 푸시 알림 이런 게 다를 수 있음

4. 화면은 당연히 다름

  • 웹 자체는 같은 코드여도 작은 화면, 터치 입력, 모바일 브라우저 UI 때문에 실제 사용 경험은 달라진다.

 

그래서 내 프로젝트 기준으로 본다면 고시원 관리자 웹은 폰에서도 열 수는 있지만 실사용은 보통 이렇게 생각해야 함

  • 인증 쿠키 / middleware / 로그인 체크 → 모바일에서도 거의 동일
  • UI 사용성 → 모바일 최적화 따로 필요
  • service worker / PWA → 모바일 브라우저별 차이 있음

 

만들면서 궁금했던 점 → 5. 웹이 폰의 앱에서도 똑같이 동작?

앱은 보통 브라우저처럼 동작하지 않아서 service worker도 없고 쿠키 자동 처리도 브라우저 만큼 자연스럽지 않음

그래서 앱에서는 보통

  • accesstoken, refresh token 이런 걸 앱이 직접 저장하고 요청할 때 헤더에 직접 넣어 보내는 방식이 많음
Authorization: Bearer <token>

즉 앱은 보통 → 쿠키 기반 자동 로그인 유지 보다는 토큰을 앱이 직접 관리하는 방식임.

 

Service worker는 앱에 있나?를 본다면 보통 없다고 보면 됨 이거는 웹 브라우저 기능임. 앱은 대신에

  • 앱 내부 로컬 저장소
  • 백그라운드 작업
  • 푸시 알림 SDK
  • 네이티브 캐시

이런 방식이 있음

 

middleware는 앱에도 의미가 있나?

  • 서버 쪽 middleware 자체는 앱 요청에도 의미가 있다. 왜냐하면 앱도 결국 서버에 HTTP 요청을 보내니까 하지만 차이점은다음과 같다.

 

웹 → 브라우저가 페이지 요청, middleware가 /login으로 redirect 가능

앱 → 앱이 API 요청, 서버가 redirect를 줘도 앱 화면이 자동으로 로그인 화면으로 바뀌는 거는 아님

 

그래서 리다이렉트보다 다음과 같이 처리

  • 서버가 401 Unauthorized 응답
  • 앱이 그걸 보고
  • 로그인 화면으로 이동

 

[앱은 쿠키가 없나?]

아예 없는 거는 아님 → 앱에서도 쿠키를 쓸 수는 있는데 보통 웹처럼 자동 중심은 아님

 

브라우저는 쿠키를 기본적으로 잘 다룸

  • 서버가 Set-Cookie 내려줌
  • 브라우저가 저장
  • 다음 요청 때 자동으로 다시 보냄

그래서 웹은 쿠키 기반 세션이 자연스러움

 

앱도 내부적으로 HTTP 라이브러리나 웹뷰를 쓰면 쿠키를 가질 수는 있음

예를 들면

  • 웹뷰 안에서 로그인
  • 앱의 네트워크 라이브러리가 쿠키 저장소를 따로 관리
  • 일부 API 호출에서 쿠키 사용

이런 건 가능함. 하지만 보통 앱은 웹 브라우저처럼 “쿠키가 자동으로 다 해결해준다” 느낌은 아님

 

[왜 앱에서는 토큰을 더 많이 쓰냐]

앱은 보통 개발자가 직접 상태를 관리하기 쉬워서 그럼.

  • access token
  • refresh token

이걸

  • Secure Storage
  • Keychain
  • Keystore

같은 곳에 저장해두고, 요청할 때 직접 헤더에 넣어 보내는 방식이 흔함 → 이 방식이 앱에서 제어하기가 더 쉬움

Authorization: Bearer access_token

 

[그럼 앱에서 쿠키가 생기는 경우는?]

  1. 웹뷰 로그인
  2. HTTP 클라이언트가 쿠키 저장을 지원할 때 → 일부 라이브러리는 쿠키 저장/재전송을 해줄 수 있음
  3. 서버가 세션 기반 인증만 제공할 때 → 앱도 쿠키 세션을 억지로 따라가는 방법

즉, 쿠키를 쓸 수는 있는데 쿠키 세션 보다 토큰 기반 인증이 더 일반적이고 관리하기 편함

 

Vercel 배포

  • 처음에 고시원 이메일로 했는데 Vercel에서 코드가 github(내꺼)랑 맞지 않아 배포가 계속 실패했다 그래서 vercel에서 다음과 같은 에러가 계속 떴다.

 

  • 그래서 vercel은 내 github으로 로그인 해서 진행하였다. → 배포는 웹사이트 상으로 진행하였다.

 

보니 이건 자동으로 github branch를 보고 자동으로 해주었다.

 

  • Supabase랑 연동을 위해 Vercel에서 환경변수 값을 넣어줌

 

배포가 잘 되었다 ㅎㅎ