[spring] redis 활용 회원가입시 이메일 인증 기능 구현

김영상 (dudtkd1221)·2023년 5월 1일
1

동아리 홈페이지에서 유효하지 않은 이메일 (a@abc.com)으로 가입된 사용자가 있는데 이는 나중에 비밀번호 찾기 등에서 문제가 생길 수 있기 때문에 회원가입시 이메일 인증 기능을 추가하였습니다.

추가된 api

  • /auth/send
  • /auth/verify

    @PostMapping("/auth/send")
    @ApiOperation(value = "인증 코드 발송", notes = "작성한 이메일로 인증코드 발송")
    public ApiResult<Boolean> sendVerificationCode(@RequestParam String userEmail) {
        return ApiResult.OK(userEmailVerifyService.sendVerificationCode(userEmail));
    }

    @PostMapping("/auth/verify")
    @ApiOperation(value = "이메일 검증", notes = "인증 코드 확인을 통한 이메일 검증")
    public ApiResult<Boolean> verifyEmail(@Valid @RequestBody VerificationCodeRequest verificationCodeRequest) {
        return ApiResult.OK(userEmailVerifyService.verifyEmail(verificationCodeRequest.getVerificationCode()));
    }

    

단순히 /auth/send 에 response에 인증코드를 전달하는 방법도 있지만 이는 클라이언트측에서 response값을 확인할 수 있기 때문에 유효하지 않은 이메일에 대한 검증 목적에 부합하지 않는다고 판단해 redis에 인증코드 값을 캐싱하기로 결정하게 되었습니다.

TTL

public class CacheKey {
        
     ...
        
    public static final int VERIFICATION_CODE_EXPIRE_SEC = 60 * 5; // 5 minutes

	public static final int TEMP_EMAIL_EXPIRE_SEC = 60 * 5; // 5 minutes
	
    ...
    
}

인증번호, 이메일에 대한 TTL값은 300sec로 설정하였습니다.

인증 기능 구현시 고려한 사항

  • 서비스내에 이미 존재하는 이메일에 대한 예외처리
  • 인증되지 않은 이메일로 가입요청할시에 대한 예외처리
  • 여러번 인증요청시 가장 마지막으로 발송된 인증번호로만 인증이 가능하도록 하기

인증번호 전송 서비스의 로직은 다음과 같습니다.

  • 적절한 이메일인지 검증
  • 이전에 발급된 인증코드 만료
  • 인증번호 생성
  • 발급된 인증번호 redis에 저장
  • 인증번호 이메일에 전송
    public Boolean sendVerificationCode(String userEmail) {

        if( isNotProperEmail(userEmail)){
            throw new CustomException(ErrorCode.VALID_CHECK_FAIL, userEmail);
        }

        expirePreviousVerificationCode(userEmail);

        String verificationCode = getVerificationCode();

        saveVerificationCode(verificationCode, userEmail);

        sendMail(userEmail, verificationCode);

        return true;
    }

적절한 이메일인지 검증

    @Transactional(readOnly = true)
    public boolean isNotProperEmail(String userEmail){
        if(userRepository.existsByEmail(userEmail)){
            throw new CustomException(ErrorCode.ALREADY_EXIST_EMAIL, userEmail);
        }
        return userEmail == null ||
                !(userEmail.contains("@gmail.com") || userEmail.contains("@naver.com"));
    }

간단히 userRepository.existByEmail()로 서비스 내에 해당 이메일로 가입된 유저가 있는지 체크하였고, 가입 가능한 이메일 형식은 gmail과 naver로 제한했습니다.

이전에 발급된 인증코드 만료

private void expirePreviousVerificationCode(String userEmail) {
        String verificationCode = redisUtil.getData(userEmail);
        if(verificationCode == null || verificationCode.isEmpty()){
            return;
        }
        //이전에 발급된 인증번호 삭제
        redisUtil.deleteData(userEmail);
        redisUtil.deleteData(verificationCode);
    }

가장 마지막으로 전송된 인증번호로만 인증이 가능하도록 하기 위해서 이전에 해당 이메일로 발급된 이전에 발급된 인증번호들은 만료되도록 하였습니다.
userEmail이 redis내에 key값으로 존재하면 userEmail과 verificationCode를 key값으로 가지는 값들을 삭제하였습니다.

인증번호 생성

    private String getVerificationCode(){
        Random random = new Random();

        StringBuilder code = new StringBuilder();
        for (int j = 0; j < 6; j++) {
            int randomInt = random.nextInt(36);
            char c = (randomInt < 10) ? (char)('0' + randomInt) : (char)('A' + randomInt - 10);
            code.append(c);
        }
        return code.toString();
    }

