Void 인증: Better Auth로 로그인부터 라우트 보호까지

Void 인증: Better Auth로 로그인부터 라우트 보호까지

Void 데이터베이스에서 테이블을 만들고 Void 라우팅으로 API까지 열고 나면, 다음으로 부딪히는 벽은 늘 똑같습니다. “그래서 이 요청을 보낸 사람이 누구인지 어떻게 알지?”라는 질문이죠. 로그인 없는 앱은 결국 누구나 같은 화면을 보는 앱이니까요.

다행히 Void는 인증을 거의 공짜로 얹어줍니다. 따로 라이브러리를 고르고 세션 저장소를 붙이고 콜백을 짜는 그 지난한 과정 없이, 설정 한 줄과 헬퍼 몇 개로 로그인이 돌아가거든요. 이번 글에서는 인증을 켜고, 회원가입과 로그인을 붙이고, “로그인한 사람만 들어올 수 있는” 영역을 만드는 데까지 차근차근 살펴보겠습니다.

인증은 void.json 한 줄로 켭니다

Void의 인증은 Better Auth를 기반으로 동작합니다. 프레임워크에 종속되지 않는 오픈소스 인증 엔진인데, Void가 이걸 자기 데이터베이스 레이어와 클라이언트에 미리 엮어둔 형태예요. 그래서 우리가 직접 Better Auth를 설치하고 어댑터를 연결할 필요가 없습니다.

시작은 void.json에 인증 공급자(provider)를 선언하는 것부터입니다.

void.json
{
  "auth": {
    "providers": ["email"]
  }
}

이렇게 email만 켜면 이메일과 비밀번호 방식이 활성화됩니다. 사실 이메일/비밀번호는 기본값이라, 인증 관련 모듈을 import하기만 해도 자동으로 켜져요. void.json에 명시적으로 적는 건 “어떤 공급자를 쓸 건지”를 분명히 해두는 의미가 큽니다.

여기서 중요한 점 하나. Void는 인증에 필요한 테이블(사용자, 세션, 계정 등)을 앱이 이미 쓰고 있는 데이터베이스에 그대로 통합합니다. 로컬에서는 SQLite, 프로덕션에서는 Cloudflare D1이나 PostgreSQL에 얹히는 거죠. 게다가 마이그레이션도 Void가 알아서 처리하기 때문에, Better Auth를 직접 쓸 때 거쳤던 별도의 CLI 마이그레이션 단계가 없습니다.

회원가입과 로그인

인증을 켰으니 이제 실제로 가입과 로그인을 처리해봅시다. 클라이언트 쪽에서는 void/client가 미리 설정된 auth 객체를 내보내는데, 이걸 그대로 가져다 쓰면 됩니다.

회원가입과 로그인
import { auth } from "void/client";

// 회원가입
await auth.signUp.email({
  email: "alice@example.com",
  password: "s3cret",
  name: "Alice",
});

// 로그인
await auth.signIn.email({
  email: "alice@example.com",
  password: "s3cret",
});

로그아웃도 마찬가지로 한 줄이에요.

await auth.signOut();

별도의 토큰을 직접 저장하거나 쿠키를 만지는 코드가 보이지 않죠? 세션 관리는 Better Auth가 뒤에서 처리하고, Void는 그걸 클라이언트 객체로 노출만 해줍니다. 우리는 “가입한다”, “로그인한다”, “로그아웃한다”는 의도만 코드로 적으면 되는 거예요.

서버 라우트를 잠그는 requireAuth

로그인이 됐다면, 이제 “로그인한 사람만 들어올 수 있는” 영역을 만들 차례입니다. Void 라우팅에서 봤던 defineHandler를 기억하실 텐데요. 그 핸들러 안에서 requireAuth를 호출하는 게 핵심입니다.

routes/api/me.ts
import { defineHandler } from "void";
import { requireAuth } from "void/auth";

export const GET = defineHandler((c) => {
  const user = requireAuth(c); // 비로그인 요청이면 여기서 401을 던집니다
  return { email: user.email };
});

requireAuth(c)는 인증된 사용자가 있으면 그 사용자 객체를 돌려주고, 없으면 그 자리에서 401 응답을 던지고 핸들러 실행을 멈춥니다. 그래서 핸들러 첫 줄에 이걸 두면, 그 아래 코드는 “사용자가 확실히 존재한다”는 전제 위에서 돌아가요. if (!user) return ... 같은 방어 코드를 매번 적을 필요가 없는 거죠.

상황에 따라 더 부드러운 처리가 필요할 때를 위해 void/auth는 세 가지 헬퍼를 제공합니다.

  • requireAuth(c) — 인증을 강제합니다. 비로그인이면 401을 던져요. “반드시 로그인해야 하는” 엔드포인트에 씁니다.
  • getUser() — 현재 사용자를 반환하되, 없으면 null을 줍니다. 로그인 여부에 따라 다르게 동작하되 막지는 않는 경우에 어울려요.
  • getSession() — 사용자뿐 아니라 세션 데이터까지 함께 반환하고, 없으면 null입니다. 세션 만료 시각 같은 메타데이터가 필요할 때 씁니다.

