백엔드 프로젝트를 시작하면서 비밀번호 재설정을 구현하는 것을 진행했다. 비밀번호 재설정을 하는 방법은 2가지를 생각했다.
1️⃣ 메일로 인증번호를 전송해 인증 번호로 인증하면 비밀번호를 재설정
2️⃣ 메일로 비밀번호 재설정 링크를 보내 그 링크로 비밀번호를 재설정
나는 이 중 2번째 방법을 선택해서 비밀번호 재설정을 진행했다. 2번째를 선택한 이유에는 2가지가 있다.
이러한 나만의 2가지 이유로 2번째 방법을 이용해 비밀번호를 재설정 하는 것으로 구현했다.
http://localhost:8080/reset-password?token=...✅ 구글 Gmail SMTP를 사용하려면 앱 비밀번호를 발급받아야 한다.
밑에 내용은 구글에서 비밀번호를 설정하는 방법을 쭉 설명한 것이다.
(1) Google 계정 관리 → 보안

(2) 앱 비밀번호 검색해서 들어가기

(3) 2단계 인증 하기

(4) 2단계 인증 → 앱 비밀번호 → 앱 비밀번호 발급 받기

발급된 16자리 앱 비밀번호를 사용해 Spring에서 메일 발송을 하면 된다. 그리고 이 16자리 비밀번호는 application.yml쪽에 설정해주면 되는 것이다.
2-2-1. application.yml
spring:
mail:
host: smtp.gmail.com
port: 587
username: ${SPRING_MAIL_USERNAME}
password: ${SPRING_MAIL_PASSWORD}
properties:
mail:
smtp:
auth: true
starttls:
enable: true

