ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 📬JPA, Spring Mail 사용해 이메일 인증 기능 구현하기 (+SMTP,IMAP)
    CS/Spring 2024. 6. 29. 00:12

    SMTP (Simple Mail Transfer Protocol)

    이메일을 주고받을 때에는 세개의 주요 구성 요소가 있다

    • user agents : 메일을 작성하거나 읽는 것을 수행 (간단히 말해 우리가 매일 사용하는 gmail, naver mail 이라고 생각하면 된다.)
    • mail server : 메세지를 agents로부터 받고, 전달하는 역할을 한다. 
    • SMTP : 메일 client와 메일 server 사이에 메세지를 주고받기 위한 프로토콜

    SMTP 특징

    • 이메일을 주고받을 때에는 신뢰성 있는 전달이 필수이기 때문에 TCP를 사용한다. 
    • 송신자 agent와 송신자 mail server 사이, 송신자 mail server와 수신자 mail server 사이에서 메일을 주고받을 때 사용된다
    • push protocol
    💡HTTP vs SMTP
    HTTP는 서버로부터 무언가를 받기(pull) 위한 프로토콜이다. TCP 통신에서 연결은 파일을 수신하는 컴퓨터에서 초기화하게 된다.
    반면 SMTP는 서버로 무언가를 보내기(push) 위한 프로토콜이다. 파일을 보내는 측에서 TCP 연결을 초기화한다.

     

    이메일 송/수신 과정

     

    이메일 송신 과정

    1. sender agent가 SMTP를 통해 sender 측의 mail server에 메세지 A를 보낸다. 
    2. 메세지 A가 mail server의 message queue에 들어간다.
    3. message queue에 쌓여있는 메세지들이 다 처리되고 A 차례가 오면, SMTP를 통해 수신측 mail server의 mailbox에 저장된다. 

     이메일 수신 과정

    1. receiver agent가 receiver 측의 mail server에게 메세지 접근을 요청한다. 이때 사용되는 프로토콜은 IMAP, POP, HTTP(웹 메일)
    2. mail server이 요청을 받으면 mailbox에 저장된 메세지를 담아 응답한다.
    💡ICMP (Internet Mail Access Protocol)
    인터넷 메시지 액세스 프로토콜(IMAP)은 이메일을 받기 위한 프로토콜이다. POP의 단점을 보완하기 위해 나온 프로토콜로, IMAP의 주요 기능은 사용자가 모든 장치에서 이메일에 액세스할 수 있게 해준다는 것이다. 서버에 이메일이 저장되며 자동으로 삭제되지 않는다. 
    vs POP (Post Office Protocol)
    서버에서 로컬 장치로 이메일을 다운로드하는 이메일을 수신하기 위한 프로토콜. POP을 사용하면 이메일이 로컬에 저장된 다음 이메일 서버에서 삭제되기 때문에 수신자가 다른 장치에서 다시 액세스할 수 없다.

    출처 :[https://www.cloudflare.com/ko-kr/learning/email-security/what-is-imap/]
    💡HTTP 웹메일
    이메일 클라이언트가 웹 인터페이스를 통해 이메일을 액세스할 때는 HTTP가 사용된다. 예를 들어, Gmail, 네이버 메일 등과 같은 웹 기반 이메일 서비스는 웹 브라우저를 통해 HTTP/HTTPS를 사용하여 이메일을 조회한다. 

     

    Spring Mail

    이메일 전송을 위한 설정과 코드를 간편하게 구성할 수 있도록 다양한 기능을 제공하는 모듈.

     

    이메일 인증

    이메일 인증 과정

    1. 사용자가 회원가입 시도 시, uuid같은 고유 번호를 발급
    2. db의 사용자 테이블에 사용자 정보를 저장한다. 이때 이메일 인증 여부 정보도 같이 저장한다.
    3. uuid를 저장하는 테이블에 사용자 이메일과 함께 uuid를 저장한다.
    4. 사용자의 가입 이메일로 uuid를 포함한 인증 요청 url을 보낸다.
    5. 사용자가 인증 요청 url로 들어오면, url로 요청한 uuid와 db에 저장해놓은 uuid와 비교해 같을 경우 인증 절차를 마친다. 

    1 ) 구글 계정 설정 

    구글 메일 서버의 SMTP를 사용하기 위해 설정이 필요하다.

    1. gmail 설정 - 모든 설정 보기 - 전달 및 POP/IMAP - IMAP 사용 상태를 'IMAP 사용함'으로 바꾸기
    2. google 계정 설정 - 보안 - 2단계 인증 설정 - 2단계 인증 사용(전화번호) 
    3. google 계정 설정 - "앱 비밀번호" 검색 - 앱 이름 입력 - 만들기 - 발급되는 앱 비밀번호 '바로' 복사해놓기

    2 ) NGINX 설정

    사용자가 uuid로 인증을 시도할 때 백엔드 서버에 직접 접근하지 않고 클라이언트 서버를 사용하도록 하기 위해 NGINX를 사용할 것이다.

    1. nginx 설치  brew istall nginx
    2. nginx 설정파일 열기 sudo nano /opt/homebrew/etc/nginx/nginx.conf (또는 직접 파일 위치 찾아가서 vscode로 열어도 된다.)
    3. 아래와 같이 설정 파일 수정 (server { 아래 부분)
    listen 80; #spring 서버가 8080을 쓰기 때문에 포트번호를 80로 바꾼다.

    location /api { # '/api'로 들어오는 모든 요청에 대해
        rewrite ^/api(.*)$ $1 break;
    # 요청 url에서 '/api'부터 앞에 내용을 없애는 정규 표현식 ex) http://localhost/api/v1/v2-> /v1/v2
        proxy_pass http://localhost:8080/;
    # 재작성된 URI를 백엔드 서버(http://localhost:8080/)로 프록시 처리(전달) -> http://localhost:8080/v1/v2로 프록시 요청
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
    }

     

    3 ) 라이브러리 추가  (build.gradle 파일 설정)

    spring-boot를 사용하기 위해 추가해야하는 기본 라이브러리

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

     

    jpa 사용하기 위한 라이브러리

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.mariadb.jdbc:mariadb-java-client'

     

    lombok (필수x)

    compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30'
    annotationProcessor('org.projectlombok:lombok')

     

    spring javaMailSender을 사용하기 위한 라이브러리

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

     

    4 ) DB 서버 구축

    5 ) yml 파일 설정 

    • yml 파일은 들여쓰기가 문법적으로 중요하기 때문에 들여쓰기 잘 지키기 (탭 하나당 띄어쓰기 두번)
    • 초록색 부분은 개인정보라 다른 사람이 보면 안되기 때문에 .env 파일에 작성하거나 configuration 환경변수 설정값에 넣어서 사용하기
    spring:
      datasource:
        url: ${DB_URL}
        username: ${DB_USERNAME}
        password: ${DB_PASSWORD}
        driver-class-name: org.mariadb.jdbc.Driver
      jpa :
        database-platform: org.hibernate.dialect.MariaDBDialect
        hibernate:
          ddl-auto: create
        properties:
          hibernate:
            format_sql: true
      mail:
        host: smtp.gmail.com
        port: 587
        username: ${yourGoogleEmail@gmail.com}
        password: ${앱 비밀번호}
        properties:
          mail:
            smtp:
              starttls:
                enable: true
                required: true
              auth: true
              connectiontimeout: 5000
              timeout: 5000
              writetimeout: 5000 

     

    6 ) 코드 작성

    깃허브 주소

     

     

    Entity 클래스

    package com.example.myProject.member.model;
    
    @Entity
    @Builder //엔티티 생성을 위해
    @Getter  //멤버 정보를 가져오기 위해
    @AllArgsConstructor
    @NoArgsConstructor
    public class Member {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        Long id;
        String email;
        String password;
        Boolean active; // 이메일 인증 여부를 저장하는 컬럼
    }
    package com.example.myProject.verify.model;
    
    @Entity
    @Builder //엔티티 생성을 위해
    @NoArgsConstructor
    @AllArgsConstructor
    public class EmailVerify {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        Long id;
        String email;
        String uuid; // 사용자에게 발급한 uuid를 저장하는 컬럼
    }

     

    Controller

    package com.example.day06_loginPrac.member;
    
    @RestController
    @RequestMapping("/member")
    @RequiredArgsConstructor //의존성 주입을 위해
    public class MemberController {
        private final MemberService memberService;
        private final EmailVerityService emailVerityService;
    
        @RequestMapping(method = RequestMethod.POST, value = "/signup") // 회원가입 uri
        public ResponseEntity<String> signup(String email, String password){
        
            //uuid 발급해서 이메일 전송 후 uuid 반환받기
            String uuid = memberService.sendEmail(email);
    
            //회원 정보 저장 (아직 인증이 안들어왔기 때문에 active=false로 저장)
            memberService.signup(email,password);
    
            //회원 이메일과 uuid를 db에 저장
            emailVerityService.create(email, uuid);
    
    
            return ResponseEntity.ok("회원 정보 저장 성공. 이메일 인증 필요");
        }
    
        @RequestMapping(method = RequestMethod.GET, value = "/verify") // 이메일 인증 요청 uri
        public ResponseEntity<String> checkUuid(String email, String uuid){
        
            //요청 정보로 들어온 uuid와 db에 저장된 uuid를 비교
            String result = emailVerityService.checkUuid(email, uuid);
            
            return ResponseEntity.ok(result);
        }
    
    
    }

     

    Service

    package com.example.day06_loginPrac.member;
    
    @Service
    @RequiredArgsConstructor //의존성 주입을 위해 필요
    public class MemberService {
        private final MemberRepository memberRepository;
        private final BCryptPasswordEncoder bCryptPasswordEncoder;
        private final JavaMailSender emailSender; //mimeMessage를 생성하거나 메세지를 전송하기 위한 인터페이스 클래스
    
        //회원가입 메서드 - 단순 회원 정보 저장(이메일 인증하기 전)
        public void signup(String email, String password){
        
            //멤버 엔티티 생성
            Member member = Member.builder()
                    .email(email)
                    .password(bCryptPasswordEncoder.encode(password)) //비밀번호 암호화
                    .active(false) //이메일 인증 전이므로 false로 설정
                    .build();
                    
            //db에 member Entity 저장
            memberRepository.save(member);
        }
    
        //이메일 전송하는 메서드
        public String sendEmail(String email) {
            
            //이메일 메세지를 구성하기 위한 클래스
            SimpleMailMessage message = new SimpleMailMessage();
            
            message.setTo(email); //메세지를 보낼 곳
            message.setSubject("[내사이트] 가입 환영"); //보낼 메세지의 제목
            
            //uuid 생성
            String uuid = UUID.randomUUID().toString();
    
            //메세지 본문 작성
            message.setText("http://localhost/api/member/verify?email="+email+"&uuid="+uuid);
    
            //메세지 보내기
            emailSender.send(message);
    
            return uuid;
        }
    }
    package com.example.day06_loginPrac.verify;
    
    @Service
    @RequiredArgsConstructor //의존성 주입을 위해
    public class EmailVerityService {
        private final EmailVerifyRepository emailVerifyRepository;
        private final MemberRepository memberRepository;
    
        public String checkUuid(String email, String uuid){
        
            //요청 정보로 들어온 email과 uuid가 일치하는 엔티티 객체 가져오기
            Optional<EmailVerify> result =  emailVerifyRepository.findByEmailAndUuid(email, uuid);
    
            if(result.isPresent()){ //일치하는 튜플이 있을 경우
            
                //이메일 정보 이용해 사용자 엔티티 객체 가져오기
                Optional<Member> res = memberRepository.findByEmail(email);
                Member member = res.get();
                
                //가져온 사용자 정보로 새로운 맴버 객체 생성 
                Member newMember = Member.builder()
                        .id(member.getId())
                        .email(member.getEmail())
                        .password(member.getPassword())
                        .active(true) //이메일 인증 정보를 true로 
                        .build();
                        
                //사용자 정보 업데이트 (왜 save 메서드를 사용하는지 추후 설명)
                memberRepository.save(newMember);
                
            }else{ //일치하는 튜플이 없을 경우
                return "이메일 인증에 실패했습니다.";
            }
            
            return "이메일 인증에 성공했습니다.";
        }
    
       
        public void create(String email, String uuid) {
            EmailVerify emailVerify = EmailVerify.builder()
                    .email(email)
                    .uuid(uuid)
                    .build();
    
            emailVerifyRepository.save(emailVerify);
        }
    }

     

    Repository

    package com.example.day06_loginPrac.member;
    
    public interface MemberRepository extends JpaRepository<Member, Long> {
        public Optional<Member> findByEmail(String email);
    }
    package com.example.day06_loginPrac.verify;
    
    public interface EmailVerifyRepository extends JpaRepository<EmailVerify, Long> {
        Optional<EmailVerify> findByEmailAndUuid(String email, String uuid);
    }

     

    🤔 checkUuid에서 멤버 빌더 생성해서 저장할 때, 나머지 값들도 다 작성해야하는 이유

    //가져온 사용자 정보로 새로운 맴버 객체 생성 
    Member newMember = Member.builder()
    	.id(member.getId())
    	.email(member.getEmail())
    	.password(member.getPassword())
    	.active(true) //이메일 인증 정보를 true로 
    	.build();
                        
    //사용자 정보 업데이트 (왜 save 메서드를 사용하는지 추후 설명)
    memberRepository.save(newMember);

     

    이 부분에서, 처음에는 newMember에 id와 변경할 값인 active만 넣어서 저장하면 된다고 생각했다. 

    하지만 그렇게 할 경우 save가 되지 않았다.

    그 이유는, builder의 경우 값을 안써주면 기존 값이 유지되는 것이 아니라 null값으로 들어가는데, (여기서는 안했지만)이메일과 패스워드부분을 not null 처리해놨기 때문에, email과 password를 안적고 save를 할 경우 null 값이 들어가서 에러가 나는 것이다. 

    만약 저렇게 안하고싶으면 setter을 쓰는 방법이 있다.

    🤔 이메일 인증 후 사용자 정보를 업데이트할 때 save 메서드

    https://shinebyul.tistory.com/57

     

    JPA - save 메서드에 대하여🤯 (엔티티 생명주기, isNew(), @GeneratedValue, persist(),merge())

    save메서드를 보기 전에 Entity 생명주기에 대해 알아보자Entity 생명주기비영속 상태 (Transient) : 엔티티가 새로 생성되었지만 아직 영속성 컨텍스트에 추가되지 않은 상태. 데이터베이스와 관련 x영

    shinebyul.tistory.com

    1. save 메서드를 실행하면 isNew() 메서드를 통해 기존 엔티티인지 새로운 엔티티인지 확인
    2. 기존 member의 id가 1이라고 할 때 member는 1차 캐시에 저장돼있고, newMember 또한 id가 1이기 때문에 isNew()메서드는 newMember가 기존 엔티티라고 판단한다.
    3. 따라서 merge() 메서드가 실행이되고, 기존 엔티티가 영속상태(1차 캐시에 저장)이기 때문에 해당 영속 상태의 엔티티의 내용을 newMember의 내용으로 업데이트한다.
Designed by Tistory.