본문 바로가기

스프링

[Spring] 이메일 인증 구현

시작하기에 앞서

우리는 어떤 웹 서비스를 이용하기 위해 로그인을 하지 않고 이용하기도 하지만 로그인을 하고 이용해야하는 부분도 존재한다.

그에 따라, 우리는 해당 서비스를 이용하고자 할 때 회원가입을 진행한다.

회원가입을 진행할 때, 우리는 이메일 인증을 마주하곤 한다.

이메일 인증을 하는 방법은 크게 두 가지로 나누어진다.

  • 이메일을 입력하고 해당 이메일에 인증 코드를 보내고 인증 코드를 입력한 뒤 가입하고 이용하는 방식
  • 회원가입을 모두 진행한 뒤, 이메일에 전송된 링크에 접속할 시 로그인 제한이 풀려 이용가능한 방식

이번 포스트에서는 후자의 방법을 구현해보려고 한다.

이번 구현은 이전에 JWT를 구현했던 부분을 이어서 진행한다.

다만, 이전에 했던 부분과 겹치는 내용이 존재하지 않기에 이번 내용만 다뤄도 충분할 것 같다.

개발 환경

이메일 인증을 구현한 환경은 다음과 같다.

  • Spring Boot : 2.5.6
  • Spring Security : 5.6.0
  • Gradle : 7.2
  • Mysql : 8.0
  • Querydsl : 4.4.0

이메일 계정 설정

우리가 이메일 인증을 한다는 것 자체가 어떤 계정으로 이메일을 대신 전송해준다는 것이다.

이 때, 우리는 SMTP(Simple Mail Transfer Protocol)을 사용한다.

SMTP는 간이 전자 우편 전송 프로토콜의 약자로 인터넷에서 이메일을 보내기 위해 이용되는 프로토콜이다.

우리는 그 중 구글에서 제공하는 Gmail SMTP를 사용할 것이다.

먼저, 우리가 Spring Boot에서 해당 부분을 이용하기 위해 의존성을 추가해야한다.

implementation 'org.springframework.boot:spring-boot-starter-web'

추가했다면 이메일 설정을 해보자.

우리가 실제로 Gmail SMTP를 이용해서 이메일을 전송할텐데 이메일을 전송할 "계정"을 설정해야한다.

먼저, SMTP용 계정 세팅을 진행해야한다.

보안 수준이 낮은 앱의 액세스 (google.com)에 접속 후 액세스 허용을 해서 SMTP가 가능하도록 설정해주어야 한다.

그리고 yml파일에 해당 Spring에서 해당 아이디를 저장해두고 SMTP 관련 설정을 진행해야한다.

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: Google ID
    password: Google PW
    properties:
      mail:
        smtp:
          starttls:
            enable: true
            required: true
          auth: true
          connectiontimeout: 5000
          timeout: 5000
          writetimeout: 5000

다음과 같이 설정을 진행했다면 실제로 이메일을 보내보자.

이메일 인증

이메일을 전송하는 것을 살펴보기 전에 회원가입이 어떻게 이루어지는지 정확히 살펴보고 그 뒤에 이메일 인증을 구현해보자.

우리는 회원가입을 진행하는데 있어 아이디와 비밀번호를 입력한다.

해당 아이디와 비밀번호를 입력하고 회원가입을 진행한다면, 가입된 이메일로 인증 링크를 포함한 메일을 보낸다.

사용자는 가입한 이메일에 들어가 해당 링크를 키게 되면 인증이 완료되어 로그인이 필요한 서비스를 사용할 수 있다.

지금 위에서 언급한 부분들은 사용자입장에서의 로직이다.

이제 우리 개발자입장에서 바라보자.

대부분 비슷하다. 하지만, 링크에 대한 부분을 자세하게 살펴보자.

우리가 링크를 통해 어떻게 인증이 되는 것일까?

먼저, 회원가입을 진행하면 일단 DB에 저장은 시키고 인증이 되었는지 안되었는지에 대한 상태를 저장해두어 이를 통해 구분해준다.

링크를 들어가게 되면 해당 상태를 인증이 된 상태(true)로 바꾸어주면 된다.

하지만 어떤 사용자에 대한 인증인지 구분하기 위해 링크에 아이디를 파라미터로 담아 보낸다.

하지만, 아이디는 공개된 정보이기에 누구나 어떤 누구든 아이디만 알고 시간만 얼추 맞다면 가능할지도 모른다.

그러기에, 파라미터로 해당 사용자에 대해 식별할 수 있는 식별자를 넣으려고 했다.

이 부분을 UUID로 하여 사용자에게 유일하고 비밀스러운 링크를 제공하도록 하였다.

또한, 이러한 링크는 만료 시간(5분)이 존재해야한다. 이 부분 또한 다루어야 한다.

