EziLog

인증/인가 모든 UserFlow에 대응해보자!

홈으로 돌아가기
post4_1_session,cookie.png

1. 도입 과정

로그인 방식은 OAuth 쇼셜로그인 방식과 직접 구현해서 관리하는 방식으로 나눌 수 있을것 같다. 기획단계 까지만 해도 두가지 방식을 모두 사용해보자라는 포부가 있었는데, 인증/인가에 대한 프로젝트 경험있는 사람이 본인 밖에 없는 것 같아 직접 구현 방식 하나만 사용하기로 했다.

그럼 왜 직접 구현하기로 했나? 한다면 지금 하고 있는 프로젝트는 부트 캠프 과정이고, 부트 캠프의 본질은 학습과 성장이라고 생각한다. 미리 구현된 api와 프로토콜을 사용하기 보단, 직접 토큰을 관리하고 UserFlow에 대응하는 과정에서 배움이 클 것이라 생각했다.

작업이 계속 지연되고 플로우와 로직 구현 과정 하나 하나 터치하는 과정에서 힘들었지만, 그 덕분에 더 성장하게 된 것 같아 회고를 하는 지금도 뿌듯하다는 생각이 있다.

 

2. 현대적인 인증/인가 처리

인증/인가에 대해 떠오르는 방식은 크게 쿠키,세션,토큰이 있다. 하지만 인증/인가에 대한 블로깅이 아니라 회고 과정이므로 우리 서비스에 적용해둔 Flow에 대해서만 정리해보겠다.

우선 우리는 JWT토큰 인증 방식을 사용중이다. 서버의 상태를 유지하지 않고도(무상태성) 클라이언트의 인증 상태를 확인할 수 있게하는 현대적인 방식으로 생각한다.

post4_2_JWT.png

1. 회원가입 흐름

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

2. 로그인 흐름

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

3. 비밀번호 재설정 흐름

post4_5_resetpassword.png
  1. 사용자가 “비밀번호 재설정 하기” 버튼을 클릭한다.
  2. 이메일 입력폼이 보이고, 이메일을 입력하면 서버에서 저장된 이메일이 있는지 검증하고, 저장된 사용자라면 해당 이메일로 token 이 포함된 url을 전송한다.
  3. "FrontOrigin://verified-email/reset-password?token=token" 사용자가 링크를 클릭하면 FE에서 만든 페이지로 이동되고 1회성 token 만 추출해서 다시 서버로 요청을 보낸다.
  4. 검증이 완료되면 비밀번호 재설정폼이 랜더링된다.

 

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와 좋은 시너지 효과

  • 런타임 검증과 타입 안전성을 동시에 확보
  • 중복 코드 없이 단일 소스로 관리(단 각 폼별로 의도 명확성을 위해 각각의 스키마 정의)
TYPESCRIPT
// 스키마 정의와 타입 추론이 동시에
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를 통한 입력 형식 안내

post4_6_placeholder.png

사용자가 입력필드만 보고도 어떤 형식을 입력해야 할지를 안내하고 싶었다.

TYPESCRIPT
export const AUTH_MESSAGES = {
  FIELD_PLACEHOLDER_PASSWORD_MIN: '비밀번호를 입력하세요 (8자리 이상)',
  FIELD_PLACEHOLDER_NICKNAME: '닉네임을 입력하세요 (2-20자)',
  FIELD_PLACEHOLDER_NEW_PASSWORD: '새 비밀번호를 입력하세요 (8자리 이상)',
};

 

2. 실시간 버튼 상태 관리

post4_7_btnstate.png

사용자에게 올바른 형식을 유도하기 위해 전체 폼 검증을 하고 통과해야 버튼을 활성화했다. 당연히 모든 입력이 제대로 완료되었다면, 자동으로 버튼이 활성화되게 하였다.

TYPESCRIPT
<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를 고려한 에러 메세지 표시 제어

post4_8_errormessage.png

개인적으로사용자가 입력 중에 계속 에러 메시지를 보면 불쾌하다고 생각한다. 따라서 아래처럼 구현했다.

  • 동작 방식
  1. 초기 입력 시 : onChange는 검증하지만 에러를 표시하지 않음
  2. 필드 이탈 시 : (onBlur): 처음으로 에러 메시지 표시
  3. 에러 발생 후 : onChange로 실시간 검증하여 올바른 입력 시 즉시 에러 제거
