비밀번호 찾기 및 재설정

뚜우웅이·2025년 4월 6일

캡스톤 디자인

목록 보기
6/35

비밀번호 재설정

gradle에 아래와 같은 코드를 추가해야 JavaMailSender를 사용할 수 있다.
gradle

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

그 후 yml에 이메일 관련 서버 설정을 추가해야 한다.
application.yml

  mail:
    host: smtp.gmail.com # 사용할 서버
    port: 587
    username: ${mail.username} # 메일을 보내는 계정
    password: ${mail.password}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          timeout: 5000
  • mail.smtp.auth: true - SMTP 서버에 인증이 필요함을 나타낸다.

  • mail.smtp.starttls.enable: true - STARTTLS 암호화를 활성화한다. 이는 처음에는 암호화되지 않은 연결로 시작하여 나중에 TLS로 업그레이드하는 방식이다.

  • SMTP 서버와의 연결 타임아웃을 5,000밀리초(5초)로 설정한다.

  • username은 구글 메일을 사용한다.

  • 해당 구글 계정을 2단계 인증을 활성화 후 앱 비밀번호를 생성하여 password에 넣어준다.

이메일 발송을 위한 서비스 클래스

EmailService

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {

    private final JavaMailSender emailSender;

    @Async
    public void sendEmail(String to, String subject, String content) {
        try {
            MimeMessage message = emailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(content, true); // HTML 형식 활성화

            emailSender.send(message);
            log.info("이메일 전송 완료: {}", to);
        } catch (MessagingException e) {
            log.error("이메일 전송 실패: {}", e.getMessage(), e);
            throw new RuntimeException("이메일 전송에 실패했습니다.", e);
        }
    }

    public void sendPasswordResetEmail(String to, String resetToken) {
        String subject = "FreeMarket 비밀번호 재설정";
        String resetUrl = "http://localhost:8080/reset-password?token=" + resetToken;

        String content = """
                <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
                    <h2>비밀번호 재설정</h2>
                    <p>안녕하세요. FreeMarket 비밀번호 재설정을 요청하셨습니다.</p>
                    <p>아래 링크를 클릭하여 비밀번호를 재설정해 주세요:</p>
                    <p><a href="%s" style="display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px;">비밀번호 재설정</a></p>
                    <p>이 링크는 30분 동안만 유효합니다.</p>
                    <p>비밀번호 재설정을 요청하지 않으셨다면 이 이메일을 무시하셔도 됩니다.</p>
                    <p>감사합니다.<br>FreeMarket</p>
                </div>
                """.formatted(resetUrl);

        sendEmail(to, subject, content);
    }
}

Spring Boot 애플리케이션에서 이메일 서비스를 구현한 EmailService 클래스다.

  • @Async: 비동기적으로 메서드를 실행하도록 지정한다. 이메일 전송이 오래 걸릴 수 있으므로 비동기로 처리한다.

PasswordResetToken

@Entity
@Table(name = "password_reset_tokens")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PasswordResetToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String token;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private LocalDateTime expiryDate;

    @Builder
    public PasswordResetToken(String token, String email, LocalDateTime expiryDate) {
        this.token = UUID.randomUUID().toString();
        this.email = email;
        // 토큰 만료시간 30분
        this.expiryDate = LocalDateTime.now().plusMinutes(30);
    }

    public boolean isExpired() {
        return LocalDateTime.now().isAfter(expiryDate);
    }
}
  • UUID.randomUUID(): 무작위로 생성된 범용 고유 식별자(Universally Unique Identifier)를 반환한다. UUID는 128비트(16바이트) 값으로, 충돌 가능성이 극히 낮은 고유한 식별자다.

PasswordResetTokenRepository

public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long>, PasswordResetTokenRepositoryCustom {
    Optional<PasswordResetToken> findByToken(String token);
    Optional<PasswordResetToken> findByEmail(String email);
}

PasswordResetTokenRepositoryCustom

public interface PasswordResetTokenRepositoryCustom {
    void deleteExpiredTokens(LocalDateTime now);
}

PasswordResetTokenRepositoryImpl

@RequiredArgsConstructor
public class PasswordResetTokenRepositoryImpl implements PasswordResetTokenRepositoryCustom {
    private final JPAQueryFactory queryFactory;


    @Override
    public void deleteExpiredTokens(LocalDateTime now) {
        queryFactory
                .delete(passwordResetToken)
                .where(passwordResetToken.expiryDate.lt(now))
                .execute();
    }
}

비밀번호 재설정 토큰 관리를 위한 엔터티 및 저장소이다.

비밀번호 재설정 관련 DTO

PasswordDto

public class PasswordDto {

    public record PasswordResetRequest(
            @NotBlank(message = "이메일은 필수 입력값입니다.")
            @Email(message = "이메일 형식이 올바르지 않습니다.")
            String email
    ) {}

