
1. 도입 과정
로그인 방식은 OAuth 쇼셜로그인 방식과 직접 구현해서 관리하는 방식으로 나눌 수 있을것 같다. 기획단계 까지만 해도 두가지 방식을 모두 사용해보자라는 포부가 있었는데, 인증/인가에 대한 프로젝트 경험있는 사람이 본인 밖에 없는 것 같아 직접 구현 방식 하나만 사용하기로 했다.
그럼 왜 직접 구현하기로 했나? 한다면 지금 하고 있는 프로젝트는 부트 캠프 과정이고, 부트 캠프의 본질은 학습과 성장이라고 생각한다. 미리 구현된 api와 프로토콜을 사용하기 보단, 직접 토큰을 관리하고 UserFlow에 대응하는 과정에서 배움이 클 것이라 생각했다.
작업이 계속 지연되고 플로우와 로직 구현 과정 하나 하나 터치하는 과정에서 힘들었지만, 그 덕분에 더 성장하게 된 것 같아 회고를 하는 지금도 뿌듯하다는 생각이 있다.
2. 현대적인 인증/인가 처리
인증/인가에 대해 떠오르는 방식은 크게 쿠키,세션,토큰이 있다. 하지만 인증/인가에 대한 블로깅이 아니라 회고 과정이므로 우리 서비스에 적용해둔 Flow에 대해서만 정리해보겠다.
우선 우리는 JWT토큰 인증 방식을 사용중이다. 서버의 상태를 유지하지 않고도(무상태성) 클라이언트의 인증 상태를 확인할 수 있게하는 현대적인 방식으로 생각한다.

1. 회원가입 흐름

- 사용자가 회원가입 페이지로 진입한다.
- 이메일, 비밀번호, 비밀번호 확인, 닉네임 필드에 입력후 회원가입 버튼을 누른다.
POST요청을 통해 서버는 비밀번호는 해싱해서 저장하고,emailVerfied값은false로User테이블에 저장한다. 이메일 필드에 입력한 주소로 이메일이 발송된다."FrontOrigin://verified-email/verify-email?token=token"사용자가 링크를 클릭하면 FE에서 만든 페이지로 이동되고token만 추출해서 다시 서버로 요청을 보낸다.- DB의 user테이블
emailVerified값이ture로 설정된다.
2. 로그인 흐름

- 사용자가 로그인 페이지로 진입한다.
- 이메일, 비밀번호 필드에 입력 후 로그인 버튼을 누른다.
POST매소드로 서버에 로그인 요청을 보낸다.success하면 서버는http-only속성으로set-cookie로access token과refresh token을 발급해준다. 서버는refresh token만 DB에 저장한다.
3. 비밀번호 재설정 흐름

- 사용자가 “비밀번호 재설정 하기” 버튼을 클릭한다.
- 이메일 입력폼이 보이고, 이메일을 입력하면 서버에서 저장된 이메일이 있는지 검증하고, 저장된 사용자라면 해당 이메일로
token이 포함된 url을 전송한다. "FrontOrigin://verified-email/reset-password?token=token"사용자가 링크를 클릭하면 FE에서 만든 페이지로 이동되고 1회성token만 추출해서 다시 서버로 요청을 보낸다.- 검증이 완료되면 비밀번호 재설정폼이 랜더링된다.
3. Tanstack-Form을 선택한 이유
사실 React에서의 Form을 다룰때는 react-hook-form이라는 국룰 라이브러리가 있는데, 2단계 때 사용해서 익숙한 김에 도입할까 생각했었다. 하지만 이번 프로젝트에서는 TanStack 생태계를 적극 활용하고 있어서 TanStack Router와 TanStack Query를 사용 중이었고, 폼 관리를 위해 TanStack Form을 새롭게 도입하면 Tan Tan Tan한 프로젝트가 될 것 같아서 사용해보았다.
Tanstack 생태계 통합
- TanStack Router, Query와의 자연스러운 통합
- 일관된 API 패턴으로 학습 곡선 감소
- TypeScript 타입 추론이 생태계 전반에서 잘 작동
zod와 좋은 시너지 효과
- 런타임 검증과 타입 안전성을 동시에 확보
- 중복 코드 없이 단일 소스로 관리(단 각 폼별로 의도 명확성을 위해 각각의 스키마 정의)
// 스키마 정의와 타입 추론이 동시에
export const loginSchema = z.object({
email: z.string().email('올바른 이메일 형식이 아닙니다'),
password: z.string().min(8, '비밀번호는 8자리 이상이어야 합니다'),
});
export type LoginFormData = z.infer<typeof loginSchema>;
4. UX향상을 위해 고민했던 점
1. Placeholder를 통한 입력 형식 안내

