Life Relay는 개인의 삶을 인터뷰 형식으로 기록하고 공유하는 한국어 플랫폼입니다. 이 프로젝트의 인증을 Supabase Auth로 붙여 두고 있었습니다. 이메일로 인증번호를 받아 로그인하는, 비밀번호 없는 방식입니다. 잘 돌아갔지만 한 가지가 계속 걸렸습니다. 우리가 Supabase에서 쓰는 건 사실상 인증 하나뿐인데, 그것 때문에 외부 인증 서비스에 묶여 있다는 점이었습니다. 이 글은 Life Relay에서 Supabase Auth를 걷어내고 인증을 직접 세운 과정을 정리한 기록입니다.
진단의존성이 얼마나 깊은가
큰 작업처럼 보여서 먼저 범위부터 쟀습니다. 그리고 두 가지를 확인하고 나니 마음이 가벼워졌습니다.
첫째, Supabase는 인증만 담당하고 있었습니다. 데이터베이스는 별도 PostgreSQL이고,
유저 테이블의 id는 독립적으로 생성하는 UUID였습니다. Supabase가 발급하는 인증 유저와는
이메일로만 느슨하게 연결돼 있었습니다. 코드 곳곳에서 인증 객체를 꺼내 쓰는 패턴도
결국 이메일 한 값만 읽고 있었습니다.
const { data: { user } } = await supabase.auth.getUser();
const userData = await UserRepository.getUserByEmail(null, user?.email ?? '');
user에서 실제로 쓰는 건 email뿐이었습니다. 이 말은, "쿠키에서 이메일을 꺼내는
함수 하나"로 모든 호출부를 1:1 치환할 수 있다는 뜻이었습니다.
둘째, 인증 방식이 단순했습니다. 비밀번호도, 소셜 로그인도 없는 순수 이메일 OTP. 교체할 대상이 명확했습니다.
의존성을 걷어낼 때 가장 먼저 할 일은 "그게 실제로 무엇을 하고 있는가"를 좁히는 일입니다. 막연히 크다고 느끼면 손도 못 댑니다. 범위를 재고 나면 대개 생각보다 작습니다.
설계 결정
방향을 정하기 위해 몇 가지를 먼저 골랐습니다.
| 항목 | 선택 | 이유 |
|---|---|---|
| 인증 방식 | 이메일 OTP 유지 | 기존 UX 그대로, 유저 데이터 마이그레이션 불필요 |
| 세션 | JWT를 httpOnly 쿠키에 저장 | 별도 세션 테이블 없이 기존 쿠키 방식과 가장 유사 |
| 전환 시점 | 전원 재로그인 | 쿠키 이름이 바뀌어 기존 세션은 자연히 무효화 |
| 회원가입 | 6자리 코드로 통일 | 기존엔 로그인=코드, 가입=매직링크로 갈렸던 걸 하나로 |
OTP 코드 발송은 이미 쓰고 있던 메일 인프라(AWS SES + 이메일 템플릿)를 그대로 재사용하면 됐습니다. 새로 만들 건 결국 OTP를 담을 테이블 하나와, 코드·세션을 다루는 유틸 몇 개뿐이었습니다.
구현작은 조각 셋
1. OTP 저장
인증번호는 평문으로 저장하지 않습니다. 유출에 대비해 HMAC 해시로만 보관하고, 만료·시도 횟수·재발송 간격을 함께 제약했습니다.
export async function issueOtp(tx, { email, type }) {
// 최근 1분 내 발급이 있으면 거절 (재발송 도배 방지)
// 기존 미사용 코드는 무효화한 뒤 새 코드 발급
const code = generateOtpCode(); // crypto.randomInt로 6자리
await EmailOtpRepository.createOtp(tx, {
email, codeHash: hashOtp(code), type, // HMAC-SHA256 해시 저장
expiresAt: new Date(now + OTP_TTL_MS), // 5분 만료
});
return { ok: true, code }; // 평문 code는 메일 발송에만 사용
}
검증할 때는 만료, 시도 횟수, 일회용 여부를 차례로 확인하고 통과하면 코드를 소비 처리합니다.
2. 세션 = JWT 쿠키
세션은 서버가 서명한 JWT를 httpOnly 쿠키에 담는 방식으로 갔습니다. 여기서 중요한 선택이
하나 있었습니다. JWT 라이브러리로 jose를 써야 한다는 점입니다. 이유는 뒤에서
설명하는 함정과 직결됩니다.
export const SESSION_COOKIE = 'lr_session';
export async function signSession({ email }) {
return new SignJWT({ email })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('30d')
.sign(secret);
}
그리고 핵심이었던 치환용 헬퍼. 이 하나가 기존 인증 객체 호출을 전부 대체했습니다.
// 기존 supabase.auth.getUser().email 의 직접 대체재
export async function getSessionEmail() {
const token = cookies().get(SESSION_COOKIE)?.value;
const session = await verifySession(token);
return session?.email ?? null;
}
3. 1:1 치환
이제 진단에서 본 패턴을 기계적으로 바꿉니다. 인증 객체를 꺼내 이메일을 읽던 모든 곳이
getSessionEmail() 한 줄로 정리됐습니다. API 라우트 여러 곳, 서버 액션, 미들웨어가
같은 방식으로 바뀌었습니다. 회원 탈퇴에서 외부 인증 유저를 삭제하던 호출은,
이제 그런 유저가 없으니 그냥 지웠습니다. DB의 유저 레코드만 지우면 충분했습니다.
함정 셋
깔끔하게 끝날 줄 알았지만, 예상대로 몇 군데서 걸렸습니다.
함정 1미들웨어는 Edge에서 돈다
Next.js 미들웨어는 Edge 런타임에서 실행됩니다. Node 전용 API에 의존하는 JWT
라이브러리(jsonwebtoken 같은)는 여기서 동작하지 않습니다. 그래서 처음부터 Edge에서도
도는 jose를 골랐던 것입니다. 미들웨어에서는 DB도 건드리지 않습니다. JWT 서명 검증만
하고, 실제 유저 존재 확인은 그 다음 단계로 미룹니다.
export async function middleware(request) {
const session = await verifySession(request.cookies.get(SESSION_COOKIE)?.value);
if (!session) {
// 페이지면 홈으로, API면 에러 JSON
}
}
함정 2매직링크를 없앴더니 코드 입력할 곳이 없다
기존 회원가입은 메일의 매직링크를 클릭하면 확인 페이지로 이동하는 방식이었습니다. 이걸 6자리 코드 입력으로 통일하면서, 로그인에 쓰던 코드 입력 모달을 회원가입에도 재사용하기로 했습니다. 로그인에서는 잘 떴습니다.
문제는 릴레이 초대 가입이었습니다. 초대 링크로 들어가는 페이지에서 "가입" 버튼을 누르면 코드 발송까지는 잘 됐는데, 코드를 입력할 모달이 안 떴습니다.
원인은 라우트 그룹이었습니다. 초대 페이지는 헤더·모달 묶음이 없는 별도 레이아웃 그룹에 있었고, 모달을 띄우는 상태를 바꿔도 그 모달을 그려 줄 컴포넌트가 그 화면엔 없었던 것입니다. 다행히 모달 provider가 루트 레이아웃에 있어서, 상태에 이메일과 "코드 입력 단계로 바로 열기" 플래그를 실어 보내는 것으로 풀렸습니다. 발송 응답에 수신 이메일을 함께 돌려주고, 그걸 모달에 미리 채워 코드 입력 단계로 곧장 열었습니다.
"서버는 정상인데 화면이 안 바뀐다"는 대개 상태가 아니라 그 상태를 그리는 컴포넌트가 그 경로에 없는 문제입니다. 라우트 그룹을 쓸 때 자주 만나는 함정입니다.
함정 3해시로 저장하니 테스트할 때 코드를 모른다
OTP를 해시로만 저장하니, 당연하게도 테스트 중에 코드를 알 길이 없었습니다. 실제 메일을 받아 확인하는 방법도 있지만 느립니다. 그래서 개발 환경에서만 발급된 코드를 서버 콘솔에 찍는 한 줄을 잠깐 넣어 두고, E2E 검증이 끝난 뒤 제거했습니다. 프로덕션에서는 절대 찍히지 않도록 환경 가드를 둔 채로요.
검증
헤드리스 브라우저로 세 흐름을 끝까지 돌렸습니다.
- 로그인: 코드 발송 → 입력 → httpOnly 쿠키 발급 → 세션 유지 → 로그아웃 시 쿠키 삭제
- 회원가입: 코드 검증 → 유저 생성 → 환영 페이지 이동
- 릴레이 초대 가입: 초대 발송 → 코드 가입 → 초대한 사람과 친구로 연결됐는지 DB에서 확인
마지막 항목이 특히 중요했습니다. 가입 직후 릴레이 레코드에서 초대한 쪽과 가입한 쪽이 제대로 연결됐는지를 직접 확인하고 나서야 마음을 놓았습니다. 보호 라우트가 비로그인 상태에서 제대로 차단되는지, 타입 체크와 빌드가 통과하는지도 함께 봤습니다.
마무리
작업을 끝내고 남은 건 코드 변경뿐이 아니었습니다. README와 프로젝트 문서, 그리고 더 이상 쓰지 않는 외부 인증용 설정 디렉토리와 메일 템플릿까지 함께 걷어냈습니다. 죽은 링크가 박힌 템플릿 하나도 이때 같이 정리했습니다. 의존성을 들어낼 때는 코드만이 아니라 그것이 남긴 흔적 전부를 따라가야 깔끔하게 끝납니다.
돌아보면 이 작업의 8할은 첫 진단에서 결정됐습니다. "Supabase가 실제로 하는 일은 이메일을 꺼내 주는 것뿐"이라는 한 문장으로 범위가 좁혀졌고, 나머지는 그 문장을 코드로 옮기는 일이었습니다. 외부 의존성을 들일 때도, 들어낼 때도, 그게 정확히 무엇을 대신해 주고 있는지를 아는 게 먼저입니다.
