My Project

Flutter로컬커머스) 인증메일 발송기능 구현

조충희 2025. 9. 1. 17:06

SMTP 를 통한 인증메일 발송기능

신규 회원가입 시 사용할 이메일 인증 코드 발송 및 검증 기능을 구현했다.
또한 이 기능을 기존 회원가입 로직과 안전하게 통합했다.


이메일 발송 시나리오

◎ 등장인물 (기술 요소)

  1. 계정 담당: MemberAuthService 파일. 고객의 요청을 받아 편지 발송을 결정한다.
  2. 우편 담당: EmailVerificationService 파일. 계정 담당의 요청을 받아 편지 내용물을 작성하고 발송을 준비한다.
  3. 배달부: JavaMailSender 객체. 우편 담당이 준비한 편지를 받아 외부 우체국으로 배달한다.
  4. 배달 매뉴얼: application.yml의 spring.mail 설정. 우체국에 방문할 때 필요한 우체국 주소와 출입증
  5. 구글 우체국: Gmail 의 SMTP 서버. 편지를 안전하게 받아 다른 곳으로 전달한다.

◎ 이메일 발송 과정

1단계: 편지 작성 및 발송 요청 (앱 내부)

  1. 고객의 계정문제를 담당하는 직원인 MemberAuthService는 고객으로부터 비밀번호 찾아달라는 주문을 받는다.
  2. 계정 담당은 회사의 우편업무를 담당하는 EmailVerificationService에게 "비밀번호 찾기용 인증 코드 편지를 보내달라"고 요청한다.
  3. 우편 담당은 편지 내용("인증 코드는 [123456] 입니다.")을 작성한다.
  4. 우편 담당은 배달부(JavaMailSender)에게 "이 편지를 우체국에 가서 부쳐줘"라며 편지 배달을 위임한다.

2단계: 우체국 방문 및 신원 확인 (우리 서버 → Gmail 서버)

  1. 배달부는 업무 매뉴얼(application.yml)을 펼쳐 우체국(Gmail SMTP 서버)의 주소와 창구 번호를 확인한다.
  2. 배달부는 우체국 창구에 도착해 신분증(application.yml 에 들어있는 계정 정보)를 보여줘 신원을 증명한다.
  3. 우체국 직원은 신분증을 확인하고 "네, 확인되었습니다. 우리 우체국을 이용하셔도 좋습니다"라고 허락한다.

3단계: 편지 접수 및 발송

  1. 신원 확인이 끝나자, 배달부는 가져온 편지를 건네준다.
  2. 우체국 직원은 보내는 사람의 주소, 받는 사람의 주소, 그리고 본문(인증 코드) 등이 담겨 있는지 확인한다.
  3. 우체국 직원은 확인을 마친 뒤 "네, 접수 완료되었습니다.

기술 하이라이트

스프링 부트 스타터 메일(Spring Boot Starter Mail)

https://notion6780.tistory.com/146

 

Flutter로컬 커뮤니티 커머스) 스프링부트 스타터 메일 기능 설명

스프링 부트 스타터 메일(Spring Boot Starter Mail)이메일 전송을 위한 스프링 프레임워크의 도구스프링 부트 스타터 메일은 스프링 프레임워크에서 이메일 보내는 걸 완전 쉽게 만들어주는 도구다.

notion6780.tistory.com

인메모리캐시 (In-memory Cache)

https://notion6780.tistory.com/147

 

Flutter로컬 커뮤니티 커머스) 인 메모리 캐시 설명

메모리 내 캐시(In-memory Cache)외부 캐시 없이 앱 메모리 쓰는 캐시의 개념과 동작 방식인메모리 캐시는 지금 돌아가는 앱의 메모리 공간을 그냥 데이터 저장소로 쓰는 방식.인증 코드처럼 빨리 사

notion6780.tistory.com


구현 절차

1단계: 기반 환경 설정

  • 목적: Spring Boot 애플리케이션이 외부 SMTP 서버와 통신해 이메일을 발송할 수 있는 환경을 구축함.
  • 작업 내역:
    1. 의존성 추가: build.gradle 파일에 spring-boot-starter-mail 라이브러리를 추가했음.
          // 이메일 전송 기능
          implementation 'org.springframework.boot:spring-boot-starter-mail'
    2. 이메일 서버 정보 설정: src/main/resources/application-dev.yml 파일에 Gmail의 SMTP 서버 정보와 계정 정보를 설정했음.
      구글 앱 비밀번호 만드는법 https://every-up.tistory.com/81
      (구현중실수: spring아래에 둔다는게 server아래에 둬서 고생함)
      # spring 환경 설정
      spring:
        # mail 서비스 구현
        mail:
          host: smtp.gmail.com
          port: 587
          username: xxx@gmail.com # 메일주소
          password: xxxx xxxx xxxx xxxx # 구글에서 발급받은 앱비밀번호
          properties:
            mail:
              smtp:
                auth: true
                starttls:
                  enable: true
       

2단계: 핵심 서비스 구현

