-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/reset password #226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feat/reset password #226
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package team.wego.wegobackend.auth.application; | ||
|
|
||
| import jakarta.mail.MessagingException; | ||
| import jakarta.mail.internet.MimeMessage; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.mail.MailException; | ||
| import org.springframework.mail.javamail.JavaMailSender; | ||
| import org.springframework.mail.javamail.MimeMessageHelper; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Service; | ||
| import team.wego.wegobackend.common.exception.AppErrorCode; | ||
| import team.wego.wegobackend.common.exception.AppException; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class PasswordResetEmailService { | ||
|
|
||
| private final JavaMailSender mailSender; | ||
|
|
||
| /** | ||
| * ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์ด๋ฉ์ผ์ ๋ฐ์กํฉ๋๋ค. | ||
| * | ||
| * @param toEmail ์์ ์ ์ด๋ฉ์ผ | ||
| * @param resetUrl ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ํ์ด์ง ์ ์ฒด URL (๊ฒ์ฆ๊ฐ ํฌํจ) | ||
| */ | ||
| @Async("mailExecutor") | ||
| public void sendPasswordResetEmail(String toEmail, String resetUrl) { | ||
| try { | ||
| MimeMessage message = mailSender.createMimeMessage(); | ||
| MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); | ||
|
|
||
| helper.setTo(toEmail); | ||
| helper.setSubject("[WeGo] ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์๋ด"); | ||
| helper.setText(buildEmailBody(resetUrl), true); | ||
|
|
||
| mailSender.send(message); | ||
| log.info("๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์ด๋ฉ์ผ ๋ฐ์ก ์๋ฃ -> {}", toEmail); | ||
|
|
||
| } catch (MessagingException | MailException e) { | ||
| log.error("๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์ด๋ฉ์ผ ๋ฐ์ก ์คํจ -> {}", toEmail, e); | ||
| throw new AppException(AppErrorCode.DEPENDENCY_FAILURE); | ||
| } | ||
| } | ||
|
|
||
| private String buildEmailBody(String resetUrl) { | ||
| return """ | ||
| <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> | ||
| <h2 style="color: #333;">๋น๋ฐ๋ฒํธ ์ฌ์ค์ </h2> | ||
| <p>์๋ ํ์ธ์. WeGo์ ๋๋ค.</p> | ||
| <p>์๋ ๋ฒํผ์ ํด๋ฆญํ์ฌ ๋น๋ฐ๋ฒํธ๋ฅผ ์ฌ์ค์ ํ์ธ์.</p> | ||
| <p> | ||
| <a href="%s" | ||
| style="display: inline-block; padding: 12px 24px; background-color: #4F46E5; | ||
| color: white; text-decoration: none; border-radius: 6px;"> | ||
| ๋น๋ฐ๋ฒํธ ์ฌ์ค์ | ||
| </a> | ||
| </p> | ||
| <p style="color: #888; font-size: 13px;"> | ||
| ๋ณธ ๋งํฌ๋ <strong>30๋ถ</strong> ๋์๋ง ์ ํจํฉ๋๋ค.<br> | ||
| ๋ณธ์ธ์ด ์์ฒญํ์ง ์์ ๊ฒฝ์ฐ ์ด ์ด๋ฉ์ผ์ ๋ฌด์ํ์ ๋ ๋ฉ๋๋ค. | ||
| </p> | ||
| </div> | ||
| """.formatted(resetUrl); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package team.wego.wegobackend.auth.application; | ||
|
|
||
| import java.util.UUID; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import team.wego.wegobackend.auth.exception.InvalidResetTokenException; | ||
| import team.wego.wegobackend.auth.infrastructure.redis.PasswordResetRedisRepository; | ||
| import team.wego.wegobackend.user.domain.User; | ||
| import team.wego.wegobackend.user.repository.UserRepository; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Transactional(readOnly = true) | ||
| public class PasswordResetService { | ||
|
|
||
| @Value("${password-reset.frontend-url}") | ||
| private String frontendUrl; | ||
|
|
||
| private final UserRepository userRepository; | ||
| private final BCryptPasswordEncoder passwordEncoder; | ||
| private final PasswordResetRedisRepository redisRepository; | ||
| private final PasswordResetEmailService emailService; | ||
|
|
||
| /** | ||
| * ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์์ฒญ. | ||
| * ๋ฏธ๊ฐ์ ์ด๋ฉ์ผ์ด์ด๋ 200 OK๋ฅผ ๋ฐํํ์ฌ ์ด๋ฉ์ผ ์ด๊ฑฐ ๊ณต๊ฒฉ์ ๋ฐฉ์งํฉ๋๋ค. | ||
| */ | ||
| public void requestPasswordReset(String email) { | ||
| boolean exists = userRepository.existsByEmail(email); | ||
| if (!exists) { | ||
| log.debug("๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์์ฒญ: ๋ฏธ๊ฐ์ ์ด๋ฉ์ผ -> {}", email); | ||
| return; | ||
|
Comment on lines
+36
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ์ด๋ฉ์ผ ์๋ฌธ ๋ก๊น ์ ๊ฐ์ธ์ ๋ณด ๋ฐ ๊ณ์ ์ ๋ณด ๋ ธ์ถ ์ํ์ด ์์ต๋๋ค. ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ํ๋ก์ฐ์์๋ ์ด๋ฉ์ผ์ ๋ง์คํนํ๊ฑฐ๋ ํด์ ํํ๋ก ๊ธฐ๋กํ๋ ํธ์ด ์์ ํฉ๋๋ค. Also applies to: 46-47, 73-73 ๐ค Prompt for AI Agents |
||
| } | ||
|
|
||
| String token = UUID.randomUUID().toString(); | ||
| redisRepository.save(email, token); | ||
|
|
||
| String resetUrl = frontendUrl + "/password-reset?validationValue=" + token; | ||
| emailService.sendPasswordResetEmail(email, resetUrl); | ||
|
|
||
|
Comment on lines
+34
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ์๋ต ์ฝ๋๋ง ๋์ผํด๋ ํ์ด๋ฐ ๊ธฐ๋ฐ ์ด๋ฉ์ผ ์ด๊ฑฐ๊ฐ ๊ฐ๋ฅํฉ๋๋ค. ๋ฏธ๊ฐ์ ์ผ์ด์ค๋ ์ฆ์ ๋ฐํ๋๊ณ , ๊ฐ์ ์ผ์ด์ค๋ Redis ์ ์ฅ/๋ฉ์ผ ํ์๊น์ง ์ํ๋์ด ์ฒ๋ฆฌ ์๊ฐ ์ฐจ์ด๊ฐ ์๊น๋๋ค. ์ต์ ์ฒ๋ฆฌ์๊ฐ ๋ณด์ ๋๋ ์ ์ฌํ ๋น์ฉ์ ๋๋ฏธ ๊ฒฝ๋ก๋ฅผ ๋ฌ์ ํธ์ฐจ๋ฅผ ์ค์ฌ ์ฃผ์ธ์. ๐ค Prompt for AI Agents |
||
| log.info("๋น๋ฐ๋ฒํธ ์ฌ์ค์ ํ ํฐ ๋ฐ๊ธ -> email={}", email); | ||
| } | ||
|
|
||
| /** | ||
| * ๊ฒ์ฆ๊ฐ ์ ํจ์ฑ ๊ฒ์ฌ. ํ ํฐ์ ์๋นํ์ง ์์ต๋๋ค. (ํ๋ก ํธ 2๋จ๊ณ ๊ฒ์ฆ 1๋จ๊ณ) | ||
| */ | ||
| public void verifyToken(String token) { | ||
| redisRepository.findEmailByToken(token) | ||
| .orElseThrow(InvalidResetTokenException::new); | ||
| } | ||
|
|
||
| /** | ||
| * ๋น๋ฐ๋ฒํธ ๋ณ๊ฒฝ. ํ ํฐ ์ ํจ์ฑ ์ฌ๊ฒ์ฌ ํ ๋น๋ฐ๋ฒํธ ๋ณ๊ฒฝ ๋ฐ ํ ํฐยท์ธ์ ํ๊ธฐ. | ||
| */ | ||
| @Transactional | ||
| public void resetPassword(String token, String newPassword) { | ||
| String email = redisRepository.findEmailByToken(token) | ||
| .orElseThrow(InvalidResetTokenException::new); | ||
|
|
||
| User user = userRepository.findByEmail(email) | ||
| .orElseThrow(InvalidResetTokenException::new); | ||
|
|
||
| user.updatePassword(passwordEncoder.encode(newPassword)); | ||
| user.updateCurrentSessionid(null); | ||
|
|
||
| redisRepository.deleteByToken(token); | ||
|
|
||
| log.info("๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์๋ฃ -> email={}", email); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package team.wego.wegobackend.auth.application.dto.request; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| public record PasswordResetConfirmRequest( | ||
| @NotBlank(message = "ํ ํฐ์ ํ์์ ๋๋ค.") | ||
| String token, | ||
|
|
||
| @NotBlank(message = "์ ๋น๋ฐ๋ฒํธ๋ ํ์์ ๋๋ค.") | ||
| @Size(min = 8, message = "๋น๋ฐ๋ฒํธ๋ 8์ ์ด์์ด์ด์ผ ํฉ๋๋ค.") | ||
| String newPassword | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package team.wego.wegobackend.auth.application.dto.request; | ||
|
|
||
| import jakarta.validation.constraints.Email; | ||
| import jakarta.validation.constraints.NotBlank; | ||
|
|
||
| public record PasswordResetRequest( | ||
| @NotBlank(message = "์ด๋ฉ์ผ์ ํ์์ ๋๋ค.") | ||
| @Email(message = "์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค.") | ||
| String email | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package team.wego.wegobackend.auth.exception; | ||
|
|
||
| import team.wego.wegobackend.common.exception.AppErrorCode; | ||
| import team.wego.wegobackend.common.exception.AppException; | ||
|
|
||
| public class InvalidResetTokenException extends AppException { | ||
|
|
||
| public InvalidResetTokenException() { | ||
| super(AppErrorCode.INVALID_RESET_TOKEN); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| package team.wego.wegobackend.auth.infrastructure.redis; | ||
|
|
||
| import java.time.Duration; | ||
| import java.util.Optional; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| /** | ||
| * ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ํ ํฐ Redis ์ ์ฅ์. | ||
| * | ||
| * <p>์ด์ค ํค ๊ตฌ์กฐ๋ก "๊ณ์ ๋น ์ต์ ํ ํฐ 1๊ฐ๋ง ์ ํจ" ์ ์ฑ ์ ๊ตฌํํฉ๋๋ค. | ||
| * <ul> | ||
| * <li>pwreset:token:{uuid} โ email (ํ ํฐ ๊ฒ์ฆ์ฉ)</li> | ||
| * <li>pwreset:email:{email} โ uuid (์ด์ ํ ํฐ ์ถ์ ๋ฐ ํ๊ธฐ์ฉ)</li> | ||
| * </ul> | ||
| */ | ||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class PasswordResetRedisRepository { | ||
|
|
||
| private static final String TOKEN_PREFIX = "pwreset:token:"; | ||
| private static final String EMAIL_PREFIX = "pwreset:email:"; | ||
|
|
||
| @Value("${password-reset.token-ttl-minutes:30}") | ||
| private long tokenTtlMinutes; | ||
|
|
||
| private final StringRedisTemplate stringRedisTemplate; | ||
|
|
||
| /** | ||
| * ํ ํฐ์ ์ ์ฅํฉ๋๋ค. ๊ธฐ์กด ํ ํฐ์ด ์์ผ๋ฉด ๋จผ์ ํ๊ธฐํฉ๋๋ค. | ||
| */ | ||
| public void save(String email, String token) { | ||
| // ๊ธฐ์กด ํ ํฐ ํ๊ธฐ | ||
| String oldToken = stringRedisTemplate.opsForValue().get(emailKey(email)); | ||
| if (oldToken != null) { | ||
| stringRedisTemplate.delete(tokenKey(oldToken)); | ||
| } | ||
|
|
||
| Duration ttl = Duration.ofMinutes(tokenTtlMinutes); | ||
| stringRedisTemplate.opsForValue().set(tokenKey(token), email, ttl); | ||
| stringRedisTemplate.opsForValue().set(emailKey(email), token, ttl); | ||
| } | ||
|
|
||
| /** | ||
| * ํ ํฐ์ผ๋ก ์ด๋ฉ์ผ์ ์กฐํํฉ๋๋ค. | ||
| */ | ||
| public Optional<String> findEmailByToken(String token) { | ||
| return Optional.ofNullable(stringRedisTemplate.opsForValue().get(tokenKey(token))); | ||
| } | ||
|
|
||
| /** | ||
| * ์ฌ์ฉ ์๋ฃ๋ ํ ํฐ์ ํ๊ธฐํฉ๋๋ค. (ํ ํฐ ํค, ์ด๋ฉ์ผ ์ญ๋ฐฉํฅ ํค ๋ชจ๋ ์ญ์ ) | ||
| */ | ||
| public void deleteByToken(String token) { | ||
| String email = stringRedisTemplate.opsForValue().get(tokenKey(token)); | ||
| stringRedisTemplate.delete(tokenKey(token)); | ||
| if (email != null) { | ||
| stringRedisTemplate.delete(emailKey(email)); | ||
| } | ||
|
Comment on lines
+34
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐งฉ Analysis chain๐ Script executed: cat -n src/main/java/team/wego/wegobackend/auth/infrastructure/redis/PasswordResetRedisRepository.javaRepository: WeGo-Together/WeGo_BackEnd Length of output: 2790 Redis ํ ํฐ-์ด๋ฉ์ผ ์ด์ค ํค ๊ฐฑ์ /์ญ์ ๊ฐ ์์์ ์ด์ง ์์ "์ต์ 1๊ฐ ํ ํฐ" ๋ณด์ฅ์ด ๊นจ์ง ์ ์์ต๋๋ค.
Lua ์คํฌ๋ฆฝํธ ๋๋ WATCH/MULTI๋ฅผ ์ฌ์ฉํ์ฌ ๋ชจ๋ ํค ์กฐ์์ ์์์ ์ผ๋ก ์ฒ๋ฆฌํด์ฃผ์ธ์. ๐ค Prompt for AI Agents |
||
| } | ||
|
|
||
| private String tokenKey(String token) { | ||
| return TOKEN_PREFIX + token; | ||
| } | ||
|
|
||
| private String emailKey(String email) { | ||
| return EMAIL_PREFIX + email; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,13 +10,18 @@ | |
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.CookieValue; | ||
| import org.springframework.web.bind.annotation.DeleteMapping; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
| import team.wego.wegobackend.auth.application.AuthService; | ||
| import team.wego.wegobackend.auth.application.PasswordResetService; | ||
| import team.wego.wegobackend.auth.application.dto.request.GoogleLoginRequest; | ||
| import team.wego.wegobackend.auth.application.dto.request.LoginRequest; | ||
| import team.wego.wegobackend.auth.application.dto.request.PasswordResetConfirmRequest; | ||
| import team.wego.wegobackend.auth.application.dto.request.PasswordResetRequest; | ||
| import team.wego.wegobackend.auth.application.dto.request.SignupRequest; | ||
| import team.wego.wegobackend.auth.application.dto.response.LoginResponse; | ||
| import team.wego.wegobackend.auth.application.dto.response.RefreshResponse; | ||
|
|
@@ -34,6 +39,8 @@ public class AuthController implements AuthControllerDocs { | |
|
|
||
| private final AuthService authService; | ||
|
|
||
| private final PasswordResetService passwordResetService; | ||
|
|
||
| private final JwtTokenProvider jwtTokenProvider; | ||
|
|
||
| /** | ||
|
|
@@ -155,6 +162,49 @@ public ResponseEntity<ApiResponse<RefreshResponse>> refresh( | |
| )); | ||
| } | ||
|
|
||
| /** | ||
| * ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์์ฒญ โ ์ด๋ฉ์ผ ๋ฐ์ก | ||
| * ๋ฏธ๊ฐ์ ์ด๋ฉ์ผ์ด์ด๋ 200 OK ๋ฐํ (์ด๋ฉ์ผ ์ด๊ฑฐ ๊ณต๊ฒฉ ๋ฐฉ์ง) | ||
| */ | ||
| @PostMapping("/password-reset/request") | ||
| public ResponseEntity<ApiResponse<Void>> requestPasswordReset( | ||
| @Valid @RequestBody PasswordResetRequest request) { | ||
|
|
||
| passwordResetService.requestPasswordReset(request.email()); | ||
|
|
||
| return ResponseEntity | ||
| .status(HttpStatus.OK) | ||
| .body(ApiResponse.success(200, true)); | ||
| } | ||
|
|
||
| /** | ||
| * ๊ฒ์ฆ๊ฐ(validationValue) ์ ํจ์ฑ ๊ฒ์ฌ | ||
| */ | ||
| @GetMapping("/reset-verify") | ||
| public ResponseEntity<ApiResponse<Void>> verifyResetToken( | ||
| @RequestParam String validationValue) { | ||
|
Comment on lines
+183
to
+185
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ์ฌ์ค์ ํ ํฐ์ GET ์ฟผ๋ฆฌ๋ก ๋ฐ๋ ๊ตฌ์กฐ๋ ๋ ธ์ถ๋ฉด์ด ํฝ๋๋ค. ํ ํฐ์ด URL์ ๋จ์ ๋ก๊ทธ/ํ์คํ ๋ฆฌ/์ค๊ฐ ์ธํ๋ผ์ ๊ธฐ๋ก๋ ์ ์์ต๋๋ค. ๊ฒ์ฆ ์๋ํฌ์ธํธ๋ฅผ POST + body๋ก ์ ํํ๋ ํธ์ด ์์ ํฉ๋๋ค. ๐ค Prompt for AI Agents |
||
|
|
||
| passwordResetService.verifyToken(validationValue); | ||
|
|
||
| return ResponseEntity | ||
| .status(HttpStatus.OK) | ||
| .body(ApiResponse.success(200, true)); | ||
| } | ||
|
|
||
| /** | ||
| * ๋น๋ฐ๋ฒํธ ๋ณ๊ฒฝ | ||
| */ | ||
| @PostMapping("/password-reset") | ||
| public ResponseEntity<ApiResponse<Void>> resetPassword( | ||
| @Valid @RequestBody PasswordResetConfirmRequest request) { | ||
|
|
||
| passwordResetService.resetPassword(request.token(), request.newPassword()); | ||
|
|
||
| return ResponseEntity | ||
| .status(HttpStatus.OK) | ||
| .body(ApiResponse.success(200, true)); | ||
| } | ||
|
|
||
| /** | ||
| * Refresh Token HttpOnly ์ฟ ํค ์์ฑ | ||
| */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๐งฉ Analysis chain
๐ Script executed:
Repository: WeGo-Together/WeGo_BackEnd
Length of output: 728
๐ Script executed:
Repository: WeGo-Together/WeGo_BackEnd
Length of output: 657
๐ Script executed:
Repository: WeGo-Together/WeGo_BackEnd
Length of output: 145
๐ Script executed:
Repository: WeGo-Together/WeGo_BackEnd
Length of output: 52
๐ Script executed:
Repository: WeGo-Together/WeGo_BackEnd
Length of output: 738
๐ Script executed:
# Check the test code cat -n src/test/java/team/wego/wegobackend/auth/PasswordResetEmailServiceTest.javaRepository: WeGo-Together/WeGo_BackEnd
Length of output: 2156
๐ Script executed:
Repository: WeGo-Together/WeGo_BackEnd
Length of output: 1025
@Async void์์ ๋์ง ์์ธ๋ ํธ์ถ์์๊ฒ ์ ๋ฌ๋์ง ์์ต๋๋ค.ํ์ฌ
sendPasswordResetEmail()์ ๋น๋๊ธฐ ์คํ ์คAppException์ throwํ์ง๋ง, void ๋ฐํ ๋ฉ์๋์ ์์ธ๋ ํธ์ถ์(PasswordResetService.java:44)์๊ฒ ์ ํ๋์ง ์์ต๋๋ค. ์ด๋ฉ์ผ ๋ฐ์ก ์คํจ๊ฐ ๋ฌด์๋๊ณ ์ฌ์ฉ์๊ฐ ์ฌ์ค์ ๋งํฌ๋ฅผ ๋ฐ์ง ๋ชปํ ์ ์์ต๋๋ค.CompletableFuture<Void>๋ฐํ์ผ๋ก ๋ณ๊ฒฝํ์ฌ ํธ์ถ์๊ฐ ์์ธ๋ฅผ ์ฒ๋ฆฌํ๊ฑฐ๋, ๋๊ธฐ ์ ์ก์ผ๋ก ๋ณ๊ฒฝํด ์ฃผ์ธ์.๐ค Prompt for AI Agents