[BlerOn] 회원/인증

Published: by Creative Commons Licence

BlerOn 회원/인증 정리

BlerOn Backend(Spring Boot 3.3.4 / Java 21)의 회원/인증 영역을 한 글로 정리한다. 인증은 크게 세 갈래로 나뉜다.

  1. 일반 가입/로그인 : 아이디(이메일)/비밀번호 기반
  2. SNS 가입/로그인 : 네이버 / 카카오 / 구글 OAuth 2.0 기반
  3. 관리자 페이지에서의 사용자(학습자) 대리 로그인 : 임시 토큰(temp token) 교환 방식

세 갈래 모두 최종적으로는 JWT(Access/Refresh Token) 를 발급한다는 점에서 출구는 같다. 다만 "어떻게 본인임을 증명하느냐"가 다를 뿐이다. 그 차이를 중심으로 정리한다.


0. 인증 아키텍처 한눈에 보기

먼저 공통 골격부터 짚고 간다. 어떤 경로로 로그인하든 마지막은 아래로 수렴한다.

flowchart LR
    A["로그인 수단<br/>(비밀번호 / SNS / 임시토큰)"] --> B["회원·역할 조회"]
    B --> C["JWT 발급<br/>Access + Refresh"]
    C --> D["Redis 저장<br/>(토큰 + UserDetails 캐시)"]
    D --> E["로그인 이력 적재<br/>+ 이벤트 발행"]
    E --> F["CommonApiResponse 응답"]
  • JWT 설정: Access Token 1시간(jwt.expiration=3600000), Refresh Token 1일(jwt.refresh-expiration=86400000)
  • Redis: 토큰과 UserDetailsCache를 함께 저장한다. 매 요청마다 DB를 조회하지 않기 위한 캐시 용도이며, 중복 로그인 차단 옵션(jwt.redis-duplicate-check-enabled)도 여기에 얹혀 있다.
    • Redis 저장이 실패해도 로그인 자체는 성공 처리한다. (서비스 중단 방지)
  • 로그인 이력: 성공/실패 모두 LoginHistoryComponent로 적재한다. 실패 사유까지 남긴다.
  • TokenRealm: 같은 사람이라도 USER(사용자 사이트)와 ADMIN(관리자 사이트) 컨텍스트를 구분해 토큰을 관리한다.

모든 API 응답은 CommonApiResponse로 감싸진다. 로그인 실패도 HTTP 200으로 내려주되 success=false + 실패 타입으로 구분하는 패턴을 쓰는 엔드포인트가 있다는 점이 특징이다.


1. 일반 가입/로그인 프로세스

1-1. 회원가입 (휴대폰 인증 → 가입)

일반 회원가입은 "휴대폰 본인 인증"을 거친 뒤 가입 정보를 제출하는 2단계 흐름이다.

sequenceDiagram
    autonumber
    participant FE as Front
    participant BE as Backend
    participant SMS as SMS 발송

    Note over FE,BE: 1단계 — 휴대폰 인증
    FE->>BE: POST /member/sms/send (휴대폰번호)
    BE->>SMS: 6자리 인증번호 발송
    FE->>BE: POST /member/sms/verify-user (번호 + 인증번호)
    BE-->>FE: 인증 성공 + 가입 여부 확인

    Note over FE,BE: 2단계 — 회원가입
    FE->>BE: POST /member/register (가입 정보)
    BE->>BE: 이메일/휴대폰 중복 체크, 비밀번호 정책 검증
    BE->>BE: 비밀번호 암호화(PasswordEncoder) 후 저장
    BE-->>FE: 가입 완료 (memberMasterSeq)

