08_backend ์
๋ฌธ โ Spring Boot / JPA ๊ธฐ์ด primer (๋ฐฑ์๋ ๊ต์ฌ ์ฝ๊ธฐ ์ ํ๋
)
์๋ณธ ์ฝ๋:
backend/src/main/java/com/DevBridge/devbridge/...(์ด ๋ฌธ์๋ ์ถ์ธก ์์ด ์ค์ ํ๋ก์ ํธ ์ฝ๋๋ง ์ธ์ฉํฉ๋๋ค)
์ด ๋ฌธ์๋ ๊ต์ฌ ํ์ค ํ์(์์ง ๊ต์ฌ์01_backtest/vbt_engine.md)์ ๋ฐ๋ฆ ๋๋ค.
์ฉ๋: โ ๋ฐฑ์๋ ๋๋ฉ์ธ ๊ต์ฌ๋ฅผ ์ฝ๊ธฐ ์ Spring/JPA ๊ธฐ์ด ๋ค์ง๊ธฐ โ โก ํ ์ค์ฉ ํ์ต โ โข ๊ฐ์ ์๋ฃ.
์์ฑ ์์น: "์์ฃผ ์ ๊ฐ๋ฅด์น๋ ๊ต์"์ฒ๋ผ โ ๋น์ ยท์์ยท"์ด๋ณด๊ฐ ํท๊ฐ๋ฆฌ๋ ํฌ์ธํธ"๋ฅผ ๊ณ๋ค์ธ๋ค.
๐ ์ด ํ์ผ ํ๋์
์ด primer๋ "Java/Spring ๋ฐฑ์๋๋ผ๋ ๊ฑด๋ฌผ์ ์ค๊ณ๋" ์
๋๋ค. ์์ง ๊ต์ฌ(Python/FastAPI)๋ฅผ ๋ค ์ฝ๊ณ ๋์ ๋ฐฑ์๋(08_backend/*)๋ก ๋์ด์ค๋ฉด, ๊ฐ์๊ธฐ @RestController, @Entity, @Transactional ๊ฐ์ ๋ฏ์ ์ ๋ํ
์ด์
(@๋ก ์์ํ๋ ํ์) ์ด ์์์ง๋๋ค. ์ด ๋ฌธ์๋ ๊ทธ ํ์๋ค์ด ๋ฌด์จ ๋ป์ธ์ง, ๊ทธ๋ฆฌ๊ณ HTTP ์์ฒญ ํ๋๊ฐ ์ด๋ป๊ฒ ์๋ฐ ๋ฉ์๋๊น์ง ํ๋ฌ๊ฐ DB๋ฅผ ๊ฑด๋๋ฆฌ๊ณ ๋ค์ JSON์ผ๋ก ๋์์ค๋์ง๋ฅผ ํ ํ๋ฆ์ผ๋ก ์ค๋ช
ํฉ๋๋ค.
ํต์ฌ๋ง ๋จผ์ :
| ๊ฐ๋ | ํ ์ค ์์ฝ | ๋น์ |
|---|---|---|
| Spring Boot | ์๋ฒ ์ฑ์ "์๋ ์กฐ๋ฆฝ"ํด์ฃผ๋ ํ๋ ์์ํฌ (ํฐ์บฃ ๋ด์ฅ) | ๊ฐ๊ตฌ๊ฐ ๋ฏธ๋ฆฌ ์กฐ๋ฆฝ๋ผ ์ค๋ ์ด์ผ์ ํ์ธํธ |
| 3๊ณ์ธต | Controller(์ ์) โ Service(์ฒ๋ฆฌ) โ Repository(DB) | ์ํ ์ฐฝ๊ตฌ โ ์ง์ โ ๊ธ๊ณ |
| DI(์์กด์ฑ ์ฃผ์ ) | ํ์ํ ๋ถํ์ ์คํ๋ง์ด ์์์ ๊ฝ์์ค | ์ฝ์ผํธ์ ํ๋ฌ๊ทธ ๊ฝ๊ธฐ(์ ๊ธฐ๋ ํ์ ์ด ์ค) |
| JPA/Hibernate | ์๋ฐ ๊ฐ์ฒด โ DB ํ ์ด๋ธ ์๋ ๋ณํ | ํ๊ตญ์ดโ์์ด ์๋ ํต์ญ๊ธฐ |
| ์ด ํ๋ก์ ํธ ํน์์ฑ | Spring Security ๋ โ ์ธ๊ฐ๋ ์ปจํธ๋กค๋ฌ์์ AuthContext null ์ฒดํฌ๋ก ์๋ |
์๋๋ฌธ ๋์ , ์ง์์ด ์ง์ ์ ๋ถ์ฆ ํ์ธ |
๋น์ : ์์ง(Analytics)์ด "์ซ์๋ฅผ ๊ณ์ฐํ๋ ์ฐ๊ตฌ์" ์๋ค๋ฉด, ๋ฐฑ์๋(Spring)๋ "์ํ ์ฐฝ๊ตฌ" ์ ๋๋ค. ๋๊ฐ(์ธ์ฆ) ๋ฌด์์(์์ฒญ) ์๊ตฌํ๋์ง ํ์ธํ๊ณ , ๊ธ๊ณ (DB)์์ ๋/๋ฐ์ดํฐ๋ฅผ ๊บผ๋ด๊ฑฐ๋ ๋ฃ๊ณ , ์์์ฆ(JSON)์ ๋๋ ค์ค๋๋ค.
๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
1) "์ ๋ํ ์ด์ (Annotation)" = ์ฝ๋์ ๋ถ์ด๋ ์คํฐ์ปค ๋ผ๋ฒจ
@RestController // โ ์ด๊ฒ ์ ๋ํ
์ด์
public class NotificationController { ... }
@๋ก ์์ํ๋ ํ ์ค์ง๋ฆฌ ํ์. ์คํ๋๋ ์ฝ๋๊ฐ ์๋๋ผ, ์คํ๋ง์๊ฒ ์ฃผ๋ ์ง์๋ฌธ์ ๋๋ค.@RestController๋ "์ด ํด๋์ค๋ HTTP ์์ฒญ์ ๋ฐ๋ ์ ์์ฒ์ผ"๋ผ๊ณ ์คํ๋ง์๊ฒ ์๋ ค์ฃผ๋ ๋ผ๋ฒจ.- ๋น์ : ํ๋ฐฐ ์์์ ๋ถ์ "์ทจ๊ธ์ฃผ์", "๋์ฅ๋ณด๊ด" ์คํฐ์ปค. ์์ ๋ด์ฉ๋ฌผ(์ฝ๋)์ ๊ทธ๋๋ก์ง๋ง, ์ฒ๋ฆฌํ๋ ์ชฝ(์คํ๋ง)์ด ๋ผ๋ฒจ์ ๋ณด๊ณ ๋ค๋ฅด๊ฒ ๋ค๋ฃน๋๋ค.
2) "๋น(Bean)" = ์คํ๋ง์ด ๋ง๋ค์ด์ ๋ณด๊ด ์ค์ธ ๋ถํ ๊ฐ์ฒด ํ๋
- ์คํ๋ง์ ์ฑ์ด ์ผ์ง ๋
@Component,@Service,@RestController,@Repository๋ผ๋ฒจ์ด ๋ถ์ ํด๋์ค๋ค์ ํ๋์ฉ ๋ง๋ค์ด ์ฐฝ๊ณ (ApplicationContext)์ ๋ฃ์ด๋ก๋๋ค. ์ด ์ฐฝ๊ณ ์ ๊ฐ์ฒด ํ๋ํ๋๊ฐ "๋น"์ ๋๋ค. - ๋น์ : ๊ณต์ฅ์ด ๊ฐ๋๋๋ฉด ํ์ํ ๊ธฐ๊ณ(๋ถํ)๋ฅผ ๋ฏธ๋ฆฌ ๋ค ์กฐ๋ฆฝํด ๊ณต์ฅ ํ์ชฝ์ ์ธ์๋ . ํ์ํ ๋ ๊ฐ์ ธ๋ค ์(์๋ก ์ ๋ง๋ฆ).
3) "HTTP ์์ฒญ/์๋ต" = ํธ์ง ํ ํต
์์ฒญ: GET /api/notifications โ "๋ด ์๋ฆผ ๋ชฉ๋ก ์ค"
์๋ต: 200 OK [ {...}, {...} ] โ "์ฌ๊ธฐ ์์ด (์ํ์ฝ๋ + JSON ๋ณธ๋ฌธ)"- ๋ฉ์๋(GET=์กฐํ / POST=์์ฑ / PATCH=์ผ๋ถ์์ / DELETE=์ญ์ ) + ๊ฒฝ๋ก(
/api/notifications) + ๊ฐ๋ ๋ณธ๋ฌธ(body, JSON). - ์๋ต์๋ ์ํ์ฝ๋(200 ์ฑ๊ณต, 401 ์ธ์ฆํ์, 404 ์์, 500 ์๋ฒ์ค๋ฅ)์ ๋ณธ๋ฌธ์ด ๋ค์ด๊ฐ๋๋ค.
4) "JSON" = ํ๋ก ํธ(React)์ ๋ฐฑ์๋๊ฐ ์ฃผ๊ณ ๋ฐ๋ ๊ณตํต ๋ฌธ์ ํฌ๋งท
{ "id": 7, "title": "์ฒด๊ฒฐ ์๋ฃ", "isRead": false }
- ์๋ฐ ๊ฐ์ฒด โ JSON ๋ณํ์ ์คํ๋ง(Jackson ๋ผ์ด๋ธ๋ฌ๋ฆฌ)์ด ์๋์ผ๋ก ํฉ๋๋ค. ์ฐ๋ฆฌ๋ ์๋ฐ ๊ฐ์ฒด๋ง ๋ฐํํ๋ฉด ๋ฉ๋๋ค.
๐บ ์ ์ฒด ํ๋ฆ๋ โ ์์ฒญ ํ๋์ ์ผ์
์ด ํ๋ก์ ํธ์ ์๋ฆผ(notification) ๋๋ฉ์ธ์ ์๋ก, GET /api/notifications ์์ฒญ ํ๋๊ฐ ํ๋ฅด๋ ๊ฒฝ๋ก:
[React ํ๋ก ํธ] GET /api/notifications (+ HttpOnly JWT ์ฟ ํค)
โ
โผ
[JwtAuthenticationFilter] ์ฟ ํค์์ JWT ๊บผ๋ด โ request์ userId ๋์ฅ ์ฐ์ โ
ํํฐ ๋จ๊ณ
โ
โผ
[NotificationController] @GetMapping โ "์ ์์ฒ"
โ AuthContext.currentUserId() ๋ก ์ ์ ํ์ธ (null์ด๋ฉด 401) โ
์๋ ์ธ๊ฐ
โผ
[StreamChatService] @Transactional โ "์ฒ๋ฆฌ ์ง์" (๋น์ฆ๋์ค ๊ท์น)
โ
โผ
[NotificationRepository] findByUser...() โ "๊ธ๊ณ ์ง๊ธฐ" (SQL ์๋ ์์ฑ)
โ
โผ
[MySQL NOTIFICATION ํ
์ด๋ธ] โ ์ค์ ๋ฐ์ดํฐ
โ (์กฐํ๋ Notification ์ํฐํฐ๋ค์ด ๊ฑฐ๊พธ๋ก ์ฌ๋ผ์ด)
โผ
[Controller] ์ํฐํฐ โ NotificationResponse(DTO) ๋ณํ โ ResponseEntity.ok(...)
โ
โผ
[์คํ๋ง/Jackson] DTO โ JSON ์๋ ๋ณํ โ 200 OK ์๋ต์ด ํ ์ฅ์ด ๋ฐฑ์๋์ ์ ๋ถ์ ๋๋ค. ์๋์์ ๊ฐ ๋จ๊ณ๋ฅผ ์ค์ ์ฝ๋๋ก ํ๋๋ค.
๐ ๊ฐ๋ ๋ณ ํด์ค (์ค์ ์ฝ๋ ์ธ์ฉ + ๋น์ )
A. Spring Boot ๊ฐ ๋ญ๊ฐ โ "์๋ ์กฐ๋ฆฝ + ๋ด์ฅ ํฐ์บฃ"
backend/build.gradle ์ ๋งจ ์:
// build.gradle:1-5
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.4'
id 'io.spring.dependency-management' version '1.1.7'
}
- Spring Boot ๋ "์คํ๋ง์ผ๋ก ์๋ฒ๋ฅผ ๋ง๋ค ๋ ํ์ํ ์๋ฐฑ ๊ฐ์ง ์ค์ ์ ๋ฏธ๋ฆฌ ํด๋ ํ์ธํธ"์
๋๋ค. ํต์ฌ ๋ง๋ฒ ๋ ๊ฐ์ง:
- ์๋์ค์ (Auto-configuration) โ DB ๋๋ผ์ด๋ฒ๋ฅผ ์์กด์ฑ์ ๋ฃ๊ธฐ๋ง ํ๋ฉด, ์คํ๋ง์ด "์ MySQL ์ฐ๋๊ตฌ๋" ํ๊ณ ์ฐ๊ฒฐ์ ์์์ ๊ตฌ์ฑ. ์ฐ๋ฆฌ๊ฐ XML๋ก ์ผ์ผ์ด ์ค์ ํ์ง ์์ต๋๋ค.
- ๋ด์ฅ ํฐ์บฃ(Embedded Tomcat) โ ์๋ ์ ํฐ์บฃ(์น์๋ฒ)์ ๋ฐ๋ก ์ค์นํ๊ณ ๊ทธ ์์ ์ฑ์ ์น์์ง๋ง, Spring Boot ๋ ์น์๋ฒ๋ฅผ ์ฑ ์์ ํ๊ณ ์์ด
gradlew bootRunํ ์ค์ด๋ฉด ์๋ฒ๊ฐ ์ผ์ง๋๋ค.
build.gradle ์ ์์กด์ฑ(dependencies) ๋ธ๋ก์ด "์ด๋ค ๋ถํ์ ์ธ์ง"์ ๋ชฉ๋ก์
๋๋ค:
// build.gradle:27-34 (๋ฐ์ท)
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // JPA(DB)
implementation 'org.springframework.boot:spring-boot-starter-webmvc' // ์น(HTTP)
implementation 'io.jsonwebtoken:jjwt-api:0.12.6' // JWT
implementation 'org.flywaydb:flyway-core' // DB ๋ง์ด๊ทธ๋ ์ด์
...starter-*๋ "์ธํธ ๋ฉ๋ด" ์ ๋๋ค.starter-webmvcํ๋๋ฉด HTTP ์ฒ๋ฆฌ์ ํ์ํ ์์ญ ๊ฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊ฐ ํ ๋ฒ์ ๋ธ๋ ค์ต๋๋ค.
๐ก ์ด๋ณด ํฌ์ธํธ: CLAUDE.md์ "Java 21"์ด๋ผ ์ ํ ์์ง๋ง ์ค์
build.gradle์ toolchain์ 17(JavaLanguageVersion.of(17))์ ๋๋ค. ๋ฌธ์์ ์ฝ๋๊ฐ ๋ค๋ฅผ ๋ ์ฝ๋๊ฐ ์ง์ค์ ๋๋ค โ ํญ์ ์ค์ ๋น๋ ํ์ผ์ ํ์ธํ์ธ์.
B. 3๊ณ์ธต ์ํคํ ์ฒ โ Controller โ Service โ Repository
์ด ํ๋ก์ ํธ๋ ๋๋ฉ์ธ ๋๋ฆฌ๋ธ ๊ตฌ์กฐ๋ก, ๊ฐ ๋๋ฉ์ธ(notification, user, strategy...)์ด ์๊ธฐ๋ง์ 5๊ฐ ํด๋๋ฅผ ๊ฐ์ง๋๋ค:
domain/notification/
โโโ controller/ โ ์ ์์ฒ (HTTP ์์ฒญ ๋ฐ๊ธฐ)
โโโ service/ โ ์ฒ๋ฆฌ์ค (๋น์ฆ๋์ค ๊ท์น) โป์๋ฆผ์ chat ๋๋ฉ์ธ์ StreamChatService๊ฐ ๊ฒธํจ
โโโ entity/ โ DB ํ
์ด๋ธ์ ์๋ฐ ๊ฑฐ์ธ
โโโ repository/ โ ๊ธ๊ณ ์ง๊ธฐ (DB ์ฝ๊ณ ์ฐ๊ธฐ)
โโโ dto/ โ ๋ฐ๊นฅ์ธ์๊ณผ ์ฃผ๊ณ ๋ฐ๋ ํฌ์ฅ์ง์ 3๊ฐ๋ก ์ชผ๊ฐ๋? ๋น์ : ์ํ์์ "์ฐฝ๊ตฌ ์ง์์ด ์ง์ ๊ธ๊ณ ์ ๋ค์ด๊ฐ ๋์ ์ธ๊ณ ์์์ฆ๊น์ง ์ธ์"ํ๋ฉด ํผ๋์ค๋ฝ๊ณ ์ํํฉ๋๋ค. ๊ทธ๋์ ์ฐฝ๊ตฌ(Controller)๋ ์ ์๋ง, ์ง์(Service)์ ๊ท์น ํ๋จ๋ง, ๊ธ๊ณ ์ง๊ธฐ(Repository)๋ ๋ณด๊ด๋ง โ ์ญํ ์ ๋๋ ๊ฐ์ ํ ๊ฐ์ง๋ง ์ํ๊ฒ ํฉ๋๋ค. ํ ๊ณณ์ด ๊ณ ์ฅ ๋๋ ๋ค๋ฅธ ๊ณณ์ ์ํฅ์ด ์ ๊ณ , ํ ์คํธยท๊ต์ฒด๊ฐ ์ฌ์์ง๋๋ค.
1) Controller โ "์ ์์ฒ"
// NotificationController.java:23-39
@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final StreamChatService streamChatService;
private final UserRepository userRepository;
/** GET /api/notifications โ ์ธ์ฆ ์ฌ์ฉ์ ๋ณธ์ธ ์๋ฆผ ์ ์ฒด, ์ต์ ์. */
@GetMapping
public ResponseEntity<?> getAll() {
User user = currentUser();
if (user == null) return unauthorized();
List<NotificationResponse> list = streamChatService.getNotificationsForUser(user)
.stream().map(NotificationResponse::from).toList();
return ResponseEntity.ok(list);
}
@RestControllerโ "์ด ํด๋์ค๋ HTTP ์ ์์ฒ, ๋ฐํ๊ฐ์ JSON์ผ๋ก"@RequestMapping("/api/notifications")โ ์ด ํด๋์ค์ ๋ชจ๋ ๋ฉ์๋ ๊ฒฝ๋ก ์์ ๊ณตํต์ผ๋ก ๋ถ๋ ์ฃผ์ ์ ๋์ฌ.- ์ปจํธ๋กค๋ฌ๋ ์ง์ ์ผํ์ง ์์ต๋๋ค.
streamChatService.getNotificationsForUser(user)๋ก ์ฒ๋ฆฌ๋ฅผ Service์๊ฒ ์์ํ๊ณ , ๊ฒฐ๊ณผ๋ฅผ DTO๋ก ํฌ์ฅํด ๋๋ ค์ค ๋ฟ์ ๋๋ค. (์ฐฝ๊ตฌ ์ง์์ด ๊ธ๊ณ ์ ์ง์ ์ ๋ค์ด๊ฐ๋ ๊ฒ.)
2) Service โ "์ฒ๋ฆฌ์ค (๋น์ฆ๋์ค ๊ท์น)"
// StreamChatService.java:226-251 (๋ฐ์ท)
public List<Notification> getNotificationsForUser(User user) {
return notificationRepository.findByUserOrderByCreatedAtDesc(user);
}
@Transactional
public void markNotificationRead(Long notificationId, User user) {
notificationRepository.findById(notificationId).ifPresent(n -> {
if (n.getUser().getId().equals(user.getId())) { // โ
์์ ๊ถ ์ฌํ์ธ
n.setRead(true);
notificationRepository.save(n);
}
});
}
- Service๋ "๊ท์น" ์ด ์ฌ๋ ๊ณณ์
๋๋ค. ์
markNotificationRead๋ ๋จ์ํ "์ฝ์ ์ฒ๋ฆฌ"๊ฐ ์๋๋ผ, "์ด ์๋ฆผ์ด ์ ๋ง ์ด ์ฌ์ฉ์ ๊ฒ์ธ์ง ํ ๋ฒ ๋ ํ์ธ"(n.getUser().getId().equals(user.getId()))ํ๋ ๋ณด์ ๊ท์น์ ํ๊ณ ์์ต๋๋ค. ๋จ์ ์๋ฆผ์ด๋ฉด ์กฐ์ฉํ ๋ฌด์ํฉ๋๋ค. - Repository(๊ธ๊ณ ์ง๊ธฐ)๋ฅผ ํธ์ถํด ์ค์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๊ฑฐ๋ ์ ์ฅํฉ๋๋ค.
3) Repository โ "๊ธ๊ณ ์ง๊ธฐ (DB)"
// NotificationRepository.java:12-22
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserOrderByCreatedAtDesc(User user);
List<Notification> findByUserAndIsReadFalseOrderByCreatedAtDesc(User user);
long countByUserAndIsReadFalse(User user);
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.user = :user AND n.isRead = false")
void markAllReadByUser(@Param("user") User user);
}
- ๋๋ผ์ด ์ : ์ด๊ฑด
interface(๊ป๋ฐ๊ธฐ)์ผ ๋ฟ, ๊ตฌํ ์ฝ๋(SQL)๊ฐ ์์ต๋๋ค. ๊ทธ๋ฐ๋ฐ๋ ๋์ํฉ๋๋ค. (โ ์๋ D์ "๋ฉ์๋ ์ด๋ฆ ์ฟผ๋ฆฌ"์์ ์ค๋ช .) extends JpaRepository<Notification, Long>โ "Notification ์ํฐํฐ๋ฅผ, ID ํ์ ์ Long์ผ๋ก ๋ค๋ฃจ๋ ๊ธ๊ณ ์ง๊ธฐ" ๋ผ๋ ๋ป. ์ด๊ฒ๋ง์ผ๋กsave(),findById(),findAll(),delete()๊ฐ์ ๊ธฐ๋ณธ CRUD๊ฐ ๊ณต์ง๋ก ์๊น๋๋ค.
๐ก ์ด๋ณด ํฌ์ธํธ: ๋ฐ์ดํฐ๋ ์โ์๋๋ก ์์ฒญ์ด ๋ด๋ ค๊ฐ๊ณ , ๊ฒฐ๊ณผ๋ ์๋โ์๋ก ์ฌ๋ผ์ต๋๋ค. Controller๋ Service๋ง ์๊ณ , Service๋ Repository๋ง ์๊ณ , Repository๋ง DB๋ฅผ ์๋๋ค. ์ด "ํ ๋ฐฉํฅ ์์กด"์ด ๊นจ์ง๋ฉด(์: Repository๊ฐ Controller๋ฅผ ๋ถ๋ฅด๋ฉด) ๊ตฌ์กฐ๊ฐ ๋ฌด๋์ง๋๋ค.
C. ์์กด์ฑ ์ฃผ์
(DI) ยท @RequiredArgsConstructor ยท ๋น(Bean)
์ ์ปจํธ๋กค๋ฌ์์ streamChatService, userRepository๋ ์ด๋์ ๋ง๋ค์ด์ก์๊น์? ์ฐ๋ฆฌ๋ new StreamChatService()๋ฅผ ์ด๋์๋ ์ฐ์ง ์์์ต๋๋ค.
// NotificationController.java:25-29
@RequiredArgsConstructor
public class NotificationController {
private final StreamChatService streamChatService;
private final UserRepository userRepository;
DI(Dependency Injection, ์์กด์ฑ ์ฃผ์ ) = "๋ด๊ฐ ํ์ํ ๋ถํ์ ๋ด๊ฐ
new๋ก ๋ง๋ค์ง ์๊ณ , ์คํ๋ง์ด ๋ง๋ค์ด์ ๊ฝ์์ค๋ค".@RequiredArgsConstructor(Lombok) โfinalํ๋๋ค์ ๋ฐ๋ ์์ฑ์๋ฅผ ์๋์ผ๋ก ๋ง๋ค์ด์ค๋๋ค. ์ฆ ๋ด๋ถ์ ์ผ๋ก๋:public NotificationController(StreamChatService s, UserRepository u) { this.streamChatService = s; this.userRepository = u; }์ด ์์ฑ์๋ฅผ ์คํ๋ง์ด ๋ณด๊ณ , ์ฐฝ๊ณ (ApplicationContext)์์ ํด๋น ํ์ ์ ๋น์ ์ฐพ์ ์ธ์๋ก ๋ฃ์ด์ค๋๋ค.
๋น์ : ์ ๊ฐ์ ์ ํ(์ปจํธ๋กค๋ฌ)์ ์ฌ๋ฉด ์ ๊ธฐ๋ฅผ ์ง์ ๋ฐ์ ํ์ง ์์ต๋๋ค. ์ฝ์ผํธ(์์ฑ์)์ ํ๋ฌ๊ทธ๋ฅผ ๊ฝ์ผ๋ฉด ํ์ (์คํ๋ง)์ด ์ ๊ธฐ(๋น)๋ฅผ ๊ณต๊ธ. ์ฐ๋ฆฌ๋ "์ ๊ธฐ๊ฐ ํ์ํ๋ค"๊ณ ์ ์ธ๋ง ํ๋ฉด ๋ฉ๋๋ค.
์ ์ข์๊ฐ? ํ ์คํธํ ๋ ์ง์ง Service ๋์ ๊ฐ์ง(Mock) Service๋ฅผ ๊ฝ์ ์ปจํธ๋กค๋ฌ๋ง ๋ฐ๋ก ์ํํ ์ ์์ต๋๋ค. ๋ถํ์ ๊ฐ์๋ผ์ฐ๊ธฐ ์ฌ์์ง๋๋ค.
๐ก ์ด๋ณด ํฌ์ธํธ:
final+@RequiredArgsConstructor์กฐํฉ์ด ์ด ํ๋ก์ ํธ์ ํ์ค DI ํจํด์ ๋๋ค. ํ๋์@Autowired๋ฅผ ์ง์ ๋ถ์ด๋ ์๋ ๋ฐฉ์๋ณด๋ค ๊ถ์ฅ๋ฉ๋๋ค(๋ถ๋ณยทํ ์คํธ ์ฉ์ด).
D. HTTP ์์ฒญ์ด ์๋ฐ ๋ฉ์๋๋ก ๋งคํ๋๋ ๋ฒ (๋งคํ ์ ๋ํ ์ด์ ์ด์ ๋ฆฌ)
์คํ๋ง์ "์ด๋ค URL + ๋ฉ์๋"๊ฐ ๋ค์ด์ค๋ฉด "์ด๋ค ์๋ฐ ํจ์"๋ฅผ ๋ถ๋ฅผ์ง ์ ๋ํ ์ด์ ์ผ๋ก ์ฐ๊ฒฐํฉ๋๋ค. ์๋ฆผ ์ปจํธ๋กค๋ฌ์ ๋ค ๋ค์ด ์์ต๋๋ค:
// NotificationController.java (๋ฐ์ท)
@GetMapping // GET /api/notifications
public ResponseEntity<?> getAll() { ... }
@GetMapping("/unread") // GET /api/notifications/unread
public ResponseEntity<?> getUnread() { ... }
@PatchMapping("/{notificationId}/read") // PATCH /api/notifications/3/read
public ResponseEntity<?> markOneRead(@PathVariable Long notificationId) { ... }
@PatchMapping("/read-all") // PATCH /api/notifications/read-all
public ResponseEntity<?> markAllRead() { ... }
| ์ ๋ํ ์ด์ | HTTP ๋ฉ์๋ | ์ฉ๋ | ๋น์ |
|---|---|---|---|
@GetMapping |
GET | ์กฐํ (๋ฐ์ดํฐ ์ ๋ฐ๊ฟ) | "์์ก ์กฐํํด์ค" |
@PostMapping |
POST | ์์ฑ (์๋ก ๋ง๋ฆ) | "์ ๊ณ์ข ๋ง๋ค์ด์ค" |
@PatchMapping |
PATCH | ์ผ๋ถ ์์ | "์ฃผ์๋ง ๋ฐ๊ฟ์ค" |
@DeleteMapping |
DELETE | ์ญ์ | "๊ณ์ข ๋ซ์์ค" |
@RequestMapping("/api/...") |
(ํด๋์ค ๋ ๋ฒจ) | ๊ณตํต ๊ฒฝ๋ก ์ ๋์ฌ | "์ด ์ฐฝ๊ตฌ๋ 3๋ฒ ์ฐฝ๊ตฌ" |
์์ฒญ์์ ๊ฐ์ ๊บผ๋ด๋ 3๊ฐ์ง ์ ๊ตฌ:
@PathVariableโ URL ๊ฒฝ๋ก ์์ ๋ณ์.// NotificationController.java:61-62 @PatchMapping("/{notificationId}/read") public ResponseEntity<?> markOneRead(@PathVariable Long notificationId) {/api/notifications/**3**/read๋ก ์์ฒญํ๋ฉดnotificationId = 3์ด ๋ฉ๋๋ค. ๊ฒฝ๋ก์{notificationId}์นธ๊ณผ ๋ฉ์๋ ์ธ์ ์ด๋ฆ์ด ์ง์ง์ด์ง๋๋ค.
@RequestParamโ?key=value์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ. (์:?page=2) โ ์ด ์ปจํธ๋กค๋ฌ์์ ์๋์ ์ผ๋ก ์ ์๋๋ค. ์๋ โ ๏ธ ํน์ด์ ์ ์์ "์?userId=๋ฅผ ์ ๋ฐ๋์ง" ์ค๋ช ํฉ๋๋ค.@RequestBodyโ POST/PATCH์ ๋ณธ๋ฌธ(JSON) ์ ์๋ฐ ๊ฐ์ฒด๋ก ๋ณํ.// (๋ค๋ฅธ ๋๋ฉ์ธ ์์ ํจํด) ๋ก๊ทธ์ธ ์์ฒญ ๋ณธ๋ฌธ์ ๊ฐ์ฒด๋ก ๋ฐ๊ธฐ @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest req) { ... }- ํ๋ก ํธ๊ฐ ๋ณด๋ธ
{"email":"...","password":"..."}JSON์ ์คํ๋ง์ดLoginRequest๊ฐ์ฒด๋ก ์๋ ๋ณํํด ๊ฝ์์ค๋๋ค.
- ํ๋ก ํธ๊ฐ ๋ณด๋ธ
๐ก ์ด๋ณด ํฌ์ธํธ: GET์ ๋ณธ๋ฌธ(
@RequestBody)์ ๊ฑฐ์ ์ ์ฐ๊ณ ๊ฒฝ๋ก/์ฟผ๋ฆฌ๋ก ๊ฐ์ ๋ฐ์ต๋๋ค. "๋ฐ์ดํฐ๋ฅผ ๋ฐ๊พธ๋(POST/PATCH/PUT)" ์์ฒญ๋ง ๋ณธ๋ฌธ JSON์ ๋ณด๋ ๋๋ค.
E. ResponseEntity ์ ์ํ์ฝ๋
์ปจํธ๋กค๋ฌ๋ ๋ณดํต ๊ทธ๋ฅ ๊ฐ์ฒด๋ฅผ ๋ฐํํด๋ ๋์ง๋ง, ์ํ์ฝ๋๊น์ง ์ง์ ์ ํ๊ณ ์ถ์ ๋ ResponseEntity๋ฅผ ์๋๋ค.
// NotificationController.java:33-38, 53-57, 65-66, 85-87
return ResponseEntity.ok(list); // 200 OK + ๋ณธ๋ฌธ
return ResponseEntity.ok(Map.of("unreadCount", count)); // 200 OK + JSON {unreadCount: 5}
return ResponseEntity.noContent().build(); // 204 No Content (์ฑ๊ณต, ๋ณธ๋ฌธ ์์)
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) // 401 + {error:"์ธ์ฆ ํ์"}
.body(Map.of("error", "์ธ์ฆ ํ์"));
| ์ฝ๋ | ์๋ฏธ | ์ด ํ๋ก์ ํธ์์ |
|---|---|---|
| 200 OK | ์ฑ๊ณต + ๋ฐ์ดํฐ ์์ | ์๋ฆผ ๋ชฉ๋ก ๋ฐํ |
| 204 No Content | ์ฑ๊ณต + ๋๋ ค์ค ๋ณธ๋ฌธ ์์ | "์ฝ์ ์ฒ๋ฆฌ" ํ |
| 401 Unauthorized | ์ธ์ฆ ํ์(๋ก๊ทธ์ธ ์ ๋จ) | currentUser() == null ์ผ ๋ |
| 404 Not Found | ์์ ์์ | ์กด์ฌํ์ง ์๋ ID |
| 500 Internal Server Error | ์๋ฒ ํฐ์ง | ์กํ์ง ์์ ์์ธ |
Map.of("unreadCount", count)๊ฐ์ ์์ ์๋ฐ Map ๋ Jackson์ด JSON์ผ๋ก ๋ณํํด์ค๋๋ค({"unreadCount": 5}). ๋ณ๋ DTO ํด๋์ค๋ฅผ ๋ง๋ค ๋งํผ ๋ฌด๊ฒ์ง ์์ ์๋ต์ ์ฐ๋ ํํ ์์ฌ์ด ํจํด.
๐ก ์ด๋ณด ํฌ์ธํธ:
ResponseEntity<?>์<?>๋ "๋ณธ๋ฌธ ํ์ ์ด ๊ฒฝ์ฐ๋ง๋ค ๋ค๋ฅด๋ค(๋ฆฌ์คํธ์ผ ์๋, Map์ผ ์๋)"๋ผ์ ์์ผ๋์นด๋๋ก ๋ ๊ฒ. ํ ๋ฉ์๋๊ฐ ์ฑ๊ณต ์ ๋ฆฌ์คํธ, ์คํจ ์ ์๋ฌ๋งต์ ๋ฐํํ๋ ์์ฐ์ค๋ฝ์ต๋๋ค.
F. JPA / Hibernate โ ์๋ฐ ๊ฐ์ฒด โ DB ํ ์ด๋ธ
JPA(Java Persistence API)๋ "์๋ฐ ๊ฐ์ฒด์ DB ํ ์ด๋ธ์ ์๋์ผ๋ก ์ด์ด์ฃผ๋ ๊ท๊ฒฉ"์ด๊ณ , Hibernate๋ ๊ทธ ๊ท๊ฒฉ์ ์ค์ ๋ก ๊ตฌํํ ์์ง์ ๋๋ค. ํต์ฌ ๋ง๋ฒ: SQL์ ๊ฑฐ์ ์ ์ฐ๊ณ ๋ DB๋ฅผ ๋ค๋ฃน๋๋ค.
1) @Entity โ "DB ํ
์ด๋ธ์ ์๋ฐ ๊ฑฐ์ธ"
// Notification.java:15-31 (๋ฐ์ท)
@Entity
@Table(name = "NOTIFICATION")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@EntityListeners(AuditingEntityListener.class)
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Entityโ "์ด ํด๋์ค๋ DB ํ ์ด๋ธ ํ๋์ ๋์ํ๋ค". ์ด ํด๋์ค์ ๊ฐ์ฒด ํ ๊ฐ = ํ ์ด๋ธ์ ํ(row) ํ ๊ฐ.@Table(name = "NOTIFICATION")โ ๋์ํ๋ ํ ์ด๋ธ ์ด๋ฆ.@Idโ ์ด ํ๋๊ฐ ๊ธฐ๋ณธํค(PK), ํ์ ๊ตฌ๋ถํ๋ ๊ณ ์ ๋ฒํธ.@GeneratedValue(strategy = IDENTITY)โ ID๋ฅผ DB๊ฐ ์๋ ์ฆ๊ฐ(auto-increment) ๋ก ๋งค๊ฒจ์ค. ์ฐ๋ฆฌ๊ฐ ์ง์ ์ ๋ฃ์.@ManyToOneโ ๊ด๊ณ ๋งคํ. "์ฌ๋ฌ(Many) ์๋ฆผ์ด ํ(One) ์ ์ ์ ์ํ๋ค". ์๋ฐ์์notification.getUser()๋ก ๊ฐ์ฒด๋ฅผ ๋ฐ๋ก ๊บผ๋ผ ์ ์๊ณ , DB์์user_id์ธ๋ํค(FK) ์ปฌ๋ผ์ผ๋ก ์ ์ฅ๋ฉ๋๋ค.@JoinColumn(name = "user_id")โ ๊ทธ FK ์ปฌ๋ผ ์ด๋ฆ.fetch = FetchType.LAZYโ ์ง์ฐ ๋ก๋ฉ. ์๋ฆผ์ ์ฝ์ ๋ ์ฐ๊ฒฐ๋ User๊น์ง ์ฆ์ ๋ค ๋ถ๋ฌ์ค์ง ์๊ณ ,getUser()๋ฅผ ์ค์ ๋ก ํธ์ถํ๋ ์๊ฐ์๋ง DB์์ ๊ฐ์ ธ์ด. (๋ถํ์ํ ์กฐํ๋ฅผ ์ค์ฌ ์ฑ๋ฅโ.)
๊ฐ ์ปฌ๋ผ์ @Column์ผ๋ก ์ธ๋ถ ์ค์ ํฉ๋๋ค:
// Notification.java:37-55 (๋ฐ์ท)
@Column(nullable = false, length = 200)
private String title; // NOT NULL, VARCHAR(200)
@Column(nullable = false, columnDefinition = "TEXT")
private String message; // TEXT ํ์
@Enumerated(EnumType.STRING)
@Column(name = "notification_type", nullable = false, length = 50)
private NotificationType notificationType; // enum์ "๋ฌธ์์ด"๋ก ์ ์ฅ (์ซ์ X)
@Column(name = "is_read", nullable = false)
@Builder.Default
private boolean isRead = false; // ๊ธฐ๋ณธ๊ฐ false
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; // ์์ฑ ์๊ฐ ์๋ ๊ธฐ๋ก (์์ ๋ถ๊ฐ)
@Enumerated(EnumType.STRING)โNEW_MESSAGE๊ฐ์ enum์ DB์ ๋ฌธ์์ด ๊ทธ๋๋ก ์ ์ฅ. (EnumType.ORDINAL๋ก ์ซ์ 0,1,2 ์ ์ฅํ๋ฉด enum ์์ ๋ฐ๋ ๋ ๋ฐ์ดํฐ๊ฐ ๊นจ์ง๋ฏ๋ก STRING์ด ์์ .)@CreatedDate+@EntityListeners(AuditingEntityListener.class)โ ํ์ด ์ฒ์ ์ ์ฅ๋ ๋ ํ์ฌ ์๊ฐ์ ์๋ ๊ธฐ์ .updatable = false๋ผ ์ดํ ์์ ๋ถ๊ฐ(์์ฑ ์๊ฐ์ ์์ํ ๊ณ ์ ).
2) Repository ์ธํฐํ์ด์ค๊ฐ SQL ์์ด ๋์ํ๋ ๋ฒ โ ๋ฉ์๋ ์ด๋ฆ ์ฟผ๋ฆฌ
๋ค์ Repository๋ฅผ ๋ด ์๋ค:
// NotificationRepository.java:14-18
List<Notification> findByUserOrderByCreatedAtDesc(User user);
List<Notification> findByUserAndIsReadFalseOrderByCreatedAtDesc(User user);
long countByUserAndIsReadFalse(User user);
- ์ถฉ๊ฒฉ ํฌ์ธํธ: ๊ตฌํ์ด ์๋๋ฐ ๋์ํฉ๋๋ค. ์คํ๋ง ๋ฐ์ดํฐ JPA๊ฐ ๋ฉ์๋ ์ด๋ฆ์ ๋ถ์ํด SQL์ ์๋ ์์ฑํ๊ธฐ ๋๋ฌธ์ ๋๋ค.
- ์ด๋ฆ์ ๋จ์ด๋ก ๋์ด ์ฝ์ผ๋ฉด ๊ทธ๊ฒ ๊ณง ์ฟผ๋ฆฌ์
๋๋ค:
findByUserOrderByCreatedAtDesc
โSELECT * FROM notification WHERE user_id = ? ORDER BY created_at DESCcountByUserAndIsReadFalse
โSELECT COUNT(*) FROM notification WHERE user_id = ? AND is_read = false
- ๋น์ : ๋ฉ๋ดํ์ "๋งค์ดยท๊ณฑ๋นผ๊ธฐยท๊ณ๋์ถ๊ฐ ๋ผ๋ฉด"์ด๋ผ๊ณ ์ ์ผ๋ฉด ์ฃผ๋ฐฉ์ฅ์ด ์์์ ๊ทธ๋ ๊ฒ ๋ง๋ค์ด ์ค. ์ฐ๋ฆฌ๋ ์ด๋ฆ๋ง ์ ํํ ์ง๊ณ ๋ ์ํผ(SQL)๋ ์ ์๋๋ค.
์ด๋ฆ์ผ๋ก ํํํ๊ธฐ ๋ณต์กํ ์ฟผ๋ฆฌ๋ @Query๋ก ์ง์ JPQL์ ์๋๋ค:
// NotificationRepository.java:20-22
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.user = :user AND n.isRead = false")
void markAllReadByUser(@Param("user") User user);
@Queryโ ์ง์ ์์ฑํ ์ฟผ๋ฆฌ. ๋จ, ํ ์ด๋ธ์ด ์๋๋ผNotification์ด๋ผ๋ ์ํฐํฐ(์๋ฐ ํด๋์ค)๋ฅผ ๋์์ผ๋ก ํ๋ JPQL์ ๋๋ค(SQL๊ณผ ๋น์ทํ์ง๋ง ๊ฐ์ฒด ๊ธฐ์ค).@Modifyingโ "์ด๊ฑด ์กฐํ๊ฐ ์๋๋ผ ๋ฐ์ดํฐ๋ฅผ ๋ฐ๊พธ๋ ์ฟผ๋ฆฌ"๋ผ๊ณ ์๋ฆผ(UPDATE/DELETE์ ํ์).:user/@Param("user")โ ์ฟผ๋ฆฌ ์ ์๋ฆฌํ์์์ ๋ฉ์๋ ์ธ์๋ฅผ ๋ผ์๋ฃ์(SQL ์ธ์ ์ ์์ ).
๐ก ์ด๋ณด ํฌ์ธํธ: ์คํ๊ฐ ๋ฌด์ญ์ต๋๋ค.
findByUesr(์คํ)๋ผ๊ณ ์ฐ๋ฉด ์คํ๋ง์ดUesr๋ผ๋ ํ๋๋ฅผ ๋ชป ์ฐพ์ ์ฑ ๊ธฐ๋ ์ ์๋ฌ๋ก ์๋ ค์ค๋๋ค(๋คํํ ๋ฐํ์ ์ค์ด ์๋๋ผ ์์ํ ๋ ํฐ์ง).
G. @Transactional โ ํธ๋์ญ์
(๋ชจ ์๋๋ฉด ๋)
// StreamChatService.java:243-251
@Transactional
public void markNotificationRead(Long notificationId, User user) {
notificationRepository.findById(notificationId).ifPresent(n -> {
if (n.getUser().getId().equals(user.getId())) {
n.setRead(true);
notificationRepository.save(n);
}
});
}
- ํธ๋์ญ์ (Transaction) = "์ฌ๋ฌ DB ์์ ์ ํ๋๋ก ๋ฌถ์ด, ์ ๋ถ ์ฑ๊ณตํ๊ฑฐ๋ ์ ๋ถ ์ทจ์๋๊ฒ ํ๋ ๋จ์".
@Transactional์ด ๋ถ์ ๋ฉ์๋๋ ์์ํ ๋ ์์ ์ ์ด๊ณ , ์ ์ ์ข ๋ฃ ์ ์ปค๋ฐ(ํ์ ), ๋์ค์ ์์ธ๊ฐ ํฐ์ง๋ฉด ๋กค๋ฐฑ(์ ๋ถ ๋๋๋ฆผ) ํฉ๋๋ค.- ๋น์ : ๊ณ์ข์ด์ฒด. "A์์ ์ถ๊ธ"๊ณผ "B์ ์ ๊ธ" ๋ ๋ค ์ฑ๊ณตํด์ผ ํ๊ณ , ์ค๊ฐ์ ๋ฉ์ถ๋ฉด ๋ ๋ค ์๋ ์ผ๋ก ํด์ผ ํจ. ์ถ๊ธ๋ง ๋๊ณ ์ ๊ธ์ด ์ ๋๋ฉด ๋์ด ์ฆ๋ฐํ๋๊น์.
- ์จ์ ํธ์ ํ๋:
@Transactional์์์ ์ํฐํฐ์setRead(true)์ฒ๋ผ ๊ฐ์ ๋ฐ๊พธ๋ฉด, ํธ๋์ญ์ ์ด ๋๋ ๋ Hibernate๊ฐ ๋ณ๊ฒฝ์ ๊ฐ์ง(dirty checking)ํด ์๋์ผ๋ก UPDATE๋ฅผ ๋ ๋ฆฝ๋๋ค. (์ ์ฝ๋์save(n)์ ๋ช ์์ ์ด์ง๋ง, ํธ๋์ญ์ ์์์๋ ์๋ตํด๋ ๋ฐ์๋๋ ๊ฒฝ์ฐ๊ฐ ๋ง์ต๋๋ค.)
๐ก ์ด๋ณด ํฌ์ธํธ: ๋ณดํต Controller๊ฐ ์๋๋ผ Service์
@Transactional์ ๋ถ์ ๋๋ค. "ํ ๋ฒ์ ๋น์ฆ๋์ค ํ๋(=ํ๋์ Service ๋ฉ์๋)" ๋จ์๋ก ํธ๋์ญ์ ์ ์ก๋ ๊ฒ ์์ฐ์ค๋ฝ๊ธฐ ๋๋ฌธ.
H. DTO โ ์ํฐํฐ ๋ถ๋ฆฌ, Lombok
1) ์ ์ํฐํฐ๋ฅผ ๊ทธ๋๋ก ํ๋ก ํธ์ ์ ์ฃผ๊ณ DTO๋ก ๋ฐ๊พธ๋?
// NotificationResponse.java (DTO ์ ์ฒด)
@Getter @Builder @NoArgsConstructor @AllArgsConstructor
public class NotificationResponse {
private Long id;
private String notificationType; // enum โ String ์ผ๋ก ๋ณํ๋จ
private String title;
private String message;
private String relatedEntityType;
private Long relatedEntityId;
private boolean isRead;
private LocalDateTime createdAt;
public static NotificationResponse from(Notification n) { // ์ํฐํฐ โ DTO ๋ณํ๊ธฐ
return NotificationResponse.builder()
.id(n.getId())
.notificationType(n.getNotificationType().name()) // enum.name() = ๋ฌธ์์ด
.title(n.getTitle())
.message(n.getMessage())
.relatedEntityType(n.getRelatedEntityType())
.relatedEntityId(n.getRelatedEntityId())
.isRead(n.isRead())
.createdAt(n.getCreatedAt())
.build();
}
}
- DTO(Data Transfer Object) = "๋ฐ๊นฅ์ธ์(ํ๋ก ํธ)๊ณผ ์ฃผ๊ณ ๋ฐ๋ ํฌ์ฅ์ง". ์ํฐํฐ(DB ๊ฑฐ์ธ)์ ์ผ๋ถ๋ฌ ๋ถ๋ฆฌํฉ๋๋ค.
- ์ ๋ถ๋ฆฌํ๋?
- ๋ณด์ โ ์ํฐํฐ
Notification์@ManyToOne private User user(๋น๋ฐ๋ฒํธ ๋ฑ ๋ฏผ๊ฐ ์ ๋ณด ํฌํจ ๊ฐ๋ฅ)๋ฅผ ๋ค๊ณ ์์ต๋๋ค. ๊ทธ๋๋ก JSON์ผ๋ก ๋ด๋ณด๋ด๋ฉด ์ ์ ๋ด๋ถ ์ ๋ณด๊ฐ ์ค์ค ์๊ฑฐ๋ ๋ฌดํ ์ํ์ฐธ์กฐ๋ก ํฐ์ง๋๋ค. DTO๋ ๋ด๋ณด๋ผ ํ๋๋ง ๊ณจ๋ผ ๋ด์ต๋๋ค(์์user๊ฐ ์์ฃ ). - ์์ ์ฑ โ DB ๊ตฌ์กฐ(์ํฐํฐ)๊ฐ ๋ฐ๋์ด๋ API ์๋ต(DTO) ๋ชจ์์ ๊ทธ๋๋ก ์ ์ง ๊ฐ๋ฅ. ํ๋ก ํธ๊ฐ ์ ๊นจ์ง๋๋ค.
- ๋ณด์ โ ์ํฐํฐ
- ์ปจํธ๋กค๋ฌ์์์ ๋ณํ์ ํ ์ค:
์ํฐํฐ ๋ฆฌ์คํธ๋ฅผ// NotificationController.java:36-37 streamChatService.getNotificationsForUser(user) .stream().map(NotificationResponse::from).toList();map(...from)์ผ๋ก DTO ๋ฆฌ์คํธ๋ก ๊ฐ์๋ผ์๋๋ค.
2) Lombok โ ๋ฐ๋ณต ์ฝ๋ ์๋ ์์ฑ๊ธฐ
์ํฐํฐ/DTO ์์ ๋ถ์ @Getter, @Builder ๋ฑ์ด Lombok ์ ๋ํ
์ด์
์
๋๋ค. build.gradle:60,62์์ ์์กด์ฑ์ผ๋ก ๋ค์ด์ต๋๋ค.
| Lombok ๋ผ๋ฒจ | ์๋์ผ๋ก ๋ง๋ค์ด์ฃผ๋ ๊ฒ |
|---|---|
@Getter / @Setter |
getTitle(), setRead(...) ๊ฐ์ ์ ๊ทผ์ ๋ฉ์๋ |
@Builder |
NotificationResponse.builder().id(1).title("x").build() ๋น๋ ํจํด |
@NoArgsConstructor |
์ธ์ ์๋ ๊ธฐ๋ณธ ์์ฑ์ (JPA๊ฐ ์๊ตฌ) |
@AllArgsConstructor |
๋ชจ๋ ํ๋๋ฅผ ๋ฐ๋ ์์ฑ์ |
@RequiredArgsConstructor |
final ํ๋๋ง ๋ฐ๋ ์์ฑ์ (โ DI์ ์ฌ์ฉ) |
@Builder.Default |
๋น๋ ์ฌ์ฉ ์์๋ ํ๋ ๊ธฐ๋ณธ๊ฐ(isRead=false) ์ ์ง |
- ์ด ๋ผ๋ฒจ๋ค์ด ์์ผ๋ฉด getter/setter/์์ฑ์๋ฅผ ์์ญ ์ค ์์ผ๋ก ์จ์ผ ํฉ๋๋ค. Lombok์ด ์ปดํ์ผ ์์ ์ ์๋ ์์ฑํด ์ฝ๋๋ฅผ ๊น๋ํ๊ฒ ์ ์งํฉ๋๋ค.
๐ก ์ด๋ณด ํฌ์ธํธ: IDE์์ ์ํฐํฐ๋ฅผ ๋ด๋
getTitle()์ ์๊ฐ ์ ๋ณด์ผ ์ ์์ต๋๋ค โ Lombok์ด ์ปดํ์ผํ ๋ ๋ง๋ค๊ธฐ ๋๋ฌธ. "๋ถ๋ช ํธ์ถํ๋๋ฐ ์ ์๊ฐ ์๋ค?" ์ถ์ผ๋ฉด ํด๋์ค ์@Getter๋ฅผ ํ์ธํ์ธ์.
I. application.properties โ ํ๋กํ์ผ(local / prod)
๊ฐ์ ์ฝ๋๋ฅผ ๋ก์ปฌ ๊ฐ๋ฐ๊ณผ ์ด์(EC2) ์์ ๋ค๋ฅด๊ฒ ์ค์ ํ๋ ค๊ณ ํ๋กํ์ผ(profile) ์ ์๋๋ค.
# application.properties:6 (๊ณตํต ๊ธฐ๋ณธ๊ฐ)
spring.profiles.default=local
# application.properties:20 (๊ธฐ๋ณธ/๊ณตํต โ ๋ก์ปฌ ์นํ)
spring.jpa.hibernate.ddl-auto=update
spring.flyway.enabled=false
- ํ์ผ 3๊ฐ๊ฐ ๊ณ์ธต์ผ๋ก ํฉ์ณ์ง๋๋ค:
application.propertiesโ ๊ณตํต ๊ธฐ๋ณธ๊ฐ (๋ชจ๋ ํ๊ฒฝ ๊ณต์ ).application-local.propertiesโ ๋ก์ปฌ ๊ฐ๋ฐ์ฉ ๋ฎ์ด์ฐ๊ธฐ(์คํคยท๋ก์ปฌ DB ๋น๋ฒ ๋ฑ, git ์ปค๋ฐ ๊ธ์ง).application-prod.propertiesโ ์ด์์ฉ ๋ฎ์ด์ฐ๊ธฐ(HTTPS ์ฟ ํค, Flyway ์ผฌ ๋ฑ).
- ์คํ ์
--spring.profiles.active=local๋๋=prod๋ก ์ด๋ ๋ฎ์ด์ฐ๊ธฐ๋ฅผ ์ธ์ง ๊ณ ๋ฆ ๋๋ค. ์ ์ฃผ๋ฉดspring.profiles.default=local์ด ์ ์ฉ๋ฉ๋๋ค. ${VAR:๊ธฐ๋ณธ๊ฐ}๋ฌธ๋ฒ โ ํ๊ฒฝ๋ณ์ ์ฐ์ , ์์ผ๋ฉด ๊ธฐ๋ณธ๊ฐ. ์:# application.properties:16-18 spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:alphahelix_db}?... spring.datasource.password=${DB_PASSWORD} โ ๊ธฐ๋ณธ๊ฐ ์์ โ ๋ฐ๋์ ํ๊ฒฝ/๋ก์ปฌ ํ์ผ์์ ์ฃผ์application-local.properties:6-10์ด ๋ฐ๋ก ๊ทธDB_HOST=localhost๋ฑ์ ์ฑ์์ค๋๋ค.
โ ๏ธ ๋ฉ๋ชจ๋ฆฌ ๊ตํ: env ์ฐ์ ์์๋ฅผ ๋ฐ๊พธ๊ธฐ ์ , ๊ฐ์ ํค๊ฐ ๋ค๋ฅธ ํ์ผ์์ ๋ค๋ฅธ ๊ฐ์ผ๋ก ์ค๋ณต ์ ์๋ผ ์๋์ง ๋ฐ๋์ ํ์ธํ์ธ์(ํนํ
app.crypto.key).application.properties์ ๊ฐ๋ฐ์ฉ ๊ธฐ๋ณธ๊ฐ์ด,application-local.properties:19์ ๋ ๋ค๋ฅธ ๊ฐ์ด ์์ด ์ด๋ ์ชฝ์ด ์ด๊ธฐ๋์ง ํท๊ฐ๋ฆฌ๋ฉด KIS ๋ณตํธํ๊ฐ ๊นจ์ง๋๋ค.
J. Flyway ๋ง์ด๊ทธ๋ ์ด์ โ DB ์คํค๋ง ๋ฒ์ ๊ด๋ฆฌ
DB ํ ์ด๋ธ ๊ตฌ์กฐ(์คํค๋ง)๋ฅผ ์ฝ๋์ฒ๋ผ ๋ฒ์ ๊ด๋ฆฌํ๋ ๋๊ตฌ๊ฐ Flyway์ ๋๋ค.
# application.properties:132-142 (๋ฐ์ท)
# ๋ก์ปฌ(ddl-auto=update)์ ๋นํ์ฑ, ์ด์(ddl-auto=validate)์ ํ์ฑํํด์ ์คํค๋ง ๋ฒ์ ๊ด๋ฆฌ
spring.flyway.enabled=false
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=16
- ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ์
backend/src/main/resources/db/migration/์V1__baseline.sql,V2__..., ...V18__refresh_tokens_table.sql์ฒ๋ผ ๋ฒํธ ์์๋๋ก ์์ ๋๋ค. - Flyway๋ DB์
flyway_schema_historyํ๋ฅผ ๋ง๋ค์ด "์ด๋ V๊น์ง ์ ์ฉํ๋์ง" ๋ฅผ ๊ธฐ๋กํ๊ณ , ์ฑ ๊ธฐ๋ ์ ์์ง ์ ๋๋ฆฐ V๋ง ์ฐจ๋ก๋ก ์คํํฉ๋๋ค. โ ๋๊ตฌ์ DB๋ ๊ฐ์ ์ต์ข ์คํค๋ง๊ฐ ๋ณด์ฅ๋ฉ๋๋ค. ddl-auto์์ ์ญํ ๋ถ๋ด(์ค์):- ๋ก์ปฌ:
ddl-auto=update+flyway.enabled=falseโ ์ํฐํฐ๋ฅผ ๋ฐ๊พธ๋ฉด Hibernate๊ฐ ํธํ๊ฒ ํ ์ด๋ธ์ ์๋ ์์ (๋น ๋ฅธ ๊ฐ๋ฐ). - ์ด์:
ddl-auto=validate+ Flyway ์ผฌ โ Hibernate๋ ์คํค๋ง๋ฅผ ์ ๋ ๋ฐ๊พธ์ง ์๊ณ (๊ฒ์ฆ๋ง), ์คํค๋ง ๋ณ๊ฒฝ์ ์ค์ง Flyway SQL ํ์ผ๋ก๋ง ํต์ (์์ ยท์ถ์ ๊ฐ๋ฅ).
- ๋ก์ปฌ:
- ๋น์ : Flyway๋ DB์ git์ ๋๋ค. "ํ ์ด๋ธ์ ์ปฌ๋ผ ์ถ๊ฐ" ๊ฐ์ ๋ณ๊ฒฝ์ ๋ฒํธ ๋งค๊ธด ์ปค๋ฐ(SQL ํ์ผ) ์ผ๋ก ๋จ๊ฒจ, ์ด๋ ์๋ฒ๋ ๋๊ฐ์ด ์ฌํํฉ๋๋ค.
๐ก ์ด๋ณด ํฌ์ธํธ: ์ ๋ง์ด๊ทธ๋ ์ด์ (
V19__...)์ ์ถ๊ฐํ๋ฉดapplication.properties:142์baseline-version๋ ๊ทธ์ ๋ง์ถฐ ์ฌ๋ ค์ผ ํ๋ค๊ณ ์ฃผ์์ด ๊ฒฝ๊ณ ํฉ๋๋ค. ๋ฒํธ๋ ๊ฑด๋๋ฐ์ง ๋ง๊ณ ์ฐ์์ผ๋ก.
โ ๏ธ ์ด ํ๋ก์ ํธ ํน์ด์ (๋ค๋ฅธ ๋๋ฉ์ธ ๊ต์ฌ์ ๋์ ์ )
โ โ โ ํน์ด์ 1: Spring Security๋ฅผ ๋๊ณ , ์ธ๊ฐ๋ฅผ ์ปจํธ๋กค๋ฌ์์ ์๋ ์ฒ๋ฆฌํ๋ค
์ด๊ฒ ์ด ๋ฐฑ์๋์์ ๊ฐ์ฅ ์ค์ํ๊ณ ๊ฐ์ฅ ํท๊ฐ๋ฆฌ๋ ๋ถ๋ถ์ ๋๋ค. ๋ค๋ฅธ ๋๋ฉ์ธ ๊ต์ฌ๋ค์ ์ ๋ถ ์ด ์ ์ ๋ฅผ ๊น๊ณ ์์ผ๋ ์ฌ๊ธฐ์ ํ์คํ ์ก๊ณ ๊ฐ์๋ค.
์ฆ๊ฑฐ 1 โ Security ํ๋ ์์ํฌ๋ฅผ ์์กด์ฑ์์ ๋บ๋ค:
// build.gradle:28-30
// implementation 'org.springframework.boot:spring-boot-starter-security' โ ์ฃผ์ ์ฒ๋ฆฌ(=์ ์)
// ๋น๋ฐ๋ฒํธ BCrypt ํด์ฑ๋ง ์ฌ์ฉ (์ ์ฒด Security ํ๋ ์์ํฌ/auto-config ์์ด PasswordEncoder ๋ง)
implementation 'org.springframework.security:spring-security-crypto'
- ๋ณดํต ์คํ๋ง ์ฑ์
spring-boot-starter-security๊ฐ ๋ชจ๋ ์์ฒญ์ ๊ฐ๋ก๋ง๊ณ ๋ก๊ทธ์ธ/๊ถํ์ ์๋ ๊ด๋ฆฌํฉ๋๋ค. ์ด ํ๋ก์ ํธ๋ ๊ทธ ์ ์ฒด ํ๋ ์์ํฌ๋ฅผ ๋๊ณ , ๋น๋ฐ๋ฒํธ ํด์ฑ์ฉspring-security-crypto(BCrypt)๋ง ๋ผ์ด ์๋๋ค.
๊ทธ๋ผ ์ธ์ฆ/์ธ๊ฐ๋ ๋๊ฐ ํ๋? โ ์ง์ ๋ง๋ ํํฐ + ์ปจํธ๋กค๋ฌ ์๋ ์ฒดํฌ.
์ฆ๊ฑฐ 2 โ JWT ํํฐ๋ "๋ง์ง ์๊ณ , ๋์ฅ๋ง ์ฐ๋๋ค":
// JwtAuthenticationFilter.java:28-32, 42-60 (๋ฐ์ท)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class JwtAuthenticationFilter extends OncePerRequestFilter {
...
protected void doFilterInternal(HttpServletRequest request, ...) {
String token = extractToken(request); // ์ฟ ํค ๋๋ Bearer ํค๋์์ JWT ์ถ์ถ
if (token != null && !token.isBlank()) {
try {
var claims = jwtUtil.parse(token);
Object uid = claims.get("uid");
if (uid instanceof Number n) {
request.setAttribute(ATTR_USER_ID, n.longValue()); // โ
userId๋ฅผ ์์ฒญ์ "๋์ฅ"
}
...
} catch (Exception e) {
// ํ ํฐ ํ์ฑ ์คํจ: ์ต๋ช
์์ฒญ์ผ๋ก ํต๊ณผ โ โ
๋ง์ง ์๋๋ค!
}
}
chain.doFilter(request, response); // ๋ฌด์กฐ๊ฑด ๋ค์์ผ๋ก ํต๊ณผ
}
- ์ด ํํฐ๋ ์์ฒญ์ ๊ฑฐ๋ถํ์ง ์์ต๋๋ค. JWT๊ฐ ์ ํจํ๋ฉด
request์userId๋์ฅ์ ์ฐ๊ณ , ์๊ฑฐ๋ ๊นจ์ก์ด๋ ๊ทธ๋ฅ ํต๊ณผ์ํต๋๋ค(์ต๋ช ์ผ๋ก). ์ฆ "๋ฌธ์ง๊ธฐ"๊ฐ ์๋๋ผ "์ ์ ์คํฌํ ์ฐ๋ ์ฌ๋"์ ๋๋ค. @Order(...HIGHEST_PRECEDENCE + 10)โ ์ด ํํฐ๊ฐ ๋ค๋ฅธ ํํฐ(AiRateLimitFilter, +20)๋ณด๋ค ๋จผ์ ๋์์ผuserId๋์ฅ์ด ๋ฏธ๋ฆฌ ์ฐํ๋๋ค.
์ฆ๊ฑฐ 3 โ ์ค์ "๋ง๋" ๊ณณ์ ์ปจํธ๋กค๋ฌ๋ค (์๋ ์ธ๊ฐ):
// NotificationController.java:78-87
/** JWT(AuthContext) ์ ์ฌ์ฉ์ โ ๋ฏธ์ธ์ฆ/๋ฏธ์กด์ฌ๋ฉด null. ์์ฒญ ํ๋ผ๋ฏธํฐ ์ ์์ ์ ๋ขฐํ์ง ์๋๋ค. */
private User currentUser() {
Long uid = AuthContext.currentUserId(); // ํํฐ๊ฐ ์ฐ์ ๋์ฅ์ ๊บผ๋
if (uid == null) return null;
return userRepository.findById(uid).orElse(null);
}
private static ResponseEntity<?> unauthorized() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "์ธ์ฆ ํ์"));
}
// NotificationController.java:33-35 (๋ชจ๋ ๋ณดํธ ์๋ํฌ์ธํธ์ ์ฒซ ๋ ์ค ํจํด)
User user = currentUser();
if (user == null) return unauthorized(); // โ
์ฌ๊ธฐ์ ์ง์ 401์ ๋ธ๋ค
- ์ด ๋ ์ค(
currentUser()โnull์ด๋ฉดunauthorized())์ด ์ด ํ๋ก์ ํธ์ "์ธ๊ฐ ๊ฒ์ดํธ"์ ๋๋ค. Security ํ๋ ์์ํฌ๊ฐ ์๋์ผ๋ก ์ ๋ง์์ฃผ๋ฏ๋ก, ๋ชจ๋ ๋ณดํธ ์๋ํฌ์ธํธ๊ฐ ์๊ธฐ ์์ผ๋ก null ์ฒดํฌ๋ฅผ ํด์ผ ํฉ๋๋ค. ๋น ๋จ๋ฆฌ๋ฉด ๊ทธ ์๋ํฌ์ธํธ๋ ๋ฌด๋ฐฉ๋น๋ก ๋ซ๋ฆฝ๋๋ค.
์ฆ๊ฑฐ 4 โ ์ ์์ ์ค์ง JWT์์๋ง (IDOR ์ฐจ๋จ):
// AuthContext.java:27-34
public static Long currentUserId() {
Long override = OVERRIDE_USER_ID.get(); // ์ค์ผ์ค๋ฌ์ฉ ์์ ์ฃผ์
(์๋ ์ค๋ช
)
if (override != null) return override;
HttpServletRequest req = currentRequest();
if (req == null) return null;
Object v = req.getAttribute(JwtAuthenticationFilter.ATTR_USER_ID); // ํํฐ๊ฐ ์ฐ์ ๋์ฅ
return v instanceof Long ? (Long) v : null;
}
AuthContext๋ "์ง๊ธ ์ด ์์ฒญ์ ์ง์ง ์ฃผ์ธ์ด ๋๊ตฌ์ธ์ง" ๋ฅผ ์๋ ค์ฃผ๋ ํฌํผ์ ๋๋ค. ์ ์์ JWT ๋์ฅ์์๋ง ์ฝ์ต๋๋ค.- ์ปจํธ๋กค๋ฌ ํด๋์ค ์ฃผ์(
NotificationController.java:19-21)์ด ๊ทธ ์ด์ ๋ฅผ ๋ชป ๋ฐ์ต๋๋ค:M9 ๋ณด์ ์์ : ์ด์ ์๋
?userId=์์ฒญ ํ๋ผ๋ฏธํฐ๋ฅผ ๊ทธ๋๋ก ์ ๋ขฐํด ์ธ์ฆ๋ ๋๊ตฌ๋ ํ์ธ์ ์๋ฆผ์ ์กฐํ/์ฝ์์ฒ๋ฆฌํ ์ ์๋ IDOR๊ฐ ์์๋ค. ์ด์ ์ ์์ ํญ์ JWT(AuthContext)์์๋ง ๊ฐ์ ธ์ค๊ณ , ์์ฒญ ํ๋ผ๋ฏธํฐ๋ก ๋ฐ์ง ์๋๋ค. - ๊ทธ๋์ ์ด ์ปจํธ๋กค๋ฌ๋
@RequestParam Long userId๋ฅผ ์ ๋ ์ฐ์ง ์์ต๋๋ค. ๋ง์ฝ ํด๋ผ์ด์ธํธ๊ฐ ๋ณด๋ธ?userId=42๋ฅผ ๋ฏฟ์ผ๋ฉด, ๊ณต๊ฒฉ์๊ฐ ๋จ์ ID๋ฅผ ๋ฃ์ด ๋จ์ ๋ฐ์ดํฐ๋ฅผ ํ์ณ๋ณผ(IDOR, Insecure Direct Object Reference) ์ ์๊ธฐ ๋๋ฌธ. ์ ์์ ์์กฐ ๋ถ๊ฐ๋ฅํ JWT ๋์ฅ์์๋ง ์ต๋๋ค.
์ฆ๊ฑฐ 5 โ Service์์ ํ ๋ฒ ๋ ์์ ๊ถ ํ์ธ (์ด์ค ๋ฐฉ์ด):
// StreamChatService.java:244-250
public void markNotificationRead(Long notificationId, User user) {
notificationRepository.findById(notificationId).ifPresent(n -> {
if (n.getUser().getId().equals(user.getId())) { // โ
์ด ์๋ฆผ์ด ์ ๋ง ์ด user ๊ฒ์ธ๊ฐ?
n.setRead(true);
...
- ์ปจํธ๋กค๋ฌ์์ ์ ์์ ํ์ธํ์ด๋, Service๊ฐ "์ด ์๋ฆผ ID๊ฐ ์ ๋ง ๊ทธ ์ ์ ์์ ์ธ์ง" ๋ฅผ ํ ๋ฒ ๋ ๊ฒ์ฌํฉ๋๋ค. ๋จ์ ์๋ฆผ ID(
3)๋ฅผ ๋ฃ์ด๋ ์กฐ์ฉํ ๋ฌด์๋ฉ๋๋ค.
์ ๋ฆฌ โ ์ด ํ๋ก์ ํธ์ ์ธ์ฆ/์ธ๊ฐ 4๋จ ๋ฐฉ์ด:
1) JwtAuthenticationFilter : ์ฟ ํค/ํค๋์ JWT โ request์ userId ๋์ฅ (๋ง์ง ์์)
2) AuthContext.currentUserId(): ๋์ฅ์์๋ง ์ ์ ์ฝ๊ธฐ (์์ฒญ ํ๋ผ๋ฏธํฐ ์ ๋ขฐ ์ ํจ)
3) Controller : currentUser()==null โ 401 (์๋ ์ธ๊ฐ ๊ฒ์ดํธ)
4) Service : ์์ ์์ ๊ถ ์ฌํ์ธ (n.user == ํ์ฌ user)
๐จ ๊ฐ์/๊ณ ๋ํ ํต์ฌ: ๋ค๋ฅธ ๋๋ฉ์ธ ๊ต์ฌ์์
if (user == null) return unauthorized();๋ ์ค์ด ๋ฐ๋ณต๋๋ ๊ฑธ ๋ณด๊ฒ ๋ฉ๋๋ค. ๊ทธ๊ฑด ์ค๋ณต์ด ์๋๋ผ ์ด ์ํคํ ์ฒ์ ํ์ ์ธ๊ฐ ์ฝ๋์ ๋๋ค. Security๋ฅผ ์ ์ฐ๊ธฐ๋ก ํ ๋๊ฐ๋ก, ๊ฐ ์ปจํธ๋กค๋ฌ๊ฐ ์ค์ค๋ก ๋ฌธ์ง๊ธฐ๊ฐ ๋ฉ๋๋ค.
ํน์ด์ 2: AuthContext์ ์ค์ผ์ค๋ฌ์ฉ "์์ ์ ์ ์ฃผ์
"
// AuthContext.java:15-25
private static final ThreadLocal<Long> OVERRIDE_USER_ID = new ThreadLocal<>();
public static void set(Long userId) { // ์: DailySignalGenerator๊ฐ ํน์ ์ ์ ๋์ ์คํ
if (userId != null) OVERRIDE_USER_ID.set(userId);
}
public static void clear() { // ๋ฐ๋์ finally์์ ํธ์ถ
OVERRIDE_USER_ID.remove();
}
- HTTP ์์ฒญ์ด ์๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ก(์ค์ผ์ค๋ฌ) ์๋ JWT ์ฟ ํค๊ฐ ์์ต๋๋ค. ๊ทธ๋์ "์ด ์์
์ ์ ์ 42๋ฒ ๊ฒ์ฒ๋ผ ์คํ"ํ๊ณ ์ถ์ ๋
AuthContext.set(42L)๋ก ์์ ์ ์์ ์ฃผ์ ํฉ๋๋ค. ThreadLocalโ "ํ์ฌ ์คํ ์ค๋ ๋์๋ง ๋ณด์ด๋ ๋ณ์". ๋ฐ๋์finally์์clear()ํด์ผ ์ค๋ ๋๊ฐ ์ฌ์ฌ์ฉ๋ ๋ ๋จ์ ์ ์์ด ์์ง ์์ต๋๋ค(์ค๋ ๋ํ ๋์ ์ฃผ์).
ํน์ด์ 3: ์ธ์ฆ์ HttpOnly JWT ์ฟ ํค๊ฐ ํ์ค (ํค๋๋ ๋ ๊ฑฐ์)
// JwtAuthenticationFilter.java:62-78 (๋ฐ์ท)
// 1์์: HttpOnly ์ฟ ํค (DEVBRIDGE_TOKEN)
for (Cookie c : cookies) {
if (AuthController.AUTH_COOKIE_NAME.equals(c.getName())) { ... return v.trim(); }
}
// 2์์: Authorization ํค๋ (๋ ๊ฑฐ์ ํธํ)
if (header != null && header.startsWith("Bearer ")) { return header.substring(7).trim(); }
- JWT๋ HttpOnly ์ฟ ํค(JS๊ฐ ๋ชป ์ฝ์ โ XSS ํ ํฐ ํ์ทจ ๋ฐฉ์ง)๋ก ์ ๋ฌ๋๋ ๊ฒ 1์์,
Authorization: Bearerํค๋๋ ์ ํด๋ผ์ด์ธํธ ํธํ์ฉ 2์์์ ๋๋ค.
๐ ์ฉ์ด ์ฌ์ (์ด primer ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
์ ๋ํ
์ด์
(@...) |
์ฝ๋์ ๋ถ์ฌ ์คํ๋ง/Lombok์ ์ง์ํ๋ ์คํฐ์ปค ๋ผ๋ฒจ |
| ๋น(Bean) | ์คํ๋ง์ด ๋ง๋ค์ด ์ฐฝ๊ณ ์ ๋ณด๊ด ์ค์ธ ๊ฐ์ฒด ํ๋ |
| DI(์์กด์ฑ ์ฃผ์ ) | ํ์ํ ๋น์ ์คํ๋ง์ด ์์ฑ์๋ก ๊ฝ์์ฃผ๋ ๊ฒ |
@RequiredArgsConstructor |
final ํ๋ ์์ฑ์๋ฅผ Lombok์ด ์๋ ์์ฑ(โ DI ํต๋ก) |
| Controller | HTTP ์์ฒญ ์ ์์ฒ(@RestController) |
| Service | ๋น์ฆ๋์ค ๊ท์นยทํธ๋์ญ์ ์ด ์ฌ๋ ์ฒ๋ฆฌ์ค |
| Repository | DB ์ฝ๊ธฐ/์ฐ๊ธฐ ๋ด๋น(JpaRepository ์์) |
์ํฐํฐ(@Entity) |
DB ํ ์ด๋ธ ํ ํ์ ๋์ํ๋ ์๋ฐ ๊ฐ์ฒด |
| DTO | ํ๋ก ํธ์ ์ฃผ๊ณ ๋ฐ๋ ํฌ์ฅ์ง ๊ฐ์ฒด(์ํฐํฐ์ ๋ถ๋ฆฌ) |
@Id / @GeneratedValue |
๊ธฐ๋ณธํค / ์๋ ์ฆ๊ฐ |
@ManyToOne / @JoinColumn |
๊ด๊ณ ๋งคํ(์ฌ๋ฌโํ๋) / ์ธ๋ํค ์ปฌ๋ผ |
@Column |
์ปฌ๋ผ ์ธ๋ถ ์ค์ (์ด๋ฆยทNOT NULLยท๊ธธ์ดยทํ์ ) |
@Enumerated(STRING) |
enum์ ๋ฌธ์์ด๋ก DB ์ ์ฅ(์์ ๋ณ๊ฒฝ ์์ ) |
| ๋ฉ์๋ ์ด๋ฆ ์ฟผ๋ฆฌ | findByUserOrderBy... ์ด๋ฆ๋ง์ผ๋ก SQL ์๋ ์์ฑ |
@Query / @Modifying |
์ง์ ์์ฑ JPQL / ๋ฐ์ดํฐ ๋ณ๊ฒฝ ์ฟผ๋ฆฌ ํ์ |
@Transactional |
์ฌ๋ฌ DB ์์ ์ "์ ๋ถ ์ฑ๊ณต or ์ ๋ถ ์ทจ์"๋ก ๋ฌถ์ |
ResponseEntity |
์ํ์ฝ๋ + ๋ณธ๋ฌธ์ ์ง์ ์ ํด ๋ฐํํ๋ ์๋ต ๊ฐ์ฒด |
@PathVariable / @RequestParam / @RequestBody |
URL ๊ฒฝ๋ก๊ฐ / ์ฟผ๋ฆฌ๊ฐ / ๋ณธ๋ฌธ JSON ์ถ์ถ |
| ํ๋กํ์ผ(profile) | local/prod ๋ฑ ํ๊ฒฝ๋ณ ์ค์ ๋ฌถ์ |
${VAR:๊ธฐ๋ณธ๊ฐ} |
ํ๊ฒฝ๋ณ์ ์ฐ์ , ์์ผ๋ฉด ๊ธฐ๋ณธ๊ฐ |
| Flyway | ๋ฒํธ ๋งค๊ธด SQL๋ก DB ์คํค๋ง๋ฅผ ๋ฒ์ ๊ด๋ฆฌ(=DB์ git) |
ddl-auto |
Hibernate์ ์คํค๋ง ์ฒ๋ฆฌ ๋ชจ๋(local=update, prod=validate) |
| IDOR | ๋จ์ ID๋ฅผ ๋ฃ์ด ๋จ์ ์์์ ์ ๊ทผํ๋ ์ทจ์ฝ์ (์ด ํ๋ก์ ํธ๊ฐ JWT ์ ์์ผ๋ก ์ฐจ๋จ) |
| AuthContext | ํ์ฌ ์์ฒญ์ ์ ์(userId)์ JWT ๋์ฅ์์ ์ฝ๋ ํฌํผ |
| HttpOnly ์ฟ ํค | JS๊ฐ ๋ชป ์ฝ๋ ์ฟ ํค โ XSS ํ ํฐ ํ์ทจ ๋ฐฉ์ง |
| Lombok | getter/builder/์์ฑ์๋ฅผ ์ปดํ์ผ ์ ์๋ ์์ฑํ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ |
โ ๋ค์ ๋จ๊ณ
์ด primer๋ฅผ ์ดํดํ๋ค๋ฉด 08_backend/์ ๊ฐ ๋๋ฉ์ธ ๊ต์ฌ๋ก ๋์ด๊ฐ์ธ์. ์ด๋ ๊ต์ฌ๋ฅผ ์ฝ๋ ์๋ ํจํด์ด ๋ฐ๋ณต๋ฉ๋๋ค โ ์ด์ ๋ค ์ฝ์ ์ ์์ต๋๋ค:
@RestController @RequiredArgsConstructor // ์ ์์ฒ + DI
public class XxxController {
private final XxxService service; // ์ฃผ์
๋ฐ์ Service
@GetMapping("/...")
public ResponseEntity<?> handle(@PathVariable ...) {
User user = currentUser(); // JWT ์ ์
if (user == null) return unauthorized(); // โ
์๋ ์ธ๊ฐ ๊ฒ์ดํธ (์ด ํ๋ก์ ํธ ์๊ทธ๋์ฒ)
return ResponseEntity.ok(service.doSomething(user, ...));
}
}
```# `domain/ai` โ Alpha ์ํฌ์คํ์ด์คยท์ฑํ
ยทํจ์น (์์ ๋ผ์ธ๋ณ ํด์ค)
> ์๋ณธ ๋๋ ํฐ๋ฆฌ: `backend/src/main/java/com/DevBridge/devbridge/domain/ai/`
> ๋ค๋ฃจ๋ ํ์ผ:
> - ์๋น์ค โ `service/AlphaHelixService.java`(960์ค) ยท `service/AlphaPatchService.java` ยท `service/ClaudeCodeAgentService.java`
> - ์ปจํธ๋กค๋ฌ โ `controller/AlphaWorkspaceController.java` ยท `controller/AlphaPatchController.java` ยท `controller/AlphaAnalyticsController.java`
> - ์ํฐํฐ โ `entity/AlphaWorkspace.java` ยท `entity/AlphaChatMessage.java` ยท `entity/AlphaDecisionLog.java` ยท `entity/AlphaWorkspaceChangeSet.java`
>
> ์ด ๋ฌธ์๋ ๊ต์ฌ ํ์ค ํ์(README "3. ๊ณตํต ํ์")์ ๋ฐ๋ฆ
๋๋ค. Spring ๊ธฐ์ด(`@Service`/`@RestController`/JPA/Lombok)๋ ์๊ณ ์๋ค๊ณ ์ ์ ํฉ๋๋ค.
---
## ๐ ์ด ํํธ ํ๋์
์ด ํํธ๋ **"์์ฐ์ด๋ก ๋งํ ์ธ์ ๋ชฉํ๋ฅผ, ๋ฐฑํ
์คํธ ๊ฐ๋ฅํ ์ ๋ต ์นด๋๊น์ง ์๋ดํ๋ AI ๋งค๋์ ์ ๋๋"** ์
๋๋ค.
์ฌ์ฉ์๊ฐ ์ฑํ
์ฐฝ์ *"5๋
์์ ์ 300๋ง์ ํ๊ธํ๋ฆ ๋ง๋ค๊ณ ์ถ์ด์"* ๋ผ๊ณ ์
๋ ฅํ๋ฉด โ
AI๊ฐ 8๊ฐ์ง ์กฐ๊ฑด์ ํ ๋จ๊ณ์ฉ ๋ฌผ์ด ์ฑ์ฐ๊ณ (๋ชฉํ ์์ง) โ ๊ทธ ๋ชฉํ๋ฅผ **deterministic ๋ฐฑํ
์คํธ๊ฐ ๊ฐ๋ฅํ ์ ๋ต ํ๋ณด 3๊ฐ**๋ก ์ ํํํ๊ณ โ ๋ฐฑํ
์คํธยท๊ตญ๋ฉดยท์ ๋ขฐ๋๊น์ง ํ ๋ฒ์ ๋๋ฆฌ๊ณ โ (์ ํ) ์ฝ๋๋ฅผ ์ง์ ํธ์ง(Claude CLI)ํ๊ณ โ ๋ชจ๋ ๋ณ๊ฒฝ์ **์ ์ง/์ทจ์ ๊ฐ๋ฅํ ํจ์น ๋ฌถ์**์ผ๋ก ๊ด๋ฆฌํฉ๋๋ค.
๋น์ ๋ก ํ๋ฉด:
- **AlphaHelixService** = ๋งค๋์ ์ **๋๋**. ์๋ ๋ง์ ๋ฃ๊ณ (์ฑํ
), ๋ฉ๋ชจ๋ก ์ ๋ฆฌํ๊ณ (๋ชฉํ JSON), ๋ฉ๋ด๋ฅผ ์ง๊ณ (์ ๋ต ํ๋ณด), ์์์ ๋๋ฆฝ๋๋ค(๋ฐฑํ
์คํธยทํธ๋ฌ์คํธ).
- **AlphaPatchService** = ๋งค๋์ ์ **์ฐํ๊ณผ ์ง์ฐ๊ฐ**. ์ค์ ์ ์ด์ง ๊ณ ์น๋ ํญ์ "๋๋๋ฆฌ๊ธฐ"๊ฐ ๊ฐ๋ฅํ๊ฒ(ChangeSet).
- **ClaudeCodeAgentService** = ๋งค๋์ ๊ฐ ๋ถ๋ฅด๋ **์ธ๋ถ ์ฝ๋ฉ ์ ๋ฌธ๊ฐ(Claude CLI)**. ๊ฒฉ๋ฆฌ๋ ๋ฐฉ์ ์ฝ๋๋ฅผ ํผ์ณ ์ฃผ๊ณ , ์๊ณผ ๋ฐ(Bashยท๋คํธ์ํฌ)์ ๋ฌถ์ ์ฑ ํ์ผ๋ง ๊ณ ์น๊ฒ ํฉ๋๋ค.
| ํด๋์ค | ํ ์ค ์ญํ | ๋น์ |
|---|---|---|
| `AlphaHelixService` | ์ฑํ
๋ชฉํ์์ง โ ์ ํํ โ ๋ฐฑํ
์คํธ/๊ตญ๋ฉด/์ ๋ขฐ/๋ธ๋ฆฌํ/์๋์ฃผ๋ฌธ/auto-run ์ ๋ถ | ๋งค๋์ ์ ๋๋(์ ๊ณผ์ ์ค์ผ์คํธ๋ ์ด์
) |
| `AlphaPatchService` | strategyConfigยทgoalProfileยทcode ๋ฅผ **๋ถ๋ถ ํจ์น**ํ๊ณ PENDING/KEPT/UNDONE ๋ก ์ถ์ | ์ฐํ+๋๋๋ฆฌ๊ธฐ |
| `ClaudeCodeAgentService` | ํค๋๋ฆฌ์ค `claude -p` CLI ๋ก ์ํฌ์คํ์ด์ค ์ฝ๋ ์คํธ์ง โ diff โ ํจ์น | ์ธ๋ถ ์ฝ๋ฉ ์ ๋ฌธ๊ฐ(๊ฒฉ๋ฆฌ์ค) |
| `AlphaWorkspaceController` | ์ํฌ์คํ์ด์ค CRUD + ์ฑํ
+ ๋ก๊ทธ/์ฃผ๋ฌธ ์กฐํ + ์ฝ๋ ์ ์ฅ | ๋๋๋ก ๊ฐ๋ ์ ๋ฌธ ์ ์์ฒ |
| `AlphaAnalyticsController` | backtest/regime/trust/queue-orders/auto-run/briefing ํธ๋ฆฌ๊ฑฐ | ๋ถ์ ํ์ดํ๋ผ์ธ ์ ์์ฒ |
| `AlphaPatchController` | changesets apply/keep/undo/list | ํจ์น ์ ์์ฒ |
| `AlphaWorkspace`(์ํฐํฐ) | ๋ชฉํยท์ ๋ตยท๋ฐฑํ
์คํธยท๊ตญ๋ฉดยท์ ๋ขฐยท๋ฆฌํฌํธยท์ฝ๋ JSON ์ ํ ํ์ ๋ณด๊ด | ์๋ ํ ๋ช
์ ์๋ฅ์ฒ |
| `AlphaChatMessage` | ์ฑํ
ํ ์ค(user/model) | ๋ํ ๋
น์ทจ ํ ์ค |
| `AlphaDecisionLog` | ๋ชจ๋ ์์ฌ๊ฒฐ์ ์๊ฐ์ ๊ธฐ๋ก | ๋งค๋์ ์ ์
๋ฌด์ผ์ง |
| `AlphaWorkspaceChangeSet` | ํจ์น ํ ๋ฌถ์ + before/after ์ค๋
์ท | ์์ ์์์ฆ(๋๋๋ฆฌ๊ธฐ ํ) |
**๋๊ฐ ํธ์ถํ๋?** โ ํ๋ก ํธ์๋ `frontend/src/alpha/` ์ AlphaWorkspace ํญ(Chat / Config / Report / Regime / Trust / Briefing / Log)์ด `/api/alpha/...` ๋ก ํธ์ถํฉ๋๋ค. ์ธ ์ปจํธ๋กค๋ฌ๊ฐ ์
๊ตฌ์ด๊ณ , ์ค์ ์ผ์ ์ ๋ถ ์ ์ธ ์๋น์ค๊ฐ ํฉ๋๋ค.
**Analytics ์์ง๊ณผ์ ๊ด๊ณ** โ ์ด ํํธ๋ **๋๋(์กฐ์จ์)** ์ด๊ณ , ์ค์ ์ซ์ ๊ณ์ฐ(๋ฐฑํ
์คํธยทTrustยทRegime)์ `01_backtest`~`04_robust` ์ Python ์์ง์ด ํฉ๋๋ค. `AnalyticsClient`(strategy ๋๋ฉ์ธ)๊ฐ ๊ทธ ๋ค๋ฆฌ์
๋๋ค. ์ฆ ์ด ๋ฌธ์๋ "**๋๊ฐ ์ธ์ ๋ฌด์์ ์ํค๋๊ฐ**"๋ฅผ, ์์ ๋ฌธ์๋ค์ "**๊ทธ ๊ณ์ฐ์ด ์ด๋ป๊ฒ ๋์๊ฐ๋๊ฐ**"๋ฅผ ๋ค๋ฃน๋๋ค.
---
## ๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
### 1) ์ํฌ์คํ์ด์ค = "์๋ ํ ๋ช
์ ์ ๋ต ์๋ฅ์ฒ ํ ๊ถ"
`AlphaWorkspace` ์ํฐํฐ ํ ํ = ์ ๋ต 1๊ฐ. Slack ์ฑ๋์ฒ๋ผ **์ํฌ์คํ์ด์ค ์์ ์ฑํ
ยท๋ก๊ทธยท์ ๋ตยท๊ฒฐ๊ณผ๊ฐ ๋ชจ๋ ์ข
์**๋ฉ๋๋ค. ํ ์ฌ์ฉ์๊ฐ ์ฌ๋ฌ ์ํฌ์คํ์ด์ค๋ฅผ ๊ฐ์ง ์ ์์ต๋๋ค(๋ชฉํ๋ง๋ค ํ๋์ฉ).
### 2) ์ํ ๋จธ์ โ `status` ํ๋
์ํฌ์คํ์ด์ค๋ ์ ํด์ง ๋จ๊ณ๋ฅผ ๋ฐ์ต๋๋ค. ์ปจํธ๋กค๋ฌ๊ฐ ํ์ฉํ๋ ๊ฐ์ ์ ํํ 5๊ฐ:
DRAFT โ GOAL_SET โ FORMALIZED โ TESTED โ LIVE
(์์ฑ) (๋ชฉํ 8๊ฐ (์ ๋ตํ๋ณด 3๊ฐ (๋ฐฑํ
์คํธ (์ค์ด์ฉ ์ค)
ํ์ ) ์์ฑ) ์๋ฃ)
- `DRAFT`: ๋ง ์์ฑ๋จ. ์ฑํ
์ผ๋ก ๋ชฉํ๋ฅผ ์ฑ์ฐ๋ ์ค.
- `GOAL_SET`: 8๊ฐ์ง ๋ชฉํ๊ฐ ์ ๋ถ ์ฌ์ฉ์ ๋ฐํ๋ก ํ์ ๋จ(`processChat`์ด ์๋ ์น๊ฒฉ).
- `FORMALIZED`: `doFormalize`๊ฐ ์ ๋ต ํ๋ณด 3๊ฐ๋ฅผ ๋ง๋ฆ.
- `TESTED`: `doBacktest`๊ฐ ํ ๋ฒ ๋์๊ฐ.
- `LIVE`: ์ค์ ์ด์ฉ ์ค. **์ด ์ํ์์๋ backtest/formalize ๊ฐ status ๋ฅผ ๊ฐ๋ฑํ์ง ์์ต๋๋ค**(์ฝ๋์ `if (!"LIVE"...)` ๊ฐ๋).
> ๐ก ์ด๋ณด ํฌ์ธํธ: ์ํ๋ "ํ์ฌ ์ด๋ ๋จ๊ณ๊น์ง ์๋"๋ฅผ ํ ๊ธ์๋ก ์์ฝํ ๋ผ๋ฒจ์ผ ๋ฟ, ๊ฐ์ ํ๋ฆ ์ ์ด๋ ๊ฑฐ์ ์์ต๋๋ค(๋ค์ ๋จ๊ณ API ๋ ๋ณดํต "์ ์ ์กฐ๊ฑด"๋ง ์ฒดํฌ). ๊ทธ๋์ LIVE ๋ณดํธ ๊ฐ๋๊ฐ ์ค์ํฉ๋๋ค.
### 3) goalProfile JSON โ "๋งค๋์ ๊ฐ ๋ฐ์ ์ ์ ๋ชฉํ ๋ฉ๋ชจ"
์ฑํ
์ผ๋ก ์์งํ๋ 8๊ฐ์ง๊ฐ ํ๋์ JSON ์ผ๋ก ๊ตณ์ด์ง๋๋ค:
```json
{
"goal": "5๋
์์ ์ 300๋ง์ ํ๊ธํ๋ฆ",
"horizon_years": 5,
"initial_capital_krw": 5000000,
"monthly_contribution_krw": 1000000,
"risk_tolerance": "์ค๋ฆฝ",
"max_drawdown_target_pct": 25,
"assets": ["QQQ","SCHD"],
"initial_strategy_direction": "์ถ์ธ์ถ์ข
+ ๋ณ๋์ฑ์กฐ์ "
}์ด JSON ์ด AlphaWorkspace.goalProfileJson ์ปฌ๋ผ์ ๋ฌธ์์ด๋ก ์ ์ฅ๋ฉ๋๋ค. 8๊ฐ ํค๊ฐ ์ ๋ถ ์ฑ์์ก๋์ง๋ฅผ hasAllGoalKeys()๊ฐ ๊ฒ์ฌํฉ๋๋ค(์๋ ๋ผ์ธ๋ณ ํด์ค).
4) strategyConfig "์๋ฒจ๋กํ(envelope)" โ ํ๋ณด 3๊ฐ๋ฅผ ๋ด๋ ๋ดํฌ
doFormalize์ ๊ฒฐ๊ณผ๋ ๋จ์ผ ์ ๋ต์ด ์๋๋ผ ํ๋ณด ๋ฐฐ์ด์ ๊ฐ์ผ ๋ดํฌ์
๋๋ค:
{
"candidates": [ {"id":"cand-1", ...}, {"id":"cand-2", ...}, {"id":"cand-3", ...} ],
"selectedId": "cand-1"
}
candidates: ๋ณด์/์ค๋ฆฝ/๊ณต๊ฒฉ ํค์ ์ ๋ต ์นด๋ 3์ฅ.selectedId: ํ์ฌ ์ ํ๋ ์นด๋ id. ๋ฐฑํ ์คํธยท๊ตญ๋ฉดยท์ ๋ขฐ๋ ์ด ์ ํ ์นด๋ ํ๋๋ง ๋์์ผ๋ก ํฉ๋๋ค(getActiveStrategy()๊ฐ ๊ณจ๋ผ๋).
5) ํจ์น ๋๊ตฌ "heli-patch" ์ ChangeSet 3์ํ
AI(๋๋ ์ฝ๋ ํธ์งยทClaude ์์ด์ ํธ)๊ฐ ์ค์ ์ ๋ฐ๊ฟ ๋, ํต์งธ๋ก ๋ฎ์ด์ฐ์ง ์๊ณ ๋ถ๋ถ ํจ์น(ops) ๋ฅผ ๋ณด๋ ๋๋ค:
[ { "target": "strategy", "path": "parameters.ma_window", "value": 120 } ]
target: ์ด๋ ๋ฌถ์์ ๊ณ ์น ์ง(strategy / backtest / regime / trustScore / goalProfile / code).path: ์ ํ๊ธฐ ๊ฒฝ๋ก(parameters.ma_window).value: ์ ๊ฐ.
์ด ํจ์น ํ ๋ฒ์ด ChangeSet ํ ํ์ผ๋ก ๊ธฐ๋ก๋๋ฉฐ, ํญ์ 3์ํ๋ฅผ ๊ฑฐ์นฉ๋๋ค:
PENDING (์ ์ฉ๋์ง๋ง ๋ฏธํ์ )
โโ keep โ KEPT (์ ์ง ํ์ , ๋ ์ด์ ๋๋๋ฆฌ๊ธฐ ๋ถ๊ฐ)
โโ undo โ UNDONE (before ์ค๋
์ท์ผ๋ก ๋กค๋ฐฑ)before/after ์ ์ฒด ์ค๋ ์ท์ ์ ์ฅํ๋ฏ๋ก undo ๊ฐ ์ ํํ ๋ณต์ํฉ๋๋ค. ๋น์ : ์๋์ "๋ณ๊ฒฝ ๋ด์ฉ ์ถ์ " + Ctrl+Z.
6) Claude CLI ๊ฒฉ๋ฆฌ โ "์ ๋ฌธ๊ฐ๋ฅผ ๊ฒฉ๋ฆฌ์ค์ ๋ค์ฌ๋ณด๋ธ๋ค"
์ฝ๋ ์คํธ์ง์ ์ธ๋ถ claude CLI ํ๋ก์ธ์ค๋ฅผ ๋์์ ํฉ๋๋ค. ์ํํ๋ฏ๋ก ์์ ์ฅ์น 4๊ฒน:
- ๊ธฐ๋ณธ OFF (
app.claude.cli.enabled=false), - ์์ ๊ฒฉ๋ฆฌ ๋๋ ํฐ๋ฆฌ (์ํฌ์คํ์ด์ค ์ฝ๋๋ง ๋ณต์ฌํด์ ํผ์นจ),
- ํ์ฉํด ํ์ดํธ๋ฆฌ์คํธ (
Read,Edit,Write,Glob,Grep๋ง, Bash/WebFetch/WebSearch ์ฐจ๋จ), - ํ์์์ + ์์ฐ ์ํ(
--max-budget-usd 1).
์ ๋ฌธ๊ฐ๋ ๊ทธ ๋ฐฉ ์์ ํ์ผ๋ง ์ฝ๊ณ ๊ณ ์น ์ ์๊ณ , ๋ช ๋ น ์คํ์ด๋ ์ธํฐ๋ท์ ๋งํ ์์ต๋๋ค.
๐บ ์์ฒญ ํ๋ฆ๋
(A) ๋ชฉํ์์ง ์ฑํ โ ์ ๋ต ํ๋ณด
[ํ๋ก ํธ Chat ํญ] POST /api/alpha/workspaces/{id}/chat { text }
โ
โผ
AlphaWorkspaceController.chat() โ JWT ๋ก uid ํ์ธ, ws ์์ ํ์ธ
โ
โผ
AlphaHelixService.processChat(ws, uid, userText)
โ
โโโ user ๋ฉ์์ง ์ ์ฅ (AlphaChatMessage)
โโโก ์ปจํ
์คํธ ์กฐ๋ฆฝ:
โ buildWorkspaceStateContext(ws) โ ํ์ฌ ๋ชฉํ/์ ๋ต/๋ฐฑํ
์คํธ ์์น ์์ฝ(์ฝ๋ ์๋)
โ + ์ต๊ทผ 12๊ฐ ๋ํ ([user]/[model])
โโโข system ํ๋กฌํํธ(ํผ์ค๋ ํํธ ๋งค๋์ + 8๊ฐ ํญ๋ชฉ + ์ ๋๊ท์น)
โโโฃ callAi(uid, system, ctx) โโโถ AiGatewayService.oneShot(์ฟผํฐยท๋ก๊ทธ) โโโถ Gemini
โโโค model ๋ต๋ณ ์ ์ฅ
โโโฅ extractJsonBlock(reply) โ hasAllGoalKeys?
YES โ goalProfileJson ์ ์ฅ, status DRAFTโGOAL_SET, ๋ก๊ทธ GOAL_DEFINED
โ
โผ
{ reply, goalProfileExtracted, autoRunReady }
โโ (์ฌ์ฉ์๊ฐ 'Goal โ Strategy' ๋ฒํผ) โโโถ POST .../formalize ๋๋ /auto-run
โ
โผ
doFormalize(ws, uid): goalProfileJson โ LLM โ ํ๋ณด 3๊ฐ ์ถ์ถ โ envelope ์ ์ฅ โ status FORMALIZED(B) ๋ถ์ ํ์ดํ๋ผ์ธ (๊ฐ๋ณ ๋๋ auto-run ์ผ๊ด)
์ ํ ์ ๋ต(getActiveStrategy) โโโฌโ doBacktest โ AnalyticsClient.backtest/infiniteBuying โ lastBacktestJson
โโ doRegime โ AnalyticsClient.regime โ lastRegimeJson
โโ doTrust โ AnalyticsClient.trustScore โ lastTrustJson
โโ doQueueOrders(infinite_buying๋ง) โ OrderProposal(PENDING) ์์ฑ
โโ doBriefing โ LLM ๋ชจ๋ ๋ธ๋ฆฌํ + ์ถ์ฒ ๋งํฌ
doAutoRun = formalize โ backtest โ regime โ trust โ (ib๋ฉด) queue-orders ๋ฅผ ์์ฐจ ์คํ
(๊ฐ ๋จ๊ณ ์คํจ๋ catch ํด์ error ๋ก ๋ด๊ณ ๊ณ์) โ lastReportJson ์ ์ฅ(C) ํจ์น / Claude ์ฝ๋ ์์ด์ ํธ
[Config ์นด๋ ์์ / AI ์ ์ / Claude ํธ์ง]
โ
โผ
POST /changesets { title, ops:[{target,path,value}] }
โ
โผ
AlphaPatchService.apply():
before ์ค๋
์ท ๋ณด์กด(strategyConfig+goalProfile+codeJson) โ setPath ๋ก ๋ถ๋ถ ์ ์ฉ
โ ws ์ ์ฅ โ ChangeSet(PENDING) ์ ์ฅ โ ๋ก๊ทธ PARAM_CHANGED
โ
์ฌ์ฉ์ ์ ํ โโโฌโ keep โ KEPT (Claude ๋ณ๊ฒฝ์ด๊ณ repo ์ฐ๋๋๋ฉด GitHub ์๋ ์ปค๋ฐ)
โโ undo โ before ์ค๋
์ท์ผ๋ก ๋กค๋ฐฑ โ UNDONE
[Claude ์์ด์ ํธ] POST /claude-agent/start { request }
โ
โผ
ClaudeCodeAgentService.startAgent โ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋
materialize(codeJson โ ์์ ๋๋ ํฐ๋ฆฌ ํ์ผ๋ค)
โ runCli(claude -p, allowedTools=Read/Edit/Write/Glob/Grep, disallowed=Bash/Web)
โ stream-json ๋ผ์ธ โ ์งํ ๋ก๊ทธ(์ก ์คํ ์ด, ํ๋ก ํธ ํด๋ง /status?since=N)
โ finishApply: ํธ์ง diff โ ops(target=code) โ AlphaPatchService.apply() โ ChangeSet(PENDING)
โ ์์ ๋๋ ํฐ๋ฆฌ ์ญ์ (finally)๐ ํต์ฌ ํด๋์ค ๋ผ์ธ๋ณ ์ฌํ
โ
. AlphaHelixService โ ๋๋
A. ํด๋์ค ๊ณจ๊ฒฉ๊ณผ ์์กด์ฑ โ AlphaHelixService.java:48-72
// L48-L72 (์์ฝ)
@Slf4j @Service @RequiredArgsConstructor @lombok.Getter
public class AlphaHelixService {
public final AlphaWorkspaceRepository workspaceRepo;
public final AlphaChatMessageRepository chatRepo;
public final AlphaDecisionLogRepository logRepo;
...
private final GeminiService gemini; // fallback (anonymous)
private final AiGatewayService gateway; // ์ฟผํฐ ๊ด๋ฆฌ ํตํฉ (Task 12)
public final AnalyticsClient analytics;
public final ObjectMapper om = new ObjectMapper();
@Autowired @Lazy
private AlphaHelixService self; // self-injection
...
private static final String DEFAULT_MODEL = "gemini-2.5-flash";
- ๋ฌด์์ ํ๋: ์ธ ์ปจํธ๋กค๋ฌ(
AlphaWorkspaceController/AlphaAnalyticsController/๊ทธ๋ฆฌ๊ณAlphaPatchController๋recordLog์ฌ์ฌ์ฉ)๊ฐ ๊ณต์ ํ๋ ์๋น์ค. ๋ฆฌํฌ์งํ ๋ฆฌยทLLMยทAnalytics ํด๋ผ์ด์ธํธยทJSON ๋งคํผ๋ฅผ ๋ชจ๋ ํ ๊ณณ์ ๋ชจ์๋๋ค. - ์ ํ๋๊ฐ
public final์ธ๊ฐ:@lombok.Getter๊ฐ ๊ฒํฐ๋ฅผ ๋ง๋ค์ด์ฃผ๊ณ , ์ปจํธ๋กค๋ฌ๊ฐsvc.getWorkspaceRepo()์์ผ๋ก ์ง์ ์ ๊ทผํฉ๋๋ค. (์ ์์ private ์ด์ง๋ง ์ฌ๊ธฐ์ ์๋์ ์ผ๋ก ๋ ธ์ถ.) @Autowired @Lazy private AlphaHelixService self;โ self-injection (์ค์): ์๊ธฐ ์์ ์ ์ฃผ์ ๋ฐ๋ ํธ๋ฆญ.doAutoRun์์์self.doFormalize(...),self.doBacktest(...)์ฒ๋ผ ์๊ธฐ ๋ฉ์๋๋ฅผ ํ๋ก์๋ฅผ ํตํด ๋ถ๋ฆ ๋๋ค.- ์? Spring ์
@Transactional์ ํ๋ก์ ๊ฐ์ฒด๋ฅผ ํตํด ํธ์ถ๋ ๋๋ง ๋์ํฉ๋๋ค.this.doBacktest()๋ก ๋ถ๋ฅด๋ฉด ํ๋ก์๋ฅผ ์ฐํํด ํธ๋์ญ์ ๊ฒฝ๊ณ๊ฐ ์๋ก ์ ์๊น๋๋ค.self.doBacktest()๋ ํ๋ก์๋ฅผ ๊ฑฐ์น๋ฏ๋ก ๊ฐ ๋จ๊ณ๊ฐ ์ ๋๋ก ํธ๋์ญ์ ์ ๊ฐ์ต๋๋ค.@Lazy๋ "์์ฑ ์์ ์ํ์ฐธ์กฐ(์๊ธฐ ์์ )"๋ฅผ ๊นจ๊ธฐ ์ํ ์ฅ์น.
- ์? Spring ์
๐ก ์ด๋ณด๊ฐ ํท๊ฐ๋ฆฌ๋ ํฌ์ธํธ: "์ ๋ด ๋ฉ์๋๋ฅผ
this.๊ฐ ์๋๋ผself.๋ก ๋ถ๋ฅด์ง?" โ Spring AOP(ํธ๋์ญ์ ๋ฑ)๋ self-invocation ์ ์ ๋จน๋๋ค๋ ์ ๋ช ํ ํจ์ ๋๋ฌธ์ ๋๋ค. auto-run ์ฒ๋ผ ๋ด๋ถ์์ ํธ๋์ญ์ ๋ฉ์๋๋ฅผ ์ฌ๋ฌ ๋ฒ ๋ถ๋ฅผ ๋ ๊ผญ ํ์ํฉ๋๋ค.
B. LLM ํธ์ถ ๋จ์ผ ํต๋ก callAi โ AlphaHelixService.java:80-85
// L80-L85
public String callAi(Long uid, String systemInstruction, String userInput) {
if (uid != null) {
return gateway.oneShot(uid, DEFAULT_MODEL, systemInstruction, userInput, false);
}
return gemini.oneShot(systemInstruction, userInput);
}
- ๋ฌด์์ ํ๋: ์ด ์๋น์ค์ ๋ชจ๋ LLM ํธ์ถ์ ์ด ํ ๋ฉ์๋๋ฅผ ํต๊ณผํฉ๋๋ค(์ฑํ ยท์ ํํยท๋ธ๋ฆฌํ ๋ชจ๋).
- ์ ์ด๋ ๊ฒ ํ๋: ์ธ์ฆ๋ ์ฌ์ฉ์๋
AiGatewayService๋ฅผ ๊ฑฐ์ณ ์ฟผํฐ ์ฐจ๊ฐยท์ฌ์ฉ ๋ก๊ทธ๊ฐ ๋จ์ต๋๋ค(gateway/AiGatewayService.java:54). ๋ฏธ์ธ์ฆ์GeminiServiceํด๋ฐฑ. ๋จ, ์ฃผ์๋๋ก *"AlphaHelix ์๋ํฌ์ธํธ๋ ํญ์ ์ธ์ฆ ํ์์ด๋ฏ๋ก uid==null ์ ์ค์ง ์๋๋ค"* โ ์ฆ ํด๋ฐฑ ๊ฒฝ๋ก๋ ์ฌ์ค์ ์์ ๋ง. - ๋ง์ง๋ง ์ธ์
false=wantJson=false: Gemini ์๋ต์responseMimeType:application/json๊ฐ์ ๋ฅผ ๊ฑธ์ง ์์ต๋๋ค. ์ฑํ ยท๋ธ๋ฆฌํ์ ๋งํฌ๋ค์ด ํ๋ฌธ์ด ํ์ํ๊ธฐ ๋๋ฌธ(JSON ๊ฐ์ ํ๋ฉด ๋งํฌ๋ค์ด ๋ณธ๋ฌธ์ด ๊นจ์ง). ์ ํํ์ฒ๋ผ ์์ JSON ์ด ํ์ํ ๋๋ ์ฌ๊ธฐ์ false ๋ก ๋ฐ๊ณ , ์๋ต์์ JSON ๋ธ๋ก๋ง ๋ฐ๋ก ์ถ์ถ(extractFirstJsonArray)ํ๋ ์ ๋ต์ ์๋๋ค.
C. ์ ํ ์ ๋ต ๊ณจ๋ผ๋ด๊ธฐ getActiveStrategy โ AlphaHelixService.java:139-152
// L139-L152
public JsonNode getActiveStrategy(JsonNode cfg) {
if (cfg == null || cfg.isMissingNode() || cfg.isNull()) return cfg;
if (cfg.has("candidates") && cfg.get("candidates").isArray()) {
String selId = cfg.path("selectedId").asText(null);
JsonNode arr = cfg.get("candidates");
if (selId != null) {
for (JsonNode c : arr) {
if (selId.equals(c.path("id").asText())) return c;
}
}
if (arr.size() > 0) return arr.get(0);
}
return cfg;
}
- ๋ฌด์์ ํ๋: ๋ดํฌ(envelope)์์ ํ์ฌ ์ ํ๋ ์ ๋ต ์นด๋ ํ ์ฅ์ ๊บผ๋
๋๋ค.
selectedId์ ์ผ์นํ๋ ์นด๋๋ฅผ ์ฐพ๊ณ , ์์ผ๋ฉด ์ฒซ ์นด๋, ๋ดํฌ ๊ตฌ์กฐ๊ฐ ์๋๋ฉด ์ ๋ ฅ์ ๊ทธ๋๋ก ๋ฐํ. - ์ ์ค์:
doBacktest/doRegime/doTrust/doQueueOrders๊ฐ ์ ๋ถ ์ด ํจ์๋ก ์์ํฉ๋๋ค. "ํ๋ณด 3๊ฐ ์ค ๋ฌด์์ ๋ถ์ํ๋๋"์ ๋จ์ผ ์ง์ค ๊ณต๊ธ์์ ๋๋ค. - ์ด๋ณด ํฌ์ธํธ:
cfg.path("x")์cfg.get("x")์ฐจ์ด โpath๋ ์์ผ๋ฉด "MissingNode"(์์ธ ์์ด ์์ ),get์ ์์ผ๋ฉดnull. Jackson ์์ ์์ ํ์์path๋ฅผ ์๋๋ค.
D. 8๊ฐ ๋ชฉํ ํค ๊ฒ์ฆ hasAllGoalKeys / ์ค๋น์๋ฃ isAutoRunReady โ AlphaHelixService.java:154-173
// L154-L165
public boolean hasAllGoalKeys(String json) {
try {
JsonNode g = om.readTree(json);
return g.hasNonNull("goal")
&& g.hasNonNull("horizon_years")
&& g.hasNonNull("monthly_contribution_krw")
&& g.hasNonNull("risk_tolerance")
&& g.hasNonNull("max_drawdown_target_pct")
&& g.path("assets").isArray() && g.path("assets").size() > 0
&& g.hasNonNull("initial_strategy_direction");
} catch (Exception e) { return false; }
}
- ๋ฌด์์ ํ๋: ์ฑํ
์์ ์ถ์ถํ JSON ์ด ๋ชฉํ๋ก ์ธ์ ํ ๋งํผ ์ถฉ๋ถํ์ง ๊ฒ์ฌ.
hasNonNull= ํค๊ฐ ์๊ณ ๊ฐ์ด null ์ด ์๋.assets๋ ๋ฐ๋์ ๋น์ด์์ง ์์ ๋ฐฐ์ด. - ์ ์ด๋ ๊ฒ ๋นก๋นกํ๊ฐ: ์ด ๊ฒ์ฌ๊ฐ ํต๊ณผํด์ผ๋ง
processChat์ด goalProfile ์ ์ ์ฅํ๊ณ status ๋ฅผ GOAL_SET ์ผ๋ก ์ฌ๋ฆฝ๋๋ค. ์ฆ "์ง์ง ๋ค ๋ชจ์์ ๋๋ง ๋ค์ ๋จ๊ณ๋ก" ์ ๊ด๋ฌธ. (์ฐธ๊ณ :initial_capital_krw๋ ์ฌ๊ธฐ ๊ฒ์ฌ์ ๋น ์ ธ ์์ต๋๋ค โ ์์คํ ํ๋กฌํํธ๋ 8๊ฐ๋ฅผ ์๊ตฌํ์ง๋ง ์ด ๊ฒ์ดํธ๋ 7๊ฐ ํ์+assets ์ ๋๋ค. ๋ฏธ๋ฌํ ๋น๋์นญ์ด๋ ๊ฐ์ ์ ์ง์ ํฌ์ธํธ.)
// L167-L173
public boolean isAutoRunReady(String goalProfileJson) {
...
return g.path("assets").isArray() && g.path("assets").size() > 0
&& !g.path("initial_strategy_direction").asText("").isBlank();
}
- ๋ ๋์จํ ๊ฒ์ฌ: auto-run(์ํด๋ฆญ ์ ์ฒด์คํ) ๋ฒํผ์ ๋์ธ์ง ๊ฒฐ์ . ์ต์ํ ๊ด์ฌ์์ฐ + ์ ๋ต๋ฐฉํฅ๋ง ์์ผ๋ฉด ์งํ ๊ฐ๋ฅ์ผ๋ก ๋ด ๋๋ค.
E. LLM ๋ต๋ณ์์ JSON ๋์ง์ด๋ด๊ธฐ extractJsonBlock โ AlphaHelixService.java:175-211
// L175-L211 (ํต์ฌ ๋ฐ์ท)
public String extractJsonBlock(String text) {
if (text == null) return null;
int s = text.indexOf("```json");
if (s < 0) s = text.indexOf("```");
if (s >= 0) { /* ์ฝ๋ํ์ค ์ ๋ณธ๋ฌธ์ ์๋ผ om.readTree ๋ก ๊ฒ์ฆ ํ ๋ฐํ */ }
int objStart = text.indexOf('{');
if (objStart < 0) return null;
int depth = 0; boolean inStr = false; char prev = 0;
for (int i = objStart; i < text.length(); i++) {
char c = text.charAt(i);
if (inStr) { if (c == '"' && prev != '\\') inStr = false; }
else {
if (c == '"') inStr = true;
else if (c == '{') depth++;
else if (c == '}') { depth--; if (depth == 0) { /* ๊ท ํ ์กํ ์ฒซ ๊ฐ์ฒด ๋ฐํ */ } }
}
prev = c;
}
return null;
}
- ๋ฌด์์ ํ๋: LLM ์๋ต์์ ์ ํจํ JSON ๊ฐ์ฒด ํ๋๋ฅผ ์์ ํ๊ฒ ์ถ์ถ. 2๋จ๊ณ ์ ๋ต:
```json ... ```์ฝ๋ํ์ค๊ฐ ์์ผ๋ฉด ๊ทธ ์์ ๋ณธ๋ฌธ์ ๊บผ๋ดom.readTree๋ก ํ์ฑ ๊ฒ์ฆ(ํ์ฑ ์คํจํ๋ฉด ๋ฌด์).- ์ฝ๋ํ์ค๊ฐ ์์ผ๋ฉด ์ค๊ดํธ ๊ท ํ(brace matching) ์ผ๋ก ์ฒซ ์๊ฒฐ ๊ฐ์ฒด๋ฅผ ์ฐพ์ต๋๋ค.
- ์ ์ง์ ํ์ฑํ๋(์ค์ํ ๊ตํ): LLM ์ JSON ์๋ค์ *"์กฐ๊ฑด์ ์ ๋ฆฌํด ๋ณด๊ฒ ์ต๋๋ค โจ"* ๊ฐ์ ์ค๋ช
ํ
์คํธ๋ฅผ ๋ถ์
๋๋ค.
om.readTree(reply)๋ฅผ ํต์งธ๋ก ํ๋ฉด ๊นจ์ง๋๋ค. ๊ทธ๋์ ๋ฌธ์์ด ์์์ JSON ์์ญ๋ง ๋๋ ค๋ด๋ ์์ ํ์๊ฐ ํ์ํฉ๋๋ค. inStr/prev์ถ์ ์ด ํต์ฌ: ๋ฌธ์์ด ๋ฆฌํฐ๋ด ์์{/}์ ์ด์ค์ผ์ดํ๋\"๋ฅผ ์ค๊ดํธ๋ก ์คํดํ์ง ์๋๋ก, "์ง๊ธ ๋ฌธ์์ด ์์ธ๊ฐ"์ "์ง์ ๋ฌธ์๊ฐ ๋ฐฑ์ฌ๋์์๋"๋ฅผ ์ถ์ ํฉ๋๋ค.- ํ์ ๋ฉ์๋:
extractFirstJsonArray(L213,[...]๋ฐฐ์ด์ฉ โ ์ ํํ ํ๋ณด ์ถ์ถ),extractFirstJson(L240, ๋ ๋จ์ํ ๊ฐ์ฒด ์ถ์ถ).extractFirstJsonArray๋ ๋ฌธ์์ด ์ถ์ ๊น์ง ํ์ง๋งextractFirstJson์ ๋จ์ depth ์นด์ดํ ๋ง ํฉ๋๋ค(์ฐจ์ด ์ฃผ์).
โ ๏ธ ๊ฐ์ ํฌ์ธํธ: "LLM ์ถ๋ ฅ = ์ ๋ขฐ ๋ชป ํ ๋ฌธ์์ด" ์ด๋ผ๋ ๋์ ์ . ์ ๊ท์ ํ ์ค๋ก ๋๋ผ ๊ฒ ๊ฐ์ง๋ง, ์ค์ฒฉ ๊ฐ์ฒดยท๋ฌธ์์ด ๋ด ๊ดํธ ๋๋ฌธ์ ์ ๊ท์์ ๊นจ์ง๋๋ค. ์ด brace-matching ํจํด์ด ์ ์์ ๋๋ค.
F. ํฐ์ปค ์ ๊ทํ normalizeTicker โ AlphaHelixService.java:262-271
// L262-L271
public static String normalizeTicker(String t) {
if (t == null || t.isBlank()) return "SPY";
String up = t.trim().toUpperCase();
return switch (up) {
case "BTC", "BITCOIN" -> "BTC-USD";
case "ETH", "ETHEREUM" -> "ETH-USD";
case "VIX" -> "^VIX";
default -> up;
};
}
- ์ฌ์ฉ์/LLM ์ด "BTC", "๋นํธ์ฝ์ธ" ๋ฑ ์์ ๋กญ๊ฒ ์ ์ด๋ Analytics ์์ง(์ผํ/๋ฐ์ด๋ธ์ค)์ด ์๋ ์ฌ๋ณผ๋ก ๋ณํ. ๋น ๊ฐ์ด๋ฉด ์์ ๊ธฐ๋ณธ๊ฐ
SPY. Java 21 switch ํํ์ ์ฌ์ฉ.
G. โญ ๋ชฉํ์์ง ์ฑํ
processChat โ AlphaHelixService.java:279-400
์ด ํํธ์ ์ฌ์ฅ์ ๋๋ค. ๋จ๊ณ๋ณ๋ก ๋ด ๋๋ค.
G-1. user ์ ์ฅ + ์ปจํ
์คํธ ์กฐ๋ฆฝ โ L280-L294
// L280-L294
@Transactional
public Map<String, Object> processChat(AlphaWorkspace ws, Long uid, String userText) {
Long id = ws.getId();
chatRepo.save(AlphaChatMessage.builder().workspaceId(id).role("user").text(userText).build());
var history = chatRepo.findByWorkspaceIdOrderByCreatedAtAsc(id);
StringBuilder ctx = new StringBuilder();
// ํ์ฌ ์ํฌ์คํ์ด์ค ์ํ๋ฅผ ์ปจํ
์คํธ ๋งจ ์์ ์ฃผ์
ํ๋ค.
ctx.append(buildWorkspaceStateContext(ws));
int start = Math.max(0, history.size() - 12);
for (int i = start; i < history.size(); i++) {
var m = history.get(i);
ctx.append("[").append(m.getRole()).append("] ").append(m.getText()).append("\n");
}
- ๋ฌด์์ ํ๋: โ ์ฌ์ฉ์์ ์ ๋ฉ์์ง๋ฅผ ์ฆ์ DB ์ ์ฅ โ โก ์ ์ฒด ํ์คํ ๋ฆฌ๋ฅผ ์ฝ๋ ์ต๊ทผ 12๊ฐ๋ง ์ปจํ ์คํธ์ ๋ฃ์(ํ ํฐ ์ ์ฝ) โ โข ๊ทธ ์์ ํ์ฌ ์ํฌ์คํ์ด์ค ์ํ ์์ฝ์ ๋ถ์.
- ์ ์ํ ์์ฝ์ ๋งจ ์์(ํต์ฌ ์ค๊ณ): ์ด๋ฏธ ์ ๋ตยท๋ฐฑํ
์คํธ๊ฐ ์๋ ์ํฌ์คํ์ด์ค์์ *"์ด ์ ๋ต ์น๋ฅ ์ข ์ฌ๋ ค์ค"* ๋ผ๊ณ ๋ฌผ์ผ๋ฉด, AI ๊ฐ "๋ก๋๋ ์ ๋ต(์ฝ๋)์ด ์๋ค"๋ ํ์๋ฆฌ๋ฅผ ํ์ง ์๋๋ก, ํ์ฌ ์์น(MDDยทSharpeยท์น๋ฅ ๋ฑ)๋ฅผ ์ปจํ
์คํธ๋ก ๋ฏธ๋ฆฌ ์ฃผ์
ํฉ๋๋ค(
buildWorkspaceStateContext, ์๋ H ์ฐธ์กฐ). ์ ๊ท ์ํฌ์คํ์ด์ค๋ ๋น ๋ฌธ์์ด์ด๋ผ ์จ๋ณด๋ฉ์ ์ํฅ ์์. @Transactional: ์ด ๋ฉ์๋ ์ ์ฒด๊ฐ ํ ํธ๋์ญ์ . user ์ ์ฅยทmodel ์ ์ฅยทgoalProfile ์ ์ฅ์ด ํ ๋ฌถ์์ผ๋ก ์ปค๋ฐ๋ฉ๋๋ค.
G-2. ์์คํ
ํ๋กฌํํธ โ L296-L374
// L296-L327 (๋ฐ์ท)
String system = """
๋๋ Alpha-Helix์ ํผ์ค๋ ํํธ ๋งค๋์ ๋ค. ์ฌ์ฉ์์ '์ถ์ ๋ชฉํ'๋ฅผ ๋ฃ๊ณ ํฌ์ ์ ๋ต ์ค๊ณ ์กฐ๊ฑด 8๊ฐ์ง๋ฅผ ํ ๋จ๊ณ์ฉ ์์งํ๋ค.
...
๐ ๋ถ์๊ฐ ๋ชจ๋ (์ด๋ฏธ ์ ๋ต/๋ฐฑํ
์คํธ๊ฐ ์๋ ๊ฒฝ์ฐ โ ์๋ ๋ชฉํ์์ง๋ณด๋ค ์ฐ์ )
...
โ ๏ธ ์ ๋ ๊ท์น (์๋ฐ ๊ธ์ง)
โ ์ฌ์ฉ์๊ฐ ๋ช
์์ ์ผ๋ก ๋ตํ์ง ์์ ํญ๋ชฉ์ ์ ๋ ์์๋ก ์ฑ์ฐ์ง ์๋๋ค.
โก 8๊ฐ์ง๊ฐ ์ ๋ถ ์ฌ์ฉ์ ๋ฐํ๋ก ํ์ธ๋๊ธฐ ์ ๊น์ง๋ ์ ๋ JSON์ ์ถ๋ ฅํ์ง ์๋๋ค.
...
์์งํด์ผ ํ 8๊ฐ์ง ํญ๋ชฉ
1) goal ... 8) initial_strategy_direction ...
""";
- ๋ฌด์์ ํ๋: Java 21 ํ
์คํธ ๋ธ๋ก(
"""...""") ์ผ๋ก ๊ธด ํ๊ตญ์ด ํ๋กฌํํธ๋ฅผ ๋ฐ์๋ฃ์. ์ด ํ๋กฌํํธ๊ฐ AI ์ ํ๋ ์ ๋ถ๋ฅผ ๊ท์ ํฉ๋๋ค. - ๋ ๊ฐ์ง ๋ชจ๋ ๋ถ๊ธฐ๋ฅผ ํ๋กฌํํธ๋ก ์ฒ๋ฆฌ:
- ๋ชฉํ์์ง ๋ชจ๋: ์ ๊ท ์ํฌ์คํ์ด์ค โ 8๊ฐ์ง๋ฅผ ํ ๋จ๊ณ์ฉ ์ง๋ฌธ.
- ๋ถ์๊ฐ ๋ชจ๋: ์ปจํ ์คํธ์ ์ํ๊ฐ ์์ผ๋ฉด โ ๋ชฉํ์์ง์ ๋ค์ ์์ํ์ง ๋ง๊ณ ์ฃผ์ด์ง ์์น๋ก ์ง๋จ/๊ฐ์ .
- ์ ๋ ๊ท์น์ ์๋: โ โก๋ ํ๊ฐ ๋ฐฉ์ง. ์ฌ์ฉ์๊ฐ ๋ง ์ ํ ๊ฐ์ AI ๊ฐ ๋ฉ๋๋ก ์ง์ด๋ด๊ฑฐ๋, ๋ ๋ชจ์๋๋ฐ JSON ์ ๋ฑ์ด ๋ค์ ๋จ๊ณ๋ก ๋์ด๊ฐ๋ ๊ฒ์ ๋ง์ต๋๋ค. (๊ทธ๋์
hasAllGoalKeys๊ฒ์ดํธ์ ์ง์ ์ด๋ฃธ.) [BTN:๋ผ๋ฒจ|์ก์ ]ํ ํฐ(L371): ํ๋ก ํธ๊ฐ ์ด ํ ํฐ์ ์ค์ ๋ฒํผ์ผ๋ก ๋ ๋๋งํฉ๋๋ค(์ก์ : next / ask_more / formalize). ์ฆ LLM ์ด UI ๋ฒํผ์ "์ฃผ๋ฌธ"ํ ์ ์๋ ์ฝ์๋ ๋งํฌ์ .- ๋ฌดํ๋งค์๋ฒ ์ธ์(L334-L338): "๋ผ์ค์ด", "40๋ถํ ", "LOC", "ํ๋จ๋งค์" ๋ฑ ํค์๋๊ฐ ๋์ค๋ฉด
initial_strategy_direction="infinite_buying"์ผ๋ก ๋ถ๊ธฐํ๊ณ ์ถ๊ฐ ํ๋ผ๋ฏธํฐ(split_count ๋ฑ)๋ฅผ ๋ ์์งํ๊ฒ ์ง์.
G-3. LLM ํธ์ถ + ๋ต๋ณ ์ ์ฅ โ L376-L383
// L376-L383
String reply;
try {
reply = callAi(uid, system, ctx.toString());
} catch (Exception e) {
log.error("AI chat fail", e);
reply = "(AI ์๋ต ์คํจ: " + e.getMessage() + ")";
}
chatRepo.save(AlphaChatMessage.builder().workspaceId(id).role("model").text(reply).build());
- LLM ์คํจํด๋ ์์ธ๋ฅผ ์ผํค๊ณ ์ฌ์ฉ์์๊ฒ ๋ณด์ด๋ ์๋ฌ ๋ฉ์์ง๋ฅผ model ๋ฉ์์ง๋ก ์ ์ฅ โ ์ฑํ ์ด ๋๊ธฐ์ง ์์ต๋๋ค(graceful degradation).
G-4. goalProfile ์ถ์ถยท์น๊ฒฉ โ L385-L399
// L385-L399
String extracted = extractJsonBlock(reply);
if (extracted != null && hasAllGoalKeys(extracted)) {
ws.setGoalProfileJson(extracted);
if ("DRAFT".equals(ws.getStatus())) ws.setStatus("GOAL_SET");
workspaceRepo.save(ws);
recordLog(id, "AI", "GOAL_DEFINED", "Goal Profile ์ถ์ถ ์๋ฃ", extracted);
} else {
extracted = null;
}
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("reply", reply);
resp.put("goalProfileExtracted", extracted != null);
resp.put("autoRunReady", extracted != null && isAutoRunReady(extracted));
return resp;
- ๋ฌด์์ ํ๋: AI ๋ต๋ณ์ ์์ฑ๋ ๋ชฉํ JSON ์ด ๋ค์ด์์ผ๋ฉด(์ถ์ถ + 8ํค ๊ฒ์ฌ ํต๊ณผ) โ ์ ์ฅํ๊ณ DRAFTโGOAL_SET ์น๊ฒฉ(์ด๋ฏธ ๋ค๋ฅธ ์ํ๋ฉด ๊ฑด๋๋ฆฌ์ง ์์) โ ๊ฒฐ์ ๋ก๊ทธ ๋จ๊น.
- ๋ฐํ 3ํ๋:
reply(๋ณด์ฌ์ค ๋ต๋ณ),goalProfileExtracted(๋ชฉํ ํ์ ๋๋ โ ํ๋ก ํธ๊ฐ 'GoalโStrategy' ๋ฒํผ ํ์ฑํ),autoRunReady(์ํด๋ฆญ ์ ์ฒด์คํ ๊ฐ๋ฅํ๊ฐ).
H. ์ํฌ์คํ์ด์ค ์ํ ์ปจํ
์คํธ buildWorkspaceStateContext โ AlphaHelixService.java:407-485
// L407-L424 (๋ชฉํ ํ๋กํ ๋ถ๋ถ)
private String buildWorkspaceStateContext(AlphaWorkspace ws) {
StringBuilder sb = new StringBuilder();
try {
String gj = ws.getGoalProfileJson();
if (gj != null && !gj.isBlank()) {
JsonNode g = om.readTree(gj);
java.util.List<String> p = new java.util.ArrayList<>();
if (g.hasNonNull("goal")) p.add("๋ชฉํ=\"" + g.get("goal").asText() + "\"");
... (๊ธฐ๊ฐ/์ด๊ธฐ๊ธ/์์ ๋ฆฝ/์ฑํฅ/MDD๋ชฉํ/๊ด์ฌ์์ฐ/์ ๋ต๋ฐฉํฅ) ...
if (!p.isEmpty()) sb.append("โข ๋ชฉํ ํ๋กํ: ").append(String.join(", ", p)).append("\n");
}
} catch (Exception ignore) { }
...
- ๋ฌด์์ ํ๋: ์ํฌ์คํ์ด์ค์ ํ์ฌ ์ํ๋ฅผ ์ฌ๋์ด ์ฝ๋ ํ ์ค ์์ฝ๋ค๋ก ์์ถํฉ๋๋ค. ๋ชฉํ / ์ ํ ์ ๋ต+ํ๋ผ๋ฏธํฐ(์ 8๊ฐ๋ง) / ์ต๊ทผ ๋ฐฑํ ์คํธ stats / Regime ๋ผ๋ฒจ / Trust Score ๋ฅผ ๊ฐ๊ฐ ํ ์ค๋ก.
- ์ stats ๋ง(์ค์): ์ฃผ์๋๋ก *"๋ฐฑํ ์คํธ๋ stats ๋ง ์ถ๋ ค ๋ฃ๋๋ค(equity_curve ๊ฐ์ ๋์ฉ๋ ๋ฐฐ์ด์ ์ ์ธ)". ์์ฐ๊ณก์ 2500์ ์ ์ปจํ ์คํธ์ ๋ฃ์ผ๋ฉด ํ ํฐ ํญ๋ฐ + ๋ ธ์ด์ฆ. ๋งค๋์ ๊ฐ ์์์ผ ํ ๊ฑด "์ด์์ต X%, MDD Y%" ๊ฐ์ *์์ฝ ์์น**๋ฟ์ ๋๋ค.
catch (Exception ignore)์ ์๋ฏธ: ๊ฐ ๋ธ๋ก์ด ๋ ๋ฆฝ์ ์ผ๋ก try/catch โ ํ JSON ์ด ๊นจ์ ธ๋ ๋๋จธ์ง ์์ฝ์ ์ด์๋จ๊ฒ. ๋ฐฉ์ด์ ํ๋ก๊ทธ๋๋ฐ.
// L482-L485
if (sb.length() == 0) return "";
return "\n[ํ์ฌ ์ํฌ์คํ์ด์ค ์ํ โ ์ฌ์ฉ์๊ฐ \"์ง๊ธ ๋ณด๊ณ ์๋/์ด ์ ๋ต\"์ด๋ผ๊ณ ํ๋ฉด ์๋๋ฅผ ๊ฐ๋ฆฌํจ๋ค]\n"
+ sb + "\n";
}
- ์ํ๊ฐ ํ๋๋ ์์ผ๋ฉด ๋น ๋ฌธ์์ด: ์ ๊ท ์ํฌ์คํ์ด์ค๋ ์ปจํ ์คํธ ์ค์ผ ์์ด ์์ ์จ๋ณด๋ฉ. ์ํ๊ฐ ์์ผ๋ฉด *"'์ด ์ ๋ต'์ด๋ผ๊ณ ํ๋ฉด ์๋๋ฅผ ๊ฐ๋ฆฌํจ๋ค"* ๋ผ๋ ์๋ด ํค๋๋ฅผ ๋ถ์ฌ AI ์ ์ง์๋๋ช ์ฌ ํด์์ ๋์ต๋๋ค.
I. โญ ์ ํํ doFormalize โ AlphaHelixService.java:493-581
// L494-L529 (์์คํ
ํ๋กฌํํธ ๋ฐ์ท)
@Transactional
public Map<String, Object> doFormalize(AlphaWorkspace ws, Long uid) throws Exception {
String system = """
๋๋ ์ฌ์ฉ์ ๋ชฉํ(JSON)๋ฅผ ๋ฐ์ **deterministic ๋ฐฑํ
์คํธ๊ฐ ๊ฐ๋ฅํ** ์ ๋ต config ํ๋ณด 3๊ฐ๋ฅผ ์ ์ํ๋ค.
๊ฐ ํ๋ณด๋ ์๋ 7๊ฐ ํ
ํ๋ฆฟ ์ค ์๋ก ๋ค๋ฅธ strategy_type์ ๊ณ ๋ฅธ ๋ณด์/์ค๋ฆฝ/๊ณต๊ฒฉ ํค์ผ๋ก ...:
- buy_hold / moving_average_timing / momentum_rotation / vix_risk_off
- trend_volatility_control / dividend_tilt / infinite_buying
...
๋ฐ๋์ ์ฝ๋๋ธ๋ก ์์ด **์์ JSON ๋ฐฐ์ด๋ง** ์ถ๋ ฅํ๋ผ. ๊ธธ์ด๋ ์ ํํ 3.
""";
- ๋ฌด์์ ํ๋: goalProfile โ ์ ๋ต ํ๋ณด 3๊ฐ JSON ๋ฐฐ์ด ์์ฑ. 7๊ฐ ํ ํ๋ฆฟ ์ค ์๋ก ๋ค๋ฅธ ๊ฒ์ ๋ณด์/์ค๋ฆฝ/๊ณต๊ฒฉ ํค์ผ๋ก.
- deterministic ๊ฐ์กฐ: ํ๋ณด์ strategy_type ์ ๋ฐ๋์ ๋ฐฑํ
์คํธ ์์ง(
01_backtest/vbt_engine.md์ 6์ ๋ต + ๋ฌดํ๋งค์)์ผ๋ก ์ฌํ ๊ฐ๋ฅํ ๊ฒ์ด์ด์ผ ํฉ๋๋ค. AI ๊ฐ "๊ฐ์ผ๋ก ์ฌ๊ณ ํ๊ธฐ" ๊ฐ์ ๋น๊ฒฐ์ ์ ๋ต์ ๋ง๋ค์ง ๋ชปํ๊ฒ.
// L531-L567 (ํ์ฑ + ํด๋ฐฑ)
String result;
try { result = callAi(uid, system, ws.getGoalProfileJson()); }
catch (RuntimeException e) { throw new RuntimeException("์ ํํ ์คํจ: " + e.getMessage(), e); }
...
String arrayJson = extractFirstJsonArray(result);
List<Map<String, Object>> candidates = new ArrayList<>();
try {
if (arrayJson != null) {
JsonNode arr = om.readTree(arrayJson);
for (int i = 0; i < arr.size() && i < 3; i++) {
Map<String, Object> cand = om.convertValue(arr.get(i), Map.class);
cand.put("id", "cand-" + (i + 1));
candidates.add(cand);
}
}
if (candidates.isEmpty()) { // ๋ฐฐ์ด ์ถ์ถ ์คํจ โ ๋จ์ผ ๊ฐ์ฒด๋ผ๋ ๊ฑด์ง๋ค
String obj = extractFirstJson(result);
if (obj != null) { Map<String,Object> cand = om.readValue(obj, Map.class); cand.put("id","cand-1"); candidates.add(cand); }
}
} catch (Exception e) { log.error("formalize parse fail", e); }
if (candidates.isEmpty()) throw new RuntimeException("LLM ์๋ต์ ํ์ฑํ์ง ๋ชปํ์ต๋๋ค: " + result);
- ๋ฐฉ์ด์ ํ์ฑ 2๋จ๊ณ: โ ๋ฐฐ์ด ์ถ์ถ โ ์ต๋ 3๊ฐ์
id(cand-1..3) ๋ถ์ฌ. โก ๋ฐฐ์ด์ด ์์ผ๋ฉด ๋จ์ผ ๊ฐ์ฒด๋ผ๋ ๊ฑด์ ธ ํ๋ณด 1๊ฐ๋ก. ๋ ๋ค ์คํจํ๋ฉด ๋ช ์์ ์์ธ. om.convertValuevsom.readValue: ์ ์๋ ์ด๋ฏธ JsonNode ์ธ ๊ฒ์ Map ์ผ๋ก ๋ณํ, ํ์๋ ๋ฌธ์์ด์ ํ์ฑ. ๋ ๋ค ๊ฒฐ๊ณผ๋Map<String,Object>.
// L569-L581 (๋ดํฌ ์ ์ฅ)
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("candidates", candidates);
envelope.put("selectedId", candidates.get(0).get("id")); // ๊ธฐ๋ณธ ์ ํ = ์ฒซ ํ๋ณด
String envelopeJson = om.writeValueAsString(envelope);
ws.setStrategyConfigJson(envelopeJson);
if (!"LIVE".equals(ws.getStatus())) ws.setStatus("FORMALIZED"); // LIVE ์ด์ฉ ์ค์ด๋ฉด ๊ฐ๋ฑ ๊ธ์ง
workspaceRepo.save(ws);
recordLog(ws.getId(), "AI", "STRATEGY_PROPOSED", "Strategy ํ๋ณด " + candidates.size() + "๊ฐ ์์ฑ", envelopeJson);
return Map.of("strategyConfig", envelopeJson, "candidates", candidates);
- ๋ดํฌ๋ก ํฌ์ฅ โ ์ฒซ ํ๋ณด๋ฅผ selectedId ๋ก โ strategyConfigJson ์ ์ฅ โ status FORMALIZED(LIVE ๋ณดํธ). ์ฌ์ ์ง์ 4๋ฒ์ envelope ๊ฐ ์ฌ๊ธฐ์ ๋ง๋ค์ด์ง๋๋ค.
J. ๋ฐฑํ
์คํธ ๋์คํจ์น doBacktest โ AlphaHelixService.java:590-655
// L590-L596
@Transactional
public String doBacktest(AlphaWorkspace ws, String period, Map<String, Object> customParams) throws Exception {
JsonNode cfg = getActiveStrategy(om.readTree(ws.getStrategyConfigJson()));
String stype = cfg.path("strategy_type").asText("moving_average_timing");
String pickedPeriod = (period != null && !period.isBlank()) ? period.trim() : "5y";
if (customParams == null) customParams = Map.of();
- ๋ฌด์์ ํ๋: ์ ํ ์ ๋ต ์ข
๋ฅ(
stype)์ ๋ฐ๋ผ ๋ ๊ฐ๋๋ก Analytics ์์ง์ ํธ์ถ. - ๊ฐ๋ 1 โ infinite_buying(L596-L621): tickers ์ ๊ทํ โ split/take_profit/loc_offset/initial_capital ํ๋ผ๋ฏธํฐ๋ฅผ ๋ชจ์
analytics.infiniteBuying(...)ํธ์ถ. - ๊ฐ๋ 2 โ ์ผ๋ฐ 6์ ๋ต(L623-L654):
strategy_type์ Python ์์ง ์ ๋ต๋ช ์ผ๋ก ๋งคํ(momentum_rotationโmacd, ๊ทธ ์ธโsma_cross). ์ฌ์ฉ์๊ฐ ์ฝ๋์์ ํธ์งํcustomParams(sma_fast/slow, rsi_, macd_ ๋ฑ)๊ฐ ์์ผ๋ฉด ์ฐ์ ์ ์ฉ, ์์ผ๋ฉดparameters.ma_window๋ฅผsma_slow๋ก ์ฌ์ฉ.// L648-L654 JsonNode bt = analytics.backtest(ticker, pyStrategy, extra); ws.setLastBacktestJson(bt.toString()); if (!"LIVE".equals(ws.getStatus())) ws.setStatus("TESTED"); // LIVE ์ด์ฉ ์ค์ด๋ฉด ๊ฐ๋ฑ ๊ธ์ง workspaceRepo.save(ws); recordLog(ws.getId(), "SYSTEM", "BACKTEST_RUN", ticker + " / " + pyStrategy + " ๋ฐฑํ ์คํธ ์๋ฃ", null); return bt.toString(); - ๊ฒฐ๊ณผ๋ฅผ
lastBacktestJson์ ์บ์ โ status TESTED(LIVE ๋ณดํธ) โ ๊ฒฐ์ ๋ก๊ทธ โ raw JSON ๋ฌธ์์ด ๋ฐํ(์ปจํธ๋กค๋ฌ๊ฐ ๊ทธ๋๋ก ํ๋ก ํธ์ ์ ๋ฌ).
๐ก ๋งคํ์ด ๋จ์ํ ์ด์ : 7๊ฐ ์ ๋ต ํ ํ๋ฆฟ ์ด๋ฆ(LLM ์ฉ)๊ณผ Python ์์ง์ 6์ ๋ต ์ด๋ฆ์ด ๋ค๋ฆ ๋๋ค. ์ฌ๊ธฐ์
moving_average_timingโsma_cross์ฒ๋ผ ๋ฒ์ญํฉ๋๋ค. ์ฆ LLM ์ดํ โ ์์ง ์ดํ ์ด๋ํฐ.
K. Regime / Trust / Queue-Orders / Briefing / Auto-Run (์์ฝ)
doRegime(L660-L678): ์ ํ ์ ๋ต ์ฒซ ์์ฐ์ผ๋กanalytics.regime(ticker, options). options ์method=hmm๋ช ์ํ๋ฉด HMM, ๊ธฐ๋ณธ์ rule(๋น ๋ฆ). โlastRegimeJson.doTrust(L683-L704):analytics.trustScore(ticker, pyStrategy, options)โlastTrustJson. ๋ก๊ทธ์trust_score์ ์ ๊ธฐ๋ก.doQueueOrders(L709-L792): infinite_buying ์ ์ฉ(๋ค๋ฅธ ์ ๋ต์IllegalStateException). ์ํฌ์คํ์ด์ค์ BrokerAccount ๊ฐ ์์ผ๋ฉด ์ฌ์ฉ์์ MOCK KIS ๊ณ์ ์ ์๋ ์ฌ์ฉ(์์ผ๋ฉด ์๋ฌ โ ์ค์ฃผ๋ฌธ ์์ ์ฅ์น).analytics.infiniteBuyingPlan(...)์ ๊ฐ plan ์OrderProposal(status=PENDING, source=SIGNAL, expiresAt=now+24h)๋ก ์ ์ฅ. ์๋ โค 0 ์ ์คํต.doBriefing(L796-L822): ๋ชฉํ+์ ๋ต+๋ฐฑํ ์คํธ+ํธ๋ฌ์คํธ๋ฅผ LLM ์ ๋๊ฒจ ํ๊ตญ์ด ๋ชจ๋ ๋ธ๋ฆฌํ ์์ฑ.buildRegimeReferences(L828-L850)๊ฐ ์ค์ฌํ๋ ์ถ์ฒ ๋งํฌ โฅ5๊ฐ(FREDยทVIXยทTradingView ๋ฑ) + ์์ฐ ํค์๋(BTC/TQQQ/KOSPI)์ ๋ฐ๋ฅธ ํนํ ์ถ์ฒ๋ฅผ ๋ถ์.doAutoRun(L867-L949): ์ํด๋ฆญ ์ ์ฒด ํ์ดํ๋ผ์ธ. self-injection ์ผ๋กself.doFormalize โ self.doBacktest โ self.doRegime โ self.doTrust โ (ib๋ฉด) self.doQueueOrders๋ฅผ ์์ฐจ ์คํ.// L876-L887 (auto-run 1๋จ๊ณ) if (ws.getStrategyConfigJson() == null) { try { self.doFormalize(ws, uid); ws = workspaceRepo.findById(wsId).orElse(ws); // reload after save steps.add("formalize"); } catch (Exception e) { report.put("formalizeError", e.getMessage()); return saveReport(ws, report); } } else { steps.add("formalize:cached"); }- ๊ฐ ๋จ๊ณ ์คํจ๋ catch โ report ์ error ๋ก ๋ด๊ณ ๊ณ์(formalize ๋ง ์คํจ ์ ์กฐ๊ธฐ ๋ฐํ โ ํ์ ๋จ๊ณ์ ์ ์ ์ด๋ฏ๋ก). ๋จ๊ณ๋ง๋ค
workspaceRepo.findById๋ก ์ฌ๋ก๋ฉ: ์ง์ self.doXxx๊ฐ ๋ณ๋ ํธ๋์ญ์ ์์ ws ๋ฅผ ์ ์ฅํ์ผ๋ฏ๋ก ์ต์ ์ํ๋ฅผ ๋ค์ ์ฝ์ด์ต๋๋ค. - ์ต์ข
๊ฒฐ๊ณผ๋
lastReportJson์ ์ ์ฅ(saveReport, L951-L959) โ ํ๋ก ํธ ์บ์.
- ๊ฐ ๋จ๊ณ ์คํจ๋ catch โ report ์ error ๋ก ๋ด๊ณ ๊ณ์(formalize ๋ง ์คํจ ์ ์กฐ๊ธฐ ๋ฐํ โ ํ์ ๋จ๊ณ์ ์ ์ ์ด๋ฏ๋ก). ๋จ๊ณ๋ง๋ค
โ
ก. AlphaPatchService โ ์ฐํ+๋๋๋ฆฌ๊ธฐ
A. ํ์ฉ target ๊ณผ ํ๊ธ ํค ๋ณ์นญ โ AlphaPatchService.java:35-52
// L35-L52
private static final Set<String> ALLOWED_TARGETS = Set.of(
"strategy", "backtest", "regime", "trustScore", "goalProfile", "code");
private static final Map<String, String> GOAL_KEY_ALIAS = Map.ofEntries(
Map.entry("๊ธฐ๊ฐ", "horizon_years"),
Map.entry("์ด๊ธฐํฌ์๊ธ", "initial_capital_krw"),
Map.entry("๊ด์ฌ์์ฐ", "assets_of_interest"),
Map.entry("์ ๋ต๋ฐฉํฅ", "strategy_direction"),
... );
ALLOWED_TARGETS(๋ณด์ ํ์ดํธ๋ฆฌ์คํธ): ํจ์น๊ฐ ๊ฑด๋๋ฆด ์ ์๋ ๋ฌถ์์ 6๊ฐ๋ก ์ ํ. ๊ทธ ์ธ target ์ ๊ฑฐ๋ถ(์๋ apply). ์์ ํ๋ ์กฐ์ ์ฐจ๋จ.GOAL_KEY_ALIAS: ๊ตฌ๋ฒ์ ๋ชจ๋ธ์ด๊ธฐ๊ฐ/์ ๋ต๋ฐฉํฅ๊ฐ์ ํ๊ธ ํค๋ก ๋ณด๋ด๋ ํ๋ก ํธ ์นด๋๊ฐ ์ฝ๋ ์๋ฌธ ํค๋ก ์ ๊ทํ. ์ ๊ฒฝ๋ก์ ์ฒซ ํ ํฐ๋ง ์นํ.
B. โญ apply โ ํจ์น ์ ์ฉ + before/after ์ค๋
์ท โ AlphaPatchService.java:54-135
// L54-L90 (์ ์ฉ ๋ฃจํ)
@Transactional
public AlphaWorkspaceChangeSet apply(AlphaWorkspace ws, String title, List<Map<String, Object>> ops) {
if (ops == null || ops.isEmpty()) throw new IllegalArgumentException("ops ๊ฐ ๋น์ด์์ต๋๋ค");
String strategyBefore = ws.getStrategyConfigJson();
String goalBefore = ws.getGoalProfileJson();
String codeBefore = ws.getCodeJson();
Map<String, Object> cfg = readMap(strategyBefore);
Map<String, Object> goal = readMap(goalBefore);
Map<String, Object> code = readMap(codeBefore);
for (Map<String, Object> op : ops) {
String target = String.valueOf(op.getOrDefault("target", "strategy")).trim();
String path = op.get("path") == null ? "" : String.valueOf(op.get("path")).trim();
Object value = op.get("value");
if (!ALLOWED_TARGETS.contains(target)) throw new IllegalArgumentException("ํ์ฉ๋์ง ์์ target: " + target);
if (path.isEmpty()) throw new IllegalArgumentException("path ๊ฐ ๋น์ด์์ต๋๋ค");
if ("goalProfile".equals(target)) { // ํ๊ธ ํค โ ์๋ฌธ ํค ์ ๊ทํ
String[] gp = path.split("\\.", 2);
String head = GOAL_KEY_ALIAS.getOrDefault(gp[0], gp[0]);
String normalized = gp.length > 1 ? head + "." + gp[1] : head;
setPath(goal, normalized, value);
} else if ("code".equals(target)) { // path=ํ์ผ๋ช
, value=์ ์ ์ฒด ํ์ผ
code.put(path, value == null ? "" : String.valueOf(value));
} else { // strategy โ path, ๊ทธ ์ธ โ target.path
String fullPath = "strategy".equals(target) ? path : target + "." + path;
setPath(cfg, fullPath, value);
}
}
- ๋ฌด์์ ํ๋: ops ๋ฐฐ์ด์ ๋๋ฉฐ ์ธ ๊ฐ๋๋ก ์ ์ฉ โ goalProfile(๋ณ์นญ ์ ๊ทํ), code(ํ์ผ ํต์งธ ๊ต์ฒด), ๋๋จธ์ง(strategyConfig ๋ด๋ถ ๊ฒฝ๋ก).
- target ๊ฒฝ๋ก ๊ท์น(ํด๋์ค ์ฃผ์ L18-L23):
target=strategy โ strategyConfig.{path},target=backtest/regime/trustScore โ strategyConfig.{target}.{path}. ์ฆ strategy ์ธ target ์ ํ ๋จ๊ณ ๋ ๊น์ด ๋ค์ด๊ฐ๋๋ค. - ๋ถ๋ถ ์ ์ฉ ํฌํผ
setPath(L204-L217): ์ ๊ฒฝ๋ก๋ฅผ ๋ฐ๋ผ ์ค์ฒฉ Map ์ ํ๊ณ ๋ค๋ฉฐ, ์ค๊ฐ ๋ ธ๋๊ฐ ์๊ฑฐ๋ Map ์ด ์๋๋ฉด ์๋ก ๋ง๋ค์ด๊ฐ๋ฉฐ ๋ง์ง๋ง ํค์ ๊ฐ์ ๋ฃ์.
// L98-L135 (์ค๋
์ท + ChangeSet ์ ์ฅ)
try {
strategyAfter = om.writeValueAsString(cfg);
goalAfter = om.writeValueAsString(goal);
codeAfter = om.writeValueAsString(code);
opsJson = om.writeValueAsString(ops);
Map<String, String> bs = new LinkedHashMap<>(); // before: ์ธ JSON ๋ชจ๋ ๋ณด์กด
bs.put("strategyConfig", strategyBefore);
bs.put("goalProfile", goalBefore);
bs.put("codeJson", codeBefore);
beforeSnap = om.writeValueAsString(bs);
Map<String, String> as = new LinkedHashMap<>(); // after: ์ธ JSON ๋ชจ๋
as.put("strategyConfig", strategyAfter); ...
afterSnap = om.writeValueAsString(as);
} catch (Exception e) { throw new RuntimeException("JSON ์ง๋ ฌํ ์คํจ: " + e.getMessage(), e); }
ws.setStrategyConfigJson(strategyAfter);
ws.setGoalProfileJson(goalAfter);
ws.setCodeJson(codeAfter);
workspaceRepo.save(ws);
AlphaWorkspaceChangeSet cs = changeSetRepo.save(AlphaWorkspaceChangeSet.builder()
.workspaceId(ws.getId()).title(...).opsJson(opsJson)
.beforeJson(beforeSnap).afterJson(afterSnap).status("PENDING").build());
helix.recordLog(ws.getId(), "AI", "PARAM_CHANGED", "Heli ํจ์น ์ ์ฉ: " + cs.getTitle(), opsJson);
return cs;
- ํต์ฌ ์ค๊ณ โ before/after ์ ์ธ JSON ์ ๋ถ ๋ณด์กด: strategyConfig ๋ง ์ ์ฅํ๋ฉด goalProfileยทcode ํจ์น๋ undo ๊ฐ ์ ๋ฉ๋๋ค. ๊ทธ๋์
{strategyConfig, goalProfile, codeJson}์ธ ๊ฐ๋ฅผ ํต์งธ๋ก ์ค๋ ์ท. ์ด๊ฒ undo ์ ํ์ฑ์ ๋น๊ฒฐ. - ํญ์
status="PENDING"์ผ๋ก ์์.
C. keep / undo โ AlphaPatchService.java:137-183
// L137-L146 (keep)
@Transactional
public AlphaWorkspaceChangeSet keep(AlphaWorkspace ws, Long csId) {
AlphaWorkspaceChangeSet cs = changeSetRepo.findByIdAndWorkspaceId(csId, ws.getId())
.orElseThrow(() -> new NoSuchElementException("changeset not found"));
if (!"PENDING".equals(cs.getStatus())) return cs; // ๋ฉฑ๋ฑ: ์ด๋ฏธ ์ฒ๋ฆฌ๋์ผ๋ฉด ๊ทธ๋๋ก
cs.setStatus("KEPT");
helix.recordLog(ws.getId(), "USER", "PARAM_CHANGED", "๋ณ๊ฒฝ ์ ์ง: " + cs.getTitle(), null);
return changeSetRepo.save(cs);
}
- keep: PENDING โ KEPT. ์ด๋ฏธ PENDING ์ด ์๋๋ฉด ์๋ฌด๊ฒ๋ ์ ํจ(๋ฉฑ๋ฑ). KEPT ํ์๋ ๋๋๋ฆฌ๊ธฐ ๋ถ๊ฐ.
// L148-L183 (undo)
@Transactional
public AlphaWorkspaceChangeSet undo(AlphaWorkspace ws, Long csId) {
AlphaWorkspaceChangeSet cs = ...;
if (!"PENDING".equals(cs.getStatus()))
throw new IllegalStateException("PENDING ์ํ์์๋ง ์คํ์ทจ์ ๊ฐ๋ฅ (ํ์ฌ: " + cs.getStatus() + ")");
String raw = cs.getBeforeJson();
boolean restored = false;
if (raw != null && !raw.isBlank()) {
try {
Map<String, Object> bs = om.readValue(raw, new TypeReference<LinkedHashMap<String, Object>>() {});
if (bs.containsKey("strategyConfig") || bs.containsKey("goalProfile") || bs.containsKey("codeJson")) {
ws.setStrategyConfigJson(...); ws.setGoalProfileJson(...);
if (bs.containsKey("codeJson")) ws.setCodeJson(...);
restored = true;
}
} catch (Exception ignore) { /* ๊ตฌํฌ๋งท์ผ๋ก ํด๋ฐฑ */ }
}
if (!restored) ws.setStrategyConfigJson(raw); // ๊ตฌํฌ๋งท: before ๊ฐ strategyConfig ๋จ๋
๋ฌธ์์ด
workspaceRepo.save(ws);
cs.setStatus("UNDONE");
helix.recordLog(ws.getId(), "USER", "USER_REVISION", "๋ณ๊ฒฝ ์คํ์ทจ์: " + cs.getTitle(), null);
return changeSetRepo.save(cs);
}
- undo: PENDING ์ผ ๋๋ง ๊ฐ๋ฅ(KEPT/UNDONE ์
IllegalStateExceptionโ ์ปจํธ๋กค๋ฌ๊ฐ 409 Conflict). before ์ค๋ ์ท์ ๊ทธ๋๋ก ๋ณต์. - ์ /๊ตฌ ํฌ๋งท ์๋ฆฝ(์ค์): ์ ๊ท ํฌ๋งท์
{strategyConfig, goalProfile, codeJson}๊ฐ์ฒด, ๊ตฌํฌ๋งท์ strategyConfig ๋ฌธ์์ด ๋จ๋ . ๊ฐ์ฒด ํ์ฑ์ด ์คํจํ๊ฑฐ๋ ํค๊ฐ ์์ผ๋ฉด ๊ตฌํฌ๋งท์ผ๋ก ํด๋ฐฑ(raw ๋ฅผ strategyConfig ๋ก). ๊ณผ๊ฑฐ ๋ฐ์ดํฐ ํธํ ๋ณด์ฅ.
โ
ข. ClaudeCodeAgentService โ ์ธ๋ถ ์ฝ๋ฉ ์ ๋ฌธ๊ฐ(๊ฒฉ๋ฆฌ์ค)
A. ์ค์ ๊ณผ ๊ธฐ๋ณธ OFF โ ClaudeCodeAgentService.java:46-61
// L46-L61
@Value("${app.claude.cli.enabled:false}") private boolean enabled; // ๊ธฐ๋ณธ OFF
@Value("${app.claude.cli.path:claude}") private String cliPath;
@Value("${app.claude.cli.timeout-sec:180}") private int timeoutSec;
@Value("${anthropic.api.key:}") private String apiKey;
private static final Set<String> CODE_EXT = Set.of(
"py", "js", "jsx", "ts", "tsx", "json", "txt", "md", "yaml", "yml", "csv", "ipynb", "java");
public boolean isEnabled() { return enabled; }
enabled๊ธฐ๋ณธ false: ์ด์ ์์ ์ ์ํด ๋ช ์์ ์ผ๋ก ์ผ์ผ๋ง(app.claude.cli.enabled=true) ๋์. ์ปจํธ๋กค๋ฌ๋!agent.isEnabled()๋ฉด 503 ๋ฐํ.CODE_EXT: ์์ด์ ํธ๊ฐ ์๋ก ๋ง๋ ํ์ผ ์ค ์ฝ๋ ํ์ฅ์๋ง ๋ณ๊ฒฝ์ผ๋ก ์ธ์ (์ฐ๋ ๊ธฐ ํ์ผ ๋ฌด์).
B. โญ ๊ฒฉ๋ฆฌ ๋๋ ํฐ๋ฆฌ materialize โ ClaudeCodeAgentService.java:188-203
// L188-L203
private Materialized materialize(AlphaWorkspace ws) throws IOException {
Map<String, Object> codeMap = readMap(ws.getCodeJson());
if (codeMap.isEmpty()) codeMap.put("main", "");
Path tmp = Files.createTempDirectory("alpha-claude-ws-" + ws.getId() + "-");
Map<String, String> fileToKey = new LinkedHashMap<>();
Map<String, String> original = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : codeMap.entrySet()) {
String key = e.getKey();
String content = e.getValue() == null ? "" : String.valueOf(e.getValue());
String filename = key.contains(".") ? key : key + ".py"; // ํ์ฅ์ ์์ผ๋ฉด .py
Files.writeString(tmp.resolve(filename), content, StandardCharsets.UTF_8);
fileToKey.put(filename, key);
original.put(filename, content);
}
return new Materialized(tmp, fileToKey, original);
}
- ๋ฌด์์ ํ๋: DB ์
codeJson({"main":"...", "risk_control":"..."})์ ์์ ๋๋ ํฐ๋ฆฌ์ ์ค์ ํ์ผ๋ค๋ก ํผ์นจ. ํ์ฅ์ ์๋ ํค๋.py๋ก ๊ฐ์ . - ์ ๊ฒฉ๋ฆฌ ๋๋ ํฐ๋ฆฌ: Claude CLI ๋ ์์ ๋๋ ํฐ๋ฆฌ(cwd)์ ํ์ผ์ ์ฝ๊ณ ์๋๋ค. ํ๋ก์ ํธ ๋ณธ์ฒด๊ฐ ์๋๋ผ ๋ฒ๋ ค๋ ๋๋ ์์ ํด๋์ ์ํฌ์คํ์ด์ค ์ฝ๋๋ง ๋ณต์ฌํด ์ค์ผ๋ก์จ ๋ค๋ฅธ ํ์ผ ์ ๊ทผ์ ์์ฒ ์ฐจ๋จ.
original๋งต ๋ณด๊ด: ๋์ค์ ํธ์ง ์ /ํ ๋น๊ต(diff) ์ ์ฌ์ฉ.
C. โญโญ CLI ์ปค๋งจ๋ โ ํ์ฉํด ํ์ดํธ๋ฆฌ์คํธ + Bash ์ฐจ๋จ โ ClaudeCodeAgentService.java:205-215
// L205-L215
private List<String> buildCommand(boolean streaming) {
List<String> cmd = new ArrayList<>(List.of(resolveCli(), "-p",
"--output-format", streaming ? "stream-json" : "json"));
if (streaming) cmd.add("--verbose");
cmd.addAll(List.of(
"--allowedTools", "Read,Edit,Write,Glob,Grep",
"--disallowedTools", "Bash,WebFetch,WebSearch", // ๋ณด์: ์์ ๋ช
๋ น/๋คํธ์ํฌ ์ฐจ๋จ
"--no-session-persistence",
"--max-budget-usd", "1"));
return cmd;
}
- ์ด ํ์ผ์์ ๊ฐ์ฅ ์ค์ํ ๋ณด์ ๋ผ์ธ:
--allowedTools Read,Edit,Write,Glob,Grepโ ํ์ผ ์ฝ๊ธฐ/ํธ์ง/์์ฑ/๊ฒ์๋ง ํ์ฉ.--disallowedTools Bash,WebFetch,WebSearchโ ์์ ์ ธ ๋ช ๋ น ์คํ๊ณผ ๋คํธ์ํฌ๋ฅผ ์ฐจ๋จ. ์ ๋ฌธ๊ฐ์ ์๋ฐ์ ๋ฌถ๊ณ ํ๋ง ์ฅ์ด์ฃผ๋ ๊ฒ.--no-session-persistenceโ ์ธ์ ์์ฌ ์ ๋จ๊น.--max-budget-usd 1โ ํ ํฐ ํญ์ฃผ ๋น์ฉ ์ํ.
-p(print/headless) +--output-format json|stream-jsonโ ๋ํํ์ด ์๋๋ผ ํค๋๋ฆฌ์ค๋ก ํ ๋ฒ ๋๋ฆฌ๊ณ ๊ฒฐ๊ณผ๋ฅผ JSON ์ผ๋ก.
โ ๏ธ ๊ฐ์ ํฌ์ธํธ: "AI ์๊ฒ ์ฝ๋ ํธ์ง์ ๋งก๊ธด๋ค"์ ์ง์ง ์ํ์ Bash ์ ๋คํธ์ํฌ์ ๋๋ค(
rm -rf, ๋ฐ์ดํฐ ์ ์ถ). allowedTools/disallowedTools ํ์ดํธ๋ฆฌ์คํธ๊ฐ ๊ทธ ์ํ์ ๋ดํฉํฉ๋๋ค. enabled ๊ธฐ๋ณธ OFF + ๊ฒฉ๋ฆฌ ๋๋ ํฐ๋ฆฌ + ์์ฐ ์ํ๊ณผ ํฉ์ณ 4์ค ๋ฐฉ์ด.
D. CLI ์คํ โ stdin ํ๋กฌํํธ + ํ์์์ โ ClaudeCodeAgentService.java:218-245
// L218-L245 (๋ฐ์ท)
private String[] runCli(Path cwd, String request, boolean streaming, Consumer<String> onLine) ... {
String prompt = "์ด ๋๋ ํฐ๋ฆฌ๋ ํํธ ํธ๋ ์ด๋ฉ ์ ๋ต ์ฝ๋์
๋๋ค. ์ฝ๋๋ฅผ ์ง์ ํธ์งํด ๋ค์ ์์ฒญ์ ์ํํ์ธ์.\n์์ฒญ: " + request;
ProcessBuilder pb = new ProcessBuilder(buildCommand(streaming));
pb.directory(cwd.toFile());
if (apiKey != null && !apiKey.isBlank()) pb.environment().put("ANTHROPIC_API_KEY", apiKey);
Process proc = pb.start();
...
Thread tOut = pump(proc.getInputStream(), out, onLine); // stdout ๋ผ์ธ๋ณ ์ฝ๋ฐฑ
Thread tErr = pump(proc.getErrorStream(), err, null);
tOut.start(); tErr.start();
try (OutputStream stdin = proc.getOutputStream()) { // ํ๋กฌํํธ๋ stdin ์ผ๋ก
stdin.write(prompt.getBytes(StandardCharsets.UTF_8)); stdin.flush();
}
boolean finished = proc.waitFor(timeoutSec, TimeUnit.SECONDS);
if (!finished) { proc.destroyForcibly(); throw new RuntimeException("Claude Code CLI ํ์์์ ..."); }
...
return new String[]{out.toString(), err.toString()};
}
- ํ๋กฌํํธ๋ฅผ stdin ์ผ๋ก ๋ณด๋ด๋ ์ด์ (ํด๋์ค ์ฃผ์ L36): *"ํ๋กฌํํธ๋ stdin(Windows .cmd ์ธ์ฉ ํํผ)"*. ์ปค๋งจ๋๋ผ์ธ ์ธ์๋ก ๋๊ธฐ๋ฉด Windows
.cmd์ ๋ฐ์ดํยทํน์๋ฌธ์ ์ด์ค์ผ์ดํ ์ง์ฅ์ ๋น ์ง๋๋ค. stdin ์ผ๋ก ์ฃผ๋ฉด ์์ . - stdout/stderr ๋ฅผ ๋ณ๋ ์ค๋ ๋๋ก pump(L311-L323): ์ถ๋ ฅ ๋ฒํผ๊ฐ ๊ฐ๋ ์ฐจ ํ๋ก์ธ์ค๊ฐ ๋ฉ์ถ๋ ๋ฐ๋๋ฝ ๋ฐฉ์ง ์ ์. stdout ์
onLine์ฝ๋ฐฑ์ผ๋ก ์คํธ๋ฆฌ๋ฐ. - ํ์์์ ์
destroyForcibly: ๋งค๋ฌ๋ฆฐ ํ๋ก์ธ์ค๋ฅผ ๊ฐ์ ์ข ๋ฃ. resolveCli()(L292-L300): Windows ์์claudeโclaude.cmd๋ก ์๋ ๋ณด์ .
E. โญ ํธ์ง ๊ฒฐ๊ณผ โ diff โ ํจ์น finishApply โ ClaudeCodeAgentService.java:248-283
// L248-L283 (๋ฐ์ท)
private AgentResult finishApply(AlphaWorkspace ws, String request, Materialized m, String narration, long t0) ... {
List<Map<String, Object>> ops = new ArrayList<>();
...
try (Stream<Path> walk = Files.list(m.tmp)) {
for (Path p : walk.filter(Files::isRegularFile).sorted().toList()) {
String fn = p.getFileName().toString();
if (fn.startsWith(".")) continue;
boolean wasOriginal = m.original.containsKey(fn);
String ext = ...;
if (!wasOriginal && !CODE_EXT.contains(ext)) continue; // ์ ํ์ผ์ ์ฝ๋ ํ์ฅ์๋ง
String content = Files.readString(p, StandardCharsets.UTF_8);
String orig = m.original.get(fn);
if (orig == null || !orig.equals(content)) { // ๋ณ๊ฒฝ๋ ๊ฒ๋ง
String key = m.fileToKey.getOrDefault(fn, ...);
Map<String,Object> op = new LinkedHashMap<>();
op.put("target", "code"); op.put("path", key); op.put("value", content);
ops.add(op);
changes.add(new FileChange(key, fn, orig == null ? "" : orig, content));
}
}
}
AlphaWorkspaceChangeSet cs = null;
if (!ops.isEmpty()) cs = patchService.apply(ws, "Claude Code: " + shortReq, ops);
...
return new AgentResult(cs, narration ..., changedFiles, changes, elapsed);
}
- ๋ฌด์์ ํ๋: Claude ๊ฐ ํธ์ง์ ๋ง์น ์์ ๋๋ ํฐ๋ฆฌ๋ฅผ ํ์ด ์๋ณธ๊ณผ ๋ค๋ฅธ ํ์ผ๋ง ๊ณจ๋ผ
target=codeํจ์น ops ๋ก ๋ง๋ค๊ณ ,AlphaPatchService.apply()๋ก ChangeSet(PENDING) ์์ฑ. - ๋ ์๋น์ค์ ์ฐ๊ฒฐ(์ค๊ณ ์ ์ ): Claude ์์ด์ ํธ๋ ์ง์ DB ๋ฅผ ์ ๊ฑด๋๋ฆฝ๋๋ค. ํธ์ง ๊ฒฐ๊ณผ๋ฅผ ํจ์น ops ๋ก ๋ณํํด AlphaPatchService ์ ์์ โ ๋ชจ๋ ๋ณ๊ฒฝ์ด ๋์ผํ PENDING/KEEP/UNDO ํ๋ฆ์ ํ๋๋ค. ์ฆ AI ์ฝ๋ ํธ์ง๋ ์ฌ๋ ํธ์ง๊ณผ ๋๊ฐ์ด ๋๋๋ฆด ์ ์์ต๋๋ค.
FileChange(path, filename, before, after)๋ ์ฝ๋ โ ํ๋ก ํธ Monaco ์๋ํฐ diff ๋ทฐ์ ์ฌ์ฉ.- title
"Claude Code: ...": ์ด ์ ๋์ฌ๋ก AlphaPatchController.keep ์์ *"Claude ๋ณ๊ฒฝ์ด๋ฉด GitHub ์๋ ์ปค๋ฐ"*(L72)์ ํ๋ณํฉ๋๋ค.
F. ์คํธ๋ฆฌ๋ฐ ์ก ์คํ ์ด โ ClaudeCodeAgentService.java:100-169, 367-406
// L100-L107
public String startAgent(AlphaWorkspace ws, String request) {
guard(request);
ClaudeJob job = createJob();
Thread t = new Thread(() -> runStreamingJob(job, ws, request), "claude-agent-" + job.id);
t.setDaemon(true); t.start();
return job.id; // ์ฆ์ jobId ๋ฐํ โ ํ๋ก ํธ๋ /status?since=N ํด๋ง
}
- ๋ฌด์์ ํ๋: ๊ธด ์์
(์์ญ ์ด~์ ๋ถ)์ ๋ฐฑ๊ทธ๋ผ์ด๋ ๋ฐ๋ชฌ ์ค๋ ๋๋ก ๋๋ฆฌ๊ณ jobId ๋ง ์ฆ์ ๋ฐํ. ํ๋ก ํธ๋
/claude-agent/status/{jobId}?since=N์ผ๋ก ์ฆ๋ถ ๋ก๊ทธ๋ฅผ ํด๋ง. streamLineToJob(L145-L169): claude ์stream-jsonํ ์ค(์ด๋ฒคํธ)์ ์ฌ๋์ด ์ฝ๋ ์งํ ๋ก๊ทธ๋ก ๋ณํ.assistant์ด๋ฒคํธ์tool_use(๐ ์ฝ๊ธฐ/โ๏ธ ํธ์ง/๐ ์์ฑ/๐ ๊ฒ์)์text(๐ฌ),thinking(๐ญ)๋ง ๋ ธ์ถํ๊ณresult์ด๋ฒคํธ์ ์ต์ข narration ์ ์ก์.toolUseMessage(L171-L182): Readโ"๐ ํ์ผ๋ช ์ฝ๊ธฐ" ์ ์ด๋ชจ์ง ๋งคํ. ์ฌ์ฉ์๊ฐ ์์ด์ ํธ์ ํ๋์ ์ค์๊ฐ์ผ๋ก ๋ณด๊ฒ.ClaudeJob(L367-L406):volatile์ํ(running/done/error) + ๋๊ธฐํ๋ ๋ก๊ทธ ๋ฆฌ์คํธ(์ต๋ 1000) +snapshot(since)๋ก ์ปค์ ์ดํ๋ง ์๋ผ ๋ฐํ. ์ก์ ์ต๋ 64๊ฐ ๋ณด๊ด(createJob์ด ๋๋ ์ก๋ถํฐ ์ ๋ฆฌ).- ์์ ๋๋ ํฐ๋ฆฌ๋ ํญ์
finally์์ ์ญ์ (deleteRecursive, L334-L340) โ ์์ ๋์ ๋ฐฉ์ง.
โ ฃ. ์ปจํธ๋กค๋ฌ 3์ข (์ ๊ตฌ)
์ธ ์ปจํธ๋กค๋ฌ๋ ๋ชจ๋ ๊ฐ์ ํจํด์ ๋ฐ๋ฆ ๋๋ค โ โ JWT ๋ก uid ์ถ์ถ โ โก ์ํฌ์คํ์ด์ค ์์ ํ์ธ โ โข ์๋น์ค ์์ โ โฃ ๊ฒฐ๊ณผ/์๋ฌ ๋งคํ.
A. AlphaWorkspaceController โ CRUD + ์ฑํ
โ controller/AlphaWorkspaceController.java
// L42-L49 (๋ชฉ๋ก)
@GetMapping("/workspaces")
public ResponseEntity<?> list() {
Long uid = AuthContext.currentUserId();
if (uid == null) return unauth();
return ResponseEntity.ok(svc.getWorkspaceRepo()
.findByUserIdOrderByUpdatedAtDesc(uid)
.stream().map(svc::toSummary).toList());
}
AuthContext.currentUserId()(๋ณด์ ํต์ฌ): ์ ์์ ์ค์ง JWT ์์ ์ต๋๋ค. ํด๋ผ์ด์ธํธ๊ฐ ๋ณด๋ธ userId ๋ฅผ ๋ฏฟ์ง ์์ต๋๋ค(IDOR ๋ฐฉ์ง). ๋ชจ๋ ํธ๋ค๋ฌ ์ฒซ ์ค์ด ์ด ํจํด.- ์์ ๊ถ ๊ฒ์ฆ
findByIdAndUserId(id, uid): ์ํฌ์คํ์ด์ค ์กฐํ๋ ํญ์ "id ๊ทธ๋ฆฌ๊ณ ๋ด uid" ๋ก. ๋จ์ ์ํฌ์คํ์ด์ค id ๋ฅผ ๋ฃ์ด๋ ๋ชป ๋ด ๋๋ค. - ์ฑํ
(L221-L232):
processChat์์. ๋น ํ ์คํธ๋ 400. ๋น ํ์คํ ๋ฆฌ๋ฉด ์น์ ํ ์จ๋ณด๋ฉ ์ธ์ฌ๋ฅผ ๋จผ์ ์ ์ฅ(L199-L217). - ์ฝ๋ ์ ์ฅ(L258-L273):
PATCH /code๋ก codeJson ํต์งธ ์ ์ฅ(DeveloperLab ์ง์ ํธ์ง).
B. AlphaAnalyticsController โ ํ์ดํ๋ผ์ธ โ controller/AlphaAnalyticsController.java
// L34-L62 (backtest ๋ฐ์ท)
@PostMapping("/workspaces/{id}/backtest")
public ResponseEntity<?> backtest(@PathVariable Long id,
@RequestParam(value="period", required=false) String period,
@RequestBody(required=false) Map<String,Object> body) {
...
if (ws.getStrategyConfigJson() == null)
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(Map.of("error", "๋จผ์ /formalize๋ก ์ ๋ต์ ์ ํํํ์ธ์"));
try {
... String json = svc.doBacktest(ws, periodFinal, customParams);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(json);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(Map.of("error", ...));
}
}
- ์ ์ ์กฐ๊ฑด ๊ฒ์ดํธ: ์ ๋ต์ด ์์ผ๋ฉด 422(
๋จผ์ /formalize). auto-run ์ goalProfile ์์ผ๋ฉด 422(๋จผ์ ์ฑํ ์ผ๋ก ๋ชฉํ ์ ์). - ์๋ฌ โ HTTP ์ํ ๋งคํ(์ค์): Analytics ์์ง ํธ์ถ ์คํจ๋ 502 Bad Gateway(์ฐ๋ฆฌ ์๋ชป์ด ์๋๋ผ ํ์ ์๋น์ค ๋ฌธ์ ), ์ ์ ์กฐ๊ฑด ์๋ฐ์ 422, ์๋ฒ ๋ด๋ถ ์ค๋ฅ๋ 500. ์๋ฏธ ์๋ ์ํ์ฝ๋๋ก ํ๋ก ํธ๊ฐ ๋ถ๊ธฐ.
- raw JSON ๊ทธ๋๋ก ๋ฐํ:
doBacktest๊ฐ Python ์์ง JSON ๋ฌธ์์ด์ ๊ทธ๋๋ก ๋๋ ค์ฃผ๊ณ , ์ปจํธ๋กค๋ฌ๋contentType(APPLICATION_JSON)์ผ๋ก ํต๊ณผ์ํด(๋ค์ ํ์ฑ ์ ํจ โ ์ค๋ฒํค๋ยท์๊ณก ๋ฐฉ์ง). (๋ฉ๋ชจ: ์ด๋ Boot4 Jackson3 JsonNode ๋น ๋ฒ๊ทธ๋ฅผ ํผํ๋ ํจํด๊ณผ๋ ํตํฉ๋๋ค.)
C. AlphaPatchController โ ํจ์น โ controller/AlphaPatchController.java
// L61-L79 (keep + GitHub ์ปค๋ฐ)
@PostMapping("/workspaces/{id}/changesets/{csId}/keep")
public ResponseEntity<?> keep(@PathVariable Long id, @PathVariable Long csId) {
...
AlphaWorkspaceChangeSet cs = patch.keep(ws, csId);
Map<String, Object> dto = toDto(cs, ws);
if (cs.getTitle() != null && cs.getTitle().startsWith("Claude Code:") && ws.getGithubRepoFullName() != null) {
dto.put("gitCommit", gitSync.commitChangeSet(ws, cs, uid)); // A3: Co-Authored-By Claude
}
return ResponseEntity.ok(dto);
}
- apply(L37-L59): ops ๊ฒ์ฆ ์คํจ๋
IllegalArgumentExceptionโ400, ๊ทธ ์ธ๋ 500. - keep ์ ์กฐ๊ฑด๋ถ GitHub ์ปค๋ฐ: ChangeSet title ์ด
"Claude Code:"๋ก ์์ํ๊ณ ์ํฌ์คํ์ด์ค์ repo ๊ฐ ์ฐ๋๋ผ ์์ผ๋ฉดClaudeGitSyncService.commitChangeSet์ผ๋ก ์๋ ์ปค๋ฐ. AI ํธ์ง์ ์ ์ง ํ์ ํ๋ฉด ์ค์ Git ํ์คํ ๋ฆฌ์ ๋จ๊น๋๋ค. - undo(L81-95):
IllegalStateException(PENDING ์๋)โ409 Conflict.
โ ค. ์ํฐํฐ 4์ข (์๋ฅ์ฒ )
| ์ํฐํฐ | ํ ์ด๋ธ | ํต์ฌ ์ปฌ๋ผ | ๋ฉ๋ชจ |
|---|---|---|---|
AlphaWorkspace |
alpha_workspace |
goalProfileJson(TEXT) ยท strategyConfigJson(LONGTEXT) ยท lastBacktestJson/lastRegimeJson/lastReportJson(LONGTEXT) ยท lastTrustJson(TEXT) ยท codeJson(LONGTEXT) ยท status ยท brokerAccountId ยท githubRepoFullName/githubBranch |
ํ ํ์ด ์๋ ํ ๋ช ์ ์ ๋ต ์ ์ฒด. JSON ์ ๋ฌธ์์ด ์ปฌ๋ผ์ ํต์งธ๋ก(์คํค๋ง๋ฆฌ์ค ์ ์ฐ์ฑ) |
AlphaChatMessage |
alpha_chat_message |
workspaceId ยท role(user/model) ยท text(TEXT) |
(workspace_id, created_at) ์ธ๋ฑ์ค๋ก ์๊ฐ์ ์กฐํ ๋น ๋ฅด๊ฒ |
AlphaDecisionLog |
alpha_decision_log |
actor(USER/AI/SYSTEM) ยท eventType(GOAL_DEFINED/STRATEGY_PROPOSED/PARAM_CHANGED/BACKTEST_RUN/...) ยท summary ยท payloadJson |
์
๋ฌด์ผ์ง. recordLog ๊ฐ ์ฑ์ |
AlphaWorkspaceChangeSet |
alpha_workspace_changeset |
opsJson ยท beforeJson ยท afterJson ยท status(PENDING/KEPT/UNDONE) |
๋๋๋ฆฌ๊ธฐ ์์์ฆ. before/after ์ ์ฒด ์ค๋ ์ท |
// AlphaWorkspace.java:38-63 (JSON ์ปฌ๋ผ๋ค)
@Lob @Column(columnDefinition = "TEXT") private String goalProfileJson;
@Lob @Column(columnDefinition = "LONGTEXT") private String strategyConfigJson;
@Lob @Column(columnDefinition = "LONGTEXT") private String lastBacktestJson;
@Lob @Column(columnDefinition = "TEXT") private String lastTrustJson;
@Lob @Column(name="last_regime_json", columnDefinition="LONGTEXT") private String lastRegimeJson;
@Lob @Column(name="last_report_json", columnDefinition="LONGTEXT") private String lastReportJson;
@Lob @Column(name="code_json", columnDefinition="LONGTEXT") private String codeJson;
- ์ JSON ๋ฌธ์์ด๋ก(์ค๊ณ ์ฒ ํ): ๋ชฉํ/์ ๋ต/๋ฐฑํ ์คํธ ๊ฒฐ๊ณผ๋ ๊ตฌ์กฐ๊ฐ ์์ฃผ ๋ฐ๋๊ณ ์ค์ฒฉ์ด ๊น์ต๋๋ค. ๋งค๋ฒ ์ปฌ๋ผยทํ ์ด๋ธ์ ์ชผ๊ฐ๋ฉด ๋ง์ด๊ทธ๋ ์ด์ ์ง์ฅ. JSON ์ ํต์งธ ๋ฌธ์์ด๋ก ๋๋ฉด ์คํค๋ง ๋ณ๊ฒฝ ์์ด ์งํ ๊ฐ๋ฅ(Document-in-RDB ํจํด). ๋์ DB ๋ ๋ฒจ ์ฟผ๋ฆฌ(WHERE goal=...)๋ ๋ชป ํฉ๋๋ค โ ์ด ๋๋ฉ์ธ์ ๊ทธ๊ฒ ํ์ ์์ต๋๋ค(ํญ์ ์ํฌ์คํ์ด์ค ๋จ์๋ก ํต์งธ ๋ก๋).
- TEXT vs LONGTEXT: ๋ฐฑํ ์คํธ ๊ฒฐ๊ณผยท์ ๋ตยท์ฝ๋์ฒ๋ผ ํด ์ ์๋ ๊ฑด LONGTEXT(์ต๋ 4GB), ์งง์ ๊ฑด TEXT(64KB).
โ ๏ธ ํจ์ ยท๋ณด์ ์ฃผ์ (์ฝ๋์ ๋ฐํ ๊ตํ ๋ชจ์)
- Claude CLI = Bash/๋คํธ์ํฌ ์ฐจ๋จ(์ต์ฐ์ ) โ
--allowedTools Read,Edit,Write,Glob,Grep+--disallowedTools Bash,WebFetch,WebSearch. ์ฌ๊ธฐ์ ๊ธฐ๋ณธ OFF(enabled=false) + ์์ ๊ฒฉ๋ฆฌ ๋๋ ํฐ๋ฆฌ +--max-budget-usd 1+ ํ์์์ 4์ค ๋ฐฉ์ด. ํ๋๋ผ๋ ํ๋ฉด "AI ์๊ฒ ์ ธ์ ์ฅ์ด์ฃผ๋" ์ํ. - ํจ์น JSON ํ์ฑ ๋ณต๊ตฌ โ LLM ์๋ต์ ์ค๋ช
+JSON ์ด ์์
๋๋ค.
extractJsonBlock/extractFirstJsonArray์ brace-matching + ๋ฌธ์์ด/์ด์ค์ผ์ดํ ์ถ์ ์ผ๋ก JSON ๋ง ๋๋ ค๋ ๋๋ค. ํต์งธreadTree๋ ๊นจ์ง. ์ ํํ๋ ๋ฐฐ์ด ์คํจ ์ ๋จ์ผ ๊ฐ์ฒด๋ก, ๊ทธ๋๋ ์ ๋๋ฉด ๋ช ์์ ์์ธ. - ์ํฌ์คํ์ด์ค ์ธ์ง ์ปจํ
์คํธ = ์ต๊ทผ ์์ ์ฌํญ โ
buildWorkspaceStateContext๊ฐ ํ์ฌ ๋ชฉํ/์ ๋ต/๋ฐฑํ ์คํธ ์์น๋ฅผ ์ปจํ ์คํธ ์์ ์ฃผ์ ํด์ผ AI ๊ฐ *"์ด ์ ๋ต ์น๋ฅ ์ฌ๋ ค์ค"* ์ ๋ตํฉ๋๋ค. stats ๋ง(equity_curve ์ ์ธ) ๋ฃ์ด ํ ํฐ ํญ๋ฐ ๋ฐฉ์ง. ๋น ์ํฌ์คํ์ด์ค๋ ๋น ๋ฌธ์์ด(์จ๋ณด๋ฉ ๋ณดํธ). - self-injection ํธ๋์ญ์
โ auto-run ์ ๋ฐ๋์
self.doXxx(ํ๋ก์ ๊ฒฝ์ )๋ก ํธ์ถํด์ผ@Transactional์ด ๋จ๊ณ๋ณ๋ก ์๋.this.๋ฉด ํธ๋์ญ์ ์ด ์ ๊ฑธ๋ฆผ. ๊ฐ ๋จ๊ณ ํworkspaceRepo.findById์ฌ๋ก๋ฉ. - before/after ์ธ JSON ์ ๋ถ ์ค๋
์ท โ strategyConfig ๋ง ์ ์ฅํ๋ฉด goalProfileยทcode ํจ์น undo ๋ถ๊ฐ.
{strategyConfig, goalProfile, codeJson}ํต์งธ ๋ณด์กด + ๊ตฌํฌ๋งท ํด๋ฐฑ. - undo ๋ PENDING ์์๋ง โ KEPT/UNDONE ์ 409. keep ์ ๋ฉฑ๋ฑ(์ด๋ฏธ ์ฒ๋ฆฌ๋์ผ๋ฉด ๋ฌด๋์).
- LIVE ์ํ ๊ฐ๋ฑ ๊ธ์ง โ backtest/formalize ๊ฐ
if (!"LIVE"...)๊ฐ๋๋ก ์ด์ฉ ์ค ์ํฌ์คํ์ด์ค์ status ๋ฅผ ๋ฎ์ง ์์. - ์ ์์ JWT ์์๋ง + ์์ ๊ถ ๊ฒ์ฆ โ
AuthContext.currentUserId()+findByIdAndUserId. ํด๋ผ์ด์ธํธ userId ๋ถ์ (IDOR ๋ฐฉ์ง). - ์๋์ฃผ๋ฌธ์ MOCK ์ฐ์ โ
doQueueOrders๋ infinite_buying ์ ์ฉ + BrokerAccount ์์ผ๋ฉด MOCK KIS ๊ณ์ ์๋ ์ฌ์ฉ(์์ผ๋ฉด ์๋ฌ). OrderProposal ์PENDING+ 24h ๋ง๋ฃ. ์ค์ฃผ๋ฌธ์ ๋ณ๋ ์น์ธ ๋จ๊ณ(05๋ณด์ ์ค๊ณ์ MOCKโREAL ๊ฒ์ดํธ). - ์๋ฌ โ ์ํ์ฝ๋ ์๋ฏธ ๋ถ์ฌ โ Analytics ์คํจ 502, ์ ์ ์กฐ๊ฑด 422, ์ถฉ๋ 409, ์ธ์ฆ 401. ํ๋ก ํธ๊ฐ ์ ํํ ๋ถ๊ธฐ.
๐ ๊ณ ๋ํ ์์ด๋์ด (๊ฐ์ยท๊ฐ์ ๋ฒ์ ์ฉ)
- ๋ชฉํ ๊ฒ์ดํธ ์ ํฉ์ฑ:
hasAllGoalKeys๊ฐinitial_capital_krw๋ฅผ ๊ฒ์ฌ ์ ํจ(ํ๋กฌํํธ๋ 8๊ฐ ์๊ตฌ). ๊ฒ์ดํธ๋ฅผ ํ๋กฌํํธ์ ์ผ์น์ํค๊ฑฐ๋, ํ๋กฌํํธ์ "8๊ฐ" ํํ์ ๊ฒ์ดํธ์ ๋ง์ถ๊ธฐ โ ์๊ตฌ์ฌํญ ๋จ์ผ ์ง์ค ๊ฐ์ ์์ ๋ก ์ต์ . - JSON ์ถ์ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌํ:
extractJsonBlock/extractFirstJson/extractFirstJsonArray3๊ฐ์ ์ค๋ณตยท๋ฏธ๋ฌํ ์ฐจ์ด(๋ฌธ์์ด ์ถ์ ์ ๋ฌด)๋ฅผ ํ ์ ํธ๋ก ํตํฉ + ๋จ์ ํ ์คํธ(์ค์ฒฉ/์ด์ค์ผ์ดํ/๋ฐฐ์ด/์๋ฆฐ ์๋ต). - ์คํธ๋ฆฌ๋ฐ์ SSE/WebSocket ์ผ๋ก: ์ง๊ธ์
/status?since=Nํด๋ง. Server-Sent Events ๋ก ๋ฐ๊พธ๋ฉด ์ง์ฐยท์์ฒญ์ ๊ฐ์. - ChangeSet ์ถฉ๋ ๊ด๋ฆฌ: ๋์์ ๋ PENDING ํจ์น๊ฐ ๊ฐ์ path ๋ฅผ ๊ฑด๋๋ฆฌ๋ฉด? ๋๊ด์ ๋ฝ(version) ๋๋ path ๋จ์ ์ ๊ธ ๋์ .
- ํจ์น ๋ฏธ๋ฆฌ๋ณด๊ธฐ(dry-run): apply ์ ์ after ์ค๋ ์ท๋ง ๊ณ์ฐํด ํ๋ก ํธ์ ๋ณด์ฌ์ฃผ๊ณ ์ฌ์ฉ์๊ฐ ํ์ ์ ์ ์ฅ โ ์ง์ง "๋ณ๊ฒฝ ์ถ์ " UX.
- Claude ์์ด์ ํธ ๋์์ฑ ์ํ: ์ฌ์ฉ์/์๋ฒ๋น ๋์ ์ก ์ ์ ํ(ํ์ฌ MAX_JOBS=64 ๋ ๋ณด๊ด ํ๋์ผ ๋ฟ ๋์ ์คํ ์ ํ ์๋) + ํ์.
- ๋ฌดํ๋งค์ ์ธ ์ ๋ต ์๋์ฃผ๋ฌธ:
doQueueOrders๋ฅผ sma_cross/macd ๋ฑ latest_signal ๊ธฐ๋ฐ์ผ๋ก ํ์ฅ(์์ง์latest_signalํ์ฉ). - ๋ธ๋ฆฌํ ์ถ์ฒ ์๋ ๊ฒ์ฆ:
buildRegimeReferences์ URL ํฌ์ค์ฒดํฌ ์บ์(๊นจ์ง ๋งํฌ ์๋ ์ ์ธ). - goalProfile ์คํค๋ง ๊ฒ์ฆ: JSON Schema ๋ก ํ์ ยท๋ฒ์(horizon_years>0, MDD 0~100)๋ฅผ ๊ฐ์ ํด LLM ํ๊ฐ ์์น ์ฐจ๋จ.
๐ ์ฉ์ด ์ฌ์ (์ด ํํธ ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
| ์ํฌ์คํ์ด์ค(AlphaWorkspace) | ์ ๋ต 1๊ฐ = ์๋ฅ์ฒ 1๊ถ. ์ฑํ ยท๋ก๊ทธยท์ ๋ตยท๊ฒฐ๊ณผ๊ฐ ์ข ์ |
| status ๋จธ์ | DRAFTโGOAL_SETโFORMALIZEDโTESTEDโLIVE 5๋จ๊ณ ๋ผ๋ฒจ |
| goalProfile | ์ฑํ ์ผ๋ก ๋ชจ์ 8๊ฐ์ง ๋ชฉํ JSON |
| strategyConfig envelope | {candidates:[...3๊ฐ], selectedId} ํ๋ณด ๋ดํฌ |
| getActiveStrategy | ๋ดํฌ์์ selectedId ์นด๋ 1์ฅ ๊บผ๋ด๊ธฐ |
| formalize(์ ํํ) | ๋ชฉํ โ deterministic ๋ฐฑํ ์คํธ ๊ฐ๋ฅํ ์ ๋ต ํ๋ณด 3๊ฐ |
| heli-patch / ops | {target,path,value} ๋ถ๋ถ ํจ์น ๋ช
๋ น |
| ChangeSet | ํจ์น ํ ๋ฌถ์ + before/after ์ค๋ ์ท. PENDING/KEPT/UNDONE |
| PENDING/KEPT/UNDONE | ์ ์ฉ๋จ(๋ฏธํ์ )/์ ์งํ์ /๋กค๋ฐฑ์๋ฃ |
| setPath | ์ ๊ฒฝ๋ก(a.b.c)๋ฅผ ๋ฐ๋ผ ์ค์ฒฉ Map ์ ๊ฐ ์ฃผ์
|
| callAi | ๋ชจ๋ LLM ํธ์ถ์ ๋จ์ผ ํต๋ก(์ธ์ฆ=Gateway ์ฟผํฐ, ๋ฏธ์ธ์ฆ=Gemini ํด๋ฐฑ) |
| buildWorkspaceStateContext | ํ์ฌ ์ํ ์์ฝ(stats๋ง)์ ์ปจํ ์คํธ์ ์ฃผ์ โ ๋ถ์๊ฐ ๋ชจ๋ |
| extractJsonBlock | LLM ๋ต๋ณ์์ brace-matching ์ผ๋ก JSON ๋๋ ค๋ด๊ธฐ |
| self-injection | @Lazy self ๋ก ์๊ธฐ ํธ๋์ญ์
๋ฉ์๋๋ฅผ ํ๋ก์ ๊ฒฝ์ ํธ์ถ |
| doAutoRun | formalizeโbacktestโregimeโtrustโ(ib)queue ์ํด๋ฆญ ํ์ดํ๋ผ์ธ |
| materialize | codeJson โ ์์ ๊ฒฉ๋ฆฌ ๋๋ ํฐ๋ฆฌ์ ์ค์ ํ์ผ๋ค |
| allowedTools/disallowedTools | Claude CLI ๋๊ตฌ ํ์ดํธ/๋ธ๋๋ฆฌ์คํธ(Read/Edit/Write/Glob/Grep ํ์ฉ, Bash/Web ์ฐจ๋จ) |
| stream-json | claude -p ์ ๋ผ์ธ๋ณ ์ด๋ฒคํธ ์ถ๋ ฅ ํฌ๋งท(์งํ ๋ก๊ทธ์ฉ) |
| ์ก ์คํ ์ด(ClaudeJob) | ๋น๋๊ธฐ ์์ด์ ํธ ์งํ/๋ก๊ทธ/๊ฒฐ๊ณผ ๋ณด๊ด์. /status?since=N ํด๋ง |
| FileChange | ํธ์ง ์ /ํ(before/after) โ ํ๋ก ํธ Monaco diff |
| AuthContext.currentUserId() | JWT ์์๋ง ์ ์ ์ทจ๋(IDOR ๋ฐฉ์ง) |
| findByIdAndUserId | ์์ ๊ถ๊น์ง ๊ฒ์ฆํ๋ ์กฐํ |
| recordLog / AlphaDecisionLog | ๋ชจ๋ ์์ฌ๊ฒฐ์ ์๊ฐ์ ๊ธฐ๋ก(์ ๋ฌด์ผ์ง) |
| OrderProposal(PENDING) | ์๋ ํ์๋ ์ฃผ๋ฌธ ์ ์(24h ๋ง๋ฃ, ๋ณ๋ ์น์ธ ํ์) |
domain/ai โ ๋ฉํฐ LLM ยท ๊ฒ์ดํธ์จ์ด ยท ์ฟผํฐ (์์ ๋ผ์ธ๋ณ ํด์ค)
์๋ณธ ํด๋:
backend/src/main/java/com/DevBridge/devbridge/domain/ai/
์ ์ : Spring ๊ธฐ์ด๋ [08_backend/00_spring_primer.md] ๋ฅผ ๋จผ์ ์ฝ์๋ค๊ณ ๊ฐ์ ํฉ๋๋ค(@Serviceยท@RestControllerยทDIยท@Value๋ฑ์ ์ฌ๊ธฐ์ ๋ค์ ํ์ด ์ค๋ช ํ์ง ์์ต๋๋ค).
์ด ๋ฌธ์๋ ๊ต์ฌ ํ์ค ํ์([01_backtest/vbt_engine.md])์ ๋ฐ๋ฆ ๋๋ค.
๐ ์ด ํํธ ํ๋์
์ด ํํธ๋ "์ฌ๋ฌ AI ๋ชจ๋ธ(GeminiยทClaudeยทGPTยทPerplexity)์ ํ๋์ ์ฐฝ๊ตฌ๋ก ๋ฌถ์ด ์ฐ๊ณ , ๋๊ฐ ์ผ๋ง๋ ์ผ๋์ง ๊ด๋ฆฌํ๋ ์ฝ์ผํฐ" ์ ๋๋ค.
๋น์ ๋ก ํ๋ฉด ์ด๋ ์ต๋๋ค. ํ์ฌ์ ์ธ๊ตญ์ด ์๋ด์ฌ๊ฐ 4๋ช ์๋ค๊ณ ํฉ์๋ค โ ์์ด ๋ด๋น(GPT), ํ๊ตญ์ด ๋ด๋น(Gemini), ๋ฒ๋ฅ ์ ๋ฌธ(Claude), ์ต์ ๋ด์ค ๊ฒ์ ๋ด๋น(Perplexity). ๊ณ ๊ฐ(ํ๋ก ํธ์๋)์ด ์ง๋ฌธ์ ๋์ง๋ฉด:
- ์ ์ ๋ฐ์คํฌ(Controller) ๊ฐ ์ ํ๋ฅผ ๋ฐ๊ณ
- ๊ด๋ฆฌ์ค(AiGatewayService) ์ด "์ด ๊ณ ๊ฐ์ด ์ด๋ฒ ๋ฌ ์๋ด ์๊ฐ์ ๋ค ์ผ๋?"(์ฟผํฐ)๋ฅผ ํ์ธํ ๋ค
- ์ ํฉํ ์๋ด์ฌ(Provider) ์๊ฒ ์ฐ๊ฒฐํ๊ณ
- ํตํ๊ฐ ๋๋๋ฉด ํตํ ๊ธฐ๋ก๋ถ(AiUsageLog) ์ ๋ช ๋ถ ์ผ๋์ง ์ ์ต๋๋ค.
โ ๏ธ ๊ฐ์ฅ ๋จผ์ ์์์ผ ํ ํต์ฌ: ์ด ๋๋ฉ์ธ์๋ AI ํ๋ก๋ฐ์ด๋ ๋ฌถ์์ด ๋ ๋ฒ(๋ ๊ฐ์ ํํ ์ธ๊ณ) ์กด์ฌํฉ๋๋ค. ์ฒ์ ๋ณด๋ฉด ๋ฐ๋์ ํท๊ฐ๋ฆฌ๋ ํ๋ก ๋ชป๋ฐ๊ณ ์์ํฉ๋๋ค.
| ๊ตฌ๋ถ | service/gateway/ ๋ฌถ์ |
service/llm/ ๋ฌถ์ |
|---|---|---|
| ๊ณตํต ์ธํฐํ์ด์ค | AiProvider |
LlmProvider |
| ์กฐ์จ์ | AiGatewayService (์ฟผํฐยท๋ก๊ทธ) |
LlmRouter (ํด๋ฐฑ ์ฒด์ธ) |
| ์ง์ ์ปจํธ๋กค๋ฌ | AiController (/api/ai/**) |
LlmController (/api/llm/**) |
| ์ฟผํฐ/์ฌ์ฉ๋ก๊ทธ | ์์ (AiUsageLogยทAiModelCatalog) |
์์ |
| ํด๋ฐฑ(ํค ์์ผ๋ฉด ๋ค๋ฅธ ๋ชจ๋ธ๋ก) | ์์ (๋ชป ์ฐ๋ฉด ๊ฑฐ๋ถ) | ์์ (defaultProvider()) |
| ํ ํฐ ์ฌ์ฉ๋ ์ถ์ | ์์ (Result.tokensIn/Out) |
์์ |
| ์ฉ๋ | ๋ก๊ทธ์ธ ์ฌ์ฉ์ ๋ฉ์ธ ์ฑํ + ํ๋ ์ฒญ๊ตฌ ๊ทผ๊ฑฐ | ์ฐ์ธก ๋ํฌ "Quick Ask" ์์ ์ฑํ |
๋ ๋ฌถ์์ ๊ฐ์ 4๊ฐ ์ธ๋ถ API(GeminiยทAnthropicยทOpenAIยทPerplexity)๋ฅผ ํธ์ถํ์ง๋ง, ๋ชฉ์ ์ด ๋ฌ๋ผ ์ฝ๋๊ฐ ๋ถ๋ฆฌ๋ผ ์์ต๋๋ค. ์ด ๋ฌธ์๋ ์ฃผ๋ก ์ฟผํฐยท๋ก๊ทธ๊ฐ ์๋ gateway ๋ฌถ์์ ๊น๊ฒ ํ๊ณ , llm ๋ฌถ์์ ํด๋ฐฑ ์ฒด์ธ์ ๊ทธ ๋ค์์ผ๋ก ๋ค๋ฃน๋๋ค.
ํต์ฌ ํด๋์ค ์ญํ ํ
| ํด๋์ค | ์์น | ํ ์ค ์ญํ | ๋น์ |
|---|---|---|---|
AiGatewayService |
service/gateway/ |
์ฟผํฐ ํ์ธ โ ํ๋ก๋ฐ์ด๋ ๋ผ์ฐํ โ ์ฌ์ฉ๋ ๋ก๊น | ์ฝ์ผํฐ ๊ด๋ฆฌ์ค(์๊ฐ ์ฒดํฌยท์ฐ๊ฒฐยท๊ธฐ๋ก) |
AiProvider (interface) |
service/gateway/ |
4๊ฐ ํ๋ก๋ฐ์ด๋์ ๊ณตํต ๊ท๊ฒฉ (chat/oneShot/isAvailable) |
์๋ด์ฌ ์ง๋ฌด ํ์ค์ |
GeminiProvider |
service/gateway/ |
๊ธฐ์กด GeminiService๋ฅผ AiProvider๋ก ๊ฐ์ธ๋ ์ด๋ํฐ |
๋ฒ ํ ๋ ์ง์์ ํ์ค ์์์ ๋ง์ถฐ ๋ฑ๋ก |
AnthropicProvider/OpenAiProvider/PerplexityProvider |
service/gateway/ |
๊ฐ ์ธ๋ถ API ์ง์ HTTP ํธ์ถ + ํ ํฐ ์ฌ์ฉ๋ ํ์ฑ | ๊ฐ ์ธ์ด ์๋ด์ฌ |
GeminiService |
service/ |
Gemini REST API ์ค์ ํธ์ถ(429 ํด๋ฐฑยท์ฌ์๋ ํฌํจ) | ๊ฐ์ฅ ๋ฐ์ ํ๊ตญ์ด ์๋ด์ฌ ๋ณธ์ฒด |
AiModelCatalog (entity) |
entity/ |
์ธ ์ ์๋ ๋ชจ๋ธ ๋ชฉ๋ก + Free/Pro ์ ํ๋ | ์๋ด ๋ฉ๋ดํ(์๊ธํ) |
AiUsageLog (entity) |
entity/ |
ํธ์ถ 1๊ฑด๋น ํ ํฐยท์ฑ๊ณต์ฌ๋ถ ๊ธฐ๋ก | ํตํ ๊ธฐ๋ก๋ถ |
LlmRouter |
service/llm/ |
providerId๋ก ๋ผ์ฐํ + ํค ์์ผ๋ฉด ํด๋ฐฑ | ์์ ์๋ด์ฉ ๊ตํ์ |
AiRateLimitFilter |
global/config/ |
์๊ฐ๋น ํ์ ์ ํ(20ํ/์๊ฐ) | ์ฝ์ผํฐ ์ ๊ตฌ ํ์ ๋ฌธ(๊ณผ๋ฐ ์ฐจ๋จ) |
๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
1) LLM ํ๋ก๋ฐ์ด๋(provider)๋?
- LLM(Large Language Model, ๋ํ ์ธ์ด ๋ชจ๋ธ) = "๊ธ์ ์ดํดํ๊ณ ๊ธ์ ์์ฑํ๋ AI". ChatGPT๊ฐ ๋ํ์ .
- ํ๋ก๋ฐ์ด๋ = ๊ทธ LLM์ ์ธํฐ๋ท ๋๋จธ์์ ๋น๋ ค์ฃผ๋ ํ์ฌ. ์ฐ๋ฆฌ๋ 4๊ณณ์ ์๋๋ค:
- Gemini(Google) โ ๊ธฐ๋ณธยท๋ฒ์ฉยท๋น ๋ฆ
- Anthropic Claude โ ๋ณต์ก ์ถ๋ก ยท์ฝ๋ยท์ ๋ต ์ ํํ
- OpenAI GPT โ ๋ฒ์ฉ ๋ฉํฐ๋ชจ๋ฌ
- Perplexity Sonar โ ์น๊ฒ์ ๊ธฐ๋ฐ(์ถ์ฒ๋ฅผ ๋ถ์ฌ์ค)
- ๊ฐ ํ์ฌ API๋ HTTP๋ก JSON์ ์ฃผ๊ณ ๋ฐ๋ ๋ฐฉ์์ด ๋ฏธ๋ฌํ๊ฒ ๋ค๋ฆ ๋๋ค. ๊ทธ๋์ "๊ณตํต ๊ท๊ฒฉ(์ธํฐํ์ด์ค)"์ผ๋ก ๊ฐ์ธ ํ ์ค๋ก ํธ์ถํ ์ ์๊ฒ ๋ง๋ญ๋๋ค.
2) ์ธํฐํ์ด์ค + ๊ตฌํ์ฒด ๋ฌถ์ ์ฃผ์ (์ด ํํธ์ ์ค๊ณ ํต์ฌ)
- Java
interface= "์ด๋ฐ ๋ฉ์๋๋ฅผ ๊ฐ์ ธ์ผ ํ๋ค"๋ ๊ณ์ฝ์.AiProvider๊ฐ ๊ทธ๊ฒ. GeminiProvider,AnthropicProvider๋ฑ์ ๊ทธ ๊ณ์ฝ์ ๊ตฌํ(implements) ํ ์ค์ ์ผ๊พผ.- Spring์ ๋ง๋ฒ: ์์ฑ์์
List<AiProvider> providers๋ผ๊ณ ์ ์ผ๋ฉด, ๊ทธ ์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ ๋ชจ๋@Component๋ฅผ ์๋์ผ๋ก ๋ฆฌ์คํธ์ ๋ด์ ๋ฃ์ด์ค๋๋ค. (์ฝ์ผํฐ ๊ด๋ฆฌ์ค์ด "AiProvider ์๊ฒฉ์ฆ ๊ฐ์ง ์ง์ ์ ์ ๋ช ๋จ"์ ์๋์ผ๋ก ๋ฐ๋ ์ .) - ๊ทธ๋์ ์ ํ๋ก๋ฐ์ด๋๋ฅผ ์ถ๊ฐํ๋ ค๋ฉด โ
AiProvider๊ตฌํ ํด๋์ค ํ๋ ๋ง๋ค๊ณ@Component๋ถ์ด๋ฉด ๋. ๊ด๋ฆฌ์ค ์ฝ๋๋ ์ ๊ฑด๋๋ฆผ.
3) ์ฟผํฐ(quota) vs ๋ ์ดํธ๋ฆฌ๋ฐ(rate limit) โ ๋์ ๋ค๋ฆ ๋๋ค!
์ด๋ณด๊ฐ ๊ฐ์ฅ ํท๊ฐ๋ฆฌ๋ ์ง์ ์ ๋๋ค. ์ด ์์คํ ์ 2์ธต ๋ฐฉ์ด์ ์ ์๋๋ค.
| ์ฟผํฐ(Quota) | ๋ ์ดํธ๋ฆฌ๋ฐ(Rate Limit) | |
|---|---|---|
| ๋จ์ | ํ ํฐ ์(๊ธ์๋) | ์์ฒญ ํ์ |
| ๊ธฐ๊ฐ | ํ ๋ฌ | 1์๊ฐ |
| ๋๊ฐ | AiGatewayService (DB๋ก ๋์ ํฉ์ฐ) |
AiRateLimitFilter (๋ฉ๋ชจ๋ฆฌ ๋ฒํท) |
| ๋น์ | ์ ๋ฐ์ดํฐ ์๊ธ์ GB ํ๋ | 1๋ถ์ ๋ฌธ์ N๊ฐ ๋ฐ์ก ์ ํ(์คํธ ๋ฐฉ์ง) |
| ์ด๊ณผ ์ | IllegalStateException("ํ๋ ์ด๊ณผ") |
HTTP 429 ์ฆ์ ์๋ต |
- ์ฟผํฐ๋ "์ด๋ฒ ๋ฌ ๋๋ฌด ๋ง์ด ์ผ๋(๋น์ฉ)"๋ฅผ ๋ง๊ณ , ๋ ์ดํธ๋ฆฌ๋ฐ์ "์งง์ ์๊ฐ์ ํญ์ฃผ(์๋ฒ ๊ณผ๋ถํยท์ ์ฉ)"๋ฅผ ๋ง์ต๋๋ค. ๋ ๋ค ํต๊ณผํด์ผ ์ค์ AI ํธ์ถ์ด ์ผ์ด๋ฉ๋๋ค.
4) ํ ํฐ(token)์ด๋?
- AI๋ ๊ธ์ "ํ ํฐ"์ด๋ผ๋ ์กฐ๊ฐ ๋จ์๋ก ์ ๋๋ค. ๋๋ต ์์ด 4์ โ 1ํ ํฐ, ํ๊ธ์ ๋ ์๊ฒ.
- ์
๋ ฅ ํ ํฐ(
tokensIn, ๋ด๊ฐ ๋ณด๋ธ ์ง๋ฌธ)๊ณผ ์ถ๋ ฅ ํ ํฐ(tokensOut, AI๊ฐ ๋ตํ ๊ธ) ๋ ๋ค ๋น์ฉ์ ํฌํจ๋ฉ๋๋ค. - ์ธ๋ถ API๋ ๋ณดํต ์๋ต JSON์
usageํ๋์ ์ ํํ ํ ํฐ ์๋ฅผ ๋ด์์ค๋๋ค. ๋จ, Gemini ์ด๋ํฐ๋ ๊ทธ๊ฑธ ์ ์ฐ๊ณ ๊ธ์์๋ก ์ถ์ ํฉ๋๋ค(๋ค์์ ์ค๋ช ).
5) ํด๋ฐฑ ์ฒด์ธ(fallback chain)์ด๋?
- "1์์๊ฐ ์ ๋๋ฉด 2์์, ๊ทธ๊ฒ๋ ์ ๋๋ฉด 3์์"๋ก ์๋ ์ ํํ๋ ์ฌ๋ค๋ฆฌ.
- ์ฌ๊ธฐ์ ๋ ์ข
๋ฅ์ ํด๋ฐฑ์ด ์์ด ํท๊ฐ๋ฆฌ๊ธฐ ์ฝ์ต๋๋ค:
- ํ๋ก๋ฐ์ด๋ ํด๋ฐฑ (
LlmRouter): "Gemini ํค๊ฐ ์์ผ๋ฉด โ OpenAI๋ก" ์ฒ๋ผ ํ์ฌ๋ฅผ ๋ฐ๊ฟ. - ๋ชจ๋ธ ํด๋ฐฑ (
GeminiService๋ด๋ถ): "gemini-2.5-flash๊ฐ 429(ํ๋์ด๊ณผ)๋ฉด โ gemini-2.0-flash๋ก" ์ฒ๋ผ ๊ฐ์ ํ์ฌ ์์์ ๋ชจ๋ธ๋ง ๋ฐ๊ฟ.
- ํ๋ก๋ฐ์ด๋ ํด๋ฐฑ (
6) @Value("${ํค:๊ธฐ๋ณธ๊ฐ}") โ ํ๊ฒฝ๋ณ์ ์ฃผ์
@Value("${anthropic.api.key:}")๋ "์ค์ ์์anthropic.api.key๋ฅผ ์ฝ๋, ์์ผ๋ฉด ๋น ๋ฌธ์์ด(:๋ค๊ฐ ๊ธฐ๋ณธ๊ฐ)" ์ด๋ผ๋ ๋ป.- ๊ทธ๋์ API ํค๋ฅผ ์ ๋ฃ์ด๋ ์ฑ์ ์ฃฝ์ง ์๊ณ ,
available()์ดfalse๊ฐ ๋์ด ๊ทธ ํ๋ก๋ฐ์ด๋๋ง ๋นํ์ฑํ๋ฉ๋๋ค. (Gemini๋ง์ ์์ธ โ ๋ค ํจ์ ์ฐธ๊ณ .)
๐บ ์์ฒญ ํ๋ฆ๋
[ํ๋ก ํธ์๋] POST /api/ai/chat (JWT ์ฟ ํค ํฌํจ)
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ AiRateLimitFilter (โป /api/alpha/** ๊ฒฝ๋ก๋ง) โ โ ํ์ ์ ํ(20ํ/์๊ฐ)
โ โป /api/ai/chat ์ ์ด ํํฐ ๋์ ์๋(์๋ ํจ์ ) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ AiController.chat() โ ์ ์ ๋ฐ์คํฌ
โ - AuthContext.currentUserId() ๋ก ์ ์ ํ์ธ โ
โ - ๋น๋ก๊ทธ์ธ โ GeminiService ์ง์ (๊ฒ์ดํธ ์ฐํ) โ
โ - ๋ก๊ทธ์ธ โ AiGatewayService.chat() โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ (๋ก๊ทธ์ธ ์ฌ์ฉ์)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ AiGatewayService.chat(userId, modelId, req) โ ๊ด๋ฆฌ์ค
โ โ ensureUsable() โ ๋ชจ๋ธ ์กด์ฌ? ํ์ฑ? ์ฟผํฐ OK? โ โโ๊ฑฐ๋ถโโโถ IllegalStateException
โ โก providerFor() โ modelId์ provider ๋งค์นญ โ
โ โข provider.chat() ํธ์ถ โ
โ โฃ recordUsage() โ ์ฑ๊ณต/์คํจ ๋ชจ๋ ๋ก๊ทธ ์ ์ฅ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ GeminiProvider / AnthropicProvider / ... โ ์๋ด์ฌ(์ด๋ํฐ)
โ โ ์ค์ ์ธ๋ถ API HTTP POST (JSON) โ
โ โ ์๋ต ํ
์คํธ + ํ ํฐ ์ฌ์ฉ๋(Result) ๋ฐํ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
DB: AI_USAGE_LOG (์ด๋ฒ ๋ฌ ๋์ ํ ํฐ โ ๋ค์ ์ฟผํฐ ๊ณ์ฐ์ ์ฌ์ฉ)
[๋ณ๊ฐ ๊ฒฝ๋ก] POST /api/llm/chat (์์ ์ฑํ
)
โ
โผ
LlmController โ LlmRouter.get(providerId)
โ โโ ํค ์์ผ๋ฉด defaultProvider() ๋ก ํด๋ฐฑ
โผ
LlmProvider.oneShot() โ ์ธ๋ถ API (์ฟผํฐ/๋ก๊ทธ ์์)๐ ํต์ฌ ํด๋์ค ๋ผ์ธ๋ณ ์ฌํ
A. AiProvider ์ธํฐํ์ด์ค โ ๋ชจ๋ ์๋ด์ฌ์ ํ์ค์ (service/gateway/AiProvider.java)
// AiProvider.java:9-25
public interface AiProvider {
String providerKey(); // "GEMINI" / "ANTHROPIC" / "OPENAI" / "PERPLEXITY"
boolean isAvailable(); // API ํค ๋ณด์ ์ฌ๋ถ (์์ผ๋ฉด ๊ฒ์ดํธ์จ์ด๊ฐ ๊ฑฐ๋ถ)
Result chat(String modelId, AiChatRequest request); // ๋ฉํฐํด ์ฑํ
Result oneShot(String modelId, String systemInstruction, String userPrompt, boolean wantJson); // ๋จ๋ฐ
record Result(String text, long tokensIn, long tokensOut) {} // ๊ฒฐ๊ณผ + ํ ํฐ
}
- ์ด 4๊ฐ ๋ฉ์๋๊ฐ "AI ์๋ด์ฌ๋ผ๋ฉด ๋ฐ๋์ ํ ์ ์์ด์ผ ํ๋ ์ผ" ์ ๋๋ค. 4๊ฐ ๊ตฌํ์ฒด๊ฐ ๊ฐ์ ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ์ด ๊ณ์ฝ์ ์งํต๋๋ค.
providerKey()โ ์๊ธฐ๊ฐ ๋๊ตฌ์ธ์ง ๋๋ฌธ์ ํค๋ก ์๋ ค์ค.AiGatewayService๊ฐ ๋ชจ๋ธ์ provider์ ์ด ํค๋ฅผ ๋ฌธ์์ด ๋น๊ตํด ์ง์ ์ฐพ์ต๋๋ค(์๋providerForOpt).isAvailable()โ "์ง๊ธ ์ผํ ์ ์๋"(API ํค๊ฐ ์๋). ํค ์๋ ์๋ด์ฌ๋ ๋ช ๋จ์๋ ์๋ ํ์ ์ฒ๋ฆฌ๋ฉ๋๋ค.Result๋ record(๋ถ๋ณ ๋ฐ์ดํฐ ๋ฌถ์). ์๋ต ๊ธ(text) + ์ ๋ ฅ ํ ํฐ + ์ถ๋ ฅ ํ ํฐ 3๊ฐ๋ฅผ ํ ๋ดํฌ์ ๋ด์ ๋ฐํ. ์ด ํ ํฐ ์ซ์๊ฐ ๊ณง ์ฟผํฐ ์ฐจ๊ฐ๋์ ๋๋ค.
๐ก ์ด๋ณด ํฌ์ธํธ:
chat์ ๋ํ ํ์คํ ๋ฆฌ ์ ์ฒด(์ฌ๋ฌ ํด)๋ฅผ ๋ฐ๊ณ ,oneShot์ ์์คํ ์ง์ + ์ง๋ฌธ ํ ๊ฐ๋ง ๋ฐ๋ ๋จ๋ฐ์ฑ. ์ฑํ ์ฐฝ์chat, "์ด ํ ์คํธ์์ JSON ๋ฝ์์ค" ๊ฐ์ ์ผํ์ฑ ์์ ์oneShot.
B. AiGatewayService โ ๊ด๋ฆฌ์ค (์ด ํํธ์ ์๋งน์ด) (service/gateway/AiGatewayService.java)
B-1. ์์กด์ฑ ์ฃผ์
: ํ๋ก๋ฐ์ด๋ "์ ์ ๋ช
๋จ" ๋ฐ๊ธฐ โ L32-L37
// AiGatewayService.java:32-37
public class AiGatewayService {
private final AiModelCatalogRepository catalogRepo; // ๋ชจ๋ธ ๋ฉ๋ดํ ์กฐํ
private final AiUsageLogRepository usageRepo; // ์ฌ์ฉ๋ ๊ธฐ๋ก/ํฉ์ฐ
private final SubscriptionService subscriptionService;// ์ด ์ ์ ๊ฐ Free๋ Pro๋
private final List<AiProvider> providers; // โ
AiProvider ๊ตฌํ ์ ์
- ๋ง์ง๋ง ์ค์ด ์ฌ์ ์ง์ 2๋ฒ์ ๋ง๋ฒ์
๋๋ค.
List<AiProvider>๋ผ๊ณ ๋ง ์ ์ผ๋ฉด Spring์ดGeminiProvider,AnthropicProvider,OpenAiProvider,PerplexityProvider4๊ฐ๋ฅผ ๋ชจ๋ ๋ด์ ์ฃผ์ ํฉ๋๋ค. - ๊ด๋ฆฌ์ค์ "์ง์์ด ๋ช ๋ช ์ธ์งยท๋๊ตฌ์ธ์ง" ํ๋์ฝ๋ฉํ์ง ์์ต๋๋ค. ์ ์ง์(ํ๋ก๋ฐ์ด๋)์ด ์๋ ์ด ์ฝ๋๋ ๊ทธ๋๋ก.
B-2. chat() โ ํ ๋ฒ์ ํธ์ถ ์ ์ฒด ํ๋ฆ โ L40-L52
// AiGatewayService.java:40-52
public String chat(Long userId, String modelId, AiChatRequest request) {
AiModelCatalog model = ensureUsable(userId, modelId); // โ ์ฟผํฐยท์กด์ฌ ๊ฒ์ฆ
AiProvider provider = providerFor(model); // โก ๋ด๋น ์๋ด์ฌ ์ฐพ๊ธฐ
AiProvider.Result result;
try {
result = provider.chat(model.getModelId(), request);// โข ์ค์ ํธ์ถ
recordUsage(userId, modelId, result.tokensIn(), result.tokensOut(), true, null); // โฃ ์ฑ๊ณต ๋ก๊ทธ
return result.text();
} catch (RuntimeException e) {
recordUsage(userId, modelId, 0, 0, false, e.getMessage()); // โฃ' ์คํจ๋ ๋ก๊ทธ
throw e;
}
}
- 4๋จ๊ณ ํ์ดํ๋ผ์ธ์ด ํ๋์ ๋ณด์ ๋๋ค: ๊ฒ์ฆ โ ๋ผ์ฐํ โ ํธ์ถ โ ๊ธฐ๋ก.
- ํต์ฌ ์ค๊ณ: ์ฑ๊ณต์ด๋ ์คํจ๋ ๋ฐ๋์
recordUsage๋ฅผ ํธ์ถํฉ๋๋ค(try์์ ์ฑ๊ณต,catch์์ ์คํจ). ์คํจ๋ ํ ํฐ 0์ผ๋ก ๊ธฐ๋กํ๋ ์๋ฌ ๋ฉ์์ง๋ฅผ ๋จ๊ฒจ "์ ์คํจํ๋" ์ถ์ ์ด ๋ฉ๋๋ค. - ์คํจ ์
throw e๋ก ์๋ฌ๋ฅผ ๋ค์ ๋์ง๋๋ค โ ๋ก๊ทธ๋ง ๋จ๊ธฐ๊ณ ์ผํค์ง ์์. ๊ทธ๋์ผ ์ปจํธ๋กค๋ฌ๊ฐ ์ฌ์ฉ์์๊ฒ ์๋ฌ๋ฅผ ์๋ ค์ค ์ ์์ต๋๋ค.
๐ก ์ด๋ณด ํฌ์ธํธ:
oneShot()(L54-66)๋ ๊ตฌ์กฐ๊ฐ ์์ ํ ๊ฐ์ต๋๋ค โ ๊ฒ์ฆโ๋ผ์ฐํ โํธ์ถโ๋ก๊ทธ.chat๋์provider.oneShot์ ๋ถ๋ฅด๋ ๊ฒ๋ง ๋ค๋ฆ ๋๋ค. ์ด๋ฐ ๋ฐ๋ณต ๊ตฌ์กฐ๋ฅผ "ํ ํ๋ฆฟ" ์ด๋ผ ๋ถ๋ฅด๋ฉฐ, ์ตํ๋๋ฉด ์ฝ๋๊ฐ ๋น ๋ฅด๊ฒ ์ฝํ๋๋ค.
B-3. ensureUsable() โ ์ฟผํฐ ๊ฐ๋ (๊ฐ์ฅ ์ค์ํ ๊ฒ์ฆ) โ L105-L123
// AiGatewayService.java:105-123
private AiModelCatalog ensureUsable(Long userId, String modelId) {
AiModelCatalog model = catalogRepo.findById(modelId)
.filter(AiModelCatalog::isEnabled) // ๋นํ์ฑ ๋ชจ๋ธ์ด๋ฉด ๋น Optional
.orElseThrow(() -> new IllegalArgumentException("์ ์ ์๊ฑฐ๋ ๋นํ์ฑํ๋ ๋ชจ๋ธ: " + modelId));
Subscription.Tier tier = subscriptionService.currentTier(userId); // FREE or PRO
long quota = (tier == Subscription.Tier.PRO) ? model.getProQuota() : model.getFreeQuota();
if (quota == 0) {
throw new IllegalStateException("์ด ๋ชจ๋ธ์ Pro ์ ์ฉ์
๋๋ค. (" + model.getDisplayName() + ")");
}
if (quota != -1) { // -1 = ๋ฌด์ ํ์ด๋ฉด ๊ฒ์ฌ ์คํต
LocalDateTime monthStart = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
long used = usageRepo.sumTokensByUserAndModelSince(userId, modelId, monthStart);
if (used >= quota) {
throw new IllegalStateException("์ด๋ฒ ๋ฌ ์ฌ์ฉ ํ๋(" + quota + " ํ ํฐ)๋ฅผ ์ด๊ณผํ์ต๋๋ค.");
}
}
return model;
}
์ด ๋ฉ์๋๊ฐ ์ฟผํฐ ์์คํ ์ ์ฌ์ฅ์ ๋๋ค. ํ ์ค์ฉ ํ์ด๋ด ๋๋ค.
- ๋ชจ๋ธ ์กด์ฌยทํ์ฑ ๊ฒ์ฆ: ๋ฉ๋ดํ(
catalogRepo)์์modelId๋ฅผ ์ฐพ๊ณ ,.filter(isEnabled)๋ก "๊บผ์ง ๋ชจ๋ธ"์ ๊ฑธ๋ฌ๋ ๋๋ค. ์์ผ๋ฉดIllegalArgumentException. - ํฐ์ด ํ์ธ:
subscriptionService.currentTier(userId)๋ก ์ด ์ฌ์ฉ์๊ฐ ๋ฌด๋ฃ(FREE)์ธ์ง ์ ๋ฃ(PRO)์ธ์ง ํ์ . (๊ฒฐ์ ๋๋ฉ์ธ๊ณผ ์ฐ๊ฒฐ๋๋ ์ง์ .) - ํ๋ ์ ํ: Pro๋ฉด
proQuota, ์๋๋ฉดfreeQuota. ํ๋๋ ์ซ์ 3์ข ์๋ฏธ๊ฐ ์์ต๋๋ค:0โ Pro ์ ์ฉ(Free๋ ๋ชป ์) โ ์ฆ์ ๊ฑฐ๋ถ-1โ ๋ฌด์ ํ โ ์ฌ์ฉ๋ ๊ฒ์ฌ ์์ฒด๋ฅผ ๊ฑด๋๋- ๊ทธ ์ธ ์์ โ ์ ํ ํฐ ํ๋
- ์ด๋ฒ ๋ฌ ๋์ ํฉ์ฐ:
monthStart(์ด๋ฒ ๋ฌ 1์ผ 00:00)๋ถํฐ ์ง๊ธ๊น์ง ์ด ์ ์ ๊ฐ ์ด ๋ชจ๋ธ๋ก ์ด ํ ํฐ ํฉ(sumTokensByUserAndModelSince).used >= quota๋ฉด ๊ฑฐ๋ถ.
โ ๏ธ ์ด๋ณด๊ฐ ๋์น๋ ํฌ์ธํธ:
withDayOfMonth(1).withHour(0)...์ "์ด๋ฒ ๋ฌ 1์ผ ์์ "์ ๋ง๋๋ ๊ฒ.withMinuteยทwithSecond๋ 0์ผ๋ก ๋ง์ถ์ง๋ง ๋๋ ธ์ด(withNano)๋ ์ ๊ฑด๋๋ฆฝ๋๋ค โ ํฉ์ฐ ์ฟผ๋ฆฌ๊ฐ>= since๋ผ 1์ผ 0์ 0๋ถ 0์ด ์ดํ๋ฅผ ๋ค ํฌํจํ๋ฏ๋ก ์ค๋ฌด์ ๋ฌธ์ ๋ ์์ง๋ง, ์ ๋ฐํ๊ฒ๋.withNano(0)๊น์ง ๋ฃ๋ ๊ฒ ๊น๋ํฉ๋๋ค(๊ณ ๋ํ ํญ๋ชฉ).
B-4. ์ฟผํฐ ํฉ์ฐ ์ฟผ๋ฆฌ โ AiUsageLogRepository.java:13-20
// AiUsageLogRepository.java:13-20
@Query("""
SELECT COALESCE(SUM(u.tokensIn + u.tokensOut), 0)
FROM AiUsageLog u
WHERE u.userId = :uid AND u.modelId = :model AND u.createdAt >= :since AND u.success = true
""")
long sumTokensByUserAndModelSince(@Param("uid") Long uid, @Param("model") String modelId, @Param("since") LocalDateTime since);
tokensIn + tokensOut์ ํจ๊ป ํฉ์ฐ โ ์ ๋ ฅยท์ถ๋ ฅ ๋ชจ๋ ๋น์ฉ์ด๋ฏ๋ก.COALESCE(..., 0)โ ํธ์ถ ๊ธฐ๋ก์ด ํ๋๋ ์์ผ๋ฉดSUM์ดnull์ ์ฃผ๋๋ฐ, ์ด๊ฑธ0์ผ๋ก ๋ฐ๊ฟ์ค๋๋ค(NPE ๋ฐฉ์ง). ์ ๊ท ์ ์ ๋ 0์ผ๋ก ์์.u.success = trueโ ์คํจํ ํธ์ถ์ ์ฟผํฐ์์ ๋นผ์ง ์์ต๋๋ค. API ์๋ฌ๋ก ๋ต์ ๋ชป ๋ฐ์์ผ๋ฉด ํ ํฐ์ ์ ๊น๋ ๊ฒ ๊ณต์ . (์คํจ ๋ก๊ทธ๋ ๋จ๋ ํฉ์ฐ์๋ ๋ถํฌํจ.)- ์ด ์ฟผ๋ฆฌ๊ฐ
AiUsageLog์ํฐํฐ์ ๋ณตํฉ ์ธ๋ฑ์คix_aiusage_user_model_time (user_id, model_id, created_at)์ ๊ทธ๋๋ก ํ๋๋ค โ ๊ทธ๋์ ์ฌ์ฉ๋์ด ์์ฌ๋ ๋น ๋ฆ ๋๋ค.
B-5. providerForOpt() โ ๋ชจ๋ธ โ ์๋ด์ฌ ๋งค์นญ โ L130-L133
// AiGatewayService.java:130-133
private java.util.Optional<AiProvider> providerForOpt(AiModelCatalog model) {
String key = model.getProvider().name(); // ์: "ANTHROPIC"
return providers.stream().filter(p -> p.providerKey().equals(key)).findFirst();
}
- ๋ชจ๋ธ ๋ฉ๋ดํ์
provider(enum) ์ด๋ฆ๊ณผ, ์ฃผ์ ๋ 4๊ฐ ํ๋ก๋ฐ์ด๋์providerKey()๋ฅผ ๋ฌธ์์ด๋ก ๋น๊ตํด ์ง์ ์ฐพ์ต๋๋ค. - ์: ์ฌ์ฉ์๊ฐ
claude-sonnet-4๋ชจ๋ธ์ ๊ณ ๋ฅด๋ฉด โ ๊ทธ ๋ชจ๋ธ์ provider๋ANTHROPICโproviderKey()=="ANTHROPIC"์ธAnthropicProvider๋ก ์ฐ๊ฒฐ. Optional๋ก ๊ฐ์ผ ์ด์ : ์ง์ด ์์ ์๋ ์์ด์.providerFor()(L125-128)๋ ์ง์ด ์์ผ๋ฉด ์์ธ๋ฅผ,listModelsFor๋ "์ฌ์ฉ๋ถ๊ฐ(ํ์)"๋ก ํ์.
B-6. listModelsFor() โ UI ๋ชจ๋ธ ์ ํ๊ธฐ ๋ฐ์ดํฐ โ L70-L95
// AiGatewayService.java:74-94 (์์ฝ)
return catalogRepo.findByEnabledTrueOrderBySortOrderAsc().stream().map(m -> {
long quota = (tier == Subscription.Tier.PRO) ? m.getProQuota() : m.getFreeQuota();
long used = usageRepo.sumTokensByUserAndModelSince(userId, m.getModelId(), monthStart);
boolean providerOk = providerForOpt(m).map(AiProvider::isAvailable).orElse(false);
boolean unlocked = quota != 0 || tier == Subscription.Tier.PRO;
boolean usable = providerOk && unlocked && (quota == -1 || used < quota);
long remaining = (quota == -1) ? Long.MAX_VALUE : Math.max(0, quota - used);
return Map.<String, Object>of( "modelId", ..., "quota", quota, "used", used,
"remaining", remaining == Long.MAX_VALUE ? -1 : remaining,
"usable", usable, "lockReason", lockReason(...) );
}).toList();
- ํ๋ก ํธ์ ๋ชจ๋ธ ๋๋กญ๋ค์ด์ ๋ฟ๋ฆด ๋ฐ์ดํฐ๋ฅผ ๋ชจ๋ธ๋ง๋ค ํ ์ค์ฉ ๋ง๋ญ๋๋ค.
usable3์กฐ๊ฑด AND: โ ํ๋ก๋ฐ์ด๋ ํค๊ฐ ์๊ณ (providerOk) โก ์ ๊ธ ํด์ ๋๊ณ (unlocked, Pro์ ์ฉ ์๋) โข ํ๋ ์ ๋์๋ค. ์ ๋ค ์ฐธ์ด์ด์ผ ์ ํ ๊ฐ๋ฅ.remaining์ด ๋ฌด์ ํ(-1ํ์)์ผ ๋ ๋ด๋ถ์ ์ผ๋กLong.MAX_VALUE๋ฅผ ์ฐ๋ค๊ฐ ์๋ต ์ง์ -1๋ก ๋ณํ โ ํ๋ก ํธ๋-1์ "๋ฌด์ ํ"์ผ๋ก ํด์.lockReason(L97-102)์ ๋ชป ์ฐ๋ ์ด์ ๋ฅผ ์ฌ๋ ๋ง๋ก ๋๋ ค์ค๋๋ค: "API ํค ๋ฏธ์ค์ " / "Pro ์ ์ฉ" / "์ด๋ฒ ๋ฌ ํ๋ ์ด๊ณผ". ์ฌ์ฉ์๊ฐ ์ ํ์์ธ์ง ์ ์ ์๊ฒ.
๐ก
listModelsFor์๋@Transactional(readOnly = true)(L69)๊ฐ ๋ถ์ด ์์ต๋๋ค. ์ฝ๊ธฐ ์ ์ฉ ํธ๋์ญ์ โ ์ฌ๋ฌ ๋ฒ์ DB ์กฐํ๋ฅผ ํ ํธ๋์ญ์ ์ผ๋ก ๋ฌถ์ด ์ผ๊ด์ฑ์ ๋ณด์ฅํ๊ณ , ์ฐ๊ธฐ๊ฐ ์์์ ๋ช ์ํด ์ฝ๊ฐ์ ์ต์ ํ๋ ์ป์ต๋๋ค.
B-7. recordUsage() โ ํตํ ๊ธฐ๋ก๋ถ ์์ฑ โ L135-L148
// AiGatewayService.java:135-148
private void recordUsage(Long userId, String modelId, long tIn, long tOut, boolean ok, String err) {
try {
usageRepo.save(AiUsageLog.builder()
.userId(userId).modelId(modelId)
.tokensIn(tIn).tokensOut(tOut)
.success(ok)
.errorMessage(err == null ? null : err.substring(0, Math.min(err.length(), 500)))
.build());
} catch (Exception e) {
log.warn("AiUsageLog ์ ์ฅ ์คํจ (๋ฌด์): {}", e.getMessage());
}
}
errorMessage๋ฅผ ์ต๋ 500์๋ก ์๋ฆ ๋๋ค(substring(0, min(len, 500))). ์ํฐํฐ์length=500์ปฌ๋ผ์ ๋์ง ์๊ฒ. (๊ธด ์คํํธ๋ ์ด์ค๊ฐ ์๋ DB ์ ์ฅ ์คํจ ์ ํจ.)- ๋ก๊ทธ ์ ์ฅ ์คํจ๋ ํต์งธ๋ก ์ผํต๋๋ค(
catch์์log.warn๋ง). "๊ธฐ๋ก๋ถ ์์ฑ์ด ์คํจํด๋ ์ฌ์ฉ์ ์๋ต์ ์ด๋ฏธ ๋๊ฐ์ผ๋ ๋ฌด๋์ง๋ฉด ์ ๋๋ค"๋ ํ๋จ. ๋ก๊น ์ ๋ถ์ ์์ ์ด๋ผ ๋ณธ ํ๋ฆ์ ๋ง์ง ์์ต๋๋ค.
C. GeminiProvider ์ด๋ํฐ โ ๋ฒ ํ
๋์ ํ์ค ์์์ ๋ผ์ฐ๊ธฐ (service/gateway/GeminiProvider.java)
์ด ํด๋์ค๋ ๋ค๋ฅธ 3๊ฐ ํ๋ก๋ฐ์ด๋์ ๊ฒฐ์ด ๋ค๋ฆ
๋๋ค. ์ง์ HTTP๋ฅผ ์น์ง ์๊ณ , ๊ธฐ์กด GeminiService๋ฅผ ๊ฐ์ธ๊ธฐ(์ด๋ํฐ ํจํด) ๋ง ํฉ๋๋ค.
// GeminiProvider.java:22-36
@Override
public Result chat(String modelId, AiChatRequest request) {
String text = geminiService.chat(request); // โ ์ค์ ์ผ์ GeminiService๊ฐ ํจ
long tIn = estimateTokens(buildPromptText(request)); // โ ํ ํฐ์ "์ถ์ "
long tOut = estimateTokens(text);
return new Result(text, tIn, tOut);
}
// GeminiProvider.java:49-53
/** ์๋ฌธ ~4์=1ํ ํฐ, ํ๊ธ ~1.5์=1ํ ํฐ. ๋ณด์์ ์ผ๋ก chars/3 ์ฌ์ฉ. */
static long estimateTokens(String s) {
if (s == null || s.isEmpty()) return 0;
return Math.max(1, s.length() / 3);
}
- โ ๏ธ ํต์ฌ ์ฐจ์ด: Anthropic/OpenAI/Perplexity๋ ์๋ต JSON์
usageํ๋์์ ์ ํํ ํ ํฐ ์๋ฅผ ์ฝ์ง๋ง, GeminiProvider๋ ๊ธ์์ รท 3์ผ๋ก ์ถ์ ํฉ๋๋ค(estimateTokens). ์ฃผ์์๋ "์ ํํ ์ฌ์ฉ๋์ Gemini usageMetadata ํ์ฑ ํ์"๋ผ๊ณ ๋ช ์ โ ์ฆ ์๋ ค์ง ๋ถ์ ํ์ฑ์ ๋๋ค. chars/3์ ์ฐ๋ ์ด์ : ํ๊ธ์ 1.5์/ํ ํฐ, ์์ด๋ 4์/ํ ํฐ์ด๋ผ ์์ด๋ฉด ํ๊ท ์ด ์ ๋งคํ๋ฐ, 3์ผ๋ก ๋๋๋ฉด ์ฝ๊ฐ ๊ณผ๋์ถ์ (๋ณด์์ )์ด ๋ฉ๋๋ค. ์ฟผํฐ๋ฅผ ์ฝ๊ฐ ๋นก๋นกํ๊ฒ ์ก์ "์ด๊ณผ ๋ฏธํ์ฉ" ์ชฝ์ผ๋ก ์์ .modelIdํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์ง๋ง ์ ์๋๋ค โgeminiService.chat์ด ๋ด๋ถ ๊ธฐ๋ณธ ๋ชจ๋ธ์ ์ฐ๊ธฐ ๋๋ฌธ(ํ์ฌ ๊ตฌ์กฐ์ ํ๊ณ). ๊ณ ๋ํ ํญ๋ชฉ.
๐ก ์ด๋ํฐ ํจํด์ด๋: "๊ธฐ์กด์ ์ ๋๋ ํด๋์ค(
GeminiService)๋ฅผ, ์ ํ์ค ์ธํฐํ์ด์ค(AiProvider)์ ๋ง๊ฒ ๋ณํ๊ธฐ๋ฅผ ์์ ์ฌ์ฌ์ฉ"ํ๋ ๊ธฐ๋ฒ. ์ฝ์ผํธ ๋ชจ์์ด ๋ฌ๋ผ๋ ๋ผ์ง์ฝ ์ด๋ํฐ๋ก ๊ฝ๋ ๊ฒ๊ณผ ๊ฐ์ต๋๋ค.
D. GeminiService โ ์ค์ Gemini API ํธ์ถ ๋ณธ์ฒด (service/GeminiService.java)
์ด๊ฒ ์ง์ง๋ก ์ธํฐ๋ท ๋๋จธ Google์ HTTP๋ฅผ ์น๋ ๊ณณ์ ๋๋ค. ๊ฐ์ฅ ์ ๊ตํ ์๋ฌ ์ฒ๋ฆฌ๊ฐ ๋ค์ด์์ต๋๋ค.
D-1. ๋ชจ๋ธ ๋ ๋ฒจ ํด๋ฐฑ โ L181-L205
// GeminiService.java:181-205 (์์ฝ)
private Map<String, Object> generateContent(Map<String, Object> body) {
try {
return postGenerateContent(model, body, true); // 1์์: ๊ธฐ๋ณธ ๋ชจ๋ธ
} catch (HttpClientErrorException.TooManyRequests primary429) {
if (fallbackModel == null || fallbackModel.isBlank() || fallbackModel.equals(model)) {
throw new RuntimeException(buildQuotaMessage(...), primary429); // ํด๋ฐฑ ์์ผ๋ฉด ์น์ ํ ๋ฉ์์ง
}
log.warn("Gemini 429 on primary model {}. Falling back to {}.", model, fallbackModel);
return postGenerateContent(fallbackModel, body, false); // 2์์: ํด๋ฐฑ ๋ชจ๋ธ
} catch (HttpClientErrorException e) {
if (e.getStatusCode().value() == 403 && fallbackModel ์กด์ฌ) {
return postGenerateContent(fallbackModel, body, false); // 403๋ ํด๋ฐฑ
}
throw e;
}
}
- 429(Too Many Requests, ํ๋์ด๊ณผ) ์ 403(Forbidden, ์ ๊ทผ๋ถ๊ฐ) ๋ ๋ค โ ํด๋ฐฑ ๋ชจ๋ธ๋ก ํ ๋ฒ ๋ ์๋.
- ์ด๊ฒ์ด ์ฌ์ ์ง์ 5๋ฒ์ "๊ฐ์ ํ์ฌ ์์์ ๋ชจ๋ธ๋ง ๋ฐ๊พธ๋ ํด๋ฐฑ"(
gemini-2.5-flashโ ํด๋ฐฑ).LlmRouter์ "ํ์ฌ ์์ฒด๋ฅผ ๋ฐ๊พธ๋ ํด๋ฐฑ"๊ณผ ๋ค๋ฆ ๋๋ค.
D-2. 429 ๋ณธ๋ฌธ ํ์ฑ โ L227-L251
// GeminiService.java:227-247 (์์ฝ)
private Quota429Info parse429Info(HttpClientErrorException e) {
String body = e.getResponseBodyAsString();
boolean isFreeTier = body.contains("free_tier") || body.contains("FreeTier");
boolean isDailyQuota = body.contains("PerDay") || body.contains("GenerateRequestsPerDay");
long retryDelayMs = 5_000L;
int rdIdx = body.indexOf("\"retryDelay\"");
if (rdIdx >= 0) { /* "retryDelay":"52.98s" ํ์์์ 52.98 ์ถ์ถ โ ms */ }
return new Quota429Info(isFreeTier, isDailyQuota, retryDelayMs);
}
- Google์ด ๋ณด๋ธ 429 ์๋ต ๋ณธ๋ฌธ์์ "๋ฌด๋ฃ ํฐ์ด์ธ๊ฐ / ์ผ๊ฐ ํ๋์ธ๊ฐ / ๋ช ์ด ๊ธฐ๋ค๋ฆฌ๋ผ๋๊ฐ" ๋ฅผ ์ถ์ถ.
- ํฅ๋ฏธ๋ก์ด ์ : JSON ํ์(Jackson)๋ฅผ ์ ์ฐ๊ณ ๋ฌธ์์ด ๊ฒ์(
contains/indexOf)์ผ๋ก ์ฒ๋ฆฌํฉ๋๋ค. ์ฃผ์์ "Jackson ์์กด ์์ด ์ฒ๋ฆฌ"๋ผ๊ณ ๋ช ์. (๋ฉ๋ชจ๋ฆฌ์ Spring Boot 4 Jackson JsonNode ๋ฒ๊ทธ ํํผ์ ๊ฐ์ ๋งฅ๋ฝ โ ๋จ์ ์ถ์ถ์ ๋ฌธ์์ด ๊ฒ์์ด ๋ ์์ .) - ์ถ์ถ ๊ฒฐ๊ณผ๋ก ๋ฉ์์ง๋ฅผ ๋ค๋ฅด๊ฒ ๋ง๋ญ๋๋ค(
buildQuotaMessage, L208-221): ์ผ๊ฐ ๋ฌด๋ฃ ํ๋ ์์ง์ด๋ฉด "๊ฒฐ์ ํ์ฑํํ๋ผ", RPM ์ด๊ณผ๋ฉด "N์ด ํ ์ฌ์๋".
D-3. RPM ์ฌ์๋ with ๋ฐฑ์คํ โ L254-L294
// GeminiService.java:271-290 (์์ฝ)
} catch (HttpClientErrorException.TooManyRequests e) {
Quota429Info info = parse429Info(e);
if (info.isFreeTier() && info.isDailyQuota()) {
throw e; // ์ผ๊ฐ ํ๋๋ ๊ธฐ๋ค๋ ค๋ ๋ฌด์๋ฏธ โ ์ฆ์ ๋์ง โ ์์์์ ๋ชจ๋ธ ํด๋ฐฑ
}
if (attempt >= maxAttempts) throw e;
long waitMs = Math.min(info.retryDelayMs(), 60_000L); // API๊ฐ ์๋ ค์ค ๋งํผ, ์ต๋ 60์ด
sleepMs(waitMs); // ๊ธฐ๋ค๋ ธ๋ค๊ฐ 1ํ ์ฌ์๋
}
- ๋ ์ข
๋ฅ์ 429๋ฅผ ๊ตฌ๋ถํฉ๋๋ค:
- ์ผ๊ฐ ๋ฌด๋ฃ ํ๋ ์์ง(
isDailyQuota) โ ๊ธฐ๋ค๋ ค๋ ์ ํ๋ฆผ โ ์ฆ์ ๋์ ธ์ ์์๊ฐ ๋ชจ๋ธ ํด๋ฐฑํ๊ฒ. - RPM(๋ถ๋น ์์ฒญ) ์ด๊ณผ โ API๊ฐ ์๋ ค์ค
retryDelay๋งํผ ๊ธฐ๋ค๋ ธ๋ค 1ํ ์ฌ์๋(์ต๋ 60์ด ์บก).
- ์ผ๊ฐ ๋ฌด๋ฃ ํ๋ ์์ง(
sleepMs(L296-303)๋Thread.sleep์ค ์ธํฐ๋ฝํธ๊ฐ ์ค๋ฉด ํ๋๊ทธ๋ฅผ ๋ณต์(Thread.currentThread().interrupt())ํ๊ณ ์์ธ๋ฅผ ๋์ง๋๋ค โ ์ฌ๋ฐ๋ฅธ ์ธํฐ๋ฝํธ ์ฒ๋ฆฌ ๊ด๋ก.
โ ๏ธ ํจ์ :
postGenerateContent์ ์๋ต์Map.class๋ก ๋ฐ์ต๋๋ค(L270). ์ด๊ฑด ๋ฉ๋ชจ๋ฆฌ์ Spring Boot 4 Jackson JsonNode ๋ฒ๊ทธ๋ฅผ ํผํ๋ ค๋ ์ ํ โJsonNode๋ก ๋ฐ์ผ๋ฉด Boot4(Jackson3 ๊ธฐ๋ณธ)์์ bean ์์ฑ ๋ณํ์ด ๊นจ์ง๋ฏ๋กMap/String์ผ๋ก ๋ฐ์ต๋๋ค. (gateway์ ๋ค๋ฅธ ํ๋ก๋ฐ์ด๋๋ค์String์ผ๋ก ๋ฐ์ ์ง์ ObjectMapper๋ก ํ์ฑ.)
E. LlmRouter โ ํด๋ฐฑ ์ฒด์ธ์ ๋ณธ์ง (service/llm/LlmRouter.java)
์ด์ ๋ ๋ฒ์งธ ํํ ์ธ๊ณ์ ๋๋ค. ์ฟผํฐยท๋ก๊ทธ๊ฐ ์๋ ๋์ ํ๋ก๋ฐ์ด๋ ํด๋ฐฑ์ด ํต์ฌ.
// LlmRouter.java:11-36
public class LlmRouter {
private final Map<String, LlmProvider> providers;
private final List<LlmProvider> ordered;
public LlmRouter(List<LlmProvider> providers) {
this.ordered = providers; // Bean ์ฃผ์
์์ ๋ณด์กด
this.providers = providers.stream().collect(Collectors.toMap(LlmProvider::id, p -> p)); // idโprovider ๋งต
}
public LlmProvider get(String id) {
if (id == null || id.isBlank()) return defaultProvider();
LlmProvider p = providers.get(id);
if (p == null || !p.available()) return defaultProvider(); // โ
์๊ฑฐ๋ ํค ์์ผ๋ฉด ํด๋ฐฑ
return p;
}
public LlmProvider defaultProvider() {
return ordered.stream()
.filter(LlmProvider::available) // ํค ์๋ ์ฒซ ํ๋ก๋ฐ์ด๋
.findFirst()
.orElseThrow(() -> new IllegalStateException("์ฌ์ฉ ๊ฐ๋ฅํ LLM ํ๋ก๋ฐ์ด๋๊ฐ ์์ต๋๋ค. ..."));
}
}
- ๋ ๊ฐ์ง ์๋ฃ๊ตฌ์กฐ๋ฅผ ๋์์ ๋ณด๊ด:
ordered(List) โ ์ฃผ์ ์์ ๊ทธ๋๋ก(UI ํ์ ์์ยทํด๋ฐฑ ์ฐ์ ์์). Spring์ ๋ณดํต@Component๋ฐ๊ฒฌ ์/์ํ๋ฒณ ์์ผ๋ก ์ฃผ์ .providers(Map) โid("gemini"/"anthropic"/...)๋ก O(1) ๋น ๋ฅธ ์กฐํ.
- ํด๋ฐฑ ๋ก์ง์
get()์ ์์ถ๋ผ ์์ต๋๋ค: ์์ฒญํ provider๊ฐ ์๊ฑฐ๋(p == null) ํค๊ฐ ์์ผ๋ฉด(!p.available()) โdefaultProvider()๋ก ์๋ ๋์ฒด. defaultProvider()=ordered์์ available()์ด ์ฐธ์ธ ์ฒซ ๋ฒ์งธ. CLAUDE.md์ "Gemini โ OpenAI โ Anthropic โ Perplexity ํด๋ฐฑ ์ฒด์ธ"์ด ๋ฐ๋ก ์ด ์์๋๋ก ์ฌ๋ค๋ฆฌ๋ฅผ ํ๋ ๊ฒ(์ฃผ์ ์์์ ์์กด).
โ ๏ธ ์ฃผ์: ์ด ํด๋ฐฑ์ "ํธ์ถ ์ ๊ฐ์ฉ์ฑ ๊ฒ์ฌ" ๊ธฐ๋ฐ์ ๋๋ค. ์ฆ "ํค๊ฐ ์ค์ ๋ผ ์๋"๋ง ๋ณด๊ณ ๊ณ ๋ฆ ๋๋ค. ๊ณ ๋ฅธ ํ๋ก๋ฐ์ด๋๊ฐ ํธ์ถ ์ค์ ์คํจํ๋ฉด(์: ์ผ์์ 5xx) ๋ค๋ฅธ ํ๋ก๋ฐ์ด๋๋ก ์๋ ์ฌ์๋ํ์ง๋ ์์ต๋๋ค โ
oneShot์ ์์ธ๊ฐ ๊ทธ๋๋ก ์ปจํธ๋กค๋ฌ๋ก ์ฌ๋ผ๊ฐ๋๋ค. (๊ณ ๋ํ: ๋ฐํ์ ์คํจ ์ ๋ค์ ํ๋ก๋ฐ์ด๋๋ก ์ฌ์๋.)
E-1. ๋ LLM ๋ฌถ์์ ํ๋ก๋ฐ์ด๋๋ ์ด๋ป๊ฒ ๋ค๋ฅธ๊ฐ?
service/llm/์ ํ๋ก๋ฐ์ด๋(AnthropicProvider ๋ฑ)๋ gateway ๋ฒ์ ๊ณผ ๋ณ๊ฐ ํด๋์ค์
๋๋ค. ์ฐจ์ด๋ฅผ ํ๋ก:
service/gateway/*Provider |
service/llm/*Provider |
|
|---|---|---|
| ์ธํฐํ์ด์ค | AiProvider |
LlmProvider |
| ๋ชจ๋ธ ๋ชฉ๋ก ๋ ธ์ถ | ์์(์นดํ๋ก๊ทธ DB๊ฐ ๋ด๋น) | models() ๋ฉ์๋๋ก ํ๋์ฝ๋ฉ ๋
ธ์ถ |
| ํ ํฐ ์ฌ์ฉ๋ | usage์์ ์ ํํ ํ์ฑ |
ํ์ฑ ์ ํจ(๋ฐํ ์ ํจ) |
๋ฉํฐํด chat |
์์ | ์์(oneShot๋ง) |
| ๊ธฐ๋ณธ ๋ชจ๋ธ | ์นดํ๋ก๊ทธ์ modelId ์ฌ์ฉ |
๋ฉ์๋ ๋ด ํ๋์ฝ๋ฉ(claude-sonnet-4-5 ๋ฑ) |
- ์๋ฅผ ๋ค์ด
service/llm/AnthropicProvider(L38-44)๋ ๋ชจ๋ธ ๋ชฉ๋ก์ ์ฝ๋์ ์ง์ ์ ์ด/api/llm/providers๋ก ๋ ธ์ถํฉ๋๋ค โ ์ฐ์ธก ๋ํฌ ๋ชจ๋ธ ์ ํ๊ธฐ์ฉ. service/gateway/AnthropicProvider(L97-98)๋ ์๋ต์์usage.input_tokens/output_tokens๋ฅผ ์ฝ์ด ์ ํํ ์ฟผํฐ ์ฐจ๊ฐ์ ํฉ๋๋ค.
F. ์ํฐํฐ โ ๋ฉ๋ดํ๊ณผ ๊ธฐ๋ก๋ถ (entity/)
F-1. AiModelCatalog โ ๋ชจ๋ธ ๋ฉ๋ดํ(์๊ธํ)
// AiModelCatalog.java:39-53 (ํต์ฌ ํ๋)
@Column(name = "free_quota", nullable = false) private long freeQuota; // 0์ด๋ฉด Free ์ฌ์ฉ ๋ถ๊ฐ
@Column(name = "pro_quota", nullable = false) private long proQuota; // -1์ด๋ฉด ๋ฌด์ ํ
@Column(name = "sort_order", nullable = false) private int sortOrder; // UI ์ ๋ ฌ
@Column(nullable = false) private boolean enabled; // ๋นํ์ฑ ํ ๊ธ
@Id๊ฐmodelId(๋ฌธ์์ด) ์ ๋๋ค(L22-24) โ ์ซ์ ์๋์ฆ๊ฐ๊ฐ ์๋๋ผ"gemini-2.5-flash"๊ฐ์ ๋ชจ๋ธ๋ช ์์ฒด๊ฐ ๊ธฐ๋ณธํค. ๊ทธ๋์catalogRepo.findById("claude-sonnet-4")๊ฐ ๊ฐ๋ฅ.freeQuota=0/proQuota=-1์ ๋งค์ง ๋๋ฒ ๊ท์ฝ(0=๊ธ์ง, -1=๋ฌด์ ํ)์ดAiGatewayService๊ณณ๊ณณ์if (quota == 0)ยทif (quota != -1)๋ถ๊ธฐ์ ์ง์ ์ด๋ฃน๋๋ค. ์ด ์ฝ์์ ๋ชจ๋ฅด๋ฉด ๊ฒ์ดํธ์จ์ด ์ฝ๋๊ฐ ์ ์ฝํ๋๋ค.
F-2. AiUsageLog โ ํธ์ถ ๊ธฐ๋ก๋ถ
// AiUsageLog.java:13-16 (์ธ๋ฑ์ค)
@Table(name = "AI_USAGE_LOG", indexes = {
@Index(name = "ix_aiusage_user_time", columnList = "user_id, created_at"),
@Index(name = "ix_aiusage_user_model_time", columnList = "user_id, model_id, created_at")
})
// AiUsageLog.java:53-56
@PrePersist
void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
- ๋ณตํฉ ์ธ๋ฑ์ค 2๊ฐ: ์ฟผํฐ ํฉ์ฐ ์ฟผ๋ฆฌ(
user_id + model_id + created_at)์ ์ฌ์ฉ์๋ณ ์๊ฐ์ ์กฐํ๋ฅผ ๋น ๋ฅด๊ฒ. ์ธ๋ฑ์ค๋ฅผ ๋ฏธ๋ฆฌ ๊น์๋ ๊ฒ "์ด ํ ์ด๋ธ์ ์์ฃผ ํฉ์ฐยท์กฐํ๋๋ค"๋ ์ค๊ณ ์๋๋ฅผ ๋๋ฌ๋ ๋๋ค. @PrePersist: ์ ์ฅ ์ง์ createdAt์ ์๋์ผ๋ก ํ์ฌ ์๊ฐ์ผ๋ก. ์ฃผ์ ํด๋์ค ์ค๋ช ๋๋ก "์๊ฐ ํ๋ ๊ณ์ฐ + Pro ๊ฒฐ์ ์ฒญ๊ตฌ ๊ทผ๊ฑฐ"๊ฐ ์ด ํ ์ด๋ธ์ ์กด์ฌ ์ด์ .
G. ์ปจํธ๋กค๋ฌ โ ์ ์ ๋ฐ์คํฌ (controller/)
G-1. AiController โ ๋ฉ์ธ ์ฑํ
(controller/AiController.java)
// AiController.java:27-38 (ํต์ฌ)
public ResponseEntity<AiChatResponse> chat(@RequestBody AiChatRequest request) {
Long uid = AuthContext.currentUserId();
String reply;
if (uid == null) {
reply = geminiService.chat(request); // ๋น๋ก๊ทธ์ธ โ ๊ฒ์ดํธ ์ฐํ
} else {
String model = (request.getModel() ๋น์์ผ๋ฉด) DEFAULT_MODEL : request.getModel();
reply = gateway.chat(uid, model, request); // ๋ก๊ทธ์ธ โ ๊ฒ์ดํธ์จ์ด
}
return ResponseEntity.ok(AiChatResponse.builder().reply(reply).build());
}
- ๋ถ๊ธฐ ํต์ฌ: ์ ์(
AuthContext.currentUserId())์ด null(๋น๋ก๊ทธ์ธ)์ด๋ฉดGeminiService์ง์ ํธ์ถ โ ์ฟผํฐ/๋ก๊ทธ๋ฅผ ๊ฑด๋๋๋๋ค. ๋ก๊ทธ์ธ ์ํ์์๋ง ๊ฒ์ดํธ์จ์ด(์ฟผํฐยท๋ก๊ทธ)๋ฅผ ํ๋๋ค. - โ ๏ธ ์ด ์ฐํ๋ ์๋์ด์ ์ํ์ ๋๋ค. ๋น๋ก๊ทธ์ธ ๊ฒฝ๋ก๋ ์ฟผํฐ๊ฐ ์์ด ๋ฌด์ ํ Gemini ํธ์ถ์ด ๊ฐ๋ฅ โ ์ธ์ฆ์ ๊ฐ์ ํ์ง ์์ผ๋ฉด ๋น์ฉ ํญํ์ด ๋ ์ ์์ต๋๋ค(ํจ์ ์น์ ์ฐธ๊ณ ).
- ์๋ฌ๋ฅผ 3์ข
์ผ๋ก ๊ตฌ๋ถ(L39-50)ํด ์ ์ ํ HTTP ์ํ๋ก:
HttpClientErrorExceptionโ500,IllegalState/Argument(์ฟผํฐยท์๋ชป๋ ๋ชจ๋ธ)โ400, ๊ธฐํโ500. ์ฌ์ฉ์์๊ฒ ์์ธ์ ์๋ ค์ฃผ๋ ์น์ ์ค๊ณ.
G-2. LlmController โ ์์ ์ฑํ
(controller/LlmController.java)
// LlmController.java:44-61 (์์ฝ)
public ResponseEntity<?> chat(@RequestBody Map<String, Object> body) {
String provider = (String) body.get("provider");
String model = (String) body.get("model");
String reply = router.oneShot(provider, model, system, prompt); // ๋ผ์ฐํฐ ํด๋ฐฑ ๊ฒฝ์
LlmProvider used = router.get(provider); // ์ค์ ์ฐ์ธ ํ๋ก๋ฐ์ด๋
return ResponseEntity.ok(Map.of("reply", reply, "provider", used.id(), ...));
}
- ์๋ต์ "์ค์ ๋ก ์ด๋ค ํ๋ก๋ฐ์ด๋๊ฐ ์ฐ์๋์ง"(
used.id())๋ฅผ ๋ด์ ๋๋ ค์ค๋๋ค โ ํด๋ฐฑ์ผ๋ก ๋ค๋ฅธ ํ๋ก๋ฐ์ด๋๊ฐ ์ ํ๋์ ์ ์์ผ๋, ์ฌ์ฉ์๊ฐ "๋ด๊ฐ ๊ณ ๋ฅธ ๊ฒ ์๋๋ผ ํด๋ฐฑ๋๊ตฌ๋"๋ฅผ ์ ์ ์๊ฒ. @RequestBody Map<String, Object>๋ก ๋์จํ๊ฒ ๋ฐ์ต๋๋ค(์ ์ฉ DTO ์์ด). ์์ ์ฑํ ์ด๋ผ ํ๋๊ฐ ์ ๋์ ์ด๊ธฐ ๋๋ฌธ.
H. AiRateLimitFilter (M10) โ ์
๊ตฌ ํ์ ๋ฌธ (global/config/AiRateLimitFilter.java)
H-1. M10์์ ๊ณ ์ณ์ง ์ ์ ํด์ ๋ฒ๊ทธ โ L73-L86
// AiRateLimitFilter.java:80-86
// ์ ์์ request attribute ์์ ์ง์ ์ฝ๋๋ค. AuthContext.currentUserId() ๋ RequestContextHolder ์ ์์กดํ๋๋ฐ
// ์๋ธ๋ฆฟ ํํฐ ๋จ๊ณ์์๋ (DispatcherServlet ์ง์
์ ์ด๋ผ) ์์ง ์ฑ์์ง์ง ์์ ํญ์ null โ ๋ ์ดํธ๋ฆฌ๋ฐ์ด ์ ์ ๋ฌด๋ ฅํ๋๋ ๋ฒ๊ทธ.
Object uidAttr = request.getAttribute(JwtAuthenticationFilter.ATTR_USER_ID);
Long userId = (uidAttr instanceof Long l) ? l : null;
if (userId == null) {
chain.doFilter(request, response); // ๋ฏธ์ธ์ฆ์ ํต๊ณผ(์ปจํธ๋กค๋ฌ๊ฐ 401 ์ฒ๋ฆฌ)
return;
}
- โ ๏ธ ์ด๊ฒ์ด M10 ์์ ์ ํต์ฌ์
๋๋ค. ์๋๋
AuthContext.currentUserId()๋ก ์ ์์ ์ป์ผ๋ ค ํ๋๋ฐ, ์๋ธ๋ฆฟ ํํฐ๋DispatcherServlet๋ณด๋ค ๋จผ์ ์คํ๋์ดRequestContextHolder๊ฐ ์์ง ๋น์ด ์์ด ํญ์ null์ด์์ต๋๋ค. โ ๋ชจ๋ ์ฌ์ฉ์๊ฐuserId==null๋ก ๋ถ๋ฅ๋ผ ๋ ์ดํธ๋ฆฌ๋ฐ์ด ํต์งธ๋ก ๋ฌด๋ ฅํ๋๋ ๋ฒ๊ทธ. - ์์ :
JwtAuthenticationFilter๊ฐ ๋จผ์ ์ฑ์๋ request attribute(ATTR_USER_ID = "auth.userId")๋ฅผ ์ง์ ์ฝ์ต๋๋ค. ๋ ํํฐ์ ์คํ ์์(@Order)๊ฐ ์ด ์์ ์ ์ ์ :JwtAuthenticationFilter=@Order(HIGHEST_PRECEDENCE + 10)(๋จผ์ , attribute ์ฑ์)AiRateLimitFilter=@Order(HIGHEST_PRECEDENCE + 20)(๋์ค, attribute ์ฝ์)
- (
JwtAuthenticationFilter.java:34,49์์setAttribute(ATTR_USER_ID, n.longValue())๋ก ์ฑ์ฐ๋ ๊ฒ์ ํ์ธ.)
H-2. ๋ฒํท ์๊ณ ๋ฆฌ์ฆ โ L88-L110
// AiRateLimitFilter.java:88-109
Bucket bucket = buckets.computeIfAbsent(userId, this::newBucket); // ์ ์ ๋ณ ๋ฒํท
if (bucket.tryConsume(1)) {
chain.doFilter(request, response); // ํ ํฐ ์์ผ๋ฉด ํต๊ณผ
} else {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // ์์ผ๋ฉด 429
response.getWriter().write("{\"error\":\"... 1์๊ฐ์ " + capacity + "ํ ...\"}");
}
// newBucket:
Bandwidth limit = Bandwidth.classic(capacity, Refill.greedy(refillTokens, Duration.ofMinutes(refillMinutes)));
- ํ ํฐ ๋ฒํท ์๊ณ ๋ฆฌ์ฆ: ๊ฐ ์ ์ ์๊ฒ ์๋์ด(
Bucket)๋ฅผ ์ฃผ๊ณ , ์์ฒญ๋ง๋ค ํ ํฐ 1๊ฐ ์๋น(tryConsume(1)). ๋น๋ฉด 429. ์๋์ด๋refillMinutes(60๋ถ)๋ง๋คrefillTokens(20๊ฐ)์ฉ ๋ค์ ์ฑ์์ง. - ์ ์ฉ ๋ฒ์๊ฐ ์ข๋ค๋ ์ ์ฃผ์:
shouldNotFilter(L62-71)๋ฅผ ๋ณด๋ฉดPOST /api/alpha/workspaces/{id}/{chat|formalize|briefing|auto-run}๋ง ๋์./api/ai/chatยท/api/llm/chat์ ์ด ํํฐ ๋์์ด ์๋๋๋ค(ํจ์ ์น์ ์ฐธ๊ณ ). - ํด๋์ค ์ฃผ์๋๋ก ์ธ๋ฉ๋ชจ๋ฆฌ(
ConcurrentHashMap)๋ผ ๋จ์ผ ์๋ฒ ๊ฐ์ โ ๋ค์ค ์ธ์คํด์ค๋ฉด Redis ์ฐ๋ ํ์(๊ณ ๋ํ).
โ ๏ธ ํจ์ ยท๋ณด์ ์ฃผ์
API ํค๋ ์ ๋ ํด๋ผ์ด์ธํธ๋ก ๋๊ฐ๋ฉด ์ ๋จ.
- ๋ชจ๋ ํค๋
@Value("${...api.key}")๋ก ์๋ฒ ํ๊ฒฝ๋ณ์์์๋ง ์ฃผ์ ๋๊ณ , HTTP ํธ์ถ์ ํค๋(x-api-key/Authorization)๋ URL ์ฟผ๋ฆฌ(?key=)์๋ง ์ฐ์ ๋๋ค. ์๋ต์ผ๋ก๋ ์ ๋ ๋ฐํํ์ง ์์ต๋๋ค. GeminiService์์ฑ์ ๋ก๊ทธ(L47-54)๋ ํค ๋ 4์๋ฆฌ๋ง(apiKeyTail) ์ฐ์ต๋๋ค โ ์ ์ฒด ํค๋ฅผ ๋ก๊ทธ์ ๋จ๊ธฐ์ง ์๋ ์์ ๊ด๋ก.- โ ๏ธ ๋จ, Gemini๋ URL ์ฟผ๋ฆฌ์คํธ๋ง์ ํค๋ฅผ ๋ถ์
๋๋ค(
...:generateContent?key=apiKey, L255). URL์ ์ก์ธ์ค ๋ก๊ทธยทํ๋ก์์ ๋จ์ ์ ์์ด ํค๋ ๋ฐฉ์๋ณด๋ค ๋ ธ์ถ ์ํ์ด ํฝ๋๋ค(Google API ๊ท๊ฒฉ์ ๋ถ๊ฐํผํ๋, ๋ก๊ทธ ๋ง์คํน ์ ๊ฒ ํ์).
- ๋ชจ๋ ํค๋
M10 ๋ ์ดํธ๋ฆฌ๋ฐ์ ์ ์ ํด์ โ ํํฐ ์์์ ์๋ช ์ด ๋ฌ๋ฆผ.
- ๋ ์ดํธ๋ฆฌ๋ฐ์
AuthContext๊ฐ ์๋๋ผ request attribute(JwtAuthenticationFilter.ATTR_USER_ID)๋ก ์ ์์ ์ฝ์ด์ผ ํฉ๋๋ค(H-1).@Order๋ฅผ ์๋ชป ๋ฐ๊พธ๋ฉด attribute๊ฐ ๋น์ด ๋ ์ดํธ๋ฆฌ๋ฐ์ด ๋ค์ ์ ์ ๋ฌด๋ ฅํ๋ฉ๋๋ค. ํํฐ ์์ ๋ณ๊ฒฝ ์ ๋ฐ๋์ ์ฌ๊ฒ์ฆ.
- ๋ ์ดํธ๋ฆฌ๋ฐ์
/api/ai/chatยท/api/llm/chat์ ๋ ์ดํธ๋ฆฌ๋ฐ ๋ฐ.AiRateLimitFilter๋/api/alpha/workspaces/**์ 4๊ฐ ์๋ํฌ์ธํธ๋ง ๋ง์ต๋๋ค. ๋ฉ์ธ ์ฑํ (/api/ai/chat)๊ณผ ์์ ์ฑํ (/api/llm/chat)์ ํ์ ์ ํ์ด ์์ต๋๋ค./api/ai/chat์ ๊ทธ๋๋ง ๊ฒ์ดํธ์จ์ด ์ฟผํฐ(์ ํ ํฐ)๊ฐ ๋ง์ง๋ง,/api/llm/chat์ ์ฟผํฐ๋ ๋ ์ดํธ๋ฆฌ๋ฐ๋ ์์ต๋๋ค โ ํค๋ง ์์ผ๋ฉด ๋ฌด์ ํ ํธ์ถ ๊ฐ๋ฅ. ๋น์ฉยท์ ์ฉ ๊ด์ ์์ ๊ฐ์ฅ ์ฝํ ๊ณ ๋ฆฌ.
๋น๋ก๊ทธ์ธ ๊ฒฝ๋ก์ ๊ฒ์ดํธ ์ฐํ.
AiController.chat์uid == null์ด๋ฉด ๊ฒ์ดํธ์จ์ด๋ฅผ ๊ฑด๋๋ฐ๊ณGeminiService๋ฅผ ์ง์ ๋ถ๋ฆ ๋๋ค(G-1). ์ด ์๋ํฌ์ธํธ์ ์ธ์ฆ์ด ๊ฐ์ ๋์ง ์์ผ๋ฉด ์ต๋ช ์ฌ์ฉ์๊ฐ ์ฟผํฐ ์์ด Gemini๋ฅผ ๋ฌดํ ํธ์ถํ ์ ์์ต๋๋ค. SecurityConfig์์/api/ai/**์ ์ธ์ฆ ์๊ตฌ ์ฌ๋ถ๋ฅผ ๋ฐ๋์ ํ์ธํ ๊ฒ.
Gemini ํ ํฐ์ ์ถ์ ๊ฐ โ ์ฟผํฐยท์ฒญ๊ตฌ๊ฐ ๋ถ์ ํ.
GeminiProvider.estimateTokens๋ ๊ธ์์รท3 ์ถ์ (C ์น์ ). ์ ํํusageMetadata๋ฅผ ์ ์๋๋ค. Gemini ๋ชจ๋ธ์ ์ฟผํฐ/์ฒญ๊ตฌ๋ ๋ค๋ฅธ 3์ฌ๋ณด๋ค ๋ถ์ ํํฉ๋๋ค. ๋ณด์์ (๊ณผ๋)์ด๋ผ ์ฌ์ฉ์๊ฐ ์ํด ๋ณผ ์ผ์ ๋๋ฌผ์ง๋ง, ์ ์ฐ ์ ํ๋๊ฐ ํ์ํ๋ฉด ์ค์ ๋ฉํ๋ฐ์ดํฐ ํ์ฑ ํ์.
ํด๋ฐฑ ์ ๋น์ฉยท์ผ๊ด์ฑ ์ฃผ์.
- ๋ชจ๋ธ ํด๋ฐฑ(GeminiService): 1์์ ๋ชจ๋ธ์ด 429/403์ด๋ฉด ํด๋ฐฑ ๋ชจ๋ธ๋ก ์๋ ์ ํ โ ํด๋ฐฑ ๋ชจ๋ธ์ด ๋ ๋น์ธ๊ฑฐ๋ ํ์ง์ด ๋ค๋ฅผ ์ ์์. ์ฌ์ฉ์๋ ๋ค๋ฅธ ๋ชจ๋ธ๋ก ๋ต๋ฐ์ ๊ฑธ ๋ชจ๋ฅผ ์ ์์.
- ํ๋ก๋ฐ์ด๋ ํด๋ฐฑ(LlmRouter): ํค ์๋ provider ์์ฒญ ์ ์กฐ์ฉํ default๋ก ๋์ฒด. ์๋ต์ ์ค์ provider๋ฅผ ๋ด์์ฃผ๊ธด ํ๋(G-2), ํ๋ก ํธ๊ฐ ์ด๋ฅผ ํ์ํ์ง ์์ผ๋ฉด ์ฌ์ฉ์๋ "Claude์๊ฒ ๋ฌผ์๋๋ฐ Gemini๊ฐ ๋ตํ" ์ํฉ์ ๋ชจ๋ฅผ ์ ์์.
- โ ๏ธ ๋จ, ๊ฒ์ดํธ์จ์ด(
gateway) ๋ฌถ์์ ํ๋ก๋ฐ์ด๋ ๊ฐ ํด๋ฐฑ์ด ์์ต๋๋ค. ์ง ํ๋ก๋ฐ์ด๋๊ฐ ์๊ฑฐ๋ ํค ์์ผ๋ฉด ๊ทธ๋ฅ ๊ฑฐ๋ถ โ ์ฟผํฐ ์ ํ์ฑ์ ์ํด ์๋์ ์ผ๋ก ํด๋ฐฑ์ ๋บ ์ค๊ณ.
๋ก๊ทธ ์ ์ฅ ์คํจ๋ ์ผ์ผ์ง โ ์ฟผํฐ ๋๋ฝ ๊ฐ๋ฅ์ฑ.
recordUsage๋ DB ์ ์ฅ ์คํจ๋ฅผlog.warn๋ง ํ๊ณ ๋๊น๋๋ค(B-7). ์ ์ฅ์ด ์คํจํ๋ฉด ๊ทธ ํธ์ถ์ ํ ํฐ์ด ์ฟผํฐ์ ๋ฐ์๋์ง ์์ ํ๋๋ฅผ ์ด์ง ๋๊ฒ ์ธ ์ ์์. ๋ณธ ํ๋ฆ ๋ณดํธ๋ฅผ ์ํ ํธ๋ ์ด๋์คํ.
์ธ๋ฉ๋ชจ๋ฆฌ ๋ ์ดํธ๋ฆฌ๋ฐ โ ์ํ ํ์ฅ ์ ๊นจ์ง.
- ๋ฒํท์ด
ConcurrentHashMap(JVM ๋ฉ๋ชจ๋ฆฌ)์ ์์ด, ์๋ฒ๋ฅผ 2๋๋ก ๋๋ฆฌ๋ฉด ๊ฐ ์๋ฒ๊ฐ ๋ณ๋ ์นด์ดํธ โ ์คํจ ํ๋๊ฐ 2๋ฐฐ๊ฐ ๋ฉ๋๋ค. ์ด์ ๋ค์คํ ์ Redis ๊ธฐ๋ฐ์ผ๋ก ๊ต์ฒด ํ์(ํด๋์ค ์ฃผ์์๋ ๋ช ์).
- ๋ฒํท์ด
๐ ๊ณ ๋ํ ์์ด๋์ด
- ๋ ํ๋ก๋ฐ์ด๋ ๋ฌถ์ ํตํฉ:
gateway/์llm/์ด ๊ฐ์ 4๊ฐ API๋ฅผ ์ค๋ณต ํธ์ถ.LlmProvider๋ฅผAiProvider๋ก ํก์ํ๊ฑฐ๋ ์ด๋ํฐ๋ก ๋ฌถ์ด ๋จ์ผ ํ๋ก๋ฐ์ด๋ ์ถ์ํ๋ก ์ ๋ฆฌํ๋ฉด ์ ์ง๋ณด์๊ฐ ์ ๋ฐ. - Gemini ์ ํ ํ ํฐ ์ง๊ณ:
GeminiService๊ฐ ์๋ต์usageMetadata.promptTokenCount/candidatesTokenCount๋ฅผ ํ์ฑํด ์ถ์ ๋์ ์ค๊ฐ์ ์ฐ๋๋ก โ ์ฟผํฐยท์ฒญ๊ตฌ ์ ํ๋ ํฅ์. - ๋ฐํ์ ํด๋ฐฑ ์ถ๊ฐ:
LlmRouter๊ฐ "์ ํํ provider ํธ์ถ์ด 5xx๋ก ์คํจํ๋ฉด ๋ค์ available provider๋ก ์ฌ์๋"ํ๋๋ก try-catch ์ฌ๋ค๋ฆฌ ์ถ๊ฐ(ํ์ฌ๋ ์ฌ์ ๊ฐ์ฉ์ฑ๋ง ๊ฒ์ฌ). - ๋ ์ดํธ๋ฆฌ๋ฐ์ ๊ฒ์ดํธ์จ์ด ๊ฒฝ๋ก๊น์ง ํ์ฅ:
/api/ai/chatยท/api/llm/chat๋ ํํฐ ๋์์ ํฌํจํ๊ฑฐ๋, ํฐ์ด๋ณ ํ๋(FREE 20ยทPRO 60)๋ฅผ ์ค์ ๊ตฌํ(ํ์ฌ PRO ๋ถ๊ธฐ๋ "์ถํ ํ์ฅ" ์ฃผ์๋ง). - Redis ๊ธฐ๋ฐ ๋ถ์ฐ ๋ ์ดํธ๋ฆฌ๋ฐ: Bucket4j JCache + Redis๋ก ๋ค์ค ์ธ์คํด์ค์์๋ ์ ํํ ํ๋.
- ๋น์ฉ ๋์๋ณด๋:
AiUsageLog๋ฅผ ์ง๊ณํด ์ฌ์ฉ์/๋ชจ๋ธ/์๋ณ ํ ํฐยท์คํจ์จ ์ฐจํธ โ ์ด๋ฏธ ์ธ๋ฑ์ค๊ฐ ๊น๋ ค ์์ด ์ฟผ๋ฆฌ๋ง ์ถ๊ฐํ๋ฉด ๋จ. - ์คํธ๋ฆฌ๋ฐ ์๋ต: ํ์ฌ๋ ์ ์ฒด ์๋ต์ ํ ๋ฒ์ ๋ฐ์. SSE๋ก ํ ํฐ ๋จ์ ์คํธ๋ฆฌ๋ฐํ๋ฉด ์ฒด๊ฐ ์๋ ๊ฐ์ (๋จ, ํ ํฐ ์ง๊ณ ๋ก์ง ์ฌ์ค๊ณ ํ์).
- ๋ชจ๋ธ ํด๋ฐฑ ํฌ๋ช ํ: ์๋ต์ "์ค์ ์ฌ์ฉ ๋ชจ๋ธ/ํ๋ก๋ฐ์ด๋"์ "ํด๋ฐฑ ์ฌ๋ถ"๋ฅผ ํ์ค ํ๋๋ก ํญ์ ํฌํจํด ํ๋ก ํธ๊ฐ ์ฌ์ฉ์์๊ฒ ๊ณ ์ง.
ensureUsable์ ๋ฐํ:monthStart์.withNano(0)๋ฅผ ์ถ๊ฐํ๊ณ , ์ฟผํฐ ๊ฒ์ฌ์ ์ค์ ์ฐจ๊ฐ ์ฌ์ด์ ๋ ์ด์ค ์ปจ๋์ (๋์ ์์ฒญ์ด ํ๋๋ฅผ ์ฝ๊ฐ ์ด๊ณผ)์ ๋น๊ด์ ๋ฝ์ด๋ ์์์ ์นด์ดํฐ๋ก ๋ณด์.
๐ ์ฉ์ด ์ฌ์ (์ด ํํธ ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
| LLM ํ๋ก๋ฐ์ด๋ | AI ๋ชจ๋ธ์ ๋น๋ ค์ฃผ๋ ํ์ฌ/API (GeminiยทAnthropicยทOpenAIยทPerplexity) |
| ๊ฒ์ดํธ์จ์ด(Gateway) | ์ฌ๋ฌ ํ๋ก๋ฐ์ด๋๋ฅผ ๋ฌถ์ด ์ฟผํฐยท๋ก๊ทธยท๋ผ์ฐํ
์ ์ผ๊ด ๊ด๋ฆฌํ๋ ์ค์ ์ฐฝ๊ตฌ(AiGatewayService) |
| ์ฟผํฐ(Quota) | ์๊ฐ ํ ํฐ ์ฌ์ฉ ํ๋. 0=๊ธ์ง, -1=๋ฌด์ ํ, ์์=ํ๋ |
| ๋ ์ดํธ๋ฆฌ๋ฐ(Rate Limit) | ์๊ฐ๋น ์์ฒญ ํ์ ์ ํ(20ํ/์๊ฐ). Bucket4j ํ ํฐ๋ฒํท |
| ํ ํฐ(Token) | AI๊ฐ ๊ธ์ ์ธ๋ ๋จ์. ์
๋ ฅ(tokensIn)+์ถ๋ ฅ(tokensOut) ๋ชจ๋ ๋น์ฉ |
| ํฐ์ด(Tier) | ๊ตฌ๋
๋ฑ๊ธ FREE/PRO. ๊ฐ์ ๋ชจ๋ธ๋ ํฐ์ด๋ณ ์ฟผํฐ๊ฐ ๋ค๋ฆ |
| ํ๋ก๋ฐ์ด๋ ํด๋ฐฑ | ํค ์๋ ํ์ฌ ๋์ ๋ค๋ฅธ ํ์ฌ๋ก ๋์ฒด(LlmRouter) |
| ๋ชจ๋ธ ํด๋ฐฑ | ๊ฐ์ ํ์ฌ ์์์ ๋ชจ๋ธ๋ง ๋ฐ๊ฟ(429/403 ์, GeminiService) |
| ์ด๋ํฐ ํจํด | ๊ธฐ์กด ํด๋์ค๋ฅผ ์ ์ธํฐํ์ด์ค์ ๋ง๊ฒ ๋ณํ๊ธฐ๋ก ๊ฐ์(GeminiProviderโGeminiService) |
List<์ธํฐํ์ด์ค> ์ฃผ์
|
๊ทธ ์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ ๋ชจ๋ @Component๋ฅผ Spring์ด ์๋์ผ๋ก ๋ฆฌ์คํธ๋ก ์ฃผ์
|
@Value("${ํค:๊ธฐ๋ณธ๊ฐ}") |
์ค์ ๊ฐ ์ฃผ์ , ์์ผ๋ฉด ๊ธฐ๋ณธ๊ฐ(๋น ๋ฌธ์์ด) โ ํค ์๋ ํ๋ก๋ฐ์ด๋๊ฐ ์ฑ์ ์ฃฝ์ด์ง ์๊ฒ |
| ํ ํฐ ๋ฒํท(Token Bucket) | ์๋์ด์ ํ ํฐ์ ์ฃผ๊ธฐ์ ์ผ๋ก ์ฑ์ฐ๊ณ ์์ฒญ๋ง๋ค 1๊ฐ์ฉ ๋นผ๋ ๋ ์ดํธ๋ฆฌ๋ฐ ์๊ณ ๋ฆฌ์ฆ |
| request attribute | ํ HTTP ์์ฒญ ๋์ ํํฐยท์ปจํธ๋กค๋ฌ๊ฐ ๊ณต์ ํ๋ ์์ ์ ์ฅ์. M10์์ ์ ์ ์ ๋ฌ์ ์ฌ์ฉ |
@Order (ํํฐ ์์) |
์๋ธ๋ฆฟ ํํฐ ์คํ ์์. ์ซ์ ์์์๋ก ๋จผ์ . JWT(+10) โ RateLimit(+20) |
@PrePersist |
JPA ์ํฐํฐ๊ฐ INSERT ๋๊ธฐ ์ง์ ์๋ ์คํ๋๋ ํ
(createdAt ์๋ ์ธํ
) |
| 429 / 403 | HTTP ์ํ์ฝ๋. 429=์์ฒญ ๋๋ฌด ๋ง์(ํ๋), 403=์ ๊ทผ ๊ธ์ง(๊ถํ/๋ชจ๋ธ๋ถ๊ฐ) |
| RPM / ์ผ๊ฐ ํ๋ | RPM=๋ถ๋น ์์ฒญ ์(๊ธฐ๋ค๋ฆฌ๋ฉด ํ๋ฆผ), ์ผ๊ฐ ํ๋=ํ๋ฃจ ์ด๋(๊ธฐ๋ค๋ ค๋ ์ ํ๋ฆผ) |
Result (record) |
์๋ต ํ ์คํธ + ์ ์ถ๋ ฅ ํ ํฐ์ ๋ฌถ์ ๋ถ๋ณ ๋ฐ์ดํฐ ๊ฐ์ฒด |
global/ โ ์ฑ ์ ์ฒด์ ๊น๋ฆฌ๋ ๊ณตํต ์ธํ๋ผ (๋ณด์ยท์ค์ ยทํํฐยท์๋ยท์ ํธยทํฐ๋ฏธ๋) ์์ ํด์ค
์๋ณธ:
backend/src/main/java/com/DevBridge/devbridge/global/(15๊ฐ.java)
ํ์ ํ์ค: README "3. ๊ณตํต ํ์" + ๋ชจ๋ฒ ์์01_backtest/vbt_engine.md.
Spring ๊ธฐ์ด(๋นยทDIยท์ด๋ ธํ ์ด์ )๋ ์ด ๋ฌธ์ ์์์๋ ๊ทธ๋๊ทธ๋ ์งง๊ฒ ๋ค์ ํ์ด ์ค๋ช ํฉ๋๋ค. ์์ ์ด๋ณด์ ๊ธฐ์ค.
๐ ์ด ์์ญ ํ๋์
domain/ ํด๋๋ค(userยทstrategyยทaiยทpaymentโฆ)์ด "๊ฐ ๋ถ์" ๋ผ๋ฉด, global/ ์ "๊ฑด๋ฌผ ์ ์ฒด์ ๊ณตํต์ผ๋ก ๊น๋ฆฐ ์ธํ๋ผ" ์
๋๋ค. ์ด๋ ๋ถ์๋ฅผ ๊ฐ๋ ๋๊ฐ์ด ํต๊ณผํด์ผ ํ๋ ์ ๋ฌธ ๊ฒ๋ฌธ์(๋ณด์), ์ ๊ธฐยท์๋ ๋ฐฐ์ (์ค์ ), ์ฒญ์ยท์ด๊ธฐ ๋นํ ์ธํ
(์๋ ๋ฐ์ดํฐ) ๊ฐ์ ๊ฒ๋ค์ด์ฃ .
๋น์ ๋ก ๋ณด๋ฉด:
์๋(HTTP ์์ฒญ)์ด ๊ฑด๋ฌผ์ ๋ค์ด์ด
โ
โโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1์ธต ์ ๋ฌธ ๊ฒ๋ฌธ์ = security/ โ
โ - ์ ๋ถ์ฆ(JWT ์ฟ ํค) ํ์ธ โ ๋๊ตฌ์ธ์ง ๋ช
์ฐฐ ๋ถ์ฐฉ โ JwtAuthenticationFilter
โ - ๊ธ๊ณ ์ด์ ์ํธํ โ AesGcmCryptoService
โโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ ํตํ๋ ์ ํ ๊ฒ์ดํธ = config/AiRateLimitFilter โ ๋น์ผ AI ๋ฐฉ์ ์๊ฐ๋น N๋ช
๋ง
โโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ ๊ฐ ๋ถ์(controller) ๋ก ์๋ด โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๊ฑด๋ฌผ์ ์ฒ์ ์ด ๋(์ฑ ๊ธฐ๋):
seed/ = ์ด๊ธฐ ๋นํยท์ํ ๋ฐ์ดํฐ๋ฅผ ๋ฏธ๋ฆฌ ์ฑ์๋ (์ด๋ฏธ ์์ผ๋ฉด ๊ฑด๋๋=๋ฉฑ๋ฑ)
config/ = CORSยท๋น๋ฐ๋ฒํธ ํด์๊ธฐยทStream ์ฑํ
ยทJackson ๋ธ๋ฆฟ์ง ๊ฐ์ ๋ฐฐ์ ์ค์น
util/ = ์์ฃผ ์ฐ๋ ๋ณํ ๋๊ตฌ(EnumMapper)
terminal/ = (๋ก์ปฌ ๊ฐ๋ฐ ์ ์ฉ) ์น์์ ์
ธ์ ์ฌ๋ ์ฝ์ผํธํต์ฌ ํด๋์ค ์ญํ ํ
| ํด๋ | ํด๋์ค | ํ ์ค ์ญํ | ๋น์ |
|---|---|---|---|
security |
JwtUtil | JWT ํ ํฐ ๋ฐํ/๊ฒ์ฆ (HS256) | ๋ช ์ฐฐ์ ๋ง๋ค๊ณ , ์์กฐ ๋ช ์ฐฐ์ ๊ฐ๋ ค๋ด๋ ๊ธฐ๊ณ |
security |
JwtAuthenticationFilter | ๋ชจ๋ ์์ฒญ์์ ํ ํฐ ๊บผ๋ด ๊ฒ์ฆ โ request.attribute ์ userId ๋ถ์ฐฉ |
์ ๋ฌธ์์ ์ ๋ถ์ฆ ํ์ธ ํ ์๋ชฉ๋ฐด๋ ์ฑ์ |
security |
AuthContext | ์ปจํธ๋กค๋ฌ/์๋น์ค์์ "์ง๊ธ ๋๊ตฌ?" ๋ฅผ ๊บผ๋ด๋ ํฌํผ | ์๋ชฉ๋ฐด๋๋ฅผ ์ฝ์ด์ฃผ๋ ๋ฆฌ๋๊ธฐ |
security |
AesGcmCryptoService | ๋ฏผ๊ฐ ํ ํฐ(GitHub PAT ๋ฑ)์ DB ์ ์ฅ ์ AES-GCM ์ํธํ | ๊ท์คํ ๊ธ๊ณ |
config |
AiRateLimitFilter | ๋น์ผ AI ์๋ํฌ์ธํธ ์ฌ์ฉ์๋น ์๊ฐ๋น 20ํ ์ ํ (Bucket4j) | ๋์ด๊ธฐ๊ตฌ 1์๊ฐ Nํ ์๋ชฉํฐ์ผ |
config |
PasswordConfig | BCrypt PasswordEncoder ๋น 1๊ฐ ์ ๊ณต |
๋น๋ฐ๋ฒํธ ์ผ๋ฐฉํฅ ๋ถ์๊ธฐ |
config |
WebConfig | ์ ์ญ CORS ๊ท์น + ์ ๋ก๋ ํ์ผ ์ ์ ์๋น | ์ธ๋ถ ์ถ์ ํ๊ฐ ๋ช ๋จ + ์๋ฃ์ค ๊ณต๊ฐ ์ ๋ฐ |
config |
StreamChatConfig | Stream Chat SDK ์ด๊ธฐํ(ํค/์ํฌ๋ฆฟ ์ฃผ์ ) | ์ธ๋ถ ์ฑํ ์ ์ฒด ๊ณ์ ์ฐ๊ฒฐ |
config |
Jackson2NodeBridgeConfig | Boot4(Jackson3) ํ๊ฒฝ์์ ์ Jackson2 JsonNode ์ง๋ ฌํ ๊นจ์ง ์์ |
๊ตฌํ ํ๋ฌ๊ทธ๋ฅผ ์ ํ ์ฝ์ผํธ์ ๊ฝ๋ ์ด๋ํฐ |
seed |
DataSeeder | ๊ธฐ๋ ์ mock JSON โ DB ์ ์ฌ + ๋ฐ์ดํฐ ๋ง์ด๊ทธ๋ ์ด์ /์ ๋ฆฌ ์ค์ผ์คํธ๋ ์ด์ | ๊ฐ๊ด ์ ๋งค์ฅ ์ง์ดยท์ฌ๊ณ ์ ๋ฆฌ |
seed |
DataCleanupService | "์์ ํ" ํ๋ก์ ํธ/ํด๋ผ์ด์ธํธ 50๊ฐ๋ง ๋จ๊ธฐ๊ณ ์ ๋ฆฌ | ๋ถ๋ ์ง์ด ์ํ ์์๋ด๊ธฐ |
seed |
AiModelCatalogSeeder | AI ๋ชจ๋ธ ์นดํ๋ก๊ทธ(Gemini/Claude/GPT) 6์ข ์๋ | ๋ฉ๋ดํ ์ด๊ธฐ ๋ฑ๋ก |
util |
EnumMapper | JSON ์๋ฌธ์ ๋ฌธ์์ด โ ๋ฐฑ์๋ UPPERCASE enum ๋ณํ | ์ธ๊ตญ์ด ๋ฉ๋ด๋ฅผ ์ฐ๋ฆฌ ์ฝ๋๋ก ๋ฒ์ญ |
terminal |
TerminalWebSocketHandler | (๋ก์ปฌยทloopback ์ ์ฉ) WS ๋ก ์ ธ ํ๋ก์ธ์ค stdin/stdout ์ค๊ณ | ๊ฐ๋ฐ์ฉ ์ฝ์ผํธ(์ธ๋ถ์ ์ฐจ๋จ) |
terminal |
TerminalWebSocketConfig | app.terminal.enabled=true ์ผ ๋๋ง /ws/terminal ๋ฑ๋ก |
์ฝ์ผํธ ์ฐจ๋จ๊ธฐ ์ค์์น |
๋๊ฐ ์ด๊ฑธ ์ฐ๋? โ ๊ฑฐ์ ๋ชจ๋ ๋๋ฉ์ธ. ์)
user์ ๋ก๊ทธ์ธ์JwtUtil๋ก ํ ํฐ์ ๋ง๋ค๊ณ ,strategy์ ์ปจํธ๋กค๋ฌ๋AuthContext.requireUserId()๋ก "์ง๊ธ ๋ก๊ทธ์ธํ ์ฌ๋"์ ์์๋ ๋๋ค. AI ์ฑํ ์AiRateLimitFilter๋ฅผ ํต๊ณผํด์ผ๋ง LLM ์ ํธ์ถํฉ๋๋ค.
๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
1) ๋น(Bean)ยทDIยท@Component/@Configuration/@Bean
- ๋น(Bean) = Spring ์ด ์ง์ ๋ง๋ค์ด ๋ณด๊ดํ๋ "์ฌ์ฌ์ฉ ๊ฐ์ฒด". ์ฐ๋ฆฌ๊ฐ
newํ์ง ์๊ณ Spring ์ด ๋ง๋ค์ด์ ํ์ํ ๊ณณ์ ๋ผ์์ค๋๋ค(= ์์กด์ฑ ์ฃผ์ , DI). @Component/@Service๋ฅผ ํด๋์ค์ ๋ถ์ด๋ฉด "์ด ํด๋์ค๋ฅผ ๋น์ผ๋ก ๋ฑ๋กํด" ๋ผ๋ ๋ป. (@Service๋ ์๋ฏธ๋ง ๋ค๋ฅธ@Component.)@Configurationํด๋์ค ์์@Bean๋ฉ์๋๋ "์ด ๋ฉ์๋ ๋ฐํ๊ฐ์ ๋น์ผ๋ก ๋ฑ๋กํด". โPasswordConfig๊ฐ ์ด ๋ฐฉ์.@RequiredArgsConstructor(Lombok) =finalํ๋๋ฅผ ๋ฐ๋ ์์ฑ์๋ฅผ ์๋ ์์ฑ โ ์์ฑ์ DI ๋ฅผ ์งง๊ฒ.
2) ์๋ธ๋ฆฟ ํํฐ(Servlet Filter) = "์ปจํธ๋กค๋ฌ ๋์ฐฉ ์ ์ ๋ชจ๋ ์์ฒญ์ด ๊ฑฐ์น๋ ๊ด๋ฌธ"
์์ฒญ โ [ํํฐ1] โ [ํํฐ2] โ ... โ DispatcherServlet โ @Controller- ํํฐ๋ ์์ฒญ์ ๊ฐ๋ก์ฑ์ ํต๊ณผ์ํค๊ฑฐ๋(
chain.doFilter(...)) ๋ง์ ์ ์์ต๋๋ค(์๋ต ์ง์ ์์ฑ). OncePerRequestFilter= "ํ ์์ฒญ์ ๋ฑ ํ ๋ฒ๋ง ์คํ"์ ๋ณด์ฅํ๋ Spring ์ ํํฐ ๋ฒ ์ด์ค ํด๋์ค. (ํฌ์๋ฉ ๋ฑ์ผ๋ก ์ค๋ณต ์คํ๋๋ ๊ฑธ ๋ง์์ค.)- ํต์ฌ: ํํฐ๋ ์ปจํธ๋กค๋ฌ๋ณด๋ค ๋จผ์ ๋๋๋ค. ๊ทธ๋์ ์ธ์ฆยท๋ ์ดํธ๋ฆฌ๋ฐ ๊ฐ์ "๊ณตํต ์ฌ์ ๊ฒ์ฌ"์ ๋ฑ์ ๋๋ค.
3) ํํฐ ์์ @Order โ ์ซ์๊ฐ ์์์๋ก ๋จผ์
- ํํฐ๊ฐ ์ฌ๋ฌ ๊ฐ๋ฉด ์ด๋ ๊ฒ ๋จผ์ ๋๋์ง๊ฐ ์ค์ํฉ๋๋ค.
@Order(๋ฎ์ ์)๊ฐ ๋จผ์ . Ordered.HIGHEST_PRECEDENCE= ๊ฐ์ฅ ๋์ ์ฐ์ ์์(์์ฃผ ์์ ์์). ๊ฑฐ๊ธฐ์+10,+20์ ๋ํด ์๋ ์์๋ฅผ ๋ง๋ญ๋๋ค.- ์ด ํ๋ก์ ํธ:
JwtAuthenticationFilter=HIGHEST_PRECEDENCE + 10โ ๋จผ์ AiRateLimitFilter=HIGHEST_PRECEDENCE + 20โ ๋์ค- ์ ์ด ์์๊ฐ ๋ชฉ์จ์ธ์ง๋ ์๋ "M10 ํจ์ "์์ ๋ค๋ฃน๋๋ค.
4) request attribute vs RequestContextHolder
- request attribute = ์์ฒญ ๊ฐ์ฒด์ ์์๋ก ๋ถ์ด๋ ๋ฉ๋ชจ์ชฝ์ง(
request.setAttribute("ํค", ๊ฐ)/getAttribute("ํค")). ๊ทธ ์์ฒญ์ด ๋๋๋ฉด ์ฌ๋ผ์ง. - RequestContextHolder = "์ง๊ธ ์ด ์ค๋ ๋๊ฐ ์ฒ๋ฆฌ ์ค์ธ ์์ฒญ"์ ์ด๋์๋ ๊บผ๋ด๊ฒ ํด์ฃผ๋ Spring ์ ์ ์ญ ๋ณด๊ดํจ. ๋จ, DispatcherServlet ์ด ์ฑ์์ค๋๋ค. โ ํํฐ ๋จ๊ณ(๋์คํจ์ฒ ์ง์ ์ )์์ ์์ง ๋น์ด ์์ ์ ์์. (์ด๊ฒ M10 ๋ฒ๊ทธ์ ๋ฟ๋ฆฌ.)
5) CORS = "๋ธ๋ผ์ฐ์ ๊ฐ ๋ค๋ฅธ ์ถ์ฒ(origin) ํธ์ถ์ ๋ง๋ ๊ท์น"
- ํ๋ก ํธ(
http://localhost:5173)๊ฐ ๋ฐฑ์๋(http://localhost:8080)๋ฅผ ๋ถ๋ฅด๋ฉด ์ถ์ฒ๊ฐ ๋ค๋ฆ ๋๋ค(ํฌํธ ๋ค๋ฆ). ๋ธ๋ผ์ฐ์ ๋ ๊ธฐ๋ณธ์ ์ผ๋ก ์ด๊ฑธ ๋ง์ต๋๋ค. - ์๋ฒ๊ฐ "์ด ์ถ์ฒ๋ ํ์ฉํ๋ค(
Access-Control-Allow-Origin)" ๊ณ ์๋ต ํค๋๋ก ์๋ ค์ค์ผ ํต๊ณผ.WebConfig.addCorsMappings๊ฐ ๊ทธ ํ์ฉ ๋ชฉ๋ก. allowCredentials(true)= ์ฟ ํค๋ฅผ ์ค์ด ๋ณด๋ด๋ ๊ฑธ ํ์ฉ(์ฐ๋ฆฌ๋ JWT ๋ฅผ ์ฟ ํค๋ก ๋ณด๋). ์ด๋allowedOrigins์*๋ฅผ ์ฐ๋ฉด ์ ๋จ(๋ธ๋ผ์ฐ์ ๊ฐ ๊ฑฐ๋ถ). ๊ทธ๋์ ๋ช ์์ ์ถ์ฒ ๋ชฉ๋ก์ ์๋๋ค.
6) ์๋(Seeder) ๋ฉฑ๋ฑ(idempotent) = "์ฌ๋ฌ ๋ฒ ๋๋ ค๋ ๊ฒฐ๊ณผ๊ฐ ๊ฐ๋ค"
- ์ฑ์ ์ผค ๋๋ง๋ค ์๋๊ฐ ๋๋๋ค. ๋ง์ฝ ๋งค๋ฒ ๋ ๋ฃ์ผ๋ฉด ๋ฐ์ดํฐ๊ฐ N๋ฐฐ๋ก ์ค๋ณต๋ฉ๋๋ค.
- ๊ทธ๋์ ์๋๋ ๋ณดํต
if (repository.count() > 0) return;์ผ๋ก ์ด๋ฏธ ์์ผ๋ฉด ๊ฑด๋๋. ์ด๊ฒ ๋ฉฑ๋ฑ์ ํต์ฌ ํจํด.
7) @Profile("!prod") = "์ด์ ํ๊ฒฝ์ด ์๋ ๋๋ง ์ด ๋น ํ์ฑํ"
- ์คํ๋ง ํ๋กํ(
local/prod)์ ๋ฐ๋ผ ๋น์ ์ผ๊ณ ๋๋๋ค.!prod= "prod ๊ฐ ์๋ ๋". โ mock ์๋๋ ์ด์ DB ๋ฅผ ๋๋ฝํ๋ฉด ์ ๋๋ ์ด์์์ ๋.
8) AES-GCMยทBCrypt โ ๋ ์ข ๋ฅ์ "์งํค๊ธฐ"๋ ๋ค๋ฅด๋ค
- ์ํธํ(AES-GCM) = ์ ๊ฐ๋ค ๋ค์ ํ ์ ์์(์๋ฐฉํฅ). ์ธ๋ถ API ํค์ฒ๋ผ "๋์ค์ ์๋ณธ์ด ํ์ํ" ๋น๋ฐ์ ์.
- ํด์ฑ(BCrypt) = ๋ถ์ํด์ ๋๋๋ฆด ์ ์์(๋จ๋ฐฉํฅ). ๋น๋ฐ๋ฒํธ์ฒ๋ผ "๋ง๋์ง ๋น๊ต๋ง ํ๋ฉด ๋๋" ๋น๋ฐ์ ์. (๋ก๊ทธ์ธ ์ ์ ๋ ฅ๊ฐ์ ๊ฐ์ ๋ฐฉ์์ผ๋ก ๋ถ์ํด ์ ์ฅ๋ณธ๊ณผ ๋์กฐ.)
๐บ ์์ฒญ ํ๋ฆ๋
HTTP ์์ฒญ (๋ธ๋ผ์ฐ์ ์์ ์ด, ์ฟ ํค DEVBRIDGE_TOKEN ๋๋ด)
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ์๋ธ๋ฆฟ ํํฐ ์ฒด์ธ (์ปจํธ๋กค๋ฌ ๋์ฐฉ ์ , @Order ์์๋๋ก) โ
โ โ
โ โ JwtAuthenticationFilter @Order(HIGHEST + 10) โ
โ - ์ฟ ํค DEVBRIDGE_TOKEN (1์์) / Bearer ํค๋(2์์) ์ถ์ถ โ
โ - JwtUtil.parse() ๋ก ๊ฒ์ฆ โ
โ - ์ฑ๊ณต โ request.setAttribute("auth.userId", 12) โ โโโ ๋ช
์ฐฐ ๋ถ์ฐฉ
โ - ์คํจ/์์ โ ๊ทธ๋ฅ ํต๊ณผ (์ต๋ช
, attribute ์ ์ฑ์) โ
โ โ โ
โ โผ โ
โ โก AiRateLimitFilter @Order(HIGHEST + 20) โ
โ - shouldNotFilter(): POST + /chatยท/formalizeยทโฆ๋ง ๋์ โ
โ - request.getAttribute("auth.userId") ๋ก ์ ์ ์ง๋
โ
โ โโโ โ ์ด ์ฑ์ด ๋ช
์ฐฐ์ ์ฝ์
โ - ์ฌ์ฉ์๋ณ Bucket ํ ํฐ 1๊ฐ ์๋น โ ์์ผ๋ฉด 429 ์ฆ์ ์๋ต โ
โ โ (ํต๊ณผ ์) โ
โโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โผ
DispatcherServlet โ ์ฌ๊ธฐ์ RequestContextHolder ์ฑ์์ง
โผ
@Controller / @Service
โ
AuthContext.requireUserId() โ RequestContextHolderโrequest attribute ์ฝ์
โผ
๋น์ฆ๋์ค ๋ก์ง ์คํ โ ์๋ต
โ
(analytics ํ๋ก์๋ฉด) Jackson2NodeBridge ๊ฐ JsonNode ๋ฅผ ์ฌ๋ฐ๋ฅธ JSON ์ผ๋ก ์ง๋ ฌํ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
์ฑ ๊ธฐ๋(1ํ): CommandLineRunner ๋ค์ด ์์๋๋ก ์คํ
AiModelCatalogSeeder(@Order 100) ยท DataSeeder(@Profile !prod)
โ seedXxx() ๋ค (count>0 ์ด๋ฉด skip) โ ๋ง์ด๊ทธ๋ ์ด์
โ DataCleanupService ์ ๋ฆฌ
โ ํ์ ์ค(
request.getAttribute์ง๋ )์ด ์ด ๋ฌธ์์์ ๊ฐ์ฅ ์ค์ํ ์ง์ ์ ๋๋ค. ์AuthContext๋ฅผ ์ ์ฐ๊ณ attribute ๋ฅผ ์ง์ ์ฝ๋๊ฐ โ M10 ํจ์ ์์ ์์ธํ.
๐ ํต์ฌ ํด๋์ค ๋ผ์ธ๋ณ ์ฌํ
A. JwtUtil โ ๋ช
์ฐฐ(ํ ํฐ) ๋ฐ๊ธ๊ธฐ/๊ฒ์ฆ๊ธฐ (security/JwtUtil.java)
ํค ์ค๋น โ JwtUtil.java:25-38
// L25-L38
public JwtUtil(
@Value("${app.jwt.secret:dev-bridge-default-secret-key-change-in-production-please}") String secret,
@Value("${app.jwt.ttl-hours:1}") long ttlHours
) {
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
// 32๋ฐ์ดํธ ๋ฏธ๋ง์ด๋ฉด ํจ๋ฉ (๊ฐ๋ฐ ํธ์)
byte[] padded = new byte[32];
System.arraycopy(bytes, 0, padded, 0, bytes.length);
bytes = padded;
}
this.key = Keys.hmacShaKeyFor(bytes);
this.ttlMillis = ttlHours * 60 * 60 * 1000L;
}
@Value("${app.jwt.secret:๊ธฐ๋ณธ๊ฐ}")โ ์ค์ ํ์ผ/ํ๊ฒฝ๋ณ์์app.jwt.secret๊ฐ์ ์ฃผ์ .:๋ค๋ ์์ ๋ ์ธ ๊ธฐ๋ณธ๊ฐ. (์ด ๊ธฐ๋ณธ๊ฐ์ ๊ฐ๋ฐ์ฉ์ด๋ฉฐ ์ด์์์ ๋ฐ๋์ ํ๊ฒฝ๋ณ์๋ก ๋ฎ์ด์จ์ผ ํจ โ ๋ฌธ์์ด์๋ "change-in-production"์ด๋ผ ๋ฐํ ์์.)- HS256(HMAC-SHA256) ์ ๋์นญํค ์๋ช โ ํค๊ฐ ์ต์ 32๋ฐ์ดํธ์ฌ์ผ ํจ. ๊ทธ๋์ ์งง์ผ๋ฉด 0์ผ๋ก ํจ๋ฉํด ๊ธธ์ด๋ฅผ ๋ง์ถฅ๋๋ค. (ํธ์ ๊ธฐ๋ฅ์ด์ง๋ง, ์ง์ง ์งง์ ๋น๋ฐํค๋ ๋ณด์์ ์ฝํ๋ ์ด์์์ ์ถฉ๋ถํ ๊ธด ๋น๋ฐํค๋ฅผ ์ค์ผ ํจ.)
ttlMillisโ ํ ํฐ ์ ํจ์๊ฐ(๋ฐ๋ฆฌ์ด). ๊ธฐ๋ณธ 1์๊ฐ(ttl-hours:1).
๐ก ์ด๋ณด ํฌ์ธํธ: ๋์นญํค(HS256) = ๊ฐ์ ๋น๋ฐํค๋ก ์๋ช ๋ ํ๊ณ ๊ฒ์ฆ๋ ํจ. ๊ทธ๋์ ์ด ๋น๋ฐํค๊ฐ ์๋ฉด ๋๊ตฌ๋ ์์กฐ ํ ํฐ์ ๋ง๋ค ์ ์์ โ
JWT_SECRET์ 1๊ธ ๋น๋ฐ.
ํ ํฐ ๋ฐํ issue() โ JwtUtil.java:46-58
// L46-L58
public String issue(Long userId, String email, String userType) {
Date now = new Date();
return Jwts.builder()
.subject(email)
.claims(Map.of("uid", userId, "type", userType))
.issuedAt(now)
.expiration(new Date(now.getTime() + ttlMillis))
.signWith(key)
.compact();
}
- JWT ๋
ํค๋.ํ์ด๋ก๋.์๋ช3ํ ๋ง์ ๋ฌธ์์ด. ์ฌ๊ธฐ์ ํ์ด๋ก๋์ claims(์ฃผ์ฅ) ๋ฅผ ๋ด์ต๋๋ค:subject(email)= ํ์ค ํ๋sub์ ์ด๋ฉ์ผ.uid= users.id(PK) โ ์ด๊ฒ ๊ฐ์ฅ ์ค์. ๋์ค์ ํํฐ/AuthContext ๊ฐ ์ด ๊ฐ์ผ๋ก "๋๊ตฌ"๋ฅผ ์๋ณ.type= "FREE"/"STANDARD"/"PREMIUM" ๊ฐ์ ๋ฑ๊ธ(์ฃผ์์ PARTNER/CLIENT/ADMIN ์ผ๋ก ์ ํ ์์ผ๋, ์ค์ ๊ฐ์ ํธ์ถ๋ถ๊ฐ ์ ํจ).
signWith(key)= ์ ๋น๋ฐํค๋ก ์๋ช โ ๋ด์ฉ์ ๋ฐ๊พธ๋ฉด ์๋ช ์ด ๊นจ์ ธ ์์กฐ๊ฐ ํ๋ก๋จ..compact()= ์ต์ข ๋ฌธ์์ด๋ก ์ง๋ ฌํ.
โ ๏ธ ์ฃผ์: JWT ์ ํ์ด๋ก๋๋ ์ํธํ๊ฐ ์๋๋ผ ์ธ์ฝ๋ฉ(Base64url)์ผ ๋ฟ์ ๋๋ค. ๋๊ตฌ๋ ๋์ฝ๋ํด ๋ด์ฉ์ ๋ณผ ์ ์์ด์. "์์กฐ ๋ฐฉ์ง(์๋ช )"๋ ๋์ง๋ง "๋น๋ฐ ์ ์ง"๋ ์ ๋จ โ ๋ฏผ๊ฐ์ ๋ณด(๋น๋ฐ๋ฒํธ ๋ฑ)๋ฅผ claims ์ ๋ฃ์ง ๋ง ๊ฒ.
๊ฒ์ฆ parse() / ์ถ์ถ โ JwtUtil.java:61-73
// L61-L73
public Claims parse(String token) {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).getPayload();
}
public Long extractUserId(String token) {
Object uid = parse(token).get("uid");
if (uid instanceof Number n) return n.longValue();
return null;
}
verifyWith(key)โ ์๋ช ยท๋ง๋ฃ ๊ฒ์ฆ. ์์กฐ๊ฑฐ๋ ๋ง๋ฃ๋ฉด ์์ธ๊ฐ ํฐ์ง๋๋ค(๊ทธ๋์ ํธ์ถ๋ถ๊ฐ try/catch ๋ก ๊ฐ์).extractUserIdโ claims ์uid๋ฅผ ๊บผ๋. JWT ์ ์ซ์๋ ๋ณดํตInteger/Long์ผ๋ก ์ญ์ง๋ ฌํ๋๋ฏ๋กNumber๋ก ๋ฐ์longValue()๋ก ํต์ผ. (์ด "Number ๋ก ๋ฐ์ longValue" ํจํด์ด ํํฐยทAuthContext ์์ ๋ฐ๋ณต๋ฉ๋๋ค.)
B. JwtAuthenticationFilter โ ์ ๋ฌธ ๊ฒ๋ฌธ์ (security/JwtAuthenticationFilter.java)
ํด๋์ค ์ ์ธ + ํํฐ ์์ โ JwtAuthenticationFilter.java:28-37
// L28-L37
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10) // ๋ฐ๋์ AiRateLimitFilter(+20)๋ณด๋ค ๋จผ์ ์คํ โ ๊ทธ๋์ผ ์์ฒญ attribute(userId)๊ฐ ์ฑ์์ง๋ค
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
public static final String ATTR_USER_ID = "auth.userId";
public static final String ATTR_USER_TYPE = "auth.userType";
private final JwtUtil jwtUtil;
@Componentโ ๋น ๋ฑ๋ก(Boot ๊ฐ ์๋์ผ๋ก ํํฐ ์ฒด์ธ์ ๋ผ์์ค).extends OncePerRequestFilterโ ์์ฒญ๋น 1ํ.@Order(HIGHEST + 10)โ ์ฒด์ธ์์ ๋จผ์ ์คํ (์ฌ์ ์ง์ 3๋ฒ). ์ฃผ์์ด ์ด์ ๋ฅผ ์ง์ ๋ช ์: ๋ค์ ์ค๋ ๋ ์ดํธ๋ฆฌ๋ฐ์ด attribute(userId)๋ฅผ ์ฝ์ผ๋ ค๋ฉด ์ฌ๊ธฐ๊ฐ ๋จผ์ ์ฑ์์ค์ผ ํจ.ATTR_USER_ID,ATTR_USER_TYPE= attribute ํค ์ด๋ฆ์ ์์๋ก ๊ณต๊ฐ โAuthContextยทAiRateLimitFilter๊ฐ ๊ฐ์ ํค๋ฅผ ๊ณต์ (์คํ ๋ฐฉ์ง).
ํต์ฌ ๋ก์ง doFilterInternal() โ JwtAuthenticationFilter.java:39-60
// L39-L60
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) {
String token = extractToken(request);
if (token != null && !token.isBlank()) {
try {
var claims = jwtUtil.parse(token);
Object uid = claims.get("uid");
Object type = claims.get("type");
if (uid instanceof Number n) request.setAttribute(ATTR_USER_ID, n.longValue());
if (type != null) request.setAttribute(ATTR_USER_TYPE, type.toString());
} catch (Exception e) {
// ํ ํฐ ํ์ฑ ์คํจ: ์ต๋ช
์์ฒญ์ผ๋ก ํต๊ณผ
log.debug("JWT parse failed: {}", e.getMessage());
}
}
chain.doFilter(request, response); // โ ์ฑ๊ณต์ด๋ ์คํจ๋ ํญ์ ๋ค์์ผ๋ก ๋๊น
}
- ํ๋ฆ: ํ ํฐ ์ถ์ถ โ ์์ผ๋ฉด ๊ฒ์ฆ โ ์ฑ๊ณตํ๋ฉด request ์ userId/userType ๋ช ์ฐฐ์ ๋ถ์.
- ์ค์ํ ์ค๊ณ ๊ฒฐ์ : ๊ฒ์ฆ ์คํจํด๋ ์์ฒญ์ ๋ง์ง ์์ต๋๋ค(
chain.doFilterํญ์ ํธ์ถ). "์ต๋ช ์ผ๋ก ํต๊ณผ"์ํค๊ณ , ์ธ์ฆ์ด ๊ผญ ํ์ํ ์ปจํธ๋กค๋ฌ๊ฐ ์์์ attribute ๋ฅผ ํ์ธ(AuthContext.requireUserId()๊ฐ ์์ผ๋ฉด 401)ํ๋ ๊ตฌ์กฐ. โ "ํํฐ๋ ๋ช ์ฐฐ๋ง ๋ถ์ด๊ณ , ์ ์ฅ ๊ฑฐ๋ถ๋ ๊ฐ ๋ฐฉ(์ปจํธ๋กค๋ฌ)์ด ๊ฒฐ์ ". n.longValue()โ JWT ์ซ์(๋ณดํต Integer)๋ฅผ Long ์ผ๋ก ํต์ผํด ์ ์ฅ. ์ด ํ์ ํต์ผ์ด ๋ค์์ ํจ์ ์ด ๋จ(AuthContext ์instanceof Long์บ์คํ ๊ณผ ์ง).
ํ ํฐ ์ถ์ถ ์ฐ์ ์์ extractToken() โ JwtAuthenticationFilter.java:62-79
// L62-L79
private String extractToken(HttpServletRequest request) {
// 1์์: HttpOnly ์ฟ ํค
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if (AuthController.AUTH_COOKIE_NAME.equals(c.getName())) { // "DEVBRIDGE_TOKEN"
String v = c.getValue();
if (v != null && !v.isBlank()) return v.trim();
}
}
}
// 2์์: Authorization ํค๋ (๋ ๊ฑฐ์ ํธํ)
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) return header.substring(7).trim();
return null;
}
- 1์์ = HttpOnly ์ฟ ํค
DEVBRIDGE_TOKEN. JS ๊ฐ ๋ชป ์ฝ๋ ์ฟ ํค๋ผ XSS ํ ํฐ ํ์ทจ ๋ฐฉ์ง(CLAUDE.md ๋ณด์ ์ค๊ณ์ ์ผ์น). - 2์์ =
Authorization: Bearer <token>ํค๋ โ ์ ํด๋ผ์ด์ธํธ/๋๊ตฌ ํธํ์ฉ.substring(7)์"Bearer "(7๊ธ์) ๋ค๋ฅผ ์๋ฆ. - ์ฟ ํค ์ด๋ฆ์
AuthController.AUTH_COOKIE_NAME์์๋ฅผ ๊ทธ๋๋ก ์ฐธ์กฐ โ ๋ฐ๊ธ(๋ก๊ทธ์ธ)๊ณผ ๊ฒ์ฆ์ด ๊ฐ์ ์ด๋ฆ์ ์ฐ๋๋ก ๋จ์ผ ์ถ์ฒํ.
๐ก ์ฟ ํค ๋ฐ๊ธ ์ชฝ(
AuthController.buildAuthCookie)์httpOnly(true),secure(์ด์ true),sameSite("Lax"),path("/"),maxAge=ttl๋ก ๋ง๋ญ๋๋ค. ์ฆ HttpOnly+Secure(prod)+SameSite=Lax ๊ฐ ์ค์ ์ฝ๋๋ก ํ์ธ๋ฉ๋๋ค.
C. AuthContext โ "์ง๊ธ ๋๊ตฌ?" ๋ฆฌ๋๊ธฐ (โ ๏ธ ํํฐ์์ ์ฐ๋ฉด ์ ๋๋ ํจ์ ) (security/AuthContext.java)
ํด๋์ค + ์ค๋ฒ๋ผ์ด๋ ์ฌ๋กฏ โ AuthContext.java:11-25
// L11-L25
public final class AuthContext {
private AuthContext() {} // ์ธ์คํด์คํ ๊ธ์ง(์ ์ ์ ํธ)
/** ๋ฐฑ๊ทธ๋ผ์ด๋ ์ก(์ค์ผ์ค๋ฌ ๋ฑ) HTTP ์ปจํ
์คํธ ๋ถ์ฌ ์ ์์ ์ฌ์ฉ์ ์ฃผ์
์ฉ */
private static final ThreadLocal<Long> OVERRIDE_USER_ID = new ThreadLocal<>();
public static void set(Long userId) { if (userId != null) OVERRIDE_USER_ID.set(userId); }
public static void clear() { OVERRIDE_USER_ID.remove(); }
finalํด๋์ค +private์์ฑ์ = "์ธ์คํด์ค ๋ง๋ค์ง ๋ง๊ณ ์ ์ ๋ฉ์๋๋ง ์จ๋ผ". (์ ํธ ํด๋์ค ๊ด์ฉ.)ThreadLocal<Long> OVERRIDE_USER_IDโ ์ค๋ ๋ ์ ์ฉ ๋ณ์. HTTP ์์ฒญ์ด ์๋ ๊ณณ(์ค์ผ์ค๋ฌยท๋ฐฐ์น ์ก)์๋ "ํ์ฌ ์ฌ์ฉ์"๋ผ๋ ๊ฐ๋ ์ด ์์ผ๋, ๊ทธ๋set(userId)๋ก ์ด ์ค๋ ๋์์๋ง ์์ ์ ์์ ์ฌ์ต๋๋ค.- ๋ฐ๋์
finally { clear() }๋ก ์ง์์ผ ํจ(์ฃผ์ ๊ฐ์กฐ). ์ ์ง์ฐ๋ฉด ์ค๋ ๋ํ์์ ๊ทธ ์ค๋ ๋๊ฐ ๋ค์ ์์ฒญ์ ์๋ฑํ ์ ์์ ๋ฌผ๊ณ ๊ฐ ์ ์์(์ค๋ ๋ ์ฌ์ฌ์ฉ ๋์).
ํ์ฌ ์ฌ์ฉ์ ์กฐํ currentUserId() โ AuthContext.java:27-34
// L27-L34
public static Long currentUserId() {
Long override = OVERRIDE_USER_ID.get();
if (override != null) return override; // โ ์ค์ผ์ค๋ฌ๊ฐ ์ฌ์ ๊ฐ ์ฐ์
HttpServletRequest req = currentRequest(); // โก ์์ผ๋ฉด ํ์ฌ HTTP ์์ฒญ์์
if (req == null) return null;
Object v = req.getAttribute(JwtAuthenticationFilter.ATTR_USER_ID);
return v instanceof Long ? (Long) v : null; // โข ํํฐ๊ฐ ๋ถ์ธ ๋ช
์ฐฐ ์ฝ๊ธฐ
}
- ์ฐ์ ์์: โ override(์ค์ผ์ค๋ฌ) โ โก HTTP ์์ฒญ attribute. ๋ ๋ค ์์ผ๋ฉด
null(์ต๋ช ). JwtAuthenticationFilter.ATTR_USER_ID์์๋ฅผ ๊ณต์ โ ํํฐ๊ฐsetAttributeํ ๊ฐ์ ์ฌ๊ธฐ์getAttribute.v instanceof Longโ ํํฐ๊ฐn.longValue()๋ก Long ์ ๋ฃ์๊ธฐ์ ์บ์คํ ์ด ๋ง์. (๋ง์ฝ ์ด๋์ ๊ฐ Integer ๋ก ๋ฃ์ผ๋ฉด ์ฌ๊ธฐ์null์ด ๋๋ ํจ์ ์ด ์ ์ฌ.)
request ํ๋ currentRequest() โ AuthContext.java:43-55 (โ
ํจ์ ์ ์ง์์ง)
// L43-L55
public static Long requireUserId() {
Long id = currentUserId();
if (id == null) throw new RuntimeException("์ธ์ฆ์ด ํ์ํฉ๋๋ค.");
return id;
}
private static HttpServletRequest currentRequest() {
var attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes sra) return sra.getRequest();
return null;
}
requireUserId()= "๋ก๊ทธ์ธ ํ์" ๋ฉ์๋. ์์ผ๋ฉด ์์ธ โ ์ปจํธ๋กค๋ฌ๊ฐ 401 ๋น์ทํ๊ฒ ์ฒ๋ฆฌ. ์ปจํธ๋กค๋ฌ์์ "ํ์ฌ ์ฌ์ฉ์"๋ฅผ ๊บผ๋ด๋ ํ์ค ์ง์ ์ .currentRequest()๊ฐRequestContextHolder์ ์์กดํ๋ ๊ฒ ํต์ฌ. ์ด๊ฑด DispatcherServlet ์ด ์ฑ์์ฃผ๋ ์ ์ญ ๋ณด๊ดํจ์ ๋๋ค(์ฌ์ ์ง์ 4๋ฒ).- ๋ฐ๋ผ์
AuthContext.currentUserId()๋ ์ปจํธ๋กค๋ฌ/์๋น์ค(๋์คํจ์ฒ ์ดํ) ์์๋ ์ ๋์ํ์ง๋ง, ์๋ธ๋ฆฟ ํํฐ ์(๋์คํจ์ฒ ์ด์ ) ์์๋RequestContextHolder๊ฐ ๋น์ด ์์ด ํญ์ null ์ ๋๋ ค์ค๋๋ค. โ ๋ฐ๋ก ์ด๊ฒ ๋ค์์ ๋์ค๋ M10 ๋ฒ๊ทธ์ ์์ธ. ๊ทธ๋์AiRateLimitFilter๋AuthContext๋ฅผ ์ ์ฐ๊ณrequest.getAttribute๋ฅผ ์ง์ ์ฝ์ต๋๋ค.
D. AiRateLimitFilter โ ๋น์ผ ๋ฐฉ ํตํ ์ ํ (M10 ํํฐ์์ ๊ตํ) (config/AiRateLimitFilter.java)
์ ์ธ + ์์ โ AiRateLimitFilter.java:37-52
// L37-L52
@Slf4j @Component
@Order(Ordered.HIGHEST_PRECEDENCE + 20) // JwtAuthenticationFilter(+10) ๋ค์์ ์คํ โ ๊ทธ๋์ผ request attribute(userId)๊ฐ ์ฑ์์ง ์ํ
public class AiRateLimitFilter extends OncePerRequestFilter {
@Value("${app.ratelimit.ai-chat.capacity:20}") private int capacity; // ๋ฒํท ์ฉ๋(์ต๋ ํ ํฐ)
@Value("${app.ratelimit.ai-chat.refill-tokens:20}") private int refillTokens; // ์ฑ์์ง๋ ํ ํฐ ์
@Value("${app.ratelimit.ai-chat.refill-minutes:60}")private int refillMinutes; // ์ฑ์ฐ๋ ์ฃผ๊ธฐ(๋ถ)
/** userId โ Bucket */
private final Map<Long, Bucket> buckets = new ConcurrentHashMap<>();
@Order(HIGHEST + 20)โ JWT ํํฐ(+10) ๋ค์. ์ด๊ฒ ์ ํํด์ผ ์๋ํจ(์ด์ ๋ doFilterInternal ์์).- Bucket4j ํ ํฐ๋ฒํท: ์ฌ์ฉ์๋ง๋ค
Bucket(ํ ํฐ ํต). ์์ฒญ 1๋ฒ = ํ ํฐ 1๊ฐ ์๋น. ๋น๋ฉด ๊ฑฐ๋ถ. ์๊ฐ์ด ์ง๋๋ฉด ๋ค์ ์ฑ์์ง(refill). ConcurrentHashMap<Long, Bucket>โ userId ๋ณ ๋ฒํท์ ๋ฉ๋ชจ๋ฆฌ์ ๋ณด๊ด(์ค๋ ๋ ์์ ). ์ฃผ์๋๋ก ์ด์ ๋ค์ค ์ธ์คํด์ค๋ผ๋ฉด Redis ๊ถ์ฅ(์๋ฒ๋ง๋ค ๋ฉ๋ชจ๋ฆฌ๊ฐ ๋ฐ๋ก๋ผ ํ๋๊ฐ ํฉ์ณ์ง์ง ์์).
์ ์ฉ ๋์ ํํฐ๋ง shouldNotFilter() โ AiRateLimitFilter.java:54-71
// L54-L71
private static final String[] RATE_LIMITED_SUFFIXES = { "/chat", "/formalize", "/briefing", "/auto-run" };
protected boolean shouldNotFilter(HttpServletRequest request) {
if (!"POST".equalsIgnoreCase(request.getMethod())) return true; // POST ์๋๋ฉด ์ ์ธ
String path = request.getRequestURI();
if (!path.startsWith("/api/alpha/workspaces/")) return true; // ์ํฌ์คํ์ด์ค ๊ฒฝ๋ก ์๋๋ฉด ์ ์ธ
for (String suffix : RATE_LIMITED_SUFFIXES) if (path.endsWith(suffix)) return false; // 4์ข
๋ง ๋์
return true;
}
shouldNotFilter๊ฐtrue๋ฉด ์ด ํํฐ๋ฅผ ๊ฑด๋๋. ์ฆ "๋ง์ ๋์"์ ์ข๊ฒ ํ์ : POST +/api/alpha/workspaces/...+ ๋์ด/chatยท/formalizeยท/briefingยท/auto-run์ธ ์์ฒญ๋ง ๋ ์ดํธ๋ฆฌ๋ฐ.- ์ ์ด 4๊ฐ? โ ์ ๋ถ LLM/์ ์ฒด ํ์ดํ๋ผ์ธ ํธ์ถ(๋ยท์ฟผํฐ ์๋ชจ) ์๋ํฌ์ธํธ(ํด๋์ค ์ฃผ์์ ๋งคํ ๋ช ์). ์กฐํ์ฑ GET ์ ๋ง์ ์ด์ ๊ฐ ์์.
ํต์ฌ: ์ ์ ์ง๋
+ ํ ํฐ ์๋น doFilterInternal() โ AiRateLimitFilter.java:73-102
// L73-L102 (์์ฝ ๋ฐ์ท)
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) {
// ์ ์์ request attribute ์์ ์ง์ ์ฝ๋๋ค. AuthContext.currentUserId() ๋ RequestContextHolder ์ ์์กดํ๋๋ฐ
// ์๋ธ๋ฆฟ ํํฐ ๋จ๊ณ์์๋ (DispatcherServlet ์ง์
์ ์ด๋ผ) ์์ง ์ฑ์์ง์ง ์์ ํญ์ null โ ๋ ์ดํธ๋ฆฌ๋ฐ์ด ์ ์ ๋ฌด๋ ฅํ๋๋ ๋ฒ๊ทธ.
// JwtAuthenticationFilter(@Order +10)๊ฐ ๋จผ์ ์คํ๋ผ ์ด attribute ๋ฅผ ์ฑ์๋๋ค(@Order +20).
Object uidAttr = request.getAttribute(JwtAuthenticationFilter.ATTR_USER_ID);
Long userId = (uidAttr instanceof Long l) ? l : null;
if (userId == null) { chain.doFilter(request, response); return; } // ๋ฏธ์ธ์ฆ โ ํต๊ณผ(์ปจํธ๋กค๋ฌ๊ฐ 401)
Bucket bucket = buckets.computeIfAbsent(userId, this::newBucket);
if (bucket.tryConsume(1)) {
chain.doFilter(request, response); // ํ ํฐ ์์ โ ํต๊ณผ
} else {
long availableIn = bucket.getAvailableTokens();
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // 429
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"...1์๊ฐ์ " + capacity + "ํ...\",\"remaining\":" + availableIn + "}");
}
}
- ์ด 8์ค์ง๋ฆฌ ์ฃผ์์ด M10 ์ ์ ๋ถ์
๋๋ค. ํต์ฌ:
AuthContext.currentUserId()๋ฅผ ํํฐ์์ ํธ์ถํ๋ฉดRequestContextHolder๊ฐ ์์ง ๋น์ด ํญ์ null โ ๋ชจ๋ ์ฌ์ฉ์๊ฐ null ๋ก ๋ฌถ์ฌ ๋ ์ดํธ๋ฆฌ๋ฐ์ด ์ฌ์ค์ ๋ฌด๋ ฅํ๋๋ ๋ฒ๊ทธ. ํด๊ฒฐ์ฑ ์ JWT ํํฐ๊ฐ ๋ฏธ๋ฆฌ ์ฑ์๋request.getAttribute(ATTR_USER_ID)๋ฅผ ์ง์ ์ฝ๋ ๊ฒ. - ๊ทธ๋์ ๋ ๊ฐ์ง๊ฐ ๋์์ ์ฑ๋ฆฝํด์ผ ํจ: โ ์์(JWT +10 ์ด RateLimit +20 ๋ณด๋ค ๋จผ์ ) โก ์ง๋ (AuthContext ์ฐํ). ๋ ์ค ํ๋๋ง ํ๋ ค๋ ๋ ์ดํธ๋ฆฌ๋ฐ์ด ๊นจ์ง.
computeIfAbsent(userId, this::newBucket)โ ๊ทธ ์ฌ์ฉ์์ ๋ฒํท์ด ์์ผ๋ฉด ์๋ก ๋ง๋ค๊ณ , ์์ผ๋ฉด ์ฌ์ฌ์ฉ.tryConsume(1)โ ํ ํฐ 1๊ฐ ์ฐจ๊ฐ ์๋(์ฑ๊ณต/์คํจ boolean).- ๊ฑฐ๋ถ ์ 429 Too Many Requests + ํ๊ตญ์ด JSON ์๋ฌ๋ฅผ ์ง์ ๊ธฐ๋ก(์ปจํธ๋กค๋ฌ๊น์ง ์ ๊ฐ๊ณ ํํฐ์์ ์๋ต ์ข ๊ฒฐ).
๋ฒํท ์์ฑ newBucket() โ AiRateLimitFilter.java:104-110
// L104-L110
private Bucket newBucket(Long userId) {
Bandwidth limit = Bandwidth.classic(capacity, Refill.greedy(refillTokens, Duration.ofMinutes(refillMinutes)));
return Bucket.builder().addLimit(limit).build();
}
Bandwidth.classic(20, greedy(20, 60๋ถ))= "์ฉ๋ 20, 60๋ถ์ 20๊ฐ๋ฅผ ์ ์ง(greedy) ๋ณด์ถฉ". ์ฆ ์๊ฐ๋น 20ํ.greedy๋ 60๋ถ์ ํต์งธ๋ก ๊ธฐ๋ค๋ฆฌ์ง ์๊ณ ๋น์จ๋๋ก ์กฐ๊ธ์ฉ ์ฑ์.
โ ๏ธ ๋ฏธ์ธ์ฆ(
userId==null)์ผ ๋ ํต๊ณผ์ํค๋ ๊ฒ ์์ํ ์ ์๋๋ฐ(๋ ์ดํธ๋ฆฌ๋ฐ ์ฐํ์ฒ๋ผ ๋ณด์), ๋์ ์ปจํธ๋กค๋ฌ๋ค์ด ์ด์ฐจํผ ์ธ์ฆ์ ์๊ตฌํด 401 ๋ก ๋งํ๊ณ , LLM ์ค์ ํธ์ถ์ ์ธ์ฆ ํต๊ณผ ํ์๋ง ์ผ์ด๋๋ฏ๋ก ๋น์ฉ ๋์๋ ์์ต๋๋ค(์ฃผ์ ๊ทผ๊ฑฐ).
E. PasswordConfig โ BCrypt ๋ถ์๊ธฐ (config/PasswordConfig.java)
// L15-L22
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}
- ๋ฑ ํ ๊ฐ์ง:
PasswordEncoder๋น(BCrypt) 1๊ฐ๋ฅผ ์ฑ ์ ์ญ์ ๊ณต๊ธ. user ๋๋ฉ์ธ์ ํ์๊ฐ์ /๋ก๊ทธ์ธ์ดpasswordEncoder.encode(...)/.matches(...)๋ก ์ฌ์ฉ. - ์ค์(ํด๋์ค ์ฃผ์): ์ด ํ๋ก์ ํธ๋ Spring Security ํ๋ ์์ํฌ ์ ์ฒด๋ฅผ ์๋์ ์ผ๋ก ๋(
build.gradle์spring-boot-starter-security๊ฐ ์ฃผ์ ์ฒ๋ฆฌ,spring-security-crypto๋ง ์์กด). ์ฆSecurityFilterChainยท์๋ ์ธ์ฆ ํ๋ฆ์ด ์๊ณ , ์ธ์ฆ/์ธ๊ฐ๋ ์์JwtAuthenticationFilter+AuthContext๊ฐ ์๋์ผ๋ก ๋ด๋น. BCrypt ์๊ณ ๋ฆฌ์ฆ๋ง ๋น๋ ค ์ฐ๋ ๊ตฌ์กฐ์ ๋๋ค.
๐ก BCrypt ๋ ๊ฐ์ ๋น๋ฐ๋ฒํธ๋ผ๋ ๋งค๋ฒ ๋ค๋ฅธ salt ๋ก ๋ค๋ฅธ ํด์๋ฅผ ๋ ๋๋ค. ๊ทธ๋์ ์ ์ฅ๋ณธ๋ผ๋ฆฌ ๋น๊ต๊ฐ ์๋๋ผ
matches(์๋ฌธ, ์ ์ฅํด์)๋ก ๊ฒ์ฆํด์ผ ํจ.
F. AesGcmCryptoService โ ๊ท์คํ ๊ธ๊ณ (security/AesGcmCryptoService.java)
ํค ์ด๊ธฐํ init() โ AesGcmCryptoService.java:33-49
// L33-L49
@Value("${app.crypto.key:}") private String configuredKey;
@Value("${app.jwt.secret:...32bytes}") private String jwtSecretFallback;
private SecretKey secretKey;
private final SecureRandom rng = new SecureRandom();
@PostConstruct
void init() throws Exception {
String raw = (configuredKey != null && !configuredKey.isBlank()) ? configuredKey : jwtSecretFallback;
byte[] keyBytes = MessageDigest.getInstance("SHA-256").digest(raw.getBytes()); // ๋ฌด์กฐ๊ฑด 32๋ฐ์ดํธ๋ก
this.secretKey = new SecretKeySpec(keyBytes, "AES");
log.info("AesGcmCryptoService initialized (key source: {})",
(configuredKey != null && !configuredKey.isBlank()) ? "app.crypto.key" : "JWT secret fallback");
}
@PostConstruct= ๋น์ด ๋ง๋ค์ด์ง ์งํ 1ํ ์คํ(์ด๊ธฐํ ํ ).- ํค ์ถ์ฒ:
app.crypto.key์ฐ์ , ๋น์ด ์์ผ๋ฉดapp.jwt.secret์ผ๋ก ํด๋ฐฑ. ์ด๋ ์ชฝ์ด๋ SHA-256 ํด์๋ก 32๋ฐ์ดํธ ๊ณ ์ โ AES-256 ํค. (์๋ณธ ๊ธธ์ด๊ฐ ๋ค์ญ๋ ์ญํด๋ ํญ์ 32๋ฐ์ดํธ๊ฐ ๋จ.) - ์ํฌ๋ฆฟ ์์ฒด๋ ๋ก๊น ํ์ง ์๊ณ "์ถ์ฒ"๋ง INFO ๋ก ๋จ๊น(์์ ).
โ ๏ธ ๋ฉ๋ชจ๋ฆฌ ๋ ธํธ("Env priority changes need duplicate-key audit")์ ์ง๊ฒฐ:
app.crypto.key๊ฐ.env/application*.properties์ ์๋ก ๋ค๋ฅธ ๊ฐ์ผ๋ก ์ค๋ณต ์ ์๋ผ ์์ผ๋ฉด, ์ด๋ค ๊ฒ ์ฃผ์ ๋๋๋์ ๋ฐ๋ผ ๋ณตํธํ ํค๊ฐ ๋ฌ๋ผ์ ธ ๊ธฐ์กด ์ํธ๋ฌธ์ ๋ชป ํธ๋ ์ฌ๊ณ ๊ฐ ๋ฉ๋๋ค. ์ด ํด๋์ค์ ํด๋ฐฑ ๋ก์ง ๋๋ฌธ์ ๋๋์ฑ "ํค ์ผ๊ด์ฑ"์ด ์ค์.
์ํธํ encrypt() โ AesGcmCryptoService.java:51-63
// L51-L63
public byte[] encrypt(String plaintext) {
if (plaintext == null) return null;
byte[] iv = new byte[IV_LENGTH]; // IV_LENGTH = 12
rng.nextBytes(iv); // ๋งค๋ฒ ๋๋ค IV
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(128, iv)); // 128 = tag bits
byte[] ct = cipher.doFinal(plaintext.getBytes());
return ByteBuffer.allocate(iv.length + ct.length).put(iv).put(ct).array(); // [12B IV][์ํธ๋ฌธ+ํ๊ทธ]
}
- AES-GCM = ์ํธํ + ๋ฌด๊ฒฐ์ฑ ํ๊ทธ(๋ณ์กฐ ๊ฐ์ง)๋ฅผ ๋์์ ์ ๊ณตํ๋ ๋ชจ๋.
NoPadding์ GCM ์ด ํจ๋ฉ์ด ํ์ ์์ด์. - IV(์ด๊ธฐํ ๋ฒกํฐ)๋ ๋งค๋ฒ ๋๋ค 12๋ฐ์ดํธ. ๊ฐ์ ํ๋ฌธ๋ ๋งค๋ฒ ๋ค๋ฅธ ์ํธ๋ฌธ์ด ๋๊ฒ ํจ(ํจํด ๋
ธ์ถ ๋ฐฉ์ง). ์ฌ์ฌ์ฉ ๊ธ์ง๊ฐ GCM ์ ์ฒ ์น โ
SecureRandom์ผ๋ก ๋งค๋ฒ ์๋ก. - ์ ์ฅ ํ์์ด ํด๋์ค ์ฃผ์๋๋ก
[12B IV][์ํธ๋ฌธ+ํ๊ทธ]ํ ๋ฉ์ด๋ฆฌ. ๋ณตํธํ ๋ ์ 12๋ฐ์ดํธ๋ฅผ ๋ผ์ด IV ๋ก ์.
๋ณตํธํ decrypt() + Base64 ํฌํผ โ AesGcmCryptoService.java:65-89
// L65-L89 (์์ฝ)
public String decrypt(byte[] payload) {
if (payload == null || payload.length < IV_LENGTH + 1) return null;
ByteBuffer bb = ByteBuffer.wrap(payload);
byte[] iv = new byte[IV_LENGTH]; bb.get(iv); // ์ 12B = IV
byte[] ct = new byte[bb.remaining()]; bb.get(ct); // ๋๋จธ์ง = ์ํธ๋ฌธ+ํ๊ทธ
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, iv));
return new String(cipher.doFinal(ct)); // ํ๊ทธ ๋ถ์ผ์น ์ ์์ธ(๋ณ์กฐ ํ์ง)
}
public String encryptToBase64(String s) { ... } // byte[] โ Base64 ๋ฌธ์์ด (DB ์ปฌ๋ผ/JSON ์ ์ฅ์ฉ)
public String decryptFromBase64(String b64) { ... }
- ๋ณตํธํ๋ ์ ํํ ์ญ์: IV ๋ถ๋ฆฌ โ ๋ณตํธํ. ์ํธ๋ฌธ์ด ํ ๋นํธ๋ผ๋ ๋ณ์กฐ๋๋ฉด GCM ํ๊ทธ ๊ฒ์ฆ ์คํจ๋ก ์์ธ โ ์๋ณ์กฐ๋ฅผ ์๋ ์ฐจ๋จ.
encryptToBase64/decryptFromBase64โ ๋ฐ์ดํธ๋ฅผ ๊ทธ๋๋ก DB/JSON ์ ๋ฃ๊ธฐ ๋ถํธํ๋ Base64 ํ ์คํธ๋ก ๋ณํํ๋ ํธ์ ๋ํผ. KIS ํคยทGitHub PAT ๋ฑ์ ๋ฌธ์์ด ์ปฌ๋ผ์ ์ ์ฅํ ๋ ์ฌ์ฉ.
G. WebConfig โ CORS + ์
๋ก๋ ํ์ผ ์๋น (config/WebConfig.java)
ํ์ฉ ์ถ์ฒ ํด์ โ WebConfig.java:28-39
// L28-L39
@Value("${app.cors.allowed-origins:http://localhost:5173,http://127.0.0.1:5173}")
private String allowedOriginsCsv;
private String[] resolveOrigins() {
if (allowedOriginsCsv == null || allowedOriginsCsv.isBlank())
return new String[]{"http://localhost:5173", "http://127.0.0.1:5173"};
return java.util.Arrays.stream(allowedOriginsCsv.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).toArray(String[]::new);
}
- ์ฝค๋ง๋ก ๊ตฌ๋ถ๋ ์ถ์ฒ ๋ชฉ๋ก์ ํ๊ฒฝ๋ณ์(
CORS_ALLOWED_ORIGINS)๋ก ๋ฐ์ ๋ฐฐ์ด๋ก ๋ถํด. ๋น๋ฉด ๋ก์ปฌ dev ์ฃผ์๋ก ํด๋ฐฑ. - ์ด์์์ ์ค์ ํ๋ก ํธ ๋๋ฉ์ธ์ ๋ฃ์ด์ผ ํจ. (CLAUDE.md ํ๊ฒฝ๋ณ์ํ์๋ ๋ฑ์ฅ.)
CORS ๋งคํ โ WebConfig.java:42-56
// L42-L56
public void addCorsMappings(CorsRegistry registry) {
String[] origins = resolveOrigins();
registry.addMapping("/api/**")
.allowedOrigins(origins)
.allowedMethods("GET","POST","PUT","PATCH","DELETE","OPTIONS")
.allowedHeaders("*")
.allowCredentials(true) // โ
์ฟ ํค ๋๋ด ํ์ฉ
.maxAge(3600);
registry.addMapping(publicBase + "/**") // "/files/**" โ ์
๋ก๋ ๋ค์ด๋ก๋
.allowedOrigins(origins).allowedMethods("GET","HEAD","OPTIONS").maxAge(3600);
}
/api/**์ CORS ํ์ฉ.allowCredentials(true)+ ๋ช ์์ allowedOrigins์กฐํฉ์ด ํต์ฌ: ์ฟ ํค ์ธ์ฆ(JWT ์ฟ ํค)์ ์ฐ๋ ์๊ฒฉ์ฆ๋ช ์ ํ์ฉํ๋, ๊ทธ๋ฌ๋ฉด*๋ฅผ ๋ชป ์ฐ๋ฏ๋ก ์ถ์ฒ๋ฅผ ์ ํํ ๋์ดํด์ผ ํจ(์ฌ์ ์ง์ 5๋ฒ).maxAge(3600)โ ํ๋ฆฌํ๋ผ์ดํธ(OPTIONS) ๊ฒฐ๊ณผ๋ฅผ 1์๊ฐ ์บ์ โ ๋งค ์์ฒญ๋ง๋ค ์ฌ์ ๊ฒ์ฌ ์ ํจ(์ฑ๋ฅ).
์ ์ ๋ฆฌ์์ค ํธ๋ค๋ฌ โ WebConfig.java:59-65
// L59-L65
public void addResourceHandlers(ResourceHandlerRegistry registry) {
Path root = Paths.get(uploadDir).toAbsolutePath().normalize();
String location = root.toUri().toString(); // file:/.../uploads/
registry.addResourceHandler(publicBase + "/**") // /files/**
.addResourceLocations(location).setCachePeriod(3600);
}
- ์
๋ก๋ ํด๋(
app.upload.dir, ๊ธฐ๋ณธuploads)๋ฅผ/files/**URL ๋ก ์ ์ ์๋น. ํ๋ก ํธ๊ฐ<a href="/files/...">๋ก ์ฒจ๋ถํ์ผ์ ์ง์ ๋ฐ์. .normalize()+ ์ ๋๊ฒฝ๋กํ โ ๊ฒฝ๋ก ์ ๊ทํ๋ก../๊ฐ์ ํธ๋๋ฒ์ค ํผ์ ์ ์ค์.
H. Jackson2NodeBridgeConfig โ Boot4 ์ง๋ ฌํ ์์ (config/Jackson2NodeBridgeConfig.java)
// L33-L53 (์์ฝ)
@Bean
JacksonModule jackson2NodeBridgeModule() {
SimpleModule module = new SimpleModule("Jackson2NodeBridge");
module.addSerializer(com.fasterxml.jackson.databind.JsonNode.class, new Jackson2NodeSerializer());
return module;
}
private static final class Jackson2NodeSerializer extends ValueSerializer<com.fasterxml.jackson.databind.JsonNode> {
public void serialize(com.fasterxml.jackson.databind.JsonNode value, JsonGenerator gen, SerializationContext ctxt) {
if (value == null || value.isNull()) gen.writeNull();
else gen.writeRawValue(value.toString()); // ์ JsonNode ์ ์๋ณธ JSON ํ
์คํธ๋ฅผ ๊ทธ๋๋ก ์ถ๋ ฅ
}
}
- ๋ฌธ์ (๋ฉ๋ชจ๋ฆฌ "Spring Boot 4 Jackson JsonNode ๋ฒ๊ทธ"์ ์ง๊ฒฐ): Boot 4 ์ ๊ธฐ๋ณธ ๋งคํผ๋ Jackson 3(
tools.jackson)์ธ๋ฐ, ์ฑ ๊ณณ๊ณณ(AnalyticsClientยท๋ธ๋ก์ปคยทLLM)์ด Jackson 2(com.fasterxml.jackson)์JsonNode๋ฅผ ๋ง๋ค์ด ์ปจํธ๋กค๋ฌ๊ฐ ๊ทธ๋๋ก ๋ฐํ. Jackson 3 ์ ๊ทธ๊ฑธ ํธ๋ฆฌ๋ก ๋ชป ์์๋ณด๊ณ getter ๋ค(isArrayยทnodeTypeโฆ)์ ์ง๋ ฌํ โ{"array":false,"nodeType":"OBJECT",...}๊ฐ์ ์ฐ๋ ๊ธฐ JSON ์ด ๋๊ฐ. analytics ํ๋ก์ ์๋ํฌ์ธํธ ์ ์ฒด๊ฐ ์ํฅ. - ํด๊ฒฐ: ๊ธฐ๋ณธ Jackson 3 ๋งคํผ๋ ๊ฑด๋๋ฆฌ์ง ์๊ณ (DTOยท๋ ์ง ์ง๋ ฌํ ํ๊ท ์ํ 0), Jackson 2
JsonNodeํ์ ๋ง ๋ง๋ฌ์ ๋ ๊ทธ ๋ ธ๋์toString()(ํญ์ ์ ํจํ JSON ํ ์คํธ)์writeRawValue๋ก ์๋ณธ ๊ทธ๋๋ก ์ถ๋ ฅํ๋ ์ง๋ ฌํ๊ธฐ๋ฅผ ๋ค๋ฆฌ์ฒ๋ผ ๋ฑ๋ก. ์ธ๊ณผ์ ์์ . - Boot 4 ๊ฐ ์ปจํ
์คํธ์ ๋ชจ๋
JacksonModule๋น์ ๊ธฐ๋ณธ ๋งคํผ์ ์๋ ๋ฑ๋กํ๋ฏ๋ก, ์ด ๋น ํ๋ ์ ์ธ๋ง์ผ๋ก ์ ์ญ ์ ์ฉ. (์ฑ์ด Jackson3 ๋ก ์์ ์ดํํ๋ฉด ์ด ๋ธ๋ฆฟ์ง๋ ์ ๊ฑฐ ๊ฐ๋ฅ.)
๐ก ์ด๋ณด ํฌ์ธํธ: "Jackson ์ด 2๊ฐ"๋ผ๋ ๊ฒ ํจ์ ์ ๋ณธ์ง. ํจํค์ง๊ฐ
com.fasterxml.jackson(2) vstools.jackson(3) ๋ก ๋ค๋ฆ ๋๋ค. ๊ฐ์ ์ด๋ฆJsonNode๋ผ๋ ๋ค๋ฅธ ํ์ ์ด๋ผ ์๋ก ๋ชป ์์๋ด ๋๋ค.
I. StreamChatConfig โ ์ธ๋ถ ์ฑํ
SDK ์ฐ๊ฒฐ (config/StreamChatConfig.java)
// L37-L53 (์์ฝ)
@PostConstruct
public void init() {
boolean haveKey = apiKey != null && !apiKey.isBlank();
boolean haveSecret = apiSecret != null && !apiSecret.isBlank();
if (haveKey && System.getProperty("STREAM_KEY") == null) System.setProperty("STREAM_KEY", apiKey);
if (haveSecret && System.getProperty("STREAM_SECRET") == null) System.setProperty("STREAM_SECRET", apiSecret);
log.info("[StreamChatConfig] keyPresent={}, secretPresent={}", haveKey, haveSecret); // ์กด์ฌ ์ฌ๋ถ๋ง(์ํฌ๋ฆฟ ๋
ธ์ถ X)
if (!haveKey || !haveSecret) log.warn("Stream Chat ํค/์ํฌ๋ฆฟ์ด ๋น์ด์์ต๋๋ค. ์ฑํ
๊ธฐ๋ฅ์ด ๋์ํ์ง ์์ ์ ์์ด์.");
}
- Stream Chat Java SDK ๋ ์์คํ
ํ๋กํผํฐ
STREAM_KEY/STREAM_SECRET๋ฅผ ์ฝ์ด ์์ฒด ์ด๊ธฐํ โ ๊ทธ๋์System.setProperty๊ฐ ํ์. - ์์ ์ฅ์น 3๊ฐ: โ ์ธ๋ถ์์ ์ด๋ฏธ ์ฃผ์
๋ผ ์์ผ๋ฉด ๋ฎ์ด์ฐ์ง ์์(
== null์ผ ๋๋ง set) โก ๋น ๊ฐ์ด๋ฉด ์์ set ์ ํจ(๋น ๊ฐ์ผ๋ก ์ด๊ธฐํ๋ผ ๋ฐํ์ 401 ๋๋ ๊ฒ ๋ฐฉ์ง) โข ์ํฌ๋ฆฟ์ ๋ก๊น ๊ธ์ง, boolean ์กด์ฌ์ฌ๋ถ๋ง.
J. DataSeeder โ ๊ฐ๊ด ์ค๋น ์ค์ผ์คํธ๋ ์ดํฐ (seed/DataSeeder.java)
์ ์ธ + ๋น-ํธ๋์ญ์
๊ฒฐ์ โ DataSeeder.java:50-84
// L50-L84 (๋ฐ์ท)
@Slf4j @Component @Profile("!prod") @RequiredArgsConstructor
public class DataSeeder implements CommandLineRunner {
...repository ๋ค์...
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<Long, Long> userIdMap = new HashMap<>(); // JSON id โ ์ค์ DB PK
...
@Override
// ์๋์ ์ผ๋ก @Transactional ์ ๊ฑฐ โ ๊ฑฐ๋ํ outer ํธ๋์ญ์
์์์ ์ผ๋ถ SQL ์ด ์คํจํ๋ฉด
// rollback-only ๋งํน๋ผ ๋ง์ง๋ง commit ์ด ์คํจํ๋ค. ๊ฐ seedXxx()/cleanupXxx()/bootstrap*() ๋
// ๋ด๋ถ์์ ์์ฒด ํธ๋์ญ์
์ ๊ด๋ฆฌํ๊ฑฐ๋ REQUIRES_NEW ๋ก ๋
๋ฆฝ ์ปค๋ฐํ๋ค.
public void run(String... args) throws Exception { ... }
implements CommandLineRunnerโ ์ฑ ๊ธฐ๋ ์๋ฃ ์งํrun()1ํ ์คํ. (๊ธฐ๋ ์ ๋ฐ์ดํฐ ์ค๋น์ ํ์ค.)@Profile("!prod")โ ์ด์์์ ๋นํ์ฑ(mock ๋ฐ์ดํฐ๋ก ์ด์ DB ์ค์ผ ๋ฐฉ์ง).AiModelCatalogSeeder๋ prod/local ๋ ๋ค์๋ ๊ฒ๊ณผ ๋๋น.@Transactional์ ์ผ๋ถ๋ฌ ์ ๋ถ์(์ฃผ์ ํต์ฌ): ๊ฑฐ๋ํ ๋จ์ผ ํธ๋์ญ์ ์ด๋ฉด ์ค๊ฐ ํ SQL ์คํจ๊ฐ ์ ์ฒด๋ฅผ rollback-only ๋ก ๋ง๋ค์ด ์ต์ข commit ๊น์ง ๋ ๋ฆผ. ๊ทธ๋์ ๊ฐ ๋จ๊ณ๊ฐ ์๊ธฐ ํธ๋์ญ์ ์ ๋ฐ๋ก ๊ฐ์ง(ํนํ cleanup ์REQUIRES_NEW๋ก ๋ ๋ฆฝ ์ปค๋ฐ).userIdMap/skillIdMap/... โ JSON ์ ์๋ณธ id โ ์ค์ ์ ์ฅ๋ PK ๋งคํํ. JSON ๋ผ๋ฆฌ์ FK ์ฐธ์กฐ๋ฅผ ์ค์ DB PK ๋ก ์ด์ด์ฃผ๋ ๋ค๋ฆฌ(์๋ seed ๋ฉ์๋๋ค์ด ์ฑ์ฐ๊ณ ์ฝ์).
์คํ ์์ run() โ DataSeeder.java:84-153
// L84-L153 (๊ตฌ์กฐ ๋ฐ์ท)
log.info("===== DataSeeder ์์ =====");
seedSkillMaster(); seedProjectFieldMaster(); seedUsers();
seedClientProfile(); seedClientProfileStats(); seedClientPreferredSkill();
seedProjects(); seedProjectApplications(); seedProjectTags(); seedProjectSkillMapping(); seedChatRooms();
contractModuleSeeder.backfillAll(); // 7๊ฐ ๊ณ์ฝ ๋ชจ๋ ๋ฐฑํ
migrateBudgetManToWon(); // ๋ง์โ์ ๋จ์ ๋ง์ด๊ทธ๋ ์ด์
backfillModulesFromContractTerms(); // AI ํ์๋ด์ฉ โ ๋ชจ๋ ๋ฐ์
milestoneSeedingService.bootstrapAll(); // ๋ง์ผ์คํค ์๋ ์๋
// 1ํ์ฑ ์ ๋ฆฌ(๋
๋ฆฝ ํธ๋์ญ์
์ผ๋ก ์ง์ ํธ์ถ):
pr = dataCleanupService.cleanupProjects();
cr = dataCleanupService.cleanupClients();
op = dataCleanupService.cleanupOrphans();
- ์์๊ฐ ๊ณง ์์กด์ฑ:
skill_master/field_masterโusersโclient_profile(user FK) โ stats/skill(client FK) โprojects(user FK) โ application/tag/mapping(project FK) โ chat. ๋ถ๋ชจ๋ฅผ ๋จผ์ ๋ง๋ค์ด์ผ ์์ FK ๊ฐ ์ฐ๊ฒฐ๋จ. - ํ๋ฐ๋ถ๋ ์๋๊ฐ ์๋๋ผ ๋ฐ์ดํฐ ๋ณด์ ์์
๋ค(๋ง์ด๊ทธ๋ ์ด์
ยท๋ฐฑํยท์ ๋ฆฌ). ์ ๋ถ
try/catch + log.warn์ผ๋ก ๊ฐ์ธ ํ๋๊ฐ ์คํจํด๋ ๋๋จธ์ง๋ ์งํ. - โ ๏ธ Spring AOP self-invocation ํจ์ (์ฃผ์ ๋ช
์):
DataCleanupService.cleanupAll()๋ด๋ถ์์cleanupProjects()๋ฅผ ๋ถ๋ฅด๋ฉด ํ๋ก์๋ฅผ ๊ฑฐ์น์ง ์์REQUIRES_NEW๊ฐ ๋ฌด์๋จ. ๊ทธ๋์ DataSeeder ๊ฐ ์ธ๋ถ์์ ๊ฐ cleanup ๋ฉ์๋๋ฅผ ์ง์ ํธ์ถ โ ์ง์ง ๋ ๋ฆฝ ํธ๋์ญ์ ์ ๋ณด์ฅ. (AOP ์ด๋ ธํ ์ด์ ์ "๋น ์ธ๋ถ ํธ์ถ"์๋ง ์ ์ฉ๋๋ ํ๊ณ.)
๋ฉฑ๋ฑ + id ๋งคํ ํจํด seedSkillMaster() โ DataSeeder.java:258-284
// L258-L284 (์์ฝ)
private void seedSkillMaster() throws Exception {
if (skillMasterRepository.count() > 0) { // โ
๋ฉฑ๋ฑ: ์ด๋ฏธ ์์ผ๋ฉด
// ๊ธฐ์กด ๋ฐ์ดํฐ์ ๋ํด JSON ์ ๋ค์ ์ฝ์ด "์๋ณธ id โ ์ค์ PK" ๋งคํ๋ง ๋ณต์ํ๊ณ return
JsonNode arr = readJson("seed/erd/skill_master.json");
for (JsonNode n : arr) {
Long jsonId = n.get("id").asLong();
String name = n.get("name").asText();
skillMasterRepository.findByName(name).ifPresent(s -> skillIdMap.put(jsonId, s.getId()));
}
return;
}
// ๋น์ด ์์ผ๋ฉด ์ค์ insert + ๋งคํ ์ฑ์
JsonNode arr = readJson("seed/erd/skill_master.json");
for (JsonNode n : arr) {
Long jsonId = n.get("id").asLong();
SkillMaster saved = skillMasterRepository.save(SkillMaster.builder().name(n.get("name").asText()).build());
skillIdMap.put(jsonId, saved.getId());
}
}
- ๋ชจ๋ seedXxx ๊ฐ ๊ณต์ ํ๋ 2๋จ ํจํด: โ
count() > 0์ด๋ฉด ์ฌ์ฝ์ ์์ด ๋งคํ๋ง ๋ณต์ํ๊ณ return(๋ฉฑ๋ฑ) โก ๋น์ด ์์ผ๋ฉด ์ฝ์ ํ๋ฉฐ ๋งคํ ์ฑ์. - ์ ๋งคํ ๋ณต์์ด ํ์ํ๊ฐ: ๊ฐ์ ๊ธฐ๋ ์ฌ์ดํด์์ ๋ค๋ฐ๋ฅด๋ seed(์:
client_preferred_skill)๊ฐskillIdMap์ ์ฐธ์กฐํ๊ธฐ ๋๋ฌธ. ์ด๋ฏธ DB ์ ์์ด๋ ์ด๋ฒ ๊ธฐ๋์ ๋ฉ๋ชจ๋ฆฌ ๋งต์ ๋น์ด ์์ผ๋ ๋ค์ ์ฑ์์ค์ผ FK ์ฐ๊ฒฐ์ด ๋จ.
JSON ์ฝ๊ธฐ + ์์ ์ถ์ถ ํฌํผ โ DataSeeder.java:622-664
// L622-L664 (๋ฐ์ท)
private JsonNode readJson(String classpathLocation) throws Exception {
try (InputStream is = new ClassPathResource(classpathLocation).getInputStream()) {
return objectMapper.readTree(is); // classpath:seed/erd/*.json
}
}
private static String text(JsonNode n, String f) { JsonNode v=n.get(f); return (v==null||v.isNull())?null:v.asText(); }
private static Integer intOrNull(JsonNode n, String f) { ... } // ์์ผ๋ฉด null
private static Boolean boolOrNull(JsonNode n, String f){ ... }
private static String jsonString(JsonNode n, String f){ JsonNode v=n.get(f); return (v==null||v.isNull())?null:v.toString(); }
private static LocalDate parseDate(String s) { try { return LocalDate.parse(s); } catch(Exception e){ return null; } }
- ์๋ JSON ์
classpath:seed/erd/*.json(build.gradle ๊ฐ ๋ณต์ฌ).objectMapper.readTree๋ก ํธ๋ฆฌ ํ์ฑ. - ํฌํผ๋ค์ ํ๋๊ฐ ์๊ฑฐ๋ null ์ด์ด๋ ์ ํฐ์ง๊ฒ ๋ฐฉ์ด์ ์ผ๋ก ์ถ์ถ(
null/๊ธฐ๋ณธ๊ฐ ๋ฐํ). ์๋ JSON ์ด ๋ค์ญ๋ ์ญํด๋ ๊ฒฌ๋๊ฒ ํ๋ ์ฅ์น. EnumMapper.xxx(text(n, "..."))์กฐํฉ์ผ๋ก ์๋ฌธ์ ๋ฌธ์์ด โ enum ๋ณํ(์๋ EnumMapper ์ฐธ๊ณ ).
๐ก ์ด๋ณด ํฌ์ธํธ: ์๋๋ "ํ ๋ฒ ์ ๋๋ฉด ๋"์ด ์๋๋ผ ๋งค ๊ธฐ๋๋ง๋ค ๋๋๋ค. ๊ทธ๋์ ๋ฉฑ๋ฑ์ฑ๊ณผ "์ด๋ฏธ ์์ผ๋ฉด ๋งคํ๋ง ๋ณต์" ํจํด์ด ํต์ฌ. ์ ์๋ ๋ฉ์๋๋ฅผ ์ถ๊ฐํ ๋ ๋ฐ๋์ ์ด 2๋จ ํจํด์ ๋ฐ๋ฅด์ธ์.
K. DataCleanupService โ "์์ ํ ๋ฐ์ดํฐ 50๊ฐ๋ง" ์ ๋ฆฌ (seed/DataCleanupService.java)
๋
๋ฆฝ ํธ๋์ญ์
+ ์ ์ํ ์ ๋ฆฌ cleanupProjects() โ DataCleanupService.java:110-156
// L110-L156 (์์ฝ)
@Transactional(propagation = Propagation.REQUIRES_NEW) // โ
๋
๋ฆฝ ์ปค๋ฐ
public int cleanupProjects() {
List<Project> all = projectRepository.findAll();
if (all.size() <= KEEP_PROJECTS) { return 0; } // ๋ฉฑ๋ฑ: ์ด๋ฏธ 50 ์ดํ๋ฉด skip
// ๋ชจ๋ ํ๋ก์ ํธ ์ ์ํ โ ๋ด๋ฆผ์ฐจ์ ์ ๋ ฌ โ ์์ 50 ์ ์ง, ๋๋จธ์ง ์ญ์
List<Project> ranked = all.stream().map(p -> Map.entry(p, scoreProject(...))).sorted(๋ด๋ฆผ์ฐจ์)...
List<Project> toRemove = ranked.stream().skip(KEEP_PROJECTS).toList();
// FK ์์กด ๋ฐ์ดํฐ ๋จผ์ ์ญ์ (escrowโmilestoneโmoduleโattachmentโmeetingโtagโskillโapplication)
for (...) { ...deleteAll... }
projectRepository.deleteAll(toRemove);
}
REQUIRES_NEWโ DataSeeder ๊ฐ ์ง์ ํธ์ถํ ๋ ๋ ๋ฆฝ ํธ๋์ญ์ ์ผ๋ก ์ปค๋ฐ(์์ ๋ณธ self-invocation ํํผ์ ์ง).- ๋ฉฑ๋ฑ ํต์ฌ:
all.size() <= 50์ด๋ฉด ์ฆ์return 0. ํ ๋ฒ 50์ผ๋ก ์ค๋ฉด ์ฌ๊ธฐ๋ํด๋ ์ถ๊ฐ ์ญ์ ์์. - ์ ์ํ ์ ๋ฆฌ: ๋ชจ๋ ํ๋ก์ ํธ์
scoreProject์ ์๋ฅผ ๋งค๊ฒจ ์์ 50๊ฐ๋ง ๋จ๊น. FK ์์๋ค์ ์ญ์์ผ๋ก ๋จผ์ ์ญ์ ํด์ผ ๋ถ๋ชจ ์ญ์ ์ ์ ์ฝ ์๋ฐ์ด ์ ๋จ.
์์ ์ฑ ์ ์ scoreProject() โ DataCleanupService.java:158-179
// L158-L179 (์์ฝ)
private long scoreProject(Project p, List<ProjectModule> mods, long milestoneCount) {
if (p.getTitle()==null||p.getTitle().isBlank()) return Long.MIN_VALUE; // ๊ฒฐ๊ฒฉ โ ์ตํ์
if (p.getBudgetAmount()==null||p.getBudgetAmount()<=0) return Long.MIN_VALUE;
if (mods.size() < 7) return Long.MIN_VALUE; // 7๊ฐ ๋ชจ๋ ๋ฏธ๋ง ๊ฒฐ๊ฒฉ
if (milestoneCount < 1) return Long.MIN_VALUE;
long score = milestoneCount*100 + mods.size()*50;
for (ProjectModule m : mods) { score += (data ๊ธธ์ด)/50; if ("ํ์์๋ฃ".equals(m.getStatus())) score += 30; }
if (desc ๊ธธ์ด>30) score += 50; if (serviceField!=null) score += 20;
if (status==IN_PROGRESS) score += 200; if (status==COMPLETED) score += 150;
return score;
}
- ๊ฒฐ๊ฒฉ ์กฐ๊ฑด(์ ๋ชฉยท์์ฐยท7๋ชจ๋ยท๋ง์ผ์คํค ์ค ํ๋๋ผ๋ ๋ถ์ค) ์ด๋ฉด
Long.MIN_VALUEโ ์ ๋ ฌ ์ ๋งจ ๋ค โ ์ฐ์ ์ญ์ ๋์. - ํต๊ณผํ ๊ฒ๋ผ๋ฆฌ๋ ๋ฐ์ดํฐ ํ๋ถ๋(๋ชจ๋ ๋ฐ์ดํฐ ๊ธธ์ดยทํ์์๋ฃยท์ค๋ช ยท์งํ์ํ) ๋ก ๊ฐ์ โ "์ง์ง ์ธ๋งํ" ํ๋ก์ ํธ๊ฐ ์ด์๋จ๋๋ก.
cleanupClients()(L181-205)๋ ๊ฐ์ ์ฒ ํ:scoreClient๋ก bio/industry/๊ฐ์ /์์ฐ ์ถฉ์ค๋๋ฅผ ์ ์ํํด ์์ 50๋ช ์ ์ง.
orphan ์ ๋ฆฌ cleanupOrphans() โ DataCleanupService.java:78-103
// L78-L103 (์์ฝ)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int cleanupOrphans() {
// ๋ถ๋ชจ projects ๊ฐ ์ฌ๋ผ์ก๋๋ฐ ๋จ์ ์์ ํ ์ผ๊ด ์ญ์ (native SQL)
em.createNativeQuery("DELETE FROM project_modules WHERE project_id NOT IN (SELECT id FROM projects)").executeUpdate();
em.createNativeQuery("DELETE FROM project_milestones WHERE project_id NOT IN (SELECT id FROM projects)").executeUpdate();
em.createNativeQuery("DELETE FROM project_escrows WHERE project_id NOT IN (SELECT id FROM projects)").executeUpdate();
}
- ๊ณ ์(orphan) ํ = ๋ถ๋ชจ๊ฐ ์ญ์ ๋๋๋ฐ ์ด์๋จ์ ์์. native SQL
NOT IN (SELECT id FROM projects)๋ก ์ผ๊ด ์ฒญ์. ๋งค ๊ธฐ๋ ์คํํด๋ ์์ (์์ผ๋ฉด 0๊ฑด ์ญ์ = ๋ฉฑ๋ฑ).
L. AiModelCatalogSeeder โ AI ๋ฉ๋ดํ ๋ฑ๋ก (seed/AiModelCatalogSeeder.java)
// L22-L57 (์์ฝ)
@Component @Order(100)
public class AiModelCatalogSeeder implements CommandLineRunner {
public void run(String... args) {
// ์ ์ฑ
๋ณ๊ฒฝ: ๊ณผ๊ฑฐ ์๋๋ Perplexity ๋ชจ๋ธ์ ์ ๊ฑฐ
repo.findByEnabledTrueOrderBySortOrderAsc().stream()
.filter(m -> m.getProvider() == Provider.PERPLEXITY).forEach(repo::delete);
if (repo.count() > 0) return; // ๋ฉฑ๋ฑ
List<AiModelCatalog> seed = List.of(
model("gemini-2.5-flash", ..., GEMINI, 200_000L, -1L, 10), // ๋ฌด๋ฃํ๋ 20๋งํ ํฐ, pro ๋ฌด์ ํ(-1)
model("gemini-2.5-pro", ..., GEMINI, 0L, 500_000L, 20),
model("claude-sonnet-4", ..., ANTHROPIC, 0L, 300_000L, 30),
model("claude-opus-4", ..., ANTHROPIC, 0L, 100_000L, 40),
model("gpt-4o-mini", ..., OPENAI, 100_000L, -1L, 50),
model("gpt-4o", ..., OPENAI, 0L, 300_000L, 60));
repo.saveAll(seed);
}
}
@Order(100)โ ์ฌ๋ฌ CommandLineRunner ์ค ์คํ ์์ ์ง์ (์์์๋ก ๋จผ์ ; DataSeeder ์ ๋ ๋ฆฝ์ ์ผ๋ก ๋์).- prod/local ๋ชจ๋ ์ ์ฉ(@Profile ์์) โ ๋ชจ๋ธ ์นดํ๋ก๊ทธ๋ ์ด์์๋ ํ์ํ๋๊น. (DataSeeder ์
@Profile("!prod")์ ๋๋น.) - ๋ฉฑ๋ฑ ํจํด์ ๋์ผ(
count() > 0์ด๋ฉด return). ๋จ, ๊ทธ ์ ์ ์ ์ฑ ๋ณ๊ฒฝ๋ถ(Perplexity) ์ ๋ฆฌ๋ฅผ ๋จผ์ ์ํ โ "๋ฉ๋ดํ ๊ฐฑ์ ". freeQuota/proQuota์-1L์ ๋ฌด์ ํ์ ์๋ฏธํ๋ ์ผํฐ๋ฌ ๊ฐ. (Gemini flashยทGPT-4o mini ์ ๋ฌด๋ฃ/Pro ํ๋๊ฐ -1.)
M. EnumMapper โ ๋ฒ์ญ๊ธฐ (util/EnumMapper.java)
// L11-L33, L180-L189 (๋ฐ์ท)
public final class EnumMapper {
private EnumMapper() {}
public static User.UserType userType(String s) {
if (s == null) return null;
return switch (s.toLowerCase()) {
case "client","user","free" -> User.UserType.FREE;
case "partner","pro","standard" -> User.UserType.STANDARD;
case "premium" -> User.UserType.PREMIUM;
default -> null;
};
}
public static Project.ProjectStatus projectStatus(String s) {
if (s == null) return Project.ProjectStatus.RECRUITING; // โ null ๊ธฐ๋ณธ๊ฐ ์์
return switch (s.toLowerCase()) {
case "recruiting" -> ProjectStatus.RECRUITING;
case "in_progress" -> ProjectStatus.IN_PROGRESS;
...
default -> ProjectStatus.RECRUITING; // โ ๋ชจ๋ฅด๋ ๊ฐ๋ ๊ธฐ๋ณธ๊ฐ
};
}
}
- ์ญํ : ERD v2 ์ ์๋ฌธ์ JSON enum ๋ฌธ์์ด โ ๋ฐฑ์๋ UPPERCASE enum(์ ํฉํ:
docs/ERD_v2_enum_alignment.md). DataSeeder ๊ฐ ์๋ JSON ์ ์ํฐํฐ๋ก ๋ฐ๊ฟ ๋ ์ฌ์ฉ. switchํํ์ ํ์ดํ(->) ๋ฌธ๋ฒ(Java 14+): break ๋ถํ์, ๊ฐ์ ๋ฐ๋ก ๋ฐํ.s.toLowerCase()๋ก ๋์๋ฌธ์ ๋ฌด์.- ๋ณ์นญ ํก์๊ฐ ๋๋ํจ: ์)
client/user/free๊ฐ ๋ชจ๋FREE๋ก,partner/pro/standard๊ฐSTANDARD๋ก. ์ ERD ์ฉ์ด์ ์ ๋ฑ๊ธ์ ๋ฅผ ๋์์ ์์ฉ. - ๋ ๊ฐ์ง ์ ์ฑ
์ด ๋ฉ์๋๋ง๋ค ๋ค๋ฆ: โ ์ผ๋ถ๋ ๋ชจ๋ฅด๋ฉด
null(์:userType,gender) โก ์ผ๋ถ๋ ์์ ํ ๊ธฐ๋ณธ๊ฐ(์:projectStatusโRECRUITING,clientTypeโINDIVIDUAL,visibilityโPUBLIC). โ "์ด ํ๋๋ ๋น์ด๋ ๋๋/์ ๋๋"์ ๋ฐ๋ฅธ ์ ํ.
N. TerminalWebSocketHandler + Config โ ๋ก์ปฌ ์ ์ฉ ์
ธ ์ฝ์ผํธ (terminal/)
๋ฑ๋ก ๊ฒ์ดํธ โ TerminalWebSocketConfig.java:15-24
// L15-L24
@Configuration @EnableWebSocket
@ConditionalOnProperty(name = "app.terminal.enabled", havingValue = "true") // โ
์ด ์ค์ ์์ฒด๊ฐ ์ตํธ์ธ
public class TerminalWebSocketConfig implements WebSocketConfigurer {
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new TerminalWebSocketHandler(), "/ws/terminal").setAllowedOriginPatterns("*");
}
}
@ConditionalOnProperty(...havingValue="true")โapp.terminal.enabled=true์ผ ๋๋ง ์ด ๋น/์๋ํฌ์ธํธ๊ฐ ์กด์ฌ. ์ด์ ๊ธฐ๋ณธ์ OFF โ/ws/terminal์์ฒด๊ฐ ์ ์๊น.
์ฐ๊ฒฐ ์ loopback ๊ฒ๋ฌธ โ TerminalWebSocketHandler.java:34-64
// L34-L64 (์์ฝ)
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
if (!isLoopback(session)) { // โ
127.0.0.1 / ::1 ๋ง ํ์ฉ
session.close(CloseStatus.POLICY_VIOLATION.withReason("local only")); return;
}
String shell = shellFor(session); // ?shell=powershell|cmd|bash|sql
ProcessBuilder pb = new ProcessBuilder(shellCmd(shell));
pb.redirectErrorStream(true); // stderr ๋ฅผ stdout ์ผ๋ก ํฉ์นจ
pb.directory(new File(System.getProperty("user.dir")));
Process p = pb.start();
procs.put(session.getId(), p);
// ์ถ๋ ฅ ํํ ์ค๋ ๋ + ์ข
๋ฃ ๊ฐ์ ์ค๋ ๋(๋ ๋ค daemon)
}
- 2์ค ์์ ์ฅ์น: โ ์ค์ OFF ๊ธฐ๋ณธ(Config) โก loopback ์ ์๋ง ํ์ฉ(ํธ๋ค๋ฌ). ์ธ๋ถ IP ๋ฉด ์ฆ์
POLICY_VIOLATION์ผ๋ก ๋์ โ ์น์์ ์์ ์ ธ ์คํ(RCE) ์ฐจ๋จ. - ์ธ์
๋ง๋ค ์
ธ ํ๋ก์ธ์ค 1๊ฐ(
procs๋งต์ ๋ณด๊ด).redirectErrorStream(true)๋ก ์๋ฌ๋ ๊ฐ์ ์คํธ๋ฆผ์ โ ํด๋ผ์ด์ธํธ ํ ํ๋ฉด์ ํ์. ?shell=์ฟผ๋ฆฌ๋ก powershell/cmd/bash/sql ์ ํ(shellCmd๊ฐ OS๋ณ ์คํ ์ธ์ ๋งคํ). ํ์ดํ ๊ธฐ๋ฐ์ด๋ผ ์์ ํ PTY ๊ฐ ์๋ โ vim ๊ฐ์ ํ์คํฌ๋ฆฐ TUI ๋ ์ ํ, lean/git/python ๋ช ๋ น์ ์ถฉ๋ถ(ํด๋์ค ์ฃผ์).
stdin ์ค๊ณ / ์ ๋ฆฌ โ TerminalWebSocketHandler.java:66-102
// L66-L102 (์์ฝ)
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
Process p = procs.get(session.getId());
if (p == null || !p.isAlive()) return;
p.getOutputStream().write(message.getPayload().getBytes(UTF_8)); flush(); // WS ์
๋ ฅ โ ํ๋ก์ธ์ค stdin
}
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
Process p = procs.remove(session.getId());
if (p != null) p.destroyForcibly(); // โ
์ธ์
๋๊ธฐ๋ฉด ํ๋ก์ธ์ค ๊ฐ์ ์ข
๋ฃ(์ข๋น ๋ฐฉ์ง)
}
private void pumpOutput(...) { while(read) sendSafe(session, ...); } // ํ๋ก์ธ์ค stdout โ WS
- ์๋ฐฉํฅ: WS ํ
์คํธ โ ํ๋ก์ธ์ค stdin(
handleTextMessage), ํ๋ก์ธ์ค stdout โ WS(pumpOutput์ค๋ ๋). - ์ธ์
์ข
๋ฃ ์
destroyForcibly()๋ก ์ ธ ํ๋ก์ธ์ค๋ฅผ ๋ฐ๋์ ์ฃฝ์ โ ์ข๋น ํ๋ก์ธ์ค ๋์ ๋ฐฉ์ง.sendSafe๋synchronized(session)๋ก ๋์ ์ ์ก ์ถฉ๋์ ๋ง์.
โ ๏ธ ์ด๊ฑด ๊ฐ๋ฐ ํธ์ ๋๊ตฌ์ ๋๋ค. ์ด์์์ ์ ๋ ์ผ์ง ๋ง์ธ์(์ค์ OFF ์ ์ง). ์ผ๋๋ผ๋ loopback ๊ฒ๋ฌธ์ด 1์ฐจ ๋ฐฉ์ด์ .
โ ๏ธ ํจ์ ยท๋ณด์ ์ฃผ์ (์ฝ๋์ ๋ฐํ ๊ตํ ๋ชจ์)
M10 โ ํํฐ ์์์
AuthContext์ฐ๋ฉด ํญ์ null (๊ฐ์ฅ ์ค์)AuthContext.currentUserId()๋RequestContextHolder์์กด์ธ๋ฐ, ์ด๊ฑด DispatcherServlet ์ดํ์์ผ ์ฑ์์ง. ์๋ธ๋ฆฟ ํํฐ(๋์คํจ์ฒ ์ด์ ) ์์ ๋น์ด ์์ด ํญ์ null.- ๊ฒฐ๊ณผ(๊ณผ๊ฑฐ ๋ฒ๊ทธ): ๋ชจ๋ ์ฌ์ฉ์๊ฐ null ๋ก ๋ฌถ์ฌ ๋ ์ดํธ๋ฆฌ๋ฐ์ด ์ ์ ๋ฌด๋ ฅํ.
- ์ ๋ต 2์ข
์ธํธ: โ
AiRateLimitFilter๋request.getAttribute(ATTR_USER_ID)์ง๋ โก ํํฐ ์์(JWT@Order(+10)๊ฐ RateLimit@Order(+20)๋ณด๋ค ๋จผ์ ์คํ๋ผ attribute ๋ฅผ ๋ฏธ๋ฆฌ ์ฑ์). ๋ ์ค ํ๋๋ง ์ด๊ธ๋๋ ๊นจ์ง.
Spring Security ํ๋ ์์ํฌ ์์ฒด๊ฐ OFF
build.gradle์์spring-boot-starter-security๋ ์ฃผ์ ์ฒ๋ฆฌ,spring-security-crypto(BCrypt)๋ง ์์กด. โSecurityFilterChainยท์๋ ์ธ์ฆ/์ธ๊ฐ๊ฐ ์์.- ์ธ์ฆ์ ์ ๋ถ ์๋(
JwtAuthenticationFilter+AuthContext). ๊ทธ๋์ "ํํฐ๊ฐ ๋ง์ง ์๊ณ ํต๊ณผ์ํค๊ณ , ์ปจํธ๋กค๋ฌ๊ฐrequireUserId()๋ก ๊ฑฐ๋ถ"ํ๋ ํจํด์ด ์ ์ผํ ๋ฐฉ์ด์ โ ์ปจํธ๋กค๋ฌ์์ ์ ์ ์ฒดํฌ๋ฅผ ๋น ๋จ๋ฆฌ๋ฉด ๊ณง ์ธ๊ฐ ๋๋ฝ. (์ต๊ทผ ์ปค๋ฐ "NotificationController IDOR ์ฐจ๋จ"์ด ๋ฐ๋ก ์ด ๊ณ์ด ์ฌ๊ณ .)
ํํฐ ์์ ์์กด์ฑ์ ์ฃผ์์ผ๋ก๋ง ๊ฐ์ ๋จ
@Order(+10)/@Order(+20)์ซ์ ๊ด๊ณ๊ฐ ๊นจ์ง๋ฉด ์กฐ์ฉํ ์ค์๋. ์ ํํฐ ์ถ๊ฐ ์ ์ด ์๋ ์์๋ฅผ ๋ฐ๋์ ๊ณ ๋ ค. ์ซ์๋ "์ ๋๊ฐ"์ด ์๋๋ผ "์๋ ์์"๋ผ๋ ์ ์ ์ ์.
์๋ ๋ฉฑ๋ฑ์ฑ โ
count()>0๊ฐ๋๋ ํ์- ์๋๋ ๋งค ๊ธฐ๋ ์คํ.
if (count()>0) return;๋น ๋จ๋ฆฌ๋ฉด ๋งค๋ฒ ์ค๋ณต ์ฝ์ . ์ seedXxx ์ถ๊ฐ ์ ๋ฐ๋์ โ ๋ฉฑ๋ฑ ๊ฐ๋ โก "์ด๋ฏธ ์์ผ๋ฉด ๋งคํ๋ง ๋ณต์" 2๋จ ํจํด. DataCleanupService๋size() <= 50๊ฐ๋๋ก ๋ฉฑ๋ฑ(์ฌ๊ธฐ๋ ์ ์ถ๊ฐ ์ญ์ ์์).
- ์๋๋ ๋งค ๊ธฐ๋ ์คํ.
app.crypto.key์ค๋ณต/ํด๋ฐฑ ์ฌ๊ณ (๋ฉ๋ชจ๋ฆฌ ๋ ธํธ์ ์ง๊ฒฐ)AesGcmCryptoService๋app.crypto.key์์ผ๋ฉดapp.jwt.secret์ผ๋ก ํด๋ฐฑ โ ๋์ด ๋ค๋ฅด๊ฑฐ๋,.env/application*.properties์ ๊ฐ์ ํค๊ฐ ๋ค๋ฅธ ๊ฐ์ผ๋ก ์ค๋ณต๋๋ฉด, ๋ณตํธํ ํค๊ฐ ๋ฐ๋์ด ๊ธฐ์กด ์ํธ๋ฌธ์ ๋ชป ํธ๋ ์ฌ๊ณ . ํ๊ฒฝ๋ณ์ ์ฐ์ ์์ ๋ณ๊ฒฝ ์ ์ค๋ณต ํค ๊ฐ์ฌ ํ์.
AOP self-invocation โ
REQUIRES_NEW๋ฌด์DataCleanupService.cleanupAll()๋ด๋ถ์์cleanupProjects()๋ฅผ ๋ถ๋ฅด๋ฉด ํ๋ก์๋ฅผ ์ ๊ฑฐ์ณ ํธ๋์ญ์ ์ด๋ ธํ ์ด์ ์ด ๋ฌด์๋จ. โ DataSeeder ๊ฐ ์ธ๋ถ์์ ์ง์ ๊ฐ ๋ฉ์๋๋ฅผ ํธ์ถํด ์ง์ง ๋ ๋ฆฝ ํธ๋์ญ์ ์ ๋ง๋ ๊ฒ.
JWT ํ์ด๋ก๋๋ ๋น๋ฐ์ด ์๋
- ์๋ช
์ผ๋ก ์์กฐ๋ ๋ง์ง๋ง ๋ด์ฉ์ ๋๊ตฌ๋ ๋์ฝ๋ ๊ฐ๋ฅ(Base64). claims ์ ๋ฏผ๊ฐ์ ๋ณด ๋ฃ์ง ๋ง ๊ฒ.
uid/type์ ๋๋ง.
- ์๋ช
์ผ๋ก ์์กฐ๋ ๋ง์ง๋ง ๋ด์ฉ์ ๋๊ตฌ๋ ๋์ฝ๋ ๊ฐ๋ฅ(Base64). claims ์ ๋ฏผ๊ฐ์ ๋ณด ๋ฃ์ง ๋ง ๊ฒ.
CORS + ์ฟ ํค ์กฐํฉ ์ฃผ์
allowCredentials(true)๋ฉดallowedOrigins("*")๊ธ์ง(๋ธ๋ผ์ฐ์ ๊ฑฐ๋ถ). ๋ฐ๋์ ๋ช ์์ ์ถ์ฒ ๋ชฉ๋ก. ์ด์์ ์ค์ ๋๋ฉ์ธ ๋ฃ๋ ๊ฑธ ์์ผ๋ฉด ํ๋ก ํธ๊ฐ ๋งํ.
ํฐ๋ฏธ๋์ ์ด์์์ ์ผ์ง ๋ง ๊ฒ
app.terminal.enabled๊ธฐ๋ณธ OFF + loopback ๊ฒ๋ฌธ. ์ผ๋ ์๊ฐ RCE ์ํ์ด ์๊ธฐ๋ฏ๋ก ๋ก์ปฌ ๊ฐ๋ฐ์์๋ง.
Jackson 2/3 ํผ์ฌ
- Boot4=Jackson3. ์
com.fasterxml.jackson.JsonNode๋ฅผ ์ปจํธ๋กค๋ฌ๊ฐ ๋ฐํํ๋ฉด ์ง๋ ฌํ๊ฐ ๊นจ์ง.Jackson2NodeBridgeConfig๊ฐ ์ธ๊ณผ์ ์ผ๋ก ๋ฉ์. ์ ์ฝ๋๋ ๊ฐ๊ธ์ Jackson3 ๋๋ DTO ์ฌ์ฉ.
- Boot4=Jackson3. ์
๐ ๊ณ ๋ํ ์์ด๋์ด (๊ฐ์ยท๊ฐ์ ๋ฒ์ ์ฉ)
- ๋ ์ดํธ๋ฆฌ๋ฐ Redis ์ ํ:
AiRateLimitFilter์ ์ธ๋ฉ๋ชจ๋ฆฌConcurrentHashMap์ ๋ค์ค ์ธ์คํด์ค์์ ํ๋๊ฐ ์ ํฉ์ณ์ง. Bucket4j + Redis(JCache)๋ก ์ฎ๊ธฐ๋ฉด ํด๋ฌ์คํฐ ์ ์ญ ํ๋. ๋ฑ๊ธ๋ณ ํ๋(FREE 20 / PRO 60)๋ ์ฌ์ฉ์type์ผ๋ก ๋ถ๊ธฐ ๊ฐ๋ฅ(ํ์ฌ ์๋ฆฌ๋ง ์๊ณ ๋ฏธ๊ตฌํ). - ์ง์ง Spring Security ๋์
๊ฒํ : ์๋ ํํฐ ๋ฐฉ์์ ๊ฐ๋ณ์ง๋ง ์ธ๊ฐ(authorization) ๋ฅผ ์ปจํธ๋กค๋ฌ๋ง๋ค ์์ผ๋ก ์ฒดํฌํด์ผ ํด IDOR/๋๋ฝ ์ํ.
@PreAuthorize๊ฐ์ ์ ์ธ์ ์ธ๊ฐ๋ก ์ฎ๊ธฐ๋ฉด ์ค์ ๊ฐ์. (๋จ, ํ๊ท ๋น์ฉ ํผ โ ์ ์ง ๋์ .) - AES ํค ํ์ (rotation): ํ์ฌ ๋จ์ผ ํค. ์ํธ๋ฌธ์ ํค ๋ฒ์ ํ๊ทธ๋ฅผ ๋ถ์ฌ ๋๋ฉด ํค ๊ต์ฒด ์ ์ ๋ฐ์ดํฐ๋ ์ ์ง ์ฌ์ํธํ ๊ฐ๋ฅ. KMS/Vault ์ฐ๋์ผ๋ก ํค๋ฅผ ์ฝ๋/ํ๊ฒฝ๋ณ์ ๋ฐ์ผ๋ก.
- ์๋๋ฅผ Flyway/๋ณ๋ import ๋ก ๋ถ๋ฆฌ: ๋ง์ด๊ทธ๋ ์ด์
ยท๋ฐฑํ ๋ก์ง(
migrateBudgetManToWonยทbackfillModulesโฆ)์ด ์๋์ ์์ฌ ๋น๋. 1ํ์ฑ ๋ง์ด๊ทธ๋ ์ด์ ์ Flyway repeatable/versioned ๋ก, ๋ฐ๋ชจ ์๋๋ ์๋๋ก ์ญํ ๋ถ๋ฆฌํ๋ฉด ๊ฐ๋ ์ฑยท์ฌํ์ฑโ. - CleanupService ์ ์ฑ
์ธ๋ถํ:
KEEP_PROJECTS=50๋ฑ ๋งค์ง๋๋ฒ์ ์ ์ ๊ฐ์ค์น๋ฅผ ์ค์ ์ผ๋ก ๋นผ๋ฉด ํ๊ฒฝ๋ณ ํ๋ ์ฉ์ด. ์ ์ ํจ์์ ๋จ์ ํ ์คํธ ์ถ๊ฐ(๊ฒฐ๊ฒฉ ์กฐ๊ฑด ํ๊ท ๋ฐฉ์ง). - ํฐ๋ฏธ๋์ PTY ๋ก: ํ์ฌ ํ์ดํ๋ผ ํ์คํฌ๋ฆฐ TUI ์ ํ.
pty4j๋ฑ์ผ๋ก ์ง์ง PTY ๋ฅผ ๋ถ์ด๋ฉด vim/htop ๊น์ง. (๋ณด์ ๊ฒ์ดํธ๋ ๋ ๊ฐํ ํ์.) - EnumMapper ์ญ๋ฐฉํฅ + ํ ์คํธ: enumโ๋ฌธ์์ด ์ญ๋ณํ๊ณผ, "๋ชจ๋ enum ๊ฐ์ด ๋งคํ๋๋์ง" ๊ฒ์ฆ ํ ์คํธ๋ฅผ ์ถ๊ฐํ๋ฉด ERD ์ ํฉ ๊นจ์ง์ ์ปดํ์ผ/ํ ์คํธ ๋จ๊ณ์์ ํฌ์ฐฉ.
๐ ์ฉ์ด ์ฌ์ (์ด ์์ญ ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
| ๋น(Bean) | Spring ์ด ๋ง๋ค์ด ๋ณด๊ดยท์ฃผ์
ํ๋ ์ฌ์ฌ์ฉ ๊ฐ์ฒด (@Component/@Bean) |
| DI(์์กด์ฑ ์ฃผ์ ) | ๊ฐ์ฒด๋ฅผ ์ง์ new ํ์ง ์๊ณ Spring ์ด ๋ผ์์ฃผ๋ ๊ฒ (@RequiredArgsConstructor) |
| ์๋ธ๋ฆฟ ํํฐ | ์ปจํธ๋กค๋ฌ ๋์ฐฉ ์ ๋ชจ๋ ์์ฒญ์ด ๊ฑฐ์น๋ ๊ด๋ฌธ. OncePerRequestFilter = ์์ฒญ๋น 1ํ |
@Order |
ํํฐ/๋ฌ๋ ์คํ ์์. ์ซ์ ์์์๋ก ๋จผ์ . HIGHEST_PRECEDENCE + N ์ผ๋ก ์๋ ์์ |
| request attribute | ์์ฒญ ๊ฐ์ฒด์ ๋ถ์ด๋ ์์ ๋ฉ๋ชจ(set/getAttribute). ์์ฒญ ๋๋๋ฉด ์๋ฉธ |
| RequestContextHolder | "ํ์ฌ ์ค๋ ๋์ ์์ฒญ"์ ์ด๋์๋ ๊บผ๋ด๋ ์ ์ญ ๋ณด๊ดํจ. DispatcherServlet ์ด ์ฑ์(ํํฐ ๋จ๊ณ์ ๋น) |
| JWT | ํค๋.ํ์ด๋ก๋.์๋ช
ํ ํฐ. ์๋ช
์ผ๋ก ์์กฐ ๋ฐฉ์ง, ๋ด์ฉ์ ๊ณต๊ฐ(์ํธํ ์๋) |
| HS256 | HMAC-SHA256 ๋์นญํค ์๋ช . ๊ฐ์ ๋น๋ฐํค๋ก ์๋ช ยท๊ฒ์ฆ |
| claims | JWT ํ์ด๋ก๋์ ์ฃผ์ฅ๊ฐ(์ฌ๊ธฐ์ uid, type, sub) |
| CORS | ๋ธ๋ผ์ฐ์ ์ ๊ต์ฐจ ์ถ์ฒ ํธ์ถ ํต์ ๊ท์น. allowCredentials+๋ช
์ ์ถ์ฒ๊ฐ ์ฟ ํค ์ธ์ฆ์ ์ง |
| ํ๋ฆฌํ๋ผ์ดํธ | ๋ณธ์์ฒญ ์ ๋ธ๋ผ์ฐ์ ๊ฐ ๋ณด๋ด๋ OPTIONS ์ฌ์ ํ์ธ. maxAge ๋ก ์บ์ |
| Bucket4j / ํ ํฐ๋ฒํท | ์์ฒญ=ํ ํฐ1์๋น, ์๊ฐ๊ฒฝ๊ณผ๋ก ๋ณด์ถฉํ๋ ๋ ์ดํธ๋ฆฌ๋ฐ ์๊ณ ๋ฆฌ์ฆ |
| BCrypt | ๋จ๋ฐฉํฅ ๋น๋ฐ๋ฒํธ ํด์(๋งค๋ฒ ๋ค๋ฅธ salt). matches๋ก ๊ฒ์ฆ |
| AES-GCM | ์ํธํ+๋ฌด๊ฒฐ์ฑ ํ๊ทธ๋ฅผ ๋์ ์ ๊ณตํ๋ ์๋ฐฉํฅ ์ํธ. IV ๋งค๋ฒ ๋๋ค 12B ํ์ |
| IV(์ด๊ธฐํ ๋ฒกํฐ) | ๊ฐ์ ํ๋ฌธ๋ ๋งค๋ฒ ๋ค๋ฅธ ์ํธ๋ฌธ์ด ๋๊ฒ ํ๋ ๋๋ค๊ฐ. ์ฌ์ฌ์ฉ ๊ธ์ง |
| ๋ฉฑ๋ฑ(idempotent) | ์ฌ๋ฌ ๋ฒ ์คํํด๋ ๊ฒฐ๊ณผ๊ฐ ๊ฐ์. ์๋์ count()>0 โ skip ํจํด |
| CommandLineRunner | ์ฑ ๊ธฐ๋ ์๋ฃ ์งํ run() 1ํ ์คํํ๋ ์ธํฐํ์ด์ค(์๋์ ์ฌ์ฉ) |
@Profile("!prod") |
์ด์์ด ์๋ ๋๋ง ๋น ํ์ฑํ(mock ์๋ ๊ฒฉ๋ฆฌ) |
@PostConstruct |
๋น ์์ฑ ์งํ 1ํ ์คํ๋๋ ์ด๊ธฐํ ํ |
REQUIRES_NEW |
ํธ์ถ ์ ํญ์ ์ ๋ ๋ฆฝ ํธ๋์ญ์ ์์(๋ณ๋ ์ปค๋ฐ/๋กค๋ฐฑ) |
| AOP self-invocation | ๊ฐ์ ๋น ๋ด๋ถ ๋ฉ์๋ ํธ์ถ์ ํ๋ก์๋ฅผ ์ ๊ฑฐ์ณ @Transactional์ด ๋ฌด์๋๋ ํ๊ณ |
| orphan(๊ณ ์) ํ | ๋ถ๋ชจ๊ฐ ์ญ์ ๋๋๋ฐ ๋จ์ ์์ FK ํ |
| loopback | 127.0.0.1 / ::1(์๊ธฐ ์์ ). ํฐ๋ฏธ๋์ด ์ด ์ฃผ์๋ง ํ์ฉํด RCE ์ฐจ๋จ |
| PTY vs ํ์ดํ | PTY=์์ ํ ๊ฐ์ ํฐ๋ฏธ๋(ํ์คํฌ๋ฆฐ TUI ๊ฐ๋ฅ), ํ์ดํ=๋จ์ ์ ์ถ๋ ฅ ์คํธ๋ฆผ(์ ํ์ ) |
domain/notification โ ์๋ฆผยท์ด๋ฉ์ผ ๋๋ฉ์ธ (์์ ๋ผ์ธ๋ณ ํด์ค)
์๋ณธ ๋๋ ํฐ๋ฆฌ:
backend/src/main/java/com/DevBridge/devbridge/domain/notification/(5๊ฐ ํ์ผ)
controller/NotificationController.java(88์ค)service/EmailAlertService.java(98์ค)entity/Notification.java(71์ค)repository/NotificationRepository.java(23์ค)dto/NotificationResponse.java(37์ค)์ด ๋ฌธ์๋ ๊ต์ฌ ํ์ค ํ์(README "3. ๊ณตํต ํ์")์ ๋ฐ๋ฆ ๋๋ค. Spring ๊ธฐ๋ณธ ๊ฐ๋ (
@RestControllerยท@ServiceยทJPAยทLombok ๋ฑ)์08_backend/00_spring_primer.md๋ฅผ ๋จผ์ ์ฝ์๋ค๊ณ ๊ฐ์ ํฉ๋๋ค.
๐ ์ด ๋๋ฉ์ธ ํ๋์
์ด ๋๋ฉ์ธ์ ์ฌ์ฉ์์๊ฒ ์์์ ์ ํ๋ "์๋ฆผ ์ฐํธํจ + ์ด๋ฉ์ผ ๋ฐ์ก์ค" ๋ ๊ฐ์ง๋ฅผ ๋ด๋นํฉ๋๋ค.
| ๋น์ | ์ค์ | ๋ด๋น |
|---|---|---|
| ์ฑ ์์ ์ฐํธํจ (์ข ๋ชจ์ ์์ด์ฝ์ ๋นจ๊ฐ ๋ฐฐ์ง) | ํ๋ฉด์ ์์ด๋ ์ธ์ฑ ์๋ฆผ โ ์ฝ์/์์ฝ์ ํ์ | Notification ์ํฐํฐ + NotificationController + NotificationRepository |
| ์ด๋ฉ์ผ ๋ฐ์ก์ค | ์ค๋์ ๋งค๋งค ์ ํธ๋ฅผ ๋ชจ์ ํ ํต์ ์ด๋ฉ์ผ๋ก ๋ฐ์ก | EmailAlertService |
ํต์ฌ์ ์ด ๋๋ฉ์ธ์ด ๋ ๊ฐ์ ๊ฑฐ์ ๋ ๋ฆฝ๋ ๊ธฐ๋ฅ์ด๋ผ๋ ์ ์ ๋๋ค. ํ๋๋ "DB์ ์์๋๊ณ ํ๋ฉด์์ ์ฝ๋ ์ธ์ฑ ์๋ฆผ", ๋ค๋ฅธ ํ๋๋ "์ด๋ฉ์ผ๋ก ๋ฐ์ด๋ด๋ ๋ค์ด์ ์คํธ"์ ๋๋ค. ๋์ ์๋ก ํธ์ถํ์ง ์์ต๋๋ค.
| ํด๋์ค | ํ ์ค ์ญํ | ๋น์ |
|---|---|---|
Notification (entity) |
DB์ NOTIFICATION ํ
์ด๋ธ ํ ์ค = ์๋ฆผ ํ๋(์ ๋ชฉยท๋ด์ฉยท์ฝ์์ฌ๋ถยท์ฐ๊ฒฐ๋์) |
์ฐํธํจ์ ๊ฝํ ํธ์ง ํ ํต |
NotificationRepository |
๊ทธ ํธ์ง๋ค์ ์ฌ์ฉ์๋ณยท์ฝ์์ฌ๋ถ๋ณ๋ก ๊บผ๋ด๋ JPA ์ธํฐํ์ด์ค | ์ฐํธํจ์ ๋ค์ง๋ ์ฌ์ |
NotificationResponse (dto) |
์ํฐํฐ๋ฅผ ํ๋ก ํธ์ ์ค ์์ ํ JSON ํํ๋ก ๋ฐ๊พผ ๊ฒ | ํธ์ง๋ฅผ ๋ดํฌ์ ๋ด์ ์ธ๋ถ๋ก ๋ด๋ณด๋ |
NotificationController |
/api/notifications REST ์
๊ตฌ. ์ ์์ JWT์์๋ง ํ์ธ(M9 ๋ณด์) |
์ฐํธํจ ์ฐฝ๊ตฌ ์ง์ |
EmailAlertService |
๋ฏธ๋ฐ์ก ์๊ทธ๋์ ์ฌ์ฉ์๋ณ๋ก ๋ฌถ์ด ๋น๋๊ธฐ ์ด๋ฉ์ผ ๋ฐ์ก | ์ด๋ฉ์ผ ๋ฐ์ก์ค |
โ ๏ธ ์ค์ํ ์ฌ์ค ํ๋:
NotificationController๊ฐ ์ค์ ์์ ์ ์ํค๋ ์๋น์ค๋ ์ด ๋๋ฉ์ธ ์์ ์์ต๋๋ค. ์ปจํธ๋กค๋ฌ๋domain/chatํจํค์ง์StreamChatService๋ฅผ ํธ์ถํฉ๋๋ค(์์ธํ ์ด์ ๋ ๋ผ์ธ๋ณ ํด์ค ์ฐธ๊ณ ). ์ฆ ์ธ์ฑ ์๋ฆผ์ "๋น์ฆ๋์ค ๋ก์ง"์ chat ๋๋ฉ์ธ์ ์นํ ์๊ณ , ์ด ๋๋ฉ์ธ์๋ ์ํฐํฐยท๋ ํฌ์งํ ๋ฆฌยทDTOยท์ปจํธ๋กค๋ฌ๋ง ์์ต๋๋ค.
๋๊ฐ ํธ์ถํ๋?
- ์ธ์ฑ ์๋ฆผ: ํ๋ก ํธ์๋(์์ โ ์๋ M5 ํจ์ ์ฐธ๊ณ )๊ฐ
/api/notifications๋ฅผ ํธ์ถ. - ์ด๋ฉ์ผ: ๋ฐฑ์๋ ์ค์ผ์ค๋ฌ(์ผ์ผ ์๊ทธ๋ ์์ฑ ํ
dispatchPending(์ค๋๋ ์ง)ํธ์ถ)๊ฐ ํธ๋ฆฌ๊ฑฐ.
๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
1) "์๋ฆผ ์ํฐํฐ" = DB ํ ์ด๋ธ์ ํ ์ค์ ์๋ฐ ๊ฐ์ฒด๋ก
Notification ๊ฐ์ฒด 1๊ฐ = NOTIFICATION ํ
์ด๋ธ์ 1ํ์
๋๋ค. JPA(Hibernate)๊ฐ ์๋ฐ ๊ฐ์ฒด โ DB ํ์ ์๋ ๋ณํํด ์ค๋๋ค. ์:
id โ user_id โ notification_type โ title โ is_read โ created_at
1 โ 42 โ NEW_MESSAGE โ ์ ๋ฉ์์ง ๋์ฐฉ โ false โ 2026-06-01 09:00์ด ํ ์ค์ด ์๋ฐ์์๋ Notification ๊ฐ์ฒด๊ฐ ๋๊ณ , ํ๋ก ํธ๋ก ๋๊ฐ ๋ NotificationResponse(JSON)๊ฐ ๋ฉ๋๋ค.
2) "์ฝ์ ์ํ(is_read)" = ์ ์ฝ์/์ฝ์ ํ ๊ธ
- ์ ์๋ฆผ์
is_read = false(์ ์ฝ์)๋ก ํ์ด๋ฉ๋๋ค. ์ข ์์ด์ฝ ์ ๋นจ๊ฐ ์ซ์ ๋ฐฐ์ง๊ฐ ๊ณง "์ ์ฝ์ ๊ฐ์"์ ๋๋ค. - ์ฌ์ฉ์๊ฐ ์๋ฆผ์ ๋ณด๋ฉด
is_read = true๋ก ๋ฐ๋๋๋ค. ํ ๊ฑด์ฉ(/{id}/read) ๋๋ ์ ์ฒด(/read-all) ์ฒ๋ฆฌ ๊ฐ๋ฅ. - "์ ์ฝ์ ๊ฐ์"๋ฅผ ๋น ๋ฅด๊ฒ ์ธ๋ ค๊ณ
countByUserAndIsReadFalse๊ฐ์ ์ ์ฉ ์ฟผ๋ฆฌ๋ฅผ ๋ก๋๋ค(์ ๋ถ ๊ฐ์ ธ์ ์ธ์ง ์์ โ ๊ฐ๋ณ๊ณ ๋น ๋ฆ).
3) Gmail SMTP = ์๋ฐ๊ฐ ๋ฉ์ผ ์๋ฒ๋ฅผ ํตํด ๋ฉ์ผ์ ์๋ ๊ธธ
JavaMailSender๋ Spring์ด ์ฃผ๋ "๋ฉ์ผ ๋ฐ์ก๊ธฐ"์ ๋๋ค. ๋ค์์ Gmail์ SMTP ์๋ฒ์ ์ ์ํด ๋ฉ์ผ์ ๋ณด๋ ๋๋ค.- ์ ์์ ํ์ํ ๊ณ์ ยท๋น๋ฐ๋ฒํธ๋ ์ค์ ๊ฐ(
spring.mail.username,spring.mail.password)์์ ์ต๋๋ค. Gmail์ ์ผ๋ฐ ๋น๋ฐ๋ฒํธ๊ฐ ์๋๋ผ "์ฑ ๋น๋ฐ๋ฒํธ" ๋ฅผ ์จ์ผ ํฉ๋๋ค(CLAUDE.md์MAIL_PASSWORD). SimpleMailMessage= ๊ฐ์ฅ ๋จ์ํ ํํ์ ๋ฉ์ผ(๋ฐ๋์ฌ๋ยท์ ๋ชฉยทํ๋ฌธ ๋ณธ๋ฌธ). HTML์ด ์๋๋ผ ์ค๋ฐ๊ฟ๋ง ์๋ ํ ์คํธ์ ๋๋ค.
4) ๋น๋๊ธฐ(@Async) ๋ฐ์ก = "๋ณด๋ด๊ณ ๊ธฐ๋ค๋ฆฌ์ง ์๊ธฐ"
- ์ด๋ฉ์ผ ๋ฐ์ก์ ์ธ๋ถ ์๋ฒ(Gmail)์ ํต์ ํ๋ฏ๋ก ๋๋ฆด ์ ์์ต๋๋ค(์๋ฐฑ ms~์ ์ด).
@Async๋ฅผ ๋ถ์ด๋ฉด ์ด ๋ฉ์๋๋ ๋ณ๋ ์ค๋ ๋์์ ๋๊ณ , ํธ์ถํ ์ชฝ(์: ์๊ทธ๋ ์ค์ผ์ค๋ฌ)์ ๋ฐ๋ก ๋ค์ ์ผ์ ํฉ๋๋ค. โ ์ค์ผ์ค๋ฌ๊ฐ ๋ฉ์ผ ๋ฐ์ก์ ๊ธฐ๋ค๋ฆฌ๋ฉฐ ๋ฉ์ถ์ง(block) ์์ต๋๋ค.- ๋น์ : ํธ์ง๋ฅผ ์ฐ์ฒดํต์ ๋ฃ๊ณ ๊ณง์ฅ ๊ฐ๋ ๊ธธ์ ๊ฐ๋๋ค. ์ฐ์ฒด๋ถ๊ฐ ๋ฐฐ๋ฌ์ ๋๋ผ ๋๊น์ง ์ฐ์ฒดํต ์์์ ์ ์์ง ์์ต๋๋ค.
5) IDOR(Insecure Direct Object Reference) โ M9๊ฐ ๋ง์ ๊ทธ๊ฒ
- "๋ด ์๋ฆผ"์ ๊ฐ์ ธ์ค๋ API๊ฐ
?userId=42์ฒ๋ผ ์์ฒญ ํ๋ผ๋ฏธํฐ๋ก ๋ฐ์ ID๋ฅผ ๊ทธ๋๋ก ๋ฏฟ์ผ๋ฉด, ๊ณต๊ฒฉ์๊ฐ?userId=43์ผ๋ก ๋ฐ๊ฟ ๋จ์ ์๋ฆผ์ ํ์ณ๋ณผ ์ ์์ต๋๋ค. ์ด๊ฒ์ด IDOR. - ํด๊ฒฐ: ์ ์์ ์ ๋ ์์ฒญ์์ ๋ฐ์ง ์๊ณ , ์์กฐ ๋ถ๊ฐ๋ฅํ JWT(๋ก๊ทธ์ธ ํ ํฐ)์์๋ง ๊บผ๋ ๋๋ค. ์ด ๋๋ฉ์ธ์ M9 ๋ณด์ ์์ ์ด ์ ํํ ์ด๊ฒ์ ๋๋ค.
๐บ ์์ฒญ ํ๋ฆ๋
(A) ์ธ์ฑ ์๋ฆผ ์กฐํยท์ฝ์ โ REST ํ๋ฆ
ํ๋ก ํธ์๋(๋ธ๋ผ์ฐ์ )
โ GET /api/notifications (JWT ์ฟ ํค ์๋ ์ฒจ๋ถ)
โผ
JwtAuthenticationFilter โโโ ์ฟ ํค์ JWT ๊ฒ์ฆ โ ์์ฒญ์ user_id ์์ฑ ๋ถ์ฐฉ
โ
โผ
NotificationController.getAll()
โ โ currentUser() โ AuthContext.currentUserId() โ JWT์์๋ง ์ ์ ์ทจ๋ (M9)
โ โ uid ์์ผ๋ฉด 401 Unauthorized
โ โผ
โ โก userRepository.findById(uid) โ User
โ โผ
โ โข streamChatService.getNotificationsForUser(user) โ chat ๋๋ฉ์ธ ์๋น์ค
โ โ
โ โผ
โ NotificationRepository.findByUserOrderByCreatedAtDesc(user)
โ โ (DB: ๋ด ์๋ฆผ๋ง, ์ต์ ์)
โ โผ
โ List<Notification>
โ โผ
โ โฃ .map(NotificationResponse::from) โ ์ํฐํฐ โ ์์ ํ JSON DTO
โผ
ResponseEntity.ok(List<NotificationResponse>) โ ํ๋ก ํธ JSON(B) ์ด๋ฉ์ผ ๋ค์ด์ ์คํธ โ ์ค์ผ์ค๋ฌ ํ๋ฆ
์ผ์ผ ์๊ทธ๋ ์ค์ผ์ค๋ฌ (์ฅ ๋ง๊ฐ ํ)
โ EmailAlertService.dispatchPending(์ค๋๋ ์ง)
โผ
DailySignalRepository.findByAsOfDateAndDeliveredAtIsNull(์ค๋) โ ์์ง ์ ๋ณด๋ธ ์๊ทธ๋
โ
โผ
์ฌ์ฉ์๋ณ ๊ทธ๋ฃนํ (groupingBy: s.getStrategy().getUser())
โ user42 โ [signalA, signalB]
โ user43 โ [signalC]
โผ
๊ฐ ์ฌ์ฉ์๋ง๋ค sendDigest(user, signals) @Async (๋ณ๋ ์ค๋ ๋)
โ โ ์ด๋ฉ์ผ ์ฃผ์ยท๋ฐ์ ์ฃผ์ ์์ผ๋ฉด skip
โ โก ์ ๋ชฉยท๋ณธ๋ฌธ ์กฐ๋ฆฝ (buildBody)
โ โข JavaMailSender.send(SimpleMailMessage) โ Gmail SMTP
โ โฃ ์ฑ๊ณต ์ ๊ฐ signal.deliveredAt = now โ saveAll (์ค๋ณต๋ฐ์ก ๋ฐฉ์ง)
โผ
(์คํจํด๋ catch๋ก ์ผ์ผ ๋ก๊ทธ๋ง โ ์ค์ผ์ค๋ฌ๋ ๊ณ์ ์งํ)
๋ ํ๋ฆ์ด ๋ง๋์ง ์๋๋ค๋ ์ ์ ๋ค์ ๊ฐ์กฐํฉ๋๋ค. (A)๋
Notificationํ ์ด๋ธ์ ์ฝ๊ณ , (B)๋DAILY_SIGNALํ ์ด๋ธ์ ์ฝ์ด ๋ฉ์ผ์ ์ฉ๋๋ค. ์ด๋ฉ์ผ ๋ฐ์ก์ ์ธ์ฑNotificationํ์ ๋ง๋ค์ง ์์ต๋๋ค.
๐ ๋ผ์ธ๋ณ ํด์ค
1) entity/Notification.java โ ์๋ฆผ ํ ํต์ ์ค๊ณ๋
๋จผ์ ์ํฐํฐ๋ถํฐ ๋ด ๋๋ค. ๋ชจ๋ ์๋ฆผ ๋ฐ์ดํฐ์ "๋ชจ์"์ด ์ฌ๊ธฐ์ ์ ์๋๊ธฐ ๋๋ฌธ์ ๋๋ค.
ํด๋์ค ์ ์ธ๊ณผ ์ด๋
ธํ
์ด์
โ Notification.java:11-23
// L11-L23
/**
* In-app system notifications.
* Covers both chat events (NEW_MESSAGE) and non-chat events (milestones, contracts, etc.).
*/
@Entity
@Table(name = "NOTIFICATION")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class Notification {
- ๋ฌด์์ ํ๋: ์ด ํด๋์ค๋ฅผ DB ํ
์ด๋ธ
NOTIFICATION๊ณผ ์ฐ๊ฒฐํฉ๋๋ค. @Entity+@Table(name="NOTIFICATION")โ "์ด ์๋ฐ ํด๋์ค 1๊ฐ = DB ํ ์ด๋ธ 1๊ฐ, ๊ฐ์ฒด 1๊ฐ = ํ 1๊ฐ".@Getter/@Setter(Lombok) โ ํ๋๋ง๋คgetId()/setId()๊ฐ์ ๋ฉ์๋๋ฅผ ์ปดํ์ผ ๋ ์๋ ์์ฑ. ์ฝ๋์๋ ์ ๋ณด์ด์ง๋ง ์กด์ฌํฉ๋๋ค.@NoArgsConstructor(๋น ์์ฑ์) +@AllArgsConstructor(๋ชจ๋ ํ๋ ์์ฑ์) +@Builder(๋น๋ ํจํด) โ ๊ฐ์ฒด๋ฅผ ์ฌ๋ฌ ๋ฐฉ์์ผ๋ก ๋ง๋ค ์ ์๊ฒ. JPA๋ ๋น ์์ฑ์๋ฅผ ํ์๋ก ์๊ตฌํฉ๋๋ค.@EntityListeners(AuditingEntityListener.class)โ ์๋@CreatedDate๊ฐ ์๋ํ๋๋ก "๊ฐ์์"๋ฅผ ๋ถ์. ์ ์ฅ ์ ์์ฑ์๊ฐ์ ์๋ ๊ธฐ๋ก.- ์ด๋ณด ํท๊ฐ๋ฆผ ํฌ์ธํธ: ํด๋์ค๋ช
์
Notification, ํ ์ด๋ธ๋ช ์ ๋๋ฌธ์NOTIFICATION. ๋์ด ๋ฌ๋ผ๋@Table(name=...)์ด ๋ค๋ฆฌ๋ฅผ ๋์์ค๋๋ค. ์ฃผ์์ "chat ์ด๋ฒคํธ์ ๋น-chat ์ด๋ฒคํธ(๋ง์ผ์คํคยท๊ณ์ฝ ๋ฑ)๋ฅผ ๋ชจ๋ ๋ค๋ฃฌ๋ค"๋ ์ค๋ช ์ ์ด ์ํฐํฐ๊ฐ ์๋ ๋ ํฐ ํ์ /๊ณ์ฝ ์์คํ ์์ ๊ฐ์ ธ์จ ํ์ ์ ๋๋ค(์๋ enum ์ฐธ๊ณ ).
๊ธฐ๋ณธํค(PK) โ Notification.java:25-27
// L25-L27
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Idโ ์ด ํ๋๊ฐ ๊ธฐ๋ณธํค(๊ฐ ํ์ ๊ณ ์ ๋ฒํธ).@GeneratedValue(strategy = IDENTITY)โ ๋ฒํธ๋ฅผ DB๊ฐ auto_increment ๋ก ์๋ ๋ถ์ฌ. ์ ์๋ฆผ์ ์ ์ฅํ๋ฉด DB๊ฐ 1, 2, 3โฆ ์ ๋งค๊น๋๋ค. ์ฐ๋ฆฌ๊ฐ ์ง์ id๋ฅผ ์ ํ์ง ์์ต๋๋ค.
์๋ฆผ ์ฃผ์ธ(์ฌ์ฉ์) โ Notification.java:29-31
// L29-L31
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOneโ ์ฌ๋ฌ(Many) ์๋ฆผ์ด ํ(One) ์ฌ์ฉ์์ ์ํจ. ํ ๋ช ์ด ์๋ฆผ์ ์ฌ๋ฌ ๊ฐ ๊ฐ์ง ์ ์์ผ๋๊น์.@JoinColumn(name="user_id", nullable=false)โ DB์๋user_id๋ผ๋ ์ธ๋ํค ์ปฌ๋ผ์ผ๋ก ์ ์ฅ.nullable=false= ์ฃผ์ธ ์๋ ์๋ฆผ์ ๊ธ์ง.fetch = FetchType.LAZYโ "ํ์ํ ๋๋ง" User๋ฅผ DB์์ ๊ฐ์ ธ์ด(์ง์ฐ ๋ก๋ฉ). ์๋ฆผ ๋ชฉ๋ก๋ง ๋ณผ ๋ User ์ ๋ณด๋ฅผ ๊ตณ์ด ์ ์ฝ์ด ์ฑ๋ฅ์ ์ ๋ฆฌ. (๋ฐ๋ EAGER๋ ํญ์ ๊ฐ์ด ์ฝ์.)- ์ ์ค์ํ๊ฐ: ์ด
userํ๋๊ฐ ๊ณง "์ด ์๋ฆผ์ ๋๊ตฌ ๊ฒ์ธ๊ฐ"์ ๊ทผ๊ฑฐ์ ๋๋ค. M9 IDOR ๋ฐฉ์ด, ์์ ๊ถ ์ฌํ์ธ(์๋markNotificationRead)์ด ๋ชจ๋ ์ด ํ๋๋ฅผ ๋น๊ตํฉ๋๋ค.
์๋ฆผ ์ข
๋ฅ(enum) โ Notification.java:33-35 + 57-70
// L33-L35
@Enumerated(EnumType.STRING)
@Column(name = "notification_type", nullable = false, length = 50)
private NotificationType notificationType;
// L57-L70
public enum NotificationType {
NEW_MESSAGE,
APPLICATION_ACCEPTED,
APPLICATION_REJECTED,
MILESTONE_SUBMITTED,
MILESTONE_APPROVED,
MILESTONE_REVISION_REQUESTED,
CONTRACT_ITEM_PROPOSED,
CONTRACT_ITEM_AGREED,
PROJECT_COMPLETED,
REVIEW_RECEIVED,
DEPOSIT_RECEIVED,
PROJECT_UPDATED
}
@Enumerated(EnumType.STRING)โ enum์ DB์ ๋ฌธ์์ด๋ก ์ ์ฅ("NEW_MESSAGE").EnumType.ORDINAL(0,1,2 ์ซ์)์ ์ฐ๋ฉด enum ์์๋ฅผ ๋ฐ๊ฟ ๋ ๊ธฐ์กด ๋ฐ์ดํฐ๊ฐ ์ด๊ธ๋๋ฏ๋ก, STRING ์ด ์์ ํ ๊ด์ต์ ๋๋ค.@Column(... length=50)โ ์ปฌ๋ผ ๊ธธ์ด 50์ ์ ํ.- ์์งํ ๊ด์ฐฐ(ํ๊ฐ ์๋, ์ค์ ์ฝ๋ ๊ธฐ๋ฐ): enum ๊ฐ 12๊ฐ ๋๋ถ๋ถ(
MILESTONE_*,CONTRACT_*,PROJECT_*,DEPOSIT_RECEIVED๋ฑ)์ ํํธ ํฌ์๊ฐ ์๋๋ผ ํ๋ฆฌ๋์/ํ์ ํ๋ก์ ํธ ๋๋ฉ์ธ ์ฉ์ด์ ๋๋ค. ์ด๋ ์ด ์ฝ๋๋ฒ ์ด์ค๊ฐ ๋ค๋ฅธ ํ๋ก์ ํธ(DevBridge โ ํ์ /๊ณ์ฝ ํ๋ซํผ)์์ ์ ๋ํด, ์๋ฆผ ์ํฐํฐ๋ฅผ ๊ทธ๋๋ก ๊ฐ์ ธ์จ ํ์ ์ ๋๋ค. ํ์ฌ Alpha-Helix์์ ์ค์ ๋ก ๋ง๋ค์ด์ง๋ ์๋ฆผ์ด ์ด ์ค ์ด๋ค ๊ฐ์ ์ฐ๋์ง๋ ์ด ๋๋ฉ์ธ ์ฝ๋๋ง์ผ๋ก๋ ์ ์ ์์ต๋๋ค(์์ฑ ์ธก ์ฝ๋๋ ๋ค๋ฅธ ๋๋ฉ์ธ). ํ์ต ์ "์ด enum์ ๋ฒ์ฉ ์๋ฆผ ์นดํ๋ก๊ทธ์ด๋ฉฐ, ํฌ์ ๋๋ฉ์ธ์ด ์ ๋ถ ์ฌ์ฉํ๋ ๊ฑด ์๋๋ค"๋ก ์ดํดํ์ธ์.
์ ๋ชฉยท๋ณธ๋ฌธ โ Notification.java:37-41
// L37-L41
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String message;
titleโ ์๋ฆผ ์ ๋ชฉ.length=200โ DB์VARCHAR(200). ๋น์ด ์์ ์ ์์(nullable=false).messageโ ์๋ฆผ ๋ณธ๋ฌธ.columnDefinition="TEXT"โ ๊ธธ์ด ์ ํ์ด ํฐTEXTํ์ (๊ธด ๋ฌธ์ฅ ๊ฐ๋ฅ).VARCHAR๊ฐ ์๋๋ผTEXT๋ฅผ ์ด ์ด์ ๋ ๋ณธ๋ฌธ์ด ๊ธธ ์ ์์ด์์ ๋๋ค.
์ฐ๊ฒฐ ๋์(ํด๋ฆญํ๋ฉด ์ด๋๋ก?) โ Notification.java:43-47
// L43-L47
@Column(name = "related_entity_type", length = 50)
private String relatedEntityType;
@Column(name = "related_entity_id")
private Long relatedEntityId;
- ์ด ๋ ํ๋๋ "์ด ์๋ฆผ์ด ๊ฐ๋ฆฌํค๋ ๋์" ์ ๋์จํ๊ฒ ๊ฐ๋ฆฌํต๋๋ค. ์:
relatedEntityType="STRATEGY",relatedEntityId=7โ "์ ๋ต 7๋ฒ์ ๊ดํ ์๋ฆผ". nullableํ๊ธฐ๊ฐ ์์ด null ํ์ฉ(๋ ๋ค ์ ํ์ฌํญ). ๋จ์ ์์คํ ๊ณต์ง์ฒ๋ผ ์ฐ๊ฒฐ ๋์์ด ์์ ์๋ ์์ผ๋๊น์.- ์ FK๊ฐ ์๋๋ผ type+id ๋ ์ปฌ๋ผ์ธ๊ฐ: ์๋ฆผ์ด ์ ๋ตยท๊ณ์ฝยทํ๋ก์ ํธ ๋ฑ ์ฌ๋ฌ ์ข ๋ฅ์ ๋์์ ๊ฐ๋ฆฌํฌ ์ ์์ด, ํน์ ํ ์ด๋ธ ํ๋์ ์ธ๋ํค๋ฅผ ๊ฑธ ์ ์์ต๋๋ค. ๊ทธ๋์ "ํ์ ๋ฌธ์์ด + id" ์กฐํฉ์ผ๋ก ๋คํ์ (polymorphic) ์ฐธ์กฐ๋ฅผ ํ๋ด๋ ๋๋ค. (DB ๋ฌด๊ฒฐ์ฑ ๋ณด์ฅ์ ์ฝํด์ง์ง๋ง ์ ์ฐํฉ๋๋ค.)
์ฝ์ ์ฌ๋ถ โ Notification.java:49-51
// L49-L51
@Column(name = "is_read", nullable = false)
@Builder.Default
private boolean isRead = false;
isReadโ ์ฝ์(true)/์์ฝ์(false). ์ ์๋ฆผ์false.@Builder.Defaultโ ๋น๋๋ก ๋ง๋ค ๋๋ ๊ธฐ๋ณธ๊ฐfalse๊ฐ ์ ์ฉ๋๊ฒ ํจ. ์ด๊ฒ ์์ผ๋ฉด@Builder๊ฐ boolean ๊ธฐ๋ณธ๊ฐfalse(์๋ฐ ์์๊ฐ ๊ธฐ๋ณธ)๋ก๋ง ์ฑ์ ํท๊ฐ๋ฆด ์ ์์ด, ๋ช ์์ ์ด๊ธฐํ์ ์๋๋ฅผ ๋ณด์กดํ๋ ค๋ ํ์์ ๋๋ค.- ์ด๋ณด ํท๊ฐ๋ฆผ: ํ๋๋ช
์
isRead์ธ๋ฐ Lombok์ด ๋ง๋๋ getter๋ boolean ๊ด์ต์isRead()(์์is๋ฅผ ์ค๋ณตํ์ง ์์)์ ๋๋ค. DTO ๋ณํ๋ถ์์n.isRead()๋ก ํธ์ถ๋๋ ์ด์ ์ ๋๋ค.
์์ฑ ์๊ฐ(์๋ ๊ธฐ๋ก) โ Notification.java:53-55
// L53-L55
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@CreatedDateโ ํ์ด ์ฒ์ ์ ์ฅ๋ ๋ ํ์ฌ ์๊ฐ์ ์๋ ๊ธฐ์ (์AuditingEntityListener๋๋ถ). ์ฐ๋ฆฌ๊ฐ ์ง์ ์ ๋ฃ์ด๋ ๋ฉ๋๋ค.updatable = falseโ ํ ๋ฒ ๊ธฐ๋ก๋๋ฉด ์์ ๋ถ๊ฐ(์์ฑ ์๊ฐ์ ๋ฐ๋๋ฉด ์ ๋๋๊น).- ์ด ์ปฌ๋ผ์ด "์ต์ ์ ์ ๋ ฌ"(
OrderByCreatedAtDesc)์ ๊ธฐ์ค์ด ๋ฉ๋๋ค.
2) repository/NotificationRepository.java โ ์๋ฆผ์ ๊บผ๋ด๋ ์ฌ์
// L12-L23
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserOrderByCreatedAtDesc(User user);
List<Notification> findByUserAndIsReadFalseOrderByCreatedAtDesc(User user);
long countByUserAndIsReadFalse(User user);
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.user = :user AND n.isRead = false")
void markAllReadByUser(@Param("user") User user);
}
extends JpaRepository<Notification, Long>โ ์ด๊ฒ๋ง ์์ํ๋ฉดsave/findById/findAll/delete๋ฑ ๊ธฐ๋ณธ CRUD๊ฐ ๊ณต์ง๋ก ์๊น๋๋ค. ๊ตฌํ ํด๋์ค๋ฅผ ์ฐ๋ฆฌ๊ฐ ์ ์งญ๋๋ค(Spring Data JPA๊ฐ ๋ฐํ์์ ์๋ ์์ฑ).- ์ฟผ๋ฆฌ ๋ฉ์๋(๋ฉ์๋ ์ด๋ฆ = ์ฟผ๋ฆฌ): Spring Data JPA๋ ๋ฉ์๋ ์ด๋ฆ์ ๋ถ์ํด SQL์ ์๋์ผ๋ก ๋ง๋ญ๋๋ค.
findByUserOrderByCreatedAtDesc(user)โ "์ด user์ ์๋ฆผ์ created_at ๋ด๋ฆผ์ฐจ์(์ต์ ๋จผ์ )์ผ๋ก ์ ๋ถ" =/api/notifications๊ฐ ์ฐ๋ ์ฟผ๋ฆฌ.findByUserAndIsReadFalseOrderByCreatedAtDesc(user)โ "์ด user์ ์ ์ฝ์ ์๋ฆผ๋ง, ์ต์ ์" =/unread๊ฐ ์ฐ๋ ์ฟผ๋ฆฌ.countByUserAndIsReadFalse(user)โ "์ด user์ ์ ์ฝ์ ์๋ฆผ ๊ฐ์"(๋ชฉ๋ก์ ์ ๊ฐ์ ธ์ค๊ณ ์ซ์๋ง) =/count(๋ฐฐ์ง)๊ฐ ์ฐ๋ ์ฟผ๋ฆฌ. ๋ชฉ๋ก ์ ์ฒด๋ฅผ ๋ฐ์ ์ธ๋ ๊ฒ๋ณด๋ค ํจ์ฌ ๊ฐ๋ณ์ต๋๋ค.
- ๋ฒํฌ ์
๋ฐ์ดํธ
markAllReadByUser:@Query("UPDATE ...")โ JPQL(์ํฐํฐ ๊ธฐ์ค ์ฟผ๋ฆฌ)๋ก "์ด user์ ์ ์ฝ์ ์๋ฆผ ์ ๋ถ๋ฅผisRead=true๋ก" ํ ๋ฐฉ์ ๊ฐฑ์ .@Modifyingโ ์ด๊ฒ ์ฝ๊ธฐ(SELECT)๊ฐ ์๋๋ผ ๋ณ๊ฒฝ(UPDATE) ์์ JPA์ ์๋ฆผ. ์์ผ๋ฉด ์คํ ์ ์๋ฌ.- ์ ์ง์ ์ฟผ๋ฆฌ์ธ๊ฐ: ์๋ฆผ์ด 100๊ฐ์ฌ๋ ๊ฐ์ฒด 100๊ฐ๋ฅผ ๋ฉ๋ชจ๋ฆฌ๋ก ๋ถ๋ฌ ํ๋์ฉ
setRead(true)ํ๋ฉด ๋๋ฆฝ๋๋ค. UPDATE ํ ๋ฌธ์ฅ์ด ํ ๋ฒ์ DB ์๋ณต์ผ๋ก ๋๋ ํจ์จ์ . - ์ด๋ณด ์ฃผ์:
@Modifying๋ฒํฌ ์ฟผ๋ฆฌ๋ ์์์ฑ ์ปจํ ์คํธ(1์ฐจ ์บ์)๋ฅผ ์ฐํํฉ๋๋ค. ๊ฐ์ ํธ๋์ญ์ ์์ ์ด๋ฏธ ๋ก๋ฉํ ์ํฐํฐ๊ฐ ์์ผ๋ฉด ์บ์์ DB๊ฐ ๋ถ์ผ์นํ ์ ์์ต๋๋ค(์ด ์ฝ๋ ํ๋ฆ์์ ์งํ ์๋ต์ ๋๋ด๋ฏ๋ก ๋ฌธ์ ์์).
3) dto/NotificationResponse.java โ ํ๋ก ํธ๋ก ๋ด๋ณด๋ผ ์์ ํ ๋ดํฌ
// L11-L23
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationResponse {
private Long id;
private String notificationType;
private String title;
private String message;
private String relatedEntityType;
private Long relatedEntityId;
private boolean isRead;
private LocalDateTime createdAt;
- ์ ์ํฐํฐ๋ฅผ ๊ทธ๋๋ก ์ ์ฃผ๊ณ DTO๋ฅผ ๋ฐ๋ก ๋๋ (DTO ํจํด์ ํต์ฌ ์ด์ ):
- ๋ณด์/์บก์ํ: ์ํฐํฐ
Notification์๋user(User ๊ฐ์ฒด ์ ์ฒด)๊ฐ ๋ค์ด ์์ต๋๋ค. ์ด๋ฅผ ๊ทธ๋๋ก JSON ์ง๋ ฌํํ๋ฉด ์ฌ์ฉ์ ๋น๋ฐ๋ฒํธ ํด์ยท์ด๋ฉ์ผ ๋ฑ ๋ฏผ๊ฐ์ ๋ณด๊ฐ ์์ด๋๊ฐ ์ํ์ด ์์ต๋๋ค. DTO๋user๋ฅผ ํต์งธ๋ก ๋นผ๊ณ , ์ธ๋ถ์ ์ค๋ ๋๋ ํ๋๋ง ๊ณจ๋ผ ๋ด์ต๋๋ค. - ์ง์ฐ๋ก๋ฉ ํจ์ ํํผ: ์ํฐํฐ์ LAZY ํ๋๋ฅผ ์ง๋ ฌํ ์์ ์ ๊ฑด๋๋ฆฌ๋ฉด
LazyInitializationException์ด ๋ ์ ์๋๋ฐ, DTO๋ก ๋ฏธ๋ฆฌ ํํํํ๋ฉด ์์ ํฉ๋๋ค. - ํ์
๋ณํ: enum
NotificationType์String(notificationType)์ผ๋ก ๋ฐ๊ฟ ํ๋ก ํธ๊ฐ ๋ค๋ฃจ๊ธฐ ์ฝ๊ฒ.
- ๋ณด์/์บก์ํ: ์ํฐํฐ
- ํ๋ ๊ตฌ์ฑ์ด ์ํฐํฐ์ ๋ฎ์์ง๋ง
user๊ฐ ์๋ค๋ ๊ฒ ํต์ฌ ์ฐจ์ด์ ๋๋ค.
์ํฐํฐ โ DTO ๋ณํ โ NotificationResponse.java:25-36
// L25-L36
public static NotificationResponse from(Notification n) {
return NotificationResponse.builder()
.id(n.getId())
.notificationType(n.getNotificationType().name())
.title(n.getTitle())
.message(n.getMessage())
.relatedEntityType(n.getRelatedEntityType())
.relatedEntityId(n.getRelatedEntityId())
.isRead(n.isRead())
.createdAt(n.getCreatedAt())
.build();
}
static ... from(Notification n)โ ์ ์ ํฉํ ๋ฆฌ ๋ฉ์๋. "์ํฐํฐ๋ฅผ ๋ฃ์ผ๋ฉด DTO๋ฅผ ๋ง๋ค์ด์ฃผ๋ ๋ณํ๊ธฐ". ์ปจํธ๋กค๋ฌ์์NotificationResponse::from(๋ฉ์๋ ์ฐธ์กฐ)์ผ๋ก ๊น๋ํ ํธ์ถ๋ฉ๋๋ค..notificationType(n.getNotificationType().name())โ enum์.name()์ผ๋ก ๋ฌธ์์ดํ(NEW_MESSAGEโ"NEW_MESSAGE")..isRead(n.isRead())โ boolean getter๋isRead()(์์ ์ค๋ช ํ Lombok ๊ด์ต).- ๋น ์ง ๊ฒ์ ์ฃผ๋ชฉ:
user๋ฅผ ๋ณต์ฌํ์ง ์์ต๋๋ค. ๊ทธ๋์ ์๋ต JSON์ ์ฌ์ฉ์ ๋ฏผ๊ฐ์ ๋ณด๊ฐ ์ ๋ ํฌํจ๋์ง ์์ต๋๋ค.
4) controller/NotificationController.java โ ์ฐฝ๊ตฌ + M9 IDOR ๋ฐฉ์ด
ํด๋์ค ์ฃผ์ = M9 ๋ณด์์ ํต์ฌ โ NotificationController.java:16-26
// L16-L26
/**
* Notification endpoints.
*
* <p>M9 ๋ณด์ ์์ : ์ด์ ์๋ {@code ?userId=} ์์ฒญ ํ๋ผ๋ฏธํฐ๋ฅผ ๊ทธ๋๋ก ์ ๋ขฐํด ์ธ์ฆ๋ ๋๊ตฌ๋ ํ์ธ์ ์๋ฆผ์
* ์กฐํ/์ฝ์์ฒ๋ฆฌํ ์ ์๋ IDOR ๊ฐ ์์๋ค. ์ด์ ์ ์์ ํญ์ JWT(AuthContext)์์๋ง ๊ฐ์ ธ์ค๊ณ ,
* ์์ฒญ ํ๋ผ๋ฏธํฐ๋ก ๋ฐ์ง ์๋๋ค. CORS ๋ WebConfig ๊ฐ ์ ์ญ ์ฒ๋ฆฌ.
*/
@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {
- M9๊ฐ ๋ฌด์์ ๊ณ ์ณค๋: ๊ณผ๊ฑฐ ์ฝ๋๋
GET /api/notifications?userId=42์ฒ๋ผ URL์userId๋ฅผ ๊ทธ๋๋ก ๋ฏฟ์์ต๋๋ค. ๋ก๊ทธ์ธ๋ง ํ์ผ๋ฉด ๋๊ตฌ๋?userId=43์ผ๋ก ๋ฐ๊ฟ ๋จ์ ์๋ฆผ์ ์กฐํ/์ฝ์์ฒ๋ฆฌํ ์ ์์์ต๋๋ค(์ ํ์ IDOR). - ๊ณ ์น ๋ฐฉ์: ๋ชจ๋ ์๋ํฌ์ธํธ์์
userIdํ๋ผ๋ฏธํฐ๋ฅผ ์์ ํ ์ ๊ฑฐํ๊ณ , ์ ์์ ์ค์งAuthContext.currentUserId()(= ์์กฐ ๋ถ๊ฐ๋ฅํ JWT)์์๋ง ๊ฐ์ ธ์ต๋๋ค. ํด๋ผ์ด์ธํธ๊ฐ ๋ณด๋ธ ๊ฐ์ ์ ์์ผ๋ก ์ ๋ ์ฐ์ง ์์ต๋๋ค. @RestControllerโ ๋ฐํ๊ฐ์ JSON ๋ณธ๋ฌธ์ผ๋ก(๋ทฐ ๋ ๋๋ง X).@RequestMapping("/api/notifications")โ ์ด ์ปจํธ๋กค๋ฌ์ ๋ชจ๋ ๊ฒฝ๋ก ์์ ๋ถ๋ ๊ณตํต ์ ๋์ฌ.@RequiredArgsConstructor(Lombok) โfinalํ๋๋ฅผ ๋ฐ๋ ์์ฑ์ ์๋ ์์ฑ โ ์๋ ๋ ์์กด์ฑ์ ์์ฑ์ ์ฃผ์ .
์์กด์ฑ โ NotificationController.java:28-29
// L28-L29
private final StreamChatService streamChatService;
private final UserRepository userRepository;
- ์ฌ๊ธฐ๊ฐ ๋๋ฉ์ธ ๊ตฌ์กฐ์ ํต์ฌ ๋จ์: ์ด ์ปจํธ๋กค๋ฌ๋
notification๋๋ฉ์ธ ์์ ์๋น์ค๊ฐ ์๋๋ผ,domain/chat์StreamChatService์ ์ผ์ ์์ํฉ๋๋ค(import com.DevBridge.devbridge.domain.chat.service.StreamChatService). - ์? ์ธ์ฑ ์๋ฆผ์ ์๋ "์ ์ฑํ
๋ฉ์์ง ๋์ฐฉ(NEW_MESSAGE)" ๊ฐ์ chat ์ด๋ฒคํธ์ ํจ๊ป ์์ฑ/๊ด๋ฆฌ๋์์ต๋๋ค. ๊ทธ๋์ ์๋ฆผ CRUD ๋ก์ง์ด ์ฑํ
์๋น์ค์ ํจ๊ป ๋ค์ด ์์ต๋๋ค(
StreamChatService.getNotificationsForUser๋ฑ). ์ด ๋๋ฉ์ธ์ ๋ฐ์ดํฐ ๋ชจ๋ธ(์ํฐํฐยท๋ ํฌยทDTO)๊ณผ REST ์ ๊ตฌ(์ปจํธ๋กค๋ฌ)๋ง ์์ ํ๊ณ , ๋ก์ง์ chat ๋๋ฉ์ธ์ ์นํ ์๋ ๊ตฌ์กฐ์ ๋๋ค. userRepository๋ JWT์์ ์ป์ user_id๋ก ์ค์ User๊ฐ์ฒด๋ฅผ ์กฐํํ๋ ๋ฐ ์๋๋ค.
GET ์ ์ฒด ๋ชฉ๋ก โ NotificationController.java:31-39
// L31-L39
/** GET /api/notifications โ ์ธ์ฆ ์ฌ์ฉ์ ๋ณธ์ธ ์๋ฆผ ์ ์ฒด, ์ต์ ์. */
@GetMapping
public ResponseEntity<?> getAll() {
User user = currentUser();
if (user == null) return unauthorized();
List<NotificationResponse> list = streamChatService.getNotificationsForUser(user)
.stream().map(NotificationResponse::from).toList();
return ResponseEntity.ok(list);
}
- ํ๋ฆ: โ
currentUser()๋ก JWT ์ฌ์ฉ์ ํ๋ณด โ โก ์์ผ๋ฉด 401 โ โขstreamChatService.getNotificationsForUser(user)๊ฐrepo.findByUserOrderByCreatedAtDesc(user)๋ฅผ ๋๋ ค ๋ณธ์ธ ์๋ฆผ๋ง ์กฐํ โ โฃ.map(NotificationResponse::from)์ผ๋ก ์ํฐํฐ๋ฅผ DTO๋ก ๋ณํ โ โค 200 OK. - IDOR์ด ๋ถ๊ฐ๋ฅํ ์ด์ : ์ฟผ๋ฆฌ์
user๊ฐ JWT์์ ์จ ๋ณธ์ธ ๊ฐ์ฒด์ด๋ฏ๋ก, ๊ตฌ์กฐ์ ์ผ๋ก ๋จ์ ์๋ฆผ์ด ๋ผ์ด๋ค ์ ์์ต๋๋ค. URL์ ์ด๋ป๊ฒ ์กฐ์ํด๋ ์ ์์ ์ํฅ์ ๋ชป ์ค๋๋ค. ResponseEntity<?>์?โ ์ ์์ด๋ฉดList<NotificationResponse>, ๋น์ ์์ด๋ฉด ์๋ฌMap์ ๋ฐํํ๋ฏ๋ก ํ์ ์ ์์ผ๋์นด๋๋ก ๋ ๊ฒ.
GET ์ ์ฝ์ ๋ชฉ๋ก โ NotificationController.java:41-49
// L41-L49
/** GET /api/notifications/unread โ ๋ณธ์ธ ๋ฏธ์ฝ์ ์๋ฆผ๋ง. */
@GetMapping("/unread")
public ResponseEntity<?> getUnread() {
User user = currentUser();
if (user == null) return unauthorized();
List<NotificationResponse> list = streamChatService.getUnreadNotificationsForUser(user)
.stream().map(NotificationResponse::from).toList();
return ResponseEntity.ok(list);
}
getAll()๊ณผ ํ๋ฐ์ด์ง๋งgetUnreadNotificationsForUser๋ฅผ ํธ์ถ โfindByUserAndIsReadFalseOrderByCreatedAtDesc. ์ ์ฝ์ ๊ฒ๋ง ๋๋ ค์ค๋๋ค.
GET ์ ์ฝ์ ๊ฐ์(๋ฐฐ์ง) โ NotificationController.java:51-58
// L51-L58
/** GET /api/notifications/count โ ๋ณธ์ธ ๋ฏธ์ฝ์ ๊ฐ์(๋ฐฐ์ง์ฉ). */
@GetMapping("/count")
public ResponseEntity<?> getUnreadCount() {
User user = currentUser();
if (user == null) return unauthorized();
long count = streamChatService.countUnreadNotifications(user);
return ResponseEntity.ok(Map.of("unreadCount", count));
}
- ๋ชฉ๋ก์ด ์๋๋ผ ์ซ์ ํ๋(
countByUserAndIsReadFalse)๋ง ๋ฐํ โ ์ข ์์ด์ฝ ๋ฐฐ์ง์ ํ์. Map.of("unreadCount", count)โ JSON{"unreadCount": 3}. ๋ฏธ๋ ํ์ฅ(๋ค๋ฅธ ์นด์ดํธ ์ถ๊ฐ)์ ์ํด ๋จ์ผ ์ซ์๊ฐ ์๋๋ผ ๊ฐ์ฒด๋ก ๊ฐ์์ต๋๋ค.
PATCH ํ ๊ฑด ์ฝ์ โ NotificationController.java:60-67
// L60-L67
/** PATCH /api/notifications/{notificationId}/read โ ๋ณธ์ธ ์๋ฆผ๋ง ์ฝ์ ์ฒ๋ฆฌ(์๋น์ค๊ฐ ์์ ๊ถ ์ฌํ์ธ). */
@PatchMapping("/{notificationId}/read")
public ResponseEntity<?> markOneRead(@PathVariable Long notificationId) {
User user = currentUser();
if (user == null) return unauthorized();
streamChatService.markNotificationRead(notificationId, user);
return ResponseEntity.noContent().build();
}
@PatchMapping("/{notificationId}/read")โ PATCH(๋ถ๋ถ ์์ ) ์๋ฏธ์ ์๋ง์ ๋ฉ์๋.@PathVariable Long notificationId๋ก URL์{notificationId}๋ฅผ ๋ฐ์.- ์ด์ค ๋ฐฉ์ด(๋งค์ฐ ์ค์): ์ฌ๊ธฐ์
notificationId๋ URL์์ ์ต๋๋ค. ๊ณต๊ฒฉ์๊ฐ ๋จ์ ์๋ฆผ id๋ฅผ ๋ฃ์ ์ ์์ต๋๋ค. ๊ทธ๋์streamChatService.markNotificationRead(notificationId, user)์์์ ์์ ๊ถ์ ๋ค์ ํ์ธํฉ๋๋ค(์๋ ์ฝ๋).// StreamChatService.markNotificationRead (์ฐธ์กฐ: chat ๋๋ฉ์ธ) notificationRepository.findById(notificationId).ifPresent(n -> { if (n.getUser().getId().equals(user.getId())) { // โ ๋ณธ์ธ ์๋ฆผ์ธ์ง ์ฌํ์ธ n.setRead(true); notificationRepository.save(n); } });- ์ฆ ์ ์์ JWT์์(์ปจํธ๋กค๋ฌ), ๋์ ์์ ๊ถ์ ์๋น์ค์์ ํ ๋ฒ ๋ ๊ฒ์ฆํ๋ 2๋จ ๋ฐฉ์ด์ ๋๋ค. id๊ฐ ๋จ์ ๊ฒ์ด๋ฉด ์กฐ์ฉํ ๋ฌด์(์๋ฌด ๋ณ๊ฒฝ ์์).
ResponseEntity.noContent().build()โ 204 No Content(์ฑ๊ณตํ์ง๋ง ๋๋ ค์ค ๋ณธ๋ฌธ ์์). ์ฝ์ ์ฒ๋ฆฌ ์ฑ๊ณต ์ ํธ๋ก ์ ์ .- ์ด๋ณด ์ฃผ์: ๋จ์ id๋ฅผ ๋ฃ์ด๋ 204๊ฐ ๋์ฌ ์ ์์ต๋๋ค(์์ ๊ถ ๋ถ์ผ์น ์ ์กฐ์ฉํ ๋ฌด์). "204=๋ด ์๋ฆผ์ด ์ฝํ"์ด ์๋๋ผ "์์ฒญ์ ์ ์ ์ ์, ๊ถํ ์๋ ๊ฒ๋ง ๋ฐ์"์ผ๋ก ์ดํดํด์ผ ํฉ๋๋ค. ์กด์ฌ ์ฌ๋ถ๋ฅผ ์๋ต์ผ๋ก ๊ตฌ๋ถํ์ง ์๋ ๊ฒ๋ ์ ๋ณด ๋ ธ์ถ์ ์ค์ด๋ ๋ฐฉ์ด์ ๋๋ค.
PATCH ์ ์ฒด ์ฝ์ โ NotificationController.java:69-76
// L69-L76
/** PATCH /api/notifications/read-all โ ๋ณธ์ธ ์๋ฆผ ์ ์ฒด ์ฝ์ ์ฒ๋ฆฌ. */
@PatchMapping("/read-all")
public ResponseEntity<?> markAllRead() {
User user = currentUser();
if (user == null) return unauthorized();
streamChatService.markAllNotificationsRead(user);
return ResponseEntity.noContent().build();
}
markAllNotificationsRead(user)โ ๋ ํฌ์ ๋ฒํฌ UPDATE(markAllReadByUser)๋ก ๋ณธ์ธ ์๋ฆผ ์ ๋ถ๋ฅผ ํ ๋ฒ์ ์ฝ์ ์ฒ๋ฆฌ. user๊ฐ JWT ๋ณธ์ธ์ด๋ผ ๋จ์ ์๋ฆผ์ ์๋์ง ๋ชปํฉ๋๋ค.
์ ์ ํฌํผ = M9์ ์ฌ์ฅ โ NotificationController.java:78-87
// L78-L87
/** JWT(AuthContext) ์ ์ฌ์ฉ์ โ ๋ฏธ์ธ์ฆ/๋ฏธ์กด์ฌ๋ฉด null. ์์ฒญ ํ๋ผ๋ฏธํฐ ์ ์์ ์ ๋ขฐํ์ง ์๋๋ค. */
private User currentUser() {
Long uid = AuthContext.currentUserId();
if (uid == null) return null;
return userRepository.findById(uid).orElse(null);
}
private static ResponseEntity<?> unauthorized() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "์ธ์ฆ ํ์"));
}
AuthContext.currentUserId()โ ํ์ฌ ์์ฒญ์ JWT์์ user_id๋ฅผ ๊บผ๋ ๋๋ค. (AuthContext๋JwtAuthenticationFilter๊ฐ ๊ฒ์ฆ ํ ์์ฒญ ์์ฑ์ ์ฌ์ด๋ user_id๋ฅผ ThreadLocal/์์ฒญ์์ ์ฝ๋ ๊ฒฝ๋ ํฌํผ.) ํด๋ผ์ด์ธํธ๊ฐ ๋ณด๋ธ ์ด๋ค ํ๋ผ๋ฏธํฐ๋ ์ฌ๊ธฐ ๋ผ์ด๋ค ์ ์์ต๋๋ค โ ์ด๊ฒ์ด IDOR ๋ฐฉ์ด์ ๋ณธ์ง.uid == nullโ ๋ฏธ์ธ์ฆ(๋๋ ํ ํฐ ์์) โnull๋ฐํ โ ๊ฐ ์๋ํฌ์ธํธ๊ฐ 401.findById(uid).orElse(null)โ ํ ํฐ์ user_id์ ํด๋นํ๋ ์ค์ User๊ฐ DB์ ์์ผ๋ฉด(์ญ์ ๋จ ๋ฑ)nullโ ์ญ์ 401.unauthorized()โ 401 +{"error":"์ธ์ฆ ํ์"}.- ๋ชจ๋ ์๋ํฌ์ธํธ๊ฐ ๋์ผํ ์ฒซ ๋ ์ค(
currentUser()โ null์ด๋ฉดunauthorized())๋ก ์์ํ๋ค๋ ์ ์ด ์ผ๊ด๋ ๋ณด์ ๊ฒ์ดํธ์ ๋๋ค.
5) service/EmailAlertService.java โ ์ด๋ฉ์ผ ๋ค์ด์ ์คํธ ๋ฐ์ก์ค
์ด ํด๋์ค๋ ์ ์ธ์ฑ ์๋ฆผ๊ณผ ๋ณ๊ฐ๋ก, ์ค๋์ ๋งค๋งค ์๊ทธ๋์ ์ด๋ฉ์ผ๋ก ๋ชจ์ ๋ณด๋ ๋๋ค.
ํด๋์ค ์ ์ธ๊ณผ ์์กด์ฑ โ EmailAlertService.java:19-32
// L19-L32
/**
* ์ฌ์ฉ์๋ณ๋ก ๋ฏธ๋ฐ์ก ์๊ทธ๋๋ค์ ๋ฌถ์ด์ ํ ํต์ ์ด๋ฉ์ผ๋ก ๋ณด๋.
* ๋น๋๊ธฐ๋ก ์ฒ๋ฆฌํด์ ์ค์ผ์ค๋ฌ๋ฅผ ๋ง์ง ์์.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailAlertService {
private final JavaMailSender mailSender;
private final DailySignalRepository signalRepo;
@Value("${spring.mail.username:}")
private String fromAddress;
@Serviceโ ๋น์ฆ๋์ค ๋ก์ง ์ปดํฌ๋ํธ.@Slf4j(Lombok) โlog๋ณ์๋ฅผ ์๋ ์์ฑ(๋ก๊น ์ฉ).JavaMailSender mailSenderโ Spring์ด ์ฃผ์ ํ๋ ๋ฉ์ผ ๋ฐ์ก๊ธฐ(SMTP ์ค์ ์application*.properties์spring.mail.*).DailySignalRepository signalRepoโDAILY_SIGNALํ ์ด๋ธ ์ ๊ทผ. ์ธ์ฑNotification์ด ์๋๋ผ ์๊ทธ๋ ํ ์ด๋ธ์ ์ฝ๋๋ค๋ ์ ์ ์ฃผ๋ชฉ.@Value("${spring.mail.username:}")โ ์ค์ ๊ฐspring.mail.username์fromAddress์ ์ฃผ์ . ์ฝ๋ก ๋ค๊ฐ ๋น์ด ์์(:}) = ์ค์ ์ด ์์ผ๋ฉด ๋น ๋ฌธ์์ด์ด ๊ธฐ๋ณธ๊ฐ. (์์ ๋ ์ฑ์ด ์ฃฝ์ง ์๊ณ , ์๋์์ "๋ฐ์ ์ฃผ์ ์์ โ skip"์ผ๋ก ์์ ์ฒ๋ฆฌํ๋ ค๋ ์๋.)
๋ค์ด์ ์คํธ ๋ฐ์ก โ EmailAlertService.java:34-67
// L34-L46
@Async
@Transactional
public void sendDigest(User user, List<DailySignal> signals) {
if (user == null || signals == null || signals.isEmpty()) return;
String to = user.getEmail();
if (to == null || to.isBlank()) {
log.warn("[Email] user {} has no email โ skip", user.getId());
return;
}
if (fromAddress == null || fromAddress.isBlank()) {
log.warn("[Email] spring.mail.username not set โ skip send for {}", to);
return;
}
@Asyncโ ๋ณ๋ ์ค๋ ๋์์ ์คํ(์ฌ์ ์ง์ 4๋ฒ). ํธ์ถํ ์ค์ผ์ค๋ฌ๋ ์ฆ์ ๋ค์ ์ฌ์ฉ์๋ก ๋์ด๊ฐ๋๋ค.@Transactionalโ ์ด ๋ฉ์๋ ์์ DB ์์ (์๋saveAll)์ ํ๋์ ํธ๋์ญ์ ์ผ๋ก ๋ฌถ์. (๋จ,@Async์ ํจ๊ป ์ฐ๋ฉด ํธ๋์ญ์ ์ด ์ ์ค๋ ๋์์ ์์๋จ์ ์ ์.)- ๊ฐ๋(๋ฐฉ์ด) 3์ข
:
user/signals๊ฐ ๋น๋ฉด ์กฐ์ฉํ ์ข ๋ฃ(๋ณด๋ผ ๊ฒ ์์).- ๋ฐ๋ ์ฌ๋ ์ด๋ฉ์ผ์ด ์์ผ๋ฉด ๊ฒฝ๊ณ ๋ก๊ทธ + skip(๋ฉ์ผ ๋ณด๋ผ ๊ณณ์ด ์์).
- ๋ฐ์ ์ฃผ์(
fromAddress)๊ฐ ๋น์ด ์์ผ๋ฉด skip โ SMTP ๋ฏธ์ค์ ํ๊ฒฝ(๋ก์ปฌ ๋ฑ)์์ ๋ฉ์ผ์ ์๋์กฐ์ฐจ ์ ํด ์๋ฌ๋ฅผ ํผํจ. CLAUDE.md "ANALYTICS ๋ฏธ์ค์ ์ ํด๋ฐฑ"๊ณผ ๊ฐ์ "์ค์ ์์ผ๋ฉด ์ฐ์ํ๊ฒ ๊ฑด๋๋ฐ๊ธฐ" ์ฒ ํ.
// L48-L67
String subject = "[Alpha-Helix] " + signals.get(0).getAsOfDate() + " ์ค๋์ ๋งค๋งค ์ ํธ ("
+ signals.size() + "๊ฑด)";
String body = buildBody(user, signals);
try {
SimpleMailMessage msg = new SimpleMailMessage();
msg.setFrom(fromAddress);
msg.setTo(to);
msg.setSubject(subject);
msg.setText(body);
mailSender.send(msg);
LocalDateTime now = LocalDateTime.now();
signals.forEach(s -> s.setDeliveredAt(now));
signalRepo.saveAll(signals);
log.info("[Email] sent digest to {} ({} signals)", to, signals.size());
} catch (Exception e) {
log.error("[Email] send failed to {}: {}", to, e.getMessage());
}
}
- ์ ๋ชฉ:
[Alpha-Helix] 2026-06-01 ์ค๋์ ๋งค๋งค ์ ํธ (3๊ฑด)์ฒ๋ผ ๋ ์งยท๊ฑด์๋ฅผ ๋ด์.signals.get(0).getAsOfDate()= ์ฒซ ์๊ทธ๋์ ๊ธฐ์ค์ผ(๊ฐ์ ๋ฐฐ์น๋ผ ๋ชจ๋ ๊ฐ์ ๋ ). - ๋ณธ๋ฌธ:
buildBody(...)๋ก ์กฐ๋ฆฝ(์๋). - ๋ฐ์ก:
SimpleMailMessage์ ๋ฐ์ /์์ /์ ๋ชฉ/๋ณธ๋ฌธ์ ์ฑ์mailSender.send(msg).setText(body)์ด๋ฏ๋ก ํ๋ฌธ(HTML ์๋). - ๋ฐ์ก ์ฑ๊ณต ํ ํต์ฌ โ ์ค๋ณต ๋ฐ์ก ๋ฐฉ์ง:
signals.forEach(s -> s.setDeliveredAt(now))+signalRepo.saveAll(signals)๋ก ๊ฐ ์๊ทธ๋์deliveredAt์ ๋ฐ์ก ์๊ฐ์ ๋์ฅ ์ฐ์.- ๋ค์ ๋ฒ์
dispatchPending์ด "์์งdeliveredAt์ด null์ธ ๊ฒ"๋ง ์ฐพ์ผ๋ฏ๋ก, ์ด๋ฏธ ๋ณด๋ธ ์๊ทธ๋์ ๋ค์ ์ ๋ณด๋ ๋๋ค(๋ฉฑ๋ฑ์ฑ). ์ด๋ฉ์ผ์ "๋ ๋ฒ ๋ณด๋ด๋" ์ฌ๊ณ ๋ฅผ ๋ง๋ ์ฅ์น.
- ์๋ฌ ์ฒ๋ฆฌ:
try/catch๋ก ๋ฉ์ผ ์คํจ๋ฅผ ์ผํค๊ณ error ๋ก๊ทธ๋ง ๋จ๊น๋๋ค. ์? ํ ์ฌ์ฉ์์๊ฒ ๋ฉ์ผ์ด ์คํจํด๋(์ฃผ์ ์ค๋ฅยทSMTP ์ผ์์ฅ์ ๋ฑ) ๋ค๋ฅธ ์ฌ์ฉ์ ๋ฐ์ก์ด๋ ์ค์ผ์ค๋ฌ ์ ์ฒด๊ฐ ๋ฉ์ถ๋ฉด ์ ๋๊ธฐ ๋๋ฌธ. ๋จ, catch ๋๋ฉดdeliveredAt๋ ์ ์ฐํ๋ฏ๋ก ๋ค์ ๋ฐฐ์น์์ ์ฌ์๋๋ฉ๋๋ค(์์ฐ์ค๋ฌ์ด ์ฌ์๋ ํจ๊ณผ).
๋ณธ๋ฌธ ์กฐ๋ฆฝ โ EmailAlertService.java:69-87
// L69-L87
private String buildBody(User user, List<DailySignal> signals) {
StringBuilder sb = new StringBuilder();
sb.append("์๋
ํ์ธ์ ").append(user.getUsername()).append("๋,\n\n");
sb.append("์ค๋์ Alpha-Helix ๋งค๋งค ์๊ทธ๋์
๋๋ค.\n");
sb.append("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n\n");
for (var s : signals) {
sb.append("[").append(s.getSignal()).append("] ").append(s.getStrategy().getCode())
.append(" โ ").append(s.getStrategy().getTicker()).append("\n");
sb.append("ยท ์ ๋ชฉ: ").append(safe(s.getTitle())).append("\n");
sb.append("ยท ๋ถ์: ").append(safe(s.getSummary())).append("\n");
sb.append("ยท ์ก์
: ").append(safe(s.getAction())).append("\n\n");
}
sb.append("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n");
sb.append("โป ๋ณธ ๋ฉ์ผ์ ์๋ ๋ฐ์ก๋ ๋ถ์ ์ ๋ณด์ด๋ฉฐ ํฌ์ ๊ถ์ ๊ฐ ์๋๋๋ค.\n");
sb.append("Alpha-Helix ยท DevBridge\n");
return sb.toString();
}
private String safe(String v) { return v == null ? "" : v; }
StringBuilder๋ก ํ๋ฌธ ๋ฉ์ผ์ ์กฐ๋ฆฝ.\n์ค๋ฐ๊ฟ์ผ๋ก ๋จ๋ฝ์ ๋๋๋๋ค(HTML ํ๊ทธ ์์).- ๊ฐ ์๊ทธ๋ ํ ๋ธ๋ก:
[BUY] ์ ๋ต์ฝ๋ โ ํฐ์ปคํค๋ + ์ ๋ชฉ/๋ถ์/์ก์ 3์ค. (s.getSignal()์BUY/HOLD/WATCH/PAUSEenum,s.getStrategy().getCode()/getTicker()๋ ์ ๋ต ๋ฉํ.) - ๋์ ๋ฉด์ฑ
๋ฌธ๊ตฌ(
ํฌ์ ๊ถ์ ๊ฐ ์๋๋๋ค) โ ๊ธ์ต ์ ๋ณด ๋ฐ์ก ์ ๋ฒ์ ์์ ์ฅ์น์ด์ ์ข์ ๊ดํ. safe(v)โ null์ด๋ฉด ๋น ๋ฌธ์์ด๋ก. ์? ๋ง์ฝยท ๋ถ์: null์ฒ๋ผ ๋ฌธ์ ๊ทธ๋๋ก "null"์ด ๋ฉ์ผ์ ์ฐํ๋ฉด ๋ณด๊ธฐ ํํฉ๋๋ค. null ํ๋๋ฅผ ๊น๋ํ ๋น์๋๋ค.- ์ด๋ณด ์ฃผ์(์ ์ฌ NPE):
user.getUsername(),s.getStrategy().getCode()๋ฑ์ null ๋ฐฉ์ด๊ฐ ์์ต๋๋ค. ๋ง์ฝ ์ ๋ต์ด LAZY์ธ๋ฐ ํธ๋์ญ์ ๋ฐ์ด๋ผ๋ฉด ๋ก๋ฉ ๋ฌธ์ ๊ฐ ๋ ์ ์์ผ๋, ์ด ๋ฉ์๋๋@Transactional sendDigest์์์ ํธ์ถ๋๋ฏ๋ก ๋ณดํต ์์ ํฉ๋๋ค.
๋ฏธ๋ฐ์ก ์ผ๊ด ๋ฐ์ก ์ง์
์ โ EmailAlertService.java:89-97
// L89-L97
/** ๋ฏธ๋ฐ์ก ์๊ทธ๋์ user๋ณ๋ก ๋ฌถ์ด์ ์ผ๊ด ๋ฐ์ก. */
@Transactional
public int dispatchPending(java.time.LocalDate asOfDate) {
var pending = signalRepo.findByAsOfDateAndDeliveredAtIsNull(asOfDate);
if (pending.isEmpty()) return 0;
var byUser = pending.stream().collect(Collectors.groupingBy(s -> s.getStrategy().getUser()));
byUser.forEach(this::sendDigest);
return pending.size();
}
- ์ด ๋ฉ์๋๊ฐ ์ด๋ฉ์ผ ํ๋ฆ์ ์์์ (์ค์ผ์ค๋ฌ๊ฐ "์ค๋ ๋ ์ง"๋ก ํธ์ถ).
findByAsOfDateAndDeliveredAtIsNull(asOfDate)โ ๊ทธ ๋ ์ง์ ์๊ทธ๋ ์ค ์์ง ์ ๋ณด๋ธ ๊ฒ(deliveredAt IS NULL)๋ง ์กฐํ. ์์์ ๋ฐ์ก ์ฑ๊ณต ์ ๋์ฅ์ ์ฐ๊ธฐ ๋๋ฌธ์ ์ด ํํฐ๊ฐ ์ค๋ณต๋ฐ์ก์ ๋ง์ต๋๋ค.Collectors.groupingBy(s -> s.getStrategy().getUser())โ ์๊ทธ๋์ ์ฌ์ฉ์๋ณ๋ก ๋ฌถ์.{user42: [s1,s2], user43: [s3]}ํํ.byUser.forEach(this::sendDigest)โ ์ฌ์ฉ์๋ง๋คsendDigest(user, signals)ํธ์ถ.sendDigest๋@Async๋ผ ๊ฐ๊ฐ ๋ณ๋ ์ค๋ ๋์์ ๋ณ๋ ฌ ๋ฐ์ก๋ฉ๋๋ค.return pending.size()โ ์ฒ๋ฆฌ ๋์ ์๊ทธ๋ ์๋ฅผ ๋ฐํ(์ค์ผ์ค๋ฌ๊ฐ ๋ก๊น /๋ชจ๋ํฐ๋ง์ ์ฌ์ฉ).- ์ด๋ณด ํท๊ฐ๋ฆผ(@Async ํธ๋์ญ์
๊ฒฝ๊ณ):
dispatchPending์@Transactional๊ณผsendDigest์@Async @Transactional์ ๋ค๋ฅธ ํธ๋์ญ์ /์ค๋ ๋์ ๋๋ค.dispatchPending์ด ๋๋๋sendDigest๋ค์ ๋ฐฑ๊ทธ๋ผ์ด๋์์ ๊ณ์ ๋ฐ์ก ์ค์ผ ์ ์์ต๋๋ค. ์ฆ ๋ฐํ๋ ์ซ์๋ "๋ฐ์ก ์๋ ๋์ ์"์ด์ง "๋ฐ์ก ์ฑ๊ณต ์"๊ฐ ์๋๋๋ค.
โ ๏ธ ํจ์ ยท๋ณด์ ์ฃผ์
M9 โ IDOR ์ฐจ๋จ(ํต์ฌ ๋ณด์)
- ๊ณผ๊ฑฐ:
?userId=ํ๋ผ๋ฏธํฐ๋ฅผ ์ ๋ขฐ โ ๋ก๊ทธ์ธ๋ง ํ๋ฉด ๋๊ตฌ๋ ํ์ธ์ ์๋ฆผ ์กฐํ/์ฝ์์ฒ๋ฆฌ ๊ฐ๋ฅ(IDOR). - ํ์ฌ: ์ ์์ ์ค์ง JWT(
AuthContext.currentUserId()) ์์๋ง. ์์ฒญ ํ๋ผ๋ฏธํฐ์ ์ ์์ ์ ๋ ์ ๋ขฐํ์ง ์์. (NotificationController.java:78-83) - ์ด์ค ๋ฐฉ์ด: ์ฝ์ ์ฒ๋ฆฌ๋ ๋์ id๊ฐ URL์์ ์ค๋ฏ๋ก, ์๋น์ค(
markNotificationRead)์์n.getUser().getId().equals(user.getId())๋ก ์์ ๊ถ์ ํ ๋ฒ ๋ ํ์ธ. ๋จ์ id๋ฉด ์กฐ์ฉํ ๋ฌด์(204์ง๋ง ๋ณ๊ฒฝ ์์). - ํ์ต ํฌ์ธํธ: "์ ์=์๋ฒ๊ฐ ์ ๋ขฐํ๋ ์ถ์ฒ(JWT)์์, ๋์ ์์ ๊ถ=DB ๋น๊ต๋ก ์ฌํ์ธ" 2๋จ ํจํด.
- ๊ณผ๊ฑฐ:
M5 โ ํ๋ก ํธ์๋๊ฐ ๋ฐฑ์๋ ์๋ฆผ๊ณผ ๋ฏธ์ฐ๋(ํํฉ)
- ๋ฐฑ์๋
/api/notificationsAPI๋ ์๋น๋์ด ์์ผ๋, ํ๋ก ํธ์๋๋ ์์ง ์ด API๋ฅผ ํธ์ถํ์ง ์์ต๋๋ค. - ์ค์ ์ฝ๋ ์ฆ๊ฑฐ:
frontend/src/store/useNotificationStore.js์buildInitial()(L4-L50)์ด ํ๋์ฝ๋ฉ๋ ๋๋ฏธ ์๋ฆผ 7๊ฑด("์ ๋ต ์ ํํ ์๋ฃ", "Trust Score ์์น" ๋ฑ)์ ๋ง๋ค๊ณ , Zustandpersist๋ก localStorage(alpha-notifications)์ ์ ์ฅํฉ๋๋ค.markRead/markAllRead/remove/clearAll๋ชจ๋ ๋ก์ปฌ ์ํ๋ง ๋ฐ๊พธ๋ฉฐ ๋ฐฑ์๋๋ฅผ ํธ์ถํ์ง ์์ต๋๋ค(L52-L74). - ๊ฒฐ๊ณผ: ํ๋ฉด์ ์๋ฆผ์ ๋ธ๋ผ์ฐ์ ์์์๋ง ์ฌ๋ ๊ฐ์ง ๋ฐ์ดํฐ์
๋๋ค. ์๋ฒ DB์
Notification๊ณผ ๋ฌด๊ดํ๊ณ , ๋ค๋ฅธ ๊ธฐ๊ธฐ/๋ธ๋ผ์ฐ์ ์ ๋๊ธฐํ๋์ง ์์ผ๋ฉฐ, localStorage๋ฅผ ๋น์ฐ๋ฉด ์ฌ๋ผ์ง๋๋ค. - ์ฆ ๋ฐฑ์๋์ ํ๋ก ํธ๊ฐ ๋๊ฒจ ์๋(wire ์ ๋) ์ํ๊ฐ M5์ ํํฉ์ ๋๋ค. (๊ณ ๋ํ ์์ด๋์ด์์ ์ฐ๊ฒฐ ๋ฐฉ์ ์ ์.)
- ๋ฐฑ์๋
SMTP ๋ฏธ์ค์ ์ ๋์ โ "์กฐ์ฉํ skip" vs "ํฌ์ค DOWN"
EmailAlertService๋spring.mail.username(๋ฐ์ ์ฃผ์)์ด ๋น๋ฉด ๋ฉ์ผ์ ์๋ํ์ง ์๊ณ skip + ๊ฒฝ๊ณ ๋ก๊ทธํฉ๋๋ค(EmailAlertService.java:43-46). ์ฆ ์ด๋ฉ์ผ ๋ฏธ์ค์ ๋ง์ผ๋ก ๋ฐ์ก ๋ก์ง์ด ์ฃฝ์ง๋ ์์ต๋๋ค(์ฐ์ํ ํด๋ฐฑ).- ๊ทธ๋ฌ๋ Spring Boot์
MailHealthIndicator(actuator)๋ ๋ณ๊ฐ์ ๋๋ค.spring.mail.host๋ฑ ๋ฉ์ผ ์ค์ ์ด ์กํ ์๋๋ฐ SMTP ์๋ฒ์ ์ฐ๊ฒฐํ ์ ์์ผ๋ฉด/actuator/health๊ฐ mail ์ปดํฌ๋ํธ๋ฅผ DOWN ์ผ๋ก ๋ณด๊ณ , ์ ์ฒด ์ํ๊ฐDOWN์ผ๋ก ๋จ์ด์ง ์ ์์ต๋๋ค. (ํฌ์ค์ฒดํฌ๊ฐ "UP"์ด ์๋๊ฒ ๋์ด ๋ฐฐํฌ/๋ชจ๋ํฐ๋ง์์ ์คํ์ ์ผ์ผํด.) - ์ ๋ฆฌ: ๋ฐ์ก ๋ก์ง์ skip์ผ๋ก ๊ฒฌ๋์ง๋ง, actuator ํฌ์ค๋ SMTP ์ฐ๊ฒฐ ์คํจ์ ๋ฏผ๊ฐํฉ๋๋ค. ๋ก์ปฌ/CI์์ ๋ฉ์ผ ์ค์ ์ ์ด์ค๊ฐํ๊ฒ(host๋ ์๊ณ ์ ์์ ์ ๋๊ฒ) ๋๋ฉด ํฌ์ค๊ฐ DOWN ๋ ์ ์์ผ๋, ์ ์ธ ๊ฑฐ๋ฉด mail health๋ฅผ ์ ์ธ(
management.health.mail.enabled=false)ํ๊ฑฐ๋ ์ค์ ์ ์์ ํ ๋น์ฐ๋ ๊ฒ ์์ ํฉ๋๋ค.
์ค๋ณต ๋ฐ์ก โ
deliveredAt๋ฉฑ๋ฑ ์ฅ์น ์์กด- ์ค๋ณต ๋ฉ์ผ ๋ฐฉ์ง๋ ์ค์ง
deliveredAt๋์ฅ +findBy...DeliveredAtIsNullํํฐ์ ๋ฌ๋ ค ์์ต๋๋ค. ๋ฐ์ก์ ์ฑ๊ณตํ๋๋ฐsaveAll์ง์ ์์ธ๊ฐ ๋๋ฉด(๋๋ฌผ์ง๋ง) ๋์ฅ์ด ์ ์ฐํ ๋ค์ ๋ฐฐ์น์์ ์ฌ๋ฐ์ก๋ ์ ์์ต๋๋ค. "์ ํํ ํ ๋ฒ"์ด ์๋๋ผ "์ต์ ํ ๋ฒ(at-least-once)" ๋ณด์ฅ์ ๊ฐ๊น์ต๋๋ค.
- ์ค๋ณต ๋ฉ์ผ ๋ฐฉ์ง๋ ์ค์ง
@Async+@Transactional๊ฒฝ๊ณ ํผ๋dispatchPending์ด ๋ฐํํด๋sendDigest๋ค์ ๋ฐฑ๊ทธ๋ผ์ด๋ ์งํ ์ค์ผ ์ ์์ต๋๋ค. ๋ฐํ๊ฐ์ "์ฑ๊ณต ์"๊ฐ ์๋ "๋์ ์". ๋ํ@Async๋ฉ์๋๋ ๊ฐ์ ํด๋์ค ๋ด๋ถ ํธ์ถ์ด๋ฉด ํ๋ก์๋ฅผ ์ ํ ๋น๋๊ธฐ๊ฐ ์ ๊ฑธ๋ฆฝ๋๋ค โ ์ฌ๊ธฐ์๋dispatchPending์ดthis::sendDigest๋ฅผ ํธ์ถํ๋ฏ๋ก(์๊ธฐ ํด๋์ค ๋ฉ์๋ ์ฐธ์กฐ) ์๊ธฐ-ํธ์ถ(self-invocation) ์ ๋๋ค. ๊ฐ์ ๋น ๋ด๋ถ ํธ์ถ์ Spring AOP ํ๋ก์๋ฅผ ์ฐํํ ์ ์์ด, ์ค์ ๋ก ๋น๋๊ธฐ๋ก ๋ถ๋ฆฌ๋์ง ์์ ๊ฐ๋ฅ์ฑ์ด ์์ต๋๋ค(์ค์ /ํ๋ก์ ๋ฐฉ์์ ๋ฐ๋ผ). ๋์์ ๋น๋๊ธฐ๋ก ๋ณด์ฅํ๋ ค๋ฉด ๋ฐ์ก ๋ฃจํ๋ฅผ ๋ณ๋ ๋น์ผ๋ก ๋ถ๋ฆฌํ๋ ๊ฒ ์์ ํฉ๋๋ค. (ํ์ต ์ ๊ผญ ์ง์ ํจ์ .)
enum๊ณผ ์ค์ ์ฌ์ฉ์ ๊ดด๋ฆฌ
NotificationType12๊ฐ ์ค ๋ค์๋ ํ์ /๊ณ์ฝ(DevBridge) ๋๋ฉ์ธ ์ฉ์ด์ ๋๋ค. ์๋ฆผ "์์ฑ" ์ฝ๋๋ ์ด ๋๋ฉ์ธ ๋ฐ์ ์์ด, ์ด๋ค ํ์ ์ด ์ค์ ๋ก ์ฐ์ด๋์ง๋ ์ด 5ํ์ผ๋ง์ผ๋ก ๋จ์ ํ ์ ์์ต๋๋ค. ํ๊ฐ ๋ฐฉ์ง๋ฅผ ์ํด "๋ฒ์ฉ ์นดํ๋ก๊ทธ"๋ก๋ง ์ดํดํ์ธ์.
๐ ๊ณ ๋ํ ์์ด๋์ด
M5 ์ฐ๋ โ ํ๋ก ํธ๋ฅผ ์ง์ง API์ ์ฐ๊ฒฐ (๊ฐ์ฅ ์๊ธ)
useNotificationStore.js์buildInitial()๋๋ฏธ ์ ๊ฑฐ โ ์ฑ ์ง์ /์ฃผ๊ธฐ์ ํด๋ง์ผ๋กGET /api/notifications, ๋ฐฐ์ง์GET /api/notifications/countํธ์ถ.markRead(id)โPATCH /api/notifications/{id}/read,markAllRead()โPATCH /api/notifications/read-all๋ก ๊ต์ฒด(๋๊ด์ ์ ๋ฐ์ดํธ + ์คํจ ๋กค๋ฐฑ).- ์ฃผ์: ๋ฐฑ์๋ DTO ํ๋๋ช
(
notificationType/message/createdAt/isRead)๊ณผ ํ๋ก ํธ ๋๋ฏธ ํ๋๋ช (type/body/time/read)์ด ๋ค๋ฆ ๋๋ค. ๋งคํ ์ด๋ํฐ๋ฅผ ๋๊ฑฐ๋ ํ์ชฝ ์คํค๋ง๋ก ํต์ผํด์ผ ํฉ๋๋ค.
์ค์๊ฐ ํธ์(ํด๋ง โ ์ด๋ฒคํธ)
- ์ง๊ธ์ ์กฐํํ(pull). SSE(Server-Sent Events)๋ WebSocket์ผ๋ก ์ ์๋ฆผ์ ์ค์๊ฐ ํธ์ํ๋ฉด ๋ฐฐ์ง๊ฐ ์ฆ์ ๊ฐฑ์ ๋ฉ๋๋ค. chat ๋๋ฉ์ธ์ด ์ด๋ฏธ Stream์ ์ฐ๋ฏ๋ก ๊ทธ ์ฑ๋ ์ฌ์ฌ์ฉ ๊ฐ๋ฅ.
์๋ฆผ ์์ฑ ํ์คํ(์ด๋ฒคํธ ๊ธฐ๋ฐ)
- ํ์ฌ ์๋ฆผ ์์ฑ ๋ก์ง์ด chat ์๋น์ค์ ์นํ ์์. Spring
ApplicationEvent(์:SignalCreatedEvent,OrderFilledEvent)๋ฅผ ๋ฐํํ๊ณ ,@EventListener๊ฐNotification์ ๋ง๋ค๋๋ก ๋ถ๋ฆฌํ๋ฉด ๋๋ฉ์ธ ๊ฒฐํฉ๋๊ฐ ๋ฎ์์ง๋๋ค. โ "์๊ทธ๋ ์์ฑ ์ ์ธ์ฑ ์๋ฆผ + ์ด๋ฉ์ผ" ๋ ์ฑ๋์ ํ ์ด๋ฒคํธ๋ก fan-out.
- ํ์ฌ ์๋ฆผ ์์ฑ ๋ก์ง์ด chat ์๋น์ค์ ์นํ ์์. Spring
์ด๋ฉ์ผ ๊ณ ๋ํ
SimpleMailMessage(ํ๋ฌธ) โMimeMessage+ Thymeleaf HTML ํ ํ๋ฆฟ์ผ๋ก ๊ฐ๋ ์ฑยท๋ธ๋๋ฉ ํฅ์.- ๋ฐ์ก ์คํจ ์ ์ฌ์๋ ํ/๋ฐฑ์คํ, ๋ฐ์ก ์ด๋ ฅ ํ ์ด๋ธ(๊ฐ์ฌ ๋ก๊ทธ), ์ฌ์ฉ์๋ณ ์์ ๊ฑฐ๋ถ(opt-out) ์ค์ .
@Async์๊ธฐ-ํธ์ถ ํจ์ ํด์- ๋ฐ์ก ๋ฃจํ๋ฅผ ๋ณ๋ ๋น(
EmailDispatcher)์ผ๋ก ๋นผ์ ๋น๋๊ธฐ๊ฐ ํ์คํ ๊ฑธ๋ฆฌ๊ฒ. ๋์์ ์ค๋ ๋ํ(TaskExecutor) ํฌ๊ธฐ๋ฅผ ์ค์ ํด ๋๋ ์ฌ์ฉ์ ๋ฐ์ก์ ์ ์ด.
- ๋ฐ์ก ๋ฃจํ๋ฅผ ๋ณ๋ ๋น(
์ฝ์ ๋๊ธฐํ ์ ํฉ์ฑ
- ๋ฒํฌ
@Modifying์ ๋ฐ์ดํธ ํ ์๋ต์ ๊ฐฑ์ ๋ unreadCount๋ฅผ ํจ๊ป ๋ฐํํด ํ๋ก ํธ๊ฐ ํ ๋ฒ ๋ ์กฐํํ์ง ์๊ฒ(์๋ณต ์ ๊ฐ).
- ๋ฒํฌ
๐ ์ฉ์ด ์ฌ์ (์ด ๋๋ฉ์ธ ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
| ์ธ์ฑ ์๋ฆผ(in-app notification) | DB NOTIFICATION ์ ์์ด๊ณ ํ๋ฉด ์ฐํธํจ์ ํ์๋๋ ์๋ฆผ. ์ฝ์/์์ฝ์ ์ํ๋ฅผ ๊ฐ์ง |
| ๋ค์ด์ ์คํธ(digest) ์ด๋ฉ์ผ | ์ฌ๋ฌ ์๊ทธ๋์ ํ ํต์ผ๋ก ๋ฌถ์ด ๋ณด๋ด๋ ์์ฝ ๋ฉ์ผ |
| IDOR | Insecure Direct Object Reference. ์์ฒญ ํ๋ผ๋ฏธํฐ์ ๊ฐ์ฒด id/์ ์์ ๊ฒ์ฆ ์์ด ๋ฏฟ์ด ํ์ธ ์์์ ์ ๊ทผํ๊ฒ ๋๋ ์ทจ์ฝ์ |
| AuthContext | JWT์์ ํ์ฌ ์ฌ์ฉ์ id๋ฅผ ๊บผ๋ด๋ ๊ฒฝ๋ ํฌํผ(global/security). Spring Security ๋ฏธ์ฌ์ฉ ๊ตฌํ |
| JWT | ๋ก๊ทธ์ธ ์ ๋ฐ๊ธ๋๋ ์์กฐ ๋ถ๊ฐ๋ฅํ ์๋ช ํ ํฐ. ์ ์์ ์ ๋ขฐ ์ถ์ฒ |
@Async |
๋ฉ์๋๋ฅผ ๋ณ๋ ์ค๋ ๋์์ ์คํ(๋น๋๊ธฐ). ํธ์ถ์๊ฐ ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋ค๋ฆฌ์ง ์์ |
@Transactional |
๋ฉ์๋ ๋ด DB ์์ ์ ํ๋์ ํธ๋์ญ์ ์ผ๋ก ๋ฌถ์(์ ๋ถ ์ฑ๊ณต or ์ ๋ถ ๋กค๋ฐฑ) |
JavaMailSender / SimpleMailMessage |
Spring์ ๋ฉ์ผ ๋ฐ์ก๊ธฐ / ํ๋ฌธ ๋ฉ์ผ ๋ฉ์์ง(๋ฐ๋์ดยท์ ๋ชฉยท๋ณธ๋ฌธ) |
| SMTP | ๋ฉ์ผ์ ๋ณด๋ด๋ ํ์ค ํ๋กํ ์ฝ. Gmail์ ์ฑ ๋น๋ฐ๋ฒํธ๋ก ์ธ์ฆ |
| ์ฟผ๋ฆฌ ๋ฉ์๋ | Spring Data JPA๊ฐ ๋ฉ์๋ ์ด๋ฆ(findByUser...)์ ํด์ํด SQL์ ์๋ ์์ฑ |
@Modifying ๋ฒํฌ ์ฟผ๋ฆฌ |
UPDATE/DELETE JPQL. ํ ๋ฌธ์ฅ์ผ๋ก ๋ค์ ํ์ ๋ณ๊ฒฝ(์์์ฑ ์ปจํ ์คํธ ์ฐํ) |
| DTO(Data Transfer Object) | ์ํฐํฐ์ ๋ฏผ๊ฐ/๋ด๋ถ ํ๋๋ฅผ ๊ฐ๋ฆฌ๊ณ ์ธ๋ถ ์๋ต์ฉ์ผ๋ก ์ถ๋ฆฐ ๊ฐ์ฒด(NotificationResponse) |
์ ์ ํฉํ ๋ฆฌ from() |
์ํฐํฐ๋ฅผ ๋ฐ์ DTO๋ฅผ ๋ง๋ค์ด ์ฃผ๋ static ๋ณํ ๋ฉ์๋ |
deliveredAt |
DailySignal ์ ์ด๋ฉ์ผ ๋ฐ์ก ์๊ฐ. null์ด๋ฉด ๋ฏธ๋ฐ์ก โ ์ค๋ณต๋ฐ์ก ๋ฐฉ์ง ํค |
| ๋ฉฑ๋ฑ์ฑ(idempotency) | ๊ฐ์ ์์ ์ ์ฌ๋ฌ ๋ฒ ํด๋ ๊ฒฐ๊ณผ๊ฐ ํ ๋ฒ๊ณผ ๊ฐ์. ์ฌ๊ธฐ์ "์ด๋ฏธ ๋ณด๋ธ ๋ฉ์ผ ์ฌ๋ฐ์ก ์ ํจ" |
@CreatedDate / Auditing |
ํ ์์ฑ ์๊ฐ ์๋ ๊ธฐ๋ก(AuditingEntityListener) |
| LAZY ๋ก๋ฉ | ์ฐ๊ด ๊ฐ์ฒด(User ๋ฑ)๋ฅผ ์ค์ ํ์ํ ๋๋ง DB์์ ๊ฐ์ ธ์ค๊ธฐ |
| M5 / M9 | ์ด ํ๋ก์ ํธ์ ๋ณด์/ํ์ง ์์ ํญ๋ชฉ ๋ฒํธ. M9=IDOR ์ฐจ๋จ(์๋ฃ), M5=ํ๋ก ํธ-๋ฐฑ์๋ ์๋ฆผ ๋ฏธ์ฐ๋(ํํฉ) |
domain/payment โ ๊ฒฐ์ ยท๊ตฌ๋
ยท์ํธํยท์์ฅ (์์ ๋ผ์ธ๋ณ ํด์ค)
์๋ณธ:
backend/src/main/java/com/DevBridge/devbridge/domain/payment/์ ์ฒด
ํ์: ์ด ๊ต์ฌ์ ํ์ค(README "3. ๊ณตํต ํ์" + ๋ชจ๋ฒ ์์01_backtest/vbt_engine.md)์ ๋ฐ๋ฆ ๋๋ค.
์ ์ : Spring ๊ธฐ์ด(@Serviceยท@RestControllerยทDIยท@Transactional)๋08_backend/00_spring_primer.md์์ ๋ค๋ฃน๋๋ค. ์ฌ๊ธฐ์๋ ๊ฒฐ์ ๋๋ฉ์ธ ๊ณ ์ ๊ฐ๋ ์ ์ง์คํฉ๋๋ค.
๐ ์ด ๋๋ฉ์ธ ํ๋์
์ด ํจํค์ง๋ ๋ฐฑ์๋์ "๋์ ๋ค๋ฃจ๋ ์ฐฝ๊ตฌ + ๋น๋ฐ์ ๋ณด๊ดํ๋ ๊ธ๊ณ " ์ ๋๋ค. ๋ ๊ฐ์ง ์ผ์ ํฉ๋๋ค.
- ๊ฒฐ์ ์ฐฝ๊ตฌ โ ์ฌ์ฉ์๊ฐ ํ ์ค(Toss)๋ก ๊ตฌ๋ ๋ฃ๋ฅผ ๋ด๋ฉด, ๊ทธ ๊ฒฐ์ ๊ฐ ์ง์ง์ธ์ง ํ ์ค ์๋ฒ์ ๋ฌผ์ด๋ณด๊ณ (confirm), ๊ฒฐ์ ์ํ๊ฐ ๋ฐ๋๋ฉด(์ทจ์ยทํ๋ถ) ํ ์ค๊ฐ ๋ณด๋ด๋ ์๋ฆผ(์นํ )์ ๋ฐ์ต๋๋ค. ๊ทธ๋ฆฌ๊ณ ๋ฑ๋กํ ์นด๋ ๋ชฉ๋ก๊ณผ ๊ฑฐ๋ ๋ด์ญ(๊ฐ๊ณ๋ถ/์์ฅ)์ ๊ด๋ฆฌํฉ๋๋ค.
- ๊ธ๊ณ (์ํธํ) โ KISยทBinance ๊ฐ์ ์ธ๋ถ ์ฆ๊ถ์ฌ API ๋น๋ฐํค๋ฅผ DB์ ์ ์ฅํ๊ธฐ ์ ์ AES-256-GCM ์ผ๋ก ์ํธํํฉ๋๋ค. DB๊ฐ ํต์งธ๋ก ์ ์ถ๋ผ๋ ํค ์์ด๋ ๋ชป ์ฝ์ต๋๋ค.
๋น์ : ์ด ๋๋ฉ์ธ์ ์ํ ์ง์ ์ ๋๋ค.
TossPaymentsService๋ ์ฐฝ๊ตฌ ์ง์(๋ณธ์ =ํ ์ค์ "์ด ๊ฒฐ์ ์ง์ง ๋ง์์?"๋ผ๊ณ ์ ํ),TossWebhookController๋ ๋ณธ์ ์์ ์ค๋ ํฉ์ค ์์ ๊ธฐ(์ทจ์ยทํ๋ถ ํต์ง๊ฐ ๋น๋๊ธฐ๋ก ๋ค์ด์ด),CryptoService๋ ๊ธ๊ณ (๊ณ ๊ฐ ๋น๋ฐ๋ฒํธ๋ฅผ ์ ๊ฐ์ ๋ณด๊ด),LedgerController๋ ํต์ฅ ๊ฑฐ๋๋ด์ญ ์ถ๋ ฅ๊ธฐ,PaymentMethodService๋ ๋ฑ๋ก๋ ์นด๋ ๊ด๋ฆฌ๋์ฅ์ ๋๋ค.
ํต์ฌ ํด๋์ค ์ญํ ํ
| ํด๋์ค | ๋ ์ด์ด | ํ ์ค ์ญํ | ๋น์ |
|---|---|---|---|
TossPaymentsService |
service | ํ ์ค /v1/payments/confirm ํธ์ถ โ ๊ฒฐ์ ํ์ |
๋ณธ์ ์ ๊ฒฐ์ ์ง์ ํ์ธ ์ ํ |
CryptoService |
service | AES-256-GCM ์๋ณตํธํ (KIS/Binance ํค ๋ณดํธ) | ๊ธ๊ณ (์ ๊ทธ๊ธฐ/์ด๊ธฐ) |
PaymentMethodService |
service | ์นด๋ ๋ฑ๋กยท์ญ์ ยท๊ธฐ๋ณธ์นด๋ ์ค์ (๋ง์คํน ์ ์ฅ) | ์นด๋ ๊ด๋ฆฌ๋์ฅ |
TossWebhookController |
controller | ํ ์ค๊ฐ ํธ์ํ๋ ์ํ๋ณ๊ฒฝ ์์ (HMAC ๊ฒ์ฆ) | ๋ณธ์ ํฉ์ค ์์ ๊ธฐ |
LedgerController |
controller | ์์คํฌ๋ก ์ด๋ฒคํธ โ ๊ฐ๊ณ๋ถ(์์ /์ง์ถ) ๋ชฉ๋ก | ํต์ฅ ๊ฑฐ๋๋ด์ญ |
PaymentMethodController |
controller | ์นด๋ CRUD REST ์๋ํฌ์ธํธ | ์ฐฝ๊ตฌ ์ ์๋ |
PaymentMethod |
entity | ์นด๋(๋ธ๋๋ยทlast4ยท๋ง๋ฃ) โ ์ ์ฒด๋ฒํธ/CVC ์ ์ฅ ์ ํจ | ๋ง์คํน๋ ์นด๋ ์ฌ๋ณธ |
AesGcmCryptoService |
global/security | (์ฐ๊ด) GitHub PAT ๋ฑ ๋ค๋ฅธ ํ ํฐ์ฉ ๋ณ๋ AES-GCM | ๋ค๋ฅธ ๊ธ๊ณ (ํค ํ์ ๋ฐฉ์ ๋ค๋ฆ) |
SubscriptionController |
(strategy ๋๋ฉ์ธ) | ๊ฒฐ์ confirm + Pro ํ์ฑํ + M8 ๋ฉฑ๋ฑ์ฑ | ๊ตฌ๋ ๋ฐ๊ธ ์ฐฝ๊ตฌ (์ด ๋ฌธ์์ ๋งํฌ) |
โ ๏ธ ํท๊ฐ๋ฆผ ์ฃผ์: ๊ฒฐ์ confirm ์ ์ง์ง ์ง์ ์ ์
payment๊ฐ ์๋๋ผstrategy๋๋ฉ์ธ์SubscriptionController์ ๋๋ค. ๊ทธ ์ปจํธ๋กค๋ฌ๊ฐpayment์TossPaymentsService.confirm()์ ๋น๋ ค ์๋๋ค. ๊ทธ๋์ ์ด ๋ฌธ์๋ "๊ฒฐ์ ๋ฅผ ๋๊ฐ ์์ํ๋"๋ฅผ ์ค๋ช ํ ๋ ๊ทธ ํด๋์ค๋ฅผ ๋งํฌ๋ก ์ธ์ฉํฉ๋๋ค(์๋ ยงF).
๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
1) Toss Payments v1 โ "๊ฒฐ์ ์ฐฝ์ ํ๋ก ํธ, ํ์ ์ ์๋ฒ"
ํ ์ค ๊ฒฐ์ ๋ 2๋จ๊ณ์ ๋๋ค. ์ด๋ณด๊ฐ ๊ฐ์ฅ ๋ง์ด ์คํดํ๋ ๋ถ๋ถ.
- 1๋จ๊ณ (ํ๋ก ํธ): ์ฌ์ฉ์๊ฐ ํ ์ค SDK ๊ฒฐ์ ์ฐฝ์์ ์นด๋์ ๋ณด๋ฅผ ๋ฃ๊ณ "๊ฒฐ์ ํ๊ธฐ"๋ฅผ ๋๋ฆ โ ํ ์ค๊ฐ
paymentKey,orderId,amount3์ข ์ธํธ๋ฅผ ๋ง๋ค์ด ์ฐ๋ฆฌ ํ๋ก ํธ์successUrl๋ก ๋๋ ค๋ณด๋. ์ด ์์ ์ ์์ง ๋์ด ์ ๋น ์ง. "๊ฒฐ์ ์์ฝ" ์ํ์ผ ๋ฟ. - 2๋จ๊ณ (์๋ฒ confirm): ์ฐ๋ฆฌ ๋ฐฑ์๋๊ฐ ๊ทธ 3์ข
์ธํธ๋ฅผ ๋ฐ์, ์๋ฒ์ ์ํฌ๋ฆฟ ํค๋ก ํ ์ค
/v1/payments/confirm์ ํธ์ถ. ์ด๋ ๋น๋ก์ ์ค์ ์ฒญ๊ตฌ๊ฐ ํ์ ๋จ.
์ ๊ตณ์ด ์๋ฒ๊ฐ ํ ๋ฒ ๋ ํ์ธํ๋? โ ์ ์ฑ ์ฌ์ฉ์๊ฐ ํ๋ก ํธ JS ๋ฅผ ์กฐ์ํด "9,900์์ง๋ฆฌ๋ฅผ 100์์ผ๋ก" ๋ฐ๊ฟ๋, ์๋ฒ๊ฐ
amount๋ฅผ ๋ค์ ๊ฒ์ฆ(VALID_PLANS)ํ๊ณ ํ ์ค์ ๊ทธ๋๋ก ๋ณด๋ด๋ฏ๋ก ์๋ณ์กฐ๊ฐ ๋งํ๋๋ค. ๊ธ์ก ๊ฒ์ฆ์ ์ต์ข ์ฑ ์์ ํญ์ ์๋ฒ.
2) ์นํ (Webhook) โ "๋ณธ์ ์ด ์ฐ๋ฆฌํํ ๊ฑฐ๋ ์ ํ"
confirm ์ ์ฐ๋ฆฌ๊ฐ ํ ์ค์๊ฒ ๊ฑฐ๋ ํธ์ถ(๋๊ธฐ, ์ฆ์ ์๋ต). ๋ฐ๋ฉด ์นํ ์ ํ ์ค๊ฐ ์ฐ๋ฆฌ์๊ฒ ๊ฑฐ๋ ํธ์ถ(๋น๋๊ธฐ)์ ๋๋ค.
- ์ธ์ ? ๊ฒฐ์ ํ์ ์ดํ์ ์ํ๊ฐ ๋ฐ๋ ๋ โ ์ฌ์ฉ์๊ฐ ํ๋ถ๋ฐ์, ๊ฐ์๊ณ์ข ์ ๊ธ์ด ๋ค๋ฆ๊ฒ ์๋ฃ๋จ, ๋ถ๋ถ์ทจ์ ๋ฑ. ์ด๊ฑด ์ฐ๋ฆฌ๊ฐ ๋ชจ๋ฅด๋ ์ฌ์ด ์ผ์ด๋๋ฏ๋ก ํ ์ค๊ฐ ํธ์๋ก ์๋ ค์ค๋๋ค.
- ๋น์ : confirm = ๋ด๊ฐ ๋ณธ์ ์ ์ ํ(๋ด๊ฐ ๋์ ๋๊น์ง ๋๋ต ๊ธฐ๋ค๋ฆผ). ์นํ = ๋ณธ์ ์ด ๋์ค์ ๋ํํ ๊ฑฐ๋ ์ ํ(์ธ์ ์ฌ์ง ๋ชจ๋ฆ, ๋ฐ์ ์ค๋น๋ง ํด๋ ).
3) AES-256-GCM ๋์นญ์ํธํ โ "๊ฐ์ ์ด์ ๋ก ์ ๊ทธ๊ณ ์ฌ๋ ๊ธ๊ณ + ๋ด์ธ์คํฐ์ปค"
- ๋์นญ(symmetric): ์ ๊ธ ๋์ ์ด ๋ ๊ฐ์ ํค๋ฅผ ์(
APP_CRYPTO_KEY). ๊ณต๊ฐํค/๊ฐ์ธํค ํ ์์ ์ฐ๋ ๋น๋์นญ(RSA ๋ฑ)๊ณผ ๋ค๋ฆ. - AES-256: 256๋นํธ(=32๋ฐ์ดํธ) ํค๋ฅผ ์ฐ๋ ํ์ค ์ํธ. ํค๊ฐ ์ ํํ 32๋ฐ์ดํธ์ฌ์ผ ํจ.
- GCM: ๋จ์ ์ํธํ์ ๋ํด ์ธ์ฆ ํ๊ทธ(GCM tag, 16๋ฐ์ดํธ) ๋ฅผ ๊ฐ์ด ๋ง๋ฆ. ๋๊ฐ ์ํธ๋ฌธ์ ํ ๊ธ์๋ผ๋ ๋ฐ๊พธ๋ฉด ๋ณตํธํ ์ ํ๊ทธ๊ฐ ์ ๋ง์ ์ฆ์ ์๋ณ์กฐ ํ์ง. (๋น์ : ๋ดํฌ์ ๋ด์ธ ์คํฐ์ปค. ๋ฏ์ผ๋ฉด ํฐ ๋จ.)
- IV / nonce(12๋ฐ์ดํธ): "์ด๊ธฐํ ๋ฒกํฐ". ๋งค๋ฒ ๋๋ค์ผ๋ก ์๋ก ๋ง๋๋ ์๊ธ ๊ฐ์ ๊ฐ. ๊ฐ์ ํ๋ฌธ์ ๋ ๋ฒ ์ํธํํด๋ IV ๊ฐ ๋ค๋ฅด๋ฉด ์ํธ๋ฌธ์ด ์์ ํ ๋ฌ๋ผ์ง โ ํจํด ๋ถ์ ๋ฐฉ์ง. ๋น๋ฐ์ด ์๋์ด์ ์ํธ๋ฌธ ์์ ๋ถ์ฌ ๊ฐ์ด ์ ์ฅํด๋ ์์ .
- ์ฐ๋ฆฌ ์ ์ฅ ํฌ๋งท:
Base64( IV(12) โ ciphertext โ GCM_TAG(16) )โ ์ธ ์กฐ๊ฐ์ ํ ์ค๋ก ์ด์ด ํ ๋ฌธ์์ด๋ก ๋ณด๊ด(๋ง์ด๊ทธ๋ ์ด์ ์ด ๋จ์ํด์ง).
4) ๋ฉฑ๋ฑ์ฑ(Idempotency) โ "๋ฒํผ ๋ ๋ฒ ๋๋ฌ๋ ๊ฒฐ์ ๋ ํ ๋ฒ"
๊ฐ์ ์์ฒญ์ ์ฌ๋ฌ ๋ฒ ๋ณด๋ด๋ ๊ฒฐ๊ณผ๊ฐ ํ ๋ฒ ํ ๊ฒ๊ณผ ๊ฐ์์ผ ํ๋ค๋ ์ฑ์ง. ๊ฒฐ์ ์์ ํ์์ ๋๋ค.
- ์? ์ฌ์ฉ์๊ฐ successUrl ์์ ์๋ก๊ณ ์นจํ๊ฑฐ๋, ๋คํธ์ํฌ๊ฐ ๋๊ฒจ ํ๋ก ํธ๊ฐ ์ฌ์๋ํ๊ฑฐ๋, ๋๋ธํด๋ฆญํ๋ฉด confirm ์ด 2๋ฒ ๋ ์์ฌ ์ ์์. ๊ทธ๋๋ก ๋๋ฉด ๊ตฌ๋ ์ด 2๋ฒ ํ์ฑํ๋๊ฑฐ๋ ํ ์ค์ 2๋ฒ confirm ํด ์๋ฌ๊ฐ ๋จ.
- ํด๊ฒฐ(M8):
toss_payment_key๋ฅผ DB ์ ๋ํฌ ํค๋ก ๊ฑธ๊ณ , ์ด๋ฏธ ์ฒ๋ฆฌ๋ ํค๋ฉด ํ ์ค ์ฌํธ์ถ ์์ด ๊ธฐ์กด ๊ตฌ๋ ์ ๊ทธ๋๋ก ๋๋ ค์ค. (์ฝ๋๋ ยงFยทDB ๋ง์ด๊ทธ๋ ์ด์ V17.)
5) ์์ฅ(Ledger) โ "๊ฑฐ๋๋ด์ญ ํต์ฅ"
์ฌ๋ฌ ์ํ ๋ณํ(์์คํฌ๋ก ์
๊ธยทํ๋ถยท์ ์ฐ)๋ฅผ ์๊ฐ์ ๊ฑฐ๋ ๋ชฉ๋ก์ผ๋ก ๋ฝ์ ๋ณด์ฌ์ฃผ๋ ๊ฒ. ํ๊ณ์ "์ฅ๋ถ"์์ ์จ ๋ง. ์ฐ๋ฆฌ๋ ProjectEscrow(์์คํฌ๋ก) ์ด๋ฒคํธ๋ฅผ ์์
(income)/์ง์ถ(expense)์ผ๋ก ๋ถ๋ฅํด ๊ฐ๊ณ๋ถ์ฒ๋ผ ๋ณด์ฌ์ค๋๋ค.
6) PCI ์ ์ฑ โ "์นด๋ ์ ์ฒด๋ฒํธยทCVC ๋ ์ ๋ ์ ์ฅ ๊ธ์ง"
์นด๋์ ๋ณด๋ฅผ ์ง์ ๋ณด๊ดํ๋ฉด PCI-DSS ๋ผ๋ ๋ฌด๊ฑฐ์ด ๋ณด์ ๊ท์ ๋ฅผ ๋ฐ์ต๋๋ค. ๊ทธ๋์ ์ฐ๋ฆฌ๋ ์นด๋๋ฒํธ ๋ค 4์๋ฆฌ(last4)ยท๋ธ๋๋ยท์์ ์๋ช
ยท๋ง๋ฃ์๋ง ์ ์ฅํ๊ณ , ์ ์ฒด ๋ฒํธ์ CVC ๋ ํ์ ๊ฒ์ฆ๋ง ํ๊ณ ๋ฒ๋ฆฝ๋๋ค(PaymentMethod ์ฃผ์์ ๋ช
์).
๐บ ์์ฒญ ํ๋ฆ๋
(A) ๊ตฌ๋ ๊ฒฐ์ โ ๋๊ธฐ confirm ํ๋ฆ
[ํ๋ก ํธ React]
์ฌ์ฉ์๊ฐ ํ ์ค SDK ๊ฒฐ์ ์ฐฝ์์ ์นด๋ ์
๋ ฅ โ "๊ฒฐ์ "
โ ํ ์ค๊ฐ paymentKey ยท orderId ยท amount ๋ฐ๊ธ
โผ
successUrl ๋ก ๋ฆฌ๋ค์ด๋ ํธ (์์ง ์ฒญ๊ตฌ ์ ๋จ)
โ POST /api/subscription/confirm { paymentKey, orderId, amount }
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SubscriptionController.confirm() (strategy ๋๋ฉ์ธ) โ
โ 1. JWT ๋ก ๋ณธ์ธ ํ์ธ (AuthContext.currentUserId) โ
โ 2. amount โ VALID_PLANS ? (9900/19900 ์๋ณ์กฐ ๋ฐฉ์ง) โ
โ 3. paymentKey ๋น์๋? โ
โ 4. โญ findByPaymentKey() โ ์ด๋ฏธ ์ฒ๋ฆฌ๋์ผ๋ฉด ๋ฉฑ๋ฑ ๋ฐํ (M8) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ (์ ๊ท ๊ฒฐ์ ์ผ ๋๋ง)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ TossPaymentsService.confirm() (payment ๋๋ฉ์ธ โ
) โ
โ POST https://api.tosspayments.com/v1/payments/confirm โ
โ Authorization: Basic base64(secretKey + ":") โ
โ body: { paymentKey, orderId, amount } โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ํ ์ค ์๋ต JSON { status:"DONE", method:"์นด๋", ... }
โผ
status == "DONE" ? โ SubscriptionService.activatePro() โ Pro ๋ฑ๊ธ ํ์ฑํ
โ (DB ์ ๋ํฌ ์ถฉ๋ ์ DataIntegrityViolation โ ๋ฉฑ๋ฑ ์ฌ์ฌ์ฉ)
โผ
{ tier:"STANDARD", status, expiresAt } ๋ฅผ ํ๋ก ํธ์ ์๋ต(B) ์ํ๋ณ๊ฒฝ โ ๋น๋๊ธฐ ์นํ ํ๋ฆ (์ทจ์ยทํ๋ถยท๊ฐ์๊ณ์ข ์ ๊ธ)
[ํ ์ค ์๋ฒ] ๊ฒฐ์ ์ทจ์/ํ๋ถ/์
๊ธ์๋ฃ ๋ฐ์
โ POST /api/payments/toss/webhook
โ ํค๋ X-Toss-Signature + raw JSON body
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ TossWebhookController.webhook() โ
โ 1. webhookSecret ์์ผ๋ฉด โ HMAC-SHA256 ์๋ช
๊ฒ์ฆ โ
โ (rawBody ๋ฅผ secret ์ผ๋ก ํด์ == X-Toss-Signature ?) โ
โ 2. eventType / data.paymentKey / data.status ํ์ฑ โ
โ 3. escrowRepository.findByPaymentTxId(paymentKey) โ
โ 4. status ์ ๋ฐ๋ผ ์์คํฌ๋ก ์ํ ์ ์ด โ
โ CANCELED/PARTIAL_CANCELED โ REFUNDED โ
โ DONE(๊ฐ์๊ณ์ข ์
๊ธ) โ markDepositedFromExternal โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ํญ์ 200 { ok:true } (ํ ์ค์ "์ ๋ฐ์๋ค" ์ ํธ)(C) ์๊ฒฉ์ฆ๋ช ๊ธ๊ณ โ CryptoService ์ฌ์ฉ์ฒ
[BrokerAccountController] ์ฌ์ฉ์๊ฐ KIS appsecret / Binance secret ๋ฑ๋ก
โ crypto.encrypt(appsecret)
โผ
CryptoService.encrypt() โ Base64(IVโCTโTAG)
โผ
BrokerAccount.appSecretEnc ์ปฌ๋ผ์ ์ํธ๋ฌธ ์ ์ฅ (ํ๋ฌธ ์ ๋ ๊ธ์ง)
โ ... ์ค์ฃผ๋ฌธ ์ ...
โผ
CryptoService.decrypt(appSecretEnc) โ ํ๋ฌธ ๋ณต์ โ KIS ํธ์ถ๐ ํต์ฌ ํด๋์ค ๋ผ์ธ๋ณ ์ฌํ
A. TossPaymentsService.confirm() โ ๊ฒฐ์ ํ์ ํธ์ถ
์๋ณธ: domain/payment/service/TossPaymentsService.java
A-1. ํด๋์ค ์ ์ธ๊ณผ ์ฃผ์
โ TossPaymentsService.java:25-37
// L25-L37
@Slf4j
@Service
@RequiredArgsConstructor
public class TossPaymentsService {
@Value("${tosspayments.secret-key}")
private String secretKey;
@Value("${tosspayments.api-base}")
private String apiBase;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper om = new ObjectMapper();
@Serviceโ ์คํ๋ง์ด ์ด ํด๋์ค๋ฅผ ๋น(bean)์ผ๋ก ๋ง๋ค์ด ๋ค๋ฅธ ๊ณณ์ ์ฃผ์ ํ ์ ์๊ฒ ํจ.@Value("${tosspayments.secret-key}")โapplication*.properties(๋๋ ํ๊ฒฝ๋ณ์TOSS_SECRET_KEY)์์ ์ํฌ๋ฆฟ ํค๋ฅผ ์ฝ์ดsecretKeyํ๋์ ๋ฃ์. ์ด ํค๋ ์ ๋ ํ๋ก ํธ์ ๋ ธ์ถ๋๋ฉด ์ ๋ฉ๋๋ค(์๋ฒ ์ ์ฉ). ํด๋ผ์ด์ธํธ ํค(test_ck_...)์ ์ํฌ๋ฆฟ ํค(test_sk_...)๋ ๋ฐ๋์ ๊ฐ์ ํ ์ค ๊ณ์ ์ ํค์์ด์ด์ผ ํจ(CLAUDE.md ์ฃผ์์ฌํญ).apiBaseโhttps://api.tosspayments.com๊ฐ์ ๋ฒ ์ด์ค URL. ํ๊ฒฝ๋ณ๋ก ๋ฐ๊ฟ ์ ์๊ฒ ์ธ๋ถํ.RestTemplateโ ์คํ๋ง์ HTTP ํด๋ผ์ด์ธํธ(์ฐ๋ฆฌ๊ฐ ํ ์ค์๊ฒ ๊ฑฐ๋ ์ ํ๊ธฐ).ObjectMapperโ JSON ๋ฌธ์์ด โ ์๋ฐ ๊ฐ์ฒด ๋ณํ๊ธฐ.
๐ก ์ด๋ณด ํฌ์ธํธ:
@Value์${...}์์:๊ฐ ์์ผ๋ฉด(์: ์ฌ๊ธฐ) ํค๊ฐ ์์ผ๋ฉด ๋ถํ ์คํจ์ ๋๋ค. ํ ์ค ํค๋ ๋ฐ๋์ ์ค์ ๋ผ์ผ ํ๋ค๋ ๋ป.
A-2. confirm ๋ณธ์ฒด โ Basic ์ธ์ฆ ํค๋ ๋ง๋ค๊ธฐ โ TossPaymentsService.java:43-58
// L43-L58
public JsonNode confirm(String paymentKey, String orderId, long amount) {
String url = apiBase + "/v1/payments/confirm";
// Basic Auth: secretKey + ":" base64
String basic = Base64.getEncoder().encodeToString(
(secretKey + ":").getBytes(StandardCharsets.UTF_8));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Basic " + basic);
Map<String, Object> body = Map.of(
"paymentKey", paymentKey,
"orderId", orderId,
"amount", amount
);
- ํ ์ค ์ธ์ฆ ๋ฐฉ์ = HTTP Basic Auth. ๊ท์น:
"์ํฌ๋ฆฟํค:"(์ฝ๋ก ๋ค ๋น๋ฐ๋ฒํธ๋ ๋น์)์ Base64 ๋ก ์ธ์ฝ๋ฉํดAuthorization: Basic <์ธ์ฝ๋ฉ๊ฐ>ํค๋์ ๋ฃ์.- ์
secretKey + ":"์ธ๊ฐ? Basic Auth ๋ ์๋์์ด๋:๋น๋ฐ๋ฒํธํ์์ธ๋ฐ, ํ ์ค๋ ์ํฌ๋ฆฟํค๋ฅผ ์์ด๋ ์๋ฆฌ์ ๋ฃ๊ณ ๋น๋ฐ๋ฒํธ๋ ๋น์. ๊ทธ๋์ ์ฝ๋ก ๋ค๊ฐ ๋น์ด ์์.
- ์
body3์ข ์ธํธ = ํ๋ก ํธ๊ฐ ๋ฐ์ ๊ทธ๋๋ก์paymentKey,orderId,amount. ํ ์ค๋ ์ด ์ ์ด ์๊ธฐ๊ฐ ๋ฐ๊ธํ ๊ฒ๊ณผ ์ผ์นํ๋์ง(ํนํ amount) ๋์กฐํด ์๋ณ์กฐ๋ฅผ ์ก์ต๋๋ค.
A-3. ํธ์ถ + ์ฑ๊ณต ์ฒ๋ฆฌ โ TossPaymentsService.java:60-66
// L60-L66
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
ResponseEntity<String> res = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
JsonNode json = om.readTree(res.getBody());
log.info("[Toss] confirm OK orderId={} status={} method={}",
orderId, json.path("status").asText(), json.path("method").asText());
return json;
restTemplate.exchange(...)โ ์ค์ POST ์ ์ก. ์๋ต ํ์ ์String.class๋ก ๋ฐ๋ ์ ์ด ํต์ฌ: JSON ์ ๋ฌธ์์ด๋ก ๋ฐ์ ๋คom.readTree()๋ก ์ง์ ํธ๋ฆฌ(JsonNode)๋ก ํ์ฑํฉ๋๋ค.- ์ ์๋ ๋งคํ(
JsonNode.class) ์ ์ฐ๋? โ ๋ฉ๋ชจ๋ฆฌ์ "Spring Boot 4 Jackson JsonNode ๋ฒ๊ทธ"(Boot4 ๊ธฐ๋ณธ์ด Jackson3 ๋ผJsonNode์๋ ์ญ์ง๋ ฌํ๊ฐ ๊นจ์ง). String ์ผ๋ก ๋ฐ์ ์ฐ๋ฆฌObjectMapper(Jackson2)๋ก ํ์ฑํ๋ ์ฐํ ํจํด์ ๋๋ค.
- ์ ์๋ ๋งคํ(
- ์ฑ๊ณต ์ ํ ์ค ์๋ต JSON ์ ๊ทธ๋๋ก ๋ฐํ. ํธ์ถ์(
SubscriptionController)๊ฐjson.path("status")๊ฐ"DONE"์ธ์ง ํ์ธ. json.path("status")โ.path()๋ ํค๊ฐ ์์ด๋ ์์ธ ๋์ "๋น ๋ ธ๋"๋ฅผ ๋๋ ค์ค NPE ์์ .
A-4. ์คํจ ์ฒ๋ฆฌ โ ํ ์ค ์๋ฌ ๋ฉ์์ง ์ถ์ถ โ TossPaymentsService.java:67-82
// L67-L82
} catch (HttpStatusCodeException e) {
String msg = "ํ ์ค ๊ฒฐ์ ์น์ธ ์คํจ";
try {
JsonNode err = om.readTree(e.getResponseBodyAsString());
msg = err.path("message").asText(msg);
log.warn("[Toss] confirm fail orderId={} code={} message={}", orderId,
err.path("code").asText(), msg);
} catch (Exception ignore) {
log.warn("[Toss] confirm fail orderId={} body={}", orderId, e.getResponseBodyAsString());
}
throw new RuntimeException(msg);
} catch (Exception e) {
log.warn("[Toss] confirm error orderId={}: {}", orderId, e.getMessage());
throw new RuntimeException("๊ฒฐ์ ์น์ธ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: " + e.getMessage());
}
}
HttpStatusCodeExceptionโ ํ ์ค๊ฐ 4xx/5xx ๋ฅผ ๋๋ ค์ค ๊ฒฝ์ฐ(์: ์ด๋ฏธ ์ฒ๋ฆฌ๋ ๊ฒฐ์ , ๊ธ์ก ๋ถ์ผ์น). ์๋ฌ ๋ฐ๋์์ ํ ์ค๊ฐ ์ค ํ๊ธ ๋ฉ์์ง(message)๋ฅผ ๊บผ๋ด ๊ทธ๋๋ก ์ฌ์ฉ์์๊ฒ ์ ๋ฌ โ ์น์ ํ ์๋ฌ.- ๋ ๋จ๊ณ
catch: ํ ์ค๊ฐ ์ค ๊ตฌ์กฐํ๋ ์๋ฌ(HttpStatusCodeException) vs ๊ทธ ์ธ ๋ชจ๋ ์ค๋ฅ(๋คํธ์ํฌ ๋๊น ๋ฑ). ์ด๋ ์ชฝ์ด๋RuntimeException์ผ๋ก ํต์ผํด ์๋ก ๋์ง โ ์ปจํธ๋กค๋ฌ๊ฐbadRequest๋ก ๋ณํ.
โ ๏ธ ํจ์ :
RuntimeExceptionํ ์ข ๋ฅ๋ก ๋ญ๋ฑ๊ทธ๋ฆฌ๋ฉด, ํธ์ถ ์ธก(SubscriptionController)์์ "ํ ์ค ๊ฑฐ์ "๊ณผ "๋คํธ์ํฌ ์ฅ์ "๋ฅผ ๊ตฌ๋ถ ๋ชป ํฉ๋๋ค. ๊ณ ๋ํ ์ ์ปค์คํ ์์ธ๋ก ๋ถ๋ฆฌํ๋ฉด ์ฌ์๋ ์ ์ฑ ์ ๋ค๋ฅด๊ฒ ์ค ์ ์์ต๋๋ค(ยง๊ณ ๋ํ).
B. CryptoService.encrypt() / decrypt() โ AES-GCM ๊ธ๊ณ
์๋ณธ: domain/payment/service/CryptoService.java
B-1. ํค ๋ก๋ฉ + ๊ฒ์ฆ (์์ฑ์) โ CryptoService.java:44-61
// L44-L61
public CryptoService(@Value("${app.crypto.key}") String base64Key) {
String trimmed = base64Key == null ? "" : base64Key.trim();
byte[] keyBytes;
try {
keyBytes = Base64.getDecoder().decode(trimmed);
} catch (IllegalArgumentException e) {
throw new IllegalStateException("app.crypto.key๋ Base64๋ก ์ธ์ฝ๋ฉ๋ 32๋ฐ์ดํธ์ฌ์ผ ํฉ๋๋ค.", e);
}
if (keyBytes.length != 32) {
throw new IllegalStateException("app.crypto.key๋ ์ ํํ 32๋ฐ์ดํธ(=AES-256)์ด์ด์ผ ํฉ๋๋ค. ํ์ฌ: " + keyBytes.length + "๋ฐ์ดํธ");
}
this.secretKey = new SecretKeySpec(keyBytes, ALG);
this.devKey = trimmed.startsWith("dev-only")
|| "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".equals(trimmed);
...
@Value("${app.crypto.key}")โ ์ฝ๋ก ์๋ ํ๋ ์ด์คํ๋ โ ๊ธฐ๋ณธ๊ฐ์ด ์์. ์ฆAPP_CRYPTO_KEY(๋๋app.crypto.key)๊ฐ ์ค์ ์ ๋๋ฉด ์ฑ์ด ๋ถํ ํ๋ค ์ฃฝ์ต๋๋ค(CLAUDE.md ์ ํต์ฌ ์ฃผ์). ์ด๊ฑด ์๋๋ ์์ ์ฅ์น: "์ํธํ ํค ์์ด ๋ฏผ๊ฐ์ ๋ณด ๋ค๋ฃจ์ง ๋ง๋ผ."- ํค๋ Base64 ๋ก ์ธ์ฝ๋ฉ๋ 32๋ฐ์ดํธ์ฌ์ผ ํจ. ๋์ฝ๋ฉ ์คํจํ๊ฑฐ๋ ๊ธธ์ด๊ฐ 32 ๊ฐ ์๋๋ฉด ๋ช
ํํ ํ๊ธ ๋ฉ์์ง๋ก
IllegalStateExceptionโ ๋ถํ ์ค๋จ. (32๋ฐ์ดํธ = AES-256 ์ ์๊ตฌ ํค ๊ธธ์ด.) devKeyํ์ : ํค๊ฐdev-only๋ก ์์ํ๊ฑฐ๋ ์๋ ค์ง ๊ฐ๋ฐ์ฉ ๊ธฐ๋ณธ Base64(AAECAwQF...)๋ฉด "๊ฐ๋ฐ์ฉ ํค"๋ก ํ์ โ ์ด์์์ ์ค์๋ก ์ฐ๋ฉด ๊ฒฝ๊ณ ํ๋ ค๋ ์ฉ๋.
๐ก ์ด๋ณด ํฌ์ธํธ: ์์ฑ์์์ ํค๋ฅผ ๊ฒ์ฆํ๋ ์ด์ โ ์๋ชป๋ ํค๋ก ์ด์ํ๋ค ๋์ค์ ๋ณตํธํ๊ฐ ๊นจ์ง๋๋, ์์ํ ๋ ๋นจ๋ฆฌ ์ฃฝ๋ ๊ฒ ๋ซ๋ค("fail fast"). ์ํธํ๋ ํ ๋ฒ ์๋ชป๋๋ฉด ๋ฐ์ดํฐ๋ฅผ ์์ ๋ชป ์ฝ์ ์ ์์ด ๋์ฑ ์ค์.
B-2. ์์ ๋ก๊ทธ โ ํค ์ง๋ฌธ(fingerprint)๋ง ๋
ธ์ถ โ CryptoService.java:63-82
// L63-L71
@PostConstruct
void warn() {
// ์์ ์์ ์ ์ด๋ค ํค๊ฐ active ์ธ์ง fingerprint ๋ก ์๋ณ (๊ฐ ์์ฒด๋ ๋
ธ์ถ ์ ํจ)
log.info("[CryptoService] app.crypto.key loaded โ b64len={} b64tail=...{} sha256[0..8]={} dev={}",
keyB64Len, keyB64Tail, keyFingerprint, devKey);
if (devKey) {
log.warn("โ ๏ธ app.crypto.key๊ฐ ๊ฐ๋ฐ์ฉ ๊ธฐ๋ณธ๊ฐ์
๋๋ค. ์ด์ ๋ฐฐํฌ ์ ๋ฐ๋์ APP_CRYPTO_KEY ํ๊ฒฝ๋ณ์๋ก ๊ต์ฒดํ์ธ์.");
}
}
@PostConstructโ ๋น์ด ๋ง๋ค์ด์ง ์งํ 1ํ ์คํ. ์์ ๋ก๊ทธ์ ์ด๋ค ํค๊ฐ ํ์ฑ์ธ์ง ์๋ณ ์ ๋ณด๋ฅผ ๋จ๊น.- ํต์ฌ ๋ณด์ ์ค๊ณ: ํค ๊ฐ ์์ฒด๋ ๋ก๊ทธ์ ์ ์ฐ์ต๋๋ค. ๋์ SHA-256 ํด์์ ์ 4๋ฐ์ดํธ(
keyFingerprint)ยท๊ธธ์ดยท๊ผฌ๋ฆฌ 4๊ธ์๋ง ์ฐ์. ์ด๋ฌ๋ฉด "์ง๊ธ ์๋ฒ A ์ B ๊ฐ ๊ฐ์ ํค๋ฅผ ์ฐ๋?"๋ฅผ ์ง๋ฌธ ๋น๊ต๋ก ํ์ธํ๋ฉด์๋ ํค ์์ฒด๋ ์ ์ถ ์ ๋จ. - ์ด๊ฒ ์ ์ค์? โ ๋ฉ๋ชจ๋ฆฌ "Env priority changes need duplicate-key audit":
app.crypto.key๊ฐ ์ฌ๋ฌ ํ์ผ์ ๋ค๋ฅธ ๊ฐ์ผ๋ก ์ค๋ณต๋๋ฉด, ์ํธํํ ํค๋ก ๋ณตํธํ๊ฐ ์ ๋๋ ๋์ฐธ์ฌ๊ฐ ๋ฉ๋๋ค. ์ด ์ง๋ฌธ ๋ก๊ทธ๊ฐ ๊ทธ๊ฑธ ์ก๋ ์ง๋จ ๋๊ตฌ.
B-3. encrypt โ IV ๋๋ค ์์ฑ + ์กฐ๋ฆฝ โ CryptoService.java:85-100
// L85-L100
public String encrypt(String plain) {
if (plain == null) return null;
try {
byte[] iv = new byte[IV_LEN]; // IV_LEN = 12
rng.nextBytes(iv); // ๋งค๋ฒ ์ ๋๋ค nonce
Cipher c = Cipher.getInstance(TRANSFORM);// "AES/GCM/NoPadding"
c.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(TAG_BITS, iv)); // TAG_BITS = 128
byte[] ct = c.doFinal(plain.getBytes(StandardCharsets.UTF_8));
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return Base64.getEncoder().encodeToString(out);
} catch (Exception e) {
throw new RuntimeException("encrypt failed", e);
}
}
rng.nextBytes(iv)โ ๋งค ์ํธํ๋ง๋ค ์ 12๋ฐ์ดํธ IV(nonce)๋ฅผ ๋๋ค ์์ฑ. GCM ์ ํฉ๊ธ๋ฅ : "๊ฐ์ ํค๋ก ๊ฐ์ IV ๋ฅผ ์ฌ์ฌ์ฉํ๋ฉด ์ ๋๋ค"(์ฌ์ฌ์ฉํ๋ฉด ๋ณด์์ด ๋ฌด๋์ง).SecureRandom์ด๋ผ ์์ธก ๋ถ๊ฐ.GCMParameterSpec(128, iv)โ ์ธ์ฆ ํ๊ทธ๋ฅผ 128๋นํธ(16๋ฐ์ดํธ)๋ก. GCM ์ ์ํธํ ํ ์ด ํ๊ทธ๋ฅผct๋์ ์๋์ผ๋ก ๋ถ์ฌ ์ค๋๋ค. ์ฆc.doFinal()๊ฒฐ๊ณผct=์ค์ ์ํธ๋ฌธ + 16๋ฐ์ดํธ ํ๊ทธ.- ์กฐ๋ฆฝ:
out = IV(12) + ct(์ํธ๋ฌธ+ํ๊ทธ). ์ด๊ฑธ Base64 ํ ๋ฌธ์์ด๋ก โ DB ํ ์ปฌ๋ผ์ ์ ์ฅ.- IV ๋ฅผ ์ํธ๋ฌธ ์์ ๊ฐ์ด ์ ์ฅํ๋ ๊ฒ ๋ง๋? โ ๋ค. IV ๋ ๋น๋ฐ์ด ์๋๋ฉฐ(๋๋ค์ด๊ธฐ๋ง ํ๋ฉด ๋จ), ๋ณตํธํํ๋ ค๋ฉด ๋ฐ๋์ ๊ฐ์ IV ๊ฐ ํ์ํ๋ฏ๋ก ํจ๊ป ๋ณด๊ดํ๋ ๊ฒ ํ์ค์ ๋๋ค.
encrypt ๊ฒฐ๊ณผ ๋ฐ์ดํธ ๋ ์ด์์:
โโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ
โ IV (12B) โ ciphertext (๊ฐ๋ณ) โ GCM tag(16B) โ โ ์ ์ฒด๋ฅผ Base64
โโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโ
โ ๋๋ค nonce โ ์ง์ง ์ํธ๋ฌธ โ ๋ด์ธ ์คํฐ์ปคB-4. decrypt โ ๋ถํด + ํ๊ทธ ๊ฒ์ฆ โ CryptoService.java:103-118
// L103-L118
public String decrypt(String encoded) {
if (encoded == null) return null;
try {
byte[] all = Base64.getDecoder().decode(encoded);
if (all.length < IV_LEN + 16) throw new IllegalArgumentException("ciphertext too short");
byte[] iv = new byte[IV_LEN];
System.arraycopy(all, 0, iv, 0, IV_LEN);
byte[] ct = new byte[all.length - IV_LEN];
System.arraycopy(all, IV_LEN, ct, 0, ct.length);
Cipher c = Cipher.getInstance(TRANSFORM);
c.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(TAG_BITS, iv));
return new String(c.doFinal(ct), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("decrypt failed (key mismatch or tampered ciphertext)", e);
}
}
- ์ํธํ์ ์ ํํ ์ญ์: Base64 ํ๊ณ โ ์ 12๋ฐ์ดํธ๋ฅผ IV ๋ก, ๋๋จธ์ง๋ฅผ
ct(์ํธ๋ฌธ+ํ๊ทธ)๋ก ์ชผ๊ฐฌ. if (all.length < IV_LEN + 16)โ ์ต์ ๊ธธ์ด(IV 12 + ํ๊ทธ 16 = 28๋ฐ์ดํธ) ๋ฏธ๋ง์ด๋ฉด ๊นจ์ง ๋ฐ์ดํฐ โ ์ฆ์ ๊ฑฐ๋ถ.c.doFinal(ct)โ ์ฌ๊ธฐ์ GCM ์ด ํ๊ทธ๋ฅผ ์๋ ๊ฒ์ฆ. ์ํธ๋ฌธ์ด 1๋นํธ๋ผ๋ ๋ณ์กฐ๋๊ฑฐ๋ ํค๊ฐ ๋ค๋ฅด๋ฉดAEADBadTagException์ด ํฐ์ง โ catch ๋ก ์ก์"key mismatch or tampered ciphertext"๋ฉ์์ง๋ก ๋์ง.- ์ฆ ์ด ํ ์ค์ด ์๋ณ์กฐ ํ์ง + ํค ๋ถ์ผ์น ํ์ง๋ฅผ ๋์์ ํฉ๋๋ค. ์๋ฌ ๋ฉ์์ง๊ฐ ๋ ์์ธ์ ๊ฐ์ด ์ ์ ์ด์ .
B-5. mask โ ํ๋ฉด ๋
ธ์ถ์ฉ ๋ง์คํน โ CryptoService.java:121-124
// L121-L124
public static String mask(String v) {
if (v == null || v.length() <= 8) return "********";
return v.substring(0, 4) + "*".repeat(Math.max(4, v.length() - 8)) + v.substring(v.length() - 4);
}
- ๋ณตํธํํ ํค๋ฅผ ํ๋ฉด์ "ํ์ธ์ฉ"์ผ๋ก ๋ณด์ฌ์ค ๋, ์ 4 + ๋ค 4๋ง ๋จ๊ธฐ๊ณ ๊ฐ์ด๋ฐ๋
*. 8์ ์ดํ๋ฉด ํต์งธ๋ก ๊ฐ๋ฆผ. (์ ์ฅ์ด ์๋๋ผ ํ์ ์ฉ๋.)
๐ก
CryptoServicevsAesGcmCryptoService์ฐจ์ด(๋ ๋ค AES-GCM):
| |CryptoService(payment) |AesGcmCryptoService(global/security) |
|---|---|---|
| ํค ์ถ์ฒ |app.crypto.key(Base64 32B, ๊ธฐ๋ณธ๊ฐ ์์) |app.crypto.keyโ ์์ผ๋ฉด JWT_SECRET ์ SHA-256 fallback |
| ํค ๊ฒ์ฆ | ์ ํํ 32B ์๋๋ฉด ๋ถํ ์คํจ | ์ด๋ค ๋ฌธ์์ด์ด๋ SHA-256 ์ผ๋ก 32B ํ์ |
| ๋ฐํํ |String(Base64) |byte[]+*Base64๋ณํ |
| ์ฃผ ์ฉ๋ | KISยทBinance ์ํฌ๋ฆฟ | GitHub PAT ๋ฑ |
๊ฐ์ ํ๋ฌธ์ ๋๋ก ์ํธํํ ๊ฒฐ๊ณผ๋ ํธํ๋์ง ์์ต๋๋ค(ํค ํ์ ๋ฐฉ์์ด ๋ค๋ฆ). ์ด๋ ์๋น์ค๋ก ์ํธํํ๋์ง ์ผ๊ด์ฑ ์ ์ง๊ฐ ์ค์.
C. TossWebhookController โ ๋น๋๊ธฐ ์ํ๋ณ๊ฒฝ ์์
์๋ณธ: domain/payment/controller/TossWebhookController.java
C-1. ์๋ํฌ์ธํธ + ์๋ช
๊ฒ์ฆ ๊ฒ์ดํธ โ TossWebhookController.java:45-55
// L45-L55
@PostMapping("/webhook")
@Transactional
public ResponseEntity<?> webhook(@RequestHeader(value = "X-Toss-Signature", required = false) String signature,
@RequestBody String rawBody) {
// ๋ณด์ํค๊ฐ ๋น์ด์์ผ๋ฉด ๊ฒ์ฆ skip (๊ฐ๋ฐ ๋จ๊ณ). ์ด์์์๋ ํ์.
if (webhookSecret != null && !webhookSecret.isBlank()) {
if (signature == null || !verifyHmac(rawBody, signature)) {
log.warn("[TossWebhook] ์๋ช
๊ฒ์ฆ ์คํจ");
return ResponseEntity.status(401).body(Map.of("message", "invalid signature"));
}
}
@RequestBody String rawBodyโ JSON ์ ๊ฐ์ฒด๊ฐ ์๋๋ผ ์์ ๋ฌธ์์ด๋ก ๋ฐ๋ ๊ฒ ํต์ฌ. ์๋ช ์ ํ ์ค๊ฐ ๋ณด๋ธ ๋ฐ์ดํธ ๊ทธ๋๋ก์ ๋ํด ๊ณ์ฐํ์ผ๋ฏ๋ก, ์ฐ๋ฆฌ๋ ํ์ฑํ๊ธฐ ์ ์๋ณธ์ผ๋ก HMAC ์ ๊ณ์ฐํด์ผ ์ผ์นํฉ๋๋ค. (๊ฐ์ฒด๋ก ๋ฐ์ ๋ค์ ์ง๋ ฌํํ๋ฉด ๊ณต๋ฐฑยทํค์์๊ฐ ๋ฌ๋ผ์ ธ ์๋ช ์ด ๊นจ์ง.)X-Toss-Signatureํค๋ โ ํ ์ค๊ฐ ๋ณธ๋ฌธ์ ์๊ธฐ ๋น๋ฐํค๋ก HMAC ํ ๊ฐ.required=false๋ผ ํค๋๊ฐ ์์ด๋ ๋ฉ์๋์๋ ๋ค์ด์ด(์๋์ ์ง์ ๊ฒ์ฌ).- ๊ฒ์ฆ ๊ฒ์ดํธ:
webhookSecret์ด ์ค์ ๋ผ ์์ ๋๋ง ๊ฒ์ฆ. ๋น์ด ์์ผ๋ฉด(๊ฐ๋ฐ) skip. โ ๏ธ ์ด์์์tosspayments.webhook-secret๋ฅผ ์ ๋ฃ์ผ๋ฉด ์๋ฌด๋ ์์กฐ ์นํ ์ ๋ณด๋ด ํ๋ถ ์ํ๋ฅผ ์กฐ์ํ ์ ์์ โ ์ด์ ํ์(ยงํจ์ ).
C-2. ๋ณธ๋ฌธ ํ์ฑ + ์์คํฌ๋ก ๋งค์นญ โ TossWebhookController.java:57-77
// L57-L77
try {
JsonNode body = om.readTree(rawBody);
String eventType = body.path("eventType").asText("");
JsonNode data = body.path("data");
String paymentKey = data.path("paymentKey").asText("");
String status = data.path("status").asText("");
log.info("[TossWebhook] eventType={} status={} paymentKey={}", eventType, status, paymentKey);
if (paymentKey.isBlank()) return ResponseEntity.ok(Map.of("ok", true));
// payment_tx_id ์ปฌ๋ผ์ paymentKey ๊ฐ ์ ์ฅ๋ผ์์ โ ๋จ๊ฑด ์กฐํ.
escrowRepository.findByPaymentTxId(paymentKey)
.ifPresent(e -> applyStatus(e, status));
return ResponseEntity.ok(Map.of("ok", true));
} catch (Exception ex) {
log.warn("[TossWebhook] ์ฒ๋ฆฌ ์ค๋ฅ: {}", ex.getMessage());
return ResponseEntity.status(500).body(Map.of("message", ex.getMessage()));
}
}
- ํ ์ค ์นํ
ํ์ด๋ก๋ ๊ตฌ์กฐ:
{ eventType, data:{ paymentKey, status, ... } }.data.paymentKey๋ก ์ฐ๋ฆฌ DB ์ ์ด๋ค ์์คํฌ๋ก์ธ์ง ์ฐพ์(payment_tx_id์ปฌ๋ผ์ paymentKey ๊ฐ ์ ์ฅ๋ผ ์์). findByPaymentTxId(...).ifPresent(...)โ ๋งค์นญ๋๋ ์์คํฌ๋ก๊ฐ ์์ ๋๋ง ์ํ ๊ฐฑ์ . ์์ผ๋ฉด ์กฐ์ฉํ ๋ฌด์(์ฐ๋ฆฌ์ ๋ฌด๊ดํ ๊ฒฐ์ ์ผ ์ ์์).- ํญ์
200 ok:true๋ฐํ(๋งค์นญ ์ ๋ผ๋). ์นํ ์ "๋ฐ์๋ค"๋ ์ ํธ๋ฅผ ๋นจ๋ฆฌ ์ค์ผ ํ ์ค๊ฐ ์ฌ์ ์ก์ ๋ฉ์ถฅ๋๋ค. ๋จ, ์ฒ๋ฆฌ ์ค ์์ธ๋ 500 ์ผ๋ก โ ํ ์ค๊ฐ ๋์ค์ ์ฌ์๋.
C-3. ์ํ ์ ์ด ๋ก์ง โ TossWebhookController.java:79-98
// L79-L98
private void applyStatus(ProjectEscrow e, String tossStatus) {
switch (tossStatus) {
case "CANCELED", "PARTIAL_CANCELED" -> {
if (e.getStatus() != ProjectEscrow.EscrowStatus.REFUNDED) {
e.setStatus(ProjectEscrow.EscrowStatus.REFUNDED);
e.setRefundedAt(LocalDateTime.now());
escrowRepository.save(e);
log.info("[TossWebhook] ์์คํฌ๋ก {} โ REFUNDED", e.getId());
}
}
case "DONE" -> {
if (e.getStatus() == ProjectEscrow.EscrowStatus.PENDING) {
// ๊ฐ์๊ณ์ข ์
๊ธ ์๋ฃ ๋ฑ โ ์์คํฌ๋ก ์ ์ด + ๋ง์ผ์คํค IN_PROGRESS + ํํธ๋ ์๋ฆผ ์ผ๊ด ์ฒ๋ฆฌ.
dashboardService.markDepositedFromExternal(e.getId());
log.info("[TossWebhook] ์์คํฌ๋ก {} โ DEPOSITED (webhook ๋ณด๊ฐ)", e.getId());
}
}
default -> { /* WAITING_FOR_DEPOSIT ๋ฑ์ ๋ฌด์ */ }
}
}
- ์ทจ์/๋ถ๋ถ์ทจ์ โ REFUNDED, ๋จ
if (status != REFUNDED)๊ฐ๋๋ก ์ด๋ฏธ ํ๋ถ๋์ผ๋ฉด ๋ ์ ํจ โ ์นํ ์ด ์ค๋ณต ๋์ฐฉํด๋ ์์ (๋ฉฑ๋ฑ).refundedAtํ์์คํฌํ ๊ธฐ๋ก. - DONE(๊ฐ์๊ณ์ข ์
๊ธ ์๋ฃ) โ DEPOSITED, ๋จ
PENDING์ํ์ผ ๋๋ง. ์ ๊ธ์ confirm ์์ ์ด ์๋๋ผ ๋์ค์ ์ผ์ด๋ ์ ์์ด ์นํ ์ผ๋ก ๋ณด๊ฐํ๋ ๊ฒ. ๋จ์ ์ํ๋ณ๊ฒฝ์ด ์๋๋ผmarkDepositedFromExternal()๋ก ๋ง์ผ์คํค ์งํ + ํํธ๋ ์๋ฆผ๊น์ง ์ผ๊ด ์ฒ๋ฆฌ. defaultโ ๊ทธ ์ธ ์ํ(WAITING_FOR_DEPOSIT๋ฑ)๋ ๋ฌด์. ๋ชจ๋ ๋ถ๊ธฐ์ ๊ฐ๋(if)๊ฐ ์์ด ๊ฐ์ ์นํ ์ ์ฌ๋ฌ ๋ฒ ๋ฐ์๋ ๊ฒฐ๊ณผ๊ฐ ๊ฐ์(์นํ ๋ฉฑ๋ฑ์ ๋ชจ๋ฒ).
D. PaymentMethodService โ ์นด๋ ๋ฑ๋ก(๋ง์คํน) ํต์ฌ
์๋ณธ: domain/payment/service/PaymentMethodService.java
D-1. ์นด๋๋ฒํธ ๊ฒ์ฆ + CVC ํ๊ธฐ โ PaymentMethodService.java:38-50
// L38-L50
String rawNumber = req.getNumber() == null ? "" : req.getNumber().replaceAll("[\\s-]", "");
if (!rawNumber.matches("\\d{13,19}")) {
throw new IllegalArgumentException("์นด๋ ๋ฒํธ ํ์์ด ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.");
}
if (!luhnCheck(rawNumber)) {
// Mock ํ๊ฒฝ: Luhn ์คํจํด๋ ํต๊ณผ์ํค๋ ๋ก๊ทธ๋ง ๋จ๊น
// throw new IllegalArgumentException("์ ํจํ์ง ์์ ์นด๋ ๋ฒํธ์
๋๋ค.");
}
String cvc = req.getCvc() == null ? "" : req.getCvc().trim();
if (!cvc.matches("\\d{3,4}")) {
throw new IllegalArgumentException("CVC ํ์์ด ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.");
}
- ๊ณต๋ฐฑยทํ์ดํ ์ ๊ฑฐ ํ 13~19์๋ฆฌ ์ซ์์ธ์ง ๊ฒ์ฌ.
luhnCheck(์๋)๋ Mock ํ๊ฒฝ์์ ํต๊ณผ์ํค๋ ์ฝ๋๋ ๋จ๊ฒจ๋ (์ฃผ์ ์ฒ๋ฆฌ). โ ํ ์คํธ ์นด๋๋ ๋ฑ๋ก๋๊ฒ ํ ์๋. - CVC ๋ ํ์(3~4์๋ฆฌ)๋ง ๊ฒ์ฌํ๊ณ ๋ณ์์์ ๊ทธ๋๋ก ๋ฒ๋ ค์ง๋๋ค. ์ด๋์๋ ์ ์ฅ ์ ํจ โ PCI ์์น ์ค์.
D-2. ๋ง์คํน ์ ์ฅ โ PaymentMethodService.java:77-92
// L77-L92
PaymentMethod pm = PaymentMethod.builder()
.user(user)
.brand(detectBrand(rawNumber))
.last4(rawNumber.substring(rawNumber.length() - 4))
.holderName(holder)
.expMonth(m)
.expYear(y)
.isDefault(makeDefault)
.nickname(req.getNickname() == null ? null : req.getNickname().trim())
.build();
PaymentMethod saved = paymentMethodRepository.save(pm);
if (makeDefault) {
paymentMethodRepository.clearOtherDefaults(user, saved.getId());
}
- DB ์ ๋ค์ด๊ฐ๋ ๊ฑด
brand+last4(๋ค 4์๋ฆฌ) + ์์ ์ + ๋ง๋ฃ๋ฟ. ์ ์ฒด ์นด๋๋ฒํธrawNumber๋ ๋ฉ์๋๊ฐ ๋๋๋ฉด ์ฌ๋ผ์ง. - ๊ธฐ๋ณธ ์นด๋ ๋จ์ผ์ฑ ๋ณด์ฅ: ์ ์นด๋๋ฅผ default ๋ก ๋ง๋ค๋ฉด
clearOtherDefaults(user, savedId)๋ก ๋๋จธ์ง ์นด๋์ isDefault ๋ฅผ ์ผ๊ด false(๋ ํฌ์งํ ๋ฆฌ์@Modifying UPDATE). "๊ธฐ๋ณธ ์นด๋๋ ํญ์ 1๊ฐ"๋ผ๋ ๋ถ๋ณ์์ ์ฝ๋๋ก ๊ฐ์ . detectBrandโ ์นด๋๋ฒํธ ์์๋ฆฌ๋ก ๋ธ๋๋ ํ์ (4โVISA, 51-55/2221-2720โMASTERCARD, 34/37โAMEX, 35โJCB, 6โDISCOVER, ๊ทธ ์ธ LOCAL). ํ์ค BIN ๊ท์น.
D-3. ๋ณธ์ธ ์์ ๊ฐ์ โ PaymentMethodService.java:126-133
// L126-L133
@Transactional(readOnly = true)
public PaymentMethod requireOwned(Long userId, Long pmId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค."));
return paymentMethodRepository.findByIdAndUser(pmId, user)
.orElseThrow(() -> new IllegalArgumentException("๊ฒฐ์ ์๋จ์ ์ฐพ์ ์ ์์ต๋๋ค."));
}
findByIdAndUser(pmId, user)โ id ๋ง์ผ๋ก ์ฐพ์ง ์๊ณ "id AND ์์ ์"๋ก ์กฐํ. ๋จ์ ์นด๋ id ๋ฅผ ๋ฃ์ด๋ ๋ด ๊ฒ์ด ์๋๋ฉด ๋ชป ์ฐพ์ โ IDOR(๋ค๋ฅธ ์ฌ์ฉ์ ๋ฆฌ์์ค ๋ฌด๋จ ์ ๊ทผ) ๋ฐฉ์ง. ์ญ์ ยท๊ธฐ๋ณธ์ค์ ๋ฑ ๋ชจ๋ ๋ณ๊ฒฝ ๊ฒฝ๋ก๊ฐ ์ด ํจํด์ ์๋๋ค.
E. ์ปจํธ๋กค๋ฌยท์ํฐํฐ ์์ฝ (REST ํ๋ฉด)
PaymentMethodController(/api/payment-methods):GET(๋ชฉ๋ก)ยทPOST(๋ฑ๋ก)ยทPATCH /{id}/default(๊ธฐ๋ณธ์ค์ )ยทDELETE /{id}. ๋งค ๋ฉ์๋ ์ฒซ ์ค์ดAuthContext.currentUserId()โ null ์ด๋ฉด 401. ์ ์์ ํญ์ JWT ์์๋ง ์ทจ๋(์์ฒญ body ์ userId ๊ฐ์ ๊ฑด ์ ๋ขฐ ์ ํจ โ ์ต๊ทผ ์ปค๋ฐ์ IDOR ์ฐจ๋จ ํจํด๊ณผ ๋์ผ).LedgerController(/api/ledger/me): ๋ด ์์คํฌ๋ก ์ด๋ฒคํธ๋ฅผ ๊ฐ๊ณ๋ถ๋ก. payer ์DEPOSITEDโ์ง์ถยทREFUNDEDโํ๋ถ์์, payee ์RELEASEDโ์ ์ฐ์์. ์นด๋/๊ณ์ข ๋ฏธ๋ฑ๋ก์ด๋ฉดlinked:false+ ๋น ๋ชฉ๋ก(ํ๋ก ํธ๊ฐ ์๋ด ๋์ฐ๊ฒ). ์๋ต์LinkedHashMap์ผ๋ก ๋ง๋ค์ด ํค ์์ ๋ณด์กด.PaymentMethod(entity):@Table(name="PAYMENT_METHODS"),last4 length=4,brand๋@Enumerated(STRING). ํด๋์ค ์ฃผ์์ "PCI ์ ์ฑ ์ ์นด๋ ์ ์ฒด๋ฒํธยทCVC ์ ๋ ์ ์ฅ ์ ํจ" ๋ช ์.@CreatedDateยท@UpdateTimestamp๋ก ๊ฐ์ฌ ์๊ฐ ์๋ ๊ธฐ๋ก.
F. (๋งํฌ) ๊ฒฐ์ confirm ์ ์ง์ง ์์์ โ SubscriptionController
์์น:
domain/strategy/controller/SubscriptionController.java(์ด ๋ฌธ์์ payment ๋๋ฉ์ธ์ด ์๋ โ ๋งํฌ๋ก๋ง ์ฐ๊ฒฐ).TossPaymentsService๋ฅผ ์ฃผ์ ๋ฐ์ ์ฌ์ฉ.
ํต์ฌ์ M8 ๋ฉฑ๋ฑ์ฑ 2์ค ๋ฐฉ์ด โ SubscriptionController.java:79-104:
// L79-L84 โ ์ ์กฐํ ๋ฉฑ๋ฑ (๊ฐ์ฅ ํํ ๊ฒฝ๋ก: ์๋ก๊ณ ์นจ/๋๋ธํด๋ฆญ)
Subscription already = subscriptionService.findByPaymentKey(paymentKey);
if (already != null) {
log.info("[Subscription] ๋ฉฑ๋ฑ confirm โ ์ด๋ฏธ ์ฒ๋ฆฌ๋ ๊ฒฐ์ ...");
return ResponseEntity.ok(idempotentBody(already, true));
}
// L98-L104 โก DB ์ ๋ํฌ ์ถฉ๋ ๋ฉฑ๋ฑ (๋์ confirm ๊ฒฝํฉ)
} catch (org.springframework.dao.DataIntegrityViolationException dup) {
Subscription existing = subscriptionService.findByPaymentKey(paymentKey);
if (existing != null) {
return ResponseEntity.ok(idempotentBody(existing, true));
}
...
}
- ๋ฐฉ์ด 1 (์ ์กฐํ): confirm ์ ์
findByPaymentKey(=findByTossPaymentKey)๋ก ์ด๋ฏธ ์ฒ๋ฆฌ๋ ๊ฒฐ์ ์ธ์ง ํ์ธ โ ์์ผ๋ฉด ํ ์ค ์ฌํธ์ถ ์์ด ๊ธฐ์กด ๊ตฌ๋ ๋ฐํ. ์๋ก๊ณ ์นจยท์ฌ์๋ยท๋๋ธํด๋ฆญ์ 99%๋ฅผ ์ฌ๊ธฐ์ ๋ง์. - ๋ฐฉ์ด 2 (DB ์ ๋ํฌ): ๋ ์์ฒญ์ด ๋์์ ์ ์กฐํ๋ฅผ ํต๊ณผํด๋ฒ๋ฆฐ ๊ฒฝํฉ(race)์์๋,
subscription.toss_payment_key์ ๊ฑธ๋ฆฐ DB ์ ๋ํฌ ์ ์ฝ(uq_subscription_toss_payment_key, ๋ง์ด๊ทธ๋ ์ด์ V17__subscription_payment_idempotency.sql)์ด ๋ ๋ฒ์งธ INSERT ๋ฅผ ๋ง์DataIntegrityViolationException๋ฐ์ โ ๋จผ์ ์ฑ๊ณตํ ๊ตฌ๋ ์ ์ฌ์ฌ์ฉ. ์ ํ๋ฆฌ์ผ์ด์ ๊ฒ์ฌ + DB ์ ์ฝ์ 2์ค ๊ทธ๋ฌผ. - ์ถ๊ฐ๋ก
VALID_PLANS(9900/19900) ๋ก ๊ธ์ก ์๋ณ์กฐ๋ฅผ ์ปจํธ๋กค๋ฌ์์ ํ ๋ฒ ๋ ๊ฒ์ฆ(SubscriptionController.java:29-32, 71-74).
โ ๏ธ ํจ์ ยท๋ณด์ ์ฃผ์
APP_CRYPTO_KEY๊ธฐ๋ณธ๊ฐ ์์ โ ๋ฏธ์ค์ ์ ๋ถํ ์คํจ.CryptoService์์ฑ์๊ฐ@Value("${app.crypto.key}")(์ฝ๋ก ์์)์ด๋ผ ํค๊ฐ ์์ผ๋ฉด ์ฑ์ด ์์ ๋ชป ํจ. ๋ก์ปฌ์์๋ ๋ฐ๋์ Base64 32๋ฐ์ดํธ ํค๋ฅผ ์ค์ . ์ด๊ฑด ๋ฒ๊ทธ๊ฐ ์๋๋ผ ์๋๋ fail-fast ์์ ์ฅ์น.app.crypto.key์ค๋ณต์ ์ = ๋ณตํธํ ๋์ฐธ์ฌ. ๋ฉ๋ชจ๋ฆฌ "Env priority changes need duplicate-key audit" ํต์ฌ: ๊ฐ์ ํค๊ฐ.env/application*.properties์ ๋ค๋ฅธ ๊ฐ์ผ๋ก ์ค๋ณต๋๋ฉด, A ๊ฐ์ผ๋ก ์ํธํํ KIS ์ํฌ๋ฆฟ์ B ๊ฐ์ผ๋ก ๋ณตํธํ ์๋ โAEADBadTagException("key mismatch") โ ์์ ๋ชป ์ฝ์. ์ฐ์ ์์ ๋ณ๊ฒฝ ์ ๋ฐ๋์ ์ค๋ณต ๊ฐ์ฌ. ์์ ๋ก๊ทธ์sha256[0..8]์ง๋ฌธ์ผ๋ก ์๋ฒ ๊ฐ ํค ์ผ์น ํ์ธ ๊ฐ๋ฅ.๊ฒฐ์ ๋ฉฑ๋ฑ์ฑ(M8)์ 2์ค์ด์ด์ผ ํ๋ค. ์ ํ๋ฆฌ์ผ์ด์ ์ ์กฐํ(
findByPaymentKey)๋ง์ผ๋ก๋ ๋์ ์์ฒญ ๊ฒฝํฉ์ ๋ชป ๋ง์. DB ์ ๋ํฌ ํค(uq_subscription_toss_payment_key)๊ฐ ์ต์ข ๋ฐฉ์ด์ . ๋ ์ค ํ๋๋ง ์์ผ๋ฉด ๋๋ธ ๊ฒฐ์ ์ํ.ํด๋ผ์ด์ธํธ ํค โ ์ํฌ๋ฆฟ ํค ์ ์ผ์น. ํ๋ก ํธ์
test_ck_...์ ์๋ฒ์test_sk_...๋ ๊ฐ์ ํ ์ค ๊ณ์ ์ด์ด์ผ ํจ. ๋ค๋ฅธ ๊ณ์ ํค๋ฅผ ์์ผ๋ฉด confirm ์ด ํ ์ค์์ ๊ฑฐ๋ถ๋จ(CLAUDE.md ์ฃผ์).์นํ ์๋ช ๊ฒ์ฆ์ ์ด์ ํ์.
tosspayments.webhook-secret์ด ๋น๋ฉดTossWebhookController๊ฐ ๊ฒ์ฆ์ skip ํจ โ ์ด์์์ ๋น์๋๋ฉด ๋๊ตฌ๋ ์์กฐ ์นํ ์ผ๋ก ์์คํฌ๋ก๋ฅผ REFUNDED ๋ก ์กฐ์ ๊ฐ๋ฅ. ๋ฐ๋์ ์ด์์์ ์ํฌ๋ฆฟ ์ฃผ์ . ๋ํ ์๋ช ๊ฒ์ฆ์ ํ์ฑ ์ raw body๋ก ํด์ผ ์ผ์น(๊ฐ์ฒด๋ก ๋ฐ์ผ๋ฉด ๊นจ์ง).๊ธ์ก ๊ฒ์ฆ์ ์ต์ข ์ฑ ์์ ์๋ฒ. ํ๋ก ํธ๊ฐ ๋ณด๋ธ
amount๋ฅผ ๊ทธ๋๋ก ๋ฏฟ์ง ๋ง๊ณVALID_PLANS๋ก ํ์ดํธ๋ฆฌ์คํธ ๊ฒ์ฆ ํ ํ ์ค์ ์ ๋ฌ. ํ ์ค๋ ๋ฐ๊ธ ๊ธ์ก๊ณผ ๋์กฐํ์ง๋ง, ์ฐ๋ฆฌ ์ธก 1์ฐจ ๋ฐฉ์ด๊ฐ ์ฐ์ .์ํฌ๋ฆฟ ํคยท๋ณตํธํ ํ๋ฌธ์ ๋ก๊ทธ์ ์ฐ์ง ๋ง ๊ฒ.
CryptoService๊ฐ ํค๋ฅผ ์ง๋ฌธ(ํด์ ์ 4๋ฐ์ดํธ)์ผ๋ก๋ง ๋ก๊น ํ๋ ์ด์ . confirm ๋ก๊ทธ๋status/method๋ง ๋จ๊ธฐ๊ณ ์นด๋์ ๋ณด๋ ์ ๋จ๊น.CVCยท์ ์ฒด ์นด๋๋ฒํธ ์ ์ฅ ๊ธ์ง(PCI).
PaymentMethodService๋ ํ์๋ง ๊ฒ์ฌํ๊ณlast4๋ง ๋ณด๊ด. ์ ํ๋๋ฅผ ์ถ๊ฐํ ๋ ์ค์๋ก ์ ์ฒด๋ฒํธ๋ฅผ ์ ์ฅํ์ง ์๋๋ก ์ฃผ์.Jackson3(Boot4) JsonNode ํจ์ .
TossPaymentsService๊ฐ ์๋ต์String์ผ๋ก ๋ฐ์ ์ฐ๋ฆฌObjectMapper๋ก ํ์ฑํ๋ ๊ฑด ๋ฉ๋ชจ๋ฆฌ "Spring Boot 4 Jackson JsonNode ๋ฒ๊ทธ" ์ฐํ.JsonNode.class๋ก ์ง์ ๋ฐ์ผ๋ฉด ๊นจ์ง ์ ์์.
๐ ๊ณ ๋ํ ์์ด๋์ด
- ๊ฒฐ์ ์์ธ ์ธ๋ถํ: ์ง๊ธ์ ๋ชจ๋
RuntimeException.TossDeclinedException(ํ ์ค ๊ฑฐ์ ยท์ฌ์๋ ๋ฌด์๋ฏธ) vsTossUnavailableException(๋คํธ์ํฌยท์ผ์์ฅ์ ยท์ฌ์๋ ๊ฐ์น)๋ก ๋๋ confirm ์ Resilience4j Retry/CircuitBreaker ์ ์ฉ(์ ๋ต ๋๋ฉ์ธAnalyticsClientํจํด ์ฌ์ฌ์ฉ). - ์นํ
๋ฉฑ๋ฑ ํ
์ด๋ธ: ํ์ฌ๋ ์ํ ๊ฐ๋(if)๋ก ๋ฉฑ๋ฑ์ ๋ณด์ฅํ์ง๋ง, ํ ์ค
eventId๋ฅผ ์ ๋ํฌ๋ก ๊ฐ์งwebhook_eventํ ์ด๋ธ์ ๋จผ์ INSERT ํด "์ด๋ฏธ ์ฒ๋ฆฌํ ์ด๋ฒคํธ๋ฉด skip"ํ๋ฉด ๋ ๊ฒฌ๊ณ . ๊ฒฐ์ confirm ์ M8 ๊ณผ ๊ฐ์ ์ฒ ํ. - ํค ํ์ (rotation): AES ํค ๊ต์ฒด ์ ๊ธฐ์กด ์ํธ๋ฌธ์ ๋ชป ์ฝ๋ ๋ฌธ์ โ ์ํธ๋ฌธ์ ํค ๋ฒ์ prefix(
v2:)๋ฅผ ๋ถ์ด๊ณCryptoService๊ฐ ๋ฒ์ ๋ณ ํค๋ฅผ ๋ค๊ณ ๋ณตํธํ โ ๋ฌด์ค๋จ ํค ํ์ . - ํ์ด๋ฐ ์์ ๋น๊ต:
verifyHmac์equalsIgnoreCase๋ ์ด๋ก ์ ํ์ด๋ฐ ๊ณต๊ฒฉ ํ๋ฉด.MessageDigest.isEqual๊ฐ์ ์์์๊ฐ ๋น๊ต๋ก ๊ต์ฒด. - ์์ฅ ์ฑ๋ฅ:
LedgerController๊ฐ payer/payee ์์คํฌ๋ก๋ฅผ ์ ๋ถ ๋ก๋ ํ ๋ฉ๋ชจ๋ฆฌ ์ ๋ ฌ. ๊ฑฐ๋๊ฐ ๋ง์์ง๋ฉด ํ์ด์ง๋ค์ด์ + DB ์ ๋ ฌ/ํํฐ๋ก ์ด์ . - ๊ฒฐ์ ๋ฉฑ๋ฑ ์๋ต ํ์คํ: confirm ์ฑ๊ณต/๋ฉฑ๋ฑ ์๋ต์ ํค(
tier/status/expiresAt/idempotent)๋ฅผ DTO ํด๋์ค๋ก ํต์ผํด ํ๋ก ํธ ๊ณ์ฝ์ ๋ช ํํ. - CryptoService ํตํฉ:
CryptoService์AesGcmCryptoService๊ฐ ๋ ๋ค AES-GCM ์ธ๋ฐ ํค ํ์ยท๋ฐํํ์ด ๋ฌ๋ผ ํผ๋ ์ํ. ํ๋๋ก ํตํฉํ๊ฑฐ๋ ์ญํ ๊ฒฝ๊ณ๋ฅผ ๋ฌธ์/๋ค์ด๋ฐ์ผ๋ก ๋ช ํํ.
๐ ์ฉ์ด ์ฌ์ (์ด ๋๋ฉ์ธ ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
| paymentKey | ํ ์ค๊ฐ ๊ฒฐ์ ๊ฑด๋ง๋ค ๋ฐ๊ธํ๋ ๊ณ ์ ์๋ณ์. confirmยท์นํ ยท๋ฉฑ๋ฑ ํค์ ๊ธฐ์ค |
| orderId | ์ฐ๋ฆฌ(๊ฐ๋งน์ )๊ฐ ๋ง๋๋ ์ฃผ๋ฌธ ๋ฒํธ. ํ ์ค confirm ์ ํจ๊ป ์ ๋ฌ |
| confirm | ์๋ฒ๊ฐ ํ ์ค /v1/payments/confirm ํธ์ถ โ ์ค์ ์ฒญ๊ตฌ ํ์ (๋๊ธฐ) |
| ์นํ (Webhook) | ํ ์ค๊ฐ ์ฐ๋ฆฌ ์๋ฒ๋ก ๋ณด๋ด๋ ์ํ๋ณ๊ฒฝ ํธ์(๋น๋๊ธฐ). X-Toss-Signature ๋ก ๊ฒ์ฆ |
| Basic Auth | base64(secretKey + ":") ๋ฅผ Authorization: Basic ํค๋์ โ ํ ์ค ์ธ์ฆ ๋ฐฉ์ |
| AES-256-GCM | 32๋ฐ์ดํธ ํค ๋์นญ์ํธ + ์ธ์ฆ ํ๊ทธ(์๋ณ์กฐ ํ์ง) |
| IV / nonce | ๋งค ์ํธํ๋ง๋ค ์๋ก ๋ง๋๋ 12๋ฐ์ดํธ ๋๋ค๊ฐ. ๊ฐ์ ํค+๊ฐ์ IV ์ฌ์ฌ์ฉ ๊ธ์ง |
| GCM tag | 16๋ฐ์ดํธ ์ธ์ฆ ํ๊ทธ. ๋ณตํธํ ์ ์ ๋ง์ผ๋ฉด ๋ณ์กฐ/ํค๋ถ์ผ์น๋ก ๊ฑฐ๋ถ |
| HMAC-SHA256 | ๋น๋ฐํค ๊ธฐ๋ฐ ๋ฉ์์ง ์ธ์ฆ. ์นํ ์๋ช (raw body ํด์) ๊ฒ์ฆ์ ์ฌ์ฉ |
| ๋ฉฑ๋ฑ์ฑ(Idempotency) | ๊ฐ์ ์์ฒญ์ ์ฌ๋ฌ ๋ฒ ํด๋ ๊ฒฐ๊ณผ๊ฐ ํ ๋ฒ๊ณผ ๋์ผ. M8 = paymentKey ์ ๋ํฌ |
| ์์ฅ(Ledger) | ๊ฑฐ๋๋ด์ญ ์ฅ๋ถ. ์์คํฌ๋ก ์ด๋ฒคํธ๋ฅผ ์์ /์ง์ถ๋ก ๋ถ๋ฅํ ๋ชฉ๋ก |
| ์์คํฌ๋ก(Escrow) | ์ 3์ ๋ณด๊ด. ๊ฒฐ์ ๊ธ์ ์ค๊ฐ ๋ณด๊ดํ๋ค๊ฐ ์กฐ๊ฑด ์ถฉ์กฑ ์ ์ ์ฐ(payerโpayee) |
| PCI-DSS | ์นด๋์ ๋ณด ๋ณดํธ ๊ท์ . ์ฐ๋ฆฌ๋ last4 ๋ง ์ ์ฅํด ์ ์ฉ ๋ฒ์๋ฅผ ์ต์ํ |
| last4 | ์นด๋๋ฒํธ ๋ค 4์๋ฆฌ. ์๋ณ์ฉ์ผ๋ก๋ง ์ ์ฅ(์ ์ฒด๋ฒํธยทCVC ๋ ์ ์ฅ ์ ํจ) |
| Luhn ๊ฒ์ฌ | ์นด๋๋ฒํธ ์ฒดํฌ์ฌ ๊ฒ์ฆ. Mock ํ๊ฒฝ์์ ํต๊ณผ์ํด |
| IDOR | ๋จ์ ๋ฆฌ์์ค id ๋ก ๋ฌด๋จ ์ ๊ทผ. findByIdAndUser ๋ก ์ฐจ๋จ |
| fail-fast | ์๋ชป๋ ์ค์ ์ด๋ฉด ์์ ์์ ์ ์ฆ์ ์ฃฝ๊ธฐ(ํค ๊ฒ์ฆ). ๋ฆ๊ฒ ํฐ์ง๋ ๊ฒ๋ณด๋ค ์์ |
domain/strategy โ ๋ฐฑํ
์คํธยท์ ๋ตยท์๊ทธ๋ยท๊ตฌ๋
(์์ ๋ผ์ธ๋ณ ํด์ค)
์๋ณธ:
backend/src/main/java/com/DevBridge/devbridge/domain/strategy/
์ด ๋ฌธ์๋ ํ์ค ํ์(01_backtest/vbt_engine.md)์ ๋ฐ๋ฆ ๋๋ค.
Spring ๊ธฐ์ด(@Serviceยท@RestControllerยทDIยท@Transactional)๋08_backend/00_spring_primer.md๋ฅผ ์ ์ ๋ก ํฉ๋๋ค(์์ง ๋ฏธ์์ฑ์ด๋ผ๋ฉด, ๊ฐ ์ ์ "์ด๋ณด ํฌ์ธํธ"๊ฐ ๊ทธ ๋นํ์ ๋ฉ์๋๋ค).
๋ค๋ฃจ๋ ํ์ผ:service/BacktestServiceยทservice/AnalyticsClientยทservice/DailySignalGeneratorยทservice/SubscriptionServiceยทservice/MarketDataServiceยทcontroller/StrategyControllerยทcontroller/SubscriptionControllerยทcontroller/AnalyticsControllerยทentity/{Strategy,Subscription,DailySignal,OrderProposal}
๐ ์ด ํํธ ํ๋์
์ด ํํธ๋ ๋ฐฑ์๋(Spring)์ "์ ๋ต ์ด์ ๋ณธ๋ถ" ์ ๋๋ค. ์ฌ์ฉ์๊ฐ ๋ง๋ ํฌ์ ์ ๋ต์ ๋ณด๊ดํ๊ณ , ๋งค์ผ ์๋์ผ๋ก ๋ฐฑํ ์คํธ๋ฅผ ๋๋ ค "์ค๋ ๋ญ ์ฌ์ผ ํ๋(์๊ทธ๋)"๋ฅผ ๊ณ์ฐํ๊ณ , ๊ทธ ์๊ทธ๋์ ์ฃผ๋ฌธ ์ ์(OrderProposal) ์ผ๋ก ๋ฐ๊พธ๊ณ , ์ ๋ฃ ๊ธฐ๋ฅ์ ์ธ ์ ์๋์ง(๊ตฌ๋ )๋ฅผ ํ์ธํฉ๋๋ค.
๋น์ : ์ด ํํธ๋ ์์ฐ์ด์ฉ์ฌ์ ๋ฐฑ์คํผ์ค์ ๋๋ค.
- ์ ๋ต ๋ณด๊ด์ค(
Strategy์ํฐํฐ +StrategyController) โ ๊ณ ๊ฐ๋ณ ์ ๋ต ์นด๋๋ฅผ ์บ๋น๋์ ๋ณด๊ด.- ๋ฆฌ์์นํ(
BacktestService,AnalyticsClient) โ ๊ณผ๊ฑฐ ๋ฐ์ดํฐ๋ก "์ด ์ ๋ต ์ผ๋ง๋ ๋ฒ์๋"๋ฅผ ๊ณ์ฐ. ๋จ์ ๊ณ์ฐ์ ์ฌ๋ด(Java)์์, ๋ฌด๊ฑฐ์ด ์ ๋ฐ ๋ถ์์ ์ธ๋ถ ์ฐ๊ตฌ์(Python:8001)์ ์ํ.- ๋ฐ์ผ๋ฆฌ ๋ฐ์คํฌ(
DailySignalGenerator) โ ๋งค์ผ ๋ฐค 22:30 ๋ชจ๋ ์ ๋ต์ ์ฌ๊ณ์ฐํ๊ณ "๋ด์ผ ํ๋ ์ง์นจ"์ ๋ฉ์ผ๋ก ๋ฐ์ก + ์ฃผ๋ฌธ ์ ์์ ์์ฑ.- ํ๊ณยท์๊ธ์ ํ(
SubscriptionService) โ ๋๊ฐ ์ ๋ฃ ํ์์ธ์ง, ๊ฒฐ์ ๊ฐ ์ค๋ณต๋์ง ์์๋์ง ๊ด๋ฆฌ.
โญ ๊ฐ์ฅ ๋จผ์ ์ดํดํ ํต์ฌ ๊ตฌ์กฐ โ "๋ฐฑํ ์คํธ๊ฐ ๋ ๊ตฐ๋ฐ์ ๋๋ค"
์ด๋ณด๊ฐ ๊ฐ์ฅ ํท๊ฐ๋ฆฌ๋ ์ง์ ์ ๋๋ค. ์ด ์ฝ๋๋ฒ ์ด์ค์๋ ๋ฐฑํ ์คํธ ๊ฒฝ๋ก๊ฐ ๋ ์์ต๋๋ค.
| ๊ฒฝ๋ก | ๋๊ฐ | ์ด๋์ ๊ณ์ฐ | ๋ฌด์์ |
|---|---|---|---|
| ๊ฒฝ๋ก A (์ฌ๋ด Java ์์ง) | BacktestService |
๋ฐฑ์๋ ์(์์ Java for-loop) | ๋ฌดํ๋งค์๋ฒ(LOC)ยทVR ๋ฐธ๋ฅ๋ฆฌ๋ฐธ๋ฐ์ฑ. DB์ ์บ์๋ ์ผ๋ด์ ์ง์ ๋๋ฆผ. โ Strategy/StrategyController/DailySignalGenerator ๊ฐ ์ฌ์ฉ |
| ๊ฒฝ๋ก B (์ธ๋ถ Python ์ํ) | AnalyticsClient |
Analytics ์ฌ์ด๋์นด(:8001, vectorbt) | sma_crossยทrsiยทmacd ๋ฑ 6์ ๋ต + XGBoost + Trust/Regime. โ AnalyticsController ๊ฐ ํ๋ก ํธ์ ๋
ธ์ถ |
์ฆ BacktestService ๋ Python์ ํธ์ถํ์ง ์์ต๋๋ค โ ๋ฌดํ๋งค์/VR ์๊ณ ๋ฆฌ์ฆ์ Java๋ก ์ง์ ๊ตฌํํ์ต๋๋ค(ํ๋ก ํธ์ lib/backtest.js ์ 1:1 ๋์ผ). ๋ฐ๋ฉด AnalyticsClient ๋ ๋ฐฑ์๋โPython ๋ค๋ฆฌ๋ก, vectorbtยทXGBoost ๊ฐ์ ๋ฌด๊ฑฐ์ด ํํธ ๋ก์ง์ :8001 ์ main.py ์๋ํฌ์ธํธ์ ์ํํฉ๋๋ค.
| ํต์ฌ ํด๋์ค | ํ ์ค ์ญํ | ๋น์ |
|---|---|---|
StrategyController |
์ ๋ต CRUD + ์ฌ์ฉ์ ๊ฒฉ๋ฆฌ + ๋ฐฑํ ์คํธ ์๋ ํธ๋ฆฌ๊ฑฐ | ์ ๋ต ์นด๋ ์บ๋น๋ ์ ์์ฐฝ๊ตฌ |
BacktestService |
์ฌ๋ด ๋ฌดํ๋งค์/VR ๋ฐฑํ ์คํธ ์์ง(Java) โ tradesยทsummaryยทstateยทsignal ์ ์ฅ | ๋ฆฌ์์นํ์ ์์ฒด ๊ณ์ฐ๊ธฐ |
MarketDataService |
์ผ๋ด OHLC ์์ง(Stooq/Binance) + DB ์บ์ | ๊ฐ๊ฒฉ ๋ฐ์ดํฐ ์ฐฝ๊ณ ์ง๊ธฐ |
AnalyticsClient |
์ธ๋ถ Python:8001 ํธ์ถ(์ํท๋ธ๋ ์ด์ปค+์ฌ์๋) | ์ธ๋ถ ์ฐ๊ตฌ์ ์ํ ๋ด๋น์ |
AnalyticsController |
AnalyticsClient ๋ฅผ ํ๋ก ํธ์ ์๊ฒ ๋
ธ์ถ |
์ํ ๊ฒฐ๊ณผ ์ ๋ฌ ๋ฐ์คํฌ |
DailySignalGenerator |
๋งค ํ์ผ 22:30 ์ ์ฒด ์ฌ๊ณ์ฐ โ ๋ฉ์ผ + OrderProposal | ๋ฐ์ผ๋ฆฌ ๋ฐ์คํฌ ์ผ๊ฐ์กฐ |
SubscriptionService |
๊ตฌ๋ ๋ฑ๊ธ ์กฐํ/ํ์ฑํ/๋ง๋ฃ(๋ฉฑ๋ฑ ๋ณด์ฅ) | ํ๊ณยท์๊ธ์ ํ |
SubscriptionController |
Toss ๊ฒฐ์ confirm + ๊ธ์ก ์๋ณ์กฐ ์ฐจ๋จ | ๊ฒฐ์ ์ฐฝ๊ตฌ |
๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
1) ์ํท ๋ธ๋ ์ด์ปค(Circuit Breaker) = "์ ๊ธฐ ๋๊บผ๋น์ง"
- ์ธ๋ถ ์๋น์ค(Python:8001)๊ฐ ์ฃฝ์๋๋ฐ๋ ๊ณ์ ํธ์ถํ๋ฉด, ๋งค๋ฒ ํ์์์๊น์ง ๊ธฐ๋ค๋ฆฌ๋๋ผ ๋ฐฑ์๋ ์ ์ฒด๊ฐ ๋๋ ค์ง๋๋ค(์ฅ์ ์ ํ).
- ๋๊บผ๋น์ง์ฒ๋ผ "์ต๊ทผ ํธ์ถ์ 50%๊ฐ ์คํจํ๋ฉด ํ๋ก๋ฅผ OPEN(์ฐจ๋จ)" ํด์, ํ๋์(์ฌ๊ธฐ์ 30์ด) ์์ ํธ์ถ์ ์ ํ๊ณ ์ฆ์ ์คํจ์ํต๋๋ค. โ ๋น ๋ฅธ ์คํจ(fast-fail)๋ก ๋ฐฑ์๋๋ฅผ ๋ณดํธ.
- ์ํ 3๊ฐ์ง:
CLOSED(์ ์, ํต๊ณผ) โOPEN(์ฐจ๋จ, ์ฆ์ ์คํจ) โHALF_OPEN(์ํ์ผ์ ๋ช ๊ฑด๋ง ํต๊ณผ์์ผ ํ๋ณต๋๋ ํ์ธ). - ์ด ํ๋ก์ ํธ ์ค์ (
application.properties): ์ฌ๋ผ์ด๋ฉ ์๋์ฐ 10๊ฑด ์ค ์คํจ์จ 50% โ OPEN 30์ด โ HALF_OPEN์์ 3๊ฑด ์ํ.
2) ์ฌ์๋(Retry) = "ํ ๋ฒ ๋ ๋๋ฌ๋ณด๊ธฐ"
- ๋คํธ์ํฌ๋ ๊ฐ๋ ์ผ์์ ์ผ๋ก ๋๊น๋๋ค(ํจํท ์ ์ค ๋ฑ). ๊ทธ๋ด ๋ ํ ๋ฒ ๋ ๋ณด๋ด๋ฉด ์ฑ๊ณตํ๋ ๊ฒฝ์ฐ๊ฐ ๋ง์ต๋๋ค.
- ์ด ํ๋ก์ ํธ: ์ต๋ 3ํ, 2์ด ๊ฐ๊ฒฉ. ๋จ, 4xx(์๋ชป๋ ์์ฒญ)๋ ์ฌ์๋ ์ ํจ โ ํ๋ผ๋ฏธํฐ๊ฐ ํ๋ฆฐ ์์ฒญ์ 100๋ฒ ๋ณด๋ด๋ ๋๊ฐ์ด ํ๋ฆฌ๋๊น(๋ญ๋น). 5xxยท๋คํธ์ํฌ ์ค๋ฅ๋ง ์ฌ์๋.
3) ์ฌ์ด๋์นด(Sidecar) ํธ์ถ = "๋ณธ์ฒด ์์ ๋ถ์ ์ ๋ฌธ ๋ณด์กฐ ํ๋ก์ธ์ค"
- ๋ฌด๊ฑฐ์ด ์ํ(vectorbtยทXGBoost)์ Java๋ก ๋ค์ ์ง๋ฉด ๋นํจ์จ์ ์ด๋ผ, Python ์ ์ฉ ํ๋ก์ธ์ค(:8001) ๋ฅผ ์์ ๋์ HTTP๋ก ์ผ์ ์ํต๋๋ค.
- ๋ฐฑ์๋๋ ๋งค ์์ฒญ์
X-Internal-Tokenํค๋๋ฅผ ๋ถ์ฌ "๋๋ ์ ์ ๋ฐฑ์๋๋ค"๋ฅผ ์ฆ๋ช ํฉ๋๋ค(์ธ๋ถ์ธ์ด :8001 ์ง์ ํธ์ถ ์ฐจ๋จ). โ ์์ง ์ชฝ ํด์ค์06_api/main.md์require_internal_token์ฐธ๊ณ .
4) ๊ตฌ๋ ํ๋ โ DB๋ ๋จ์, ํ์๋ ๊ธ์ก์ผ๋ก
- ์ด ํ๋ก์ ํธ์ ๋ฏธ๋ฌํ ์ค๊ณ: DB์
Subscription.Tierenum ์FREE/PRO๋๋ฟ์ ๋๋ค. - ์ฌ์ฉ์์๊ฒ ๋ณด์ด๋ STANDARD(9,900์) / PREMIUM(19,900์) ๊ตฌ๋ถ์ DB์ ๋ฐ๋ก ์ปฌ๋ผ์ด ์๊ณ , ๊ฒฐ์ ๊ธ์ก(
amountKrw)์ผ๋ก ๋ฐํ์์ ํ์ํฉ๋๋ค(deriveTierDisplay: 19,900 ์ด์์ด๋ฉด PREMIUM, ์๋๋ฉด STANDARD). User.UserType์๋FREE/STANDARD/PREMIUM์ด ๋ฐ๋ก ์์ด, ๊ฒฐ์ ์ ๊ธ์ก์ ๋ง์ถฐ ๊ฐฑ์ ํฉ๋๋ค. (CLAUDE.md์ "EXPERT(์ค๋น์ค)"๋ ์์ง ์ฝ๋์ ์์.)
5) ๋ฉฑ๋ฑ์ฑ(Idempotency) = "์ฌ๋ฌ ๋ฒ ๋๋ฌ๋ ๊ฒฐ๊ณผ๋ ํ ๋ฒ"
- ๊ฒฐ์ ์ฑ๊ณต ํ ์ฌ์ฉ์๊ฐ ์๋ก๊ณ ์นจํ๊ฑฐ๋ ๋๋ธํด๋ฆญํ๋ฉด confirm ์์ฒญ์ด 2๋ฒ ๊ฐ ์ ์์ต๋๋ค. ๊ทธ๋๋ก ๋๋ฉด ๊ตฌ๋ ์ด 2๊ฐ ์๊ธฐ๊ณ ์ด์ค ๊ณผ๊ธ์ฒ๋ผ ๋ณด์ ๋๋ค.
- "๊ฐ์ ๊ฒฐ์ ํค๋ฉด ๋ ๋ฒ์งธ๋ถํฐ๋ ์๋ก ๋ง๋ค์ง ์๊ณ ๊ธฐ์กด ๊ฒ์ ๊ทธ๋๋ก ๋ฐํ" โ ์ด๊ฒ ๋ฉฑ๋ฑ์ฑ. ์ด ํ๋ก์ ํธ๋ 3์ค ๋ฐฉ์ด(์ ํ๋ฆฌ์ผ์ด์ ์ฌ์ ์ฒดํฌ + DB ์ ๋ํฌ ์ ์ฝ + ๊ฒฝํฉ ์์ธ ์ฒ๋ฆฌ)๋ก ๋ณด์ฅํฉ๋๋ค(์๋ ํจ์ ์ ).
6) @Transactional = "์ ๋ถ ์ฑ๊ณต ์๋๋ฉด ์ ๋ถ ์ทจ์(ํธ๋์ญ์
)"
- ๋ฉ์๋์ ๋ถ์ด๋ฉด ๊ทธ ์์ DB ์์ ๋ค์ด ํ๋์ ๋ฌถ์(์์์ ). ์ค๊ฐ์ ์์ธ๊ฐ ๋๋ฉด ์์ ํ ์ ์ฅ๋ ๋กค๋ฐฑ๋ฉ๋๋ค.
readOnly = true๋ "์ฝ๊ธฐ ์ ์ฉ"์ด๋ผ DB์ ์ฝ๊ฐ์ ์ต์ ํ ํํธ๋ฅผ ์ค๋๋ค(์ฐ๊ธฐ ์ ํจ ๋ณด์ฅ).
7) @Scheduled(cron=...) = "์ ํด์ง ์๊ฐ์ ์๋ ์คํ๋๋ ์๋"
cron = "0 30 22 * * MON-FRI"= ์ด ๋ถ ์ ์ผ ์ ์์ผ โ "์~๊ธ ๋งค์ผ 22์ 30๋ถ 0์ด".zone="Asia/Seoul"๋ก ํ๊ตญ์๊ฐ ๊ธฐ์ค.- ์๋ฒ๊ฐ ๋ ์๊ธฐ๋ง ํ๋ฉด, ์๋ฌด๋ ํธ์ถํ์ง ์์๋ ์คํ๋ง์ด ์์์ ์คํํฉ๋๋ค.
8) AuthContext.currentUserId() = "์ง๊ธ ์์ฒญํ ์ฌ๋์ด ๋๊ตฌ์ธ์ง"
- JWT ํํฐ๊ฐ ํ ํฐ์์ ์ฌ์ฉ์ ID๋ฅผ ๊บผ๋ด ์์ฒญ ์ค๋ ๋์ ๋ณด๊ดํด ๋ ๊ฒ. ์ปจํธ๋กค๋ฌ๋ ์ด๊ฑธ ์ฝ์ด "์ด ์ ๋ต์ด ์ ๋ง ์ด ์ฌ๋ ๊ฒ์ธ๊ฐ"๋ฅผ ๊ฒ์ฌํฉ๋๋ค(์์ ๊ถ ๊ฒฉ๋ฆฌ).
๐บ ์์ฒญ ํ๋ฆ๋
ํ๋ฆ โ : ์ฌ์ฉ์๊ฐ "๋ฐฑํ ์คํธ ์คํ" ๋ฒํผ (๊ฒฝ๋ก A โ ์ฌ๋ด Java ์์ง)
[ํ๋ก ํธ] POST /api/strategies/{id}/backtest (JWT ์ฟ ํค)
โ
โผ
StrategyController.runBacktest()
โ โ AuthContext.currentUserId() ๋ก ๋ก๊ทธ์ธ ํ์ธ
โ โก strategy.user.id == uid ์์ ๊ถ ๊ฒ์ฆ (๋จ์ ์ ๋ต ์ฐจ๋จ)
โผ
BacktestService.runFor(Strategy)
โ โข MarketDataService.getDaily(ticker, startDate)
โ โโ DB์ ์ผ๋ด ์์ผ๋ฉด ๊ทธ๋๋ก, ์ค๋๋์ผ๋ฉด(2์ผโ) Stooq/Binance์์ ๋ฐ์ ์ฑ์
โ โฃ method ๋ถ๊ธฐ:
โ INFINITE_BUY โ runInfiniteBuy() (LOC ๋ถํ ๋งค์ ์๋ฎฌ๋ ์ด์
)
โ VALUE_REBALANCINGโ runValueRebalancing() (V๊ฐ ๋ฐด๋ ๋งค๋งค)
โ โค ๊ฒฐ๊ณผ๋ฅผ DB์ 4์ข
์ ์ฅ:
โ StrategyTrade(source=BACKTEST) ์ฌ์์ฑ
โ StrategyState (๋ง์ง๋ง ๋ ์๊ณ ) upsert
โ StrategyBacktestSummary (CAGR/MDD/์น๋ฅ /TrustScore) upsert
โ DailySignal (์ค๋ BUY/HOLD/WATCH/PAUSE) upsert
โผ
[ํ๋ก ํธ] โ StrategyBacktestSummaryDto (์ฑ์ ํ ์นด๋)ํ๋ฆ โก: ํ๋ก ํธ๊ฐ "์ ๋ฐ ๋ถ์(vectorbt/XGBoost)" ์์ฒญ (๊ฒฝ๋ก B โ Python ์ํ)
[ํ๋ก ํธ] POST /api/analytics/backtest (JWT)
โ
โผ
AnalyticsController.backtest()
โผ
AnalyticsClient.backtest(ticker, strategy, extra)
โ call() โ [Retry(3ํ)] โ [CircuitBreaker]
โ โ
โ โผ HTTP POST + ํค๋ X-Internal-Token
โผ
[Analytics :8001] main.py POST /backtest
โ get_history โ run_backtest(vectorbt) โ compute_metrics(QuantStats)
โผ
JSON {stats, equity_curve, risk_metrics} โ JsonNode โ ํ๋ก ํธ ์ฐจํธ
โ ํ๋ก OPEN์ด๋ฉด: ์ฆ์ "Analytics ์ผ์ ์ฌ์ฉ๋ถ๊ฐ" ๋์ง๊ณ ๋(ํด๋ฐฑ)ํ๋ฆ โข: ๋งค์ผ ๋ฐค 22:30 ์๋ ๋ฐ์ผ๋ฆฌ ์ก
@Scheduled 22:30 KST DailySignalGenerator.runDaily()
โ
โโ 1) marketDataService.scheduledRefresh() ์์ธ ์ ์ ํ
โโ 2) ํ์ฑ ์ ๋ต ์ ์ฒด backtestService.runFor(s) โ DailySignal upsert
โโ 3) emailAlertService.dispatchPending(today) ๋ฏธ๋ฐ์ก ์๊ทธ๋ ๋ฉ์ผ
โโ 4) createProposalsFor(today) โ
BUY ์๊ทธ๋ โ OrderProposal
โ โโ BUY์ธ ์ ๋ต๋ง๋ค:
โ pickTradingAccount(๊ฑฐ๋๊ฐ๋ฅ ๊ณ์ , REAL ์ฐ์ )
โ ์ค๋ณต ์ฒดํฌ(๊ฐ์ sourceSignalId)
โ OrderProposal(status=PENDING, expiresAt=+24h) ์ ์ฅ
โ ๊ณ์ ์ด autoExecute=ON ์ด๋ฉด โ ProposalExecutionService.execute()
โโ 5) refreshAlphaWorkspaces() Alpha ์ํฌ์คํ์ด์ค ์ฌ์คํ
ํต์ฌ ์์ ์์น: 2~4๋จ๊ณ๊ฐ ์๋์ด์ง๋ง, OrderProposal์ ํญ์ PENDING(๋๊ธฐ) ์ผ๋ก ์์ํฉ๋๋ค. ์ฌ์ฉ์๊ฐ ๋ช ์์ ์ผ๋ก ์น์ธํ์ง ์์ผ๋ฉด ์ค์ฃผ๋ฌธ์ผ๋ก ๊ฐ์ง ์์ต๋๋ค(
autoExecute๊ณ์ ์ ์์ธ โ ๋จ REAL ์๋๋งค๋งค๋ ์ฌ์ ์ MOCK ์กธ์ ๊ฒ์ดํธ๋ฅผ ํต๊ณผํ ๊ณ์ ๋ง).
๐ ํต์ฌ ํด๋์ค ๋ผ์ธ๋ณ ์ฌํ
A. BacktestService โ ์ฌ๋ด ๋ฐฑํ
์คํธ ์์ง (Java)
์ด ํด๋์ค์ ์ ์ฒด๋ฅผ ๋จผ์ ๋ชป๋ฐ์ต๋๋ค. ํ์ผ ์๋จ ์ฃผ์:
// BacktestService.java:33-42
/**
* ๋ฌดํ๋งค์๋ฒ(LOC) + VR ๋ฐธ๋ฅ ๋ฆฌ๋ฐธ๋ฐ์ฑ ๋ฐฑํ
์คํธ ์์ง.
* Frontend lib/backtest.js์ ์๊ณ ๋ฆฌ์ฆ๊ณผ 1:1 ๋์ผ.
* ๊ฒฐ๊ณผ:
* - StrategyTrade rows (source=BACKTEST) ์ฌ์์ฑ
* - StrategyBacktestSummary 1๊ฑด upsert
* - ๋ง์ง๋ง ๋ ์ StrategyState 1๊ฑด upsert
* - ๋ง์ง๋ง ๋ ์ DailySignal 1๊ฑด upsert
*/
- ์ด๊ฑด Python์ ๋ถ๋ฅด์ง ์์ต๋๋ค. ๋ฌดํ๋งค์/VR ์๊ณ ๋ฆฌ์ฆ์ ์์ Java for-loop๋ก ์ง์ ๋๋ฆฝ๋๋ค. (vectorbt ๊ธฐ๋ฐ 6์ ๋ต์ ๋ณ๊ฐ์ ๊ฒฝ๋ก B.)
A-1. ์ง์
์ runFor โ BacktestService.java:66-124
// BacktestService.java:66-75
@Transactional
public StrategyBacktestSummary runFor(Strategy s) {
var ohlc = marketDataService.getDaily(s.getTicker(), s.getStartDate());
if (ohlc.size() < 30) {
log.warn("[Backtest] {} insufficient OHLC ({} rows)", s.getCode(), ohlc.size());
return null;
}
Result r = s.getMethod() == Strategy.Method.INFINITE_BUY
? runInfiniteBuy(s, ohlc)
: runValueRebalancing(s, ohlc);
- ๋ฌด์์ ํ๋: โ ์ผ๋ด์ ๊ฐ์ ธ์ค๊ณ โก 30๊ฐ ๋ฏธ๋ง์ด๋ฉด ํฌ๊ธฐ(
null) โข ์ ๋ต method์ ๋ฐ๋ผ ๋ ์์ง ์ค ํ๋๋ฅผ ํธ์ถ. - ์ 30๊ฐ ์ปท: 30๊ฑฐ๋์ผ๋ ์ ๋๋ ๋ฐ์ดํฐ๋ก๋ CAGRยทMDD๊ฐ ์๋ฏธ ์์ต๋๋ค(ํต๊ณ์ ๋
ธ์ด์ฆ). ์ปจํธ๋กค๋ฌ๋ ์ด
null์ ๋ฐ์ HTTP 422("insufficient market data") ๋ก ๋ณํํฉ๋๋ค(StrategyController:320). - ์ด๋ณด ํฌ์ธํธ:
?:๋ ์ผํญ์ฐ์ฐ์ โ "์กฐ๊ฑด ? ์ฐธ์ผ๋๊ฐ : ๊ฑฐ์ง์ผ๋๊ฐ".method == INFINITE_BUY๋ฉด ๋ฌดํ๋งค์, ์๋๋ฉด VR.
// BacktestService.java:77-94 (์์ฝ)
// 1) trades ์ฌ์์ฑ (๋ฐฑํ
์คํธ source๋ง)
tradeRepo.deleteByStrategyIdAndSource(s.getId(), StrategyTrade.Source.BACKTEST);
tradeRepo.saveAll(r.trades);
// 2) state upsert (๋ง์ง๋ง ๋ )
var last = r.equityCurve.get(r.equityCurve.size() - 1);
var stateOpt = stateRepo.findByStrategyIdAndAsOfDate(s.getId(), last.date);
StrategyState st = stateOpt.orElseGet(() -> StrategyState.builder()
.strategy(s).asOfDate(last.date).build());
... st.setCashUsd(...); st.setShares(...); ...
stateRepo.save(st);
- ์ฌ์์ฑ ํจํด: ๋ฐฑํ
์คํธ๋ฅผ ๋ค์ ๋๋ฆด ๋๋ง๋ค ๊ธฐ์กด BACKTEST trades๋ฅผ ์น ์ง์ฐ๊ณ ์๋ก ์ ์ฅ. ๋ฉฑ๋ฑํ๊ฒ ๋ง๋๋ ๋ฐฉ๋ฒ(์ฌ๋ฌ ๋ฒ ๋๋ ค๋ trades๊ฐ ๋์ ๋์ง ์์). โ ๏ธ
source=BACKTEST๋ง ์ง์๋๋ค โ ์ค์ ์ฒด๊ฒฐ(LIVE)ยท์๋(MANUAL) trades๋ ๊ฑด๋๋ฆฌ์ง ์์(์ค๊ฑฐ๋ ๊ธฐ๋ก ๋ณด์กด). - upsert ํจํด:
findBy...๋ก ๊ธฐ์กด ํ์ ์ฐพ๊ณ , ์์ผ๋ฉด(orElseGet) ์๋ก ๋น๋. ์์ผ๋ฉด ๊ทธ ํ์ ํ๋๋ง ๊ฐฑ์ . โ ์ ๋ตร๋ ์ง๋น state/summary/signal์ด ๋ฑ 1๊ฑด๋ง ์ ์ง(DailySignal์uq_signal_strategy_date์ ๋ํฌ์ ์ ํฉ).
// BacktestService.java:104
summary.setTrustScore(computeTrustScore(r.metrics));
- ์ฌ๊ธฐ
computeTrustScore๋ ์ฌ๋ด ๊ฐ์ด ์ ์์ ๋๋ค(์๋ A-4). ๊ฒฝ๋ก B์ Python Trust Score(/trust, Walk-Forward+Regime ๊ธฐ๋ฐ)์๋ ๋ค๋ฅธ ๊ฒ์์ ์ฃผ์.
// BacktestService.java:110-121
// 4) ์ค๋์ ์๊ทธ๋ upsert
if (r.signal != null) {
var sigOpt = signalRepo.findByStrategyIdAndAsOfDate(s.getId(), last.date);
DailySignal sig = sigOpt.orElseGet(() -> DailySignal.builder()
.strategy(s).asOfDate(last.date).build());
sig.setSignal(r.signal.signal);
... sig.setTitle/Summary/Action ...
// ์ ๊ท ์๊ทธ๋์ด๋ฉด deliveredAt์ null๋ก ๋๊ณ ์๋ฆผ ์ค์ผ์ค๋ฌ๊ฐ ๋ฐ์ก
signalRepo.save(sig);
}
- ์๊ทธ๋์ด ๋ฉ์ผ๋ก ๊ฐ๋ ์ฐ๊ฒฐ๊ณ ๋ฆฌ: ์ ์๊ทธ๋์
deliveredAt=null.DailySignalGenerator๊ฐ 22:30์dispatchPending์ผ๋ก null์ธ ๊ฒ๋ง ๊ณจ๋ผ ๋ฉ์ผ ๋ฐ์ก ํ ์๊ฐ์ ์ฑ์๋๋ค(์ค๋ณต ๋ฐ์ก ๋ฐฉ์ง).
A-2. ๋ฌดํ๋งค์ ์์ง runInfiniteBuy โ BacktestService.java:128-200
// BacktestService.java:128-136
private Result runInfiniteBuy(Strategy s, List<MarketOhlcDaily> ohlc) {
Params p = parseParams(s.getParamsJson());
boolean crypto = isCryptoTicker(s.getTicker()); // ํฌ๋ฆฝํ ๋ ๋ถ์ ์๋
double principalUsd = s.getPrincipalKrw() / USD_KRW;
double dailyBudget = principalUsd / p.splits;
double cash = principalUsd, shares = 0, totalCost = 0;
- ๋ฌด์์: ์๊ธ(KRW)์ USD๋ก ํ์ฐ(
USD_KRW=1300๊ณ ์ โ:48์ "์ค์๊ฐ ํ์จ API๋ก ๊ต์ฒด" TODO)ํ๊ณ ,splits(๊ธฐ๋ณธ 40)๋ก ๋๋ ํ๋ฃจ ์์ฐ์ ์ ํฉ๋๋ค. ๋ผ์ค์ด์ ๋ฌดํ๋งค์๋ฒ์ ํต์ฌ = "์๊ธ์ 40๋ฑ๋ถํด ๋งค์ผ ์กฐ๊ธ์ฉ". - ์ด๋ณด ํฌ์ธํธ:
parseParams๋Strategy.paramsJson(์:{"splits":40,"sellTargetPct":10,...})์ JacksonObjectMapper๋ก ์ฝ์ดParams๊ฐ์ฒด๋ก ๋ณํ(์๋ A-3).
// BacktestService.java:145-157 (๋งค์ผ: 1) ๋จผ์ ๋งค๋ ์ฒดํฌ)
if (shares > 0 && avg > 0) {
double sellPx = avg * (1 + p.sellTargetPct / 100.0);
if (high >= sellPx) { // ๊ทธ๋ ๊ณ ๊ฐ๊ฐ ๋ชฉํ๊ฐ๋ฅผ ์ฐ์์ผ๋ฉด ์ต์
double proceeds = shares * sellPx;
double pnl = proceeds - totalCost;
... trades.add(SELL_TARGET) ...
cash += proceeds; shares = 0; totalCost = 0; // ์ฌ์ดํด ๋ฆฌ์
}
}
- ํ๋จ(
avg) ๋๋น+sellTargetPct%(๊ธฐ๋ณธ 10%)์ ๋งค๋ ์ง์ ๊ฐ๋ฅผ ๊ฑธ๊ณ , ๊ทธ๋ ๊ณ ๊ฐ๊ฐ ๊ทธ ๊ฐ๊ฒฉ์ ๊ฑด๋๋ฆฌ๋ฉด ์ ๋ ์ต์ . ์ต์ ํ๋ฉด ๋ณด์ 0์ผ๋ก ๋ฆฌ์ โ ์ ์ฌ์ดํด ์์.
// BacktestService.java:169-190 (๋งค์ผ: 2) ๋งค์ โ LOC ๋ ์ข
๋ฅ)
} else if (cash >= dailyBudget * 0.05) {
double newAvg = shares > 0 ? totalCost / shares : close;
double upperLOC = newAvg * (1 + p.locUpperPct / 100.0);
if (close <= newAvg) { // ํ๋จ ์ดํ โ 1ํ์ฐจ ํ๋งค์
double qty = crypto ? dailyBudget / close : Math.floor(dailyBudget / close);
... kind = LOC_AVG ...
} else if (close <= upperLOC) { // ํ๋จ~์๋จ โ 0.5ํ์ฐจ
double qty = crypto ? (dailyBudget/2.0)/close : Math.floor((dailyBudget/2.0)/close);
... kind = LOC_UPPER ...
}
}
- LOC(Limit On Close, ์ข ๊ฐ ์ง์ ๊ฐ) ๋ ๋จ๊ณ: ์ข ๊ฐ๊ฐ ํ๋จ ์ดํ๋ฉด ํ๋ฃจ์น ํ๋งค์, ํ๋จ~์๋จ ์ฌ์ด๋ฉด ์ ๋ฐ ๋งค์, ์๋จ ์๋ฉด ๋งค์ ์ ํจ. "์์๋ก ๋ ์ฐ๋ค"๋ ๋ถํ ๋งค์ ์ฒ ํ.
- โ ๏ธ ํฌ๋ฆฝํ vs ์ฃผ์: ์ฃผ์์
Math.floor(์ ์ ์ฃผ์ ์)์ง๋ง, ๊ณ ๊ฐ ์ฝ์ธ์ ์ ์๋ก ๋ด๋ฆผํ๋ฉด 0์ฃผ๊ฐ ๋์ด๋ฒ๋ ค ํฌ๋ฆฝํ ๋ ๋ถ์ ์๋(dailyBudget/close)์ ๊ทธ๋๋ก ์๋๋ค.
A-3. ํ๋ผ๋ฏธํฐ ํ์ฑ parseParams โ BacktestService.java:377-396
// BacktestService.java:380-393 (๋ฐ์ท)
try {
JsonNode n = om.readTree(json);
if (n.has("splits")) p.splits = n.get("splits").asInt(40);
if (n.has("sellTargetPct")) p.sellTargetPct = n.get("sellTargetPct").asInt(10);
...
} catch (Exception e) {
log.warn("paramsJson parse failed: {}", e.getMessage());
}
return p;
- ๋ฐฉ์ด์ ํ์ฑ: ๊ฐ ํค๊ฐ ์์ ๋๋ง ๋ฎ์ด์ฐ๊ณ (
if n.has), ํ์ฑ์ด ๊นจ์ ธ๋ ์์ธ๋ฅผ ์ผ์ผ ๊ธฐ๋ณธ๊ฐ Params๋ก ํด๋ฐฑ. โ ์ฌ์ฉ์๊ฐ paramsJson์ ๋ง๊ฐ๋จ๋ ค๋ ๋ฐฑํ ์คํธ๊ฐ ์ฃฝ์ง ์์ต๋๋ค(์์ ์ฑ > ์๊ฒฉ์ฑ). - ์ด๋ณด ํฌ์ธํธ:
asInt(40)์ ์ธ์ 40์ "๊ฐ์ด ์๊ฑฐ๋ ์ซ์๊ฐ ์๋๋ฉด 40์ ์จ๋ผ"๋ ๊ธฐ๋ณธ๊ฐ.
A-4. ์ฌ๋ด ๊ฐ์ด Trust Score computeTrustScore โ BacktestService.java:351-355
// BacktestService.java:351-355
private int computeTrustScore(Metrics m) {
double mddAbs = Math.abs(m.mddPct);
double raw = 70 - mddAbs * 0.7 + m.cagrPct * 0.4 + (m.winRate - 0.5) * 30;
return Math.max(0, Math.min(100, (int) Math.round(raw)));
}
- 70์ ์์ ์ถ๋ฐํด ๋ํญ(MDD)์ด ํด์๋ก ๊น๊ณ , ์์ต(CAGR)ยท์น๋ฅ ์ด ๋์์๋ก ๋ํจ. 0~100์ผ๋ก ํด๋จํ.
- โ ๏ธ ์ด๊ฑด ๊ฒฝํ์ ๊ณต์์
๋๋ค. ๊ฒฝ๋ก B์ Python
/trust(Walk-Forward + Regime + ํ๋ผ๋ฏธํฐ ์ญ๋์ผ๋ก ๊ณผ์ ํฉ๊น์ง ๋ณด๋ ์ ๋ฐ ์ฑ์ )์๋ ๊น์ด๊ฐ ๋ค๋ฆ ๋๋ค. UI์ ๋ ๋ค "Trust Score"๋ก ๋ณด์ผ ์ ์์ผ๋ ์ถ์ฒ๋ฅผ ๊ตฌ๋ถํ์ธ์.
B. AnalyticsClient โ Python ์ฌ์ด๋์นด ํธ์ถ (์ํท๋ธ๋ ์ด์ปค + ์ฌ์๋)
์ด ํด๋์ค๊ฐ ๋ฐฑ์๋(Java)โAnalytics ์์ง(Python) ์ ์ ์ผํ ๋ค๋ฆฌ์
๋๋ค. ํธ์ถ ๋์ ์๋ํฌ์ธํธ๋ 06_api/main.md ์์ ๋ผ์ธ๋ณ๋ก ํด์ค๋ฉ๋๋ค.
B-1. ์ค์ ์ฃผ์
+ CB/Retry ๋ฑ๋ก โ AnalyticsClient.java:38-61
// AnalyticsClient.java:38-61 (๋ฐ์ท)
@Value("${app.analytics.base-url}") private String baseUrl;
@Value("${app.analytics.internal-token}") private String token;
@Value("${app.analytics.timeout-sec:30}") private int timeoutSec;
@Value("${app.analytics.heavy-timeout-sec:120}") private int heavyTimeoutSec;
...
@PostConstruct
void init() {
circuitBreaker = cbRegistry.circuitBreaker("analytics");
retry = retryRegistry.retry("analytics");
}
baseUrl/token์application.properties์์ ์ฃผ์ :app.analytics.base-url=${ANALYTICS_BASE_URL:http://localhost:8001}โ ํ๊ฒฝ๋ณ์ ๋ฏธ์ค์ ์ ๊ธฐ๋ณธ localhost:8001.app.analytics.internal-token=${ANALYTICS_INTERNAL_TOKEN:dev-internal-token-change-me}โ ๊ธฐ๋ณธ๊ฐ์ด ๋ปํ๋ฏ๋ก prod ํ์ ๋ณ๊ฒฝ(์์ง ์ชฝrequire_internal_token์ด ๊ฒ์ฌ).
@PostConstruct init()โ ๋น์ด ๋ง๋ค์ด์ง ์งํ,application.properties์instances.analytics.*์ค์ (์๋์ฐ 10ยท์คํจ์จ 50%ยทOPEN 30์ดยท์ฌ์๋ 3ํ/2์ด)์ผ๋ก ๋ฑ๋ก๋ CBยทRetry๋ฅผ ์ด๋ฆ("analytics")์ผ๋ก ๊บผ๋ด์ต๋๋ค.- ์ผ๋ฐ ํธ์ถ์ 30์ด, ๋ฌด๊ฑฐ์ด ์ฐ์ฐ(Trust/Regime/Lean)์ 120์ด ํ์์์์ผ๋ก ๋ถ๋ฆฌ.
B-2. ์ค์ 1ํ ํธ์ถ callOnce โ 4xx/5xx ๋ถ๊ธฐ โ AnalyticsClient.java:80-118
// AnalyticsClient.java:85-107 (๋ฐ์ท)
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path))
.header("Content-Type", "application/json; charset=utf-8")
.header("Accept", "application/json")
.header("X-Internal-Token", token) // โ
์ฌ์ด๋์นด ์ธ์ฆ ํค๋
.timeout(Duration.ofSeconds(timeoutSeconds));
...
HttpResponse<String> resp = client().send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() >= 400 && resp.statusCode() < 500) {
// 4xx โ ํด๋ผ์ด์ธํธ ์ค๋ฅ: ์ฌ์๋ ๋ฌด์๋ฏธ, CB ์นด์ดํธ ์ ์ธ
throw new AnalyticsException.ClientError("analytics client error HTTP " + ...);
}
if (resp.statusCode() >= 500) {
throw new AnalyticsException("analytics server error HTTP " + ...);
}
return om.readTree(resp.body());
- ํต์ฌ ์ค๊ณ โ ์ค๋ฅ๋ฅผ ๋ ์ข
๋ฅ๋ก ๋๋:
- 4xx โ
ClientError(๋ณ๋ ํ์ ์์ธ): "๋ด๊ฐ ๋ณด๋ธ ๊ฒ ํ๋ ธ๋ค". ์ฌ์๋ํด๋ ๋๊ฐ์ผ๋ ์ฌ์๋ยทCB ์นด์ดํธ์์ ์ ์ธ(application.properties์ignore-exceptions์ ๋ฑ๋ก). - 5xx โ
AnalyticsException: "์๋ฒ๊ฐ ์ ๊น ๋ง์ด ๊ฐ๋ค". ์ฌ์๋ ๋์.
- 4xx โ
X-Internal-Tokenํค๋๊ฐ ๋ฐ๋ก ์ฌ์ด๋์นด ์ธ์ฆ. ์์ง์require_internal_token๊ณผ ์ง.- ์ด๋ณด ํฌ์ธํธ:
om.readTree(...)๋ ์๋ต JSON ๋ฌธ์์ด์JsonNode(ํธ๋ฆฌ)๋ก ํ์ฑ. ๋ฐฑ์๋๋ ์ด JsonNode๋ฅผ ๊ทธ๋๋ก ํ๋ก ํธ๋ก ํ๋ ค๋ณด๋ ๋๋ค(์๋ ํจ์ ์ ์ Jackson ์ด์ ์ฃผ์).
B-3. CB+Retry ํฉ์ฑ + ํด๋ฐฑ call / executeDecorated โ AnalyticsClient.java:121-150
// AnalyticsClient.java:121-127
private JsonNode call(String path, String method, Object body) {
Supplier<JsonNode> decorated = CircuitBreaker.decorateSupplier(
circuitBreaker,
Retry.decorateSupplier(retry, () -> callOnce(path, method, body))
);
return executeDecorated(decorated, method, path);
}
- ๋ฐ์ฝ๋ ์ดํฐ ํฉ์ฑ ์์๊ฐ ์ค์:
callOnce๋ฅผ Retry๋ก ๋จผ์ ๊ฐ์ธ๊ณ , ๊ทธ๊ฑธ ๋ค์ CircuitBreaker๋ก ๊ฐ์๋๋ค. โ ์์ชฝ์์ 3๋ฒ ์ฌ์๋๊ฐ ๋ชจ๋ ์คํจํ๋ฉด ๊ทธ "1๋ฒ์ ์ต์ข ์คํจ"๊ฐ CB์ 1๊ฑด์ผ๋ก ์ง๊ณ๋ฉ๋๋ค.
// AnalyticsClient.java:138-150
private JsonNode executeDecorated(Supplier<JsonNode> decorated, String method, String path) {
try {
return decorated.get();
} catch (AnalyticsException.ClientError e) {
throw e; // ํด๋ผ์ด์ธํธ ์ค๋ฅ๋ ๊ทธ๋๋ก ์ ํ
} catch (CallNotPermittedException e) {
log.warn("analytics circuit OPEN โ fast fail for {} {}", method, path);
throw new AnalyticsException("Analytics ์๋น์ค๊ฐ ์ผ์์ ์ผ๋ก ์ฌ์ฉ ๋ถ๊ฐํฉ๋๋ค. ์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.");
} catch (Exception e) {
if (e instanceof AnalyticsException ae) throw ae;
throw new AnalyticsException("analytics call failed: " + e.getMessage(), e);
}
}
CallNotPermittedException= "ํ๋ก๊ฐ OPEN ์ํ๋ผ ํธ์ถ ์์ฒด๊ฐ ๊ฑฐ๋ถ๋จ". ์ด๋ ๋ฐฑ์๋๋ ํ์์์์ ๊ธฐ๋ค๋ฆฌ์ง ์๊ณ ์ฆ์ ์น์ ํ ํ๊ตญ์ด ๋ฉ์์ง๋ก ์คํจ โ ๋น ๋ฅธ ํด๋ฐฑ. CLAUDE.md์ "ANALYTICS_BASE_URL๋ฏธ์ค์ ์ CB๋ก ๋น ๋ฅด๊ฒ ํด๋ฐฑ โ ์๊ทธ๋ ์์ด ๋์"์ด ๋ฐ๋ก ์ด ๋ฉ์ปค๋์ฆ.
B-4. heavy ํธ์ถ์ ์ฌ์๋ ์์ callHeavy โ AnalyticsClient.java:130-136
// AnalyticsClient.java:130-136
private JsonNode callHeavy(String path, String method, Object body) {
Supplier<JsonNode> decorated = CircuitBreaker.decorateSupplier(
circuitBreaker, () -> callOnceHeavy(path, method, body)); // Retry ์์
return executeDecorated(decorated, method, path);
}
- Trust/Regime/Lean์ฒ๋ผ ์ฐ์ฐ์ด ์์ญ ์ด~์ ๋ถ ๊ฑธ๋ฆฌ๋ ํธ์ถ์ ์ฌ์๋๊ฐ ์ญํจ๊ณผ(๋ถํ ํญ์ฆ). ๊ทธ๋์ CB๋ง ๋๋ฅด๊ณ ์ฌ์๋๋ ๋บ๋๋ค. ํ์์์๋ 120์ด๋ก ๊ธธ๊ฒ.
B-5. health ์ฒดํฌ๋ CB ์ฐํ โ AnalyticsClient.java:152-160
// AnalyticsClient.java:153-159
public boolean isHealthy() {
try {
JsonNode n = callOnce("/health", "GET", null); // CB ์ฐํ
return "ok".equals(n.path("status").asText());
} catch (Exception e) { return false; }
}
- ์๋์ ์ผ๋ก
call(CB ๋ํ)์ด ์๋๋ผcallOnce(์ํธ์ถ)๋ฅผ ์๋๋ค. ํ๋ก๊ฐ OPEN์ด์ด๋ "์ ๋ง ์ด์๋ฌ๋?"๋ฅผ ์ง์ ํ์ธํ๊ธฐ ์ํจ. ์ฃฝ์์ผ๋ฉด ์กฐ์ฉํfalse.
B-6. ์๋ํฌ์ธํธ๋ณ ๋ฉ์๋ โ ์์ง ๋งคํ
AnalyticsClient ๋ฉ์๋ |
ํธ์ถ | ์์ง(main.py) ํธ๋ค๋ฌ |
ํด์ค ๋ฌธ์ |
|---|---|---|---|
backtest(...) :163 |
call |
POST /backtest |
vbt_engine.md |
todaySignals(...) :243 |
call |
POST /signals/today |
vbt_engine+xgb_signal |
trainModel(...) :252 |
call |
POST /models/train |
xgb_signal.md |
walkForward(...) :257 |
call |
POST /robust/walk-forward |
walkforward.md |
regime(...) :287 |
callHeavy |
POST /regime |
regime.md |
trustScore(...) :313 |
callHeavy |
POST /trust |
trust_score.md |
infiniteBuying(...) :333 |
call |
POST /backtest/infinite-buying |
infinite_buying.md |
leanBacktest(...) :172 |
callHeavy |
POST /lean/backtest |
07_lean/runner.md |
- ์ฃผ์:
todaySignals//signals/today๋ AnalyticsClient์๋ ์กด์ฌํ์ง๋ง(๊ฒฝ๋ก B), ์ค์ 22:30DailySignalGenerator๋ ์ด๊ฑธ ํธ์ถํ์ง ์๊ณ ์ฌ๋ดBacktestService(๊ฒฝ๋ก A) ๋ก ์๊ทธ๋์ ๋ง๋ญ๋๋ค./signals/today๋ ์ฃผ๋กAnalyticsController๋ฅผ ํตํด ํ๋ก ํธ์ "Developer Studio"์ฉ์ผ๋ก ๋ ธ์ถ๋ฉ๋๋ค. - โ ๏ธ
regime์ ์ต์ ์์ ํค๋ฅผ ํ์ดํธ๋ฆฌ์คํธ๋ก ๊ณจ๋ผ ๋ฃ์ต๋๋ค(AnalyticsClient:293) โ Pydantic์ด unknown ํค๋ฅผ ๊ฑฐ๋ถํ๊ธฐ ๋๋ฌธ.trustScore๋ ๋์ผ(:319).
C. DailySignalGenerator โ ์ผ์ผ ์ก: ๋ฐฑํ
์คํธ โ ์๊ทธ๋ โ ์ ์
C-1. ์ค์ผ์ค ์ง์
์ runDaily โ DailySignalGenerator.java:53-99
// DailySignalGenerator.java:52-74 (๋ฐ์ท)
@Scheduled(cron = "0 30 22 * * MON-FRI", zone = "Asia/Seoul")
public void runDaily() {
try { marketDataService.scheduledRefresh(); } catch (Exception e) { log.warn(...); } // 1) ์์ธ ์ ์ ํ
var actives = strategyRepo.findByActiveTrue();
int ok = 0, fail = 0;
for (var s : actives) {
try { backtestService.runFor(s); ok++; } // 2) ์ ์ฒด ๋ฐฑํ
์คํธ
catch (Exception e) { fail++; log.warn("[DailySignal] {} failed: {}", s.getCode(), e.getMessage()); }
}
- ์๋ฌ ๊ฒฉ๋ฆฌ: ๊ฐ ์ ๋ต์ ๊ฐ๋ณ try/catch๋ก ๊ฐ์ธ ํ ์ ๋ต์ด ํฐ์ ธ๋ ๋๋จธ์ง๋ ๊ณ์(๊ฒฌ๊ณ ํ ๋ฐฐ์น). ์์ง
signals_today์ "ํ ์ข ๋ชฉ ์คํจํด๋ ๋ค์ ์ข ๋ชฉ ๊ณ์" ํจํด๊ณผ ๊ฐ์ ์ฒ ํ. - ์ 22:30: ์ฃผ์(
:28-29)์ "๋ฏธ๊ตญ์ฅ ๋ง๊ฐ(KST ์ต์ผ ์๋ฒฝ 6์)๋ณด๋ค ์์ ๋ฐ์ก โ ๋ค์๋ ์์นจ ์ฌ์ฉ์๊ฐ ํ๋"์ด๋ผ๊ณ ๋ช ์.
// DailySignalGenerator.java:76-98 (๋ฐ์ท)
int sent = emailAlertService.dispatchPending(LocalDate.now()); // 3) ๋ฏธ๋ฐ์ก ๋ฉ์ผ ์ผ๊ด
int created = createProposalsFor(LocalDate.now()); // 4) BUY โ OrderProposal
int refreshed = refreshAlphaWorkspaces(); // 5) Alpha ์ํฌ์คํ์ด์ค ์ฌ์คํ
- 3ยท4ยท5๋จ๊ณ๋ ๊ฐ๊ฐ try/catch๋ก ๊ฐ์ธ ํ ๋จ๊ณ ์คํจ๊ฐ ๋ค์ ๋จ๊ณ๋ฅผ ๋ง์ง ์์.
C-2. โ
BUY ์๊ทธ๋ โ OrderProposal createProposalsFor โ DailySignalGenerator.java:128-201
// DailySignalGenerator.java:128-141 (๋ฐ์ท)
int createProposalsFor(LocalDate asOf) {
var todays = signalRepo.findByAsOfDateFetchStrategyUser(asOf); // strategy/user ์ฆ์๋ก๋ฉ
for (DailySignal sig : todays) {
if (sig.getSignal() != DailySignal.Signal.BUY) continue; // BUY๋ง ์ ์ํ
var strategy = sig.getStrategy();
Long userId = strategy.getUser().getId();
boolean crypto = isCrypto(strategy.getTicker());
BrokerAccount target = pickTradingAccount(userId, crypto); // ๊ฑฐ๋๊ฐ๋ฅ ๊ณ์ ์ ํ
if (target == null) continue; // ์์ผ๋ฉด ์คํต
- BUY์ผ ๋๋ง ์ฃผ๋ฌธ ์ ์์ ๋ง๋ญ๋๋ค(HOLD/WATCH/PAUSE๋ ๋ฉ์ผ๋ง).
findByAsOfDateFetchStrategyUserโ fetch join์ผ๋ก strategyยทuser๋ฅผ ์ฆ์ ๋ก๋ฉ. ์ฃผ์(:129)๋๋กopen-in-view=falseํ๊ฒฝ์์ ํธ๋์ญ์ ๋ฐ lazy ์ ๊ทผ(LazyInitializationException)์ ๋ฐฉ์งํ๋ ์๋์ ์ฟผ๋ฆฌ.
// DailySignalGenerator.java:143-155 (๋ฐ์ท)
int qtyInt; java.math.BigDecimal qtyDec = null;
if (crypto) {
double price = cryptoPrice(target, strategy.getTicker());
if (price <= 0) { ...์คํต... }
double orderUsdt = cryptoOrderUsdt(strategy);
qtyDec = BigDecimal.valueOf(orderUsdt / price).setScale(8, RoundingMode.DOWN);
if (qtyDec.signum() <= 0) { ...์คํต... }
qtyInt = 1; // NOT NULL placeholder (์ค์๋์ qtyDecimal)
} else {
qtyInt = parseFirstBuyShares(strategy.getParamsJson());
}
- ์๋ ์ฐ์ โ ์์ฐ๊ตฐ ๋ถ๊ธฐ: ์ฃผ์์ ์ ์ ์ฃผ์ ์(
firstBuyShares), ํฌ๋ฆฝํ ๋ ๋ช ๋ชฉ๊ฐ(USDT)รทํ์ฌ๊ฐ โ ๋ถ์(์์ 8์๋ฆฌ ๋ด๋ฆผ). ํฌ๋ฆฝํ ๋qtyInt=1์ placeholder๋ก ์ฑ์ฐ๊ณ ์ค์๋์qtyDecimal์(์ํฐํฐ๊ฐqtyNOT NULL์ด๋ผ).
// DailySignalGenerator.java:157-178 (๋ฐ์ท)
// ์ค๋ณต ์ฒดํฌ: ๊ฐ์ sourceSignalId๋ก ์ด์์๋ ์ ์์ด ์์ผ๋ฉด skip
boolean dup = proposalRepo.findByUserIdOrderByCreatedAtDesc(userId).stream()
.anyMatch(p -> sig.getId().equals(p.getSourceSignalId())
&& !"REJECTED".equals(p.getStatus())
&& !"EXPIRED".equals(p.getStatus())
&& !"EXEC_FAILED".equals(p.getStatus()));
if (dup) continue;
OrderProposal saved = proposalRepo.save(OrderProposal.builder()
.userId(userId).brokerAccountId(target.getId())
.ticker(strategy.getTicker()).side("BUY")
.qty(qtyInt).qtyDecimal(qtyDec)
.source("SIGNAL").sourceSignalId(sig.getId())
.status("PENDING").expiresAt(LocalDateTime.now().plusHours(24))
.build());
- ๋ฉฑ๋ฑ โ ์๊ทธ๋๋น ์ ์ 1๊ฑด: ๊ฐ์
sourceSignalId๋ก ์ด๋ฏธ ์ด์์๋(REJECTED/EXPIRED/EXEC_FAILED๊ฐ ์๋) ์ ์์ด ์์ผ๋ฉด ์๋ก ์ ๋ง๋ญ๋๋ค. โ ์ก์ด ํ๋ฃจ ์ฌ๋ฌ ๋ฒ ๋์๋ ์ค๋ณต ์ ์ ๋ฐฉ์ง. - ํญ์ PENDING + 24์๊ฐ ๋ง๋ฃ: ์๋์ผ๋ก ์ค์ฃผ๋ฌธ ์ ๋จ.
OrderProposalExpiryJob์ด ๋ง๋ฃ ์ฒ๋ฆฌ. (์ํฐํฐOrderProposal.java:12-23์ ๋ณด์ ์์น๊ณผ ์ ํฉ.)
// DailySignalGenerator.java:181-198 (๋ฐ์ท)
if (Boolean.TRUE.equals(target.getAutoExecute())) {
var res = exec.execute(saved, target, true); // ProposalExecutionService
if (!res.ok()) log.warn("[auto-exec] proposal {} ์๋์ฒด๊ฒฐ ๋ณด๋ฅ: {}", saved.getId(), res.error());
else log.info("[auto-exec] proposal {} ์๋์ฒด๊ฒฐ ์ ์ ...", ...);
}
- ๊ณ์ ์ด
autoExecute=ON์ด๋ฉด ์ฌ๋ ์น์ธ ์์ด ์ฆ์ ์ฒด๊ฒฐ ์๋. ๋จ ์ฃผ์(:182-183)๋๋ก ๋ชจ๋ ์์ ๊ฒ์ดํธ(kill-switchยทtradingEnabledยทํ๋)๋ProposalExecutionService๊ฐ ๊ฐ์ ํ๊ณ , REAL ๊ณ์ ์ autoExecute๋ MOCK ์กธ์ ๊ฒ์ดํธ(2์ฃผ+20ํ) ๋ฅผ ์ด๋ฏธ ํต๊ณผํ ์ํ๋ง ์ผค ์ ์์ต๋๋ค.
C-3. ๊ฑฐ๋ ๊ณ์ ์ ํ pickTradingAccount โ DailySignalGenerator.java:204-213
// DailySignalGenerator.java:204-213
private BrokerAccount pickTradingAccount(Long userId, boolean crypto) {
var want = crypto ? BrokerAccount.BrokerType.BINANCE : BrokerAccount.BrokerType.KIS;
return brokerAccountRepo.findAllByUserIdOrderByEnvAsc(userId).stream()
.filter(a -> Boolean.TRUE.equals(a.getTradingEnabled()))
.filter(a -> a.getBrokerType() == want)
.sorted((a, b) -> Boolean.compare(
b.getEnv() == BrokerAccount.Env.REAL, a.getEnv() == BrokerAccount.Env.REAL))
.findFirst().orElse(null);
}
- ์์ฐ๊ตฐโ๋ธ๋ก์ปค ๋ผ์ฐํ
: ํฌ๋ฆฝํ ๋ Binance, ์ฃผ์์ KIS.
tradingEnabled=true์ธ ๊ณ์ ๋ง, REAL์ MOCK๋ณด๋ค ์ฐ์ (์ ๋ ฌ). ์ ํฉ ๊ณ์ ์ด ์์ผ๋ฉดnull(โ ์ ์ ์คํต).
D. SubscriptionService โ ๊ตฌ๋
๋ฑ๊ธยท๊ฒฐ์ ๋ฉฑ๋ฑ
โ ๏ธ ์ด ํ์ผ์ ์ผ๋ถ ํ๊ธ ์ฃผ์์ ์ธ์ฝ๋ฉ์ด ๊นจ์ ธ(mojibake) ๋ณด์ผ ์ ์์ต๋๋ค(์:
?ั์??). ์ฝ๋ ๋์์ ์ ์์ด๋ฉฐ, ์๋ ํด์ค์ ์ค์ ์ฝ๋ ๋ก์ง ๊ธฐ์ค์ ๋๋ค.
D-1. ํ์ฌ ๋ฑ๊ธ ์กฐํ currentTier โ SubscriptionService.java:29-35
// SubscriptionService.java:29-35
@Transactional(readOnly = true)
public Subscription.Tier currentTier(Long userId) {
return repo.findFirstByUserIdAndStatusOrderByExpiresAtDesc(userId, Subscription.Status.ACTIVE)
.filter(s -> s.getExpiresAt() != null && s.getExpiresAt().isAfter(LocalDateTime.now()))
.map(Subscription::getTier)
.orElse(Subscription.Tier.FREE);
}
- ํ์ฑ(ACTIVE)์ด๊ณ ์์ง ๋ง๋ฃ๋์ง ์์ ๊ตฌ๋ ์ด ์์ผ๋ฉด ๊ทธ Tier(=PRO), ์๋๋ฉด FREE. ๋ง๋ฃ ์๊ฐ์ ์ฝ๋ ์์ ์ ํ ๋ฒ ๋ ๊ฒ์ฌ(๋ง๋ฃ ์ก์ด ์์ง ์ ๋์์ด๋ ์ค์๊ฐ์ผ๋ก ์ ํ).
D-2. ํ์ ๋ฑ๊ธ ํ์ deriveTierDisplay โ SubscriptionService.java:52-57
// SubscriptionService.java:52-57
public static String deriveTierDisplay(Subscription sub) {
if (sub == null) return "FREE";
long amt = sub.getAmountKrw() != null ? sub.getAmountKrw() : 0L;
if (amt >= 19900L) return "PREMIUM";
return "STANDARD";
}
- ์ฌ์ ์ง์ 4๋ฒ์ ํต์ฌ: DB Tier๋ PRO ๋จ์ผ์ด์ง๋ง, ๊ฒฐ์ ๊ธ์ก์ผ๋ก STANDARD/PREMIUM์ ๋ฐํ์ ํ์. 19,900์ ์ด์์ด๋ฉด PREMIUM.
D-3. โ
Pro ํ์ฑํ (๋ฉฑ๋ฑ) activatePro โ SubscriptionService.java:67-93
// SubscriptionService.java:67-93 (๋ฐ์ท)
@Transactional
public Subscription activatePro(Long userId, String paymentKey, String orderId, long amountKrw) {
Subscription dup = findByPaymentKey(paymentKey); // M8 ์ฌ์ ๋ฉฑ๋ฑ ์ฒดํฌ
if (dup != null) { log.info("Pro activate ๋ฉฑ๋ฑ ์ฒ๋ฆฌ ..."); return dup; }
LocalDateTime now = LocalDateTime.now();
Subscription sub = Subscription.builder()
.userId(userId).tier(Subscription.Tier.PRO).status(Subscription.Status.ACTIVE)
.startedAt(now).expiresAt(now.plusDays(30)) // 30์ผ
.tossPaymentKey(paymentKey).tossOrderId(orderId).amountKrw(amountKrw)
.build();
Subscription saved = repo.save(sub);
userRepository.findById(userId).ifPresent(u -> {
u.setUserType(amountKrw >= 19900L ? User.UserType.PREMIUM : User.UserType.STANDARD); // User ๋ฑ๊ธ ๋๊ธฐํ
userRepository.save(u);
});
return saved;
}
- ๋ฉฑ๋ฑ 1์ฐจ ๋ฐฉ์ด: ๊ฐ์ ๊ฒฐ์ ํค๋ก ์ด๋ฏธ ๊ตฌ๋ ์ด ์์ผ๋ฉด ์๋ก INSERTํ์ง ์๊ณ ๊ธฐ์กด ๊ฒ ๋ฐํ(์ด์ค ๋ฑ๊ธ ๋ถ์ฌ ๋ฐฉ์ง).
- ๊ตฌ๋
๊ธฐ๊ฐ 30์ผ. ๋์์
User.UserType๋ ๊ธ์ก์ ๋ง์ถฐ ๊ฐฑ์ (STANDARD/PREMIUM) โ ๊ตฌ๋ ๊ณผ ์ฌ์ฉ์ ๋ฑ๊ธ์ ํจ๊ป ๋๊ธฐํ.
D-4. ๋ง๋ฃ ์ผ๊ด ์ฒ๋ฆฌ expireAllDue โ SubscriptionService.java:96-109
// SubscriptionService.java:96-109
@Scheduled(cron = "0 0 * * * *") // ๋งค์ ์ ๊ฐ
@Transactional
public int expireAllDue() {
var due = repo.findByStatusAndExpiresAtBefore(Subscription.Status.ACTIVE, LocalDateTime.now());
for (var s : due) {
s.setStatus(Subscription.Status.EXPIRED);
userRepository.findById(s.getUserId()).ifPresent(u -> {
u.setUserType(User.UserType.FREE); userRepository.save(u); // FREE ๊ฐ๋ฑ
});
}
return due.size();
}
- ๋งค์ ์ ๊ฐ ๋ง๋ฃ๋ ํ์ฑ ๊ตฌ๋
์ EXPIRED๋ก ๋ฐ๊พธ๊ณ ์ฌ์ฉ์๋ฅผ FREE๋ก ๊ฐ๋ฑ. (๋จ
currentTier๊ฐ ์ฝ๋ ์์ ์๋ ๋ง๋ฃ๋ฅผ ๊ฒ์ฌํ๋ฏ๋ก, ์ด ์ก์ด ๋ฆ์ด๋ ์ฌ์ฉ์์๊ฒ๋ ์ ํํ ๋ณด์ โ ์ด์ค ์์ .)
E. SubscriptionController โ ๊ฒฐ์ confirm + ๊ธ์ก ์๋ณ์กฐ ์ฐจ๋จ
E-1. ํ์ฉ ๊ธ์ก ํ์ดํธ๋ฆฌ์คํธ โ SubscriptionController.java:29-32
// SubscriptionController.java:29-32
private static final java.util.Map<Long, String> VALID_PLANS = java.util.Map.of(
9900L, "STANDARD",
19900L, "PREMIUM"
);
- ๊ธ์ก ์๋ณ์กฐ ๋ฐฉ์ง์ ํต์ฌ: ํ๋ก ํธ๊ฐ ๋ณด๋ธ ๊ธ์ก์ด ์ด Map์ ์์ผ๋ฉด ๊ฑฐ๋ถ. ์ฌ์ฉ์๊ฐ ๊ฒฐ์ ๊ธ์ก์ 1์์ผ๋ก ์กฐ์ํด PREMIUM์ ๋ฐ๋ ๊ณต๊ฒฉ์ ์ฐจ๋จ.
E-2. โ
confirm โ 3์ค ๋ฉฑ๋ฑ ๋ฐฉ์ด โ SubscriptionController.java:62-111
// SubscriptionController.java:67-84 (๋ฐ์ท)
String paymentKey = String.valueOf(body.get("paymentKey"));
long amount = ((Number) body.getOrDefault("amount", 0)).longValue();
if (!VALID_PLANS.containsKey(amount)) { ...badRequest("ํ์ฉ๋์ง ์์ ๊ฒฐ์ ๊ธ์ก")... } // โ ๊ธ์ก ๊ฒ์ฆ
if (paymentKey == null || paymentKey.isBlank() || "null".equals(paymentKey)) { ...badRequest... }
// M8 ๋ฉฑ๋ฑ์ฑ โ : ์ด๋ฏธ ์ฒ๋ฆฌ๋ ๊ฒฐ์ ํค๋ฉด Toss ์ฌconfirm ์์ด ๊ธฐ์กด ๊ตฌ๋
๋ฐํ
Subscription already = subscriptionService.findByPaymentKey(paymentKey);
if (already != null) { return ResponseEntity.ok(idempotentBody(already, true)); }
// SubscriptionController.java:86-106 (๋ฐ์ท)
try {
JsonNode result = toss.confirm(paymentKey, orderId, amount); // โก Toss ์๋ฒ confirm
if (!"DONE".equalsIgnoreCase(result.path("status").asText())) { ...badRequest... }
Subscription sub = subscriptionService.activatePro(uid, paymentKey, orderId, amount);
return ResponseEntity.ok(Map.of("tier", VALID_PLANS.get(amount), ...));
} catch (DataIntegrityViolationException dup) {
// ๋์ confirm ๊ฒฝํฉ โ DB ์ ๋ํฌ(uq_subscription_toss_payment_key)์ ๋งํ โ ๋จผ์ ์ฒ๋ฆฌ๋ ๊ตฌ๋
์ฌ์ฌ์ฉ
Subscription existing = subscriptionService.findByPaymentKey(paymentKey);
if (existing != null) { return ResponseEntity.ok(idempotentBody(existing, true)); }
...
}
- 3์ค ๋ฉฑ๋ฑ ๋ฐฉ์ด(M8):
- ์ ํ๋ฆฌ์ผ์ด์
์ฌ์ ์ฒดํฌ(
findByPaymentKey != null) โ ํํ ๋๋ธํด๋ฆญ/์๋ก๊ณ ์นจ. - ์๋น์ค ๋ ์ด์ด ์ฌํ์ธ(
activatePro์์dup์ฒดํฌ). - DB ์ ๋ํฌ ์ ์ฝ(
uq_subscription_toss_payment_key) โ ๋์(race) ๋ ์์ฒญ์ด ์ฌ์ ์ฒดํฌ๋ฅผ ๋์์ ํต๊ณผํด๋, ๋ ๋ฒ์งธ INSERT๊ฐDataIntegrityViolationException์ผ๋ก ๋งํ๊ณ , catch์์ ๋จผ์ ๋ง๋ค์ด์ง ๊ตฌ๋ ์ ์ฌ์ฌ์ฉ.
- ์ ํ๋ฆฌ์ผ์ด์
์ฌ์ ์ฒดํฌ(
- โ "์ฌ์ ์ฒดํฌ๋ ํํ ๊ฒฝ์ฐ๋ฅผ, DB ์ ์ฝ์ ๋์์ฑ ๊ฒฝํฉ์ ์ก๋๋ค"๋ ๊ฒฌ๊ณ ํ ๋ฉฑ๋ฑ ์ค๊ณ. (์ํฐํฐ
Subscription.java:14-17์ ์ ๋ํฌ ์ ์ฝ๊ณผ ์ ํฉ.)
F. StrategyController โ ์ ๋ต CRUD + ์์ ๊ถ ๊ฒฉ๋ฆฌ
F-1. ์์ ๊ถ ๊ฒฉ๋ฆฌ ํจํด (๋ชจ๋ ์๋ํฌ์ธํธ ๊ณตํต) โ ์: detail StrategyController.java:74-82
// StrategyController.java:74-82
@GetMapping("/{id}")
public ResponseEntity<?> detail(@PathVariable Long id) {
Long uid = AuthContext.currentUserId();
if (uid == null) return unauthorized();
return strategyRepository.findById(id)
.filter(s -> s.getUser().getId().equals(uid)) // โ
์์ ๊ถ ๊ฒ์ฆ
.<ResponseEntity<?>>map(s -> ResponseEntity.ok(StrategyDto.from(s)))
.orElse(ResponseEntity.notFound().build());
}
- IDOR ์ฐจ๋จ: id๋ก ์กฐํํ ๋ค
s.getUser().getId().equals(uid)๋ก "์ด ์ ๋ต์ด ์ ๋ง ๋ด ๊ฒ์ธ๊ฐ" ๋ฅผ ๊ฒ์ฌ. ๋จ์ ์ ๋ต์ด๋ฉด404(์กด์ฌ ์์ฒด๋ฅผ ์จ๊น). ์ด ํจํด์ด detail/trades/states/signals/summary/update/delete/backtest ์ ๋ถ์ ๋ฐ๋ณต๋ฉ๋๋ค.
F-2. ์์ฑ ๊ฒ์ฆ create โ StrategyController.java:157-186
// StrategyController.java:160-168 (๋ฐ์ท)
if (req.code() == null || req.code().isBlank()) return badRequest("code ํ์");
if (req.ticker() == null || req.ticker().isBlank()) return badRequest("ticker ํ์");
if (req.method() == null) return badRequest("method ํ์");
if (req.principalKrw() == null || req.principalKrw() <= 0) return badRequest("principalKrw ์์ ํ์");
if (strategyRepository.existsByUserIdAndCode(uid, req.code())) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("error", "์ด๋ฏธ ์กด์ฌํ๋ code"));
}
- ํ์๊ฐ + ์ฌ์ฉ์๋ณ code ์ ๋ํฌ(409 Conflict).
principalKrw > 0์ ๊ฐ์ ํด "์๊ธ ๋ถ๋ฆฌ" ๊ท์น(์ํฐํฐStrategy.java:57)์ ์ ๋ ฅ ๋จ์์ ๋ณด์ฅ.
F-3. ์์ ์ ๋ถ๋ณ ํ๋ update โ StrategyController.java:188-205
// StrategyController.java:203
// ticker/code/method๋ ์ด์ ์ค ๋ณ๊ฒฝ ์ํ โ ๋ณ๊ฒฝ ๊ธ์ง
- name/regime/goal/paramsJson/principal/startDate/benchmark/active๋ง ์์ ๊ฐ๋ฅ. tickerยทcodeยทmethod๋ ์ผ๋ถ๋ฌ ๊ฐฑ์ ์ ํจ โ ์ด์ ์ค ์ข ๋ชฉยท์ ๋ต๋ฐฉ์์ ๋ฐ๊พธ๋ฉด ๊ธฐ์กด trades/state์ ๋ถ์ผ์น๊ฐ ์๊ธฐ๊ธฐ ๋๋ฌธ.
F-4. ๋ฐฑํ
์คํธ ์๋ ํธ๋ฆฌ๊ฑฐ runBacktest โ StrategyController.java:312-328
// StrategyController.java:312-327 (๋ฐ์ท)
@PostMapping("/{id}/backtest")
public ResponseEntity<?> runBacktest(@PathVariable Long id) {
... ์์ ๊ถ ๊ฒ์ฆ ...
try {
var summary = backtestService.runFor(s);
if (summary == null) return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(Map.of("error", "insufficient market data")); // 30๊ฐ ๋ฏธ๋ง โ 422
return ResponseEntity.ok(StrategyBacktestSummaryDto.from(summary));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(...);
}
}
BacktestService.runFor์null(๋ฐ์ดํฐ ๋ถ์กฑ)์ 422๋ก, ์์ธ๋ฅผ 500์ผ๋ก ๋ณํ. CLAUDE.md API ํ์POST /api/strategies/{id}/backtest๊ฐ ์ด ๋ฉ์๋.
G. AnalyticsController โ Python ๋ธ๋ฆฌ์ง์ ์์ ๋
ธ์ถ
// AnalyticsController.java:39-50 (๋ฐ์ท)
@PostMapping("/backtest")
public ResponseEntity<JsonNode> backtest(@RequestBody Map<String, Object> body) {
String ticker = (String) body.get("ticker");
if (ticker == null || ticker.isBlank()) return ResponseEntity.badRequest().build();
String strategy = (String) body.getOrDefault("strategy", "sma_cross");
Map<String, Object> extra = new HashMap<>(body);
extra.remove("ticker"); extra.remove("strategy");
return ResponseEntity.ok(analytics.backtest(ticker, strategy, extra)); // โ ๊ฒฝ๋ก B
}
- ์์ ์์(thin pass-through): ์
๋ ฅ๋ง ์ด์ง ์ ๋ฆฌํด
AnalyticsClient(๊ฒฝ๋ก B)๋ก ๋๊ธฐ๊ณ , ๋ฐํ๋JsonNode๋ฅผ ๊ทธ๋๋ก ํ๋ก ํธ์. ๊ณ์ฐ์ ์ ๋ถ Python:8001์์. /api/analytics/**๋ Spring Security๊ฐ JWT๋ฅผ ์๊ตฌํ๊ณ , ๊ทธ ์์์AnalyticsClient๊ฐ ๋ค์X-Internal-Token์ผ๋ก ์ฌ์ด๋์นด ์ธ์ฆ โ ์ด์ค ์ธ์ฆ ๊ฒ์ดํธ.
โ ๏ธ ํจ์ ยท๋ณด์ ์ฃผ์
1. (M8) ๊ฒฐ์ ๋ฉฑ๋ฑ์ฑ โ 3์ค ๋ฐฉ์ด๊ฐ ํ์ํ ์ด์
- ์ฌ์ ์ฒดํฌ(
findByPaymentKey)๋ง์ผ๋ก๋ ๋์ ์์ฒญ์ ๋ชป ๋ง์ต๋๋ค(๋ ์์ฒญ์ด ๋์์ "์์"์ ์ฝ๊ณ ๋ ๋ค INSERT). ๊ทธ๋์ DB ์ ๋ํฌ ์ ์ฝuq_subscription_toss_payment_key์ด ์ต์ข ๋ฐฉ์ด์ ์ด๊ณ , ์ปจํธ๋กค๋ฌ๊ฐDataIntegrityViolationException์ ์ก์ ๋ฉฑ๋ฑ ์๋ต์ผ๋ก ๋ฐ๊ฟ๋๋ค(SubscriptionController.java:98-104). ์ ์ค ํ๋๋ผ๋ ๋น ์ง๋ฉด ์ด์ค ๊ตฌ๋ /๊ณผ๊ธ ์ํ.
2. Jackson JsonNode ์ง๋ ฌํ (Spring Boot 4 = Jackson 3 ๊ธฐ๋ณธ)
AnalyticsClient๋ ๋ด๋ถ์ ์ผ๋ก Jackson 2์com.fasterxml.jackson.databind.JsonNode๋ก ์๋ต์ ํ์ฑํ๊ณ ,AnalyticsController๊ฐ ์ดJsonNode๋ฅผ ๊ทธ๋๋ก ๋ฐํํฉ๋๋ค.- โ ๏ธ ๋ฉ๋ชจ๋ฆฌ ๊ธฐ๋ก(
project_springboot4_jackson_jsonnode_bug.md)๋๋ก Spring Boot 4๋ Jackson 3๊ฐ ๊ธฐ๋ณธ์ด๋ผ, ํธ๋ค๋ฌ๊ฐ Jackson 2JsonNode๋ฅผ ๋ฐํํ๋ฉด ๋ชจ๋ ํค๊ฐ ๋น bean ์์ฑ์ผ๋ก ๊นจ์ ธ ์ง๋ ฌํ๋ ์ ์์ต๋๋ค. analytics ์๋ต์ด ํ๋ก ํธ์์{}์ฒ๋ผ ๋น์ด ๋ณด์ด๋ฉด ์ด ์์ธ์ ์์ฌํ์ธ์(ํด๊ฒฐ: ์๋ต์ ๋ฌธ์์ด/Map๋ก ๋ณํํ๊ฑฐ๋ Jackson 3JsonNode์ฌ์ฉ).
3. ANALYTICS_BASE_URL ๋ฏธ์ค์ โ CB ๋น ๋ฅธ ํด๋ฐฑ
- ํ๊ฒฝ๋ณ์ ๋ฏธ์ค์ ์ด๋ฉด
base-url์ดhttp://localhost:8001๋ก ๊ธฐ๋ณธ ๋์ํฉ๋๋ค(application.properties:101). Python์ด ์ ๋ ์์ผ๋ฉดConnectExceptionโ ์ฌ์๋ 3ํ ์คํจ โ ์ํท OPEN โ ์ดํ 30์ด๊ฐ ์ฆ์ ์คํจ. ๋ฐฑ์๋๋ ์ ์ฃฝ์ง๋ง ๊ฒฝ๋ก B ๊ธฐ๋ฅ(์ ๋ฐ ๋ฐฑํ ์คํธยทTrustยทRegime)์ด ๋นํ์ฑ. ๋จ ๊ฒฝ๋ก A(๋ฌดํ๋งค์/VR ๋ฐฑํ ์คํธยท์ผ์ผ ์๊ทธ๋)๋ Python๊ณผ ๋ฌด๊ดํ๊ฒ ๊ณ์ ๋์ํฉ๋๋ค.
4. internal-token ๊ธฐ๋ณธ๊ฐ์ด ๋ปํจ
dev-internal-token-change-me๊ฐ ๊ธฐ๋ณธ๊ฐ(application.properties:102). prod์์ANALYTICS_INTERNAL_TOKEN์ ์ ๋ฐ๊พธ๋ฉด ๋๊ตฌ๋ :8001์ ์ง์ ํธ์ถ ๊ฐ๋ฅ. prod ํ์ ๋ณ๊ฒฝ.
5. USD_KRW = 1300.0 ํ๋์ฝ๋ฉ
BacktestService.java:48์ ํ์จ์ด ์์์ ๋๋ค(TODO: ์ค์๊ฐ API). ๋ฐฑํ ์คํธ์ ์๊ธ ํ์ฐยทKRW ํ๊ฐ์ก์ด ์ค์ ํ์จ๊ณผ ์ด๊ธ๋ ์ ์์. ์๊ทธ๋์ USDT ๋ช ๋ชฉ๊ฐ ๊ณ์ฐ(DailySignalGenerator:226)๋ 1300 ๊ณ ์ .
6. ์ฌ๋ด TrustScore vs Python TrustScore ํผ๋
BacktestService.computeTrustScore(๊ฐ์ด ๊ฒฝํ์)์/trust(Walk-Forward+Regime ์ ๋ฐ)๋ ๋ค๋ฅธ ์ ์์ ๋๋ค. UI์์ ๋ ๋ค "Trust Score"๋ก ๋ณด์ผ ์ ์์ผ๋ ์ถ์ฒ๋ฅผ ๋ช ํํ.
7. OrderProposal์ ํญ์ PENDING์ผ๋ก ์์
- ์๋ ์๊ทธ๋์ด ์ ์์ ๋ง๋ค์ด๋ ์น์ธ ์ ์ค์ฃผ๋ฌธ ๊ธ์ง(์ํฐํฐ
OrderProposal.java:18-22).autoExecute๊ณ์ ๋ง ์์ธ์ด๋ฉฐ, ๊ทธ์กฐ์ฐจ kill-switchยท์กธ์ ๊ฒ์ดํธ ๋ฑProposalExecutionService์ ๊ฒ์ดํธ๋ฅผ ํต๊ณผํด์ผ ํจ.
8. BacktestService์ ์์ผ๋์นด๋ import
BacktestService.java:4-19๊ฐ ๊ฑฐ์ ๋ชจ๋ ๋๋ฉ์ธ ์ํฐํฐ/๋ ํฌ์งํ ๋ฆฌ๋ฅผ*๋ก importํฉ๋๋ค. ์ค์ ๋ก ์ฐ๋ ๊ฑด strategy ๋๋ฉ์ธ๋ฟ โ ์ปดํ์ผ์ ๋ฌดํดํ์ง๋ง ๊ฐ๋ ์ฑยท๊ฒฐํฉ๋ ์ธก๋ฉด์ ์ ๋ฆฌ ๋์(๊ณ ๋ํ).
๐ ๊ณ ๋ํ ์์ด๋์ด
- ์ค์๊ฐ ํ์จ:
USD_KRW์์๋ฅผ ํ์จ API(๋๋ ์บ์๋MarketDataService)๋ก ๊ต์ฒด โ ๋ฐฑํ ์คํธยท์๊ทธ๋ ๋ช ๋ชฉ๊ฐ ์ ํ๋ ํฅ์. ํ ์ค ์์๊ฐ ์ฌ๋ฌ ๊ณ์ฐ์ ํผ์ ธ ์์ด ์ข์ ๋ฆฌํฉํฐ ์ค์ต. - ๊ฒฝ๋ก AโB ํตํฉ ์๊ทธ๋: ํ์ฌ ์ผ์ผ ์๊ทธ๋์ ์ฌ๋ด Java(๊ฒฝ๋ก A)๋ก๋ง ์์ฑ.
AnalyticsClient.todaySignals(XGBoost ํ๋ฅ +SHAP)๋ฅผ ํฉ์ณ "๊ท์น ์๊ทธ๋ + ML ํ๋ฅ "์ ํDailySignal์ ๋ด์ผ๋ฉด ์ค๋ช ๋ ฅ ๊ฐํ. ์์ง/signals/today(main.md I์ )๊ฐ ์ด๋ฏธ 3์์ง ํฉ์ฑ์ ์ง์. - CB ํด๋ฐฑ ์บ์: ํ๋ก OPEN ์ ๋ง์ง๋ง ์ฑ๊ณต ์๋ต์ ์บ์ํด "์ค๋๋ ๊ฐ์ด๋ผ๋" ๋ณด์ฌ์ฃผ๊ธฐ(stale-while-revalidate). ์ง๊ธ์ ์ฆ์ ์คํจ๋ง.
- ํ์ด๋ฐ ์์ ํ ํฐ ๋น๊ต: ๋ด๋ถ ํ ํฐ ๋น๊ต๋ฅผ
MessageDigest.isEqual(์์์๊ฐ)๋ก โ ์์ง ์ชฝhmac.compare_digest๊ถ์ฅ๊ณผ ๋์นญ. - ๊ตฌ๋ Tier ์ ๊ทํ: STANDARD/PREMIUM์ ๊ธ์ก ํ์์ด ์๋๋ผ DB enum/์ปฌ๋ผ์ผ๋ก ์น๊ฒฉํ๋ฉด ๊ฐ๊ฒฉ ๋ณ๊ฒฝ ์ ๊ณผ๊ฑฐ ๊ตฌ๋ ํ์๊ฐ ์์ . CLAUDE.md์ EXPERT ํ๋ ํ์ฅ๋ ์์.
- ๋ฐฑํ
์คํธ ๋น๋๊ธฐํ:
backtest-all์ด ๋๊ธฐ ๋ฃจํ๋ผ ์ ๋ต์ด ๋ง์ผ๋ฉด ์๋ต ์ง์ฐ. ์ก ํ(๋๋@Async)๋ก ๋ถ๋ฆฌํ๊ณ ์งํ๋ฅ ์ ํด๋ง(Leanbacktest/start+statusํจํด ์ฐธ๊ณ ). StrategyBacktestSummary์ ๋ฒค์น๋งํฌ ๋๋น ์ํ: ํ์ฌ ์ ๋ ์ฑ๊ณผ๋ง.benchmark์ข ๋ชฉ ๋๋น ์ด๊ณผ์์ต์ ๋ํ๋ฉด "๊ทธ๋ฅ ์ง์๋ณด๋ค ๋์๊ฐ?" ํ๊ฐ ๊ฐ๋ฅ(์์งcompute_metrics์ SPY ๋ฒค์น๋งํฌ ํจํด ์ฐจ์ฉ).
๐ ์ฉ์ด ์ฌ์ (์ด ํํธ ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
| ๊ฒฝ๋ก A / ๊ฒฝ๋ก B | A=์ฌ๋ด Java ๋ฐฑํ
์คํธ(๋ฌดํ๋งค์/VR, BacktestService), B=Python ์ํ(vectorbt/XGBoost, AnalyticsClient) |
| ์ฌ์ด๋์นด(Sidecar) | ๋ณธ์ฒด ์์ ๋ถ์ด ํน์ ์์ ์ ์ ๋ดํ๋ ๋ณด์กฐ ํ๋ก์ธ์ค(์ฌ๊ธฐ์ Analytics :8001) |
| ์ํท ๋ธ๋ ์ด์ปค(CircuitBreaker) | ์คํจ์จ์ด ์๊ณ์น ๋์ผ๋ฉด ํธ์ถ์ ์ฐจ๋จ(OPEN)ํด ์ฅ์ ์ ํ๋ฅผ ๋ง๋ ๋๊บผ๋น์ง. CLOSEDโOPENโHALF_OPEN |
| Retry | ์ผ์์ ์คํจ์ ํํด ๊ฐ์ ํธ์ถ์ Nํ ์ฌ์๋(4xx๋ ์ ์ธ) |
CallNotPermittedException |
ํ๋ก๊ฐ OPEN์ด๋ผ ํธ์ถ ์์ฒด๊ฐ ๊ฑฐ๋ถ๋จ โ ์ฆ์ ํด๋ฐฑ |
X-Internal-Token |
๋ฐฑ์๋๊ฐ ์ฌ์ด๋์นด์ "์ ์ ๋ฐฑ์๋"์์ ์ฆ๋ช ํ๋ ๋ด๋ถ ์ธ์ฆ ํค๋ |
| ๋ฉฑ๋ฑ์ฑ(Idempotency) | ๊ฐ์ ์์ฒญ์ ์ฌ๋ฌ ๋ฒ ๋ณด๋ด๋ ๊ฒฐ๊ณผ๊ฐ ํ ๋ฒ๊ณผ ๋์ผ(๊ฒฐ์ ์ค๋ณต ๋ฐฉ์ง) |
| upsert | ์์ผ๋ฉด ๊ฐฑ์ , ์์ผ๋ฉด ์ฝ์
โ findByโฆ().orElseGet(build) ํจํด |
| ์์ ๊ถ ๊ฒฉ๋ฆฌ/IDOR | "์ด ๋ฆฌ์์ค๊ฐ ์ ๋ง ๋ด ๊ฒ์ธ๊ฐ"๋ฅผ ๊ฒ์ฌํด ๋จ์ ๋ฐ์ดํฐ ์ ๊ทผ ์ฐจ๋จ |
| OrderProposal | ์๊ทธ๋/์๋์์ ๋์จ ์ฃผ๋ฌธ ์ ์. PENDING์ผ๋ก ์์, ์น์ธ ์ ์ค์ฃผ๋ฌธ ๊ธ์ง |
| LOC (Limit On Close) | ์ข ๊ฐ ๊ธฐ์ค ์ง์ ๊ฐ ๋งค์. ๋ฌดํ๋งค์๋ฒ์ ํต์ฌ ์ฃผ๋ฌธ ์ ํ |
| VR (Value Rebalancing) | V๊ฐ ๋ฐด๋๋ฅผ ๋ฒ์ด๋๋ฉด ๋งค์/๋งค๋ํด ๋ชฉํ ํ๊ฐ์ก์ผ๋ก ๋๋๋ฆฌ๋ ์ ๋ต |
| Tier vs UserType | DB ๊ตฌ๋ ์ FREE/PRO ๋จ์ผ, ํ์(STANDARD/PREMIUM)๋ ๊ฒฐ์ ๊ธ์ก์ผ๋ก ํ์ |
@Scheduled(cron) |
์ ํด์ง ์๊ฐ์ ์๋ ์คํ๋๋ ์ก(22:30 ์๊ทธ๋, ๋งค์ ๋ง๋ฃ, 07:00 ์์ธ) |
| fetch join | lazy ์ฐ๊ด์ ์ฟผ๋ฆฌ์์ ์ฆ์ ๋ก๋ฉํด ํธ๋์ญ์ ๋ฐ LazyInitializationException ๋ฐฉ์ง |
strategy/service/broker โ ๋ธ๋ก์ปคยท์ฃผ๋ฌธ ์คํ (์์ ๋ผ์ธ๋ณ ํด์ค)
์๋ณธ:
backend/src/main/java/com/DevBridge/devbridge/domain/strategy/
๋ฒ์:service/broker/(BrokerยทBrokerRouterยทKIS/Binance ์ด๋ํฐยทApiClientยทProposalExecutionServiceยทOrderFillServiceยทTradingControlServiceยทPromotionGateService) +controller/(BrokerAccountยทBrokerOrderยทOrderProposal) +entity/(BrokerAccountยทOrderProposal)
์ ์ : Spring ๊ธฐ๋ณธ ๋ฌธ๋ฒ(@Serviceยท@RestControllerยท@Transactionalยท์์กด์ฑ ์ฃผ์ ยทJPA)์00_spring_primer.md์์ ๋ค๋ฃน๋๋ค. ์ฌ๊ธฐ์๋ ๋๋ฉ์ธ ๋ก์ง์๋ง ์ง์คํฉ๋๋ค.
๐ ์ด ํํธ ํ๋์
์ด ํํธ๋ "์ฌ๋ฌ ์ฆ๊ถ์ฌ์ ์ฃผ๋ฌธ์ ๋ฃ๋ ๋จ์ผ ์ฐฝ๊ตฌ" ์
๋๋ค. ์ฐ๋ฆฌ ์์คํ
์ ๋ ๊ตฐ๋ฐ์ ์ง์ง ๋์ ๋ณด๋ผ ์ ์์ต๋๋ค โ KIS(ํ๊ตญํฌ์์ฆ๊ถ, ๋ฏธ๊ตญ์ฃผ์) ์ Binance(๋ฐ์ด๋ธ์ค, ํฌ๋ฆฝํ ํ๋ฌผ). ๋ ๊ณณ์ API ์๊น์๊ฐ ์์ ํ ๋ค๋ฅธ๋ฐ(ํ์ชฝ์ KIS ๊ฒ์ดํธ์จ์ด + TR-ID, ๋ค๋ฅธ ์ชฝ์ HMAC ์๋ช
+ ์ฟผ๋ฆฌ์คํธ๋ง), ์ด ํํธ๋ ๊ทธ ์ฐจ์ด๋ฅผ ํ๋์ Broker ๊ณ์ฝ์ผ๋ก ๊ฐ์ธ์ ์๋จ ์ฝ๋๊ฐ "์ด๋ ์ฆ๊ถ์ฌ์ธ์ง" ์ ๊ฒฝ ์ฐ์ง ์๊ฒ ๋ง๋ญ๋๋ค.
๋น์ : ํด์ธ ์ก๊ธ ์ฐฝ๊ตฌ. ๊ณ ๊ฐ(์ฃผ๋ฌธ ์ ์)์ "์ด ๋์ ์ด ์ฌ๋์๊ฒ ๋ณด๋ด์ค"๋ผ๊ณ ๋ง ๋งํฉ๋๋ค. ์ฐฝ๊ตฌ ์ง์(
ProposalExecutionService)์ ๋จผ์ ์ ๋ถยทํ๋ยท๋ธ๋๋ฆฌ์คํธ๋ฅผ ์ ๋ถ ํ์ธํ๊ณ (์์ ๊ฒ์ดํธ), ํต๊ณผํ๋ฉด ํ์ ์(BrokerRouter)์ ๋๊น๋๋ค. ํ์ ์๋ ๋ชฉ์ ์ง๊ฐ ๋ฏธ๊ตญ์ด๋ฉด KIS ๋ฐ์คํฌ๋ก, ์ฝ์ธ์ด๋ฉด Binance ๋ฐ์คํฌ๋ก ๋ณด๋ ๋๋ค. ๊ฐ ๋ฐ์คํฌ(์ด๋ํฐ)๋ ๊ทธ ๋๋ผ ์์์ผ๋ก ์๋ฅ๋ฅผ ๋ฐ๊ฟ ์ค์ ๋ก ์ก๊ธํฉ๋๋ค.
ํต์ฌ ํด๋์ค ์ญํ ํ:
| ํด๋์ค | ํ ์ค ์ญํ | ๋น์ |
|---|---|---|
Broker (interface) |
๋ชจ๋ ์ฆ๊ถ์ฌ๊ฐ ์ง์ผ์ผ ํ ๊ณตํต ๊ณ์ฝ(์ฃผ๋ฌธ/์ฒด๊ฒฐ/์๊ณ /์์ธ 4๊ฐ ๋ฉ์๋) | ์ก๊ธ ํ์ค ์์ |
BrokerRouter |
brokerType(KIS/BINANCE)์ผ๋ก ์๋ง์ ๊ตฌํ ์ ํ |
๋ชฉ์ ์ง๋ณ ๋ฐ์คํฌ ์๋ด |
KisBrokerAdapter |
Broker ๊ณ์ฝ โ KisApiClient ๋ณํ (์ ์ํยทrt_cd ํด์) |
๋ฏธ๊ตญ ๋ฐ์คํฌ |
BinanceBrokerAdapter |
Broker ๊ณ์ฝ โ BinanceApiClient ๋ณํ (ํํฐ ์ ์ญยทFUTURES ์ฐจ๋จ) |
์ฝ์ธ ๋ฐ์คํฌ |
KisApiClient |
KIS OpenAPI ์ค์ ํธ์ถ(ํ ํฐยท์ฃผ๋ฌธยท์๊ณ ยท์์ธ). ์์ ๊ธ์ง ์๋ณธ | ๋ฏธ๊ตญ ์ก๊ธ๋ง ๋จ๋ง |
BinanceApiClient |
Binance REST ์ค์ ํธ์ถ(HMAC ์๋ช ยท์ฃผ๋ฌธยท์๊ณ ). ์์ ๊ธ์ง ์๋ณธ | ์ฝ์ธ ์ก๊ธ๋ง ๋จ๋ง |
ProposalExecutionService |
๋ชจ๋ ์ฃผ๋ฌธ์ด ๋ฐ๋์ ์ง๋๋ ๋จ์ผ ๊ฒฝ๋ก + ์์ ๊ฒ์ดํธ ์ ๋ถ | ์ฐฝ๊ตฌ ์ฌ์ฌ ๋ฐ์คํฌ |
OrderFillService |
์ ์๋ ์ฃผ๋ฌธ์ ์ค์ ์ฒด๊ฒฐ ์ํ๋ฅผ ๋์ค์ ํ์ธ | ์ก๊ธ ๋์ฐฉ ํ์ธ |
TradingControlService |
์ ์ญ kill-switch(์ฌ์์ ์์ด ์ ์ฃผ๋ฌธ ์ฐจ๋จ) | ๋น์ ์ ์ง ๋ฒํผ |
PromotionGateService |
MOCK โ REAL ์น๊ฒฉ ๊ฒ์ดํธ(๋ชจ์๋ก ์ถฉ๋ถํ ๊ฒ์ฆํ๋) | ์ค๊ฑฐ๋ ๋ฉดํ ์ํ |
๋๊ฐ ์ด ํํธ๋ฅผ ํธ์ถํ๋? โ โ ์ฌ์ฉ์๊ฐ ํ๋ฉด์์ "์น์ธ" ๋ฒํผ์ ๋๋ฅด๋ฉด OrderProposalController.approve โ ProposalExecutionService.execute. โก ์ฌ์ฉ์๊ฐ ์ง์ ์ฃผ๋ฌธ ํผ์ ๋ฃ์ผ๋ฉด BrokerOrderController.place. โข ์๊ทธ๋ ์์ง(DailySignalGenerator)์ด ๋ง๋ ์๋ ์ ์๋ ๊ฐ์ ProposalExecutionService.execute๋ก ๋ชจ์
๋๋ค. ์ฆ ๋ชจ๋ ๊ธธ์ ํ ๋ฐ์คํฌ๋ก ๋ชจ์
๋๋ค.
๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
1) ๋ธ๋ก์ปค(Broker) = "์ฃผ๋ฌธ์ ๋ฐ์ ์ค์ ์์ฅ์ ๋ฃ์ด์ฃผ๋ ์ฆ๊ถ์ฌ"
- ์ฐ๋ฆฌ๊ฐ "SPY 1์ฃผ ์ฌ์ค"๋ผ๊ณ ํด๋, ์ง์ง ์ฃผ์์์ฅ์ ์ฃผ๋ฌธ์ ๋ฃ๋ ๊ฑด ์ฆ๊ถ์ฌ์ ๋๋ค. KISยทBinance๊ฐ ๊ทธ ์ฆ๊ถ์ฌ ์ญํ .
- ๊ฐ ๋ธ๋ก์ปค๋ API ํค(์ ๋ถ์ฆ)๋ก ์ฐ๋ฆฌ๋ฅผ ์ธ์ฆํฉ๋๋ค. KIS๋
appkey+appsecret, Binance๋apiKey+apiSecret.
2) OrderProposal(์ฃผ๋ฌธ ์ ์) = "์์ง ์คํ ์ ๋ ์ฃผ๋ฌธ ์ด์"
- ์๊ทธ๋ ์์ง์ด๋ ์ฌ์ฉ์๊ฐ "์ด๊ฑฐ ์ฌ๋ฉด ์ด๋?"๋ผ๊ณ ๋ง๋ ์ด์์ ๋๋ค. ๋ง๋ ๋ค๊ณ ๋ฐ๋ก ๋์ด ๋๊ฐ์ง ์์ต๋๋ค.
- ์๋ช
์ฃผ๊ธฐ:
PENDING(๋๊ธฐ) โAPPROVED(์น์ธ) โEXECUTED(์ ์๋จ) ๋๋EXEC_FAILED(์คํจ) /REJECTED(๊ฑฐ์ ) /EXPIRED(๋ง๋ฃ). - ํต์ฌ ๋ณด์ ์์น: ์๊ทธ๋์ PENDING๊น์ง๋ง ์๋ ์์ฑ,
EXECUTED๋ก ๊ฐ๋ ค๋ฉด ์์ ๊ฒ์ดํธ๋ฅผ ์ ๋ถ ํต๊ณผํด์ผ ํจ.
3) MOCK vs REAL = "๋ชจ์ํฌ์ vs ์ง์ง ๋"
MOCK= KIS ๋ชจ์ํฌ์ / Binance ํ ์คํธ๋ท. ๊ฐ์ง ๋, ์ฐ์ต์ฉ. ์์ ๋กญ๊ฒ ์ผ๊ณ ๋.REAL= KIS ์ค์ / Binance ๋ฉ์ธ๋ท. ์ง์ง ๋์ด ๋๊ฐ. ๊ทธ๋์ ์ผ๊ธฐ ์ ์ ์กธ์ ์ํ(์น๊ฒฉ ๊ฒ์ดํธ)์ ํต๊ณผํด์ผ ํจ.- ๊ฐ์
env์ด๋ฆ์ด ๋ ๋ธ๋ก์ปค์์ ์๋ฏธ๊ฐ ๋ค๋ฆ ๋๋ค โ KIS๋ ๋๋ฉ์ธ(URL)์ด ๊ฐ๋ฆฌ๊ณ , Binance๋ ํธ์คํธ๊ฐ ๊ฐ๋ฆผ.
4) kill-switch(์ ์ญ ๊ฑฐ๋ ์ฐจ๋จ ์ค์์น)
- "์ง๊ธ ๋น์ฅ ๋ชจ๋ ์ฃผ๋ฌธ์ ๋ง์๋ผ" ๋ฒํผ. ์ด์ ์ค ๋ญ๊ฐ ์๋ชป๋์ ๋ ์ฌ์์ ์์ด ๋ชจ๋ ์ฃผ๋ฌธ์ ์ฆ์ ๊ฑฐ๋ถ.
- ํ๊ฒฝ๋ณ์
TRADING_KILL_SWITCH=true๊ฐ ๊ธฐ๋ณธ๊ฐ์ด๊ณ ,TradingControlService๊ฐ ๋ฐํ์์ ๋ฎ์ด์ธ ์ ์์.
5) ์ด๋ํฐ ํจํด(Adapter Pattern)
- "๋ชจ์์ด ๋ค๋ฅธ ๋ ๋ฌผ๊ฑด์ ๊ฐ์ ์ฝ์ผํธ์ ๊ฝ๊ฒ ํด์ฃผ๋ ๋ณํ ํ๋ฌ๊ทธ".
KisApiClient/BinanceApiClient๋ ๊ฐ์ ์๊ธด ๋๋ก ๋๊ณ (์๋ณธ ์์ ๊ธ์ง), ๊ทธ ์์ ์์ ์ด๋ํฐ๋ฅผ ์์ ๋ ๋คBroker์ธํฐํ์ด์ค ๋ชจ์์ผ๋ก ๋ณด์ด๊ฒ ํฉ๋๋ค. ์๋จ์Broker๋ง ์๋ฉด ๋ฉ๋๋ค.
6) HMAC ์๋ช / ์น์ธ TTL
- HMAC: ๋น๋ฐํค๋ก ๋ง๋ "์์กฐ ๋ถ๊ฐ ๋์ฅ". Binance๋ ๋ชจ๋ ์ฌ์ ์์ฒญ์
HMAC-SHA256(์ฟผ๋ฆฌ, secret)์๋ช ์ ๋ถ์ฌ "์ด ์์ฒญ์ด ์ง์ง ํค ์ฃผ์ธ์ด ๋ณด๋์"์ ์ฆ๋ช ํฉ๋๋ค(secret ์์ฒด๋ ๋คํธ์ํฌ๋ก ์ ๋ณด๋). - TTL(๋ง๋ฃ): OrderProposal์ ๋ณดํต ์์ฑ +24์๊ฐ ํ ๋ง๋ฃ(
expiresAt).OrderProposalExpiryJob์ด 5๋ถ๋ง๋ค ์ง๋ ๊ฒ์EXPIRED๋ก ์ ๋ฆฌํด, ์ค๋๋ ์ด์์ด ์ค์๋ก ์คํ๋๋ ๊ฒ์ ๋ง์ต๋๋ค.
๐บ ์์ฒญ ํ๋ฆ๋
โ ์๋ ์๊ทธ๋ โก ์๋ ์น์ธ โข ์ง์ ์ฃผ๋ฌธ
DailySignalGenerator ์ฌ์ฉ์ "์น์ธ" ํด๋ฆญ ์ฌ์ฉ์ ์ฃผ๋ฌธ ํผ
โ PENDING ์์ฑ โ โ
โผ โผ โผ
OrderProposal(PENDING) โโโบ OrderProposalController.approve BrokerOrderController.place
โ โ (ํ๋/๊ฒ์ฆ ๊ฐ๋ ์ง์ ์ํ)
โผ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ ProposalExecutionService.execute โโโ ๋จ์ผ ๊ฒฝ๋ก โโ โ
โ โ ์์ ๊ฒ์ดํธ 6์ค โ โ โ โ
โ 1. kill-switch โ โ โ
โ 2. PENDINGยท๋ง๋ฃ ๊ฒ์ฆ โ โ โ (place ๋ ์์ฒด ๊ฐ๋ ํ
โ 3. tradingEnabled ๋ง์คํฐ ์ค์์น โ โ โ ์ง์ BrokerRouter ํธ์ถ)
โ 4. 1๊ฑด๋นยท์ผ์ผ USD ํ๋ โ โ โ
โ 5. KIS KRW ๋งค์/๋งค๋ ํ๋ โ โ โ
โ 6. ์์ค ์ํท๋ธ๋ ์ด์ปค โ โ โ
โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ โ โ
โ ํต๊ณผ โ โ
โผ โผ โผ
โโโโโโโโโโโโโโโโโ
โ BrokerRouter โ brokerType ์ผ๋ก ๋ถ๊ธฐ
โโโโโโโโโฌโโโโโโโโ
KIS โ โ BINANCE
โผ โผ
KisBrokerAdapter BinanceBrokerAdapter
ยท ์๋ ์ ์ํ ยท LOT_SIZE/NOTIONAL ์ ์ญ
ยท 0์ ์ง์ ๊ฐ ๋ฐฉ์ง ยท FUTURES ์ฐจ๋จ
ยท rt_cd โ ๊ฒฐ๊ณผ ยท kill-switch ์ฌํ์ธ
โ โ
โผ โผ
KisApiClient BinanceApiClient
ยท ํ ํฐ ์บ์(23h) ยท HMAC-SHA256 ์๋ช
ยท ๋ธ๋ผ์ฐ์ UA ยท X-MBX-APIKEY ํค๋
ยท ๊ฑฐ๋์ ํด๋ฐฑ ยท 429/418 ๋ฐฑ์คํ
โ โ
โผ โผ
๐บ๐ธ KIS ์ค์ฃผ๋ฌธ ๐ช Binance ์ค์ฃผ๋ฌธ
โ โ
โโโโโโโโฌโโโโโโโ
โผ status=EXECUTED, kisOrderNo ์ ์ฅ
(๋์ค์ ์ฃผ๊ธฐ์ ์ผ๋ก)
OrderFillPollingJob โ OrderFillService.pollFill
โ broker.queryFill()
โผ
fillStatus = FILLED/PARTIAL/OPEN/CANCELLED
+ ์๊ณ ์ค๋
์ท ๋๊ธฐํ(lastBalanceJson)ํต์ฌ: โก์ โข์ ๋ค๋ฅธ ์
๊ตฌ์ง๋ง, โก๋ ๋ฐ๋์ ProposalExecutionService๋ฅผ ๊ฑฐ์น๊ณ โข(place)์ ๊ฐ์ ํ๋ ์ ์ฑ
๋ฉ์๋๋ฅผ ์ฌ์ฌ์ฉํฉ๋๋ค. ๋ ๊ฒฝ๋ก์ ์ ์ฑ
์ด ์ด๊ธ๋์ง ์๋๋ก ์ผ๋ถ๋ฌ ์ฝ๋๋ฅผ ๊ณต์ ํฉ๋๋ค(๋ค์ ํจ์ ์น์
์ฐธ๊ณ ).
๐ ํต์ฌ ํด๋์ค ๋ผ์ธ๋ณ ์ฌํ
A. Broker โ ๋ชจ๋ ์ฆ๊ถ์ฌ์ ๊ณตํต ๊ณ์ฝ (Broker.java:20-61)
// Broker.java:20-24
public interface Broker {
BrokerAccount.BrokerType type();
enum Side { BUY, SELL }
- ๋ฌด์์ ํ๋: "์ฃผ๋ฌธ์ ๋ฐ๋ ์ฆ๊ถ์ฌ๋ผ๋ฉด ๋ฐ๋์ ์ด ๋ฉ์๋๋ค์ ๊ตฌํํ๋ผ"๋ ์ฝ์(์ธํฐํ์ด์ค).
type()์ ์๊ธฐ๊ฐ KIS์ธ์ง BINANCE์ธ์ง ์๋ ค์ค๋๋ค โBrokerRouter๊ฐ ์ด๊ฑธ๋ก ๋ฑ๋กํฉ๋๋ค. - ์ ์ด๋ ๊ฒ ํ๋: ์๋จ ์ฝ๋(
ProposalExecutionService๋ฑ)๊ฐif (KIS) ... else if (BINANCE) ...๋ถ๊ธฐ๋ฅผ ๊ณณ๊ณณ์ ๋๋ฉด ์ ๋ธ๋ก์ปค ์ถ๊ฐ ๋๋ง๋ค ์ง์ฅ. ๋์ "๊ณ์ฝ"๋ง ์๋ฉด ๊ตฌํ์ด ๋ฌด์์ด๋ ๋๊ฐ์ด ๋ค๋ฃน๋๋ค(๋คํ์ฑ).
// Broker.java:26-44
record OrderResult(boolean ok, String orderNo, String code, String message) {
public static OrderResult success(String orderNo, String code) { ... }
public static OrderResult failure(String code, String message) { ... }
}
record FillResult(String fillStatus, BigDecimal filledQty, BigDecimal avgPrice, String error) { ... }
- ๋ฌด์์ ํ๋: ์ฃผ๋ฌธ ๊ฒฐ๊ณผ(
OrderResult)์ ์ฒด๊ฒฐ ์กฐํ ๊ฒฐ๊ณผ(FillResult)๋ฅผ ์ ๊ทํ๋ ํ ๊ฐ์ง ๋ชจ์์ผ๋ก ์ ์. KIS์rt_cd๋ Binance์status๋ , ์ด๋ํฐ๊ฐ ์ด ๋ชจ์์ผ๋ก ๋ฒ์ญํด ๋๋ ค์ค๋๋ค. - ์ด๋ณด ํฌ์ธํธ:
record๋ "๊ฐ๋ง ๋ด๋ ๋ถ๋ณ ์์"๋ฅผ ์งง๊ฒ ๋ง๋๋ ์๋ฐ ๋ฌธ๋ฒ.success(...)/failure(...)๋ ์ ์ ํฉํ ๋ฆฌ โnew OrderResult(true, ..., null)๋์ ์๋ฏธ๊ฐ ๋๋ ทํOrderResult.success(...)๋ก ๋ง๋ค๊ฒ ํด์ค๋๋ค.
// Broker.java:46-60
OrderResult placeOrder(BrokerAccount account, String symbol, Side side, BigDecimal qty, BigDecimal limitPrice);
FillResult queryFill(BrokerAccount account, OrderProposal proposal);
Map<String, Object> getBalance(BrokerAccount account);
Map<String, Object> getQuote(BrokerAccount account, String symbol);
- 4๊ฐ ๋ฉ์๋ = ์ฆ๊ถ์ฌ๊ฐ ํ ์ ์๋ ์ผ ์ ๋ถ: ์ฃผ๋ฌธ ์ ์ก / ์ฒด๊ฒฐ ์กฐํ / ์๊ณ / ํ์ฌ๊ฐ.
- ์ฃผ์(
Broker.java:46)์ ํต์ฌ ํ ์ค: *"ํธ์ถ์ธก์ด ๋ชจ๋ ์์ ๊ฒ์ดํธ(kill-switch/ํ๋/๊ฒ์ฆ)๋ฅผ ๋จผ์ ํต๊ณผ์์ผ์ผ ํ๋ค."* โ ์ธํฐํ์ด์ค ์์ฒด๋ ์์ ์ฅ์น๋ฅผ ๋ชจ๋ฆ. ์์ ์ProposalExecutionService์ ์ฑ ์. (์ด๋ํฐ์์ kill-switch๋ฅผ ํ ๋ฒ ๋ ๋ณด๋ ๊ฒ์ ์ด์ค ๋ฐฉ์ด์ผ ๋ฟ.) limitPrice == null์ด๋ฉด ์์ฅ๊ฐ๋ผ๋ ์ฝ์(Broker.java:18)๋ ์ฌ๊ธฐ์ ์ ์๋ฉ๋๋ค.
B. BrokerRouter โ ๋จ์ผ ๋ถ๊ธฐ์ (BrokerRouter.java:16-34)
// BrokerRouter.java:18-23
private final Map<BrokerAccount.BrokerType, Broker> registry =
new EnumMap<>(BrokerAccount.BrokerType.class);
public BrokerRouter(List<Broker> brokers) {
for (Broker b : brokers) registry.put(b.type(), b);
}
- ๋ฌด์์ ํ๋: ์คํ๋ง์ด ๋ฑ๋ก๋ ๋ชจ๋
Broker๋น(KISยทBinance ์ด๋ํฐ)์ ์์ฑ์์ ๋ฆฌ์คํธ๋ก ์ฃผ์ ํฉ๋๋ค. ๋ผ์ฐํฐ๋ ๊ฐ์์type()์ ํค๋กEnumMap์ ๊ฝ์ "ํ์ โ ๊ตฌํ" ์์ธ์ ๋ง๋ญ๋๋ค. - ์ ์ด๋ ๊ฒ ํ๋: ์ ๋ธ๋ก์ปค(
Broker๊ตฌํ +@Component)๋ฅผ ์ถ๊ฐํ๋ฉด ์ด ํด๋์ค๋ฅผ ์๋์ง ์์๋ ์๋ ๋ฑ๋ก๋ฉ๋๋ค. ํ์ฅ์ ์ด๋ ค ์๊ณ ์์ ์ ๋ซํ ๊ตฌ์กฐ.
// BrokerRouter.java:25-33
public Broker forAccount(BrokerAccount account) {
return forType(account == null ? null : account.getBrokerType());
}
public Broker forType(BrokerAccount.BrokerType type) {
Broker b = registry.get(type == null ? BrokerAccount.BrokerType.KIS : type);
if (b == null) throw new IllegalStateException("์ง์ํ์ง ์๋ ๋ธ๋ก์ปค: " + type);
return b;
}
- ๊ณ์ ์ ์ฃผ๋ฉด ๊ทธ ๊ณ์ ์
brokerType์ ๋ง๋ ๊ตฌํ์ ๋๋ ค์ค๋๋ค.null์ด๋ฉด KIS๋ก ํด๋ฐฑ โ ์ ๋ฐ์ดํฐ(brokerType ์ปฌ๋ผ์ด ์๋ ์์ )์์ ํ์ํธํ. - ๋ชจ๋ฅด๋ ํ์ ์ด๋ฉด ์ฆ์ ์์ธ โ "์กฐ์ฉํ ์๋ฑํ ๊ณณ์ ์ฃผ๋ฌธ ๋ฃ๊ธฐ"๋ณด๋ค ๋ช ํํ ์คํจ๊ฐ ๋ซ์ต๋๋ค.
C. ProposalExecutionService.execute โ ์์ ๊ฒ์ดํธ์ ์ฌ์ฅ (ProposalExecutionService.java:54-139)
์ด ๋ฉ์๋๊ฐ ์ด ํํธ์์ ๊ฐ์ฅ ์ค์ํฉ๋๋ค. ๋ชจ๋ ์ฃผ๋ฌธ(์๋/์๋)์ด ์ฌ๊ธฐ๋ฅผ ํต๊ณผํ๋ฏ๋ก, ์์ ๊ฒ์ดํธ๊ฐ ํ ๊ณณ์ ๋ชจ์ฌ ๋๋ฝ์ด ์์ต๋๋ค.
// ProposalExecutionService.java:54-59
@Transactional
public Result execute(OrderProposal p, BrokerAccount ba, boolean auto) {
if (tradingControl.isKillSwitchOn()) {
log.warn("[exec] kill-switch ON โ ์ฃผ๋ฌธ ๊ฑฐ๋ถ proposal={}", p.getId());
return new Result(false, "์ ์ญ ๊ฑฐ๋ ์ฐจ๋จ(kill-switch) ํ์ฑํ โ ๋ชจ๋ ์ฃผ๋ฌธ ๊ฑฐ๋ถ", p);
}
- ๊ฒ์ดํธ โ : kill-switch. ๊ฐ์ฅ ๋จผ์ ๋ณธ๋ค. ์ผ์ ธ ์์ผ๋ฉด ๊ทธ ๋ฌด์๋ ํต๊ณผ ๋ชป ํจ.
auto์ธ์๋ "์๋ ์ฒด๊ฒฐ ์ฌ๋ถ" โ ๋์ค์autoExecutedํ๋๊ทธ๋ก ๊ธฐ๋ก(์น๊ฒฉ ๊ฒ์ดํธ ์ง๊ณ์ ์ฐ์).
// ProposalExecutionService.java:60-71
if (!"PENDING".equals(p.getStatus())) {
return new Result(false, "PENDING ์ํ๊ฐ ์๋ (ํ์ฌ=" + p.getStatus() + ")", p);
}
if (p.getExpiresAt() != null && p.getExpiresAt().isBefore(LocalDateTime.now())) {
p.setStatus("EXPIRED");
proposalRepo.save(p);
return new Result(false, "์ด๋ฏธ ๋ง๋ฃ๋จ", p);
}
if (ba == null) return new Result(false, "BrokerAccount ์์", p);
if (!Boolean.TRUE.equals(ba.getTradingEnabled())) {
return new Result(false, "BrokerAccount.tradingEnabled=false โ ์๋๋งค๋งค ๋ง์คํฐ ์ค์์น OFF", p);
}
- ๊ฒ์ดํธ โก: ์ํยท๋ง๋ฃ. ์ด๋ฏธ ์ฒ๋ฆฌ๋๊ฑฐ๋(EXECUTED/REJECTED) ๋ง๋ฃ๋ ์ ์์ ์ฌ์คํ ๋ถ๊ฐ. ๋ง๋ฃ๋ฅผ ๋ฐ๊ฒฌํ๋ฉด ๊ทธ ์๋ฆฌ์์
EXPIRED๋ก ๊ตณํ ์ ํฉ์ฑ ์ ์ง. - ๊ฒ์ดํธ โข: ๋ง์คํฐ ์ค์์น.
tradingEnabled=false๋ฉด ๋. ์ฌ์ฉ์๊ฐ ์ง์ ๋ ์ ์๋ "์ด ๊ณ์ข์ ๋งค๋งค ์ผ์ง/๊บผ์ง" ํฐ ์ค์์น(BrokerAccount.java:108-111). - ์ด๋ณด ํฌ์ธํธ:
Boolean.TRUE.equals(x)๋x๊ฐnull์ด์ด๋ ์์ ํ๊ฒ false๋ฅผ ๋ฐํํฉ๋๋ค.x == true(์ธ๋ฐ์ฑ)๋null์ด๋ฉด NPE.
// ProposalExecutionService.java:73-89
Broker broker = brokerRouter.forAccount(ba);
BigDecimal qtyEff = effectiveQty(p);
// 1๊ฑด๋น ํ๋ (์์ฅ๊ฐ๋ ํ์ฌ๊ฐ๋ก ์ถ์ โ ์์ฅ๊ฐ ํ๋์ฐํ ๋ฐฉ์ง)
double estUsd = estimateUsd(broker, ba, p, qtyEff);
if (ba.getMaxOrderUsd() != null && ba.getMaxOrderUsd() > 0 && estUsd > ba.getMaxOrderUsd()) {
return new Result(false, "1๊ฑด๋น ํ๋(USD " + ba.getMaxOrderUsd() + ") ์ด๊ณผ: ์์ " + estUsd, p);
}
// ์ผ์ผ ๋์ ํ๋
if (ba.getDailyOrderUsd() != null && ba.getDailyOrderUsd() > 0) {
BigDecimal todaySum = proposalRepo.sumExecutedUsdSince(p.getUserId(), LocalDate.now().atStartOfDay());
double todayTotal = todaySum == null ? 0.0 : todaySum.doubleValue();
if (todayTotal + estUsd > ba.getDailyOrderUsd()) {
return new Result(false, "์ผ์ผ ๋์ ํ๋(USD " + ba.getDailyOrderUsd() + ") ์ด๊ณผ: ...", p);
}
}
- ๊ฒ์ดํธ โฃ: ๊ธ์ก ํ๋(USD). ํ ๊ฑด๋น(
maxOrderUsd)๊ณผ ํ๋ฃจ ๋์ (dailyOrderUsd) ๋ ์ข ๋ฅ. - ์์ฅ๊ฐ ํ๋ ์ฐํ ๋ฐฉ์ง๊ฐ ํต์ฌ: ์์ฅ๊ฐ๋
limitPrice๊ฐ ์์ด ๊ธ์ก์ ๋ชจ๋ฆ ๋๋ค. ๊ทธ๋์estimateUsd(์๋)๊ฐ ํ์ฌ๊ฐ๋ฅผ ์กฐํํด ์ถ์ ํฉ๋๋ค โ ์ ํ๋ฉด "์ง์ ๊ฐ๋ ๋งํ๋๋ฐ ์์ฅ๊ฐ๋ก๋ ๋ฌด์ ํ"์ด ๋๋ ๊ตฌ๋ฉ์ด ์๊น๋๋ค. qtyEff(effectiveQty,ProposalExecutionService.java:142-145): ํฌ๋ฆฝํ ๋ถ์(qtyDecimal)๊ฐ ์์ผ๋ฉด ์ฐ์ , ์์ผ๋ฉด ์ ์qty. KIS๋ ์ ์ ์ฃผ์, Binance๋ 0.0015 BTC ๊ฐ์ ๋ถ์๋ผ์.- ์ผ์ผ ๋์ ์
sumExecutedUsdSince๋ก ์ค๋ ์์ ์ดํ EXECUTED ์ฃผ๋ฌธ์ USD ํฉ์ DB์์ ๊ณ์ฐ(OrderProposalRepository.java:30-34: ์๋ รlimitPrice์ฐ์ , ์์ผ๋ฉดfillAvgPrice).
// ProposalExecutionService.java:91-94
String krwViol = krwDailyLimitViolation(proposalRepo, ba, p.getSide(), p.getUserId(), estUsd);
if (krwViol != null) return new Result(false, krwViol, p);
- ๊ฒ์ดํธ โค: KIS KRW ๋งค์/๋งค๋ ํ๋. KIS ๊ณ์ ์ ์ํ ํ๋(
dailyBuyKrw/dailySellKrw)๊ฐ USD ํ๋๋ณด๋ค ์ฐ์ .krwDailyLimitViolation(ProposalExecutionService.java:153-169)์ด public static์ธ ์ด์ : ์๋ ์ฃผ๋ฌธ ๊ฒฝ๋ก(BrokerOrderController.place)๊ฐ ๊ฐ์ ๋ฉ์๋๋ฅผ ์ฌ์ฌ์ฉํด ๋ ๊ฒฝ๋ก์ ์ ์ฑ ์ ์ผ์น์ํค๊ธฐ ์ํจ์ ๋๋ค. ์ฃผ์(ProposalExecutionService.java:91-92)์ *"์ค์ ๋ง ๋๊ณ ๋ ์ฃผ๋ฌธ ๊ฒฝ๋ก ์ด๋์๋ ์งํ๋์ง ์๋ dead ํ๋์๋ค"* โ ๊ณผ๊ฑฐ์ ํ์ชฝ ๊ฒฝ๋ก์์ ๋น ์ ธ ์ฐํ๋๋ ๋ฒ๊ทธ๋ฅผ ๊ณ ์น ํ์ . - USD๋ฅผ KRW๋ก ๋ฐ๊ฟ ๋
BrokerAccount.USD_KRW_APPROX = 1300.0(BrokerAccount.java:31) ๊ทผ์ฌ ํ์จ ์ฌ์ฉ.
// ProposalExecutionService.java:96-103
if ("BUY".equals(p.getSide()) && ba.getDailyLossLimitUsd() != null && ba.getDailyLossLimitUsd() > 0) {
Double pnl = totalUnrealizedPnl(ba);
if (pnl != null && pnl < -ba.getDailyLossLimitUsd()) {
return new Result(false, "์์ค ํ๋ ์ํท๋ธ๋ ์ด์ปค: ๋ฏธ์คํ ์์ค " + Math.round(-pnl)
+ " USD ๊ฐ ํ๋(" + ba.getDailyLossLimitUsd() + " USD) ์ด๊ณผ โ ์ ๊ท ๋งค์ ์ฐจ๋จ", p);
}
}
- ๊ฒ์ดํธ โฅ: ์์ค ์ํท๋ธ๋ ์ด์ปค. ๋งค์์๋ง ์ ์ฉ. ๋ง์ง๋ง ์๊ณ ์ค๋
์ท(
lastBalanceJson)์ ๋ฏธ์คํ ์ด์์ต(totalUnrealizedPnl,ProposalExecutionService.java:209-217โtotal_market_value_usdํค)์ด ํ๋๋ณด๋ค ๋ ํฐ ์์ค์ด๋ฉด ์ถ๊ฐ ๋งค์๋ฅผ ๋ง์ ๋ฌผํ๊ธฐ ํญ์ฃผ๋ฅผ ์ฐจ๋จ.
// ProposalExecutionService.java:105-130
p.setStatus("APPROVED");
p.setDecidedAt(LocalDateTime.now());
p.setAutoExecuted(auto);
proposalRepo.save(p);
try {
Broker.Side side = "BUY".equals(p.getSide()) ? Broker.Side.BUY : Broker.Side.SELL;
Broker.OrderResult res = broker.placeOrder(ba, p.getTicker(), side, qtyEff, p.getLimitPrice());
if (!res.ok()) {
p.setStatus("EXEC_FAILED");
p.setExecError(...);
proposalRepo.save(p);
recordLog(...); recordAudit(..., "EXEC_FAILED", ...);
return new Result(false, res.message(), p);
}
p.setStatus("EXECUTED");
p.setExecutedAt(LocalDateTime.now());
p.setKisOrderNo(res.orderNo());
proposalRepo.save(p);
recordLog(...); recordAudit(..., "EXECUTED", ...);
return new Result(true, null, p);
}
- ๋ชจ๋ ๊ฒ์ดํธ ํต๊ณผ ํ์์ผ
APPROVED๋ก ํ์ํ๊ณ ์ค์ ๋กbroker.placeOrder๋ฅผ ํธ์ถ. ์ฌ๊ธฐ์๋ถํฐ ์ง์ง ๋์ด ์์ง์ ๋๋ค. - ์ฑ๊ณต์ด๋ฉด
EXECUTED+ ๋ธ๋ก์ปค ์ฃผ๋ฌธ๋ฒํธ๋ฅผkisOrderNo์ ์ ์ฅ(์ด๋ฆ์ kis์ง๋ง Binance orderId๋ ์ฌ๊ธฐ ๋ค์ด๊ฐ). ์คํจ๋ฉดEXEC_FAILED+ ์๋ฌ ๊ธฐ๋ก. - ๊ฐ์ฌ ๋ก๊ทธ(
recordAudit,ProposalExecutionService.java:189-206): ์ค์ ๋ก ์์ฅ์ ๋๊ฐ ๋ชจ๋ ์๋(์ฑ๊ณต/์คํจ)๋ฅผOrderExecutionAudit์ ๋ถ๋ณ ๊ธฐ๋ก. best-effort(๊ธฐ๋ก ์คํจ๊ฐ ์ฃผ๋ฌธ์ ๋ง์ง ์์). ํฌ๋ฆฝํ ๋ถ์ ์๋์ ์ ์ audit ํ๋๋ก ํํ ๋ถ๊ฐ๋ผdetail์ ํ์ด ์ ์ต๋๋ค.
// ProposalExecutionService.java:171-186 (estimateUsd)
private double estimateUsd(Broker broker, BrokerAccount ba, OrderProposal p, BigDecimal qtyEff) {
double price;
if (p.getLimitPrice() != null) {
price = p.getLimitPrice().doubleValue();
} else {
try {
Map<String, Object> q = broker.getQuote(ba, p.getTicker());
Object lp = q.get("last_price");
price = lp instanceof Number n ? n.doubleValue() : Double.parseDouble(String.valueOf(lp));
} catch (Exception e) { price = 0.0; }
}
return qtyEff.doubleValue() * price;
}
- ์ง์ ๊ฐ๋ฉด ๊ทธ ๊ฐ, ์์ฅ๊ฐ๋ฉด ํ์ฌ๊ฐ ์กฐํ๋ก ์ถ์ (์คํจ ์ 0 โ ๊ธฐ์กด ๋์ ์ ์ง). ์ ๊ฒ์ดํธ โฃ/โค๊ฐ ์์ฅ๊ฐ๋ ๋ง์ ์ ์๊ฒ ํด์ฃผ๋ ๋ถ๋ถ.
D. KisApiClient โ KIS ์ค์ ํธ์ถ (ํต์ฌ๋ง)
์๋ณธ(671์ค)์ ์์ ๊ธ์ง ํด๋์ค์ ๋๋ค. ์๋ช ยทUAยทenv ๋ถ๊ธฐ ์ธ ๊ฐ์ง๊ฐ ํต์ฌ.
env ๋ถ๊ธฐ: ๋๋ฉ์ธ์ด ๊ฐ๋ฆฐ๋ค (KisApiClient.java:47-114)
// KisApiClient.java:47-48, 112-114
private static final String MOCK_HOST = "https://openapivts.koreainvestment.com:29443";
private static final String REAL_HOST = "https://openapi.koreainvestment.com:9443";
private String host(BrokerAccount.Env env) {
return env == BrokerAccount.Env.REAL ? REAL_HOST : MOCK_HOST;
}
- env=REAL์ด๋ฉด ์ค์ ๋๋ฉ์ธ, MOCK์ด๋ฉด ๋ชจ์ ๋๋ฉ์ธ. ๊ฐ์ ์ฝ๋๊ฐ ๊ณ์ ์
envํ๋๋ก ๋ชจ์/์ค์ ์ ๊ฐ๋ฆ ๋๋ค. TR-ID๋ ๊ฐ์ ๋ฐฉ์์ผ๋ก ๊ฐ๋ฆผ(์: ๋งค์ ์ค์ TTTT1002U/ ๋ชจ์VTTT1002U,KisApiClient.java:484).
ํ ํฐ ์บ์ + ๋์์ฑ ๋ฝ (KisApiClient.java:170-269)
// KisApiClient.java:170-178
public String getAccessToken(BrokerAccount b) {
String key = cacheKey(b); // userId + ":" + env
CachedToken cached = tokenCache.get(key);
if (cached != null && cached.valid()) return cached.token;
synchronized (tokenLockFor(key)) {
CachedToken again = tokenCache.get(key); // double-check
if (again != null && again.valid()) return again.token;
- ์ ๋ฝ์ด ํ์: KIS๋ ํ ํฐ ๋ฐ๊ธ์ 1๋ถ 1ํ๋ก ์ ํ. ๋์ ์์ฒญ์ด ๋ชฐ๋ฆฌ๋ฉด ์ฌ๋ฌ ๋ฒ ๋ฐ๊ธ์ ์๋ํ๋ค ์ฐจ๋จ๋ฉ๋๋ค.
synchronized+ double-check๋ก ํ ๋ฒ๋ง ๋ฐ๊ธํ๊ณ ๋๋จธ์ง๋ ์บ์๋ฅผ ์๋๋ค. ํ ํฐ์ 23์๊ฐ ์ ํจ(CachedToken.valid()๋ ๋ง๋ฃ 5๋ถ ์ ๋ถํฐ false๋ก ๋ณด์ ๋ฏธ๋ฆฌ ๊ฐฑ์ ,KisApiClient.java:108-110).
UA(User-Agent) โ ๋ฉ๋ชจ๋ฆฌ์ ๋ฐํ ๊ตํ (KisApiClient.java:198-226)
// KisApiClient.java:222-226
// ํค๋: ๊ณต์ Python ์ํ๊ณผ ๋์ผ + PowerShell ์คํ์ผ User-Agent.
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "text/plain");
conn.setRequestProperty("charset", "UTF-8");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 alpha-helix/1.0");
- ํต์ฌ ํจ์ : Spring
RestClient๊ฐ ๊ธฐ๋ณธ์ผ๋ก ๋ถ์ด๋User-Agent: Java-http-client/...๋ฅผ KIS ๊ฒ์ดํธ์จ์ด๊ฐ ์๋ํ ํธ๋ํฝ์ผ๋ก ๋ณด๊ณ 403 + EGW00002๋ก ์ฆ์ ๊ฑฐ๋ถํฉ๋๋ค(ํค/IP ๊ฒ์ฆ ์ด์ ๋จ๊ณ). ๊ทธ๋์ ํ ํฐ ๋ฐ๊ธ์RestClient๊ฐ ์๋๋ผ JDKHttpURLConnection์ผ๋ก ๋ด๋ ค๊ฐ ๋ธ๋ผ์ฐ์ UA๋ฅผ ๋ช ์(KisApiClient.java:215-226). WebSocket ์น์ธํค ๋ฐ๊ธ(getWsApprovalKey,KisApiClient.java:621-669)๋ ๊ฐ์ ์ด์ ๋ก ๋์ผ ํจํด. - ์ถ๊ฐ ํจ์ : KIS GW๋ chunked ์ ์ก๋ EGW00202๋ก ๊ฑฐ๋ถ. ๊ทธ๋์ ๋ณธ๋ฌธ์
byte[]๋ก ์ง๋ ฌํํด Content-Length๋ฅผ ๋ช ์(jsonBytes,KisApiClient.java:146-152; ์ฃผ๋ฌธ๋KisApiClient.java:515-517).
์ฃผ๋ฌธ + ๊ฑฐ๋์ ํด๋ฐฑ (KisApiClient.java:476-550)
// KisApiClient.java:486-492
String primary = exchangeOf(ticker);
java.util.List<String> exchanges = new java.util.ArrayList<>();
exchanges.add(primary);
for (String alt : List.of("NASD", "NYSE", "AMEX")) {
if (!exchanges.contains(alt)) exchanges.add(alt);
}
- KIS ๋ฏธ๊ตญ์ฃผ์ ์ฃผ๋ฌธ์ ๊ฑฐ๋์ ์ฝ๋(NASD/NYSE/AMEX)๋ฅผ ์ ํํ ๋ณด๋ด์ผ ํฉ๋๋ค. ํ๋ฆฌ๋ฉด
EGW00202(GW ๋ผ์ฐํ ์ค๋ฅ). ํฐ์ปค๋ณ ์๋ ค์ง ๋งคํ(EXCHANGE_BY_TICKER,KisApiClient.java:56-82; ์: ๋ ๋ฒ๋ฆฌ์ง ETF SOXL์ AMEX)์ผ๋ก 1์ฐจ ์๋ํ๊ณ , ์คํจํ๋ฉด ๋ค๋ฅธ ๊ฑฐ๋์๋ก ์์ฐจ ํด๋ฐฑ(KisApiClient.java:526-537). - ์ฃผ๋ฌธ ์ง์ kill-switch๋ฅผ ์ฌ๊ธฐ์๋ ํ ๋ฒ ๋ ํ์ธ(
KisApiClient.java:479-481) โ ์ด์ค ๋ฐฉ์ด. - rate-limit(EGW00201, ์ด๋น ๊ฑฐ๋๊ฑด์ ์ด๊ณผ)์
withRateLimitRetry(KisApiClient.java:404-420)๊ฐ 1.5์ด ํ 1ํ ์ฌ์๋.
E. BinanceApiClient โ Binance ์ค์ ํธ์ถ (ํต์ฌ๋ง)
env ๋ถ๊ธฐ + ์๋ช
(BinanceApiClient.java:45-71, 116-143)
// BinanceApiClient.java:63-71
private String spotHost(BrokerAccount b) {
return b.getEnv() == BrokerAccount.Env.MOCK ? SPOT_TESTNET_HOST : SPOT_HOST;
}
private String futuresHost(BrokerAccount b) {
return FUTURES_HOST; // โ MOCK ์ด์ด๋ ์ค๊ฑฐ๋ fapi ๋ก ๋๊ฐ๋ค
}
- ํ๋ฌผ(SPOT): env=MOCK์ด๋ฉด ํ
์คํธ๋ท(
testnet.binance.vision), REAL์ด๋ฉด ๋ฉ์ธ๋ท(api.binance.com). - ํจ์ ์ฃผ์:
futuresHost๋ MOCK์ด์ด๋ ์ค๊ฑฐ๋fapi.binance.com์ ๋ฐํํฉ๋๋ค. ์ฆ ์ ๋ฌผ์ ํ ์คํธ๋ท ๋ถ๊ธฐ๊ฐ ์์ต๋๋ค โ ๊ทธ๋์ ์ด๋ํฐ์์ FUTURES ์ฃผ๋ฌธ์ ํต์งธ๋ก ์ฐจ๋จํฉ๋๋ค(์๋ G ์ฐธ๊ณ ).
// BinanceApiClient.java:116-143
private String sign(String data, String secret) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(raw);
}
private String signedQuery(Map<String, Object> params, String secret) {
params.put("timestamp", System.currentTimeMillis());
String qs = toQueryString(params);
return qs + "&signature=" + sign(qs, secret);
}
- HMAC-SHA256 ์๋ช
: ์ฟผ๋ฆฌ์คํธ๋ง ์ ์ฒด๋ฅผ secret์ผ๋ก ๋์ฅ ์ฐ์ด
&signature=...๋ฅผ ๋ถ์ ๋๋ค. secret์ ์ ๋ ์ ์กํ์ง ์๊ณ ์๋ช ์๋ง ์(BinanceApiClient.java:34). API Key๋ ํค๋X-MBX-APIKEY๋ก๋ง ์ ๋ฌ(BinanceApiClient.java:33). timestamp๊ฐ ํ์์ธ ์ด์ : Binance๋ ์๋ฒ ์๊ฐ๊ณผ ์ฐจ์ด๊ฐ ํฌ๋ฉด ๊ฑฐ๋ถ(-1021). ๊ทธ๋์ ์์คํ ์๊ณ๊ฐ ๋ง์์ผ ํจ.
rate-limit ๋ฐฑ์คํ (BinanceApiClient.java:81-109)
// BinanceApiClient.java:86-104 (์์ฝ)
if ((sc == 429 || sc == 418) && attempts < 3) {
... Retry-After ํค๋ ์กด์ค, ์ ์ฆ ๋๊ธฐ ํ ์ฌ์๋ ...
}
- HTTP 429(์์ฒญํ๋)/418(IP ์๋๋ฐด ๊ฒฝ๊ณ )์ ๋ฐ์ผ๋ฉด Retry-After๋ฅผ ์กด์คํด ์ต๋ 3ํ ์ฌ์๋. ๋ฌด์ํ๊ณ ๊ณ์ ๋๋ฆฌ๋ฉด IP๊ฐ ์ต๋ 3์ผ ๋ฐด๋๋ฏ๋ก ๋ชจ๋ ํธ์ถ์ด ์ด ๋ํผ๋ฅผ ํต๊ณผ(
BinanceApiClient.java:78-79).
๊ฑฐ๋์ ํํฐ (BinanceApiClient.java:430-487)
// BinanceApiClient.java:470-487 (์์ฝ)
public record SymbolFilters(BigDecimal stepSize, BigDecimal tickSize, BigDecimal minQty, BigDecimal minNotional) {
public BigDecimal truncateQty(BigDecimal qty) { ... stepSize ๋ฐฐ์๋ก ๋ด๋ฆผ ... }
public BigDecimal truncatePrice(BigDecimal price) { ... tickSize ๋ฐฐ์๋ก ๋ด๋ฆผ ... }
}
- ํฌ๋ฆฝํ ๋ ์๋/๊ฐ๊ฒฉ์ด ๊ฑฐ๋์๊ฐ ์ ํ stepSize/tickSize ๋ฐฐ์์ฌ์ผ ํ๊ณ ๋ช
๋ชฉ๊ฐ๋ minNotional ์ด์์ด์ด์ผ ํฉ๋๋ค. ๋ถ๋์์ ๋ฐ์ฌ๋ฆผ์ผ๋ก ๊ฑฐ๋ถ๋นํ์ง ์๊ฒ ํญ์
BigDecimal๋ก ๋ด๋ฆผ ์ ์ญ(RoundingMode.DOWN). ์ฌ๋ณผ๋ณ๋ก ์บ์(filterCache).
F. KisBrokerAdapter โ KIS๋ฅผ Broker๋ก ๋ฒ์ญ (KisBrokerAdapter.java:31-92)
// KisBrokerAdapter.java:31-50
public OrderResult placeOrder(BrokerAccount b, String symbol, Side side, BigDecimal qty, BigDecimal limitPrice) {
KisApiClient.Side ks = side == Side.BUY ? KisApiClient.Side.BUY : KisApiClient.Side.SELL;
long q = qty.setScale(0, RoundingMode.DOWN).longValue(); // ๋ฏธ๊ตญ์ฃผ์์ ์ ์ ์๋
Double lim = limitPrice == null ? null : limitPrice.doubleValue();
// M4: ์์ฅ๊ฐ(limitPrice=null) ์๋๋ฉด ํ์ฌ๊ฐ๋ฅผ ์กฐํํด ๊ทธ ๊ฐ๊ฒฉ์ ์ง์ ๊ฐ๋ก ๋ณํ
if (lim == null) {
try {
Map<String, Object> quote = kis.getOverseasQuote(b, symbol);
Object lp = quote.get("last_price");
double px = lp instanceof Number n ? n.doubleValue() : Double.parseDouble(String.valueOf(lp));
if (px > 0) lim = px;
} catch (Exception ignore) { }
if (lim == null) {
return OrderResult.failure("NO_QUOTE",
"KIS ์ง์ ๊ฐ ์ฐ์ ์คํจ: ... 0์ ์ง์ ๊ฐ ์ ์ก์ ๋ง์์ต๋๋ค. ...");
}
}
- ์๋ ์ ์ํ: ์ฃผ์์ 0.5์ฃผ๊ฐ ์์ผ๋ฏ๋ก
RoundingMode.DOWN์ผ๋ก ์ ์ํ. - 0์ ์ง์ ๊ฐ ๋ฐฉ์ง(M4)๊ฐ ํต์ฌ: KIS ๋ฏธ๊ตญ์ฃผ์์
ORD_DVSN=00(์ง์ ๊ฐ)๋ง ์ฐ๋ฏ๋ก, ๋จ๊ฐ 0(=limitPrice null)์ ๊ทธ๋๋ก ๋ณด๋ด๋ฉด 0์ ์ง์ ๊ฐ๋ก ๊ฑฐ๋ถ๋ฉ๋๋ค. ๊ทธ๋์ ์์ฅ๊ฐ ์๋๋ฉด ํ์ฌ๊ฐ๋ฅผ ์กฐํํด ๊ทธ ๊ฐ์ผ๋ก ์ง์ ๊ฐ ๋ณํ. ์กฐํ ์คํจ ์ 0์ ์ ์ก ๋์ ๋ช ํํ ์คํจ ๋ฐํ(KisBrokerAdapter.java:46-49).
// KisBrokerAdapter.java:51-58
Map<String, Object> resp = kis.placeOverseasOrder(b, symbol, ks, q, lim);
String rtCd = String.valueOf(resp.getOrDefault("rt_cd", ""));
if (!"0".equals(rtCd)) {
String msgCd = String.valueOf(resp.getOrDefault("msg_cd", ""));
return OrderResult.failure(msgCd, friendlyKisError(msgCd, msg, b));
}
return OrderResult.success(String.valueOf(resp.getOrDefault("kis_order_no", "")), rtCd);
- KIS๋
rt_cd="0"์ด ์ฑ๊ณต. ๊ทธ ์ธ๋msg_cd๋ฅผ ์ฌ๋์ด ์ฝ์ ๋ฉ์์ง๋ก ๋ฒ์ญ(friendlyKisError, EGW00202/00201/00105 ๋ฑ)ํดOrderResult.failure๋ก ์ ๊ทํ.
// KisBrokerAdapter.java:64-92 (queryFill ์์ฝ)
JsonNode output = resp.path("output"); // inquire-nccs(๋ฏธ์ฒด๊ฒฐ๋ด์ญ)
... p.getKisOrderNo() ์ ์ผ์นํ๋ ์ฃผ๋ฌธ ์ฐพ๊ธฐ ...
if (match != null) {
filledQty = max(0, ์ฃผ๋ฌธ์๋ - ๋ฏธ์ฒด๊ฒฐ์๋);
fillStatus = filledQty > 0 ? "PARTIAL" : "OPEN";
} else {
fillStatus = "FILLED"; // ๋ฏธ์ฒด๊ฒฐ ๋ชฉ๋ก์ ์์ โ ์ ๋ ์ฒด๊ฒฐ๋ก ๊ฐ์ฃผ(ํด๋ฆฌ์คํฑ)
}
- KIS๋ ์ง์ "์ฒด๊ฒฐ๋๋ค" API๊ฐ ์์ด ๋ฏธ์ฒด๊ฒฐ๋ด์ญ์ ์์ผ๋ฉด ์ ๋ ์ฒด๊ฒฐ๋ก ๋ณด๋ ํด๋ฆฌ์คํฑ. ํ๊ท ์ฒด๊ฒฐ๊ฐ๋ ์ ๊ณต ์ ํจ(
avgPrice=null).
G. BinanceBrokerAdapter โ Binance๋ฅผ Broker๋ก ๋ฒ์ญ (BinanceBrokerAdapter.java:37-83)
// BinanceBrokerAdapter.java:37-44
public OrderResult placeOrder(...) {
if (b.getBinanceMode() == BrokerAccount.BinanceMode.FUTURES) {
return OrderResult.failure("FUTURES_DISABLED",
"Binance ์ ๋ฌผ(FUTURES) ์ฃผ๋ฌธ์ ํ์ฌ ๋นํ์ฑํ ... ์์ ์ ์ํด SPOT(ํ๋ฌผ)๋ง ์ง์ํฉ๋๋ค.");
}
if (tradingControl.isKillSwitchOn()) {
return OrderResult.failure("KILL_SWITCH", "์ ์ญ ๊ฑฐ๋ ์ฐจ๋จ(kill-switch) ํ์ฑํ โ ๋ชจ๋ ์ฃผ๋ฌธ ๊ฑฐ๋ถ");
}
- FUTURES ์ฐจ๋จ์ด ํต์ฌ ์์ ์ฅ์น: ์ E์์ ๋ดค๋ฏ
futuresHost๋ MOCK์ด์ด๋ ์ค๊ฑฐ๋๋ก ๋๊ฐ๋๋ค. "MOCK์ธ๋ฐ ์ง์ง ๋์ด ๋๊ฐ๋" ์ฌ๊ณ ๋ฅผ ๋ง๊ธฐ ์ํด ์ ๋ฌผ ์ฃผ๋ฌธ์ ์ด๋ํฐ์์ ํต์งธ๋ก ๊ฑฐ๋ถ(์ฃผ์BinanceBrokerAdapter.java:18-19). BinanceApiClient์์ฒด๋ kill-switch๋ฅผ ์ ๋ณด๋ฏ๋ก, ์ด๋ํฐ์์ ์ฌํ์ธ(BinanceBrokerAdapter.java:42-44).
// BinanceBrokerAdapter.java:45-71
BinanceApiClient.SymbolFilters f = binance.getSymbolFilters(b, symbol);
BigDecimal q = f.truncateQty(qty);
if (q.signum() <= 0) return OrderResult.failure("LOT_SIZE", "... ์ต์ ์ฃผ๋ฌธ ๋จ์ ๋ฏธ๋ง ...");
String type = (limitPrice == null) ? "MARKET" : "LIMIT";
... ๋ช
๋ชฉ๊ฐ = q ร refPrice ๊ฐ minNotional ๋ฏธ๋ง์ด๋ฉด ๊ฑฐ๋ถ ...
Map<String, Object> resp = binance.placeSpotOrder(b, symbol, side.name(), type, q.toPlainString(), priceStr);
- ์ฃผ๋ฌธ ์ ๊ฑฐ๋์ ํํฐ๋ก ์๋/๊ฐ๊ฒฉ์ ์ ์ญยท๊ฒ์ฆํด Binance๊ฐ ๊ฑฐ๋ถํ ์ฃผ๋ฌธ์ ๋ฏธ๋ฆฌ ๊ฑฐ๋ฆ.
limitPrice์ ๋ฌด๋ก MARKET/LIMIT ๊ฒฐ์ . - ์ฒด๊ฒฐ ์กฐํ(
queryFill,BinanceBrokerAdapter.java:86-106)๋ KIS์ ๋ฌ๋ฆฌ ์ ํํ status + ํ๊ท ์ฒด๊ฒฐ๊ฐ(cummulativeQuoteQty / executedQty)๋ฅผ ์ ๊ณต.mapStatus(BinanceBrokerAdapter.java:121-129)๊ฐ Binance status๋ฅผ ์ ๊ทํ(FILLED/PARTIAL/OPEN/CANCELLED).
H. OrderFillService.pollFill โ ์ฒด๊ฒฐ ๋์ฐฉ ํ์ธ (OrderFillService.java:39-84)
// OrderFillService.java:45-67 (์์ฝ)
BrokerAccount b = brokerRepo.findById(p.getBrokerAccountId()).orElse(null);
Broker broker = brokerRouter.forAccount(b);
Broker.FillResult fr = broker.queryFill(b, p);
... p.setFillStatus/filledQty/fillAvgPrice ... proposalRepo.save(p);
// B2: ์ฒด๊ฒฐ ์ ์๊ณ ์ค๋
์ท ์๋ ๋๊ธฐํ
if ("FILLED".equals(fr.fillStatus()) || "PARTIAL".equals(fr.fillStatus())) {
Map<String, Object> bal = broker.getBalance(b);
b.setLastBalanceJson(om.writeValueAsString(bal));
b.setLastBalanceAt(LocalDateTime.now());
brokerRepo.save(b);
}
EXECUTED(KIS/Binance๊ฐ ์ฃผ๋ฌธ์ ์๋ฝ)์ ์ค์ ์ฒด๊ฒฐ(FILLED)์ ๋ค๋ฆ ๋๋ค. ์ฃผ๋ฌธ์ ๋ฐ์์ก์ด๋ ์์ฅ์์ ์ ์ฑ์์ง ์ ์์. ๊ทธ๋์ ๋์ค์ ํด๋ง์ผ๋ก ์ง์ง ์ฒด๊ฒฐ์ ํ์ธ.- ์ฒด๊ฒฐ๋๋ฉด ์๊ณ ์ค๋
์ท์ ์๋ ๊ฐฑ์ (
lastBalanceJson) โ ์ด ์ค๋ ์ท์total_market_value_usd๋ฅผ ์ ๊ฒ์ดํธ โฅ(์์ค ์ํท๋ธ๋ ์ด์ปค)์ด ์ฌ์ฉ. best-effort(์๊ณ ๋๊ธฐํ ์คํจ๊ฐ ์ฒด๊ฒฐ ํ์ ์ ๋ค์ง์ง ์์). OrderFillPollingJob(OrderFillPollingJob.java)์ด 3๋ถ๋ง๋ค ์ต๊ทผ 36์๊ฐ ๋ด ๋ฏธํ์ ๊ฑด๋ง ํด๋งํด KIS rate-limit ๋ถ๋ด ์ต์ํ.
I. TradingControlService โ ๋ฐํ์ kill-switch (TradingControlService.java:19-47)
// TradingControlService.java:21-38
@Value("${app.trading.kill-switch:false}")
private boolean configKillSwitch;
private volatile Boolean override = null; // null = ์ค์ ๊ฐ ์ฌ์ฉ
public boolean isKillSwitchOn() {
return override != null ? override : configKillSwitch;
}
public void setKillSwitch(boolean on) { this.override = on; ... }
- ๊ธฐ๋ณธ๊ฐ์ ํ๊ฒฝ์ค์ (
TRADING_KILL_SWITCH). ์ด์ ์คsetKillSwitch(true)๋ก ์ฌ์์ ์์ด ์ฆ์ ์ ์ฃผ๋ฌธ ์ฐจ๋จ. ๋ชจ๋ ์ฃผ๋ฌธ ๊ฒฝ๋ก(ProposalExecutionServiceยทKisApiClientยทBinanceBrokerAdapter)๊ฐ ์ด๊ฑธ ๋ณด๋ฏ๋ก ํ ๊ธ ํ ๋ฒ์ ์ ์ฒด ์ ์ฉ.volatile์ ๋ค๋ฅธ ์ค๋ ๋๊ฐ ์ฆ์ ๋ณด๊ฒ ํ๋ ํค์๋(๊ฐ์์ฑ).
J. PromotionGateService โ MOCKโREAL ์น๊ฒฉ ๊ฒ์ดํธ (PromotionGateService.java:51-118)
// PromotionGateService.java:51-56
public GateResult evaluate(Long userId, BrokerAccount realAccount) {
if (realAccount.getEnv() != BrokerAccount.Env.REAL) {
return new GateResult(true, List.of(), "MOCK ๊ณ์ ์ ์น๊ฒฉ ๊ฒ์ดํธ๊ฐ ์ ์ฉ๋์ง ์์ต๋๋ค.");
}
- MOCK์ ์์ . REAL์
tradingEnabled=true์ ํ์ ํ์ฉํ๊ธฐ ์ 5๊ฐ์ง๋ฅผ ๊ฒ์ฌ:- REAL ์์ฒด
/testํต๊ณผ(lastVerifiedAt != null) - ๊ฐ์ ๋ธ๋ก์ปค์ MOCK ๊ณ์ ์กด์ฌ โ
findByUserIdAndBrokerTypeAndEnv๋ก ์กฐํ(PromotionGateService.java:69; ์ฃผ์์ *"findByUserIdAndEnv ๋ ๋ค์ค๋ธ๋ก์ปค ์ NonUnique 500"* โ ๊ณผ๊ฑฐ ๋ค์ค ๋ธ๋ก์ปค์์ ํฐ์ง๋ ๋ฒ๊ทธ๋ฅผ ๊ณ ์น ํ์ ) - MOCK ๋ฑ๋ก ํ 14์ผ ์ด์(
MIN_MOCK_DAYS) - MOCK EXECUTED 5๊ฑด ์ด์(
MIN_MOCK_EXECUTED) - MOCK ์คํจ์จ < 30%(
MAX_MOCK_FAIL_RATIO)
- REAL ์์ฒด
- ํ ํญ๋ชฉ์ด๋ผ๋ ์คํจํ๋ฉด ๋ฌด์์ด ๋ถ์กฑํ์ง ์ ํํ ๋ฐํ(
checks๋ฆฌ์คํธ).BrokerAccountController.setTradingEnabled(BrokerAccountController.java:158-168)๊ฐ REAL+KIS์ผ ๋ ์ด ๊ฒ์ดํธ๋ฅผ ํธ์ถ.
์ฃผ์: ์ด ๊ฒ์ดํธ๋ ์๋ ๋งค๋งค ํ์ฑํ(
tradingEnabled) ์ฉ์ด๊ณ , ์๋ ์ฒด๊ฒฐ(auto-execute) ์ ๋ณ๋์ ๋ ์๊ฒฉํ ๊ฒ์ดํธ(MOCK ์๋๋งค๋งค 14์ผ + 20ํ)๋ฅผBrokerAccountController.setAutoExecute(BrokerAccountController.java:200-222)๊ฐ ์ง์ ๊ฒ์ฌํฉ๋๋ค. ๋ ๊ฒ์ดํธ๋ ๋ค๋ฅธ ๊ธฐ์ค์ ๋๋ค.
K. ์ปจํธ๋กค๋ฌ 3์ข โ ์ ๊ตฌ
BrokerAccountController โ ๊ณ์ขยทํ๋ยท์ค์์น (BrokerAccountController.java)
- ํค ๋ฑ๋ก(
upsert): appsecret์ ์ฆ์crypto.encryptํ ์ ์ฅ(BrokerAccountController.java:119,124). ํ๋ฌธ ์ ์ฅ ์ ๋ ๊ธ์ง. - REAL ํ๋ ์์ ์ํ(
realCapViolation,BrokerAccountController.java:60-73): 1๊ฑด๋น USD 5๋ง, ์ผ์ผ USD 20๋ง, ๋งค์ 1์ต์/๋งค๋ 5์ต์. ๋ฑ๋กยท์์ ์ ๊ฒฝ๋ก์์ ๋์ผ ์ ์ฉ(์ฃผ์BrokerAccountController.java:50-54: ๊ณผ๊ฑฐ patchLimits์๋ง ์บก์ด ์์ด upsert๋ก ์ฐํ๋๋ ๋น๋์นญ์ ๊ณ ์นจ). setTradingEnabled: REAL์/testํต๊ณผ + KIS๋ฉด ์น๊ฒฉ ๊ฒ์ดํธ ํต๊ณผํด์ผ ์ผ์ง.testConnection: ํ ํฐ ๋ฐ๊ธ๋ง ์ฑ๊ณตํด๋ ์ธ์ฆ ํต๊ณผ๋ก ๊ฐ์ฃผ(lastVerifiedAt๊ธฐ๋ก). ์๊ณ ์กฐํ๋ best-effort(KIS ์ด๋น ํธ์ถ ์ ํ์ผ๋ก ์คํจํด๋ ์ธ์ฆ์ ์ ์ง). KIS ์๋ฌ ์ฝ๋๋ฅผ ํ๋ถํ ์ฌ์ฉ์ ์๋ด๋ก ๋ฒ์ญ(friendlyKisError,BrokerAccountController.java:358-417).
OrderProposalController โ ์น์ธ ํ (OrderProposalController.java)
create: ์๋ ์ ์ ์์ฑ(source=MANUAL, +24h ๋ง๋ฃ). ํฌ๋ฆฝํ ๋ ๋ถ์๋ฅผqtyDecimal์, ์ฃผ์์ ์ ์qty์(OrderProposalController.java:91-95). ํญ์ PENDING์ผ๋ก๋ง ์์ฑ.approve: ์์ ๊ถ ๊ฒ์ฆ(findByIdAndUserId) ํ ์๋ ์น์ธ๋ ์๋๊ณผ ๋์ผํexec.execute(p, ba, false)ํธ์ถ(OrderProposalController.java:135). ํ๋/kill-switch๋ฅผ ์ฐํํ๋ ๋ณ๋ ๊ฒฝ๋ก๊ฐ ์์.pollFill: EXECUTED ์ฃผ๋ฌธ์ ์ฒด๊ฒฐ์ ์ฆ์ ํด๋ง(์ค์ผ์ค์ก๊ณผ ๋์ผ ๋ก์ง ์๋ ํธ๋ฆฌ๊ฑฐ).
BrokerOrderController โ ์ง์ ์ฃผ๋ฌธ (BrokerOrderController.java)
place: ์ ์์ ์ ๊ฑฐ์น๋ ์ง์ ์ฃผ๋ฌธ ๊ฒฝ๋ก. ๊ทธ๋์ ๊ฐ๋๋ฅผ ์ค์ค๋ก ์ ๋ถ ์ํ โtradingEnabledยทlastVerifiedAtยท1๊ฑด๋น/์ผ์ผ USD ํ๋, ๊ทธ๋ฆฌ๊ณProposalExecutionService.krwDailyLimitViolation์ ์ฌ์ฌ์ฉ(BrokerOrderController.java:198)ํด KRW ํ๋๊น์ง. ํต๊ณผ ํbrokerRouter.forAccount(b).placeOrder(...)์ง์ ํธ์ถ.preview: ์ค์ ์ฃผ๋ฌธ ์์ด ํ๋/์์ ๋น์ฉ๋ง ๊ณ์ฐ.quote/balance/orders/today๋ ๋ผ์ฐํฐ ๊ฒฝ์ ์กฐํ.
L. ์ํฐํฐ ํ๋ ์๋ฏธ
BrokerAccount (BrokerAccount.java) โ ๊ณ์ข + ํ๋ + ์ค์์น
| ํ๋ | ์๋ฏธ |
|---|---|
env |
MOCK(๋ชจ์/ํ
์คํธ๋ท) | REAL(์ค์ /๋ฉ์ธ๋ท). ๊ธฐ๋ณธ MOCK |
brokerType |
KIS | BINANCE. ๊ธฐ๋ณธ KIS |
appKey / appSecretEnc |
KIS ํค. secret์ ์ํธํ ์ ์ฅ(*_enc) |
cano / acntPrdtCd |
KIS ์ข ํฉ๊ณ์ข๋ฒํธ(8) / ์ํ์ฝ๋(๋ณดํต "01") |
binanceApiKey / binanceApiSecretEnc |
Binance ํค. secret ์ํธํ ์ ์ฅ |
binanceMode |
SPOT | FUTURES. FUTURES๋ ์ฃผ๋ฌธ ์ฐจ๋จ๋จ |
maxOrderUsd |
1๊ฑด๋น ์ต๋(USD), 0=๋ฌด์ ํ. ๊ธฐ๋ณธ 5,000 |
dailyOrderUsd |
์ผ์ผ ๋์ ์ต๋(USD). ๊ธฐ๋ณธ 20,000. KIS๋ KRW ํ๋ ์ฐ์ |
dailyLossLimitUsd |
์์ค ์ํท๋ธ๋ ์ด์ปค. ๋ฏธ์คํ ์์ค์ด ์ด ๊ฐ ์ด๊ณผ ์ ์ ๊ท ๋งค์ ์ฐจ๋จ |
dailyBuyKrw / dailySellKrw |
KIS ์ ์ฉ 1์ผ ๋งค์/๋งค๋ ํ๋(์ํ) |
tradingEnabled |
๋ง์คํฐ ์ค์์น. false๋ฉด ๋ชจ๋ ์น์ธ ๊ฑฐ๋ถ. ๊ธฐ๋ณธ false |
autoExecute |
์๋ ์ฒด๊ฒฐ ์ค์์น. ์๊ทธ๋ PENDING์ ์ฌ๋ ์น์ธ ์์ด ์คํ. ๊ธฐ๋ณธ false |
lastVerifiedAt |
๋ง์ง๋ง ์ฐ๊ฒฐ ํ ์คํธ ์ฑ๊ณต ์๊ฐ |
lastBalanceJson / lastBalanceAt |
์ฒด๊ฒฐ ํ ์๊ณ ์ค๋ ์ท(์์ค ์ํท๋ธ๋ ์ด์ปค๊ฐ ์ฝ์) |
- ์ ๋ํฌ ์ ์ฝ(BrokerAccount.java:20-22): (user_id, broker_type, env) โ ํ ์ฌ์ฉ์๊ฐ ๊ฐ์ ๋ธ๋ก์ปคยทenv ๊ณ์ข๋ฅผ ์ค๋ณต ๋ฑ๋ก ๋ชป ํจ. |
|
- USD_KRW_APPROX = 1300.0(BrokerAccount.java:31): KRW ํ๋ ํ์ฐ์ฉ ๊ทผ์ฌ ํ์จ(์ค์๊ฐ ์๋). |
OrderProposal (OrderProposal.java) โ ์ฃผ๋ฌธ ์ ์
| ํ๋ | ์๋ฏธ |
|---|---|
userId |
์์ ์ โ ๊ถํ ๊ฒ์ฆ 1์ฐจ ๊ฒ์ดํธ |
workspaceId |
๋ฐ์ ์ํฌ์คํ์ด์ค(์๋์ด๋ฉด null ํ์ฉ) |
brokerAccountId |
๋ณด๋ผ ๊ณ์ข(๋ฐ๋์ ๋ณธ์ธ ์์ ) |
ticker / side |
์ข ๋ชฉ(SPY/BTCUSDT) / BUY|SELL |
qty |
์ ์ ์๋(์ฃผ์). NOT NULL placeholder |
qtyDecimal |
๋ถ์ ์๋(ํฌ๋ฆฝํ ). ์์ผ๋ฉด ์คํ/์ฒด๊ฒฐ์์ ์ฐ์ |
limitPrice |
์ง์ ๊ฐ. null=์์ฅ๊ฐ |
source |
SIGNAL | MANUAL |
status |
PENDINGโ(APPROVEDโEXECUTED|EXEC_FAILED)|REJECTED|EXPIRED |
kisOrderNo |
๋ธ๋ก์ปค๊ฐ ์ค ์ฃผ๋ฌธ๋ฒํธ(Binance orderId๋ ์ฌ๊ธฐ) |
execError |
์คํจ ์ฌ์ |
expiresAt |
๋ง๋ฃ ์๊ฐ(๋ณดํต +24h). OrderProposalExpiryJob์ด ์ ๋ฆฌ |
autoExecuted |
์ฌ๋ ์น์ธ ์์ด ์๋ ์ฒด๊ฒฐ๋๋์ง(์กธ์ ๊ฒ์ดํธ ์ง๊ณ์ฉ) |
fillStatus / filledQty / filledQtyDecimal / fillAvgPrice / fillCheckedAt |
์ค์ ์ฒด๊ฒฐ ํด๋ง ๊ฒฐ๊ณผ(EXECUTED์ ๋ณ๊ฐ) |
โ ๏ธ ํจ์ ยท๋ณด์ ์ฃผ์
KIS ๊ธฐ๋ณธ Java UA ์ฐจ๋จ โ Spring
RestClient์Java-http-client/...UA๋ฅผ KIS GW๊ฐ 403 + EGW00002๋ก ๊ฑฐ๋ถ. ํ ํฐ/์น์ธํค ๋ฐ๊ธ์HttpURLConnection+ ๋ธ๋ผ์ฐ์ UA๋ก ์ฐํ(KisApiClient.java:222-226, 642). ์ด ํด๋์ค๋ฅผ ์๋ ๋ UA๋ฅผ ์ง์ฐ๋ฉด ์ธ์ฆ์ด ํต์งธ๋ก ๊นจ์ง๋๋ค. (๋ฉ๋ชจ๋ฆฌ ๊ตํ๊ณผ ์ผ์น.)KIS chunked ์ ์ก = EGW00202 โ Map์ ๊ทธ๋๋ก ๋ณด๋ด๋ฉด chunked๋ก ๋๊ฐ GW๊ฐ ๊ฑฐ๋ถ. ๋ณธ๋ฌธ์
byte[]๋ก ์ง๋ ฌํํด Content-Length ๋ช ์(jsonBytes). ์ฃผ๋ฌธ ๋ณธ๋ฌธ๋ ๋์ผ(KisApiClient.java:515-517).Binance env=REAL โ ๋ฉ์ธ๋ท / FUTURES๋ ํญ์ ์ค๊ฑฐ๋ โ
spotHost๋ MOCK์ด๋ฉด ํ ์คํธ๋ท์ด์ง๋งfuturesHost๋ MOCK์ด์ด๋fapi.binance.com(์ค๊ฑฐ๋). ๊ทธ๋์BinanceBrokerAdapter๊ฐ FUTURES ์ฃผ๋ฌธ์ ํต์งธ๋ก ์ฐจ๋จ(BinanceBrokerAdapter.java:38-41). ์ด ์ฐจ๋จ์ ํ๋ฉด ๋ชจ์๊ฐ ์ง์ง ๋์ ์๋๋ค.๋ ๋ฒ์งธ ์ฃผ๋ฌธ ๊ฒฝ๋ก ์ฐํ ๋ฐฉ์ง โ ์ง์ ์ฃผ๋ฌธ(
BrokerOrderController.place)์ด ์ ์ ์น์ธ ๊ฒฝ๋ก์ ๋ค๋ฅธ ํ๋ ์ ์ฑ ์ ์ฐ๋ฉด ์ฐํ ๊ตฌ๋ฉ์ด ์๊น๋๋ค. ๊ทธ๋์ KRW ํ๋ ๊ฒ์ฌ๋ฅผProposalExecutionService.krwDailyLimitViolationpublic static ๋ฉ์๋๋ก ๊ณต์ ํด ์ ๊ฒฝ๋ก๊ฐ ๊ฐ์ ๊ท์น์ ์๋๋ค(BrokerOrderController.java:198). REAL ํ๋ ์บก๋ ๋ฑ๋กยท์์ ์ ๊ฒฝ๋ก์์ ๋์ผ ์ ์ฉ(BrokerAccountController.java:107, 260).์์ฅ๊ฐ ํ๋ ์ฐํ ๋ฐฉ์ง โ ์์ฅ๊ฐ๋ ๊ธ์ก์ ๋ชจ๋ฅด๋ฏ๋ก ํ์ฌ๊ฐ๋ฅผ ์กฐํํด ์ถ์ (
estimateUsd). ์ ํ๋ฉด "์ง์ ๊ฐ๋ ๋งํ๊ณ ์์ฅ๊ฐ๋ ๋ฌด์ ํ" ๊ตฌ๋ฉ.0์ ์ง์ ๊ฐ ์ฌ๊ณ โ KIS ๋ฏธ๊ตญ์ฃผ์์ ์ง์ ๊ฐ๋ง ์ฐ๋ฏ๋ก ๋จ๊ฐ 0 ์ ์ก์ ๊ฑฐ๋ถ๋ฉ๋๋ค. ์์ฅ๊ฐ ์๋๋ฉด ํ์ฌ๊ฐ๋ฅผ ์ง์ ๊ฐ๋ก ๋ณํํ๊ณ , ์กฐํ ์คํจ ์ 0์ ๋์ ๋ช ํํ ์คํจ ๋ฐํ(
KisBrokerAdapter.java:39-49).ํค ํ๋ฌธ ์ ์ฅ ๊ธ์ง โ appsecret/apiSecret์ ๋ฑ๋ก ์ฆ์
crypto.encryptํ*_enc์ปฌ๋ผ์ ์ ์ฅ. ๋ก๊ทธ์๋ ๋ง์คํน(previewWithSecretMasked,KisApiClient.java:155-162).MOCKโREAL ์กธ์ ๊ฒ์ดํธ โ ์๋(
PromotionGateService: 14์ผ+5๊ฑด+์คํจ์จ<30%)๊ณผ ์๋(setAutoExecute: 14์ผ+20ํ)์ด ๋ค๋ฅธ ๊ธฐ์ค. ํท๊ฐ๋ฆฌ์ง ๋ง ๊ฒ.EXECUTED โ FILLED โ ์ฃผ๋ฌธ ์๋ฝ๊ณผ ์ค์ ์ฒด๊ฒฐ์ ๋ค๋ฆ. ์ฒด๊ฒฐ์
OrderFillService/ํด๋ง์ก์ผ๋ก ๋ณ๋ ํ์ธ.kill-switch๋ ๋ฐํ์ ์ฐ์ โ
TradingControlService.override๊ฐ ์ค์ ๊ฐ๋ณด๋ค ์ฐ์ . ์ฌ์์ํ๋ฉด ์ค์ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ๋ณต๊ทํ๋ฏ๋ก, ๊ธด๊ธ ์ฐจ๋จ ํ ์๊ตฌ ์ฐจ๋จ์ด ํ์ํ๋ฉด ํ๊ฒฝ๋ณ์๋ ๋ฐ๊ฟ์ผ ํฉ๋๋ค.
๐ ๊ณ ๋ํ ์์ด๋์ด
- ์ค์๊ฐ ํ์จ:
USD_KRW_APPROX = 1300๊ณ ์ โ ํ์จ API ์ฐ๋์ผ๋ก KRW ํ๋ ์ ํ๋ ํฅ์(TODO ์ฃผ์BrokerAccount.java:29). - ์ ๋ฌผ ํ
์คํธ๋ท ๋ฐฐ์ :
testnet.binancefuture.com๋ณ๋ ๊ณ์ + env ๋ถ๊ธฐ๋ฅผ ์ถ๊ฐํ๋ฉด FUTURES ์ฐจ๋จ์ ์์ ํ๊ฒ ํ ์ ์์(ํ์ฌ ๋ฏธ๋ฐฐ์ ์ด๋ผ ์ฐจ๋จ). - ์ฒด๊ฒฐ WebSocket ์ ๋ฐํ: KIS ์ฒด๊ฒฐํต๋ณด WS(
KisFillWebSocketService)์ Binance user-data-stream์ ๋ถ์ฌ ํด๋ง ๋์ ํธ์ ๊ธฐ๋ฐ ์ฒด๊ฒฐ ํ์ธ์ผ๋ก ์ง์ฐ/๋ถํ ์ ๊ฐ. - ์ ๋ธ๋ก์ปค ์ถ๊ฐ:
Broker๊ตฌํ +@Componentํ ๊ฐ๋ฉดBrokerRouter๊ฐ ์๋ ์ธ์. ์: Alpaca, IBKR. ๋ผ์ฐํฐ/์คํ์๋น์ค ์์ ๋ถํ์. - ๋ถ๋ถ์ฒด๊ฒฐ ํ์ ์ฒ๋ฆฌ: ํ์ฌ PARTIAL์ ์ํ๋ง ๊ธฐ๋ก. ๋ฏธ์ฒด๊ฒฐ ์๋ ์๋ ์ทจ์/์ฌ์ฃผ๋ฌธ ์ ์ฑ
์
OrderFillService์ ์ถ๊ฐ ๊ฐ๋ฅ. - ํ๋ ์ ์ฑ
์์งํ: ๊ฒ์ดํธ 6์ข
์ด
execute์ ์ธ๋ผ์ธ. ์ ์ฑ ์OrderGuard์ฒด์ธ์ผ๋ก ๋ถ๋ฆฌํ๋ฉด ํ ์คํธยทํ์ฅ์ด ์ฌ์์ง(์ ๋ต ํจํด). - KIS ๊ฑฐ๋์ ๋งคํ ์๋ํ:
EXCHANGE_BY_TICKERํ๋์ฝ๋ฉ โ exchangeInfo๋ฅ ์กฐํ/์บ์๋ก ์๋ ํ๋ณ.
๐ ์ฉ์ด ์ฌ์ (์ด ํํธ ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
| Broker (์ธํฐํ์ด์ค) | ๋ชจ๋ ์ฆ๊ถ์ฌ๊ฐ ๊ตฌํํ๋ ๊ณตํต ๊ณ์ฝ(์ฃผ๋ฌธ/์ฒด๊ฒฐ/์๊ณ /์์ธ) |
| ์ด๋ํฐ(Adapter) | ์๊น์ ๋ค๋ฅธ ์๋ณธ ApiClient๋ฅผ Broker ๋ชจ์์ผ๋ก ๋ณํํ๋ ์์ ๋ํผ |
| OrderProposal | ์คํ ์ ์ฃผ๋ฌธ ์ด์. PENDINGโโฆโEXECUTED ์๋ช ์ฃผ๊ธฐ |
| ์์ ๊ฒ์ดํธ | execute๊ฐ ์ฃผ๋ฌธ ์ ํต๊ณผ์ํค๋ 6์ค ๊ฒ์ฌ(kill-switchยทํ๋ยท์์ค ๋ฑ) |
| kill-switch | ์ ์ญ ๊ฑฐ๋ ์ฐจ๋จ ์ค์์น. ์ฌ์์ ์์ด ์ ์ฃผ๋ฌธ ์ฆ์ ๊ฑฐ๋ถ |
| tradingEnabled | ๊ณ์ข๋ณ ๋งค๋งค ๋ง์คํฐ ์ค์์น |
| autoExecute | ์๊ทธ๋ ์ ์์ ์ฌ๋ ์น์ธ ์์ด ์๋ ์ฒด๊ฒฐํ๋ ์ค์์น |
| MOCK / REAL | ๋ชจ์/ํ ์คํธ๋ท โ ์ค์ /๋ฉ์ธ๋ท |
| ์น๊ฒฉ ๊ฒ์ดํธ | MOCK ์ค์ ์ ๊ฒ์ฆํด์ผ REAL์ ์ผค ์ ์๊ฒ ํ๋ ๊ด๋ฌธ |
| rt_cd / msg_cd | KIS ์๋ต ์ฝ๋("0"=์ฑ๊ณต) / ์๋ฌ ์ธ๋ถ ์ฝ๋(EGW00xxx) |
| EGW00202 / 00201 / 00002 | KIS GW ๋ผ์ฐํ ์ค๋ฅ / ์ด๋น ํ๋ ์ด๊ณผ / ์๋ํ ํธ๋ํฝ(UA) ๊ฑฐ๋ถ |
| HMAC-SHA256 | secret์ผ๋ก ๋ง๋ ์์กฐ ๋ถ๊ฐ ์๋ช . Binance ์ฌ์ ์์ฒญ ์ธ์ฆ |
| X-MBX-APIKEY | Binance API Key ์ ๋ฌ ํค๋(URL ๊ธ์ง) |
| LOT_SIZE / tickSize / minNotional | ํฌ๋ฆฝํ ์๋ยท๊ฐ๊ฒฉ ๋จ์ยท์ต์ ๋ช ๋ชฉ๊ฐ ๊ฑฐ๋์ ํํฐ |
| FillResult / fillStatus | ์ ๊ทํ ์ฒด๊ฒฐ ๊ฒฐ๊ณผ. FILLED/PARTIAL/OPEN/CANCELLED/UNKNOWN |
| EXECUTED vs FILLED | ์ฃผ๋ฌธ ์๋ฝ(์ ์) โ ์ค์ ์์ฅ ์ฒด๊ฒฐ. ๋ค๋ฆ |
| lastBalanceJson | ์ฒด๊ฒฐ ํ ๋๊ธฐํ๋๋ ์๊ณ ์ค๋ ์ท(์์ค ์ํท๋ธ๋ ์ด์ปค๊ฐ ์ฝ์) |
| OrderExecutionAudit | ์์ฅ์ ๋๊ฐ ๋ชจ๋ ์ฃผ๋ฌธ ์๋์ ๋ถ๋ณ ๊ฐ์ฌ ๊ธฐ๋ก |
| TTL / expiresAt | ์ ์ ๋ง๋ฃ ์๊ฐ. ExpiryJob์ด PENDING ๋ง๋ฃ๋ถ์ EXPIRED๋ก ์ ๋ฆฌ |
domain/user โ ํ์ ๊ด๋ฆฌ + ์ ๋ถ์ฆ(JWT) ๋ฐ๊ธ์ (์์ ๋ผ์ธ๋ณ ํด์ค)
์๋ณธ:
backend/src/main/java/com/DevBridge/devbridge/domain/user/์ ์ฒด
- ์ฐ๊ด ๋ณด์ ๋ชจ๋:
backend/src/main/java/com/DevBridge/devbridge/global/security/
์ด ๋ฌธ์๋ ๊ต์ฌ ํ์ค ํ์(README "3. ๊ณตํต ํ์", ๋ชจ๋ฒ ์์01_backtest/vbt_engine.md)์ ๋ฐ๋ฆ ๋๋ค.
์ ์ : ์คํ๋ง ๊ธฐ์ด(@RestControllerยท@ServiceยทDIยทResponseEntity๋ฑ)๋08_backend/00_spring_primer.md์์ ๋ค๋ฃน๋๋ค. ์ฌ๊ธฐ์๋ user ๋๋ฉ์ธ ์ฝ๋ ๊ทธ ์์ฒด๋ฅผ ํ ์ค์ฉ ํ๋๋ค.
๐ ์ด ๋๋ฉ์ธ ํ๋์
์ด ๋๋ฉ์ธ์ "ํ์ ๊ด๋ฆฌ + ์ ๋ถ์ฆ ๋ฐ๊ธ์" ์ ๋๋ค. ์ฌ๋์ผ๋ก ๋น์ ํ๋ฉด ์ฃผ๋ฏผ์ผํฐ + ์ถ์ ์ฆ ๋ฐ๊ธ ๋ฐ์คํฌ์์.
- ํ์๊ฐ์ (signup) = ์ฃผ๋ฏผ์ผํฐ์ ์ ์ฃผ๋ฏผ ๋ฑ๋ก. ๋น๋ฐ๋ฒํธ๋ ์ ๋ ํ๋ฌธ์ผ๋ก ๋ณด๊ดํ์ง ์๊ณ ๊ธ๊ณ (BCrypt ํด์) ์ ๋ฃ์ต๋๋ค.
- ๋ก๊ทธ์ธ(login) = ์ ๋ถ ํ์ธ ํ ์ถ์ ์ฆ(JWT ํ ํฐ) ๋ฐ๊ธ. ์ด ์ถ์ ์ฆ์ ๋ด์ธ๋ ๋ดํฌ(HttpOnly ์ฟ ํค) ์ ๋ด์ ๋ธ๋ผ์ฐ์ ์ ์ค๋๋ค.
- ์ดํ ๋ชจ๋ ์์ฒญ = ์ถ์
์ฆ(์ฟ ํค)์ ๋งค๋ฒ ๋ค๊ณ ์ค๋ฉด, ์
๊ตฌ์ ๊ฒฝ๋น์(JwtAuthenticationFilter) ์ด ์์กฐ ์ฌ๋ถ๋ฅผ ํ์ธํ๊ณ "์ด ์ฌ๋์ N๋ฒ ํ์"์ด๋ผ๊ณ ์์ฒญ์ ๋์ฅ(
auth.userId์์ฑ)์ ์ฐ์ด์ค๋๋ค. - ์์ ๋ก๊ทธ์ธ(GoogleยทGitHub) = ๋ค๋ฅธ ๊ธฐ๊ด(๊ตฌ๊ธยท๊นํ๋ธ)์ด ๋ฐ๊ธํ ์ ๋ถ์ฆ์ ์ฐ๋ฆฌ๊ฐ ์ง์ ๊ทธ ๊ธฐ๊ด์ ์ ํํด ์ง์ ํ์ธ ํ ์ฐ๋ฆฌ ์ถ์ ์ฆ์ผ๋ก ๊ตํ.
- ํ๋กํ/์ํ/์ด๋ฉ์ผ ์ธ์ฆ = ๋ฑ๋ก๋ ์ฃผ๋ฏผ์ ๋ถ๊ฐ ์ ๋ณด ๊ด๋ฆฌ.
ํต์ฌ ํด๋์ค ์ญํ ํ:
| ํด๋์ค | ํ ์ค ์ญํ | ๋น์ |
|---|---|---|
AuthController |
signupยทloginยทrefreshยทsocialยทgithubยทlogout ์ HTTP ์ ๊ตฌ. ์ฟ ํค ๊ตฝ๊ธฐ ๋ด๋น | ์ถ์ ์ฆ ๋ฐ๊ธ ์ฐฝ๊ตฌ |
AuthService |
๊ฐ์ ๊ฒ์ฆยทBCrypt ํด์ฑยท๋ก๊ทธ์ธ ๋น๋ฒ ๋์กฐยท์์ ๊ณ์ ์กฐํ/์์ฑ | ์ ์ ํ์ธ ๋ด๋น๊ด |
JwtUtil |
JWT ํ ํฐ ๋ฐํ(issue) ยท ๊ฒ์ฆ(parse) | ์ถ์ ์ฆ ์ธ์๊ธฐ + ์์กฐ ๊ฐ๋ณ๊ธฐ |
JwtAuthenticationFilter |
๋งค ์์ฒญ๋ง๋ค ์ฟ ํค/ํค๋์์ ํ ํฐ ๊บผ๋ด ๊ฒ์ฆ โ ์์ฒญ์ userId ์ฃผ์ | ์ ๊ตฌ ๊ฒฝ๋น์ |
AuthContext |
์ปจํธ๋กค๋ฌ์์ "์ง๊ธ ๋๊ตฌ?"๋ฅผ ๊บผ๋ด๋ ํฌํผ | ๋์ฅ ์ฐํ userId ํ๋ ๊ธฐ |
UserController |
์ฌ์ฉ์ ๊ฒ์ยท์กฐํ(์ฑํ ์ฉ)ยทGitHub username ์์ | ์ฃผ๋ฏผ ์กฐํ ์ฐฝ๊ตฌ |
ProfileController/ProfileService |
ํ๋กํ ์์ธ ์ ์ฅ/์กฐํ/์ด๊ธฐํ | ํ๋กํ ๊ด๋ฆฌ์ค |
BankVerificationController/Service |
์ ๊ธ์๋ช 3์๋ฆฌ ์ฝ๋๋ก ๊ณ์ข ์ธ์ฆ | ๊ณ์ข ๋ณธ์ธํ์ธ ๋ฐ์คํฌ |
EmailVerificationController/Service |
6์๋ฆฌ ์ฝ๋๋ก ์ด๋ฉ์ผ ์ธ์ฆ | ์ด๋ฉ์ผ ๋ณธ์ธํ์ธ ๋ฐ์คํฌ |
User (์ํฐํฐ) |
ํ์ 1๋ช
= DB USERS ํ ํ |
์ฃผ๋ฏผ๋ฑ๋ก ์นด๋ |
RefreshToken (์ํฐํฐ) |
์ถ์ ์ฆ ์๋ ๊ฐฑ์ ์ฉ ์ฅ๊ธฐ ํฐ์ผ | ์ฌ๋ฐ๊ธ ์ฟ ํฐ |
๋๊ฐ ํธ์ถํ๋? โ ํ๋ก ํธ์๋(React)๊ฐ /api/auth/*, /api/users/*, /api/profile/*, /api/bank/*, /api/verify/* ๋ก REST ์์ฒญ์ ๋ณด๋ด๋ฉด ๊ฐ ์ปจํธ๋กค๋ฌ๊ฐ ๋ฐ์ต๋๋ค. ์ถ์
์ฆ(JWT ์ฟ ํค)์ ํ ๋ฒ ๋ฐ๊ธ๋๋ฉด ์ดํ ๋ชจ๋ ๋๋ฉ์ธ(strategyยทaiยทpaymentโฆ) ์์ฒญ์์ "์ด ์์ฒญ์ ์ฃผ์ธ"์ ์๋ณํ๋ ๋ฐ ์ฐ์
๋๋ค.
โ ๏ธ ์ค์ํ ์ค๊ณ ์ฌ์ค: ์ด ํ๋ก์ ํธ๋ ํ Spring Security ํ๋ ์์ํฌ(SecurityFilterChain)๋ฅผ ์๋์ ์ผ๋ก ๋๊ณ , ๋์ ์ง์ ๋ง๋ ๊ฒฝ๋ ์กฐํฉ(
JwtAuthenticationFilter+AuthContext)์ผ๋ก ์ธ์ฆ์ ์ฒ๋ฆฌํฉ๋๋ค.PasswordConfig(global/config/PasswordConfig.java)๋ ๊ทธ์ค BCrypt ํด์ฑ ๊ธฐ๋ฅ๋ง ๋น๋ ค ์๋๋ค. ์ด ํ ์ค์ด ๋๋ฉ์ธ ์ ์ฒด์ ๋ณด์ ๋ชจ๋ธ์ ๊ฒฐ์ ํ๋ ๊ผญ ๊ธฐ์ตํ์ธ์.
๐ง ์ฌ์ ์ง์ (์ด๊ฑฐ ๋ชจ๋ฅด๋ฉด ๋งํ)
1) JWT ํ ํฐ = "์์กฐ ๋ถ๊ฐ๋ฅํ ์ฝํ ์ถ์ ์ฆ"
JWT(JSON Web Token)๋ ํค๋.ํ์ด๋ก๋.์๋ช
์ธ ํ ๋ง์ ์ (.)์ผ๋ก ์ด์ ๋ฌธ์์ด์
๋๋ค.
eyJhbGciOi... . eyJ1aWQiOjEyLCJ0eXBlIjoiRlJFRSJ9 . k3jD9f...
ํค๋(์๊ณ ๋ฆฌ์ฆ) ํ์ด๋ก๋(uid=12, type=FREE ๋ฑ) ์๋ช
(HMAC)- ํ์ด๋ก๋๋ ๋๊ตฌ๋ ์ฝ์ ์ ์์ต๋๋ค(Base64, ์ํธํ ์๋). ๊ทธ๋์ ๋น๋ฐ๋ฒํธ ๊ฐ์ ๊ฑด ์ ๋ ์ ๋ฃ์ต๋๋ค.
- ํต์ฌ์ ์๋ช
: ์๋ฒ๋ง ์๋ ๋น๋ฐํค(
app.jwt.secret)๋กHMAC-SHA256์ ์ฐ์ต๋๋ค. ๋๊ตฐ๊ฐ ํ์ด๋ก๋๋ฅผ "uid=12 โ uid=1(๊ด๋ฆฌ์)"๋ก ๊ณ ์น๋ฉด ์๋ช ์ด ๊นจ์ ธ ๊ฒ์ฆ(parse)์์ ์ฆ์ ํ๋ฝํฉ๋๋ค. ๋์ฅ ์์กฐ ๋ถ๊ฐ๋ฅํ ์ถ์ ์ฆ๊ณผ ๊ฐ์์. - "๋์นญํค(HS256)"๋: ๋ฐํํ ๋์ ๊ฒ์ฆํ ๋ ๊ฐ์ ๋น๋ฐํค๋ฅผ ์๋๋ค(์๋ฒ ํผ์๋ง ๋ณด๊ด).
2) HttpOnly ์ฟ ํค = "์๋ฐ์คํฌ๋ฆฝํธ๊ฐ ๋ชป ์ฌ๋ ๋ด์ธ ๋ดํฌ"
- ์ผ๋ฐ์ ์ผ๋ก ํ ํฐ์ ๋ธ๋ผ์ฐ์
localStorage์ ๋ฃ์ผ๋ฉด, XSS(์ ์ฑ ์คํฌ๋ฆฝํธ)๊ฐlocalStorage.getItem('token')์ผ๋ก ํ์ณ๊ฐ ์ ์์ต๋๋ค. HttpOnly์ฟ ํค๋ ์๋ฐ์คํฌ๋ฆฝํธ๊ฐ ์ฝ์ ์ ์๊ฒ ๋ธ๋ผ์ฐ์ ๊ฐ ๋ง์ต๋๋ค. ์ค์ง ๋ธ๋ผ์ฐ์ ๊ฐ ์์ฒญ ๋ณด๋ผ ๋ ์๋์ผ๋ก ๋๋ด๋ง ํฉ๋๋ค. โ XSS ํ ํฐ ํ์ทจ ๋ฐฉ์ด.- ์ถ๊ฐ ์์ฑ:
Secure(HTTPS์์๋ง ์ ์ก),SameSite(๋ค๋ฅธ ์ฌ์ดํธ๊ฐ ์ฐ๋ฆฌ ์ฟ ํค๋ฅผ ๋ผ์ ๋ณด๋ด๋ CSRF ๋ฐฉ์ด).
3) BCrypt ํด์ฑ = "๋๋๋ฆด ์ ์๋ ๋ถ์ + ์๊ธ"
- ๋น๋ฐ๋ฒํธ๋ฅผ ๊ทธ๋๋ก DB์ ์ ์ฅํ๋ฉด, DB๊ฐ ์ ์ถ๋๋ ์๊ฐ ์ ์ ๋น๋ฒ์ด ์์ด๋๊ฐ๋๋ค.
- ํด์ฑ์ ๋จ๋ฐฉํฅ ํจ์:
"hunter2"โ$2a$10$N9qo8.... ์ด ๊ฒฐ๊ณผ๋ก๋ ์๋ ๋น๋ฒ์ ์ญ์ฐํ ์ ์์ต๋๋ค. - BCrypt๋ โ ์ํธ(salt, ๋ฌด์์ ์๊ธ) ๋ฅผ ๋งค๋ฒ ์์ด ๊ฐ์ ๋น๋ฒ๋ ๋งค๋ฒ ๋ค๋ฅธ ํด์๊ฐ ๋์ค๊ณ (๋ ์ธ๋ณด์ฐ ํ ์ด๋ธ ๋ฐฉ์ด), โก ์ผ๋ถ๋ฌ ๋๋ฆฌ๊ฒ(work factor) ๋ง๋ค์ด ๋ฌด์ฐจ๋ณ ๋์ ์ ๋น์ธ๊ฒ ํฉ๋๋ค.
- BCrypt ํด์๋ ํญ์
$2a$/$2b$/$2y$๊ฐ์$2๋ก ์์ํฉ๋๋ค. ์ด ๋ฌธ์ ๋ค์์ ์ด ์ ๋์ฌ๋ก "ํด์์ธ๊ฐ ํ๋ฌธ์ธ๊ฐ"๋ฅผ ๊ตฌ๋ถํ๋ ์ฝ๋๊ฐ ๋์ต๋๋ค.
4) OAuth ์์ ๋ก๊ทธ์ธ = "๋ค๋ฅธ ๊ธฐ๊ด ์ ๋ถ์ฆ์ผ๋ก ์ ์ฅ"
- ์ฌ์ฉ์๋ ๊ตฌ๊ธ/๊นํ๋ธ์ ๋ก๊ทธ์ธ โ ๊ทธ ๊ธฐ๊ด์ด access_token(์์ ์ถ์ ์ฆ)์ ๋ฐ๊ธ โ ํ๋ก ํธ๊ฐ ๊ทธ๊ฑธ ์ฐ๋ฆฌ ๋ฐฑ์๋๋ก ์ ๋ฌ.
- ํต์ฌ ๋ณด์ ์์น: ํ๋ก ํธ๊ฐ ๊ฐ์ด ๋ณด๋ธ "์ด ์ฌ๋ ์ด๋ฉ์ผ์ X"๋ผ๋ ๋ง์ ๋ฏฟ์ผ๋ฉด ์ ๋ฉ๋๋ค(์์กฐ ๊ฐ๋ฅ). ๋ฐฑ์๋๊ฐ ๊ทธ access_token์ ๋ค๊ณ ์ง์ ๊ตฌ๊ธ/๊นํ๋ธ์ ์ ํํด์ "์ด ํ ํฐ์ ์ง์ง ์ด๋ฉ์ผ์ด ๋ญ๋"๋ฅผ ๋ฐ์์์ผ ํฉ๋๋ค. (์ด๊ฒ ์๋ C2 ์ทจ์ฝ์ ์์ ์ ํต์ฌ.)
5) Access Token / Refresh Token ์ = "์งง์ ์ถ์ ์ฆ + ๊ธด ์ฌ๋ฐ๊ธ ์ฟ ํฐ"
- Access Token(JWT): ์๋ช ์ด ์งง์(์ฌ๊ธฐ์ 1์๊ฐ). ๋งค ์์ฒญ๋ง๋ค ์ ์. ํ์ทจ๋ผ๋ ๊ณง ๋ง๋ฃ๋ผ ํผํด๊ฐ ์์.
- Refresh Token: ์๋ช ์ด ๊น(์ฌ๊ธฐ์ 15์ผ). DB์ ์ ์ฅ. Access๊ฐ ๋ง๋ฃ๋๋ฉด ์ด๊ฑธ ์ ์ํด ์ Access๋ฅผ ๋ฐ์ต๋๋ค(์ฌ๋ก๊ทธ์ธ ์์ด). ์ถ์ ์ฆ์ด ๋ง๋ฃ๋๋ฉด ์๋ด๋ฐ์คํฌ์ ์ฟ ํฐ์ ๋ด๋ฐ์ด ์ ์ถ์ ์ฆ์ ๋ฐ๋ ์ .
- ์ ๋๋ก ๋๋๋? ํธ์(์์ฃผ ๋ก๊ทธ์ธ ์ ํจ) โ ๋ณด์(์งง์ access) ์ ๊ท ํ. Refresh๋ ์์ฃผ ์ ์ฐ์ด๊ณ DB์์ ํ๊ธฐ(๋ก๊ทธ์์ยท๋ง๋ฃ)ํ ์ ์์ด ํต์ ๊ฐ ์ฝ์ต๋๋ค.
๐บ ์์ฒญ ํ๋ฆ๋
(A) ํ์๊ฐ์ / ๋ก๊ทธ์ธ โ ์ถ์ ์ฆ ๋ฐ๊ธ
[ํ๋ก ํธ] POST /api/auth/login {email, password}
โ
โผ
AuthController.login()
โ authService.login(request)
โผ
AuthService.login()
โโ findByEmail ์ผ๋ก User ์กฐํ (์์ผ๋ฉด "๊ฐ์
๋์ง ์์ ์ด๋ฉ์ผ")
โโ stored.startsWith("$2") ?
โ ์ โ passwordEncoder.matches(raw, stored) (BCrypt ๋์กฐ)
โ ์๋์ค โ ํ๋ฌธ ๋น๊ต โ ์ผ์น ์ ์ฆ์ BCrypt ์ฌํด์ฑ ํ ์ ์ฅ โ ๏ธC1
โโ ๋ถ์ผ์น โ "๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค"
โ (User ๋ฐํ)
โผ
jwtUtil.issue(uid, email, type) โ access (JWT, 1h)
issueRefreshToken(uid) โ refresh (UUID, DB์ ์ฅ, 15d)
โ
โผ
withTokenCookies(access, refresh)
โโ Set-Cookie: DEVBRIDGE_TOKEN (HttpOnly, path=/)
โโ Set-Cookie: DEVBRIDGE_REFRESH (HttpOnly, path=/api/auth)
โ
โผ
[ํ๋ก ํธ] ์ฟ ํค 2์ฅ ์ ์ฅ(์๋) + AuthResponse(body) ์์ (B) ์ดํ ๋งค ์์ฒญ โ ๊ฒฝ๋น์์ด ์ ๋ถ ํ์ธ
[ํ๋ก ํธ] GET /api/profile/me/detail (์ฟ ํค DEVBRIDGE_TOKEN ์๋ ๋๋ด)
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ JwtAuthenticationFilter (๋ชจ๋ ์์ฒญ 1๋ฒ์ฉ) โ โ Order: ๊ฐ์ฅ ๋จผ์
โ extractToken: โ ์ฟ ํค DEVBRIDGE_TOKEN โ
โ โกAuthorization: Bearer (๋ ๊ฑฐ์)โ
โ jwtUtil.parse(token) (์๋ช
ยท๋ง๋ฃ ๊ฒ์ฆ) โ
โ ์ฑ๊ณต โ request.setAttribute("auth.userId")โ
โ ์คํจ โ ์กฐ์ฉํ ํต๊ณผ(์ต๋ช
) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
ProfileController.myDetail()
Long userId = AuthContext.currentUserId(); โ ๋์ฅ ํ๋
if (userId == null) โ 401
โ
โผ
ProfileService.getDetail(userId) โ ์๋ต
๐ก ํต์ฌ: ํํฐ๋ ์ธ์ฆ ์คํจํด๋ ๋ง์ง ์๊ณ ๊ทธ๋ฅ ํต๊ณผ์ํต๋๋ค("์ต๋ช ์์ฒญ"). ์ค์ ์ฐจ๋จ์ ๊ฐ ์ปจํธ๋กค๋ฌ๊ฐ
AuthContext.currentUserId() == null์ ๋ณด๊ณ 401์ ๋ด๋ ๋ฐฉ์์ ๋๋ค. ์ด๊ฒ ์ด ํ๋ก์ ํธ์ ๊ฒฝ๋ ์ธ์ฆ ํจํด์ ๋๋ค.
(C) ์์ ๋ก๊ทธ์ธ (Google)
[ํ๋ก ํธ] (๊ตฌ๊ธ ๋ก๊ทธ์ธ) โ access_token ํ๋ โ POST /api/auth/social-login {accessToken}
โ
โผ
AuthController.socialLogin()
verifyGoogleAccessToken(accessToken)
โ GET googleapis.com/oauth2/v3/userinfo (Bearer) โ ๏ธC2: ์๋ฒ๊ฐ ์ง์ ๊ฒ์ฆ
โ email + email_verified ํ์ธ
โ (๊ฒ์ฆ๋ email)
โผ
authService.socialLogin(email) โ findByEmail (๊ฐ์
๋ผ ์์ด์ผ ํจ)
โ
โผ
(A)์ ๋์ผํ๊ฒ JWT + Refresh ์ฟ ํค ๋ฐ๊ธ๐ ํต์ฌ ํด๋์ค ๋ผ์ธ๋ณ ์ฌํ
A. AuthController โ ์ถ์
์ฆ ๋ฐ๊ธ ์ฐฝ๊ตฌ
A-1. ํด๋์ค ์ ์ธ + ์ฟ ํค ์ด๋ฆ ์์ โ AuthController.java:30-42
// L30-L42
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
public static final String AUTH_COOKIE_NAME = "DEVBRIDGE_TOKEN";
public static final String REFRESH_COOKIE_NAME = "DEVBRIDGE_REFRESH";
private final AuthService authService;
private final JwtUtil jwtUtil;
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
@RestController= "์ด ํด๋์ค์ ๋ฉ์๋ ๋ฐํ๊ฐ์ JSON์ผ๋ก ์๋ตํ๋ค".@RequestMapping("/api/auth")= ์ด ํด๋์ค์ ๋ชจ๋ URL์/api/auth๋ก ์์.@RequiredArgsConstructor(๋กฌ๋ณต) =finalํ๋๋ค์ ๋ฐ๋ ์์ฑ์๋ฅผ ์๋ ์์ฑ โ ์คํ๋ง์ด ๊ทธ ์์ฑ์๋ก ์์กด์ฑ ์ฃผ์ (DI).newํค์๋ ์์ดauthService,jwtUtil๋ฑ์ ์คํ๋ง์ด ์ฑ์์ค๋๋ค.AUTH_COOKIE_NAME/REFRESH_COOKIE_NAME์ดpublic static final์ธ ์ด์ : ๋ค๋ฅธ ํด๋์ค(JwtAuthenticationFilter)๋ ๊ฐ์ ์ฟ ํค ์ด๋ฆ์ ์ฐธ์กฐํด์ผ ํ๊ธฐ ๋๋ฌธ(AuthController.AUTH_COOKIE_NAME). ์ด๋ฆ์ ํ ๊ณณ์์๋ง ์ ์ํด ์คํยท๋ถ์ผ์น๋ฅผ ๋ง๋ ํจํด.
๐ก ์ด๋ณด ํฌ์ธํธ: ์ฟ ํค ์ด๋ฆ์ด ๋ ๊ณณ(๋ฐ๊ธ=AuthController, ์ฝ๊ธฐ=Filter)์์ ์ฐ์ด๋๋ฐ ๋์ด ๋ค๋ฅด๋ฉด "๋ก๊ทธ์ธ์ ๋๋๋ฐ ๊ทธ๋ค์ ์์ฒญ์ด ์ต๋ช "์ด ๋๋ ๋ฏธ๋ฌํ ๋ฒ๊ทธ๊ฐ ๋ฉ๋๋ค. ๊ทธ๋์ ์์ ํ ๊ณณ์์ ๊ณต์ .
A-2. ์ค์ ๊ฐ ์ฃผ์
(@Value) โ AuthController.java:46-62
// L46-L62
@Value("${app.cookie.secure:false}")
private boolean cookieSecure;
@Value("${app.cookie.same-site:Lax}")
private String cookieSameSite;
@Value("${app.jwt.ttl-hours:1}")
private long jwtTtlHours;
@Value("${app.jwt.refresh-ttl-days:15}")
private long refreshTtlDays;
@Value("${ํค:๊ธฐ๋ณธ๊ฐ}")=application.properties(๋๋ ํ๊ฒฝ๋ณ์)์์ ๊ฐ์ ์ฝ์ด ํ๋์ ์ฃผ์ . ์ฝ๋ก ๋ค๊ฐ ๊ธฐ๋ณธ๊ฐ.cookieSecure:falseโ ๋ก์ปฌ(http)์์๋false๋ผ์ผ ์ฟ ํค๊ฐ ์ ์ก๋จ. ์ด์(prod)์์๋ ๋ฐ๋์true(HTTPS์์๋ง ์ฟ ํค ์ ์ก)๋ก ์ค์ ํด์ผ ํฉ๋๋ค. ์ด๊ฒ ๋น ์ง๋ฉด ํ๋ฌธ HTTP๋ก ์ฟ ํค๊ฐ ์ ๋๊ฐ ์ ์์ด์.jwtTtlHours:1= Access ํ ํฐ ์๋ช 1์๊ฐ,refreshTtlDays:15= Refresh 15์ผ. ์์ ์ฌ์ ์ง์ 5๋ฒ์ "์งง์ access / ๊ธด refresh".
A-3. ์ฟ ํค ๊ตฝ๊ธฐ ํฌํผ โ AuthController.java:66-94
// L66-L84
private ResponseCookie buildAuthCookie(String token) {
return ResponseCookie.from(AUTH_COOKIE_NAME, token)
.httpOnly(true)
.secure(cookieSecure)
.sameSite(cookieSameSite)
.path("/")
.maxAge(java.time.Duration.ofHours(jwtTtlHours))
.build();
}
private ResponseCookie buildRefreshCookie(String token) {
return ResponseCookie.from(REFRESH_COOKIE_NAME, token)
.httpOnly(true)
.secure(cookieSecure)
.sameSite(cookieSameSite)
.path("/api/auth") // refresh ์๋ํฌ์ธํธ๋ก๋ง ์ ์ก
.maxAge(java.time.Duration.ofDays(refreshTtlDays))
.build();
}
.httpOnly(true)โ JS๊ฐ ๋ชป ์ฝ์(XSS ๋ฐฉ์ด, ์ฌ์ ์ง์ 2). ๋ ์ฟ ํค ๋ชจ๋ ์ ์ฉ..path("/")vs.path("/api/auth")โ ์ฌ๊ธฐ๊ฐ ์๋ฆฌํ ๋ณด์ ์ค๊ณ:- Access ์ฟ ํค๋
path=/โ ๋ชจ๋ API ์์ฒญ์ ๋๋ด(์ด๋์๋ ์ธ์ฆ ํ์). - Refresh ์ฟ ํค๋
path=/api/authโ ์ค์ง/api/auth/*์์ฒญ์๋ง ๋ธ๋ผ์ฐ์ ๊ฐ ๋ณด๋. ์ฆ ํ์ ์ผ๋ฐ API ์์ฒญ์๋ refresh๊ฐ ๋ ธ์ถ์กฐ์ฐจ ์ ๋จ โ ํ์ทจ ํ๋ฉด ์ถ์.
- Access ์ฟ ํค๋
.maxAge(...)โ ์ฟ ํค ์์ฒด์ ๋ง๋ฃ(๋ธ๋ผ์ฐ์ ๊ฐ ์๋ ์ญ์ ). Access๋ 1์๊ฐ, Refresh๋ 15์ผ.buildClearCookie(L86-94)๋ ๊ฐ์ ์ฟ ํค๋ฅผ ๋น ๊ฐ + maxAge(0) ์ผ๋ก ๋ค์ ๊ตฌ์ ์ฆ์ ์ญ์ ์ํต๋๋ค(๋ก๊ทธ์์์ฉ).
โ ๏ธ ํจ์ : ์ฟ ํค๋ฅผ ์ง์ธ ๋๋ ๋ง๋ค ๋์ ๋์ผํ
path/์์ฑ์ผ๋ก ๊ตฌ์์ผ ๋ธ๋ผ์ฐ์ ๊ฐ "๊ฐ์ ์ฟ ํค"๋ก ์ธ์ํด ์ญ์ ํฉ๋๋ค. ๊ทธ๋์logout์์buildClearCookie(REFRESH_COOKIE_NAME, "/api/auth")์ฒ๋ผ path๋ฅผ ์ ํํ ๋ง์ถฅ๋๋ค(L364).
A-4. Refresh ํ ํฐ ๋ฐ๊ธ ํฌํผ โ AuthController.java:98-108
// L98-L108
private String issueRefreshToken(Long userId) {
String raw = UUID.randomUUID().toString().replace("-", "");
refreshTokenRepository.save(RefreshToken.of(userId, raw, refreshTtlDays));
return raw;
}
private ResponseEntity.BodyBuilder withTokenCookies(String accessToken, String refreshToken) {
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, buildAuthCookie(accessToken).toString())
.header(HttpHeaders.SET_COOKIE, buildRefreshCookie(refreshToken).toString());
}
- Refresh ํ ํฐ์ JWT๊ฐ ์๋๋ผ ๊ทธ๋ฅ ๋ฌด์์ UUID ๋ฌธ์์ด(
replace("-","")๋ก ํ์ดํ ์ ๊ฑฐ โ 32์). JWT์ ๋ฌ๋ฆฌ "์๋ฏธ ์๋ ์์ ๊ฐ"์ด๊ณ , ์ ํจ์ฑ์ DB ์กด์ฌ ์ฌ๋ถ๋ก๋ง ํ๋จํฉ๋๋ค(์๋ refresh ์ฐธ๊ณ ). ๊ทธ๋์ DB์์ ์ง์ฐ๋ฉด ์ฆ์ ๋ฌดํจํ ๊ฐ๋ฅ. withTokenCookiesโSet-Cookieํค๋๋ฅผ ๋ ๋ฒ ์ถ๊ฐ(access + refresh). HTTP๋ Set-Cookie ํค๋๋ฅผ ์ฌ๋ฌ ๊ฐ ๋ณด๋ผ ์ ์์ด ์ฟ ํค 2์ฅ์ ๋์์ ๊ตฝ์ต๋๋ค.
A-5. ํ์๊ฐ์
signup โ AuthController.java:112-135
// L112-L130
@PostMapping("/signup")
public ResponseEntity<AuthResponse> signup(@RequestBody SignupRequest request) {
try {
User user = authService.signup(request);
String userType = user.getUserType() != null ? user.getUserType().name() : "GUEST";
String access = jwtUtil.issue(user.getId(), user.getEmail(), userType);
String refresh = issueRefreshToken(user.getId());
return withTokenCookies(access, refresh)
.body(AuthResponse.builder()
.userId(user.getId())
.email(user.getEmail())
...
.token(access)
.message("ํ์๊ฐ์
์ด ์๋ฃ๋์์ต๋๋ค.")
.build());
} catch (Exception e) {
return ResponseEntity.badRequest().body(AuthResponse.builder()
.message(e.getMessage()).build());
}
}
@PostMapping("/signup")โPOST /api/auth/signup.@RequestBody SignupRequest= ์์ฒญ JSON ๋ณธ๋ฌธ์SignupRequest๊ฐ์ฒด๋ก ์๋ ์ญ์ง๋ ฌํ.- ํ๋ฆ: ๊ฐ์
์
AuthService.signup์ ์์(๋น์ฆ๋์ค ๋ก์ง ๋ถ๋ฆฌ) โ ๋์์จUser๋ก ์ฆ์ ํ ํฐ ๋ฐ๊ธ(๊ฐ์ ๊ณผ ๋์์ ๋ก๊ทธ์ธ ์ํ๋ก). ์ด๊ฑธ "auto-login on signup" ํจํด์ด๋ผ ํฉ๋๋ค. userType.name()์ด null์ด๋ฉด"GUEST"ํด๋ฐฑ โ ํ ํฐ์typeํด๋ ์์ ๋ค์ด๊ฐ ๋ฌธ์์ด..token(access)โ ์ฟ ํค๋ก๋ ์ฃผ์ง๋ง ์๋ต ๋ณธ๋ฌธ์๋ access ํ ํฐ์ ๊ฐ์ด ๋ด์ต๋๋ค(๋ ๊ฑฐ์ ํธํ: ํค๋ ๋ฐฉ์ ํด๋ผ์ด์ธํธ ๋๋น. ์๋ ํํฐ 2์์ ์ฐธ๊ณ ).- ์์ธ ์ฒ๋ฆฌ: ๊ฐ์
์คํจ(์ค๋ณต ์ด๋ฉ์ผ ๋ฑ)๋ฉด
400 Bad Request+ ํ๊ธ ๋ฉ์์ง. ์ปจํธ๋กค๋ฌ๊ฐ ์์ธ๋ฅผ ์ก์ ์ฌ์ฉ์ ์นํ ๋ฉ์์ง๋ก ๋ณํ.
A-6. ๋ก๊ทธ์ธ login โ AuthController.java:137-160
๊ตฌ์กฐ๋ signup๊ณผ ๊ฑฐ์ ๋์ผ(authService.login โ ํ ํฐ ๋ฐ๊ธ โ ์ฟ ํค). ์ฐจ์ด๋ ์ง์
์ ์ด authService.login์ด๋ผ๋ ๊ฒ๋ฟ. ๋น๋ฐ๋ฒํธ ๋์กฐ์ ์ง์ง ๋ก์ง์ AuthService.login์ ์์ต๋๋ค(์๋ B-2์์ ์ฌํ).
A-7. ํ ํฐ ์ฌ๋ฐ๊ธ refresh โ AuthController.java:166-191
// L166-L191
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request) {
String rawToken = extractCookie(request, REFRESH_COOKIE_NAME);
if (rawToken == null || rawToken.isBlank()) {
return ResponseEntity.status(401).body(Map.of("message", "Refresh token ์์"));
}
RefreshToken rt = refreshTokenRepository.findByToken(rawToken).orElse(null);
if (rt == null || rt.isExpired()) {
if (rt != null) refreshTokenRepository.deleteByToken(rawToken);
return ResponseEntity.status(401).body(Map.of("message", "Refresh token ๋ง๋ฃ ๋๋ ์ ํจํ์ง ์์"));
}
User user = userRepository.findById(rt.getUserId()).orElse(null);
if (user == null) {
refreshTokenRepository.deleteByToken(rawToken);
return ResponseEntity.status(401).body(Map.of("message", "์ฌ์ฉ์ ์์"));
}
String userType = user.getUserType() != null ? user.getUserType().name() : "GUEST";
String newAccess = jwtUtil.issue(user.getId(), user.getEmail(), userType);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, buildAuthCookie(newAccess).toString())
.body(Map.of("message", "ํ ํฐ ์ฌ๋ฐ๊ธ ์๋ฃ"));
}
- ์๋๋ฆฌ์ค: Access(1์๊ฐ)๊ฐ ๋ง๋ฃ๋๋ฉด, ํ๋ก ํธ๋ ์ฌ๋ก๊ทธ์ธ ๋์ ์ด
/api/auth/refresh๋ฅผ ํธ์ถ. (Refresh ์ฟ ํค๋path=/api/auth๋ผ ์ฌ๊ธฐ์๋ง ๋๋ด๋จ โ A-3์ ์ค๊ณ๊ฐ ๋น๋๋ ์๊ฐ.) - ๊ฒ์ฆ 3๋จ:
- ์ฟ ํค์ refresh๊ฐ ์๋ โ ์์ผ๋ฉด 401.
- DB์ ๊ทธ ํ ํฐ์ด ์กด์ฌํ๊ณ ์ ๋ง๋ฃ๋๋(
findByToken+isExpired) โ ๋ง๋ฃ๋ฉด DB์์ ์ญ์ ํ๊ณ 401(์ฐ๋ ๊ธฐ ์ฒญ์). - ๊ทธ ํ ํฐ์ ์ฃผ์ธ User๊ฐ ์ค์ ์กด์ฌํ๋ โ ํํดํ ์ฌ์ฉ์๋ฉด ํ ํฐ ์ญ์ ํ 401.
- ํต๊ณผํ๋ฉด ์ Access๋ง ๋ฐ๊ธ(
buildAuthCookie๋ง). Refresh๋ ๊ทธ๋๋ก ๋์ด 15์ผ ๋์ ์ฌ์ฌ์ฉ. (์ด๋ฅผ "non-rotating refresh"๋ผ ํฉ๋๋ค โ ๊ณ ๋ํ ์์ด๋์ด์์ ํ์ ๋ฐฉ์ ๋ ผ์.)
๐ก ์ด๋ณด ํฌ์ธํธ: refresh ๊ฒ์ฆ์ด JWT ํ์ฑ์ด ์๋๋ผ DB ์กฐํ๋ผ๋ ์ ์ด ์ค์. Access(JWT)๋ "์๋ช ๋ง ๋ง์ผ๋ฉด ํต๊ณผ"(stateless)์ง๋ง, Refresh๋ "DB์ ์ด์์์ด์ผ ํต๊ณผ"(stateful). ๊ทธ๋์ ๋ก๊ทธ์์/ํํด ์ DB์์ ์ง์ฐ๋ฉด ์ฆ์ ๋ฌดํจํ๋ฉ๋๋ค.
A-8. ์์
๋ก๊ทธ์ธ socialLogin + ๊ตฌ๊ธ ๊ฒ์ฆ โ AuthController.java:193-253
// L193-L210
@PostMapping("/social-login")
public ResponseEntity<AuthResponse> socialLogin(@RequestBody Map<String, String> request) {
String accessToken = request.get("accessToken");
if (accessToken == null || accessToken.isBlank()) {
return ResponseEntity.badRequest().body(AuthResponse.builder()
.message("์์
๋ก๊ทธ์ธ ํ ํฐ(accessToken)์ด ํ์ํฉ๋๋ค.").build());
}
// ๋ณด์(C2): ํด๋ผ์ด์ธํธ๊ฐ ๋ณด๋ธ email ์ ์ ๋ขฐํ์ง ์๋๋ค. accessToken ์ Google ์ ์ง์ ๊ฒ์ฆํด verified email ์ ์ป๋๋ค.
String email;
try {
email = verifyGoogleAccessToken(accessToken);
} catch (Exception e) {
return ResponseEntity.status(401).body(AuthResponse.builder()
.message("์์
์ธ์ฆ ๊ฒ์ฆ ์คํจ โ ๋ค์ ์๋ํด ์ฃผ์ธ์.").build());
}
...
}
- ์ด 4์ค์ง๋ฆฌ ์ฃผ์์ด ๋๋ฉ์ธ ์ ์ฒด์์ ๊ฐ์ฅ ์ค์ํ ๋ณด์ ํ ์ค์
๋๋ค. ํ๋ก ํธ๊ฐ ๋ณด๋ธ
email์ ์ ๋ ์ ์๋๋ค. ์ค์ง ํ๋ก ํธ๊ฐ ์คaccessToken๋ง ๋ฐ์, ์๋ฒ๊ฐ ์ง์ ๊ตฌ๊ธ์ ๋ฌผ์ด๋ด ๋๋ค. - ์? ๋ง์ฝ
email = request.get("email")๋ก ํด๋ผ์ด์ธํธ ๋ง์ ๋ฏฟ์ผ๋ฉด, ๊ณต๊ฒฉ์๊ฐ{accessToken: ๋ดํ ํฐ, email: victim@x.com}์ ๋ณด๋ด ๋จ์ ๊ณ์ ์ผ๋ก ๋ก๊ทธ์ธํ ์ ์์ต๋๋ค(IDOR/๊ณ์ ํ์ทจ). ์ด๊ฒ ํจ์ ์น์ C2.
// L238-L253
@SuppressWarnings("unchecked")
private String verifyGoogleAccessToken(String accessToken) {
var headers = new org.springframework.http.HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
Map<String, Object> info = restTemplate.exchange(
"https://www.googleapis.com/oauth2/v3/userinfo",
HttpMethod.GET, new org.springframework.http.HttpEntity<>(headers), Map.class
).getBody();
if (info == null) throw new RuntimeException("Google userinfo ์๋ต ์์");
Object email = info.get("email");
if (email == null || String.valueOf(email).isBlank()) throw new RuntimeException("์ด๋ฉ์ผ ์ ๋ณด ์์");
Object verified = info.get("email_verified");
boolean isVerified = Boolean.TRUE.equals(verified) || "true".equalsIgnoreCase(String.valueOf(verified));
if (!isVerified) throw new RuntimeException("์ด๋ฉ์ผ ๋ฏธ์ธ์ฆ Google ๊ณ์ ");
return String.valueOf(email);
}
restTemplate.exchange(...userinfo..., Bearer accessToken)โ ๊ตฌ๊ธ์ ๊ณต์ userinfo ์๋ํฌ์ธํธ์ ๊ทธ ํ ํฐ์ผ๋ก ์ง์ GET. ํ ํฐ์ด ๊ฐ์ง๋ฉด ๊ตฌ๊ธ์ด 401์ ์ฃผ๊ณ ์ฌ๊ธฐ์ ์์ธ โ ํต๊ณผ ๋ชป ํจ. ์ด๊ฒ "์์กฐ ๋ถ๊ฐ"์ ๊ทผ๊ฑฐ.email_verified๋ ํ์ธ โ ๊ตฌ๊ธ์์ ์ด๋ฉ์ผ ์ธ์ฆ๊น์ง ๋ง์น ๊ณ์ ๋ง ํ์ฉ(๋ฏธ์ธ์ฆ ๊ตฌ๊ธ ๊ณ์ ์ผ๋ก ๋จ์ ์ด๋ฉ์ผ ์ ์ ๋ฐฉ์ง).- ๊ฒ์ฆ๋ email๋ก
authService.socialLogin(email)ํธ์ถ โ ๊ฐ์ ๋ผ ์์ผ๋ฉด ํ ํฐ ๋ฐ๊ธ, ์์ผ๋ฉด "๊ฐ์ ๋์ง ์์ ์ด๋ฉ์ผ" ์์ธ(์์ ์ ์๋๊ฐ์ ์ ํจ, ๊นํ๋ธ์ ์ฐจ์ด).
A-9. GitHub ๋ก๊ทธ์ธ githubLogin โ AuthController.java:258-354
๊ตฌ๊ธ๊ณผ ํ๋ฆ์ ๋น์ทํ๋ ๋จ๊ณ๊ฐ ๋ ๋ง์ต๋๋ค(GitHub๋ code โ token โ email โ profile 4ํ):
- code โ access_token ๊ตํ(L269-290): ํ๋ก ํธ๊ฐ ๋ฐ์
code๋ฅผ ์ฐ๋ฆฌclient_id/secret๊ณผ ํจ๊ป GitHub์ ๋ณด๋ด access_token์ผ๋ก ๊ตํ. (secret์ ์๋ฒ๋ง ๋ณด๊ด โ ํ๋ก ํธ๊ฐ ํ๋ด ๋ชป ๋.) - ์ธ์ฆ๋ primary ์ด๋ฉ์ผ ์กฐํ(L298-321):
/user/emails์์primary && verified์ธ ์ด๋ฉ์ผ ์ฐ์ , ์์ผ๋ฉดverified์ธ ๊ฒ โ ์ธ์ฆ๋ ์ด๋ฉ์ผ๋ง ์ฑํ. - ํ๋กํ ์กฐํ(L323-330):
/user์์login(=๊นํ๋ธ username) ํ๋ณด. - ๊ณ์ ์กฐํ ๋๋ ์๋ ์์ฑ(L332):
authService.findOrCreateGithubUser(email, login, token)โ ๊ตฌ๊ธ๊ณผ ๋ฌ๋ฆฌ ์์ผ๋ฉด ์๋ ๊ฐ์ (B-4 ์ฐธ๊ณ ).
๐ก ๋ ์์ ์ ์ฐจ์ด: ๊ตฌ๊ธ์ "์ด๋ฏธ ๊ฐ์ ํ ์ฌ๋๋ง ์์ ๋ก๊ทธ์ธ ํ์ฉ", ๊นํ๋ธ๋ "์์ผ๋ฉด ๋ง๋ค์ด์ค(๊ฐ๋ฐ์ ์ฐ๋ ๋ชฉ์ )". ๊ฐ์ OAuth๋ผ๋ ์ ์ฑ ์ด ๋ค๋ฅผ ์ ์์์ ๋ณด์ฌ์ฃผ๋ ์ข์ ์.
A-10. ๋ก๊ทธ์์ logout โ AuthController.java:356-366
// L356-L366
@PostMapping("/logout")
public ResponseEntity<Map<String, String>> logout(HttpServletRequest request) {
String rawToken = extractCookie(request, REFRESH_COOKIE_NAME);
if (rawToken != null && !rawToken.isBlank()) {
refreshTokenRepository.deleteByToken(rawToken);
}
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, buildClearCookie(AUTH_COOKIE_NAME, "/").toString())
.header(HttpHeaders.SET_COOKIE, buildClearCookie(REFRESH_COOKIE_NAME, "/api/auth").toString())
.body(Map.of("message", "๋ก๊ทธ์์ ๋์์ต๋๋ค."));
}
- ๋ก๊ทธ์์์ ๋ ๊ฐ์ง๋ฅผ ๋์์: โ DB์ refresh ํ ํฐ ์ญ์ (์๋ฒ์ธก ๋ฌดํจํ โ ๋๋ ์ฌ๋ฐ๊ธ ๋ถ๊ฐ), โก ๋ธ๋ผ์ฐ์ ์ฟ ํค 2์ฅ ์ญ์ (maxAge=0).
- Access(JWT)๋ stateless๋ผ ์๋ฒ๊ฐ "์ทจ์"ํ ์ ์์ต๋๋ค โ ํ์ง๋ง ์ฟ ํค๋ฅผ ์ง์ฐ๊ณ 1์๊ฐ ๋ค ์์ฐ ๋ง๋ฃ๋๋ฉฐ, refresh๊ฐ ์ฃฝ์์ผ๋ ๊ฐฑ์ ๋ ์ ๋ฉ๋๋ค. ์ฌ์ค์ ๋ก๊ทธ์์ ์๋ฃ.
A-11. ์ฟ ํค ์ถ์ถ ์ ํธ โ AuthController.java:370-378
// L370-L378
private String extractCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) return null;
return Arrays.stream(cookies)
.filter(c -> name.equals(c.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}
- ์์ฒญ์ ์ฟ ํค ๋ฐฐ์ด์์ ์ด๋ฆ์ด ์ผ์นํ๋ ์ฒซ ์ฟ ํค์ ๊ฐ์ ๊บผ๋.
getCookies()๋ ์ฟ ํค๊ฐ ํ๋๋ ์์ผ๋ฉด null์ ๋ฐํํ๋ฏ๋ก null ์ฒดํฌ ํ์(ํํ NPE ํจ์ ).
B. AuthService โ ์ ์ ํ์ธ ๋ด๋น๊ด
B-1. ํ์๊ฐ์
signup โ AuthService.java:26-59
// L26-L44
@Transactional
public User signup(SignupRequest request) {
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new RuntimeException("์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ์ด๋ฉ์ผ์
๋๋ค.");
}
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
throw new RuntimeException("์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ์ฌ์ฉ์ ์ด๋ฆ์
๋๋ค.");
}
User user = User.builder()
.email(request.getEmail())
.phone(request.getPhone())
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword())) // โ
BCrypt ํด์ฑ
.userType(request.getUserType())
.birthDate(request.getBirthDate())
.build();
User savedUser = userRepository.save(user);
...
}
@Transactional= ์ด ๋ฉ์๋ ์์ DB ์์ ์ ํ๋์ ํธ๋์ญ์ ์ผ๋ก ๋ฌถ์. ์ค๊ฐ์ ์์ธ๊ฐ ๋๋ฉด ์ ๋ถ ๋กค๋ฐฑ(์: ์ ์ ์ ์ฅ ํ ํ๋กํ ์ ์ฅ ์คํจ ์ ์ ์ ๋ ์ทจ์).- ์ค๋ณต ๊ฒ์ฌ: ์ด๋ฉ์ผยทusername ๋ ๋ค
unique์ ์ฝ์ด DB์๋ ์์ง๋ง(์ํฐํฐ ์ฐธ๊ณ ), ์ฌ๊ธฐ์ ๋ฏธ๋ฆฌ ๊ฒ์ฌํด ์น์ ํ ํ๊ธ ๋ฉ์์ง๋ฅผ ์ค๋๋ค. (DB ์ ์ฝ์ ์ตํ์ ๋ฐฉ์ด์ , ์ด๊ฑด UX ๋ฐฉ์ด์ .) passwordEncoder.encode(...)= ํ๋ฌธ ๋น๋ฒ์ BCrypt ํด์๋ก ๋ณํํด ์ ์ฅ. ์ด ํ ์ค์ด ํจ์ ์น์ C1์ ํต์ฌ โ DB์ ์ ๋ ํ๋ฌธ์ด ์ ๋ค์ด๊ฐ๋๋ค.- ์ดํ(L46-58):
userType == FREE๋ฉดClientProfile์์ฑ, ๊ทธ๋ฆฌ๊ณ Stream Chat์ ์ ์ ๋๊ธฐํ(์คํจํด๋ ๊ฐ์ ์ ์งํ โ try/catch๋ก ๊ฒฉ๋ฆฌ).
B-2. ๋ก๊ทธ์ธ + ๋ ๊ฑฐ์ ๋น๋ฒ ํฌ๋ช
๋ง์ด๊ทธ๋ ์ด์
โ AuthService.java:84-106 โญ
// L84-L106
@Transactional
public User login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new RuntimeException("๊ฐ์
๋์ง ์์ ์ด๋ฉ์ผ์
๋๋ค."));
String stored = user.getPassword();
String raw = request.getPassword();
boolean ok;
if (stored != null && stored.startsWith("$2")) {
ok = passwordEncoder.matches(raw, stored); // BCrypt ํด์ ๋น๊ต
} else {
// ๋ ๊ฑฐ์ ํ๋ฌธ ๋น๋ฒ โ ์ผ์น ์ ์ฆ์ BCrypt ๋ก ์ฌํด์ฑ(ํฌ๋ช
๋ง์ด๊ทธ๋ ์ด์
)
ok = stored != null && stored.equals(raw);
if (ok) {
user.setPassword(passwordEncoder.encode(raw));
userRepository.save(user);
}
}
if (!ok) {
throw new RuntimeException("๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค.");
}
return user;
}
- ์ด ๋ฉ์๋๊ฐ C1 ๋ณด์ ์์ ์ ์๋งน์ด์
๋๋ค. ๋ ๊ฐ๋:
stored.startsWith("$2")โ ์ด๋ฏธ BCrypt ํด์(์ฌ์ ์ง์ 3์$2์ ๋์ฌ) โpasswordEncoder.matches(raw, stored)๋ก ์์ ๋น๊ต. (matches๋ ํ๋ฌธ raw๋ฅผ ๊ฐ์ ์ํธ๋ก ํด์ฑํด ๋น๊ต โ ํ๋ฌธ ๋น๋ฒ์ ์ ๋ ๋ณต์ํ์ง ์์.)- ๊ทธ ์ธ(๋ ๊ฑฐ์ ํ๋ฌธ) โ ๊ณผ๊ฑฐ์ ํ๋ฌธ์ผ๋ก ์ ์ฅ๋ ๊ณ์ . ํ๋ฌธ๋ผ๋ฆฌ ๋น๊ตํด ๋ง์ผ๋ฉด ๊ทธ ์๋ฆฌ์์ ์ฆ์ BCrypt๋ก ์ฌํด์ฑํด ์ ์ฅ(
encodeํsave). ์ด๊ฑธ ํฌ๋ช ๋ง์ด๊ทธ๋ ์ด์ ์ด๋ผ ํฉ๋๋ค โ ์ฌ์ฉ์๋ ๋ชจ๋ฅด๋ ์, ๋ก๊ทธ์ธ ์ฑ๊ณต ์๊ฐ ๊ทธ ๊ณ์ ์ ๋น๋ฒ์ด ์์ ํ ํด์๋ก ์ ๊ทธ๋ ์ด๋๋ฉ๋๋ค.
- ์ ์ด๋ ๊ฒ? DB์ ์ ํ๋ฌธ ๊ณ์ ์ด ์์ฌ ์์ ๋, ์ ์ฒด๋ฅผ ํ ๋ฒ์ ์ฌํด์ฑํ ์ ์์ผ๋(ํ๋ฌธ์ ์์์ผ ํด์ํจ) ๋ก๊ทธ์ธํ ๋๋ง๋ค ํ ๋ช ์ฉ ์ ์ง ์ ํ. ํ ๋ฒ ๋ก๊ทธ์ธํ๋ฉด ์์ํ ํด์๋ก ๊ตณ์ต๋๋ค.
โ ๏ธ ํจ์ :
stored.startsWith("$2")ํ๋ณ์ "BCrypt์ฒ๋ผ ๋ณด์ด๋ฉด ํด์๋ก ์ทจ๊ธ"์ ๋๋ค. ๋ง์ฝ ์ด๋ค ์ฌ์ฉ์์ ํ๋ฌธ ๋น๋ฒ์ด ์ง์ง๋ก$2๋ก ์์ํ๋ค๋ฉด(์:$2abc), ๋ ๊ฑฐ์ ๋ถ๊ธฐ๋ฅผ ๋ชป ํ ๋ก๊ทธ์ธ ์คํจํ ์ ์๋ ๊ทน๋จ ์ผ์ด์ค๊ฐ ์์ต๋๋ค. ์ค๋ฌด์์ ๊ฑฐ์ ์์ง๋ง, ๋ง์ด๊ทธ๋ ์ด์ ์๋ฃ ํ ๋ ๊ฑฐ์ ๋ถ๊ธฐ ์์ฒด๋ฅผ ์ ๊ฑฐํ๋ ๊ฒ ๊น๋ํฉ๋๋ค(๊ณ ๋ํ).
B-3. ์์
๋ก๊ทธ์ธ ์กฐํ socialLogin โ AuthService.java:113-116
// L113-L116
public User socialLogin(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("๊ฐ์
๋์ง ์์ ์ด๋ฉ์ผ์
๋๋ค."));
}
- ๋จ์ํจ์ด ํต์ฌ: ๋น๋ฐ๋ฒํธ ๊ฒ์ฆ์ ๊ฑด๋๋ฐ๊ณ (์ด๋ฏธ ๊ตฌ๊ธ์ด ์ ์ ๋ณด์ฆ) ์ด๋ฉ์ผ๋ก ๊ธฐ์กด User๋ง ์กฐํ. ์์ผ๋ฉด ์์ธ โ ์ปจํธ๋กค๋ฌ๊ฐ "๊ฐ์ ์๋ด"๋ก ์ฒ๋ฆฌ. (์๋๊ฐ์ ์ ํจ โ A-8 ์ ์ฑ .)
@Transactional์ด ์์์ ์ฃผ๋ชฉ: ์ฝ๊ธฐ๋ง ํ๋ฏ๋ก ํธ๋์ญ์ ์์ด ๋จ์ ์กฐํ.
B-4. GitHub ๊ณ์ ์กฐํ/์์ฑ findOrCreateGithubUser โ AuthService.java:122-168
// L122-L137
@Transactional
public User findOrCreateGithubUser(String email, String githubLogin, String accessToken) {
java.time.LocalDateTime now = java.time.LocalDateTime.now();
byte[] encryptedToken = null;
if (accessToken != null && !accessToken.isBlank()) {
try { encryptedToken = crypto.encrypt(accessToken); } catch (Exception ignored) {}
}
final byte[] tokenBytes = encryptedToken;
return userRepository.findByEmail(email)
.map(existing -> {
existing.setGithubUsername(githubLogin);
existing.setGithubConnectedAt(now);
if (tokenBytes != null) existing.setGithubTokenEncrypted(tokenBytes);
return userRepository.save(existing);
})
.orElseGet(() -> { ... ์ ๊ท ์์ฑ ... });
}
- GitHub access_token์ ํ๋ฌธ ์ ์ฅ ๊ธ์ง โ
crypto.encrypt(accessToken)๋ก AES-GCM ์ํธํํ ๋ฐ์ดํธ๋ฅผ ์ ์ฅ(githubTokenEncrypted). (์ํธํ ์๋น์ค๋ D-4 ์ฐธ๊ณ . CLAUDE.md์ "KIS ์๊ฒฉ์ฆ๋ช AES-GCM ์ํธํ"์ ๊ฐ์ ์์น.) findByEmail(...).map(๊ธฐ์กด ๊ฐฑ์ ).orElseGet(์ ๊ท ์์ฑ)ํจํด: ์ด๋ฉ์ผ๋ก ์ฐพ์ผ๋ฉด ๊ธฐ์กด ๊ณ์ ์ GitHub ์ ๋ณด๋ง ๋ง๋ถ์(์ด๋ฏธ ์ผ๋ฐ ๊ฐ์ ํ ์ฌ๋์ด GitHub ์ฐ๋), ์์ผ๋ฉด ์ ๊ณ์ ์์ฑ.
// L138-L157 (์ ๊ท ์์ฑ)
.orElseGet(() -> {
String baseUsername = githubLogin.toLowerCase().replaceAll("[^a-z0-9_]", "_");
String username = baseUsername;
int suffix = 2;
while (userRepository.findByUsername(username).isPresent()) {
username = baseUsername + suffix++;
}
User newUser = User.builder()
.email(email)
.username(username)
.password(passwordEncoder.encode(java.util.UUID.randomUUID().toString())) // โ
๋๋ค ๋๋ฏธ ๋น๋ฒ
.phone("00000000000")
.userType(User.UserType.FREE)
.githubUsername(githubLogin)
.githubTokenEncrypted(tokenBytes)
.githubConnectedAt(now)
.build();
...
});
- username ์ถฉ๋ ํํผ: GitHub login์ ์๋ฌธ์+์์ ๋ฌธ์๋ก ์ ๊ทํํ๊ณ , ์ด๋ฏธ ์กด์ฌํ๋ฉด
2, 3, 4โฆ์ ๋ฏธ์ฌ๋ฅผ ๋ถ์ฌ ์ ์ผํ username์ ๋ง๋ฆ(username์ปฌ๋ผ์ด unique๋ผ ์ถฉ๋ ์ ์ ์ฅ ์คํจํ๋ฏ๋ก ์ฌ์ ํํผ). - ๋น๋ฒ์ด ์๋ ๊ณ์ ์ฒ๋ฆฌ: ์์
๊ฐ์
์๋ ๋น๋ฒ์ด ์์ผ๋
UUID.randomUUID()(์๋ฌด๋ ๋ชจ๋ฅด๋ ๋ฌด์์)๋ฅผ BCrypt ํด์ฑํด ์ฑ์. ์ด๋ ๊ฒ ํ๋ฉด โpasswordNOT NULL ์ ์ฝ ๋ง์กฑ, โก ๊ทธ ๋น๋ฒ์ผ๋ก๋ ์ผ๋ฐ ๋ก๊ทธ์ธ ๋ถ๊ฐ(์๋ฌด๋ ๊ทธ UUID๋ฅผ ๋ชจ๋ฆ) โ ์ค์ง GitHub๋ก๋ง ๋ก๊ทธ์ธ. ์๋ฆฌํ ํธ๋ฆญ.
C. JwtUtil โ ์ถ์
์ฆ ์ธ์๊ธฐ + ์์กฐ ๊ฐ๋ณ๊ธฐ
C-1. ๋น๋ฐํค ์ค๋น โ JwtUtil.java:25-38
// L25-L38
public JwtUtil(
@Value("${app.jwt.secret:dev-bridge-default-secret-key-change-in-production-please}") String secret,
@Value("${app.jwt.ttl-hours:1}") long ttlHours
) {
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
// 32๋ฐ์ดํธ ๋ฏธ๋ง์ด๋ฉด ํจ๋ฉ (๊ฐ๋ฐ ํธ์)
byte[] padded = new byte[32];
System.arraycopy(bytes, 0, padded, 0, bytes.length);
bytes = padded;
}
this.key = Keys.hmacShaKeyFor(bytes);
this.ttlMillis = ttlHours * 60 * 60 * 1000L;
}
- HS256(HMAC-SHA256)์ ์ต์ 32๋ฐ์ดํธ(256๋นํธ) ํค๋ฅผ ์๊ตฌํฉ๋๋ค. ์งง์ผ๋ฉด ๋ผ์ด๋ธ๋ฌ๋ฆฌ(JJWT)๊ฐ ๊ฑฐ๋ถํ๋ฏ๋ก, ๋ถ์กฑํ๋ฉด 0์ผ๋ก ํจ๋ฉ(๊ฐ๋ฐ ํธ์).
- โ ๏ธ ์ด์ ์ฃผ์: ๊ธฐ๋ณธ๊ฐ
dev-bridge-default-secret-...์ผ๋ก ์ด์ํ๋ฉด ๋๊ตฌ๋ ํ ํฐ์ ์์กฐํ ์ ์์ต๋๋ค(์์ค์ ํค๊ฐ ๊ณต๊ฐ๋จ). ์ด์์์๋JWT_SECRETํ๊ฒฝ๋ณ์๋ก ๊ฐํ ํค๋ฅผ ๋ฐ๋์ ์ฃผ์ ํด์ผ ํฉ๋๋ค(CLAUDE.md ํ์ ํ๊ฒฝ๋ณ์). ttlMillis= ํ ํฐ ์๋ช ์ ๋ฐ๋ฆฌ์ด๋ก ํ์ฐ(์๊ฐ ร 3,600,000).
C-2. ํ ํฐ ๋ฐํ issue โ JwtUtil.java:46-58
// L46-L58
public String issue(Long userId, String email, String userType) {
Date now = new Date();
return Jwts.builder()
.subject(email)
.claims(Map.of(
"uid", userId,
"type", userType
))
.issuedAt(now)
.expiration(new Date(now.getTime() + ttlMillis))
.signWith(key)
.compact();
}
- JWT ํ์ด๋ก๋(ํด๋ ์) ๊ตฌ์ฑ:
subject(email)โ ํ์ค ํด๋ ์sub์ ์ด๋ฉ์ผ.uidโ ์ฌ์ฉ์ PK(์ดํAuthContext/ํํฐ๊ฐ ์ด๊ฑธ ๊บผ๋). ์ธ์ฆ์ ํต์ฌ ์๋ณ์.typeโ userType(FREE/STANDARD/PREMIUM). ๊ถํยท๊ตฌ๋ ๊ธฐ๋ฅ ๊ฒ์ดํธ์ ํ์ฉ ๊ฐ๋ฅ.issuedAt/expirationโ ๋ฐํ์๊ฐยท๋ง๋ฃ์๊ฐ(now + 1์๊ฐ).
.signWith(key)โ ๋น๋ฐํค๋ก ์๋ช (์ฌ๊ธฐ์ ์์กฐ ๋ถ๊ฐ๊ฐ ๋ณด์ฅ๋จ)..compact()โ ์ต์ขa.b.c๋ฌธ์์ด๋ก ์ง๋ ฌํ.
๐ก ํ์ด๋ก๋๋ ๋๊ตฌ๋ ์ฝ์ ์ ์์ผ๋(์ฌ์ ์ง์ 1)
uid,type๊ฐ์ ๋ฏผ๊ฐํ์ง ์์ ์๋ณ์๋ง ๋ฃ์ต๋๋ค. ๋น๋ฒยท์ ํ๋ฒํธ ๋ฑ์ ์ ๋ ์ ๋ฃ์ต๋๋ค.
C-3. ํ ํฐ ๊ฒ์ฆ parse / extractUserId โ JwtUtil.java:61-73
// L61-L73
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
public Long extractUserId(String token) {
Object uid = parse(token).get("uid");
if (uid instanceof Number n) return n.longValue();
return null;
}
verifyWith(key).parseSignedClaims(token)โ ์๋ช ๊ฒ์ฆ + ๋ง๋ฃ ๊ฒ์ฆ์ ๋์์. ์๋ช ์ด ํ๋ฆฌ๊ฑฐ๋(์์กฐ) ๋ง๋ฃ๋์ผ๋ฉด ์์ธ๋ฅผ ๋์ง๋๋ค(์ด๊ฒ ๋ณด์์ ์๋ ์ฐจ๋จ๋ง).uid instanceof Number์ฒดํฌ ์ด์ : JWT๋ฅผ JSON์ผ๋ก ์ญ์ง๋ ฌํํ๋ฉด ์ซ์๊ฐInteger๋ก ์ฌ ์๋,Long์ผ๋ก ์ฌ ์๋ ์์ดNumber๋ก ๋ฐ์longValue()๋ก ํต์ผ. (Integer๋ก ์บ์คํ ํ๋ฉด ํฐ id์์ClassCastException์ํ.)
D. JwtAuthenticationFilter โ ์
๊ตฌ ๊ฒฝ๋น์
D-1. ํํฐ ๋ฑ๋ก + ์คํ ์์ โ JwtAuthenticationFilter.java:28-37
// L28-L37
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10) // ๋ฐ๋์ AiRateLimitFilter(+20)๋ณด๋ค ๋จผ์ ์คํ
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
public static final String ATTR_USER_ID = "auth.userId";
public static final String ATTR_USER_TYPE = "auth.userType";
private final JwtUtil jwtUtil;
OncePerRequestFilter= ์์ฒญ 1๊ฐ๋น ์ ํํ ํ ๋ฒ ์คํ ๋ณด์ฅ(์คํ๋ง ๋ด๋ถ forward ๋ฑ์ผ๋ก ์ค๋ณต ์คํ ๋ฐฉ์ง).@Order(HIGHEST_PRECEDENCE + 10)โ ๋ฐ๋์ ์ผ์ฐ ์คํ. ์ฃผ์๋๋กAiRateLimitFilter(+20)๋ณด๋ค ๋จผ์ ๋์์ผ, ๋ ์ดํธ๋ฆฌ๋ฐ ํํฐ๊ฐauth.userId๋ฅผ ์ฝ์ ์ ์์ต๋๋ค(์ ์ ๋ณ ํ๋ ์ ์ฉ). ํํฐ ์์ ์์กด์ฑ์ ๋ณด์ฌ์ฃผ๋ ์ข์ ์.ATTR_USER_ID/ATTR_USER_TYPE์์ = ์์ฒญ์ ๋์ฅ ์ฐ์ ๋ ์ฐ๋ ํค(์ด๋ฆ).AuthContext๊ฐ ๊ฐ์ ํค๋ก ๊บผ๋ ๋๋ค.
D-2. ํต์ฌ โ ํ ํฐ ๊ฒ์ฆ ํ ์์ฒญ์ ๋์ฅ โ JwtAuthenticationFilter.java:39-60
// L39-L60
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = extractToken(request);
if (token != null && !token.isBlank()) {
try {
var claims = jwtUtil.parse(token);
Object uid = claims.get("uid");
Object type = claims.get("type");
if (uid instanceof Number n) {
request.setAttribute(ATTR_USER_ID, n.longValue());
}
if (type != null) {
request.setAttribute(ATTR_USER_TYPE, type.toString());
}
} catch (Exception e) {
// ํ ํฐ ํ์ฑ ์คํจ: ์ต๋ช
์์ฒญ์ผ๋ก ํต๊ณผ
log.debug("JWT parse failed: {}", e.getMessage());
}
}
chain.doFilter(request, response);
}
- ํ๋ฆ: ํ ํฐ ์ถ์ถ โ
parse(๊ฒ์ฆ) โ ์ฑ๊ณตํ๋ฉดrequest.setAttribute(ATTR_USER_ID, uid)๋ก ์์ฒญ์ ๋์ฅ. ์ด๊ฒ (B) ํ๋ฆ๋์ "๋์ฅ ์ฐ๊ธฐ". - ์คํจํด๋ ๋ง์ง ์์(catch ํ ๊ทธ๋ฅ ํต๊ณผ): ํ ํฐ์ด ์๊ฑฐ๋ ๊นจ์ก์ผ๋ฉด ์ต๋ช
์์ฒญ์ผ๋ก ์ฒ๋ฆฌ. ์ฐจ๋จ์ ์ปจํธ๋กค๋ฌ๊ฐ
AuthContext.currentUserId() == null๊ฒ์ฌ๋ก ํฉ๋๋ค(์ด ํ๋ก์ ํธ ๊ฒฝ๋ ์ธ์ฆ ์ฒ ํ). chain.doFilter(...)โ ๋ค์ ํํฐ/์ปจํธ๋กค๋ฌ๋ก ์์ฒญ ์ ๋ฌ(ํํฐ ์ฒด์ธ ์งํ).
D-3. ํ ํฐ ์ถ์ถ ์ฐ์ ์์ โ JwtAuthenticationFilter.java:62-79
// L62-L79
private String extractToken(HttpServletRequest request) {
// 1์์: HttpOnly ์ฟ ํค
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if (AuthController.AUTH_COOKIE_NAME.equals(c.getName())) {
String v = c.getValue();
if (v != null && !v.isBlank()) return v.trim();
}
}
}
// 2์์: Authorization ํค๋ (๋ ๊ฑฐ์ ํธํ)
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7).trim();
}
return null;
}
- 1์์: HttpOnly ์ฟ ํค
DEVBRIDGE_TOKEN(์ ํ์ค, XSS ์์ ).AuthController.AUTH_COOKIE_NAME์์๋ฅผ ์ฐธ์กฐ โ A-1์ "์ด๋ฆ ๊ณต์ "๊ฐ ์ฌ๊ธฐ์ ์๋. - 2์์:
Authorization: Bearer <token>ํค๋(๋ ๊ฑฐ์ ํธํ). ์ ํด๋ผ์ด์ธํธ๋ ์ธ๋ถ API ํธ์ถ์ด ํค๋ ๋ฐฉ์์ ์ธ ์ ์์ด ๋ ๋ค ์ง์.substring(7)="Bearer "(7๊ธ์) ์๋ผ๋ด๊ธฐ.
D-4. (์ฐ๊ด) AesGcmCryptoService โ GitHub ํ ํฐ ์ํธํ โ AesGcmCryptoService.java
- GitHub PAT/access_token ๊ฐ์ ๋ฏผ๊ฐ๊ฐ์ DB ์ ์ฅ ์ AES-GCM์ผ๋ก ์ํธํ(
encryptโ[12B IV][์ํธ๋ฌธ+ํ๊ทธ]). - ํค ์ถ์ฒ:
app.crypto.key(์์ผ๋ฉดapp.jwt.secret์ SHA-256 ํด๋ฐฑ, L42-49). ์ด์์์๋ ๋ณ๋ ํค ๊ถ์ฅ(์ฃผ์ ๋ช ์). encrypt(L51-63): ๋งค๋ฒ ๋ฌด์์ IV 12๋ฐ์ดํธ ์์ฑ โ ๊ฐ์ ํ๋ฌธ๋ ๋งค๋ฒ ๋ค๋ฅธ ์ํธ๋ฌธ(๋ณด์). GCM์ ์ธ์ฆํ๊ทธ(128bit)๋ก ๋ณ์กฐ ํ์ง๊น์ง ์ ๊ณต.
E. AuthContext โ "์ง๊ธ ๋๊ตฌ?" ํ๋
๊ธฐ
E-1. ํ์ฌ ์ฌ์ฉ์ ID ์กฐํ โ AuthContext.java:27-47
// L27-L47
public static Long currentUserId() {
Long override = OVERRIDE_USER_ID.get();
if (override != null) return override;
HttpServletRequest req = currentRequest();
if (req == null) return null;
Object v = req.getAttribute(JwtAuthenticationFilter.ATTR_USER_ID);
return v instanceof Long ? (Long) v : null;
}
...
public static Long requireUserId() {
Long id = currentUserId();
if (id == null) throw new RuntimeException("์ธ์ฆ์ด ํ์ํฉ๋๋ค.");
return id;
}
- ์ปจํธ๋กค๋ฌ๊ฐ
AuthContext.currentUserId()ํ ์ค๋ก ํ์ฌ ์์ฒญ์ ์ฃผ์ธ์ ์ป์ต๋๋ค โ ๋ด๋ถ์ ์ผ๋ก๋ ํํฐ๊ฐ ์ฐ์request์auth.userId์์ฑ์ ๊บผ๋ด๋ ๊ฒ(D-2์ ์ง). RequestContextHolder๋ก ํ์ฌ ์ค๋ ๋์ ์์ฒญ์ ๊ฐ์ ธ์ต๋๋ค(์ปจํธ๋กค๋ฌ ์ธ์๋กHttpServletRequest๋ฅผ ์ ๋ฐ์๋ ์ด๋์๋ ์ ๊ทผ ๊ฐ๋ฅ โ ์ฝ๋ ๊ฐ๊ฒฐ).OVERRIDE_USER_ID(ThreadLocal): HTTP ์์ฒญ์ด ์๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ก(์ค์ผ์ค๋ฌ ๋ฑ)์์ ์์๋ก "์ด ์์ ์ N๋ฒ ์ ์ ๋ก ์น๋ค"๋ฅผ ์ฃผ์ ํ๋ ์ฉ๋.setํ ๋ฐ๋์finally์์clear()(ThreadLocal ๋์ ๋ฐฉ์ง โ ํจ์ ).requireUserId()= null์ด๋ฉด ์์ธ(๋ฐ๋์ ๋ก๊ทธ์ธ ํ์ํ ๊ณณ์ฉ). ๋ฐ๋ฉดcurrentUserId()= null ํ์ฉ(์ง์ 401 ๋ถ๊ธฐํ๊ณ ์ถ์ ๋). ์ปจํธ๋กค๋ฌ๋ค์ด ๋ ์ค ๊ณจ๋ผ ์๋๋ค.
๐ก ๋ณด์ ๊ตํ: ์ปจํธ๋กค๋ฌ๋ ์ ๋ ํด๋ผ์ด์ธํธ๊ฐ ๋ณด๋ธ userId๋ฅผ ์ ๋ขฐํ์ง ์๊ณ , ์ค์ง
AuthContext(=์๋ฒ๊ฐ ๊ฒ์ฆํ JWT)์์๋ง ์ ์์ ์ป์ต๋๋ค. ์ต๊ทผ ์ปค๋ฐNotificationController IDOR ์ฐจ๋จ์ด ๋ฐ๋ก ์ด ์์น(์ ์์ JWT์์๋ง)์ ์ ์ฉํ ์ฌ๋ก์ ๋๋ค.
F. ์ํฐํฐ โ DB๋ก ๊ตณ๋ ๋ฐ์ดํฐ
F-1. User โ ํ์ 1๋ช
= USERS ํ ํ โ User.java
// L13-L41
@Entity
@Table(name = "USERS")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, length = 20)
private String phone;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, length = 255)
private String password; // โ
BCrypt ํด์ ์ ์ฅ (ํ๋ฌธ ๊ธ์ง)
@Convert(converter = UserTypeConverter.class)
@Column(name = "user_type", nullable = false)
private UserType userType;
@Entity @Table(name="USERS")= ์ด ํด๋์ค 1๊ฐ = DBUSERSํ ์ด๋ธ, ์ธ์คํด์ค 1๊ฐ = ํ ํ.@Id @GeneratedValue(IDENTITY)= PKid๋ฅผ DB๊ฐ auto-increment๋ก ์์ฑ. ์ดid๊ฐ JWT์uid๊ฐ ๋ฉ๋๋ค.email/username์unique = true= DB ์ฐจ์์ ์ค๋ณต ๊ธ์ง(AuthService ์ฌ์ ๊ฒ์ฌ์ ์ต์ข ๋ฐฉ์ด์ ).password length=255= BCrypt ํด์(์ฝ 60์)๊ฐ ๋ค์ด๊ฐ๋ ๋๋. ์ฌ๊ธฐ ์ ๋ ํ๋ฌธ ์ ๋ค์ด๊ฐ(C1).@EntityListeners(AuditingEntityListener.class)+@CreatedDate/@UpdateTimestamp(L86-92) = ์์ฑยท์์ ์๊ฐ ์๋ ๊ธฐ๋ก.
// L78-L84 (GitHub ์ฐ๋ ํ๋)
@Column(name = "github_token_encrypted", columnDefinition = "VARBINARY(512)")
private byte[] githubTokenEncrypted; // โ
AES-GCM ์ํธํ ๋ฐ์ดํธ (ํ๋ฌธ ๋
ธ์ถ ๊ธ์ง)
- GitHub ํ ํฐ์
byte[](VARBINARY)๋ก ์ํธํ๋ ํํ๋ง ์ ์ฅ(D-4์ ์ง). ์ฃผ์์ด "์ ๋ ํ๋ฌธ ๋ ธ์ถ ๊ธ์ง"๋ฅผ ๋ชป ๋ฐ์.
// L94-L110 (UserType + ์ญ๋ฐฉํฅ ํธํ)
public enum UserType {
FREE, STANDARD, PREMIUM;
@JsonCreator
public static UserType fromJson(String value) {
if (value == null) return null;
return switch (value.toUpperCase()) {
case "CLIENT", "USER", "FREE" -> FREE;
case "PARTNER", "PRO", "STANDARD" -> STANDARD;
case "PREMIUM" -> PREMIUM;
default -> FREE;
};
}
}
UserType์ ๊ตฌ๋ ๋ฑ๊ธ(FREE/STANDARD/PREMIUM).@JsonCreator fromJson= ํ๋ก ํธ๊ฐ ์ ๊ฐ(CLIENT/PARTNER/PRO๋ฑ)์ ๋ณด๋ด๋ ์ enum์ผ๋ก ๋งคํ(์์ฒญ ์ญ์ง๋ ฌํ ์).- ์ง์ผ๋ก
UserTypeConverter(UserTypeConverter.java)๊ฐ DB ์ฝ๊ธฐ/์ฐ๊ธฐ๋ฅผ ๋ด๋น: DB์ ์ ๊ฐ(CLIENT/PARTNERโฆ)๋ ์ enum์ผ๋ก ์ฝ๊ณ , ์ ์ฅ์ ํญ์ ์ ๊ฐ์ผ๋ก. (CLAUDE.md "๊ตฌ ๊ฐ ์ญ๋ฐฉํฅ ํธํ"์ ์ค์ฒด.)
๐ก ์ ์ด ํธํ ์ฅ์น๊ฐ ๋ ๊ฐ(
@JsonCreator+Converter)๋ ์๋? ๋ ๊ฒฝ๊ณ๊ฐ ๋ค๋ฅด๊ธฐ ๋๋ฌธ:@JsonCreator๋ "HTTP JSON โ Java" ๊ฒฝ๊ณ,Converter๋ "Java โ DB" ๊ฒฝ๊ณ. ์ ๋ฐ์ดํฐยท์ ํด๋ผ์ด์ธํธ๊ฐ ์์ชฝ์ ๋ค ์์ ์ ์์ด ๋ ๊ตฐ๋ฐ๋ฅผ ๋ชจ๋ ๋ง์ ๊ฒ.
F-2. RefreshToken โ ์ฌ๋ฐ๊ธ ์ฟ ํฐ โ RefreshToken.java
// L8-L40
@Entity
@Table(name = "refresh_tokens", indexes = {
@Index(name = "idx_rt_user_id", columnList = "user_id"),
@Index(name = "idx_rt_expires_at", columnList = "expires_at")
})
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false, unique = true, length = 64)
private String token;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
...
public static RefreshToken of(Long userId, String token, long ttlDays) {
RefreshToken rt = new RefreshToken();
rt.userId = userId;
rt.token = token;
rt.expiresAt = Instant.now().plusSeconds(ttlDays * 86400L);
rt.createdAt = Instant.now();
return rt;
}
public boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
}
token unique= ๊ฐ์ ํ ํฐ ๋ฌธ์์ด ์ค๋ณต ๊ธ์ง.@Index(user_id, expires_at)= ์ฌ์ฉ์๋ณ ์กฐํยท๋ง๋ฃ ์ฒญ์๋ฅผ ๋น ๋ฅด๊ฒ(DB ์ธ๋ฑ์ค).@NoArgsConstructor(access = PROTECTED)+ ์ ์ ํฉํ ๋ฆฌof(...)= ์ธ๋ถ์์new๋ง๊ณ ํญ์of๋ก๋ง ์์ฑํ๊ฒ ๊ฐ์ (์ ํจํ ์ํ๋ก๋ง ๋ง๋ค์ด์ง๋๋ก).isExpired()๋ A-7์ refresh ๊ฒ์ฆ์์ ์ฐ์ ๋๋ค.- โ ๏ธ ์ด ์ํฐํฐ ํ
์ด๋ธ์ ๋ง์ด๊ทธ๋ ์ด์
V18__refresh_tokens_table.sql๋ก ์์ฑ๋ฉ๋๋ค(ํจ์ ์น์ ์ฐธ๊ณ ).
G. ๋ถ๊ฐ ์ปจํธ๋กค๋ฌยท์๋น์ค (์กฐํยทํ๋กํยท์ํยท์ด๋ฉ์ผ)
UserController(/api/users): ์ฑํ ๊ธฐ๋ฅ์ฉ ๊ณต๊ฐ ์กฐํ.searchByUsernameยทfindByEmailยทfindById๋ id/username/userType/ํ๋กํ์ด๋ฏธ์ง๋ง ๋ฐํํ๊ณ emailยท๊ฐ์ธ์ ๋ณด๋ ์ ๋ ์ ์ค(์ฃผ์ ๋ช ์ "never returns email")./me/github-usernameGET/PATCH๋AuthContext๋ก ๋ณธ์ธ๋ง ์๊ธฐ GitHub username ์กฐํยท์์ .ProfileController/ProfileService(/api/profile): ํ๋กํ ์์ธ ์ ์ฅ(PUT /me/detail)ยท์กฐํ(GET /me/detail, publicGET /{username}/detail)ยท๊ธฐ๋ณธ์ ๋ณด ์์ (PUT /me/basic)ยท์ด๊ธฐํ(POST /me/reset).POST /admin/reset/{username}๋localํ๋กํ์ผ์์๋ง ๋์(L78-86์์ ํ์ฑ ํ๋กํ์ผ ๊ฒ์ฌ ํ ์๋๋ฉด 403) โ ์ด์์์ ์๋ ์ฐจ๋จ๋๋ dev ์ ์ฉ ์์ ์ฅ์น.BankVerificationController/Service(/api/bank): ๊ณ์ข ์ธ์ฆ.send-code๊ฐ 3์๋ฆฌ ์ฝ๋๋ฅผ ์์ฑํด ์ด๋ฉ์ผ ๋ฐ์ก(์์ฐ์ฉ์ด๋ผ ์๋ต ๋ณธ๋ฌธ์๋mockCode๋ ธ์ถ),verify-code๊ฐ ๊ฒ์ฆ ํ ๊ณ์ข์ ๋ณด ์ ์ฅ. ํต์ฌ์verifyAndSave(L84-123)์codeStore.compute(...)โ ๋์ ์์ฒญ ๋ ์ด์ค๋ฅผ ๋ฝ ์์์ ๋ง๊ณ , ๋ง๋ฃ/์๋์ด๊ณผ(์ต๋ 5ํ)/1ํ์ฉ ํ๊ธฐ๋ฅผ ํ ๋ฒ์ ์ฒ๋ฆฌ. ์ฝ๋๋ ๋ฉ๋ชจ๋ฆฌ(ConcurrentHashMap)์ ์ ์ฅ โ ์ฌ๊ธฐ๋ ์ ํ๋ฐ(์์ฐ ๋ฒ์).EmailVerificationController/Service(/api/verify): ํ์๊ฐ์ ์ ์ด๋ฉ์ผ ๋ณธ์ธํ์ธ.send-code๊ฐ 6์๋ฆฌ ์ฝ๋ ๋ฐ์ก,check-code๊ฐ ๊ฒ์ฆ(์ผ์น ์ 1ํ์ฉ ์ ๊ฑฐ). ๋ง์ฐฌ๊ฐ์ง๋ก ๋ฉ๋ชจ๋ฆฌ ์ ์ฅยทํ๋ฐ.
๐ก ๊ณตํต ํจํด: bank/email ์ธ์ฆ ๋ชจ๋ ์ธ๋ถ ๋ณธ์ธํ์ธ ์๋น์ค ๋์ "์ฝ๋ ๋ฉ์ผ ๋ฐ์ก โ ์ ๋ ฅ ๋์กฐ" ๋ผ๋ ๋ชฉ์ /์์ฐ ๋ฐฉ์์ ๋๋ค. ์ด์ ์ ํ ์ โ ์ฝ๋ ์ ์ฅ์ Redis๋ก(๋ค์ค ์ธ์คํด์คยท์ฌ๊ธฐ๋ ๋๋น), โก
mockCode์๋ต ์ ๊ฑฐ, โข ์ค์ ๋ณธ์ธํ์ธ API ์ฐ๋์ด ํ์ํฉ๋๋ค(๊ณ ๋ํ).
โ ๏ธ ํจ์ ยท๋ณด์ ์ฃผ์
| # | ํญ๋ชฉ | ๋ฌด์์ด ์ํํ๋ / ์ด๋ป๊ฒ ๋ง์๋ |
|---|---|---|
| C1 | BCrypt ๋ ๊ฑฐ์ ์ฌํด์ฑ | ๊ณผ๊ฑฐ ํ๋ฌธ ๋น๋ฒ ์ ์ฅ โ DB ์ ์ถ ์ ์ ์ ๋น๋ฒ ๋
ธ์ถ. AuthService.login(B-2)์ด $2 ์ ๋์ฌ๋ก ํด์/ํ๋ฌธ์ ๊ตฌ๋ถํ๊ณ , ํ๋ฌธ์ ๋ก๊ทธ์ธ ์ฑ๊ณต ์๊ฐ ์ฆ์ BCrypt๋ก ์ฌํด์ฑ(ํฌ๋ช
๋ง์ด๊ทธ๋ ์ด์
). ์ ๊ท ๊ฐ์
์ ์ฒ์๋ถํฐ passwordEncoder.encode(B-1). |
| C2 | ์์ accessToken ์๋ฒ๊ฒ์ฆ | ํ๋ก ํธ๊ฐ ๋ณด๋ธ email์ ๋ฏฟ์ผ๋ฉด {๋ดํ ํฐ, ๋จ์email}๋ก ๊ณ์ ํ์ทจ. socialLogin(A-8)์ ํด๋ผ email์ ๋ฒ๋ฆฌ๊ณ , verifyGoogleAccessToken์ผ๋ก ๊ตฌ๊ธ userinfo์ ์ง์ ๊ฒ์ฆํด verified email๋ง ์ฌ์ฉ. GitHub๋ /user/emails์์ verified ์ด๋ฉ์ผ๋ง ์ฑํ. |
| 3 | ์ฟ ํค Secure/SameSite | cookieSecure:false ๊ธฐ๋ณธ๊ฐ์ผ๋ก ์ด์ํ๋ฉด ํ๋ฌธ HTTP๋ก ์ฟ ํค ์ ์ก ๊ฐ๋ฅ. ์ด์์์ app.cookie.secure=true ํ์. SameSite=Lax๋ก CSRF ๊ธฐ๋ณธ ๋ฐฉ์ด. |
| 4 | Refresh ์ฟ ํค path ๊ฒฉ๋ฆฌ | Refresh๋ฅผ path=/๋ก ๊น๋ฉด ๋ชจ๋ ์์ฒญ์ ๋
ธ์ถ. path=/api/auth๋ก ๋ฌถ์ด ๊ฐฑ์ ์๋ํฌ์ธํธ์์๋ง ์ ์ก(A-3) โ ํ์ทจ ํ๋ฉด ์ต์ํ. |
| 5 | refresh_tokens ๋ง์ด๊ทธ๋ ์ด์ | main์ด RefreshToken ์ํฐํฐ๋ฅผ ์ถ๊ฐํ์ผ๋ Flyway ๋ง์ด๊ทธ๋ ์ด์
์ด ์์ด, ddl-auto=validate์ธ ์ด์ ๋ถํ
์ด Hibernate validate์์ ์คํจ. V18__refresh_tokens_table.sql(CREATE TABLE IF NOT EXISTS, ๋ฉฑ๋ฑ)๋ก ๋ณด๊ฐ. |
| 6 | JWT secret ๊ธฐ๋ณธ๊ฐ | JwtUtil์ ๊ธฐ๋ณธ secret์ผ๋ก ์ด์ํ๋ฉด ๋๊ตฌ๋ ํ ํฐ ์์กฐ. JWT_SECRET ํ๊ฒฝ๋ณ์๋ก ๊ฐํ ํค ์ฃผ์
ํ์(C-1). |
| 7 | ์ ์์ JWT์์๋ง | ์ปจํธ๋กค๋ฌ๊ฐ ํด๋ผ์ด์ธํธ userId๋ฅผ ๋ฏฟ์ผ๋ฉด IDOR. ํญ์ AuthContext.currentUserId()(์๋ฒ ๊ฒ์ฆ๊ฐ)๋ง ์ฌ์ฉ(E-1). ์ต๊ทผ NotificationController IDOR ์ฐจ๋จ ์ปค๋ฐ์ด ๊ฐ์ ์์น. |
| 8 | $2 ํ๋ฌธ ์คํ |
ํ๋ฌธ ๋น๋ฒ์ด ์ฐ์ฐํ $2๋ก ์์ํ๋ฉด ๋ ๊ฑฐ์ ๋ถ๊ธฐ๋ฅผ ๋ชป ํ ๋ก๊ทธ์ธ ์คํจ(B-2 ํจ์ ). ๋ง์ด๊ทธ๋ ์ด์
์๋ฃ ํ ๋ ๊ฑฐ์ ๋ถ๊ธฐ ์ ๊ฑฐ ๊ถ์ฅ. |
| 9 | GitHub ํ ํฐ ํ๋ฌธ ๊ธ์ง | access_token์ AesGcmCryptoService.encrypt๋ก ์ํธํ ํ byte[] ์ ์ฅ(B-4ยทF-1). ํค๋ ์ด์์์ app.crypto.key ๋ณ๋ ๋ฐ๊ธ. |
| 10 | ์ธ์ฆ์ฝ๋ ํ๋ฐ/๋ ธ์ถ | bankยทemail ์ฝ๋๊ฐ ๋ฉ๋ชจ๋ฆฌ(ConcurrentHashMap)๋ผ ๋ค์ค ์ธ์คํด์ค/์ฌ๊ธฐ๋์ ์ทจ์ฝ. bank mockCode๋ ์์ฐ์ฉ ์๋ต ๋
ธ์ถ โ ์ด์ ์ Redis ์ด์ + ์๋ต ์ ๊ฑฐ ํ์. |
๐ ๊ณ ๋ํ ์์ด๋์ด
- Refresh ํ ํฐ ํ์ (rotation): ํ์ฌ๋ ๊ฐฑ์ ์ Access๋ง ์๋ก ๋ฐ๊ธํ๊ณ Refresh๋ 15์ผ ๊ณ ์ ์ฌ์ฌ์ฉ(A-7). ๊ฐฑ์ ํ ๋๋ง๋ค ์ Refresh๋ก ๊ต์ฒด + ์ ๊ฒ ํ๊ธฐํ๋ฉด, ํ์ทจ๋ ์ Refresh๋ฅผ ์ฆ์ ๋ฌด๋ ฅํํ๊ณ ์ฌ์ฌ์ฉ ํ์ง(ํ ๋ฒ ์ด ํ ํฐ์ด ๋ ์ค๋ฉด ํ์ทจ ์์ฌ โ ์ ์ธ์ ๊ฐ์ ๋ก๊ทธ์์)๊ฐ ๊ฐ๋ฅ.
- ๋ ๊ฑฐ์ ํ๋ฌธ ๋ถ๊ธฐ ์ ๊ฑฐ: B-2์
else๋ถ๊ธฐ๋ ๋ง์ด๊ทธ๋ ์ด์ ์ด ๋๋๋ฉด ๋ถํ์ + C8 ์คํ ์ํ. ์ ์ ์ ํ ํ์ธ ํ ์ญ์ ํด ์ฝ๋ ๋จ์ํ. - ๋ก๊ทธ์์ = ์ ๊ธฐ๊ธฐ / ๋จ์ผ ๊ธฐ๊ธฐ ์ ํ: ํ์ฌ๋ ์ ์ํ Refresh 1๊ฐ๋ง ์ญ์ .
deleteByUserId(์ด๋ฏธ ๋ฆฌํฌ์งํ ๋ฆฌ์ ์กด์ฌ)๋ก "๋ชจ๋ ๊ธฐ๊ธฐ ๋ก๊ทธ์์" ์ต์ ์ถ๊ฐ ๊ฐ๋ฅ. - ๊ถํ(Role) ๊ธฐ๋ฐ ์ธ๊ฐ: ์ง๊ธ์
typeํด๋ ์๋ง ๋ด๊ณ ์ปจํธ๋กค๋ฌ๋ณ ๊ฒ์ฌ. ๋ฉ์๋ ๋จ์@PreAuthorize๋ฅ๋ก ์ ์ธ์ ์ธ๊ฐ๋ฅผ ๋์ (ํ Spring Security ์ฌํ์ฑ ๊ฒํ )ํ๋ฉด ์ผ๊ด์ฑโ. - ์ธ์ฆ์ฝ๋ ์ ์ฅ Redis ์ด์ : bank/email ์ฝ๋๋ฅผ Redis TTL ํค๋ก ์ฎ๊ธฐ๋ฉด ๋ค์ค ์ธ์คํด์คยท์ฌ๊ธฐ๋์๋ ์์ ํ๊ณ ๋ง๋ฃ ์๋ํ.
mockCode์๋ต ์ ๊ฑฐ. - rate limit on auth: login/social/verify์ IPยท๊ณ์ ๋ณ ์๋ ์ ํ(๋ธ๋ฃจํธํฌ์ค ๋ฐฉ์ด). AiRateLimitFilter์ฒ๋ผ Bucket4j ํ์ฉ.
- ์ด๋ฉ์ผ ์ธ์ฆ โ ๊ฐ์
๊ฐ๊ฒฐํฉ: ํ์ฌ
/api/verify๋ ๊ฐ์ ๊ณผ ๋ถ๋ฆฌ. ๊ฐ์ ์ "์ธ์ฆ ์๋ฃ ํ ํฐ"์ ์๊ตฌํ๋๋ก ๋ฌถ์ผ๋ฉด ๊ฐ์ง ์ด๋ฉ์ผ ๊ฐ์ ์ฐจ๋จ. - ์์ ์ ๊ท ์๋๊ฐ์ ์ ์ฑ ํต์ผ: ๊ตฌ๊ธ์ ๋ฏธ๊ฐ์ ์ ๊ฑฐ๋ถ, ๊นํ๋ธ๋ ์๋์์ฑ(A-8 vs A-9). ์ ํ ์ ์ฑ ์ ๋ง๊ฒ ํต์ผ/๋ช ๋ฌธํ.
๐ ์ฉ์ด ์ฌ์ (์ด ๋๋ฉ์ธ ํ์ )
| ์ฉ์ด | ๋ป |
|---|---|
| JWT | ํค๋.ํ์ด๋ก๋.์๋ช
๋ฌธ์์ด ํ ํฐ. ์๋ช
์ผ๋ก ์์กฐ ๋ฐฉ์ง, ํ์ด๋ก๋๋ ๋๊ตฌ๋ ์ฝํ(์ํธํ ์๋) |
| HS256 | HMAC-SHA256. ๋ฐํยท๊ฒ์ฆ์ ๊ฐ์ ๋น๋ฐํค๋ฅผ ์ฐ๋ ๋์นญ ์๋ช |
| Access Token | ์งง์ ์๋ช (1h) JWT. ๋งค ์์ฒญ ์ ์. ํ์ทจ๋ผ๋ ๊ณง ๋ง๋ฃ |
| Refresh Token | ๊ธด ์๋ช (15d) ๋ฌด์์ ๋ฌธ์์ด. DB ์ ์ฅ. Access ๋ง๋ฃ ์ ์ Access ๋ฐ๊ธ์ฉ. DB์์ ์ง์ ๋ฌดํจํ |
| HttpOnly ์ฟ ํค | JS๊ฐ ๋ชป ์ฝ๋ ์ฟ ํค. XSS ํ ํฐ ํ์ทจ ๋ฐฉ์ด |
| Secure / SameSite | Secure=HTTPS์์๋ง ์ ์ก, SameSite=๊ต์ฐจ์ฌ์ดํธ ๋๋ด ์ ํ(CSRF ๋ฐฉ์ด) |
| BCrypt | ์ํธ+๋๋ฆฐ ๋จ๋ฐฉํฅ ํด์ฑ. $2๋ก ์์. ๋น๋ฒ ํ๋ฌธ ์ ์ฅ ๋์ฒด |
| PasswordEncoder.matches | ํ๋ฌธ์ ๊ฐ์ ์ํธ๋ก ํด์ฑํด ์ ์ฅ ํด์์ ๋น๊ต(ํ๋ฌธ ๋ณต์ ์์) |
| OAuth access_token | ๊ตฌ๊ธยท๊นํ๋ธ๊ฐ ๋ฐ๊ธํ ์์ ์ถ์ ์ฆ. ์๋ฒ๊ฐ ์ง์ ๊ฒ์ฆํด์ผ ์ ๋ขฐ |
| ํฌ๋ช ๋ง์ด๊ทธ๋ ์ด์ | ์ฌ์ฉ์ ๋ชจ๋ฅด๊ฒ(๋ก๊ทธ์ธ ์) ํ๋ฌธโํด์๋ก ์ ์ง ์ ๊ทธ๋ ์ด๋ |
| AES-GCM | ๋์นญ ์ํธํ + ๋ณ์กฐํ์ง. GitHub ํ ํฐ ๋ฑ ๋ฏผ๊ฐ๊ฐ DB ์ ์ฅ์ฉ |
| OncePerRequestFilter | ์์ฒญ๋น ์ ํํ 1ํ ์คํ๋๋ ์คํ๋ง ํํฐ ๋ฒ ์ด์ค |
| request attribute | ํ ์์ฒญ ๋์๋ง ์ ํจํ ํค-๊ฐ ์ ์ฅ์. ํํฐโ์ปจํธ๋กค๋ฌ๋ก userId ์ ๋ฌ ํต๋ก |
| ThreadLocal | ์ค๋ ๋๋ณ ๋ ๋ฆฝ ์ ์ฅ์. AuthContext๊ฐ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ก์ฉ userId ์ฃผ์ ์ ์ฌ์ฉ(๋์ ์ฃผ์) |
@Value("${k:๊ธฐ๋ณธ๊ฐ}") |
properties/ํ๊ฒฝ๋ณ์์์ ์ค์ ์ฃผ์ (์ฝ๋ก ๋ค๊ฐ ๊ธฐ๋ณธ๊ฐ) |
@Transactional |
๋ฉ์๋ ๋ด DB ์์ ์ ํ ํธ๋์ญ์ ์ผ๋ก ๋ฌถ์(์์ธ ์ ๋กค๋ฐฑ) |
์ ์ ํฉํ ๋ฆฌ(of) |
new ๋์ ๊ฒ์ฆ๋ ์ํ๋ก๋ง ๊ฐ์ฒด ์์ฑํ๊ฒ ํ๋ ํจํด(RefreshToken.of) |
'๐ฉโ๐ปDeveloper ๐ก' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
| ํค-๊ฐ ์ ์ฅ์ ์ค๊ณ (0) | 2026.06.20 |
|---|---|
| AWS ๋ฐฐํฌ A-Z ๋ฐฐํฌ ์ฌ์ โ ์ฒ์ ๋ฐฐ์ฐ๋ ์ฌ๋์ ์ํ ์ ์ฒด ์ ๋ฆฌ (0) | 2026.06.03 |
| ํ๋ก์ ํธ2 ์ฌ๋ฅ ๋ฐ ํ๋ฆฌ๋์ ๋งค์นญ ํ๋ซํผ ๊ธฐํ๋ถํฐ ์ค๊ณ ๋ฐ ๊ฐ๋ฐ (0) | 2026.03.08 |
| ํ์ค์ ์ผ๋ก ์ด์ง ๊ฐ๋ฅํด. (0) | 2026.02.02 |
| ํด์ธ ๊ธ์ต๊ถ์์ โ์ฝ๋ฉ์ผ๋ก ํ๋โ ๋ํ ์ง๋ฌด 3๊ฐ์ง (0) | 2026.02.02 |