사용자가 입력필드만 보고도 어떤 형식을 입력해야 할지를 안내하고 싶었다.
export const AUTH_MESSAGES = {
FIELD_PLACEHOLDER_PASSWORD_MIN: '비밀번호를 입력하세요 (8자리 이상)',
FIELD_PLACEHOLDER_NICKNAME: '닉네임을 입력하세요 (2-20자)',
FIELD_PLACEHOLDER_NEW_PASSWORD: '새 비밀번호를 입력하세요 (8자리 이상)',
};
2. 실시간 버튼 상태 관리

사용자에게 올바른 형식을 유도하기 위해 전체 폼 검증을 하고 통과해야 버튼을 활성화했다. 당연히 모든 입력이 제대로 완료되었다면, 자동으로 버튼이 활성화되게 하였다.
<form.Subscribe selector={(state) => [state.isValid, state.isSubmitting, state.values]}>
{([isValid, isSubmitting, values]) => {
const hasValues = values && values.email && values.password;
const isLoading = isSubmitting || loginMutation.isPending;
const canSubmit = hasValues && isValid && !isLoading;
return (
<FormButton
variant={canSubmit ? 'primary' : 'disabled'}
isLoading={isLoading}
disabled={!canSubmit}
>
{AUTH_MESSAGES.LOGIN_BUTTON}
</FormButton>
);
}}
</form.Subscribe>3. UX를 고려한 에러 메세지 표시 제어