    public record PasswordResetVerifyRequest(
            @NotBlank(message = "토큰은 필수 입력값입니다.")
            String token,

            @NotBlank(message = "새 비밀번호는 필수 입력값입니다.")
            @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
            String newPassword
    ) {}

    @Builder
    public record PasswordResetResponse(
            boolean success,
            String message
    ) {}
}

DTO를 한 곳에 모으는 이유

  • 관련 DTO들을 하나의 논리적 단위로 그룹화하여 코드의 구조와 가독성이 향상된다.
  • 클래스 이름 충돌을 방지하고 관련 DTO들을 명확한 네임스페이스 아래 구성할 수 있다.
  • 관련 DTO들이 여러 패키지나 클래스에 분산되지 않고 한 곳에 있어 유지보수가 용이하다.
  • 한 도메인이나 기능에 관련된 DTO들을 모아 높은 응집도를 유지할 수 있다.
  • 관련 DTO를 사용하는 클래스에서 여러 개별 클래스를 임포트하지 않고 하나의 외부 클래스만 임포트하면 된다.

비밀번호 재설정 서비스 구현

PasswordResetService

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PasswordResetService {

    private final UserRepository userRepository;
    private final PasswordResetTokenRepository passwordResetTokenRepository;
    private final EmailService emailService;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public void requestPasswordReset(PasswordDto.PasswordResetRequest request) {
        String email = request.email();

        // 사용자 존재 여부 확인
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UserException.UserNotFoundException(email));

        // 기존 토큰이 있으면 삭제
        passwordResetTokenRepository.findByEmail(email)
                .ifPresent(passwordResetTokenRepository::delete);

        // 새 토큰 생성
        PasswordResetToken resetToken = PasswordResetToken.builder()
                .email(email)
                .build();

        passwordResetTokenRepository.save(resetToken);

        // 이메일 발송
        emailService.sendPasswordResetEmail(email, resetToken.getToken());

        log.info("비밀번호 재설정 요청 처리 완료: {}", email);
    }

    @Transactional
    public void resetPassword(PasswordDto.PasswordResetVerifyRequest request) {
        String token = request.token();
        String newPassword = request.newPassword();

        // 토큰 검증
        PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(token)
                .orElseThrow(() -> new AuthException.PasswordResetTokenNotFoundException());

        if (resetToken.isExpired()) {
            throw new AuthException.PasswordResetTokenExpiredException();
        }

        // 사용자 정보 조회
        User user = userRepository.findByEmail(resetToken.getEmail())
                .orElseThrow(() -> new UserException.UserNotFoundException(resetToken.getEmail()));

        // 비밀번호 변경
        user.changePassword(passwordEncoder.encode(newPassword));
        userRepository.save(user);

        // 사용한 토큰 삭제
        passwordResetTokenRepository.delete(resetToken);

        log.info("비밀번호 재설정 완료: {}", resetToken.getEmail());
    }

    @Scheduled(cron = "0 */30 * * * *") // 30분마다 실행
    @Transactional
    public void cleanupExpiredTokens() {
        log.info("만료된 비밀번호 재설정 토큰 정리 시작");
        passwordResetTokenRepository.deleteExpiredTokens(LocalDateTime.now());
        log.info("만료된 비밀번호 재설정 토큰 정리 완료");
    }
}

이 서비스는 다음과 같은 비밀번호 재설정 프로세스를 구현한다

  • 사용자가 이메일 주소를 입력하여 비밀번호 재설정을 요청
  • 시스템은 사용자 확인 후, 고유 토큰을 생성하고 이메일로 재설정 링크를 전송
  • 사용자가 이메일 링크를 통해 토큰과 새 비밀번호를 제출
  • 시스템은 토큰 검증 후 비밀번호를 업데이트
  • 30분마다 실행되는 배치 작업이 만료된 토큰을 자동으로 제거