핵심 포인트는 다음과 같다.

  • SMS 인증 API (/member/sms)
    • POST /member/sms/send : 휴대폰 번호로 6자리 인증번호 발송
    • POST /member/sms/verify-user : 번호 + 인증번호 검증 + 회원가입 여부 확인
    • 이 외에 ID 찾기(/verify/find-id), 비밀번호 찾기(/verify/find-password)용 검증 API도 별도로 있다.
  • 회원가입 API (POST /member/register, @PublicApi)
    • 이메일(=회원ID) 중복 체크, 휴대폰번호 중복 체크
    • 비밀번호 정책 검증은 MemberDomainService.validatePassword(...)에서 수행 (아이디/휴대폰/이메일과의 유사성까지 검사)
    • 통과하면 PasswordEncoder로 암호화 후 저장
  • 비밀번호는 절대 평문으로 저장하지 않는다. 검증도 passwordEncoder.matches(raw, encoded)로만 한다.

1-2. 로그인 (POST /member/auth/token)

아이디/비밀번호 로그인의 본체다. 단순히 "비밀번호 맞으면 토큰 발급"이 아니라, 계정 잠금 정책이 꽤 촘촘하게 들어가 있다.

flowchart TD
    A[로그인 요청] --> B{회원 존재?}
    B -- 없음 --> B1[미등록 사용자 IP 기준<br/>실패 횟수 체크]
    B1 --> B2[횟수 초과 시 RATE_LIMITED<br/>아니면 CREDENTIAL_MISMATCH]
    B -- 있음 --> C{계정 잠금 상태?}
    C -- 잠금 --> C1{관리자 / 잠금시간 미만료?}
    C1 -- 차단 --> C2[ACCOUNT_LOCKED 응답]
    C1 -- 만료됨 --> C3[잠금 자동 해제 후 진행]
    C -- 정상 --> D{비밀번호 일치?}
    C3 --> D
    D -- 불일치 --> D1[실패 이력 + CREDENTIAL_MISMATCH]
    D -- 일치 --> E[역할/관리자유형 조회]
    E --> F[JWT 발급 + Redis 저장]
    F --> G[성공 이력 + 이벤트 발행]
    G --> H[로그인 성공 응답]

코드상 동작을 풀어 쓰면 이렇다. (MemberAuthAppQueryServiceImpl.login)

  1. 회원 조회: findByMemberIdAndDelYnFalse로 삭제되지 않은 회원만 조회.
    • 회원이 없으면, 같은 IP에서 "존재하지 않는 아이디"로 반복 시도한 횟수를 확인해 일정 횟수를 넘으면 LOGIN_RATE_LIMITED로 막는다. (계정 존재 여부를 캐내려는 시도 방어)
  2. 계정 잠금(lock) 처리: lockYn이 true면,
    • 관리자 계정이거나 잠금 시간이 아직 안 지났으면 차단(ACCOUNT_LOCKED / ACCOUNT_LOCKED_ADMIN).
    • 잠금 시간이 지난 일반 계정이면 자동으로 잠금을 풀고 로그인 흐름을 계속한다. 이때 잠금 해제 이력도 남긴다.
  3. 비밀번호 검증: 불일치 시 실패 이력을 남기고 CREDENTIAL_MISMATCH.
  4. 역할 조회 + 관리자유형(AdminType) 조회: ADMIN 역할이 있으면 어떤 관리자 유형인지까지 조회.
  5. JWT 발급: Access/Refresh Token 생성.
  6. Redis 저장: 기존 토큰 삭제 후 새 토큰 + UserDetailsCache 저장.
  7. 로그인 이력 + 이벤트 발행(MemberMasterEvent).

보안 디테일: 로그인 엔드포인트는 @ExceptionHandler(MethodArgumentNotValidException)를 컨트롤러 레벨에서 잡아, 검증 실패 시에도 "아이디 또는 비밀번호가 일치하지 않습니다" 라는 동일한 메시지로 응답한다. 어떤 필드가 틀렸는지 흘리지 않기 위함이다.

1-3. 토큰 갱신 / 로그아웃

  • Refresh (POST /member/auth/refresh): Refresh Token을 검증하고 새 Access Token을 발급한다. Refresh Token 자체는 재사용(유효기간 연장 없음)한다.
  • Logout (POST /member/auth/logout): 현재 인증된 사용자의 해당 realm(user/admin) 토큰을 Redis에서 삭제한다. 바디로 loginContext를 받아 어느 컨텍스트를 로그아웃할지 구분한다.