예를 들어 “로그인했으면 닉네임을, 아니면 손님이라고” 보여주는 화면이라면 requireAuth로 막아버리는 대신 getUser()로 분기하는 게 자연스럽습니다.

getUser로 부드럽게 분기하기
import { defineHandler } from "void";
import { getUser } from "void/auth";

export const GET = defineHandler(async () => {
  const user = await getUser();
  return { greeting: user ? `${user.name}님 환영합니다` : "손님 환영합니다" };
});

API뿐 아니라 화면도 지킵니다

지금까지 본 예제는 routes/의 API 핸들러였는데요. “로그인한 사람만 볼 수 있는 대시보드 화면”처럼 페이지 자체를 막고 싶을 때는 어떻게 할까요? 답은 같은 requireAuth입니다.

Void 라우팅에서 봤듯이, 페이지 옆에 두는 .server.ts 로더도 사실은 defineHandler로 정의됩니다. API 핸들러와 같은 컨텍스트를 받는다는 뜻이니, 로더 첫 줄에서 requireAuth를 부르면 화면 진입 자체가 막혀요.

pages/dashboard.server.ts
import { defineHandler } from "void";
import { requireAuth } from "void/auth";

export const loader = defineHandler((c) => {
  const user = requireAuth(c); // 비로그인이면 401 — 데이터를 그리기도 전에 막힙니다
  return { name: user.name }; // 그대로 페이지 컴포넌트의 props가 됩니다
});

이렇게 하면 데이터를 가져오는 로직과 접근 제어가 한곳에 모입니다. 화면을 그리는 컴포넌트(.tsx)는 “이미 로그인이 보장된 사용자”만 props로 받으니, UI 코드에서 로그인 여부를 또 따질 필요가 없어요. API든 화면이든 “이 자원에 누가 접근할 수 있는가”를 같은 한 줄로 표현하는 거죠.

소셜 로그인 붙이기

이메일/비밀번호만으로는 아쉽죠. Google이나 GitHub 같은 소셜 로그인도 void.jsonproviders 배열에 추가하면 켜집니다. 소셜 로그인 자체의 동작 원리가 궁금하다면 Google OAuth 흐름을 따로 정리해뒀으니 참고하시면 좋습니다.

void.json
{
  "auth": {
    "providers": ["email", "github"]
  }
}

그런데 여기까지만 하고 서버를 켜면 이런 에러를 만납니다.

자격 증명 누락 에러
Error: auth: Missing credentials for provider 'github'.
Set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET,
or remove 'github' from auth.providers.

소셜 공급자를 켜면 OAuth 앱에서 발급받은 자격 증명이 필요하거든요. Void는 이걸 Better Auth 규칙대로 AUTH_<공급자>_CLIENT_IDAUTH_<공급자>_CLIENT_SECRET이라는 환경변수에서 읽습니다. GitHub이라면 이렇게요.

.env.local
AUTH_GITHUB_CLIENT_ID=...
AUTH_GITHUB_CLIENT_SECRET=...

이 값은 GitHub의 OAuth 앱 설정 화면에서 발급받는데, 그때 콜백 URL을 하나 등록해야 합니다. 경로는 /api/auth/callback/<공급자>로 고정이에요. 로컬이라면 http://localhost:5173/api/auth/callback/github, 배포 후라면 https://myapp.void.app/api/auth/callback/github 형태가 됩니다. 콜백 라우트를 우리가 직접 만들 필요는 없고, Better Auth가 이 규약대로 알아서 처리해요. 콜백 URL은 OAuth 앱마다 묶이기 때문에, 로컬용과 배포용 앱을 따로 만들어 CLIENT_ID/CLIENT_SECRET을 분리해두면 가장 깔끔합니다.

발급받은 자격 증명을 .env.local에 적고 서버를 다시 켜면, 그제야 GitHub 로그인 버튼이 제대로 동작합니다. 이 값들은 결국 환경변수이자 시크릿이라, 로컬에서 다루는 법과 프로덕션에 올리는 법은 Void 환경변수에서 따로 정리했어요.

아무나 가입하지 못하게 막기

소셜 로그인을 켜고 나면 곧바로 한 가지가 걸립니다. “GitHub 계정이 있는 사람이면 누구나 우리 앱에 계정이 생긴다”는 거예요. 공개 서비스라면 괜찮지만, 사이드 프로젝트나 내부 도구라면 특정 이메일만 들이고 싶을 때가 많죠.

여기서 흔히 빠지는 함정이 있습니다. 회원가입 API를 검사하면 될 것 같지만, 소셜 로그인은 그 경로를 거치지 않아요. 그래서 막히지 않습니다. 정답은 “사용자 레코드가 처음 만들어지는 순간”을 가로채는 거예요. 소셜이든 이메일이든 첫 로그인은 결국 사용자 한 줄을 DB에 만드는 일이거든요. 바로 이 지점을 Better Auth의 databaseHooks로 잡습니다.

이때 프로젝트 루트에 auth.ts를 만들어 세부 동작을 손봅니다. defineAuth에 들어오는 defaults를 펼친 뒤 필요한 값만 덮어쓰는 패턴이에요.