AuthException 추가


    public static class PasswordResetTokenNotFoundException extends AuthException {
        public PasswordResetTokenNotFoundException() {
            super("비밀번호 재설정 토큰을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "PASSWORD_RESET_TOKEN_NOT_FOUND");
        }
    }

    public static class PasswordResetTokenExpiredException extends AuthException {
        public PasswordResetTokenExpiredException() {
            super("비밀번호 재설정 토큰이 만료되었습니다.", HttpStatus.BAD_REQUEST, "PASSWORD_RESET_TOKEN_EXPIRED");
        }
    }

비밀번호 재설정 컨트롤러

PasswordResetController

@Slf4j
@RestController
@RequestMapping("/api/auth/password")
@RequiredArgsConstructor
@Tag(name = "비밀번호 재설정", description = "비밀번호 찾기 및 재설정 관련 API")
public class PasswordResetController {

    private final PasswordResetService passwordResetService;

    @Operation(summary = "비밀번호 재설정 요청", description = "이메일 주소를 입력받아 비밀번호 재설정 링크를 발송합니다.")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "비밀번호 재설정 이메일 발송 성공"),
            @ApiResponse(responseCode = "404", description = "사용자 정보를 찾을 수 없음"),
            @ApiResponse(responseCode = "400", description = "잘못된 요청")
    })
    @PostMapping("/reset-request")
    public ResponseEntity<ResponseDTO<PasswordDto.PasswordResetResponse>> requestPasswordReset(
            @Parameter(description = "비밀번호 재설정 요청 정보", required = true)
            @Valid @RequestBody PasswordDto.PasswordResetRequest request) {
        log.info("비밀번호 재설정 요청: {}", request.email());
        passwordResetService.requestPasswordReset(request);

        PasswordDto.PasswordResetResponse response = PasswordDto.PasswordResetResponse.builder()
                .success(true)
                .message("비밀번호 재설정 이메일이 발송되었습니다.")
                .build();

        return ResponseEntity.ok(ResponseDTO.success(response, "비밀번호 재설정 이메일이 발송되었습니다."));
    }

    @Operation(summary = "비밀번호 재설정", description = "토큰과 새 비밀번호를 입력받아 비밀번호를 재설정합니다.")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"),
            @ApiResponse(responseCode = "404", description = "유효하지 않은 토큰"),
            @ApiResponse(responseCode = "400", description = "만료된 토큰 또는 잘못된 요청")
    })
    @PostMapping("/reset-verify")
    public ResponseEntity<ResponseDTO<PasswordDto.PasswordResetResponse>> resetPassword(
            @Parameter(description = "비밀번호 재설정 정보", required = true)
            @Valid @RequestBody PasswordDto.PasswordResetVerifyRequest request) {

        log.info("비밀번호 재설정 확인");
        passwordResetService.resetPassword(request);

        PasswordDto.PasswordResetResponse response = PasswordDto.PasswordResetResponse.builder()
                .success(true)
                .message("비밀번호가 성공적으로 재설정되었습니다.")
                .build();

        return ResponseEntity.ok(ResponseDTO.success(response, "비밀번호가 성공적으로 재설정되었습니다."));
    }

}

전체 API 흐름

  • 첫 번째 단계 ("/reset-request")

    • 사용자가 이메일 주소를 제공한다.
    • 시스템은 이메일로 비밀번호 재설정 링크(토큰 포함)를 전송한다.
  • 두 번째 단계 ("/reset-verify")

    • 사용자가 이메일 링크를 통해 토큰과 새 비밀번호를 제출한다.
    • 시스템은 토큰을 검증하고 비밀번호를 변경한다.

비밀번호 재설정 경로 허용

WebSecurityConfig

.requestMatchers("/api/auth/password/**").permitAll()

EmailConfig

EmailConfig

@Configuration
@EnableAsync
public class EmailConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("EmailThread-");
        executor.initialize();
        return executor;
    }
}
  • 비동기 이메일 처리를 위한 스레드 풀 설정을 정의하는 구성 클래스이다.

  • @EnableAsync: Spring에서 비동기 메서드 실행을 활성화한다. 이 어노테이션은 @Async 어노테이션이 있는 메서드가 별도의 스레드에서 실행되도록 한다.

  • setCorePoolSize(2)

    • 스레드 풀의 기본 크기를 2로 설정한다.
    • 이는 평소에 유지되는 스레드 수다.
  • setMaxPoolSize(4)

    • 스레드 풀의 최대 크기를 4로 설정한다.
    • 작업량이 많아 대기열이 가득 차면 풀은 최대 4개의 스레드까지 확장될 수 있다.
  • setQueueCapacity(100)

    • 작업 대기열의 크기를 100으로 설정한다.
    • 모든 코어 스레드가 바쁘면 새 작업은 이 대기열에 추가된다.
    • 대기열이 가득 차면 추가 스레드가 생성된다(최대 풀 크기까지).
  • setThreadNamePrefix("EmailThread-")

    • 생성된 스레드의 이름 접두사를 "EmailThread-"로 설정한다.
    • 이는 로깅이나 디버깅 시 스레드를 구분하는 데 유용하다.
  • initialize()

    • ThreadPoolTaskExecutor를 초기화한다.

사용 목적
이 설정은 이메일 전송과 같은 시간이 걸리고 I/O 집약적인 작업을 비동기적으로 처리하기 위한 것이다. 기존의 EmailService 클래스에서 @Async 어노테이션이 있는 메서드는 이 스레드 풀을 사용하여 비동기적으로 실행된다.

테스트




profile
공부하는 초보 개발자

0개의 댓글