2-2-2. build.gradle
각각의 의존성을 추가해준다.
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'com.sun.mail:jakarta.mail:2.0.1'
2-3-1. PasswordResetController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class PasswordResetCommandController {
private final PasswordRestCommandService passwordRestCommandService;
// 비밀번호 재설정 링크 요청
@PostMapping("/password/reset-link")
public ResponseEntity<ApiResponse<String>> requestReset(
@RequestBody PasswordResetLinkRequest passwordResetLinkRequest
) {
String token = passwordRestCommandService.requestPasswordReset(passwordResetLinkRequest);
return ResponseEntity
.status(HttpStatus.OK)
.body(ApiResponse.success(token));
}
// 비밀번호 재설정 하기
@PostMapping("/password/reset")
public ResponseEntity<ApiResponse<Void>> resetPassword(@RequestBody PasswordModifyRequest passwordModifyRequest) {
passwordRestCommandService.resetPassword(passwordModifyRequest);
return ResponseEntity.ok(ApiResponse.success(null));
}
}
비밀번호를 재설정 하는 서비스와 이메일 전송 서비스는 서로 분리를 해서 사용했다. 그리고, 이메일 전송 서비스를 비밀번호 재설정 서비스에서 사용하는 방식으로 코드를 구성했다.
2-4-1. EmailCommandService
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailCommandServiceImpl implements EmailCommandService {
private final JavaMailSender mailSender;
@Override
public void sendPasswordResetEmail(Member member, String token) throws MessagingException {
// 현재 시간
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
String formattedNow = now.format(formatter);
// 비밀번호 재설정 링크
String resetLink = "http://localhost:5173/password/reset?token=" + token;
// 이메일 제목
String subject = "Book적Book적 비밀번호 재설정 메일";
// 받는 사람 이메일
String to = member.getEmail();
// 이메일 전체 구조 정의하는 틀
String htmlMsg =
"<div style='font-family: Arial, sans-serif; padding: 20px; background-color: #f4f4f4;'>"
+ " <div style='max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);'>"
+ " <h2 style='color: #333;'>안녕하세요, Book적Book적입니다! 📚</h2>"
+ " <p style='font-size: 16px; color: #555;'>회원님은 <strong>" + formattedNow + "</strong>에 비밀번호 재설정을 요청하셨습니다.</p>"
+ " <p style='font-size: 16px; color: #555;'>아래 버튼을 클릭하셔서 비밀번호를 변경해주세요.</p>"
+ " <p style='font-size: 16px; color: #555;'>회원 아이디: <strong>" + member.getMemberId() + "</strong></p>"
+ " <div style='text-align: center; margin: 30px 0;'>"
+ " <a href='" + resetLink + "' style='display: inline-block; padding: 14px 24px; background-color: #1a73e8; color: white; text-decoration: none; font-weight: bold; border-radius: 6px;'>비밀번호 재설정 하기</a>"
+ " </div>"
+ " </div>"
+ "</div>";
// 메일 전송 로직
sendEmail(to, subject, htmlMsg);
}
public void sendEmail(String to, String subject, String htmlContent) throws MessagingException {
// html 내용 전송을 도와주는 객체
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true);
mailSender.send(message);
log.info("이메일을 {}로 전송했습니다. 제목: {}", to, subject);
}
}
2-4-2.PasswordResetService
@Slf4j
@Service
@RequiredArgsConstructor
public class PasswordResetCommandServiceImpl implements PasswordRestCommandService {
private final PasswordResetTokenRepository passwordResetTokenRepository;
private final PasswordEncoder passwordEncoder;
private final MemberRepository memberRepository;
private final EmailCommandServiceImpl emailCommandService;
@Override
@Transactional
public String requestPasswordReset(PasswordResetLinkRequest passwordResetLinkRequest) {
// 멤버의 아이디로 멤버가 존재하는지 확인하기
String memberId = passwordResetLinkRequest.getMemberId();
Member member = memberRepository.findByMemberId(memberId)
.orElseThrow(() -> new MemberException(MemberErrorCode.NOT_EXIST_MEMBER));
// 토큰 생성하기
String token = UUID.randomUUID().toString();
// 토큰 만료 시간은 30분으로 설정
LocalDateTime expirationTime = LocalDateTime.now().plusMinutes(30);
// 토큰 생성하기
PasswordResetToken passwordResetToken = PasswordResetToken.builder()
.memberId(memberId)
.resetToken(token)
.expiryTime(expirationTime)
.build();
// 토큰 저장하기
passwordResetTokenRepository.save(passwordResetToken);
try {
// 비밀번호 재설정 이메일 보내기 (토큰이 포함되어 있는 이메일이 발송 됨)
emailCommandService.sendPasswordResetEmail(member, token);
} catch (MessagingException e) {
throw new RuntimeException("이메일 전송 실패", e);
}
return token;
}
@Override
@Transactional
public void resetPassword(PasswordModifyRequest passwordModifyRequest) {
String token = passwordModifyRequest.getPasswordResetToken();
String newPassword = passwordModifyRequest.getNewPassword();
// 만약 토큰이 없다면
PasswordResetToken resetToken = passwordResetTokenRepository.findByResetToken(token)
.orElseThrow(() -> new NotExistTokenException(MemberErrorCode.NOT_EXIST_PASSWORD_RESET_TOKEN));
// 만약 토큰의 시간이 만료 됐다면
if (resetToken.getExpiryTime().isBefore(LocalDateTime.now())) {
throw new ExpiredTokenException(MemberErrorCode.EXPIRED_PASSWORD_RESET_TOKEN);
}
// 멤버의 아이디로 멤버를 가져오기
Member member = memberRepository.findByMemberId(resetToken.getMemberId())
.orElseThrow(() -> new MemberException(MemberErrorCode.NOT_EXIST_MEMBER));
// 비밀번호 변경하기
member.setEncodedPassword(passwordEncoder.encode(newPassword));
// 비밀번호 변경 직후 토큰 삭제하기
passwordResetTokenRepository.delete(resetToken);
}
}

✅ 이렇게 메일로 잘 전송된 것을 확인할 수 있다!