개인적으로사용자가 입력 중에 계속 에러 메시지를 보면 불쾌하다고 생각한다. 따라서 아래처럼 구현했다.
- 동작 방식
- 초기 입력 시 :
onChange는 검증하지만 에러를 표시하지 않음 - 필드 이탈 시 :
(onBlur): 처음으로 에러 메시지 표시 - 에러 발생 후 :
onChange로 실시간 검증하여 올바른 입력 시 즉시 에러 제거
export const useFormValidation = () => {
const [touchedFields, setTouchedFields] = useState<Set<string>>(new Set());
const createEmailValidator = (): ValidatorConfig => ({
// 1. onBlur: 필드를 벗어날 때만 에러 표시
onBlur: ({ value }) => {
markFieldAsTouched('email');
return validateField(loginSchema.shape.email, value);
},
// 2. onChange: 실시간 검증 (에러 빠르게 제거)
onChange: ({ value }) => {
return validateField(loginSchema.shape.email, value);
},
});
};
4. Mutation 상태와 Form 상태 통합
처음에는 mutateAsync를 사용했지만, 불필요한 try-catch가 생기고 코드가 복잡해진다고 생각했다. mutate로 변경하면서 mutation.isPending 상태를 직접 확인하는 방식으로 개선했다.
- 개선 전
onSubmit: async ({ value }) => {
try {
await loginMutation.mutateAsync(value);
} catch (error) {
// 에러는 mutation의 onError에서 처리됨
}
}- 개선 후
onSubmit: ({ value }) => {
loginMutation.mutate(value);
}
5. 비밀번호 재설정 후 자동 로그인 리다이렉트 구현
비밀번호 재설정 후에 로그인 페이지 이동 버튼 대신, 인증에 성공하면 바로 리다이렉트 되는 코드를 구현했다.
사용자가 추가 클릭 없이 자연스럽게 플로우를 진행할 수 있다고 생각했다.
export const usePasswordResetMutation = () => {
const navigate = useNavigate();
return useMutation({
mutationFn: (data: PasswordResetData) => resetPasswordApi(data),
onSuccess: () => {
// 2초 후 자동으로 로그인 페이지로 이동
setTimeout(() => {
navigate({ to: '/auth/login' });
}, 2000);
},
});
};
5. 새탭, 새로고침 간 로그인 상태 유지
JWT 토큰 방식에서 로그인 상태를 관리하는 3가지 방법
JWT토큰 방식의 인증 인가에서는 서버에서 refresh token , access token 을 생성해서 클라이언트에 서명된 인증서를 내려주는 방식으로 동작한다. 이때 refresh token 은 모든 방법에서 http only 속성의 쿠키게 담아둔다는 가정하고 access token 을 클라이언트(브라우저)단에서 관리하는 3가지 방법을 정리해보겠다.
access token을 브라우저 스토리지에 저장하는 방법
서버가 로그인시 body에 access token 을 담아서 보내주면 이를 localstorage 에 저장하고 로그아웃시 제거하는 방식이다. 개발자 입장에선 가장 쉬운 방식이지만 치명적인 단점이 있는데 XSS(Cross-Site Scripting) 공격에 매우 취약하다는 것이다. localStorage는 JavaScript로 자유롭게 접근할 수 있다.(localStorage.getItem('token')) 만약 웹사이트에 악의적인 스크립트가 삽입된다면 공격자는 이 스크립트를 이용해 localStorage에 저장된 토큰을 탈취할 수 있게된다.
access token을 메모리에 저장하는 방법
메모리에 저장한다, 즉 변수에 값을 넣고 사용한다는 뜻이다. 단 이방식에는 단점이 존재하는데 인-메모리에 저장된 데이터는 휘발성이기 때문에 사용자가 브라우저 탭을 닫거나, 새로고침(F5)을 누르면 JavaScript 런타임이 초기화되면서 저장된 토큰이 사라지는 문제가 있다. 해결방법으로는 쿠키에 저장된 refresh token 을 사용하여 휘발된 access token 을 새롭게 발급받아서 다시 메모리에 저장하는 방법이 있다. 사실상 가장 안전한 방법으로 생각한다.
access token도cookie에 넣고 관리하는 방법
가장 일반적으로 사용되는 방법으로 생각하고, 모든 토큰을 서버에서 set-cookie 를 통해 브라우저의 쿠키에 심어주는 방식이다. 확인해보니 카카오 엔터도 해당 방식으로 인증/인가를 처리하고있다. login 시 두가지 토큰을 쿠키에 심어주고, logout 시에 만료 시간 0인 토큰을 새로 심어서 만료(삭제)시키는 방식이다.
그렇다면 로그인 상태는 어떻게 처리할까? 쿠키 만료시 인터셉터가 잡아서 api/refresh를 호출하고 사용자가 새탭,새로고침 시에는 api/status 를 호출해서 로그인 상태인지를 검증한다.
6. UserFlow 최종 테스트
- 회원가입
[ ] 처음 가입하는 사용자가 회원가입하면 인증 메일을 보내주고 인증 상태에 따라 true / false 저장한다.
[ ] 이메일 인증도 완료했던 사용자가 회원가입하면 존재하는 유저로 표시해야한다.
[ ] 이메일 인증을 안한 사용자가 회원가입하면 신규 유저로 취급하고 회원가입을 진행한다.
[ ] 닉네임의 중복을 체크해서 중복이라면 중복 메세지를 띄워야한다.
[ ] 프론트,백엔드 모두에서 이메일,비밀번호 형식에 대한 검증을 수행해야한다.
- 로그인
[ ] 이메일 인증까지 완료한 사용자가 로그인하면 성공 해야한다.
[ ] 신규 사용자나 이메일 인증이 안된 사용자가 로그인하면 실패 해야한다.
[ ] 탈퇴 처리된 사용자가 이전 정보로 로그인을 시도하면 실패해야한다.
- 비밀번호 재설정
[ ] DB에 저장된 사용자의 이메일이 요청 들어오면 그 메일로 인증 메일을 보내야한다.
[ ] 신규 사용자가 비밀번호 재설정을 하려고하면 실패 처리 해야한다.
[ ] 사용자가 이메일 인증을 완료하고, 다른 이메일을 사용해 비밀번호를 변경하려 하면 막아야한다.
[ ] 비밀번호 변경 후 이전 비밀번호로는 더 이상 로그인할 수 없도록 처리해야한다.
[ ] 사용자가 비밀번호를 성공적으로 재설정했다면 그 토큰은 1회성으로 다시 쓸 수 없어야한다.
- 로그인 상태 관리
[ ] 사용자가 새로고침 하면 /status api를 호출해서 인증정보를 검증후 자연스럽게 로그인상태를 유지해야한다.
[ ] 사용자가 새탭을 열어도 로그인 상태가 유지되어야한다.
[ ] access token 이 만료된 후에는 /refresh api를 호출해서 만료된 토큰을 재발급해야한다.
[ ] 사용자가 logout 을 하면 브라우저 쿠키에서 토큰이 삭제되어야 한다.
[ ] 로그인한 사용자가 로그인 페이지로 접근 시도시에 마이페이지 로 강제 리다이렉트 시켜야한다.
[ ] 로그인 하지않은 사용자가 인증이 필요한 페이지 진입시 강제로 로그인 페이지 로 리다이렉트 시켜야한다.