[Spring] 비밀번호 재설정 링크 구현하기

지니·2025년 5월 28일

Spring

목록 보기
7/13
post-thumbnail

백엔드 프로젝트를 시작하면서 비밀번호 재설정을 구현하는 것을 진행했다. 비밀번호 재설정을 하는 방법은 2가지를 생각했다.

1️⃣ 메일로 인증번호를 전송해 인증 번호로 인증하면 비밀번호를 재설정
2️⃣ 메일로 비밀번호 재설정 링크를 보내 그 링크로 비밀번호를 재설정

나는 이 중 2번째 방법을 선택해서 비밀번호 재설정을 진행했다. 2번째를 선택한 이유에는 2가지가 있다.

  • 인증번호를 사용하는 것은 메일보다는 핸드폰으로 보내는 것이 더 좋을 것 같다고 생각했다. 그렇기에 메일을 사용하기로 결정한 입장에서 2번째 방식이 더 좋다고 생각했다.
  • 그리고, 인증번호를 받아 다시 페이지로 넘어가 인증하고 링크로 넘어가는 것보다 메일로 온 비밀번호 재설정 링크를 딱 클릭해서 바로 비밀번호 재설정 페이지로 넘어가는 게 더 좋다고 생각했다.

이러한 나만의 2가지 이유로 2번째 방법을 이용해 비밀번호를 재설정 하는 것으로 구현했다.

1. 비밀번호 재설정 흐름

(1) 아이디 입력 → 비밀번호 재설정 요청

  • 사용자가 아이디를 입력하고, 해당 아이디의 회원이 존재하는지 확인
  • 존재하는 회원이라면, 등록된 이메일 주소로 비밀번호 재설정 링크 전송

(2) 비밀번호 재설정 토큰 생성 및 저장

  • UUID로 랜덤한 토큰 생성
  • 토큰과 만료 시간을 DB에 저장

(3) 비밀번호 재설정 링크 이메일 발송

  • 이메일 본문에 다음과 같은 링크를 포함
    http://localhost:8080/reset-password?token=...
  • 사용자는 해당 링크를 클릭하여 비밀번호 재설정 페이지로 이동

(4) 비밀번호 재설정 링크 접근

  • 사용자가 링크를 클릭하면 reset-password 페이지로 이동
  • 쿼리 파라미터의 토큰 값을 서버에 전송하여 검증 요청

(5) 토큰 유효성 검증

  • 서버에서 다음 조건으로 토큰을 검증
  • DB에 존재하는지 & 만료되지 않았는지
    • 유효할 경우: 비밀번호 재설정 폼 반환
    • 유효하지 않을 경우: 오류 메시지 반환 (ex. 유효하지 않은 링크 입니다.)

(6) 새 비밀번호 입력 및 저장

  • 사용자가 새 비밀번호를 입력
  • 서버는 해당 사용자의 비밀번호를 암호화하여 저장
  • 사용된 토큰은 즉시 삭제하여 재사용을 방지

2. 비밀번호 재설정 코드


2-1. 메일 관련 설정들

✅ 구글 Gmail SMTP를 사용하려면 앱 비밀번호를 발급받아야 한다.

밑에 내용은 구글에서 비밀번호를 설정하는 방법을 쭉 설명한 것이다.

(1) Google 계정 관리 → 보안

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

(3) 2단계 인증 하기

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

발급된 16자리 앱 비밀번호를 사용해 Spring에서 메일 발송을 하면 된다. 그리고 이 16자리 비밀번호는 application.yml쪽에 설정해주면 되는 것이다.


2-2. Spring쪽에 설정해줘야 하는 부분

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. Controller

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. Service

비밀번호를 재설정 하는 서비스와 이메일 전송 서비스는 서로 분리를 해서 사용했다. 그리고, 이메일 전송 서비스를 비밀번호 재설정 서비스에서 사용하는 방식으로 코드를 구성했다.

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);
    }
}


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

0개의 댓글