2. SNS 가입/로그인 프로세스 (네이버 · 카카오 · 구글)

SNS 로그인은 표준 OAuth 2.0 Authorization Code 방식이다. 지원 제공자는 SnsProvider enum에 정의되어 있다.

코드 제공자 비고
naver 네이버 토큰 요청 시 redirectUri 미사용
kakao 카카오 토큰 요청 시 redirectUri 미사용
google 구글 토큰 요청 시 redirectUri 필요

SNS 로그인에서 가장 중요한 개념은 "백엔드가 직접 토큰을 프론트로 던지지 않는다"는 점이다. 콜백 처리 결과에 따라 기가입 회원이면 임시 토큰(tempToken), 신규 회원이면 인증정보 토큰(snsAuthinfoToken) 을 쿼리스트링에 실어 프론트로 redirect한다. 프론트는 그 토큰을 다시 백엔드로 보내 최종 처리(로그인 or 가입)를 한다. 일종의 "토큰 핸드오프" 구조다.

2-1. 전체 시퀀스

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as Front
    participant P as SNS 제공자<br/>(네이버/카카오/구글)
    participant BE as Backend

    U->>P: SNS 로그인 동의 (Authorize)
    P->>BE: GET /member/auth/sns/callback/{provider}?code=...
    BE->>P: code로 Access Token 요청
    P-->>BE: Access Token
    BE->>P: Access Token으로 사용자 정보 조회
    P-->>BE: socialId, email, name, mobile, gender, birth

    alt 기가입 회원 (provider+socialId 존재)
        BE->>BE: 임시 토큰(tempToken) 생성 (3분)
        BE-->>FE: 302 Redirect → 로그인성공URL?tempToken=...
        FE->>BE: POST /member/auth/sns/login { tempToken }
        BE-->>FE: JWT 발급 (로그인 완료)
    else 신규 회원
        BE->>BE: SNS 인증정보 저장 + snsAuthinfoToken 생성 (10분)
        BE-->>FE: 302 Redirect → 회원가입URL?sns_authinfo_token=...
        FE->>BE: GET /member/auth/sns/info?snsAuthinfoToken=...
        BE-->>FE: SNS 인증 정보 (이름/이메일/휴대폰 등 프리필)
        Note over FE,BE: 휴대폰 인증 등 가입 절차 진행 후
        FE->>BE: POST /member/auth/sns/info (회원-SNS 연동)
    end

2-2. 콜백 처리 (provider 공통)

네이버/카카오/구글 콜백은 각각 엔드포인트가 있지만 내부적으로는 하나의 handleCallback으로 모인다.

  • GET /member/auth/sns/callback/naver
  • GET /member/auth/sns/callback/kakao
  • GET /member/auth/sns/callback/google

이들은 모두 @PublicApi이며(인증 불필요), 인가코드(code)와 state, 에러 파라미터를 받는다. 처리의 핵심은 SnsAuthAppQueryServiceImpl.handleSnsAuthCallback에 있다.

flowchart TD
    A["콜백 수신 (code)"] --> B{"error 파라미터?"}
    B -- 있음 --> BX["BizException (PROVIDER_AUTH)"]
    B -- 없음 --> C["provider별 clientId/secret 조회"]
    C --> D["Gateway.getAccessToken()"]
    D --> E["Gateway.getUserInfo()"]
    E --> F{"provider + socialId<br/>가입 이력 존재?"}
    F -- "예" --> G["기가입 회원 처리"]
    F -- "아니오" --> H["신규 회원 처리"]
    G --> I["302 Redirect (tempToken)"]
    H --> J["302 Redirect (snsAuthinfoToken)"]

provider별 차이는 SnsAuthGateway 구현체(NaverSnsAuthClient 등)와 팩토리(SnsAuthClientFactory)로 흡수한다. 예를 들어 네이버 클라이언트는 토큰 엔드포인트(nid.naver.com/oauth2.0/token)와 사용자 정보 엔드포인트(openapi.naver.com/v1/nid/me)를 호출하고, 응답 JSON에서 socialId/email/name/mobile/gender/birth를 추출한다. 구글만 토큰 교환 시 redirectUri를 함께 넘긴다.