인증번호 redis에 저장

    private void saveVerificationCode(String verificationCode, String userEmail) {
        redisUtil.setDataExpire(verificationCode, userEmail, CacheKey.VERIFICATION_CODE_EXPIRE_SEC);
        redisUtil.setDataExpire(userEmail,verificationCode, CacheKey.VERIFICATION_CODE_EXPIRE_SEC);
    }

이전에 발급된 인증코드를 만료시키기 위해서 verificationCode, userEmail key, value 값을 양방향으로 저장했습니다.

인증번호 이메일에 전송

    private void sendMail(String userEmail, String verificationCode) {
        MimeMessage message = javaMailSender.createMimeMessage();
        try {
            message.addRecipients(Message.RecipientType.TO, userEmail);
            message.setSubject("SAMMaru 인증 코드");
            String htmlStr =
                    "<div>" +
                    "  <h3>SAMMaru</h3>\n" +
                    "  <div><p>다음 인증코드를 입력해주세요.</p><p>인증코드: <span style=\"color:blue\">"
                    + verificationCode +
                    "</span></p></div>" +
                    "</div>";
            message.setText(htmlStr,"UTF-8", "html");
            javaMailSender.send(message);
        } catch (Exception e ){
            e.printStackTrace();
        }
    }

인증번호 검증 서비스의 로직은 다음과 같습니다.

  • 유요한 인증번호인지 확인
  • 해당 인증번호 발급을 위해 사용된 이메일 임시로 인증
    public Boolean verifyEmail(String verificationCode) {
        if (!validateVerificationCode(verificationCode)){
            throw new CustomException(ErrorCode.INVALID_VERIFICATION_CODE);
        }
        saveTempVerifiedEmail(verificationCode);
        return true;
    }

유효한 인증번호 인지 확인

    private boolean validateVerificationCode(String verificationCode) {
        return redisUtil.hasKey(verificationCode);
    }

redis에 verficationCode가 key값이 존재하는지 체크하였습니다.

해당 인증번호 발급을 위해 사용된 이메일 임시로 인증

    private void saveTempVerifiedEmail(String verificationCode){
        String userEmail = redisUtil.getData(verificationCode);
        redisUtil.deleteData(userEmail);
        redisUtil.setDataExpire(userEmail+":auth", verificationCode, CacheKey.TEMP_EMAIL_EXPIRE_SEC);
    }

인증된 이메일로만 회원가입을 진행할 수 있도록 userEmail에 ":auth"라는 키워드를 붙여 redis에 다시 저장하였습니다.

회원가입 로직에 이메일 검증 추가

package com.sammaru5.sammaru.service.user;

@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class UserRegisterService {
     
     public UserDTO signUpUser(SignUpRequest signUpRequest) {
      
    	  ...
       
        if (!isValidEmail(signUpRequest.getEmail())){
            throw new CustomException(ErrorCode.INVALID_EMAIL, signUpRequest.getEmail());
        }

    }
    
  		  ...
  	  
    private boolean isValidEmail(String userEmail){

        if (!redisUtil.hasKey(userEmail+":auth")){
            return false;
        }
        redisUtil.deleteData(redisUtil.getData(userEmail+":auth"));
        redisUtil.deleteData(userEmail+":auth");
        return true;
    }
}

redis에 signUpRequest에서의 userEmail + ":auth" 키의 존재여부를 판별하는 로직을 추가함으로써 위에서 인증된 이메일로만 회원가입을 할 수 있도록 하였습니다.

느낀점

단순해 보였지만 내부에서 꽤나 많은 예외처리가 필요하다는 것을 느끼게 되었다.
특히 코드리뷰를 받으면서 생각하지 못했던 부분을 보완하게 되고 생각을 넓힐 수 있었던 것 같다.

보완할점

smtp 라이브러리 특성상 이메일 전송에 적지 않은 시간이 소요되는데
이는 사용자 입장에서 응답 시간이 길어져 꽤나 불편할 것 같다는 생각이 들었다.
추후에 이메일 전송 부분은 비동기로 처리하여 응답속도를 개선해야 할 것 같다.

profile
아직 배고프다

1개의 댓글

comment-user-thumbnail
2023년 5월 3일

나 한승헌이올시다.
잘 봤소이다.
나처럼 살지 마시오.

답글 달기