나는 프론트엔드 개발자다. 그동안 Next.js를 주력으로 다뤘다. 화면을 만들고 사용자의 입력을 받아 동작을 연결하는 일이 내 개발의 중심이었다. 그런 내가 이번에는 바이브 코딩으로 Timesheet를 설계하고, 데이터베이스와 API를 만들고, 서비스를 배포하는 데까지 갔다. 기존에는 내가 할 수 없었던 일이었다.
Timesheet는 파트타임 근무자의 출퇴근 기록과 급여를 관리하는 앱이다. 출근 버튼이 있는 화면만으로는 이 앱을 완성할 수 없다. 누가 어느 매장에서 일하는지, 언제 출근하고 퇴근했는지 저장해야 한다. 로그인한 사람이 자기 기록을 보고, 사장님은 매장에 속한 사람들의 기록을 볼 수 있어야 한다. 화면에서 시작한 기능이 데이터 저장과 서버의 처리까지 이어져야 했다.
프로젝트의 기술 구성을 보면 이번에 다룬 범위가 드러난다. 화면은 익숙한 Next.js로 만들었지만, 그 뒤에는 Express로 만든 API가 있다. PostgreSQL에 데이터를 저장하고, Prisma로 서버와 데이터베이스를 연결했다. 웹과 API는 Vercel에, 데이터베이스는 Neon에 두었다. 브라우저에서 버튼을 누르면 API를 거쳐 데이터베이스에 기록이 남고, 그 결과를 다시 화면에서 보여 주는 흐름을 하나의 서비스로 구성했다.
API를 만드는 일에는 프론트엔드가 무엇을 보내고 무엇을 받을지 정하는 작업도 포함됐다. Timesheet에서는 그 정의를 바탕으로 OpenAPI 문서를 만들고, 프론트엔드에서 쓸 타입도 생성한다. Swagger로 API를 살펴볼 수 있는 구성까지 갖췄다. 다만 운영 환경에서는 Swagger 화면을 꺼 두었다. API를 쓰는 화면과 API 자체, 그 사용법을 담은 문서까지 이번 프로젝트 안에 들어온 셈이다.
화면에서 데이터 저장까지, 각 기술이 맡은 일
Next.js와 React는 사용자가 직접 만지는 부분을 맡았다. 출퇴근 화면, 근무 달력, 로그인 화면, 사장님이 보는 관리 화면을 만들고, 버튼을 누르거나 입력값을 바꾸면 화면의 상태를 갱신한다. Next.js의 App Router로 페이지를 나누고, React 컴포넌트로 화면을 구성했다. 급여 계산은 현재 웹의 web/src/lib/pay.ts에 모여 있다. 서버가 근무 기록과 시급 등의 조건을 내려주면, 웹에서 그 값으로 급여를 계산한다.
Tailwind CSS는 화면의 간격과 색상, 크기, 모바일 배치를 표현하는 데 썼다. shadcn 방식으로 프로젝트에 들인 UI 컴포넌트와 Base UI는 대화상자 같은 공통 UI를 구성하고, Lucide는 아이콘을 맡았다. MDX로 만든 /guide 페이지에서는 문서 안에 실제 컴포넌트를 함께 보여 준다. 화면을 만드는 일에 더해, 어떤 모양과 동작을 반복해서 사용할지도 프로젝트 안에 남겨 둔 것이다.
Express는 화면의 요청을 받아 처리하는 API 서버다. 로그인, 매장 생성, 초대 코드 등록, 출퇴근 기록, 수정 요청과 승인 같은 동작이 이 서버를 거친다. 예를 들어 출근 요청이 들어오면 로그인한 사람과 매장 소속을 확인한 뒤 근무 시작 시각을 저장한다. 사용자가 보낸 값만 믿고 기록의 주인을 정하지 않고, 인증 결과에서 사용자 ID를 가져온다.
PostgreSQL은 그 결과를 계속 보관하는 데이터베이스다. 사용자, 매장, 매장 소속과 역할, 시급과 시간표, 출퇴근 기록, 휴가와 대타 요청, 로그인 갱신 정보가 저장된다. 사용자와 매장, 근무 기록 사이의 관계도 데이터 구조에 들어 있다. 아직 퇴근하지 않은 기록을 사용자 한 명에게 두 개 만들지 못하게 하는 제약도 DB에 두었다. 화면에서 버튼을 막는 것과 별개로, 저장하는 단계에서도 중복을 막는다.
Prisma는 TypeScript로 작성한 서버 코드에서 PostgreSQL을 읽고 쓰는 일을 돕는다. 데이터 모델은 server/prisma/schema.prisma에 정의하고, 구조를 바꿀 때는 마이그레이션으로 DB에 반영한다. 여러 변경이 함께 성공하거나 함께 취소되어야 할 때는 트랜잭션으로 묶는다. 초대 코드를 사용 처리하는 일과 매장 소속을 만드는 일이 그 예다. 로그인 토큰 갱신처럼 잠금 순서를 세밀하게 다뤄야 하는 곳에서는 Prisma의 트랜잭션 안에서 SQL도 직접 실행한다.
TypeScript는 웹과 서버의 코드에서 값의 형태를 확인한다. 다만 브라우저에서 실제로 어떤 값이 넘어올지는 타입 선언만으로 보장할 수 없다. 그 부분은 Zod가 맡는다. 이메일 형식, 비밀번호 길이, 시급 범위, 출근과 퇴근 시각의 순서 등을 서버에서 검사하고 조건에 맞지 않으면 요청을 거절한다. 코드 작성 중의 타입 검사와 서버가 요청을 받았을 때의 값 검사가 서로 다른 단계에 있는 셈이다.
API 문서도 이 정의에 연결했다. server/src/contract.ts의 Zod 스키마와 경로 정의를 OpenAPI 문서로 내보내고, openapi-typescript로 웹에서 쓸 타입을 생성한다. 웹은 openapi-fetch를 통해 그 타입에 맞춰 API를 호출한다. 서버의 요청·응답 형식을 바꾸면 문서와 타입을 다시 생성하고, 프론트엔드에서 맞지 않는 부분을 타입 검사로 찾는 흐름이다. Swagger UI는 OpenAPI 문서를 사람이 읽고 요청을 시험할 수 있는 화면으로 보여 준다. 이 화면은 /api/docs에 구성되어 있지만 운영 설정에서는 꺼 두었다.
서버 주변의 작은 도구들도 역할이 있다. cookie-parser는 요청에 실린 쿠키를 읽고, Helmet은 보안 관련 응답 헤더를 설정한다. express-rate-limit은 반복되는 로그인 실패를 제한한다. 예상하지 못한 서버 오류는 Pino로 기록하고, 클라이언트에는 내부 오류 내용 대신 정해진 오류 응답을 보낸다.
로컬에서는 Docker Compose로 PostgreSQL을 띄운다. 웹과 서버는 pnpm 워크스페이스 하나에서 관리하되 각각 실행하고 빌드할 수 있다. 운영에서는 웹과 Express API를 서로 다른 Vercel 프로젝트에 배포하고, API가 Neon의 PostgreSQL에 연결한다. Prisma가 데이터에 접근하는 도구라면, Neon은 운영 DB가 실제로 실행되는 곳이다.
웹과 API의 배포 주소는 다르지만 브라우저는 웹 주소의 /api/*로 요청한다. Next.js의 rewrites가 그 요청을 API 서버로 넘긴다. 브라우저가 보는 요청 주소를 같은 출처로 유지해 쿠키 인증을 처리하는 구조다. 출근 버튼 하나의 동작을 따라가면 React 화면, Next.js의 전달 설정, Express의 인증과 처리, Prisma의 저장, PostgreSQL의 기록이 차례로 이어진다.
로그인은 비밀번호 확인 다음에도 계속된다
Timesheet의 인증은 외부 인증 서비스에 맡기지 않고 Express 서버 안에 구현했다. 비밀번호 처리에는 Argon2, JWT 처리에는 jose를 사용한다. 로그인에 필요한 절차를 직접 구성하되, 해시와 서명을 계산하는 일은 라이브러리가 맡는다.
가입할 때는 이메일과 비밀번호, 이름을 서버로 보낸다. 서버는 Zod로 입력을 확인하고 이메일을 소문자로 맞춘 뒤 사용자를 저장한다. 비밀번호 원문은 저장하지 않는다. Argon2id로 해시한 값을 저장하고, 나중에 로그인할 때 입력한 비밀번호가 그 해시와 맞는지 검사한다. 가입 API가 성공하면 프론트엔드가 이어서 로그인 API를 호출한다.
로그인에서는 이메일로 사용자를 찾고 Argon2로 비밀번호를 확인한다. 이메일이 없거나 비밀번호가 틀렸을 때는 모두 INVALID_CREDENTIALS라는 같은 오류를 반환한다. 없는 이메일에도 더미 해시 검증을 수행해 처리 시간 차이로 가입 여부가 드러나는 것을 줄이려 했다. 실패한 로그인은 IP 기준으로 1분에 5회까지 허용하고, 제한에 걸리면 429 응답을 보낸다. 현재 제한 횟수는 서버 인스턴스의 메모리에 저장하므로, 인스턴스가 여러 개일 때 전체 요청을 하나로 합쳐 세지는 못한다.
비밀번호 확인이 끝나면 서버는 액세스 토큰과 리프레시 토큰을 발급한다. 두 토큰은 용도와 수명이 다르다.
| 구분 | 액세스 토큰 | 리프레시 토큰 |
|---|---|---|
| 하는 일 | 일반 API 요청에서 로그인한 사용자를 확인한다 | 액세스 토큰을 새로 발급받는다 |
| 형태 | HS256으로 서명한 JWT | 32바이트 난수로 만든 문자열 |
| 기본 수명 | 10분 | 30일 |
| 브라우저 보관 | access_token 쿠키 | refresh_token 쿠키 |
| 쿠키가 전달되는 경로 | /api | /api/auth |
| 서버의 확인 방법 | 서명·만료·발급자·대상과 사용자·세션 식별자를 검사한다 | 전달된 토큰의 SHA-256 해시를 DB 기록과 대조한다 |
액세스 토큰에는 사용자 ID와 세션 ID 등이 들어 있다. 서버는 jose로 JWT를 발급하고, 요청을 받으면 허용한 서명 방식인 HS256인지, 서명과 유효 기간이 맞는지, 이 서비스가 발급한 토큰인지 검사한다. 리프레시 토큰의 원문은 DB에 넣지 않고 SHA-256 해시만 저장한다. 비밀번호에는 Argon2id를 쓰고, 충분히 긴 난수인 리프레시 토큰의 조회에는 SHA-256을 쓰는 서로 다른 처리다.
두 인증 토큰은 응답 본문에 넣지 않는다. 서버가 쿠키로 내려주며, 프론트엔드 JavaScript가 직접 읽을 수 없도록 HttpOnly를 설정한다. 운영에서는 HTTPS 연결에만 쿠키를 보내도록 Secure를 켜고, 다른 사이트에서 시작된 요청에 쿠키가 실리는 것을 제한하는 SameSite=Strict도 사용한다. 프론트엔드는 토큰 문자열 대신 로그인한 사용자 정보와 세션 상태를 관리한다.
새로고침해도 로그인이 이어지는 과정
앱을 열면 프론트엔드는 먼저 GET /api/users/me를 호출한다. 액세스 쿠키가 유효하면 서버가 사용자 정보와 소속 매장, 역할을 반환한다. 액세스 토큰이 만료되어 401을 받았을 때는 리프레시 쿠키로 POST /api/auth/refresh를 요청한다. 갱신에 성공하면 새 쿠키를 받은 상태에서 사용자 정보를 다시 읽는다.
일반 API 호출도 같은 흐름을 따른다. authFetch가 요청을 보내고, 401을 받으면 로그인 갱신을 시도한 뒤 원래 요청을 한 번 다시 보낸다. 인증 API용 클라이언트는 별도로 두었다. 갱신 요청이 실패했는데 그 실패를 처리하려고 또 갱신하는 반복을 피하기 위해서다.
로그인 유지 기간은 마지막으로 갱신한 시점부터 다시 30일로 잡는다. 이를 슬라이딩 세션이라고 한다. 처음 로그인한 날부터 무조건 30일 뒤에 끝나는 방식이 아니라, 갱신이 성공할 때마다 기한이 연장된다. 반대로 마지막 갱신 이후 30일 동안 갱신하지 않으면 다시 로그인해야 한다. 페이지를 열어 둔 시간 자체가 기한을 늘리는 것은 아니고, 실제 토큰 갱신이 기준이다.
갱신할 때는 리프레시 토큰도 새것으로 바꾼다. DB에는 이전 토큰을 이미 교체된 것으로 표시하고 새 토큰과의 연결을 남긴다. 이미 교체한 토큰이 다시 들어오면, 교체 직후 10초 안에는 동시에 들어온 요청일 수 있으므로 409를 반환한다. 그 시간 안에 같은 토큰으로 또 새 토큰을 발급해 주지는 않는다. 10초가 지난 뒤 다시 사용되면 재사용으로 판단해 해당 로그인에서 이어진 유효한 리프레시 토큰들을 폐기한다.
이 처리가 동시에 실행되어도 순서가 뒤섞이지 않도록 PostgreSQL에서 같은 세션의 갱신을 잠금으로 직렬화한다. DB 트랜잭션 안에서 폐기 결과를 저장한 뒤 오류 응답을 만든다. 폐기하는 도중 오류를 던져 트랜잭션이 취소되면, 막으려던 토큰이 다시 유효한 상태로 남을 수 있기 때문이다.
브라우저 쪽에서도 중복 갱신을 줄인다. 한 탭 안에서는 진행 중인 갱신 작업을 공유하고, 여러 탭 사이에서는 navigator.locks로 갱신 순서를 조정한다. 로그인과 로그아웃은 BroadcastChannel로 다른 탭에 알린다. 탭마다 로그인 화면과 사용자 정보가 따로 남지 않도록 연결한 것이다.
쿠키 인증에 함께 들어간 CSRF 검사와 권한 확인
브라우저는 조건에 맞는 요청에 쿠키를 자동으로 붙인다. 그래서 쿠키가 있다는 사실 외에도 요청이 허용한 웹에서 왔는지 확인해야 한다. Timesheet는 데이터를 바꾸는 모든 POST, PUT, PATCH, DELETE 요청에 CSRF 검사를 공통으로 적용한다. 로그인과 회원가입도 포함된다.
프론트엔드는 먼저 /api/auth/csrf에서 CSRF 토큰을 받는다. 서버는 같은 값을 쿠키와 응답 본문으로 내려준다. 프론트엔드는 응답으로 받은 값을 메모리에 두었다가 변경 요청의 X-CSRF-Token 헤더에 붙인다. 서버는 헤더와 쿠키의 값이 같은지, 서버 비밀값으로 만든 HMAC 서명이 유효한지 확인한다. 요청의 Origin도 허용 목록과 비교하고, Origin이 없으면 Referer에서 출처를 확인한다. 검사에 실패하면 403으로 거절한다. CSRF 토큰이 어긋난 경우 프론트엔드는 새로 받아 한 번 재시도한다.
로그인한 사람인지 확인하는 인증과, 그 사람이 무엇을 할 수 있는지 확인하는 권한 검사는 구분되어 있다. 서버의 requireAuth가 액세스 쿠키를 검사하고, requireMembership이 DB에서 매장 소속을 찾는다. 사장님만 할 수 있는 작업에는 requireMaster를 추가해 master 역할인지 확인한다. 역할은 화면에서 보낸 값을 믿지 않고 DB의 소속 정보에서 읽는다.
예를 들어 멤버의 출퇴근 조회는 인증된 사용자 ID와 소속 매장으로 범위를 제한한다. 사장님이 특정 멤버를 관리할 때도 대상이 같은 매장 소속인지 확인한다. 화면에서 관리 버튼을 숨기는 것에 더해 API에서도 접근 범위를 검사한다. 로그인 후에는 소속이 없으면 온보딩으로, 사장님이면 관리 화면으로, 멤버면 출퇴근 화면으로 이동한다.
로그아웃하면 해당 로그인에서 이어진 리프레시 토큰들을 DB에서 폐기하고 브라우저의 두 인증 쿠키를 지운다. 다른 탭에도 로그아웃을 알린다. 다만 현재 일반 API의 인증 검사는 JWT 자체의 유효성만 확인한다. DB에서 그 세션이 폐기됐는지를 매번 조회하지 않으므로, 별도로 복사된 액세스 토큰까지 즉시 무효화하는 구조는 아니다. 그런 토큰은 남은 유효 시간인 최대 10분 동안 인증 검사를 통과할 수 있다. 리프레시 토큰 재사용 탐지로 갱신을 막는 것과 이미 발급한 액세스 토큰을 즉시 차단하는 것은 범위가 다르다.
구현한 내용과 확인한 내용을 구분해서 남기기
저장소의 인증 검증 기록에는 쿠키 속성, 변조한 토큰 거절, 리프레시 토큰 교체와 재사용 탐지, CSRF 검사, 로그아웃, 매장 권한 등을 API 테스트로 확인한 결과가 있다. 2차 검증 기록에는 로컬 e2e 15/15, 짧게 설정한 세션의 만료와 연장 테스트 2/2가 통과했다고 적혀 있다. 이는 기존 작업에서 남긴 결과이며, 이 글을 확장하면서 테스트를 다시 실행한 것은 아니다.
새로고침 후 로그인 유지, 여러 탭의 동시 만료, 로그아웃 동기화는 코드에 구현되어 있지만 기존 기록상 실제 브라우저 검증은 남아 있다. 세션 목록과 모든 기기에서 로그아웃하는 기능도 이번 구현 범위에는 없다. 구현된 흐름과 검증 범위를 함께 적어 두어야, 나중에 이 글을 보며 어디까지 확인했는지 알 수 있다.
이 과정을 바이브 코딩으로 진행했다. 만들고 싶은 기능을 말로 설명하고, AI의 도움을 받아 설계와 구현을 이어 갔다. 내가 그동안 주력으로 다루지 않았던 서버와 데이터베이스도 그렇게 작업 범위에 들어왔다. Next.js로 화면을 만드는 데서 출발해, 그 화면에 필요한 데이터를 저장하고 주고받는 부분까지 만들 수 있었다.
이전의 나에게는 프론트엔드 개발 경험이 있었다. 이번에는 그 경험에 AI의 도움을 더해 DB와 API, 문서 구성과 배포까지 진행했다. 새로 들어간 기술 이름을 하나씩 나열하는 것보다, 그 기술들이 연결되어 Timesheet라는 앱을 이루었다는 사실이 이번 경험을 더 잘 설명한다. 내가 만들 수 있는 범위가 실제 프로젝트에서 넓어졌다.
Timesheet를 기록하며 남겨 두고 싶은 변화도 이것이다. Next.js를 주력으로 다루던 프론트엔드 개발자가 바이브 코딩으로 서비스를 설계하고, 화면 뒤에서 필요한 부분까지 만들어 배포했다. 예전에는 할 수 없었던 일을 이번에는 해냈다.
확인한 구현: server/src/auth/{routes,tokens,refresh,guard,csrf}.ts, web/src/auth/session.ts, web/src/api/client.ts, server/src/contract.ts, server/prisma/schema.prisma. 배포 구성과 기존 검증 결과는 저장소의 docs/deploy.md, docs/verify/auth.md를 기준으로 했다.