2-3. 기가입 회원 — 임시 토큰 로그인

이미 해당 SNS로 가입한 회원이라면:

  1. CreateSnsLoginTempTokenCommand임시 토큰(UUID) 을 생성한다. 만료는 3분(plusMinutes(3)), kl_sns_login 테이블에 memberMasterSeq, snsProvider와 함께 저장된다.
  2. 프론트 로그인 성공 URL에 ?tempToken=...을 붙여 302 Redirect.
  3. 프론트가 POST /member/auth/sns/login으로 tempToken을 넘기면, 백엔드(loginByTempToken)가:
    • 토큰 조회 → 만료 검사 → 회원/역할 조회 → JWT 발급 → Redis 저장 → 이력/이벤트
    • 일반 로그인과 동일하게 CommonApiResponse + 성공/실패 타입으로 응답한다.

2-4. 신규 회원 — 인증정보 토큰으로 가입 유도

가입 이력이 없으면:

  1. SNS에서 받은 사용자 정보를 kl_sns_authinfo에 저장하고 인증정보 토큰(snsAuthinfoToken) 을 만든다. 만료는 10분.
  2. 회원가입 URL에 ?sns_authinfo_token=...을 붙여 302 Redirect.
  3. 프론트는 GET /member/auth/sns/info?snsAuthinfoToken=...로 SNS가 준 이름/이메일/휴대폰 등을 미리 채워(prefill) 가입 폼을 보여준다.
  4. 가입 절차(휴대폰 인증 등)를 마친 뒤 POST /member/auth/sns/info회원 ↔ SNS 계정을 연동(MemberSnsDomain 저장)한다. 이때 동일 provider+socialId 중복 연동은 막는다.

토큰을 굳이 두 종류(tempToken 3분 / snsAuthinfoToken 10분)로 나눈 이유: 전자는 "곧바로 로그인"을 위한 일회성 비밀값이라 짧게, 후자는 "사람이 가입 폼을 채우는 시간"을 고려해 더 넉넉하게 잡았다.


3. 관리자 페이지에서 사용자(학습자) 로그인 처리

CS/운영 상황에서 관리자가 "이 학습자 화면에서 무엇이 보이는지" 직접 확인해야 할 때가 있다. 이를 위해 관리자가 특정 학습자로 대리 로그인하는 흐름이 있다. (참고: docs/learner-temp-token-flow.html)

핵심 아이디어는 SNS 기가입 로그인과 동일한 "임시 토큰 핸드오프"다. 관리자가 ADMIN 권한으로 임시 토큰을 발급받고, 학습자 화면(새 창) 이 그 토큰을 일회성 비밀번호처럼 넘겨 정식 JWT로 교환한다.

3-1. 전체 시퀀스

sequenceDiagram
    autonumber
    participant AF as 관리자 Front
    participant AB as Backend
    participant DB as DB(kl_sns_login)
    participant UF as 학습자 Front

    Note over AF,AB: 발급 단계 — ADMIN JWT 필요
    AF->>AB: POST /member/auth/learner-temp-token<br/>Authorization: Bearer {ADMIN JWT}<br/>{ memberMasterSeq }
    AB->>AB: @PreAuthorize("hasRole('ADMIN')") 검증
    AB->>DB: UUID 임시토큰 + 만료(now+3분) + 회원키 저장
    AB-->>AF: tempToken

    Note over AF,UF: 새 창으로 학습자 사이트 오픈
    AF->>UF: window.open(학습자 URL + ?temp_token=...)

    Note over UF,AB: 교환 단계 — Public API
    UF->>AB: POST /member/auth/admin-learner/login { tempToken }
    AB->>DB: tempToken 조회
    alt 없음/만료
        AB-->>UF: 오류 (글로벌 예외 처리)
    else 정상
        AB->>AB: 회원/역할 로드 후 JWT 발급
        AB-->>UF: AuthTokenResponse (Access/Refresh)
    end