TYPESCRIPT
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 상태를 직접 확인하는 방식으로 개선했다.

  • 개선 전
TYPESCRIPT
onSubmit: async ({ value }) => {
  try {
    await loginMutation.mutateAsync(value);
  } catch (error) {
    // 에러는 mutation의 onError에서 처리됨
  }
}
  • 개선 후
TYPESCRIPT
onSubmit: ({ value }) => {
  loginMutation.mutate(value);
}

 

5. 비밀번호 재설정 후 자동 로그인 리다이렉트 구현

비밀번호 재설정 후에 로그인 페이지 이동 버튼 대신, 인증에 성공하면 바로 리다이렉트 되는 코드를 구현했다. 

사용자가 추가 클릭 없이 자연스럽게 플로우를 진행할 수 있다고 생각했다.

TYPESCRIPT
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 tokencookie 에 넣고 관리하는 방법

가장 일반적으로 사용되는 방법으로 생각하고, 모든 토큰을 서버에서 set-cookie 를 통해 브라우저의 쿠키에 심어주는 방식이다. 확인해보니 카카오 엔터도 해당 방식으로 인증/인가를 처리하고있다. login 시 두가지 토큰을 쿠키에 심어주고, logout 시에 만료 시간 0인 토큰을 새로 심어서 만료(삭제)시키는 방식이다.

그렇다면 로그인 상태는 어떻게 처리할까? 쿠키 만료시 인터셉터가 잡아서 api/refresh를 호출하고 사용자가 새탭,새로고침 시에는 api/status 를 호출해서 로그인 상태인지를 검증한다.

 

6. UserFlow 최종 테스트

  • 회원가입

[ ] 처음 가입하는 사용자가 회원가입하면 인증 메일을 보내주고 인증 상태에 따라 true / false 저장한다.

[ ] 이메일 인증도 완료했던 사용자가 회원가입하면 존재하는 유저로 표시해야한다.

[ ] 이메일 인증을 안한 사용자가 회원가입하면 신규 유저로 취급하고 회원가입을 진행한다.

[ ] 닉네임의 중복을 체크해서 중복이라면 중복 메세지를 띄워야한다.

[ ] 프론트,백엔드 모두에서 이메일,비밀번호 형식에 대한 검증을 수행해야한다.

  • 로그인

[ ] 이메일 인증까지 완료한 사용자가 로그인하면 성공 해야한다.

[ ] 신규 사용자나 이메일 인증이 안된 사용자가 로그인하면 실패 해야한다.

[ ] 탈퇴 처리된 사용자가 이전 정보로 로그인을 시도하면 실패해야한다.

  • 비밀번호 재설정

[ ] DB에 저장된 사용자의 이메일이 요청 들어오면 그 메일로 인증 메일을 보내야한다.

[ ] 신규 사용자가 비밀번호 재설정을 하려고하면 실패 처리 해야한다.

[ ] 사용자가 이메일 인증을 완료하고, 다른 이메일을 사용해 비밀번호를 변경하려 하면 막아야한다.

[ ] 비밀번호 변경 후 이전 비밀번호로는 더 이상 로그인할 수 없도록 처리해야한다.

[ ] 사용자가 비밀번호를 성공적으로 재설정했다면 그 토큰은 1회성으로 다시 쓸 수 없어야한다.

  • 로그인 상태 관리

[ ] 사용자가 새로고침 하면 /status api를 호출해서 인증정보를 검증후 자연스럽게 로그인상태를 유지해야한다.

[ ] 사용자가 새탭을 열어도 로그인 상태가 유지되어야한다.

[ ] access token 이 만료된 후에는 /refresh api를 호출해서 만료된 토큰을 재발급해야한다.

[ ] 사용자가 logout 을 하면 브라우저 쿠키에서 토큰이 삭제되어야 한다.

[ ] 로그인한 사용자가 로그인 페이지로 접근 시도시에 마이페이지 로 강제 리다이렉트 시켜야한다.

[ ] 로그인 하지않은 사용자가 인증이 필요한 페이지 진입시 강제로 로그인 페이지 로 리다이렉트 시켜야한다.

Comments