Spring/Spring Boot

[Spring Boot][KoLiving] 3-1 Sign-up

noahkim_ 2023. 9. 19. 00:49

안녕하세요. 이번에는 회원가입에 대해 포스팅하겠습니다.

 

저희 팀에서는 "인증 이메일을 통해 인증을 받고 회원가입을 진행시키자" 라는 의견이 모였습니다.

저는 이번에 처음으로 인증 이메일을 구현해 보았는데요. 처음 구현해보는 거라 시행착오를 많이 겪었습니다.

그 과정속에서 제가 어떻게 생각했고 구현해 나갔는지 공유해드리려 합니다

 

1. 이메일 인증

 

이메일 인증 Flow

1. 해당 이메일의 유효성 검사

2. 인증 이메일 전송

유저 테이블의 정규화가 필요합니다.

사용자가 인증 이메일을 받았지만 실제로 인증 절차를 진행하지 않은 경우가 발생할 수 있습니다.

 

ConfirmationToken 테이블

 

EMAIL 이메일 발송을 요청한 이메일
TOKEN 서버에서 인증에 대한 검증을 위해 저장합니다.
- UUID형
TOKEN_TYPE 인증 이메일 발송에 대한 타입을 의미합니다.
- 회원가입, 비밀번호 재설정 등
EXPIRED_DATE 발송한 이메일의 유효날짜를 기록합니다.

 

  • 인증 이메일 발송 정보를 저장합니다.
  • 인증 요청에 대한 정보가 행 테이블에 한 행으로 저장됩니다.

 

Flow

1. ConfirmationToken 테이블에 발송 기록 정보의 새로운 행을 추가합니다.

2. 인증 이메일을 발송합니다.

 

ApplicationEventPublisher
@Service
@Transactional
@RequiredArgsConstructor
public class AuthFacade {
    private final ApplicationEventPublisher eventPublisher;

    public void processEmailAuth(String email, ConfirmationTokenType tokenType) {
        ConfirmationToken newToken = confirmationTokenService.create(email, tokenType);
        ConfirmationToken savedToken = confirmationTokenService.save(newToken);
        eventPublisher.publishEvent(new ConfirmationTokenCreatedEvent(savedToken));
    }
}
  • 이메일 발송 비동기 처리
    • 사용자 반응성을 높이기 위해 비동기적으로 이메일을 발송합니다.
    • 이를 위해 이벤트를 발행합니다.

 

TransactionalPhase.AFTER_COMMIT
@Component
@RequiredArgsConstructor
public class EventListener {

    private final IConfirmationTokenService confirmationTokenService;

    @Async
    @TransactionalEventListener(
        classes = ConfirmationTokenCreatedEvent.class,
        phase = TransactionPhase.AFTER_COMMIT
    )
    public void onConfirmationTokenCreated(ConfirmationTokenCreatedEvent event) {
        confirmationTokenService.sendEmail(event.getEmail(), event.getToken(), event.getLinkPathResource());
    }
}
  • 트랜잭션 후, 이메일이 전송되도록 작업 순서를 보장합니다.

 

비동기 전송
  • 메인 트랜잭션과 격리
    • 비동기로 인증 이메일을 전송하기 때문에 새로운 스레드가 만들어집니다.
  • 미완료 상태 가능성 존재
    • 만약 인증 이메일의 전송이 실패하게 되면 발송되지 않은 정보가 ConfirmationToken에 존재하게 됩니다.
  • 해결방안
    • 메시징 큐
    • 분산 트랜잭션 사용(OutBox Pattern)

 

포인트는 동기 요청 vs 비동기 요청 입니다.

데이터의 일관성을 유지하기 위해 비동기 요청을 포기하는 것은 서비스 측면에서 좋지 않은 결정이라 생각했습니다.

테이블에 실제 전송되지 않은 인증 이메일 전송 행들이 있다 하더라도 유효기간이 있으므로 의사결정 하는데 큰 어려움은 없습니다.

또한 이메일 전송 실패시 로그가 쌓이고 모니터링 할 수 있으므로 고치는데 집중하면 된다 생각하였습니다.

 

예외 처리

  • 인증 이메일을 자신의 메일함에서 받아 인증 시도를 할 때 다양한 예외가 발생합니다.

 

인증 토큰관련 행이 ConfirmationToken 테이블에 존재하지 않는 경우
  • 악의적인 사람이 인증 토큰을 자신이 임의로 생성해 인증을 시도한다 볼 수 있습니다. 
  • 토큰은 이메일 발송을 요청하는 순간 테이블에 기록되기 때문입니다.

 

인증 토큰이 만료되었을 때
  • EXPIRED_AT 컬럼에 적힌 값까지 데이터가 유효합니다.

 

이미 인증 토큰으로 사용되었으나 중복 요청한 경우
  • 이미 인증에 사용되어 비밀번호 설정, 프로필 설정 화면까지 넘어갔는데 또 다시 인증 요청하는 경우입니다.
  • 가장 최근에 진행한 단계로 리다이렉 해주도록 결정하였습니다.

 

SignUpStatus
@Getter
public enum SignUpStatus {

    PASSWORD_VERIFICATION_PENDING("/signup/step2"),
    PROFILE_INFORMATION_PENDING("/signup/step3"),
    COMPLETED("/login");

    private final String redirectUrl;

    SignUpStatus(String redirectUrl) {
        this.redirectUrl = redirectUrl;
    }
}
  • 회원가입 진행 상황을 저장하기 위해 인스턴스 필드를 저장하였습니다.
  • 회원가입의 다음단계로 이동할 때마다 업데이트 됩니다.

 

ConfirmationTokenException (사용자 정의 런타임 예외)
@ExceptionHandler(value = ConfirmationTokenException.class)
public ResponseEntity<ResponseDto<ConfirmationTokenErrorDto>> handleAuthException(ConfirmationTokenException e, Locale locale) {
    String messageKey = e.getMessage();
    String errorMessage = messageSource.getMessage(messageKey, null, locale);

    HttpStatus status = null;
    String email = e.getEmail(), redirectPath = null;
    switch (messageKey) {
        case "ungenerated_confirmation_token", "expired_confirmation_token" -> {
            status = HttpStatus.BAD_REQUEST;
            redirectPath = getRedirectUrl("/login");
        }
        case "authenticated_confirmation_token" -> {
            status = HttpStatus.UNAUTHORIZED;
            ConfirmationToken confirmationToken = confirmationTokenService.get(e.getToken()).get();
            redirectPath = getConfirmationTokenRedirectUrl(confirmationToken.getTokenType(), email);
        }
    }

	// ...
}
  • 인증 이메일 발송 관련 예외는 'ConfirmationTokenException' 로 포장해서 던집니다.
  • 전역적으로 예외처리가 가능하며 가독성 측면에서 좋기 때문에 선택하였습니다.

 

인증 이메일을 성공하게 되면 패스워드 설정 화면으로 넘어감과 동시에 유저 테이블에 신규 유저데이터 행이 추가됩니다.

 

 

참고