지금까지 언급한 부분을 코드로 구현하면 된다.

먼저, 파라미터로 유저 이메일, UUID 값이 들어가고 만료기간이 필요하다.

이 부분을 검증해주기 위해 DB에 미리 저장하여 링크의 파라미터 값과 비교하여 이를 처리해주면 된다.

글을 잘 못써서 이해를 못할 수도 있지만 코드를 보면 느낌이 올 것이다.

먼저, Entity들을 확인해보자.

Entity

Member

@Entity
@Getter
@Table(name = "MEMBERS")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String email;
    private String password;
    private String provider;
    private String refreshToken;
    private Boolean emailAuth;

    @ElementCollection(fetch = FetchType.LAZY)
    @Enumerated(EnumType.STRING)
    private List<Role> roles = new ArrayList<>();

    @Builder
    public Member(String email, String password, String provider, List<Role> roles, Boolean emailAuth) {
        this.email = email;
        this.password = password;
        this.provider = provider;
        this.roles = Collections.singletonList(Role.ROLE_MEMBER);
        this.emailAuth = emailAuth;
    }

    public void addRole(Role role) {
        this.roles.add(role);
    }

    public void updateRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }

    public void emailVerifiedSuccess() {
        this.emailAuth = true;
    }
}
  • 이메일
  • 패스워드
  • Provider(소셜 로그인)
  • RefreshToken(JWT)
  • emailAuth : 이메일 인증을 진행했는지에 대한 여부
  • emailVerifiedSuccess : 이메일 인증을 진행해주는 메소드

EmailAuth

이메일 인증 정보를 담고 있는 엔티티

아까 언급한 가입 이메일, Token(UUID), 만료시간, 만료된 토큰인지에 대한 구분을 진행한다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class EmailAuth {

    private static final Long MAX_EXPIRE_TIME = 5L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;
    private String authToken;
    private Boolean expired;
    private LocalDateTime expireDate;

    @Builder
    public EmailAuth(String email, String authToken, Boolean expired) {
        this.email = email;
        this.authToken = authToken;
        this.expired = expired;
        this.expireDate = LocalDateTime.now().plusMinutes(MAX_EXPIRE_TIME);
    }

    public void useToken() {
        this.expired = true;
    }
}
  • email : 가입 이메일
  • authToken : UUID
  • expired : 만료 여부
  • expireDate : 만료시간
  • 생성자에서 생성한 시간 후로 5분을 설정한 모습을 확인할 수 있다.

Repository

우리가 인증을 진행하기 위해 파라미터로 전송된 값과 DB에 실제 저장된 값을 비교해야한다.

그러기 위해서 DB에 정상적인 값을 꺼내와 비교해야한다.

이를 위해 QueryDsl을 활용하였다.

public interface EmailAuthCustomRepository {
    Optional<EmailAuth> findValidAuthByEmail(String email, String authToken, LocalDateTime currentTime);
}
public class EmailAuthCustomRepositoryImpl implements EmailAuthCustomRepository{

    JPAQueryFactory jpaQueryFactory;

    public EmailAuthCustomRepositoryImpl(EntityManager em) {
        this.jpaQueryFactory = new JPAQueryFactory(em);
    }

    public Optional<EmailAuth> findValidAuthByEmail(String email, String authToken, LocalDateTime currentTime) {
        EmailAuth emailAuth = jpaQueryFactory
                .selectFrom(QEmailAuth.emailAuth)
                .where(QEmailAuth.emailAuth.email.eq(email),
                        QEmailAuth.emailAuth.authToken.eq(authToken),
                        QEmailAuth.emailAuth.expireDate.goe(currentTime),
                        QEmailAuth.emailAuth.expired.eq(false))
                .fetchFirst();

        return Optional.ofNullable(emailAuth);
    }
}

우리는 UUID값과 email값이 일치하는 인증코드를 찾아봐야한다.

그리고 해당 코드는 만료되지 않고 만료시간이 지나지 않은 코드를 이용해야할 것이다.

Service

이제 이메일을 실제 전송하는 서비스와 이메일 인증을 검증하는 서비스를 살펴보자.

먼저, 이메일을 실제 전송하는 서비스이다.

@Service
@EnableAsync
@RequiredArgsConstructor
public class EmailService {
    private final JavaMailSender javaMailSender;

    @Async
    public void send(String email, String authToken) {
        SimpleMailMessage smm = new SimpleMailMessage();
        smm.setTo(email+"@gmail.com");
        smm.setSubject("회원가입 이메일 인증");
        smm.setText("http://localhost:8080/sign/confirm-email?email="+email+"&authToken="+authToken);

        javaMailSender.send(smm);
    }
}

