OIDC 로그인 이후의 회원 생명주기와 상태 모델링 인증은 시작일 뿐

미생의 개발 이야기

목록 보기
73/73

회원 생명주기와 상태 모델링

튜토리얼이 알려주지 않는 진짜 문제들

소셜 로그인 연동 튜토리얼을 보면 보통 인증 완료 -> 사용자 정보 DB 저장 -> 로그인 처리의 흐름으로 끝납니다.
하지만 실제 서비스에서 이 흐름을 그대로 운영에 배포하면 곧바로 여러 장벽에 부딪히게 됩니다.

  • OIDC 인증이 끝났다고 우리 서비스의 '가입'도 끝난 것인가?
  • 약관 동의나 추가 연락처 정보는 어느 시점에 받아야 하는가?
  • 관리자의 가입 승인 대기 상태는 어떻게 표현할 것인가?
  • 탈퇴한 사용자의 데이터를 정말 DELETE 쿼리로 날려버릴 것인가?
  • 탈퇴 이력이 있는 아이디의 재가입을 어떻게 제어할 것인가?

이번 네이버 OIDC 기반 프로젝트는 이러한 질문들에 대한 답을 코드 구조로 남기려는 시도였습니다. 단순한 기능 구현을 넘어, 회원의 생명주기와 운영 정책을 도메인 모델에 어떻게 녹여냈는지 그 설계 과정을 공유합니다.


1. 인증과 가입의 분리, 그리고 상태머신

OIDC는 단지 "이 사용자가 네이버에서 본인 인증을 마쳤다"는 사실만 보장합니다.
서비스 운영에 필요한 '로그인 아이디, 이름, 연락처, 약관 동의'는 OIDC가 해결해 주지 않습니다.

따라서 인증 성공 직후 바로 ACTIVE 회원으로 만들지 않고, 회원의 상태를 세분화하여 상태 전이 모델을 구축했습니다.

회원 상태설명 및 제어 정책
SIGNUP_REQUIREDOIDC 인증은 성공했으나, 서비스 추가 정보(약관 동의 등)가 필요한 상태. API 접근 제한.
PENDING추가 정보 제출 완료. 관리자의 가입 승인을 대기하는 상태.
ACTIVE관리자 승인 완료. 정상적으로 모든 서비스 이용이 가능한 상태.
REJECTED관리자가 가입을 반려한 상태.
WITHDRAWN탈퇴한 회원. 로그인은 차단되나 운영 및 CS 추적을 위해 이력은 보존되는 상태.

이렇게 상태를 명확히 분리해 두면 화면 요구사항이 바뀌더라도 백엔드의 시스템 규칙이 흔들리지 않습니다.
결국 UI는 현재 상태를 투영하는 얇은 막일 뿐, 핵심은 "도메인이 어떤 상태 전이를 허용하느냐"에 있습니다.


2. 행위를 드러내는 도메인과 조율자

Aggregate Root: Setter 대신 의도를 담은 메서드

데이터를 담는 DTO 역할에 머물지 않고, 객체 스스로 상태 전이를 책임지도록 설계했습니다.

public void completeRegistration(String loginId, String displayName, String contactNumber, LocalDateTime termsAgreedAt) {
    this.loginId = loginId;
    this.displayName = displayName;
    this.contactNumber = contactNumber;
    this.termsAgreedAt = termsAgreedAt;
    this.status = AccountStatus.PENDING; // 상태 전이
    this.withdrawnAt = null;
    this.withdrawalReason = null;
    this.roles = new ArrayList<>();
}

가입 완료 처리를 위해 account.setStatus(PENDING) 한 줄을 외부에서 호출하는 대신, completeRegistration() 이라는 행위 메서드를 두었습니다.
이로써 '가입 완료'라는 도메인 이벤트가 어떤 데이터들을 동반하고 어떤 상태들을 초기화해야 하는지 응집도 있게 관리됩니다.

Application Service

도메인 객체가 자신의 상태를 관리한다면, 애플리케이션 서비스는 여러 도메인과 정책을 엮어 유스케이스를 완성합니다.
가장 대표적인 것이 아이디 중복 체크 로직입니다.

