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가 실행돼서 확인함.
- 이 요청에 로그인 쿠키가 있나?
- 그 쿠키로 Supabase가 사용자를 확인할 수 있나?
- 확인되면 통과
- 아니면 /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...' } ]
분석 한 번 해보자
- 로그 /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)
- /요청
즉 사용자가 로그인 후 / 메인 페이지로 이동하려고 했고, 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는
- request에 들어온 쿠키 읽음
- sb-...-auth-token 확인
- 그 안의 토큰으로 사용자 확인
- 유효하면 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 요청은 원래 매번 독립적임 예를 들어
- 사용자가 로그인 요청을 보냄
- 서버가 로그인 성공 판단
- 그런데 다음 요청에서 서버는 원래 그 사용자를 기억 못함
그래서 서버가 브라우저에게 쿠키를 줌
예 → 세션ID, 로그인 토큰 관련 값, 사용자 설정, 최근 본 항목
브라우저는 그걸 저장했다가 다음에 같은 사이트로 요청할 때 자동으로 같이 보낸다.
쿠키 흐름 예시
로그인 할때
서버가 응답하면서 이런 느낌으로 보냄
Set-Cookie: session=abc123
브라우저는 이걸 저장함
다음 요청 때 → 브라우저가 자동으로 이렇게 보냄
Cookie: session=abc123
그러면 서버는 보고 아 이 브라우저는 아까 로그인했던 사용자구나 하고 알 수 있음
그래서 아까 코드를 보면 다음 코드는 현재 요청에 브라우저가 같이 보낸 쿠키들을 꺼내는 거임
request.cookies.getAll()
즉 middleware는
- 브라우저가 보낸 쿠키를 읽고
- Supabase에 전달해서
- 로그인 세션인지 확인하는 거임
쿠키 안에는 뭐가 들어갈 수 있을까
- 꼭 로그인만 있는 거는 아님
→ 로그인 세션 식별자, 언어 설정, 다크모드 여부, 장바구니 정보 일부, 팝업 다시 보지 않기 값 이런 것도 들어갈 수 있음
쿠키 안에는 중요한 정보 넣으면 안된다!
쿠키는 브라우저에 저장되니까 민감한 값을 그대로 넣는 건 조심해야함
그래서 보통은
- 진짜 사용자 정보 전체를 넣는 게 아니라 세션ID나 토큰 같은 식별자 값만 넣고 실제 사용자 정보는 서버나 인증 시스템이 확인함
내가 본 쿠키는 이건 Supabase 로그인 쿠키가 아니라 개발 환경 쪽 쿠키일 가능성이 크다.
Idea-27414554=...
만들면서 궁금했던 점 → 3. 서비스 워커 파일이란?
서비스 워커 파일은 브라우저 뒤에서 따로 돌아가는 자바스크립트 파일임 보통 웹페이지 코드와 다르게 페이지 안에서만 실행되는 게 아니라 브라우저 레벨에서 백그라운드처러 동작함
대표적으로 하는 일이 다음과 같음.
- 오프라인 캐시, 푸시 알림, 네트워크 요청 가로채기, PWA 동작 지원
왜 이름이 sw.js냐 → 보통 파일명을 많이 다음과 같이 씀
sw.js
service-worker.js
즉 sw.js는 거의 늘 service worker script를 뜻함
일반 JS랑 다른 점
- 일반 JS는 보통 페이지가 열려 있어야 동작함
- 예를 들어 → 버튼 클릭, 화면 렌더링, 입력 처리
반면 서비스 워커는 페이지 바깥쪽에서 요청을 가로채거나 캐시를 관리할 수 있음
예를 들면
- 사용자가 사이트 접속
- 브라우저가 sw.js를 등록
- 이후 요청을 서비스 워커가 중간에서 볼 수 있음
- 인터넷이 없어도 캐시된 파일을 보여줄 수 있음
[비유]
웹페이지 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
[그럼 앱에서 쿠키가 생기는 경우는?]
- 웹뷰 로그인
- HTTP 클라이언트가 쿠키 저장을 지원할 때 → 일부 라이브러리는 쿠키 저장/재전송을 해줄 수 있음
- 서버가 세션 기반 인증만 제공할 때 → 앱도 쿠키 세션을 억지로 따라가는 방법
즉, 쿠키를 쓸 수는 있는데 쿠키 세션 보다 토큰 기반 인증이 더 일반적이고 관리하기 편함
Vercel 배포
- 처음에 고시원 이메일로 했는데 Vercel에서 코드가 github(내꺼)랑 맞지 않아 배포가 계속 실패했다 그래서 vercel에서 다음과 같은 에러가 계속 떴다.

- 그래서 vercel은 내 github으로 로그인 해서 진행하였다. → 배포는 웹사이트 상으로 진행하였다.
보니 이건 자동으로 github branch를 보고 자동으로 해주었다.
- Supabase랑 연동을 위해 Vercel에서 환경변수 값을 넣어줌

배포가 잘 되었다 ㅎㅎ
'외주 > 고시원 외주 개발 일지' 카테고리의 다른 글
| [외주] 기획 요구사항에서 데이터 모델과 조회 로직까지 정리한 과정 - 보증금 차감과 반환을 하나의 흐름으로 볼지 분리할지 (1) | 2026.04.24 |
|---|---|
| [외주] 기획 요구사항에서 데이터 모델과 조회 로직까지 정리한 과정 - 예정 입실 기능을 어떻게 데이터로 분리했는가(기능 업데이트) - 4/19 (0) | 2026.04.19 |
| [외주] 현금 승계 PDF로 만들기 - 4/8 (0) | 2026.04.14 |
| [외주] 클라이언트 앞에서 PT를 진행하다! - 4/8 (0) | 2026.04.13 |
| [외주] 어떤 데이터를 관리해야할지 선정 필요 && DB 설계 - 4/5 (0) | 2026.04.05 |