우리가 이메일을 전송하기 위해 이메일 구성을 SimpleMailMessage를 통해 쉽게 구성할 수 있다.

setTo를 통해 누구에게 보낼지, setSubject를 통해 제목을, setText를 통해 내용을 작성할 수 있다.

이 부분에서 내용은 단순히 링크로 설정했다.

그리고 JavaMailSender를 통해 이전에 설정했던 부분으로 메일을 실제로 보내게 된다.

여기서 비동기로 진행한 이유는 메일을 보내는데 우리가 전송하는 동안 기다리게 되어 그 동안 블럭 상태에 놓이게 된다.

이 부분을 방지하고자 비동기 처리하였다.

실제 회원가입하고 인증받는 로직이다.

@Transactional
public MemberRegisterResponseDto registerMember(MemberRegisterRequestDto requestDto) {
    validateDuplicated(requestDto.getEmail());
    EmailAuth emailAuth = emailAuthRepository.save(
            EmailAuth.builder()
                    .email(requestDto.getEmail())
                    .authToken(UUID.randomUUID().toString())
                    .expired(false)
                    .build());

    Member member = memberRepository.save(
            Member.builder()
                    .email(requestDto.getEmail())
                    .password(passwordEncoder.encode(requestDto.getPassword()))
                    .provider(null)
                    .emailAuth(false)
                    .build());

    emailService.send(emailAuth.getEmail(), emailAuth.getAuthToken());
    return MemberRegisterResponseDto.builder()
            .id(member.getId())
            .email(member.getEmail())
            .authToken(emailAuth.getAuthToken())
            .build();
}
public void validateDuplicated(String email) {
    if (memberRepository.findByEmail(email).isPresent())
        throw new MemberEmailAlreadyExistsException();
}

회원가입을 진행한다면 온 아이디로 중복이 되어있는지 확인한다.

중복이 되어있지 않다면, 이메일인증토큰을 만들고 회원을 만들어 DB에 저장한 뒤, 아까 구성한 이메일 전송 서비스로 실제 이메일을 보낸다.

실제 인증을 하는 로직이다.

@Transactional
public void confirmEmail(EmailAuthRequestDto requestDto) {
    EmailAuth emailAuth = emailAuthRepository.findValidAuthByEmail(requestDto.getEmail(), requestDto.getAuthToken(), LocalDateTime.now())
            .orElseThrow(EmailAuthTokenNotFountException::new);
    Member member = memberRepository.findByEmail(requestDto.getEmail()).orElseThrow(MemberNotFoundException::new);
    emailAuth.useToken();
    member.emailVerifiedSuccess();
}

먼저, 인증이 가능한 토큰값을 불러오고 인증가능한 토큰이 없다면 Exception을 내던져 다시 인증메일을 받고 인증할 수 있도록 한다.

인증가능한 토큰이 존재한다면, 해당 계정을 찾아 인증한 계정으로 변경하고 사용한 토큰을 만료시킨다.

실제 컨트롤러를 구성해보자.

Controller

컨트롤러는 다음과 같이 단순하게 진행했다.

@ApiOperation(value = "회원가입", notes = "회원가입을 진행한다.")
@PostMapping("/register")
public SingleResult<MemberRegisterResponseDto> register(@RequestBody MemberRegisterRequestDto requestDto) {
    MemberRegisterResponseDto responseDto = signService.registerMember(requestDto);
    return responseService.getSingleResult(responseDto);
}

@ApiOperation(value = "이메일 인증", notes = "이메일 인증을 진행한다.")
@GetMapping("/confirm-email")
public SingleResult<String> confirmEmail(@ModelAttribute EmailAuthRequestDto requestDto) {
    signService.confirmEmail(requestDto);
    return responseService.getSingleResult("인증이 완료되었습니다.");
}

Test

먼저 회원가입을 진행한다.

다음과 같이 이메일이 오게된다.

이메일을 인증하지 않은 상태에서 로그인을 진행하면 다음과 같다.

이메일에 왔던 링크에 접속해 실제 인증을 진행해보자.

이제 다시 로그인을 진행해보자.

로그인이 잘된 모습을 확인할 수 있다.

마무리

현재, 회원가입을 위한 로그인만 지정되어있고 ID/PW 찾기와 같은 다른 부분에 대한 인증은 구현되어 있지 않다.

하지만, 이 부분에 대한 처리도 비슷하게 진행한다면 충분히 구현할 수 있을 것이다.

그리고 이 부분은 REST API에 대한 구현을 진행한 부분으로 프론트 측면에서도 구현해야할 부분이 상당수 존재한다.

이 부분에 대해서는 위 포스트에서 다루지 않았고 추후에 진행해보려고 한다.

깃허브

SangHyunGil/SpringSecurityJWT-Local-OAuth2- (github.com)