email
├── controllers
│   └── EmailVerificationController.java
├── dtos
│   ├── ConfirmVerificationRequest.java
│   └── SendVerificationRequest.java
└── services
    ├── EmailService.java
    ├── EmailVerificationService.java
    └── VerificationCodeManager.java
  • 목적: 이메일 인증의 모든 비즈니스 로직을 담당하는 컴포넌트들을 'email' 도메인 패키지 내에 독립적으로 구현.
  • 작업 내역:
    1. EmailService.java: Spring의 JavaMailSender를 주입받아 실제 이메일 발송을 담당하는 핵심 서비스를 구현했음.
    2. VerificationCodeManager.java: 발송된 6자리 인증 코드를 5분간 메모리에 임시 저장하고 관리하는 역할을 수행함. 코드 검증 후에는 즉시 제거해 재사용을 방지함.
    3. EmailVerificationService.java: 이메일 중복 확인, 인증 코드 생성, 저장, 발송, 검증 등 전체 비즈니스 로직을 총괄하는 메인 서비스를 구현했음. 개발 편의를 위해 생성된 코드가 콘솔에 로그로 출력되도록 했음.
public class EmailService {

    private JavaMailSender javaMailSender;

    @Bean
    private JavaMailSender javaMailSender() {
        return this.javaMailSender;
    }

    public void sendEmail(String to, String subject, String text) {
        log.debug("이메일 발송 시도. 수신자: {}", to);
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(to);
        message.setSubject(subject);
        message.setText(text);

        try {
            javaMailSender.send(message);
            log.info("이메일 발송 성공. 수신자: {}", to);
        } catch (Exception e) {
            // 메일 전송 실패 시, 스택 트레이스 전체를 기록하여 원인 파악을 용이하게 함
            log.error("이메일 발송 실패. 수신자: {}", to, e);
            // 실제 운영 환경에서는 이 예외를 좀 더 구체적으로 처리해야 합니다.
            throw new RuntimeException("메일 전송 중 오류가 발생했습니다.", e);
        }
    }
}
public class VerificationCodeManager {

    private static final int EXPIRATION_MINUTES = 5; // 인증 코드 유효 시간 (5분)
    private final ConcurrentHashMap<String, VerificationInfo> verificationCodes = new ConcurrentHashMap<>();

    // 인증 코드 저장
    public void storeCode(String email, String code) {
        verificationCodes.put(email, new VerificationInfo(code, LocalDateTime.now()));
        log.info("인증 코드 저장 완료. 이메일: {}", email);
    }

    // 인증 코드 검증
    public boolean verifyCode(String email, String code) {
        log.debug("인증 코드 검증 시도. 이메일: {}", email);
        VerificationInfo info = verificationCodes.get(email);

        // 1. 코드가 존재하지 않는 경우
        if (info == null) {
            log.warn("인증 코드 검증 실패: 코드가 존재하지 않음. 이메일: {}", email);
            return false;
        }

        // 2. 유효 시간이 만료된 경우
        if (Duration.between(info.getCreatedAt(), LocalDateTime.now()).toMinutes() >= EXPIRATION_MINUTES) {
            log.warn("인증 코드 검증 실패: 유효 시간 만료. 이메일: {}", email);
            verificationCodes.remove(email); // 만료된 코드는 맵에서 제거
            return false;
        }

        // 3. 코드가 일치하지 않는 경우
        if (!info.getCode().equals(code)) {
            log.warn("인증 코드 검증 실패: 코드가 일치하지 않음. 이메일: {}", email);
            return false;
        }

        // 4. 코드가 일치하는 경우
        log.info("인증 코드 검증 성공. 이메일: {}", email);
        verificationCodes.remove(email); // 성공적으로 검증된 코드는 맵에서 제거
        return true;
    }

    // 인증 코드와 생성 시간을 저장하는 내부 클래스
    private static class VerificationInfo {
        private final String code;
        private final LocalDateTime createdAt;

        public VerificationInfo(String code, LocalDateTime createdAt) {
            this.code = code;
            this.createdAt = createdAt;
        }

        public String getCode() {
            return code;
        }

        public LocalDateTime getCreatedAt() {
            return createdAt;
        }
    }
}
public class EmailVerificationService {

    private final EmailService emailService;
    private final VerificationCodeManager verificationCodeManager;
    private final MemberAuthRepository memberAuthRepository;

    // 인증 코드 발송
    public void sendVerificationCode(String email) {
        log.info("이메일 인증 코드 발송 절차 시작. 수신자: {}", email);
        // 1. 이메일 중복 확인
        if (memberAuthRepository.findByEmail(email).isPresent()) {
            throw new Exception400("이미 가입된 이메일입니다.");
        }

        // 2. 6자리 인증 코드 생성
        String code = generate6DigitCode();

        // 3. 개발/테스트 환경을 위한 인증 코드 로깅
        log.info("개발용 인증 코드 ({}): {}", email, code);

        // 4. 인증 코드 임시 저장
        verificationCodeManager.storeCode(email, code);

        // 5. 이메일 발송
        String subject = "[Market Place] 회원가입 이메일 인증 코드";
        String text = "인증 코드는 [" + code + "] 입니다. 5분 이내에 입력해주세요.";
        emailService.sendEmail(email, subject, text);
        log.info("이메일 인증 코드 발송 절차 완료. 수신자: {}", email);
    }