3-2. API 요약

구분 메서드 · 경로 인증 설명
임시 토큰 발급 POST /member/auth/learner-temp-token ADMIN (Bearer JWT) Body LearnerTokenRequest(memberMasterSeq). 응답에 tempToken
임시 토큰 로그인 POST /member/auth/admin-learner/login 공개(@PublicApi) Body SnsTempTokenLoginRequest(tempToken). 성공 시 일반 로그인과 동일 형태 JWT 응답

구현 근거를 정리하면:

  • 발급은 MemberAuthController.issueLearnerTempToken이며 @PreAuthorize("hasRole('ADMIN')")로 보호된다.
  • 임시 토큰 생성/만료(3분)는 SnsAuthAppCommandServiceImpl.createTempTokenplusMinutes(3)에서 결정된다.
    • 즉, SNS 기가입 로그인과 동일한 임시 토큰 메커니즘(kl_sns_login)을 그대로 재사용한다. 관리자 대리 로그인은 "발급 주체가 관리자"라는 점만 다르다.
  • 교환은 loginByTempTokenForAdminLearnerSnsAuthAppQueryServiceImpl.loginByTempToken을 호출해 정식 JWT를 발급한다.

과거에는 POST /member/auth/learner-token으로 토큰을 바로 발급하는 방식이 있었으나(@Deprecated), 토큰이 URL 등에 직접 노출되는 위험을 줄이기 위해 "짧은 임시 토큰 → 교환" 방식으로 옮겨갔다.

3-3. 프론트엔드가 맞춰야 할 것

  • 쿼리 파라미터 이름 통일: 예시는 ?temp_token=이지만 SNS 콜백 쪽은 ?tempToken= 형태를 쓰는 경로도 있다. 학습자 앱이 파싱하는 이름과 반드시 일치시켜야 한다.
  • 만료 UX: 약 3분 내 교환하지 못하면 재발급이 필요하다.
  • 보안: 임시 토큰은 URL에 남을 수 있으므로 로그/북마크/공유에 유의하고, 교환 후 주소창에서 쿼리를 제거하는 것을 권장한다.

4. 세 가지 흐름 비교

구분 본인 증명 수단 진입점 최종 토큰 발급 임시 토큰
일반 로그인 아이디 + 비밀번호 POST /member/auth/token 즉시 발급 없음
SNS 로그인(기가입) SNS OAuth GET /sns/callback/{provider}POST /sns/login 교환 후 발급 tempToken(3분)
SNS 가입(신규) SNS OAuth GET /sns/callback/{provider}GET/POST /sns/info 가입 완료 후 로그인 snsAuthinfoToken(10분)
관리자 대리 로그인 ADMIN JWT POST /learner-temp-tokenPOST /admin-learner/login 교환 후 발급 tempToken(3분)

세 흐름 모두 결국 "검증된 신원 → 회원/역할 로드 → JWT 발급 → Redis 저장 → 이력/이벤트" 라는 동일한 출구로 모인다. 입구(증명 방식)는 다르지만 출구는 하나로 통일해 두었기 때문에, 토큰 발급/세션 관리 로직을 한 곳에서 유지보수할 수 있다는 점이 이 설계의 가장 큰 장점이다.


5. 마치며

  • 공통화: 로그인의 "출구"를 통일한 덕분에, 새로운 로그인 수단(예: 애플 로그인)을 붙여도 토큰/세션 처리는 재사용할 수 있다.
  • 보안: 비밀번호 평문 미저장, 로그인 메시지 통일(정보 노출 최소화), 미등록 아이디 반복 시도 방지, 계정 잠금 정책, 짧은 만료의 임시 토큰 등 곳곳에 방어 장치를 넣었다.
  • 임시 토큰 재사용: SNS 기가입 로그인과 관리자 대리 로그인이 같은 kl_sns_login 메커니즘을 공유한다. 한 번 잘 만든 일회성 토큰 구조를 여러 시나리오에 재활용한 사례다.