단순히 boolean을 반환하는 중복 체크가 아니라, 정책적 의미를 담은 결과를 반환합니다.

// checkLoginId 메서드 일부
if (duplicate.getStatus() == AccountStatus.WITHDRAWN) {
    return new LoginIdCheckResult(false, LoginIdCheckStatus.WITHDRAWN_MEMBER, "탈퇴 이력이 있는 아이디입니다. 관리자에게 문의하세요.");
}
return new LoginIdCheckResult(false, LoginIdCheckStatus.EXISTING_MEMBER, "이미 가입된 계정입니다.");

단순히 "중복되었다"가 아니라 "탈퇴 이력이 있어 재가입이 제한된 상태"임을 명확히 구분합니다. 백엔드에서 정책을 담아 던져주면, 프론트엔드는 이 상태를 해석해 경고 UI의 톤앤매너를 쉽게 바꿀 수 있습니다.

탈퇴는 '삭제'가 아닌 '운영 이벤트'다

실무에서 탈퇴 처리를 DELETE 쿼리로 구현하면 고객 응대 시 과거 상태를 추적할 수 없고, 악의적인 아이디 재사용을 막을 근거가 사라집니다.

@Transactional
public UserAccount withdraw(Long userId, String reason) {
    // 1. 역할(권한) 회수
    roleCatalogRepository.replaceUserRoles(userId, List.of()); 
    // 2. WITHDRAWN 상태 전이 및 사유 기록
    account.withdraw(normalizeReason(reason));
    userAccountRepository.updateStatus(account);
    // 3. 관리자 알림 이벤트 발행
    adminNotificationRepository.save(AdminNotification.withdrawal(account)); 
    return getRequired(userId);
}

탈퇴 로직은 철저히 상태 전이와 권한 회수, 그리고 관리자 알림 생성이라는 운영 이벤트로 처리했습니다.


3. 상태 해석의 격리와 단방향 흐름

프론트엔드 역시 컴포넌트 내부에 비즈니스 로직이 흩어지는 것을 막는 데 집중했습니다.

도메인 규칙을 순수 함수로 격리

export function canOpenDashboard(session) {
    return Boolean(session?.account?.active);
}

export function isWithdrawn(session) {
    return Boolean(session?.account?.withdrawn);
}

Svelte 컴포넌트 템플릿 안에서 {#if session?.account?.status === 'ACTIVE'} 와 같이 날것의 데이터를 직접 비교하지 않습니다.
프론트엔드 나름의 도메인 규칙을 순수 함수로 분리하여 컴포넌트는 오직 '무엇을 보여줄지'에만 집중하도록 렌더링을 단순화했습니다.

상태 조작은 Store로 위임

API 호출, 로딩 상태, 에러 핸들링, 플래시 메시지 제어 등의 비동기 흐름은 모두 Store가 전담합니다. 컴포넌트는 사용자 이벤트를 받아 Store의 submitRegistration() 이나 approveUser(id) 같은 의도만 트리거합니다.
덕분에 UI 코드와 비즈니스 흐름 코드가 완벽하게 분리되어 예측 가능한 상태 관리가 가능해졌니다.


4. 아키텍처는 책임의 분배다

이 프로젝트는 거창한 전술적 DDD 패턴을 맹목적으로 따르지 않았습니다.
영속성은 직관적인 제어가 가능한 MyBatis를 선택하되, 도메인 규칙과 영속성 구현을 분리한다는 본질만은 철저히 지켰습니다.

결국 좋은 인증/가입 시스템은 '어떤 OAuth 라이브러리를 썼는가'가 아니라, '인증 이후의 상태 변화와 운영 정책을 얼마나 응집력 있는 코드로 설명할 수 있는가'에 달려 있습니다.

초기 모델링 단계에서 상태와 권한을 깊게 고민해 둔 덕분에, 화면이 추가되고 정책이 복잡해져도 기존 코드를 허물지 않고 우아하게 확장할 수 있는 단단한 기반을 마련할 수 있었습니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글