    // 인증 코드 검증
    public boolean verifyCode(String email, String code) {
        boolean isVerified = verificationCodeManager.verifyCode(email, code);
        log.info("이메일 코드 검증 요청 처리. 이메일: {}, 결과: {}", email, isVerified);
        return isVerified;
    }

    // 6자리 숫자 코드 생성기
    private String generate6DigitCode() {
        Random random = new Random();
        int number = 100000 + random.nextInt(900000); // 100000 ~ 999999
        return String.valueOf(number);
    }
}

3단계: API 엔드포인트 구현

  • 목적: 클라이언트(웹/앱)가 이메일 인증 기능을 사용할 수 있도록 외부와 통신하는 접점을 구현했음.
  • 작업 내역:
    1. DTO 생성: SendVerificationRequest와 ConfirmVerificationRequest DTO를 만들어 API 요청/응답에 사용했음.
    2. EmailVerificationController.java: 다음 두 가지 주요 엔드포인트를 구현했음.
      • POST /api/email/send-verification: 지정된 이메일로 인증 코드를 발송하는 엔드포인트.
      • POST /api/email/confirm-verification: 수신한 인증 코드의 유효성을 검증하는 엔드포인트.
public class SendVerificationRequest {
    @NotBlank(message = "이메일을 입력해주세요.")
    @Email(message = "유효한 이메일 주소를 입력해주세요.")
    private String email;
}
public class ConfirmVerificationRequest {
    @NotBlank(message = "이메일을 입력해주세요.")
    @Email(message = "유효한 이메일 주소를 입력해주세요.")
    private String email;

    @NotBlank(message = "인증 코드를 입력해주세요.")
    private String code;
}
public class EmailVerificationController {

    private final EmailVerificationService emailVerificationService;

    @Operation(summary = "이메일 인증 코드 발송", description = "회원가입을 위해 해당 이메일로 인증 코드를 발송합니다.")
    @PostMapping("/send-verification")
    public ResponseEntity<ApiUtil.ApiResult<String>> sendVerificationCode(
            @Valid @RequestBody SendVerificationRequest request) {
        emailVerificationService.sendVerificationCode(request.getEmail());
        return ResponseEntity.ok(ApiUtil.success("인증 코드가 성공적으로 발송되었습니다."));
    }

    @Operation(summary = "이메일 인증 코드 확인", description = "발송된 인증 코드가 유효한지 확인합니다.")
    @PostMapping("/confirm-verification")
    public ResponseEntity<ApiUtil.ApiResult<String>> confirmVerificationCode(
            @Valid @RequestBody ConfirmVerificationRequest request) {
        boolean isVerified = emailVerificationService.verifyCode(request.getEmail(), request.getCode());

        if (!isVerified) {
            throw new Exception400("인증 코드가 유효하지 않거나 만료되었습니다.");
        }

        return ResponseEntity.ok(ApiUtil.success("이메일 인증이 성공적으로 완료되었습니다."));
    }
}

4단계: 회원가입 로직과 통합

  • 목적: 이메일 인증을 성공적으로 마친 사용자만 회원가입을 할 수 있도록, 기존 회원가입 프로세스의 보안을 강화했음.
  • 작업 내역:
    1. MemberRegisterRequest.java 수정: 회원가입 요청 DTO에 이메일 인증 완료 여부를 증명하는 isEmailVerified 필드를 추가했음.
    2. MemberAuthService.java 수정: registerMember 메서드 시작 부분에 isEmailVerified 필드가 true인지 확인하는 방어 로직을 추가했음.
// 이메일
@NotBlank(message = "이메일을 입력해주세요.")
@Email(message = "유효한 이메일 주소를 입력해주세요.")
private String email;

// 이메일 인증 여부
@NotNull(message = "이메일 인증 여부가 필요합니다.")
private Boolean isEmailVerified;
public MemberRegisterResponse registerMember(MemberRegisterRequest request) {
    log.info("회원가입 시작. 로그인 ID: {}, 이메일: {}", request.getLoginId(), request.getEmail());
    // 1. 이메일 인증 확인
    if (request.getIsEmailVerified() == null || !request.getIsEmailVerified()) {
        throw new Exception400("이메일 인증이 완료되지 않았습니다.");
    }

 

추가작업: 이메일 도메인 리팩토링

https://notion6780.tistory.com/149

 

Flutter로컬커머스) 이메일 도메인 리팩토링 작업

◇ 이메일 도메인 리팩토링내부적으로 더 유연하고 확장 가능한 구조 확보, 외부적으로는 사용자의 계정 보안과 편의성을 크게 높이는 필수 기능들을 확보했음.EmailService 비동기 처리:개요: 메

notion6780.tistory.com