이메일 인증 회원가입 구현하기 (2): 이메일 인증 코드 & 인증코드 확인

    회원가입에서 만들고자 하는 Request는 총 4개이다.

     

    1. 이메일 인증코드 보내기

    2. 인증코드 확인

    3. 프로필 이미지 업로드

    4. 회원가입

     

    이 포스팅에서는 이메일 인증코드 보내기, 인증코드 확인까지 다룰 것이다.

     

    프로젝트 파일 구성


    1. Entity 클래스 작성

    1) Members

    package com.betweenourclothes.domain.members;
    
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import org.hibernate.annotations.DynamicInsert;
    
    import javax.persistence.*;
    
    @Getter
    @DynamicInsert // 기본값 설정을 위한 어노테이션
    @NoArgsConstructor
    @Entity
    @Table(name = "members") // DB에 테이블 이름이 members로 형성됨
    public class Members {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY) //auto_increment를 위한 설정
        private Long id;
    
        @Column(length=45, nullable=false)
        private String email;
    
        @Column(length=15, nullable=false)
        private String password;
    
        @Column(length=10, nullable=false)
        private String name;
    
        @Column(length=20, nullable=false)
        private String nickname;
    
        @Column(length=11, nullable=false)
        private String phone;
    
        @Column(columnDefinition = "varchar(300) default '이미지경로'")
        private String image;
    
        @Column(columnDefinition = "varchar(15) default 'USER'")
        private String role;
    
        @Builder
        public Members(String email, String password, String name, String nickname, String phone, String image){
            this.email = email;
            this.password = password;
            this.nickname = nickname;
            this.name = name;
            this.phone = phone;
            this.image = image;
        }
    }

    Members에는 사용자 정보를 저장한다.

    프로그램이 자동으로 부여하는 회원번호, 이메일, 비밀번호, 이름, 전화번호, 프로필 이미지, 역할이 존재한다.

    • 프로필 이미지는 경로로 저장한다. 사용자가 프로필 이미지를 설정하지 않는 경우를 대비해 default 이미지를 기본값으로 저장한다.
    • 역할은 일단 'USER' 하나라 기본값으로 설정했다. 하지만 관리자가 추가될 수 있기 때문에 enum을 만들어두었는데 db에 저장될 때는 문자열로 저장되게 만들었다.

     

     

    Default 값을 저장하는 방법은 다음과 같다.

    1. @Column(columnDefintion= "")을 이용해 테이블을 만들 때 해당 컬럼이 기본값을 지닌 필드라는 것을 알려준다.

    2. Entitiy 클래스에 @DynamicInsert를 붙인다. 이 어노테이션을 붙이면 Insert구문이 만들어질 때 null이 아닌 값들만 포함하게 된다.

    3. 데이터를 삽입할 때 값을 넘겨주지 않는다. 그러면 자동으로 null인 필드에 기본값이 채워지게 된다.

     

     

    2) Email

    package com.betweenourclothes.domain.members;
    
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    
    import javax.persistence.Column;
    import javax.persistence.Entity;
    import javax.persistence.Id;
    import javax.persistence.Table;
    
    @Getter
    @NoArgsConstructor
    @Entity
    @Table(name = "authentication")
    public class Email {
    
        @Id
        private String email;
        
        @Column(length=6)
        private String code;
    
        private String status;
    
        @Builder
        public Email(String email, String code, String status){
            this.email = email;
            this.code = code;
            this.status = status;
        }
    
        public void update(String status){
            this.status = status;
        }
    }

    Email에서는 이메일 인증코드를 받고 나서 회원가입이 완료될 때까지의 정보를 임시로 보관한다.

    사용자 이메일, 인증코드, 인증상태를 가지고 있다.

     

     

    Repository Mapping

    두 클래스의 객체는 Repository 인터페이스를 통해 DB와 연결된다.

    package com.betweenourclothes.domain.members;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    import java.util.Optional;
    
    public interface MembersRepository extends JpaRepository<Members, Long> { // 넣을 객체, ID 타입
    
        Optional<Members> findByEmail(String email);
        Optional<Members> findByNickname(String nickname);
    
    }
    package com.betweenourclothes.domain.members;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    import java.util.Optional;
    
    public interface EmailRepository extends JpaRepository<Email, String> {
        Optional<Email> findByEmail(String email);
    }
    

    Optional<T> 클래스는 T 객체를 한번 더 감싸는 wrapper 클래스로 NullPointerException을 방지가 가능하다.

    find메서드의 결과값이 없을 시 null이 리턴되는게 아니기 때문에 NullPointerException까지 이어질 가능성을 막아준다.

     

     


    2. AuthService

    package com.betweenourclothes.service.auth;
    
    import com.betweenourclothes.web.dto.AuthSignUpRequestDto;
    import com.betweenourclothes.web.dto.AuthEmailRequestDto;
    
    public interface AuthService {
        void sendMail(AuthEmailRequestDto receiver);
        void checkAuthCode(AuthEmailRequestDto receiver);
    }
    package com.betweenourclothes.service.auth;
    
    import com.betweenourclothes.domain.members.Email;
    import com.betweenourclothes.domain.members.EmailRepository;
    import com.betweenourclothes.domain.members.MembersRepository;
    import com.betweenourclothes.exception.ErrorCode;
    import com.betweenourclothes.exception.customException.*;
    import com.betweenourclothes.web.dto.AuthSignUpRequestDto;
    import com.betweenourclothes.web.dto.AuthEmailRequestDto;
    import lombok.RequiredArgsConstructor;
    import org.springframework.mail.javamail.JavaMailSender;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    import javax.mail.Message;
    import javax.mail.MessagingException;
    import javax.mail.internet.InternetAddress;
    import javax.mail.internet.MimeMessage;
    import java.io.UnsupportedEncodingException;
    
    @RequiredArgsConstructor
    @Service
    public class AuthServiceImpl implements AuthService{
        private final MembersRepository membersRepository;
        private final EmailRepository emailRepository;
        private final JavaMailSender sender;
    
        @Transactional
        @Override
        public void sendMail(AuthEmailRequestDto requestDto){
            if(membersRepository.findByEmail(requestDto.getEmail()).isPresent()){
                throw new DuplicatedDataException(ErrorCode.DUPLICATE_EMAIL);
            }
    
            MimeMessage message;
            try {
                message = createMessage(requestDto);
            }catch(Exception e){
                throw new MailMsgCreationException(ErrorCode.MAIL_MSG_CREATION_ERROR);
            }
    
            try{
                System.out.println(message);
                sender.send(message);
            }catch(Exception e){
                e.printStackTrace();
                throw new MailRequestException(ErrorCode.MAIL_REQUEST_ERROR);
            }
            emailRepository.save(requestDto.toEntity());
        }
    
        @Transactional
        @Override
        public void checkAuthCode(AuthEmailRequestDto receiver) {
            Email user = emailRepository.findByEmail(receiver.getEmail()).orElseThrow(()->new UserNotFoundException(ErrorCode.USER_NOT_FOUND));
            System.out.println("existed code: " + user.getCode());
            System.out.println("requested code: " + receiver.getCode());
            if(user.getCode().equals(receiver.getCode())){
                receiver.setStatusAccepted();
                user.update(receiver.getStatus());
                return;
            }
            throw new NotAuthenticatedException(ErrorCode.NOT_AUTHENTICATED);
        }
    
        private MimeMessage createMessage(AuthEmailRequestDto requestDto) throws MessagingException, UnsupportedEncodingException {
            MimeMessage msg = sender.createMimeMessage();
            msg.addRecipients(Message.RecipientType.TO, requestDto.getEmail());
            msg.setSubject("너와 내 옷 사이 회원가입 인증번호가 도착했습니다.");
    
            String body = "<div style='margin:100px;'>\n" +
                    "<h1> 안녕하세요. 너와 내 옷 사이입니다.</h1>\n" +
                    "<br>\n" +
                    "<p>아래 인증코드를 회원가입 창으로 돌아가 입력해주세요.<p>\n" +
                    "<p>감사합니다!<p>\n" +
                    "<br>\n" +
                    "<div align='center' style='border:1px solid black; font-family:verdana';>\n" +
                    "<br>\n" +
                    "<div style='font-size:130%'>인증코드 : \n" +
                    "<strong>"+ requestDto.getCode() +"\n" +
                    "</strong><div><br/></div>";
    
            msg.setText(body, "utf-8", "html");
            msg.setFrom(new InternetAddress("gunsong2@gmail.com", "너와 내 옷 사이"));
            return msg;
        }
    }

     

    1) sendMail

    @Transactional //db랑 연결되는 서비스는 Transactional을 붙여서 데이터를 보호한다
    @Override
    public void sendMail(AuthEmailRequestDto requestDto){
        // 1. 가입된 이메일이 있는지 체크
        if(membersRepository.findByEmail(requestDto.getEmail()).isPresent()){
            throw new DuplicatedDataException(ErrorCode.DUPLICATE_EMAIL);
        }
    
    	// 2. 인증코드 메시지 만들기
        MimeMessage message;
        try {
            message = createMessage(requestDto);
        }catch(Exception e){
            throw new MailMsgCreationException(ErrorCode.MAIL_MSG_CREATION_ERROR);
        }
        
        // 3. 전송
        try{
            //System.out.println(message);
            sender.send(message);
        }catch(Exception e){
            e.printStackTrace();
            throw new MailRequestException(ErrorCode.MAIL_REQUEST_ERROR);
        }
        
        // 4. 이메일-인증코드-상태 임시저장
        emailRepository.save(requestDto.toEntity());
    }

    가입된 이메일이 있는지 체크

    인증메일을 보내달라고 요청한 메일이 Members 테이블에 이미 존재하는지 확인하고,

    존재할 시 Exception을 던진다

     

    메일 전송

    메일 전송은 Spring에서 제공하는 API인 JavaMailSender를 이용했다.

    메일 전송을 위해서는 application.properties에 관련 조건들을 명시해주어야 한다. (이전 포스팅 참조)

     

    JavaMailSender는 MimeMessage를 정의해 본문이 HTML로 이루어진 메일을 발송할 수 있다.

     

     

    2) checkCode

    @Transactional
    @Override
    public void checkAuthCode(AuthEmailRequestDto receiver) {
        // 인증코드를 보낸 이메일이 있는지 확인
        Email user = emailRepository.findByEmail(receiver.getEmail()).orElseThrow(()->new UserNotFoundException(ErrorCode.USER_NOT_FOUND));
        //System.out.println("existed code: " + user.getCode());
        //System.out.println("requested code: " + receiver.getCode());
        
        // 코드가 일치한다면,
        if(user.getCode().equals(receiver.getCode())){
            receiver.setStatusAccepted();      // status 'y'로 업데이트
            user.update(receiver.getStatus()); // status 테이블에 업데이트
            return;
        }
        throw new NotAuthenticatedException(ErrorCode.NOT_AUTHENTICATED); //일치하지 않으면 Exception 던지기
    }

    3. Dto

    package com.betweenourclothes.web.dto;
    
    import com.betweenourclothes.domain.members.Email;
    import lombok.*;
    
    import javax.validation.constraints.NotBlank;
    import javax.validation.constraints.NotNull;
    import java.util.Random;
    
    @Getter
    public class AuthEmailRequestDto {
    
        @NotBlank(message = "이메일을 입력하세요.")
        @javax.validation.constraints.Email(message = "올바른 이메일 형식이 아닙니다.")
        private String email;
        @NotNull
        private String code;
        @NotNull
        private String status;
    
    
        public Email toEntity(){
            return Email.builder().email(email).code(code).status(status).build();
        }
    
        private String createAuthCode(){
            int leftLimit = 48;   //아스키코드 숫자0
            int rightLimit = 122; //아스키코드 영문자z
            int length = 6;       //인증코드 길이
    
            Random random = new Random();
            String key = random.ints(leftLimit, rightLimit+1)
                    .filter(i -> (i<=57 || i>=65) && (i <=90 || i>=97)) //영문자, 숫자가 아닌 값 제외
                    .limit(length)
                    .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
                    .toString();
    
            return key;
        }
    
    	//Status 업데이트를 위함
        private String booleanConverter(boolean value){
            if(value){
                return "Y";
            } else{
                return "N";
            }
        }
    	
        //Status 업데이트를 위함
        public void setStatusAccepted(){
            this.status = "Y";
        }
    
    	// 인증코드, 인증상태는 객체가 만들어질 때 생성
        public AuthEmailRequestDto(){
            this.code = createAuthCode();
            this.status = booleanConverter(false);
        }
    
        @Builder
        public AuthEmailRequestDto(String email){
            this.email = email;
            this.code = createAuthCode();
            this.status = booleanConverter(false);
        }
    
        @Builder
        public AuthEmailRequestDto(String email, String code){
            this.email = email;
            this.code = code;
            this.status = booleanConverter(false);
        }
    
    }

    4. AuthApiController

    package com.betweenourclothes.web;
    
    import com.betweenourclothes.exception.ErrorCode;
    import com.betweenourclothes.exception.customException.RequestFormatException;
    import com.betweenourclothes.service.auth.AuthServiceImpl;
    import com.betweenourclothes.web.dto.AuthEmailRequestDto;
    import com.betweenourclothes.web.dto.AuthSignUpRequestDto;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.*;
    import org.springframework.web.multipart.MultipartFile;
    
    import javax.validation.Valid;
    import java.io.File;
    import java.io.IOException;
    import java.util.UUID;
    
    @RequiredArgsConstructor
    @RestController
    @RequestMapping("/api/v1/auth")
    public class AuthApiController {
    
        private final AuthServiceImpl membersService;
    
        @PostMapping("/sign-up/email")
        public ResponseEntity<String> sendEmail(@RequestBody @Valid AuthEmailRequestDto requestDto) throws Exception{
            membersService.sendMail(requestDto);
            return new ResponseEntity<>("이메일 전송 성공", HttpStatus.OK);
        }
    
        @PostMapping("/sign-up/code")
        public ResponseEntity<String> checkAuthCode(@RequestBody @Valid AuthEmailRequestDto requestDto) throws Exception{
            membersService.checkAuthCode(requestDto);
            return new ResponseEntity<>("인증 성공", HttpStatus.OK);
        }
    }

    결과

    결과

     

    댓글