auth.ts
import { defineAuth } from "void/auth";
import { APIError } from "better-auth/api";

const ALLOWED_EMAILS = ["dale.seo@gmail.com"];

export default defineAuth(({ defaults }) => ({
  ...defaults,
  databaseHooks: {
    ...defaults.databaseHooks,
    user: {
      create: {
        // 사용자 레코드가 만들어지기 직전 = 첫 로그인 시점
        before: async (user) => {
          if (!ALLOWED_EMAILS.includes(user.email)) {
            throw new APIError("FORBIDDEN", {
              message: "허용된 계정만 로그인할 수 있습니다.",
            });
          }
          return { data: user }; // 통과시키려면 user를 그대로 돌려줍니다
        },
      },
    },
  },
}));

...defaults로 Void가 잡아둔 기본 설정을 물려받고, 그 위에 user.create.before 훅을 얹었습니다. 허용 목록에 없는 이메일이면 APIError를 던져 계정 생성을 막고, 통과시킬 때는 user를 그대로 돌려주면 돼요. trustedOrigins처럼 다른 커스텀 설정이 필요할 때도 같은 auth.ts에서 ...defaults 위에 덧붙이면 됩니다.

한 가지 더 챙길 게 있어요. 위처럼 차단하면 Better Auth는 기본 에러 페이지로 보내면서 “Something went wrong” 같은 무미건조한 메시지를 띄웁니다. 차단당한 사용자 입장에선 뭐가 문제인지 알 수가 없죠. 그래서 클라이언트에서 소셜 로그인을 호출할 때 errorCallbackURL을 지정해 우리 화면으로 끌어옵니다.

차단 시 우리 화면으로 되돌리기
await auth.signIn.social({
  provider: "github",
  errorCallbackURL: "/?error=blocked", // 차단되면 홈으로, 쿼리에 사유를 달아서
});

이러면 거절된 사용자는 /?error=blocked로 돌아오고, 페이지 로더에서 그 쿼리를 읽어 “허용된 계정만 로그인할 수 있어요” 같은 안내를 직접 보여줄 수 있습니다.

마지막으로 알아둘 점 하나. 이 인증 기능은 Void 앱(Pages 모드) 전용이에요. 기존 메타 프레임워크를 그대로 얹는 Framework 모드나 Node.js/Bun/Deno 타깃에서는 Better Auth를 직접 연결해야 합니다.

배포할 때 비밀값은 어떻게 다룰까

로컬에서 로그인이 돌아가면, 다음은 배포입니다. 여기서 인증에는 성격이 다른 두 종류의 비밀값이 얽혀 있다는 걸 짚고 가야 해요.

하나는 세션 토큰을 서명하는 BETTER_AUTH_SECRET입니다. 로컬에서는 Void가 임시 값을 자동으로 채워주고, Void Cloud로 배포할 때도 플랫폼이 알아서 생성해 주입해줍니다. 그래서 이 키는 우리가 따로 등록할 일이 거의 없어요. 직접 신경 쓰지 않아도 배포만 하면 안전한 값이 붙는 셈이죠.

다른 하나는 방금 본 소셜 로그인 자격 증명(AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET)입니다. 이건 우리가 OAuth 앱에서 발급받은 값이라 플랫폼이 알아서 채워줄 수 없으니, 프로덕션에도 직접 올려야 합니다. 당연히 코드에 적으면 안 되고요. 깃에 올라가는 순간 끝이니까요.

그래서 인증을 제대로 배포하려면 “이 비밀값들을 어떻게 안전하게 관리하느냐”라는 다음 주제로 이어집니다. Void는 환경변수에 타입 검증까지 입혀 다루는데, 소셜 자격 증명 같은 시크릿을 프로덕션에 올리는 방법은 Void 환경변수에서 이어서 정리했습니다. 인증을 실제 도메인에 올릴 계획이라면 그 글을 꼭 함께 보시길 권해요.

마치며

Void의 인증은 void.json에 공급자를 적는 것에서 시작해, void/client로 가입·로그인·로그아웃을 처리하고, requireAuth로 서버 라우트와 화면을 잠그는 흐름으로 정리됩니다. 소셜 로그인은 AUTH_<공급자>_* 자격 증명만 채우면 켜지고, 아무나 가입하는 걸 막고 싶으면 databaseHooks로 이메일 허용 목록을 거는 식이었죠. 직접 세션 저장소를 만들고 콜백을 짜던 일을 Void가 Better Auth 위에 얇게 감싸 대신 해주니, 우리는 “누가 로그인했는가”라는 본질에만 집중할 수 있어요.

다음 단계는 방금 만난 소셜 자격 증명 같은 비밀값을 안전하게 다루는 일입니다. Void 환경변수에서 타입 안전한 환경변수와 시크릿 관리를 이어서 보시고, 이 인증의 기반이 되는 엔진 자체를 더 깊이 파고 싶다면 Better Auth로 TypeScript 인증 시스템 구축하기도 좋은 출발점입니다.

더 자세한 내용은 Void 인증 가이드를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord