SMTP 를 통한 인증메일 발송기능
신규 회원가입 시 사용할 이메일 인증 코드 발송 및 검증 기능을 구현했다.
또한 이 기능을 기존 회원가입 로직과 안전하게 통합했다.
이메일 발송 시나리오
◎ 등장인물 (기술 요소)
- 계정 담당: MemberAuthService 파일. 고객의 요청을 받아 편지 발송을 결정한다.
- 우편 담당: EmailVerificationService 파일. 계정 담당의 요청을 받아 편지 내용물을 작성하고 발송을 준비한다.
- 배달부: JavaMailSender 객체. 우편 담당이 준비한 편지를 받아 외부 우체국으로 배달한다.
- 배달 매뉴얼: application.yml의 spring.mail 설정. 우체국에 방문할 때 필요한 우체국 주소와 출입증
- 구글 우체국: Gmail 의 SMTP 서버. 편지를 안전하게 받아 다른 곳으로 전달한다.
◎ 이메일 발송 과정
1단계: 편지 작성 및 발송 요청 (앱 내부)
- 고객의 계정문제를 담당하는 직원인 MemberAuthService는 고객으로부터 비밀번호 찾아달라는 주문을 받는다.
- 계정 담당은 회사의 우편업무를 담당하는 EmailVerificationService에게 "비밀번호 찾기용 인증 코드 편지를 보내달라"고 요청한다.
- 우편 담당은 편지 내용("인증 코드는 [123456] 입니다.")을 작성한다.
- 우편 담당은 배달부(JavaMailSender)에게 "이 편지를 우체국에 가서 부쳐줘"라며 편지 배달을 위임한다.
2단계: 우체국 방문 및 신원 확인 (우리 서버 → Gmail 서버)
- 배달부는 업무 매뉴얼(application.yml)을 펼쳐 우체국(Gmail SMTP 서버)의 주소와 창구 번호를 확인한다.
- 배달부는 우체국 창구에 도착해 신분증(application.yml 에 들어있는 계정 정보)를 보여줘 신원을 증명한다.
- 우체국 직원은 신분증을 확인하고 "네, 확인되었습니다. 우리 우체국을 이용하셔도 좋습니다"라고 허락한다.
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 서버와 통신해 이메일을 발송할 수 있는 환경을 구축함.
- 작업 내역:
- 의존성 추가: build.gradle 파일에 spring-boot-starter-mail 라이브러리를 추가했음.
// 이메일 전송 기능 implementation 'org.springframework.boot:spring-boot-starter-mail'
- 이메일 서버 정보 설정: 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
- 의존성 추가: build.gradle 파일에 spring-boot-starter-mail 라이브러리를 추가했음.
2단계: 핵심 서비스 구현
email
├── controllers
│ └── EmailVerificationController.java
├── dtos
│ ├── ConfirmVerificationRequest.java
│ └── SendVerificationRequest.java
└── services
├── EmailService.java
├── EmailVerificationService.java
└── VerificationCodeManager.java
- 목적: 이메일 인증의 모든 비즈니스 로직을 담당하는 컴포넌트들을 'email' 도메인 패키지 내에 독립적으로 구현.
- 작업 내역:
- EmailService.java: Spring의 JavaMailSender를 주입받아 실제 이메일 발송을 담당하는 핵심 서비스를 구현했음.
- VerificationCodeManager.java: 발송된 6자리 인증 코드를 5분간 메모리에 임시 저장하고 관리하는 역할을 수행함. 코드 검증 후에는 즉시 제거해 재사용을 방지함.
- 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 엔드포인트 구현
- 목적: 클라이언트(웹/앱)가 이메일 인증 기능을 사용할 수 있도록 외부와 통신하는 접점을 구현했음.
- 작업 내역:
- DTO 생성: SendVerificationRequest와 ConfirmVerificationRequest DTO를 만들어 API 요청/응답에 사용했음.
- 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단계: 회원가입 로직과 통합
- 목적: 이메일 인증을 성공적으로 마친 사용자만 회원가입을 할 수 있도록, 기존 회원가입 프로세스의 보안을 강화했음.
- 작업 내역:
- MemberRegisterRequest.java 수정: 회원가입 요청 DTO에 이메일 인증 완료 여부를 증명하는 isEmailVerified 필드를 추가했음.
- 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
'My Project' 카테고리의 다른 글
Flutter로컬커머스) 이메일 도메인 리팩토링 작업 (0) | 2025.09.02 |
---|---|
Flutter로컬커머스) 비밀번호 찾기 기능 구현 (0) | 2025.09.02 |
CRUD 이후 필요한 고급 기능 체크리스트 (1) | 2025.08.06 |