ah/backend

2026. 6. 2. 11:28ยท๐Ÿ‘ฉ‍๐Ÿ’ปDeveloper ๐Ÿ’ก

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 ๋Š” "์Šคํ”„๋ง์œผ๋กœ ์„œ๋ฒ„๋ฅผ ๋งŒ๋“ค ๋•Œ ํ•„์š”ํ•œ ์ˆ˜๋ฐฑ ๊ฐ€์ง€ ์„ค์ •์„ ๋ฏธ๋ฆฌ ํ•ด๋‘” ํ’€์„ธํŠธ"์ž…๋‹ˆ๋‹ค. ํ•ต์‹ฌ ๋งˆ๋ฒ• ๋‘ ๊ฐ€์ง€:
    1. ์ž๋™์„ค์ •(Auto-configuration) โ€” DB ๋“œ๋ผ์ด๋ฒ„๋ฅผ ์˜์กด์„ฑ์— ๋„ฃ๊ธฐ๋งŒ ํ•˜๋ฉด, ์Šคํ”„๋ง์ด "์•„ MySQL ์“ฐ๋Š”๊ตฌ๋‚˜" ํ•˜๊ณ  ์—ฐ๊ฒฐ์„ ์•Œ์•„์„œ ๊ตฌ์„ฑ. ์šฐ๋ฆฌ๊ฐ€ XML๋กœ ์ผ์ผ์ด ์„ค์ •ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
    2. ๋‚ด์žฅ ํ†ฐ์บฃ(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๊ฐ€์ง€ ์ž…๊ตฌ:

  1. @PathVariable โ€” URL ๊ฒฝ๋กœ ์•ˆ์˜ ๋ณ€์ˆ˜.

    // NotificationController.java:61-62
    @PatchMapping("/{notificationId}/read")
    public ResponseEntity<?> markOneRead(@PathVariable Long notificationId) {
    • /api/notifications/**3**/read ๋กœ ์š”์ฒญํ•˜๋ฉด notificationId = 3 ์ด ๋ฉ๋‹ˆ๋‹ค. ๊ฒฝ๋กœ์˜ {notificationId} ์นธ๊ณผ ๋ฉ”์„œ๋“œ ์ธ์ž ์ด๋ฆ„์ด ์ง์ง€์–ด์ง‘๋‹ˆ๋‹ค.
  2. @RequestParam โ€” ?key=value ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ. (์˜ˆ: ?page=2) โ€” ์ด ์ปจํŠธ๋กค๋Ÿฌ์—์„  ์˜๋„์ ์œผ๋กœ ์•ˆ ์”๋‹ˆ๋‹ค. ์•„๋ž˜ โš ๏ธ ํŠน์ด์  ์ ˆ์—์„œ "์™œ ?userId=๋ฅผ ์•ˆ ๋ฐ›๋Š”์ง€" ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค.

  3. @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์„ ์ž๋™ ์ƒ์„ฑํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • ์ด๋ฆ„์„ ๋‹จ์–ด๋กœ ๋Š์–ด ์ฝ์œผ๋ฉด ๊ทธ๊ฒŒ ๊ณง ์ฟผ๋ฆฌ์ž…๋‹ˆ๋‹ค:
    • findBy User OrderBy CreatedAt Desc
      โ†’ SELECT * FROM notification WHERE user_id = ? ORDER BY created_at DESC
    • count By User And IsReadFalse
      โ†’ 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 ๊ฑฐ์šธ)์™€ ์ผ๋ถ€๋Ÿฌ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
  • ์™œ ๋ถ„๋ฆฌํ•˜๋‚˜?
    1. ๋ณด์•ˆ โ€” ์—”ํ‹ฐํ‹ฐ Notification์€ @ManyToOne private User user(๋น„๋ฐ€๋ฒˆํ˜ธ ๋“ฑ ๋ฏผ๊ฐ ์ •๋ณด ํฌํ•จ ๊ฐ€๋Šฅ)๋ฅผ ๋“ค๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Œ€๋กœ JSON์œผ๋กœ ๋‚ด๋ณด๋‚ด๋ฉด ์œ ์ € ๋‚ด๋ถ€ ์ •๋ณด๊ฐ€ ์ค„์ค„ ์ƒˆ๊ฑฐ๋‚˜ ๋ฌดํ•œ ์ˆœํ™˜์ฐธ์กฐ๋กœ ํ„ฐ์ง‘๋‹ˆ๋‹ค. DTO๋Š” ๋‚ด๋ณด๋‚ผ ํ•„๋“œ๋งŒ ๊ณจ๋ผ ๋‹ด์Šต๋‹ˆ๋‹ค(์œ„์— user๊ฐ€ ์—†์ฃ ).
    2. ์•ˆ์ •์„ฑ โ€” 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๊ฒน:

  1. ๊ธฐ๋ณธ OFF (app.claude.cli.enabled=false),
  2. ์ž„์‹œ ๊ฒฉ๋ฆฌ ๋””๋ ‰ํ„ฐ๋ฆฌ (์›Œํฌ์ŠคํŽ˜์ด์Šค ์ฝ”๋“œ๋งŒ ๋ณต์‚ฌํ•ด์„œ ํŽผ์นจ),
  3. ํ—ˆ์šฉํˆด ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ (Read,Edit,Write,Glob,Grep ๋งŒ, Bash/WebFetch/WebSearch ์ฐจ๋‹จ),
  4. ํƒ€์ž„์•„์›ƒ + ์˜ˆ์‚ฐ ์ƒํ•œ(--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 ๋Š” "์ƒ์„ฑ ์‹œ์  ์ˆœํ™˜์ฐธ์กฐ(์ž๊ธฐ ์ž์‹ )"๋ฅผ ๊นจ๊ธฐ ์œ„ํ•œ ์žฅ์น˜.

๐Ÿ’ก ์ดˆ๋ณด๊ฐ€ ํ—ท๊ฐˆ๋ฆฌ๋Š” ํฌ์ธํŠธ: "์™œ ๋‚ด ๋ฉ”์„œ๋“œ๋ฅผ 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๋‹จ๊ณ„ ์ „๋žต:
    1. ```json ... ``` ์ฝ”๋“œํŽœ์Šค๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ ์•ˆ์˜ ๋ณธ๋ฌธ์„ ๊บผ๋‚ด om.readTree ๋กœ ํŒŒ์‹ฑ ๊ฒ€์ฆ(ํŒŒ์‹ฑ ์‹คํŒจํ•˜๋ฉด ๋ฌด์‹œ).
    2. ์ฝ”๋“œํŽœ์Šค๊ฐ€ ์—†์œผ๋ฉด ์ค‘๊ด„ํ˜ธ ๊ท ํ˜•(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.convertValue vs om.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) โ†’ ํ”„๋ก ํŠธ ์บ์‹œ.

โ…ก. 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).

โš ๏ธ ํ•จ์ •ยท๋ณด์•ˆ ์ฃผ์˜ (์ฝ”๋“œ์— ๋ฐ•ํžŒ ๊ตํ›ˆ ๋ชจ์Œ)

  1. Claude CLI = Bash/๋„คํŠธ์›Œํฌ ์ฐจ๋‹จ(์ตœ์šฐ์„ ) โ€” --allowedTools Read,Edit,Write,Glob,Grep + --disallowedTools Bash,WebFetch,WebSearch. ์—ฌ๊ธฐ์— ๊ธฐ๋ณธ OFF(enabled=false) + ์ž„์‹œ ๊ฒฉ๋ฆฌ ๋””๋ ‰ํ„ฐ๋ฆฌ + --max-budget-usd 1 + ํƒ€์ž„์•„์›ƒ 4์ค‘ ๋ฐฉ์–ด. ํ•˜๋‚˜๋ผ๋„ ํ’€๋ฉด "AI ์—๊ฒŒ ์…ธ์„ ์ฅ์–ด์ฃผ๋Š”" ์œ„ํ—˜.
  2. ํŒจ์น˜ JSON ํŒŒ์‹ฑ ๋ณต๊ตฌ โ€” LLM ์‘๋‹ต์€ ์„ค๋ช…+JSON ์ด ์„ž์ž…๋‹ˆ๋‹ค. extractJsonBlock/extractFirstJsonArray ์˜ brace-matching + ๋ฌธ์ž์—ด/์ด์Šค์ผ€์ดํ”„ ์ถ”์ ์œผ๋กœ JSON ๋งŒ ๋„๋ ค๋ƒ…๋‹ˆ๋‹ค. ํ†ต์งธ readTree ๋Š” ๊นจ์ง. ์ •ํ˜•ํ™”๋Š” ๋ฐฐ์—ด ์‹คํŒจ ์‹œ ๋‹จ์ผ ๊ฐ์ฒด๋กœ, ๊ทธ๋ž˜๋„ ์•ˆ ๋˜๋ฉด ๋ช…์‹œ์  ์˜ˆ์™ธ.
  3. ์›Œํฌ์ŠคํŽ˜์ด์Šค ์ธ์ง€ ์ปจํ…์ŠคํŠธ = ์ตœ๊ทผ ์ˆ˜์ •์‚ฌํ•ญ โ€” buildWorkspaceStateContext ๊ฐ€ ํ˜„์žฌ ๋ชฉํ‘œ/์ „๋žต/๋ฐฑํ…Œ์ŠคํŠธ ์ˆ˜์น˜๋ฅผ ์ปจํ…์ŠคํŠธ ์•ž์— ์ฃผ์ž…ํ•ด์•ผ AI ๊ฐ€ *"์ด ์ „๋žต ์Šน๋ฅ  ์˜ฌ๋ ค์ค˜"* ์— ๋‹ตํ•ฉ๋‹ˆ๋‹ค. stats ๋งŒ(equity_curve ์ œ์™ธ) ๋„ฃ์–ด ํ† ํฐ ํญ๋ฐœ ๋ฐฉ์ง€. ๋นˆ ์›Œํฌ์ŠคํŽ˜์ด์Šค๋Š” ๋นˆ ๋ฌธ์ž์—ด(์˜จ๋ณด๋”ฉ ๋ณดํ˜ธ).
  4. self-injection ํŠธ๋žœ์žญ์…˜ โ€” auto-run ์€ ๋ฐ˜๋“œ์‹œ self.doXxx(ํ”„๋ก์‹œ ๊ฒฝ์œ )๋กœ ํ˜ธ์ถœํ•ด์•ผ @Transactional ์ด ๋‹จ๊ณ„๋ณ„๋กœ ์ž‘๋™. this. ๋ฉด ํŠธ๋žœ์žญ์…˜์ด ์•ˆ ๊ฑธ๋ฆผ. ๊ฐ ๋‹จ๊ณ„ ํ›„ workspaceRepo.findById ์žฌ๋กœ๋”ฉ.
  5. before/after ์„ธ JSON ์ „๋ถ€ ์Šค๋ƒ…์ƒท โ€” strategyConfig ๋งŒ ์ €์žฅํ•˜๋ฉด goalProfileยทcode ํŒจ์น˜ undo ๋ถˆ๊ฐ€. {strategyConfig, goalProfile, codeJson} ํ†ต์งธ ๋ณด์กด + ๊ตฌํฌ๋งท ํด๋ฐฑ.
  6. undo ๋Š” PENDING ์—์„œ๋งŒ โ€” KEPT/UNDONE ์€ 409. keep ์€ ๋ฉฑ๋“ฑ(์ด๋ฏธ ์ฒ˜๋ฆฌ๋์œผ๋ฉด ๋ฌด๋™์ž‘).
  7. LIVE ์ƒํƒœ ๊ฐ•๋“ฑ ๊ธˆ์ง€ โ€” backtest/formalize ๊ฐ€ if (!"LIVE"...) ๊ฐ€๋“œ๋กœ ์šด์šฉ ์ค‘ ์›Œํฌ์ŠคํŽ˜์ด์Šค์˜ status ๋ฅผ ๋ฎ์ง€ ์•Š์Œ.
  8. ์‹ ์›์€ JWT ์—์„œ๋งŒ + ์†Œ์œ ๊ถŒ ๊ฒ€์ฆ โ€” AuthContext.currentUserId() + findByIdAndUserId. ํด๋ผ์ด์–ธํŠธ userId ๋ถˆ์‹ (IDOR ๋ฐฉ์ง€).
  9. ์ž๋™์ฃผ๋ฌธ์€ MOCK ์šฐ์„  โ€” doQueueOrders ๋Š” infinite_buying ์ „์šฉ + BrokerAccount ์—†์œผ๋ฉด MOCK KIS ๊ณ„์ • ์ž๋™ ์‚ฌ์šฉ(์—†์œผ๋ฉด ์—๋Ÿฌ). OrderProposal ์€ PENDING + 24h ๋งŒ๋ฃŒ. ์‹ค์ฃผ๋ฌธ์€ ๋ณ„๋„ ์Šน์ธ ๋‹จ๊ณ„(05 ๋ณด์•ˆ ์„ค๊ณ„์˜ MOCKโ†’REAL ๊ฒŒ์ดํŠธ).
  10. ์—๋Ÿฌ โ†’ ์ƒํƒœ์ฝ”๋“œ ์˜๋ฏธ ๋ถ€์—ฌ โ€” Analytics ์‹คํŒจ 502, ์ „์ œ์กฐ๊ฑด 422, ์ถฉ๋Œ 409, ์ธ์ฆ 401. ํ”„๋ก ํŠธ๊ฐ€ ์ •ํ™•ํžˆ ๋ถ„๊ธฐ.

๐Ÿš€ ๊ณ ๋„ํ™” ์•„์ด๋””์–ด (๊ฐ•์˜ยท๊ฐœ์„  ๋ฒ„์ „์šฉ)

  • ๋ชฉํ‘œ ๊ฒŒ์ดํŠธ ์ •ํ•ฉ์„ฑ: hasAllGoalKeys ๊ฐ€ initial_capital_krw ๋ฅผ ๊ฒ€์‚ฌ ์•ˆ ํ•จ(ํ”„๋กฌํ”„ํŠธ๋Š” 8๊ฐœ ์š”๊ตฌ). ๊ฒŒ์ดํŠธ๋ฅผ ํ”„๋กฌํ”„ํŠธ์™€ ์ผ์น˜์‹œํ‚ค๊ฑฐ๋‚˜, ํ”„๋กฌํ”„ํŠธ์˜ "8๊ฐœ" ํ‘œํ˜„์„ ๊ฒŒ์ดํŠธ์™€ ๋งž์ถ”๊ธฐ โ€” ์š”๊ตฌ์‚ฌํ•ญ ๋‹จ์ผ ์ง„์‹ค ๊ฐ•์˜ ์˜ˆ์ œ๋กœ ์ตœ์ .
  • JSON ์ถ”์ถœ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌํ™”: extractJsonBlock/extractFirstJson/extractFirstJsonArray 3๊ฐœ์˜ ์ค‘๋ณตยท๋ฏธ๋ฌ˜ํ•œ ์ฐจ์ด(๋ฌธ์ž์—ด ์ถ”์  ์œ ๋ฌด)๋ฅผ ํ•œ ์œ ํ‹ธ๋กœ ํ†ตํ•ฉ + ๋‹จ์œ„ ํ…Œ์ŠคํŠธ(์ค‘์ฒฉ/์ด์Šค์ผ€์ดํ”„/๋ฐฐ์—ด/์ž˜๋ฆฐ ์‘๋‹ต).
  • ์ŠคํŠธ๋ฆฌ๋ฐ์„ 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). ๊ณ ๊ฐ(ํ”„๋ก ํŠธ์—”๋“œ)์ด ์งˆ๋ฌธ์„ ๋˜์ง€๋ฉด:

  1. ์ ‘์ˆ˜ ๋ฐ์Šคํฌ(Controller) ๊ฐ€ ์ „ํ™”๋ฅผ ๋ฐ›๊ณ 
  2. ๊ด€๋ฆฌ์‹ค(AiGatewayService) ์ด "์ด ๊ณ ๊ฐ์ด ์ด๋ฒˆ ๋‹ฌ ์ƒ๋‹ด ์‹œ๊ฐ„์„ ๋‹ค ์ผ๋‚˜?"(์ฟผํ„ฐ)๋ฅผ ํ™•์ธํ•œ ๋’ค
  3. ์ ํ•ฉํ•œ ์ƒ๋‹ด์‚ฌ(Provider) ์—๊ฒŒ ์—ฐ๊ฒฐํ•˜๊ณ 
  4. ํ†ตํ™”๊ฐ€ ๋๋‚˜๋ฉด ํ†ตํ™” ๊ธฐ๋ก๋ถ€(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, PerplexityProvider 4๊ฐœ๋ฅผ ๋ชจ๋‘ ๋‹ด์•„ ์ฃผ์ž…ํ•ฉ๋‹ˆ๋‹ค.
  • ๊ด€๋ฆฌ์‹ค์€ "์ง์›์ด ๋ช‡ ๋ช…์ธ์ง€ยท๋ˆ„๊ตฌ์ธ์ง€" ํ•˜๋“œ์ฝ”๋”ฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ƒˆ ์ง์›(ํ”„๋กœ๋ฐ”์ด๋”)์ด ์™€๋„ ์ด ์ฝ”๋“œ๋Š” ๊ทธ๋Œ€๋กœ.

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

์ด ๋ฉ”์„œ๋“œ๊ฐ€ ์ฟผํ„ฐ ์‹œ์Šคํ…œ์˜ ์‹ฌ์žฅ์ž…๋‹ˆ๋‹ค. ํ•œ ์ค„์”ฉ ํ’€์–ด๋ด…๋‹ˆ๋‹ค.

  1. ๋ชจ๋ธ ์กด์žฌยทํ™œ์„ฑ ๊ฒ€์ฆ: ๋ฉ”๋‰ดํŒ(catalogRepo)์—์„œ modelId๋ฅผ ์ฐพ๊ณ , .filter(isEnabled)๋กœ "๊บผ์ง„ ๋ชจ๋ธ"์„ ๊ฑธ๋Ÿฌ๋ƒ…๋‹ˆ๋‹ค. ์—†์œผ๋ฉด IllegalArgumentException.
  2. ํ‹ฐ์–ด ํ™•์ธ: subscriptionService.currentTier(userId)๋กœ ์ด ์‚ฌ์šฉ์ž๊ฐ€ ๋ฌด๋ฃŒ(FREE)์ธ์ง€ ์œ ๋ฃŒ(PRO)์ธ์ง€ ํŒ์ •. (๊ฒฐ์ œ ๋„๋ฉ”์ธ๊ณผ ์—ฐ๊ฒฐ๋˜๋Š” ์ง€์ .)
  3. ํ•œ๋„ ์„ ํƒ: Pro๋ฉด proQuota, ์•„๋‹ˆ๋ฉด freeQuota. ํ•œ๋„๋Š” ์ˆซ์ž 3์ข… ์˜๋ฏธ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค:
    • 0 โ†’ Pro ์ „์šฉ(Free๋Š” ๋ชป ์”€) โ†’ ์ฆ‰์‹œ ๊ฑฐ๋ถ€
    • -1 โ†’ ๋ฌด์ œํ•œ โ†’ ์‚ฌ์šฉ๋Ÿ‰ ๊ฒ€์‚ฌ ์ž์ฒด๋ฅผ ๊ฑด๋„ˆ๋œ€
    • ๊ทธ ์™ธ ์–‘์ˆ˜ โ†’ ์›” ํ† ํฐ ํ•œ๋„
  4. ์ด๋ฒˆ ๋‹ฌ ๋ˆ„์  ํ•ฉ์‚ฐ: 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();
  • ํ”„๋ก ํŠธ์˜ ๋ชจ๋ธ ๋“œ๋กญ๋‹ค์šด์— ๋ฟŒ๋ฆด ๋ฐ์ดํ„ฐ๋ฅผ ๋ชจ๋ธ๋งˆ๋‹ค ํ•œ ์ค„์”ฉ ๋งŒ๋“ญ๋‹ˆ๋‹ค.
  • usable 3์กฐ๊ฑด 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 ์—ฐ๋™ ํ•„์š”(๊ณ ๋„ํ™”).

โš ๏ธ ํ•จ์ •ยท๋ณด์•ˆ ์ฃผ์˜

  1. 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 ๊ทœ๊ฒฉ์ƒ ๋ถˆ๊ฐ€ํ”ผํ•˜๋‚˜, ๋กœ๊ทธ ๋งˆ์Šคํ‚น ์ ๊ฒ€ ํ•„์š”).
  2. M10 ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹์˜ ์‹ ์› ํ•ด์„ โ€” ํ•„ํ„ฐ ์ˆœ์„œ์— ์ƒ๋ช…์ด ๋‹ฌ๋ฆผ.

    • ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹์€ AuthContext๊ฐ€ ์•„๋‹ˆ๋ผ request attribute(JwtAuthenticationFilter.ATTR_USER_ID)๋กœ ์‹ ์›์„ ์ฝ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค(H-1). @Order๋ฅผ ์ž˜๋ชป ๋ฐ”๊พธ๋ฉด attribute๊ฐ€ ๋น„์–ด ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹์ด ๋‹ค์‹œ ์ „์› ๋ฌด๋ ฅํ™”๋ฉ๋‹ˆ๋‹ค. ํ•„ํ„ฐ ์ˆœ์„œ ๋ณ€๊ฒฝ ์‹œ ๋ฐ˜๋“œ์‹œ ์žฌ๊ฒ€์ฆ.
  3. /api/ai/chatยท/api/llm/chat์€ ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹ ๋ฐ–.

    • AiRateLimitFilter๋Š” /api/alpha/workspaces/**์˜ 4๊ฐœ ์—”๋“œํฌ์ธํŠธ๋งŒ ๋ง‰์Šต๋‹ˆ๋‹ค. ๋ฉ”์ธ ์ฑ„ํŒ…(/api/ai/chat)๊ณผ ์ž์œ ์ฑ„ํŒ…(/api/llm/chat)์€ ํšŸ์ˆ˜ ์ œํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค. /api/ai/chat์€ ๊ทธ๋‚˜๋งˆ ๊ฒŒ์ดํŠธ์›จ์ด ์ฟผํ„ฐ(์›” ํ† ํฐ)๊ฐ€ ๋ง‰์ง€๋งŒ, /api/llm/chat์€ ์ฟผํ„ฐ๋„ ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹๋„ ์—†์Šต๋‹ˆ๋‹ค โ€” ํ‚ค๋งŒ ์žˆ์œผ๋ฉด ๋ฌด์ œํ•œ ํ˜ธ์ถœ ๊ฐ€๋Šฅ. ๋น„์šฉยท์•…์šฉ ๊ด€์ ์—์„œ ๊ฐ€์žฅ ์•ฝํ•œ ๊ณ ๋ฆฌ.
  4. ๋น„๋กœ๊ทธ์ธ ๊ฒฝ๋กœ์˜ ๊ฒŒ์ดํŠธ ์šฐํšŒ.

    • AiController.chat์€ uid == null์ด๋ฉด ๊ฒŒ์ดํŠธ์›จ์ด๋ฅผ ๊ฑด๋„ˆ๋›ฐ๊ณ  GeminiService๋ฅผ ์ง์ ‘ ๋ถ€๋ฆ…๋‹ˆ๋‹ค(G-1). ์ด ์—”๋“œํฌ์ธํŠธ์— ์ธ์ฆ์ด ๊ฐ•์ œ๋˜์ง€ ์•Š์œผ๋ฉด ์ต๋ช… ์‚ฌ์šฉ์ž๊ฐ€ ์ฟผํ„ฐ ์—†์ด Gemini๋ฅผ ๋ฌดํ•œ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. SecurityConfig์—์„œ /api/ai/**์˜ ์ธ์ฆ ์š”๊ตฌ ์—ฌ๋ถ€๋ฅผ ๋ฐ˜๋“œ์‹œ ํ™•์ธํ•  ๊ฒƒ.
  5. Gemini ํ† ํฐ์€ ์ถ”์ •๊ฐ’ โ€” ์ฟผํ„ฐยท์ฒญ๊ตฌ๊ฐ€ ๋ถ€์ •ํ™•.

    • GeminiProvider.estimateTokens๋Š” ๊ธ€์ž์ˆ˜รท3 ์ถ”์ •(C ์„น์…˜). ์ •ํ™•ํ•œ usageMetadata๋ฅผ ์•ˆ ์”๋‹ˆ๋‹ค. Gemini ๋ชจ๋ธ์˜ ์ฟผํ„ฐ/์ฒญ๊ตฌ๋Š” ๋‹ค๋ฅธ 3์‚ฌ๋ณด๋‹ค ๋ถ€์ •ํ™•ํ•ฉ๋‹ˆ๋‹ค. ๋ณด์ˆ˜์ (๊ณผ๋Œ€)์ด๋ผ ์‚ฌ์šฉ์ž๊ฐ€ ์†ํ•ด ๋ณผ ์ผ์€ ๋“œ๋ฌผ์ง€๋งŒ, ์ •์‚ฐ ์ •ํ™•๋„๊ฐ€ ํ•„์š”ํ•˜๋ฉด ์‹ค์ œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ ํ•„์š”.
  6. ํด๋ฐฑ ์‹œ ๋น„์šฉยท์ผ๊ด€์„ฑ ์ฃผ์˜.

    • ๋ชจ๋ธ ํด๋ฐฑ(GeminiService): 1์ˆœ์œ„ ๋ชจ๋ธ์ด 429/403์ด๋ฉด ํด๋ฐฑ ๋ชจ๋ธ๋กœ ์ž๋™ ์ „ํ™˜ โ€” ํด๋ฐฑ ๋ชจ๋ธ์ด ๋” ๋น„์‹ธ๊ฑฐ๋‚˜ ํ’ˆ์งˆ์ด ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ. ์‚ฌ์šฉ์ž๋Š” ๋‹ค๋ฅธ ๋ชจ๋ธ๋กœ ๋‹ต๋ฐ›์€ ๊ฑธ ๋ชจ๋ฅผ ์ˆ˜ ์žˆ์Œ.
    • ํ”„๋กœ๋ฐ”์ด๋” ํด๋ฐฑ(LlmRouter): ํ‚ค ์—†๋Š” provider ์š”์ฒญ ์‹œ ์กฐ์šฉํžˆ default๋กœ ๋Œ€์ฒด. ์‘๋‹ต์— ์‹ค์ œ provider๋ฅผ ๋‹ด์•„์ฃผ๊ธด ํ•˜๋‚˜(G-2), ํ”„๋ก ํŠธ๊ฐ€ ์ด๋ฅผ ํ‘œ์‹œํ•˜์ง€ ์•Š์œผ๋ฉด ์‚ฌ์šฉ์ž๋Š” "Claude์—๊ฒŒ ๋ฌผ์—ˆ๋Š”๋ฐ Gemini๊ฐ€ ๋‹ตํ•œ" ์ƒํ™ฉ์„ ๋ชจ๋ฅผ ์ˆ˜ ์žˆ์Œ.
    • โš ๏ธ ๋‹จ, ๊ฒŒ์ดํŠธ์›จ์ด(gateway) ๋ฌถ์Œ์€ ํ”„๋กœ๋ฐ”์ด๋” ๊ฐ„ ํด๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค. ์ง ํ”„๋กœ๋ฐ”์ด๋”๊ฐ€ ์—†๊ฑฐ๋‚˜ ํ‚ค ์—†์œผ๋ฉด ๊ทธ๋ƒฅ ๊ฑฐ๋ถ€ โ€” ์ฟผํ„ฐ ์ •ํ™•์„ฑ์„ ์œ„ํ•ด ์˜๋„์ ์œผ๋กœ ํด๋ฐฑ์„ ๋บ€ ์„ค๊ณ„.
  7. ๋กœ๊ทธ ์ €์žฅ ์‹คํŒจ๋Š” ์‚ผ์ผœ์ง โ€” ์ฟผํ„ฐ ๋ˆ„๋ฝ ๊ฐ€๋Šฅ์„ฑ.

    • recordUsage๋Š” DB ์ €์žฅ ์‹คํŒจ๋ฅผ log.warn๋งŒ ํ•˜๊ณ  ๋„˜๊น๋‹ˆ๋‹ค(B-7). ์ €์žฅ์ด ์‹คํŒจํ•˜๋ฉด ๊ทธ ํ˜ธ์ถœ์˜ ํ† ํฐ์ด ์ฟผํ„ฐ์— ๋ฐ˜์˜๋˜์ง€ ์•Š์•„ ํ•œ๋„๋ฅผ ์‚ด์ง ๋„˜๊ฒŒ ์“ธ ์ˆ˜ ์žˆ์Œ. ๋ณธ ํ๋ฆ„ ๋ณดํ˜ธ๋ฅผ ์œ„ํ•œ ํŠธ๋ ˆ์ด๋“œ์˜คํ”„.
  8. ์ธ๋ฉ”๋ชจ๋ฆฌ ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹ โ€” ์ˆ˜ํ‰ ํ™•์žฅ ์‹œ ๊นจ์ง.

    • ๋ฒ„ํ‚ท์ด 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) vs tools.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์ฐจ ๋ฐฉ์–ด์„ .


โš ๏ธ ํ•จ์ •ยท๋ณด์•ˆ ์ฃผ์˜ (์ฝ”๋“œ์— ๋ฐ•ํžŒ ๊ตํ›ˆ ๋ชจ์Œ)

  1. M10 โ€” ํ•„ํ„ฐ ์•ˆ์—์„œ AuthContext ์“ฐ๋ฉด ํ•ญ์ƒ null (๊ฐ€์žฅ ์ค‘์š”)

    • AuthContext.currentUserId() ๋Š” RequestContextHolder ์˜์กด์ธ๋ฐ, ์ด๊ฑด DispatcherServlet ์ดํ›„์—์•ผ ์ฑ„์›Œ์ง. ์„œ๋ธ”๋ฆฟ ํ•„ํ„ฐ(๋””์ŠคํŒจ์ฒ˜ ์ด์ „) ์—์„  ๋น„์–ด ์žˆ์–ด ํ•ญ์ƒ null.
    • ๊ฒฐ๊ณผ(๊ณผ๊ฑฐ ๋ฒ„๊ทธ): ๋ชจ๋“  ์‚ฌ์šฉ์ž๊ฐ€ null ๋กœ ๋ฌถ์—ฌ ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹์ด ์ „์› ๋ฌด๋ ฅํ™”.
    • ์ •๋‹ต 2์ข… ์„ธํŠธ: โ‘  AiRateLimitFilter ๋Š” request.getAttribute(ATTR_USER_ID) ์ง๋… โ‘ก ํ•„ํ„ฐ ์ˆœ์„œ(JWT @Order(+10) ๊ฐ€ RateLimit @Order(+20) ๋ณด๋‹ค ๋จผ์ € ์‹คํ–‰๋ผ attribute ๋ฅผ ๋ฏธ๋ฆฌ ์ฑ„์›€). ๋‘˜ ์ค‘ ํ•˜๋‚˜๋งŒ ์–ด๊ธ‹๋‚˜๋„ ๊นจ์ง.
  2. Spring Security ํ”„๋ ˆ์ž„์›Œํฌ ์ž์ฒด๊ฐ€ OFF

    • build.gradle ์—์„œ spring-boot-starter-security ๋Š” ์ฃผ์„ ์ฒ˜๋ฆฌ, spring-security-crypto(BCrypt)๋งŒ ์˜์กด. โ†’ SecurityFilterChainยท์ž๋™ ์ธ์ฆ/์ธ๊ฐ€๊ฐ€ ์—†์Œ.
    • ์ธ์ฆ์€ ์ „๋ถ€ ์ˆ˜๋™(JwtAuthenticationFilter+AuthContext). ๊ทธ๋ž˜์„œ "ํ•„ํ„ฐ๊ฐ€ ๋ง‰์ง€ ์•Š๊ณ  ํ†ต๊ณผ์‹œํ‚ค๊ณ , ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ requireUserId() ๋กœ ๊ฑฐ๋ถ€"ํ•˜๋Š” ํŒจํ„ด์ด ์œ ์ผํ•œ ๋ฐฉ์–ด์„  โ†’ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ ์‹ ์› ์ฒดํฌ๋ฅผ ๋น ๋œจ๋ฆฌ๋ฉด ๊ณง ์ธ๊ฐ€ ๋ˆ„๋ฝ. (์ตœ๊ทผ ์ปค๋ฐ‹ "NotificationController IDOR ์ฐจ๋‹จ"์ด ๋ฐ”๋กœ ์ด ๊ณ„์—ด ์‚ฌ๊ณ .)
  3. ํ•„ํ„ฐ ์ˆœ์„œ ์˜์กด์„ฑ์€ ์ฃผ์„์œผ๋กœ๋งŒ ๊ฐ•์ œ๋จ

    • @Order(+10)/@Order(+20) ์ˆซ์ž ๊ด€๊ณ„๊ฐ€ ๊นจ์ง€๋ฉด ์กฐ์šฉํžˆ ์˜ค์ž‘๋™. ์ƒˆ ํ•„ํ„ฐ ์ถ”๊ฐ€ ์‹œ ์ด ์ƒ๋Œ€ ์ˆœ์„œ๋ฅผ ๋ฐ˜๋“œ์‹œ ๊ณ ๋ ค. ์ˆซ์ž๋Š” "์ ˆ๋Œ€๊ฐ’"์ด ์•„๋‹ˆ๋ผ "์ƒ๋Œ€ ์ˆœ์„œ"๋ผ๋Š” ์ ์— ์œ ์˜.
  4. ์‹œ๋” ๋ฉฑ๋“ฑ์„ฑ โ€” count()>0 ๊ฐ€๋“œ๋Š” ํ•„์ˆ˜

    • ์‹œ๋”๋Š” ๋งค ๊ธฐ๋™ ์‹คํ–‰. if (count()>0) return; ๋น ๋œจ๋ฆฌ๋ฉด ๋งค๋ฒˆ ์ค‘๋ณต ์‚ฝ์ž…. ์ƒˆ seedXxx ์ถ”๊ฐ€ ์‹œ ๋ฐ˜๋“œ์‹œ โ‘  ๋ฉฑ๋“ฑ ๊ฐ€๋“œ โ‘ก "์ด๋ฏธ ์žˆ์œผ๋ฉด ๋งคํ•‘๋งŒ ๋ณต์›" 2๋‹จ ํŒจํ„ด.
    • DataCleanupService ๋„ size() <= 50 ๊ฐ€๋“œ๋กœ ๋ฉฑ๋“ฑ(์žฌ๊ธฐ๋™ ์‹œ ์ถ”๊ฐ€ ์‚ญ์ œ ์—†์Œ).
  5. app.crypto.key ์ค‘๋ณต/ํด๋ฐฑ ์‚ฌ๊ณ  (๋ฉ”๋ชจ๋ฆฌ ๋…ธํŠธ์™€ ์ง๊ฒฐ)

    • AesGcmCryptoService ๋Š” app.crypto.key ์—†์œผ๋ฉด app.jwt.secret ์œผ๋กœ ํด๋ฐฑ โ†’ ๋‘˜์ด ๋‹ค๋ฅด๊ฑฐ๋‚˜, .env/application*.properties ์— ๊ฐ™์€ ํ‚ค๊ฐ€ ๋‹ค๋ฅธ ๊ฐ’์œผ๋กœ ์ค‘๋ณต๋˜๋ฉด, ๋ณตํ˜ธํ™” ํ‚ค๊ฐ€ ๋ฐ”๋€Œ์–ด ๊ธฐ์กด ์•”ํ˜ธ๋ฌธ์„ ๋ชป ํ‘ธ๋Š” ์‚ฌ๊ณ . ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์šฐ์„ ์ˆœ์œ„ ๋ณ€๊ฒฝ ์ „ ์ค‘๋ณต ํ‚ค ๊ฐ์‚ฌ ํ•„์ˆ˜.
  6. AOP self-invocation โ€” REQUIRES_NEW ๋ฌด์‹œ

    • DataCleanupService.cleanupAll() ๋‚ด๋ถ€์—์„œ cleanupProjects() ๋ฅผ ๋ถ€๋ฅด๋ฉด ํ”„๋ก์‹œ๋ฅผ ์•ˆ ๊ฑฐ์ณ ํŠธ๋žœ์žญ์…˜ ์–ด๋…ธํ…Œ์ด์…˜์ด ๋ฌด์‹œ๋จ. โ†’ DataSeeder ๊ฐ€ ์™ธ๋ถ€์—์„œ ์ง์ ‘ ๊ฐ ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•ด ์ง„์งœ ๋…๋ฆฝ ํŠธ๋žœ์žญ์…˜์„ ๋งŒ๋“  ๊ฒƒ.
  7. JWT ํŽ˜์ด๋กœ๋“œ๋Š” ๋น„๋ฐ€์ด ์•„๋‹˜

    • ์„œ๋ช…์œผ๋กœ ์œ„์กฐ๋Š” ๋ง‰์ง€๋งŒ ๋‚ด์šฉ์€ ๋ˆ„๊ตฌ๋‚˜ ๋””์ฝ”๋“œ ๊ฐ€๋Šฅ(Base64). claims ์— ๋ฏผ๊ฐ์ •๋ณด ๋„ฃ์ง€ ๋ง ๊ฒƒ. uid/type ์ •๋„๋งŒ.
  8. CORS + ์ฟ ํ‚ค ์กฐํ•ฉ ์ฃผ์˜

    • allowCredentials(true) ๋ฉด allowedOrigins("*") ๊ธˆ์ง€(๋ธŒ๋ผ์šฐ์ € ๊ฑฐ๋ถ€). ๋ฐ˜๋“œ์‹œ ๋ช…์‹œ์  ์ถœ์ฒ˜ ๋ชฉ๋ก. ์šด์˜์— ์‹ค์ œ ๋„๋ฉ”์ธ ๋„ฃ๋Š” ๊ฑธ ์žŠ์œผ๋ฉด ํ”„๋ก ํŠธ๊ฐ€ ๋ง‰ํž˜.
  9. ํ„ฐ๋ฏธ๋„์€ ์šด์˜์—์„œ ์ผœ์ง€ ๋ง ๊ฒƒ

    • app.terminal.enabled ๊ธฐ๋ณธ OFF + loopback ๊ฒ€๋ฌธ. ์ผœ๋Š” ์ˆœ๊ฐ„ RCE ์œ„ํ—˜์ด ์ƒ๊ธฐ๋ฏ€๋กœ ๋กœ์ปฌ ๊ฐœ๋ฐœ์—์„œ๋งŒ.
  10. Jackson 2/3 ํ˜ผ์žฌ

    • Boot4=Jackson3. ์˜› com.fasterxml.jackson.JsonNode ๋ฅผ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๋ฐ˜ํ™˜ํ•˜๋ฉด ์ง๋ ฌํ™”๊ฐ€ ๊นจ์ง. Jackson2NodeBridgeConfig ๊ฐ€ ์™ธ๊ณผ์ ์œผ๋กœ ๋ฉ”์›€. ์ƒˆ ์ฝ”๋“œ๋Š” ๊ฐ€๊ธ‰์  Jackson3 ๋˜๋Š” DTO ์‚ฌ์šฉ.

๐Ÿš€ ๊ณ ๋„ํ™” ์•„์ด๋””์–ด (๊ฐ•์˜ยท๊ฐœ์„  ๋ฒ„์ „์šฉ)

  • ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹ 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 ํŒจํ„ด์˜ ํ•ต์‹ฌ ์ด์œ ):
    1. ๋ณด์•ˆ/์บก์Аํ™”: ์—”ํ‹ฐํ‹ฐ Notification ์—๋Š” user(User ๊ฐ์ฒด ์ „์ฒด)๊ฐ€ ๋“ค์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ๊ทธ๋Œ€๋กœ JSON ์ง๋ ฌํ™”ํ•˜๋ฉด ์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹œยท์ด๋ฉ”์ผ ๋“ฑ ๋ฏผ๊ฐ์ •๋ณด๊ฐ€ ์ƒˆ์–ด๋‚˜๊ฐˆ ์œ„ํ—˜์ด ์žˆ์Šต๋‹ˆ๋‹ค. DTO๋Š” user๋ฅผ ํ†ต์งธ๋กœ ๋นผ๊ณ , ์™ธ๋ถ€์— ์ค˜๋„ ๋˜๋Š” ํ•„๋“œ๋งŒ ๊ณจ๋ผ ๋‹ด์Šต๋‹ˆ๋‹ค.
    2. ์ง€์—ฐ๋กœ๋”ฉ ํ•จ์ • ํšŒํ”ผ: ์—”ํ‹ฐํ‹ฐ์˜ LAZY ํ•„๋“œ๋ฅผ ์ง๋ ฌํ™” ์‹œ์ ์— ๊ฑด๋“œ๋ฆฌ๋ฉด LazyInitializationException ์ด ๋‚  ์ˆ˜ ์žˆ๋Š”๋ฐ, DTO๋กœ ๋ฏธ๋ฆฌ ํ‰ํƒ„ํ™”ํ•˜๋ฉด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.
    3. ํƒ€์ž… ๋ณ€ํ™˜: 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์ข…:
    1. user/signals ๊ฐ€ ๋น„๋ฉด ์กฐ์šฉํžˆ ์ข…๋ฃŒ(๋ณด๋‚ผ ๊ฒŒ ์—†์Œ).
    2. ๋ฐ›๋Š” ์‚ฌ๋žŒ ์ด๋ฉ”์ผ์ด ์—†์œผ๋ฉด ๊ฒฝ๊ณ  ๋กœ๊ทธ + skip(๋ฉ”์ผ ๋ณด๋‚ผ ๊ณณ์ด ์—†์Œ).
    3. ๋ฐœ์‹  ์ฃผ์†Œ(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/PAUSE enum, 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 ๋“ค์€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๊ณ„์† ๋ฐœ์†ก ์ค‘์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰ ๋ฐ˜ํ™˜๋œ ์ˆซ์ž๋Š” "๋ฐœ์†ก ์‹œ๋„ ๋Œ€์ƒ ์ˆ˜"์ด์ง€ "๋ฐœ์†ก ์„ฑ๊ณต ์ˆ˜"๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.

โš ๏ธ ํ•จ์ •ยท๋ณด์•ˆ ์ฃผ์˜

  1. 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๋‹จ ํŒจํ„ด.
  2. M5 โ€” ํ”„๋ก ํŠธ์—”๋“œ๊ฐ€ ๋ฐฑ์—”๋“œ ์•Œ๋ฆผ๊ณผ ๋ฏธ์—ฐ๋™(ํ˜„ํ™ฉ)

    • ๋ฐฑ์—”๋“œ /api/notifications API๋Š” ์™„๋น„๋˜์–ด ์žˆ์œผ๋‚˜, ํ”„๋ก ํŠธ์—”๋“œ๋Š” ์•„์ง ์ด API๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
    • ์‹ค์ œ ์ฝ”๋“œ ์ฆ๊ฑฐ: frontend/src/store/useNotificationStore.js ์˜ buildInitial()(L4-L50)์ด ํ•˜๋“œ์ฝ”๋”ฉ๋œ ๋”๋ฏธ ์•Œ๋ฆผ 7๊ฑด("์ „๋žต ์ •ํ˜•ํ™” ์™„๋ฃŒ", "Trust Score ์ƒ์Šน" ๋“ฑ)์„ ๋งŒ๋“ค๊ณ , Zustand persist ๋กœ localStorage(alpha-notifications)์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. markRead/markAllRead/remove/clearAll ๋ชจ๋‘ ๋กœ์ปฌ ์ƒํƒœ๋งŒ ๋ฐ”๊พธ๋ฉฐ ๋ฐฑ์—”๋“œ๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค(L52-L74).
    • ๊ฒฐ๊ณผ: ํ™”๋ฉด์˜ ์•Œ๋ฆผ์€ ๋ธŒ๋ผ์šฐ์ € ์•ˆ์—์„œ๋งŒ ์‚ฌ๋Š” ๊ฐ€์งœ ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค. ์„œ๋ฒ„ DB์˜ Notification ๊ณผ ๋ฌด๊ด€ํ•˜๊ณ , ๋‹ค๋ฅธ ๊ธฐ๊ธฐ/๋ธŒ๋ผ์šฐ์ €์™€ ๋™๊ธฐํ™”๋˜์ง€ ์•Š์œผ๋ฉฐ, localStorage๋ฅผ ๋น„์šฐ๋ฉด ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค.
    • ์ฆ‰ ๋ฐฑ์—”๋“œ์™€ ํ”„๋ก ํŠธ๊ฐ€ ๋Š๊ฒจ ์žˆ๋Š”(wire ์•ˆ ๋œ) ์ƒํƒœ๊ฐ€ M5์˜ ํ˜„ํ™ฉ์ž…๋‹ˆ๋‹ค. (๊ณ ๋„ํ™” ์•„์ด๋””์–ด์—์„œ ์—ฐ๊ฒฐ ๋ฐฉ์•ˆ ์ œ์‹œ.)
  3. 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)ํ•˜๊ฑฐ๋‚˜ ์„ค์ •์„ ์™„์ „ํžˆ ๋น„์šฐ๋Š” ๊ฒŒ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.
  4. ์ค‘๋ณต ๋ฐœ์†ก โ€” deliveredAt ๋ฉฑ๋“ฑ ์žฅ์น˜ ์˜์กด

    • ์ค‘๋ณต ๋ฉ”์ผ ๋ฐฉ์ง€๋Š” ์˜ค์ง deliveredAt ๋„์žฅ + findBy...DeliveredAtIsNull ํ•„ํ„ฐ์— ๋‹ฌ๋ ค ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐœ์†ก์€ ์„ฑ๊ณตํ–ˆ๋Š”๋ฐ saveAll ์ง์ „ ์˜ˆ์™ธ๊ฐ€ ๋‚˜๋ฉด(๋“œ๋ฌผ์ง€๋งŒ) ๋„์žฅ์ด ์•ˆ ์ฐํ˜€ ๋‹ค์Œ ๋ฐฐ์น˜์—์„œ ์žฌ๋ฐœ์†ก๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. "์ •ํ™•ํžˆ ํ•œ ๋ฒˆ"์ด ์•„๋‹ˆ๋ผ "์ตœ์†Œ ํ•œ ๋ฒˆ(at-least-once)" ๋ณด์žฅ์— ๊ฐ€๊น์Šต๋‹ˆ๋‹ค.
  5. @Async + @Transactional ๊ฒฝ๊ณ„ ํ˜ผ๋™

    • dispatchPending ์ด ๋ฐ˜ํ™˜ํ•ด๋„ sendDigest ๋“ค์€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„ํ–‰ ์ค‘์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ˜ํ™˜๊ฐ’์€ "์„ฑ๊ณต ์ˆ˜"๊ฐ€ ์•„๋‹Œ "๋Œ€์ƒ ์ˆ˜". ๋˜ํ•œ @Async ๋ฉ”์„œ๋“œ๋Š” ๊ฐ™์€ ํด๋ž˜์Šค ๋‚ด๋ถ€ ํ˜ธ์ถœ์ด๋ฉด ํ”„๋ก์‹œ๋ฅผ ์•ˆ ํƒ€ ๋น„๋™๊ธฐ๊ฐ€ ์•ˆ ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค โ€” ์—ฌ๊ธฐ์„œ๋Š” dispatchPending ์ด this::sendDigest ๋ฅผ ํ˜ธ์ถœํ•˜๋ฏ€๋กœ(์ž๊ธฐ ํด๋ž˜์Šค ๋ฉ”์„œ๋“œ ์ฐธ์กฐ) ์ž๊ธฐ-ํ˜ธ์ถœ(self-invocation) ์ž…๋‹ˆ๋‹ค. ๊ฐ™์€ ๋นˆ ๋‚ด๋ถ€ ํ˜ธ์ถœ์€ Spring AOP ํ”„๋ก์‹œ๋ฅผ ์šฐํšŒํ•  ์ˆ˜ ์žˆ์–ด, ์‹ค์ œ๋กœ ๋น„๋™๊ธฐ๋กœ ๋ถ„๋ฆฌ๋˜์ง€ ์•Š์„ ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ์Šต๋‹ˆ๋‹ค(์„ค์ •/ํ”„๋ก์‹œ ๋ฐฉ์‹์— ๋”ฐ๋ผ). ๋™์ž‘์„ ๋น„๋™๊ธฐ๋กœ ๋ณด์žฅํ•˜๋ ค๋ฉด ๋ฐœ์†ก ๋ฃจํ”„๋ฅผ ๋ณ„๋„ ๋นˆ์œผ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒŒ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค. (ํ•™์Šต ์‹œ ๊ผญ ์งš์„ ํ•จ์ •.)
  6. enum๊ณผ ์‹ค์ œ ์‚ฌ์šฉ์˜ ๊ดด๋ฆฌ

    • NotificationType 12๊ฐœ ์ค‘ ๋‹ค์ˆ˜๋Š” ํ˜‘์—…/๊ณ„์•ฝ(DevBridge) ๋„๋ฉ”์ธ ์šฉ์–ด์ž…๋‹ˆ๋‹ค. ์•Œ๋ฆผ "์ƒ์„ฑ" ์ฝ”๋“œ๋Š” ์ด ๋„๋ฉ”์ธ ๋ฐ–์— ์žˆ์–ด, ์–ด๋–ค ํƒ€์ž…์ด ์‹ค์ œ๋กœ ์“ฐ์ด๋Š”์ง€๋Š” ์ด 5ํŒŒ์ผ๋งŒ์œผ๋กœ ๋‹จ์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ํ™˜๊ฐ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด "๋ฒ”์šฉ ์นดํƒˆ๋กœ๊ทธ"๋กœ๋งŒ ์ดํ•ดํ•˜์„ธ์š”.

๐Ÿš€ ๊ณ ๋„ํ™” ์•„์ด๋””์–ด

  1. 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)์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค. ๋งคํ•‘ ์–ด๋Œ‘ํ„ฐ๋ฅผ ๋‘๊ฑฐ๋‚˜ ํ•œ์ชฝ ์Šคํ‚ค๋งˆ๋กœ ํ†ต์ผํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  2. ์‹ค์‹œ๊ฐ„ ํ‘ธ์‹œ(ํด๋ง โ†’ ์ด๋ฒคํŠธ)

    • ์ง€๊ธˆ์€ ์กฐํšŒํ˜•(pull). SSE(Server-Sent Events)๋‚˜ WebSocket์œผ๋กœ ์ƒˆ ์•Œ๋ฆผ์„ ์‹ค์‹œ๊ฐ„ ํ‘ธ์‹œํ•˜๋ฉด ๋ฐฐ์ง€๊ฐ€ ์ฆ‰์‹œ ๊ฐฑ์‹ ๋ฉ๋‹ˆ๋‹ค. chat ๋„๋ฉ”์ธ์ด ์ด๋ฏธ Stream์„ ์“ฐ๋ฏ€๋กœ ๊ทธ ์ฑ„๋„ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅ.
  3. ์•Œ๋ฆผ ์ƒ์„ฑ ํ‘œ์ค€ํ™”(์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜)

    • ํ˜„์žฌ ์•Œ๋ฆผ ์ƒ์„ฑ ๋กœ์ง์ด chat ์„œ๋น„์Šค์— ์–นํ˜€ ์žˆ์Œ. Spring ApplicationEvent(์˜ˆ: SignalCreatedEvent, OrderFilledEvent)๋ฅผ ๋ฐœํ–‰ํ•˜๊ณ , @EventListener ๊ฐ€ Notification ์„ ๋งŒ๋“ค๋„๋ก ๋ถ„๋ฆฌํ•˜๋ฉด ๋„๋ฉ”์ธ ๊ฒฐํ•ฉ๋„๊ฐ€ ๋‚ฎ์•„์ง‘๋‹ˆ๋‹ค. โ†’ "์‹œ๊ทธ๋„ ์ƒ์„ฑ ์‹œ ์ธ์•ฑ ์•Œ๋ฆผ + ์ด๋ฉ”์ผ" ๋‘ ์ฑ„๋„์„ ํ•œ ์ด๋ฒคํŠธ๋กœ fan-out.
  4. ์ด๋ฉ”์ผ ๊ณ ๋„ํ™”

    • SimpleMailMessage(ํ‰๋ฌธ) โ†’ MimeMessage + Thymeleaf HTML ํ…œํ”Œ๋ฆฟ์œผ๋กœ ๊ฐ€๋…์„ฑยท๋ธŒ๋žœ๋”ฉ ํ–ฅ์ƒ.
    • ๋ฐœ์†ก ์‹คํŒจ ์‹œ ์žฌ์‹œ๋„ ํ/๋ฐฑ์˜คํ”„, ๋ฐœ์†ก ์ด๋ ฅ ํ…Œ์ด๋ธ”(๊ฐ์‚ฌ ๋กœ๊ทธ), ์‚ฌ์šฉ์ž๋ณ„ ์ˆ˜์‹  ๊ฑฐ๋ถ€(opt-out) ์„ค์ •.
  5. @Async ์ž๊ธฐ-ํ˜ธ์ถœ ํ•จ์ • ํ•ด์†Œ

    • ๋ฐœ์†ก ๋ฃจํ”„๋ฅผ ๋ณ„๋„ ๋นˆ(EmailDispatcher)์œผ๋กœ ๋นผ์„œ ๋น„๋™๊ธฐ๊ฐ€ ํ™•์‹คํžˆ ๊ฑธ๋ฆฌ๊ฒŒ. ๋™์‹œ์— ์Šค๋ ˆ๋“œํ’€(TaskExecutor) ํฌ๊ธฐ๋ฅผ ์„ค์ •ํ•ด ๋Œ€๋Ÿ‰ ์‚ฌ์šฉ์ž ๋ฐœ์†ก์„ ์ œ์–ด.
  6. ์ฝ์Œ ๋™๊ธฐํ™” ์ •ํ•ฉ์„ฑ

    • ๋ฒŒํฌ @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 ์—์„œ ๋‹ค๋ฃน๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ๊ฒฐ์ œ ๋„๋ฉ”์ธ ๊ณ ์œ  ๊ฐœ๋…์— ์ง‘์ค‘ํ•ฉ๋‹ˆ๋‹ค.


๐Ÿ“Œ ์ด ๋„๋ฉ”์ธ ํ•œ๋ˆˆ์—

์ด ํŒจํ‚ค์ง€๋Š” ๋ฐฑ์—”๋“œ์˜ "๋ˆ์„ ๋‹ค๋ฃจ๋Š” ์ฐฝ๊ตฌ + ๋น„๋ฐ€์„ ๋ณด๊ด€ํ•˜๋Š” ๊ธˆ๊ณ " ์ž…๋‹ˆ๋‹ค. ๋‘ ๊ฐ€์ง€ ์ผ์„ ํ•ฉ๋‹ˆ๋‹ค.

  1. ๊ฒฐ์ œ ์ฐฝ๊ตฌ โ€” ์‚ฌ์šฉ์ž๊ฐ€ ํ† ์Šค(Toss)๋กœ ๊ตฌ๋…๋ฃŒ๋ฅผ ๋‚ด๋ฉด, ๊ทธ ๊ฒฐ์ œ๊ฐ€ ์ง„์งœ์ธ์ง€ ํ† ์Šค ์„œ๋ฒ„์— ๋ฌผ์–ด๋ณด๊ณ (confirm), ๊ฒฐ์ œ ์ƒํƒœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด(์ทจ์†Œยทํ™˜๋ถˆ) ํ† ์Šค๊ฐ€ ๋ณด๋‚ด๋Š” ์•Œ๋ฆผ(์›นํ›…)์„ ๋ฐ›์Šต๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๋“ฑ๋กํ•œ ์นด๋“œ ๋ชฉ๋ก๊ณผ ๊ฑฐ๋ž˜ ๋‚ด์—ญ(๊ฐ€๊ณ„๋ถ€/์›์žฅ)์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
  2. ๊ธˆ๊ณ (์•”ํ˜ธํ™”) โ€” 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, amount 3์ข… ์„ธํŠธ๋ฅผ ๋งŒ๋“ค์–ด ์šฐ๋ฆฌ ํ”„๋ก ํŠธ์˜ 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 ๋Š” ์›๋ž˜ ์•„์ด๋””:๋น„๋ฐ€๋ฒˆํ˜ธ ํ˜•์‹์ธ๋ฐ, ํ† ์Šค๋Š” ์‹œํฌ๋ฆฟํ‚ค๋ฅผ ์•„์ด๋”” ์ž๋ฆฌ์— ๋„ฃ๊ณ  ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋น„์›€. ๊ทธ๋ž˜์„œ ์ฝœ๋ก  ๋’ค๊ฐ€ ๋น„์–ด ์žˆ์Œ.
  • body 3์ข… ์„ธํŠธ = ํ”„๋ก ํŠธ๊ฐ€ ๋ฐ›์€ ๊ทธ๋Œ€๋กœ์˜ 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์ž ์ดํ•˜๋ฉด ํ†ต์งธ๋กœ ๊ฐ€๋ฆผ. (์ €์žฅ์ด ์•„๋‹ˆ๋ผ ํ‘œ์‹œ ์šฉ๋„.)

๐Ÿ’ก CryptoService vs AesGcmCryptoService ์ฐจ์ด(๋‘˜ ๋‹ค 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).

โš ๏ธ ํ•จ์ •ยท๋ณด์•ˆ ์ฃผ์˜

  1. APP_CRYPTO_KEY ๊ธฐ๋ณธ๊ฐ’ ์—†์Œ โ†’ ๋ฏธ์„ค์ • ์‹œ ๋ถ€ํŒ… ์‹คํŒจ. CryptoService ์ƒ์„ฑ์ž๊ฐ€ @Value("${app.crypto.key}")(์ฝœ๋ก  ์—†์Œ)์ด๋ผ ํ‚ค๊ฐ€ ์—†์œผ๋ฉด ์•ฑ์ด ์‹œ์ž‘ ๋ชป ํ•จ. ๋กœ์ปฌ์—์„œ๋„ ๋ฐ˜๋“œ์‹œ Base64 32๋ฐ”์ดํŠธ ํ‚ค๋ฅผ ์„ค์ •. ์ด๊ฑด ๋ฒ„๊ทธ๊ฐ€ ์•„๋‹ˆ๋ผ ์˜๋„๋œ fail-fast ์•ˆ์ „์žฅ์น˜.

  2. app.crypto.key ์ค‘๋ณต์ •์˜ = ๋ณตํ˜ธํ™” ๋Œ€์ฐธ์‚ฌ. ๋ฉ”๋ชจ๋ฆฌ "Env priority changes need duplicate-key audit" ํ•ต์‹ฌ: ๊ฐ™์€ ํ‚ค๊ฐ€ .env/application*.properties ์— ๋‹ค๋ฅธ ๊ฐ’์œผ๋กœ ์ค‘๋ณต๋˜๋ฉด, A ๊ฐ’์œผ๋กœ ์•”ํ˜ธํ™”ํ•œ KIS ์‹œํฌ๋ฆฟ์„ B ๊ฐ’์œผ๋กœ ๋ณตํ˜ธํ™” ์‹œ๋„ โ†’ AEADBadTagException("key mismatch") โ†’ ์˜์˜ ๋ชป ์ฝ์Œ. ์šฐ์„ ์ˆœ์œ„ ๋ณ€๊ฒฝ ์ „ ๋ฐ˜๋“œ์‹œ ์ค‘๋ณต ๊ฐ์‚ฌ. ์‹œ์ž‘ ๋กœ๊ทธ์˜ sha256[0..8] ์ง€๋ฌธ์œผ๋กœ ์„œ๋ฒ„ ๊ฐ„ ํ‚ค ์ผ์น˜ ํ™•์ธ ๊ฐ€๋Šฅ.

  3. ๊ฒฐ์ œ ๋ฉฑ๋“ฑ์„ฑ(M8)์€ 2์ค‘์ด์–ด์•ผ ํ•œ๋‹ค. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ ์กฐํšŒ(findByPaymentKey)๋งŒ์œผ๋กœ๋Š” ๋™์‹œ ์š”์ฒญ ๊ฒฝํ•ฉ์„ ๋ชป ๋ง‰์Œ. DB ์œ ๋‹ˆํฌ ํ‚ค(uq_subscription_toss_payment_key)๊ฐ€ ์ตœ์ข… ๋ฐฉ์–ด์„ . ๋‘˜ ์ค‘ ํ•˜๋‚˜๋งŒ ์žˆ์œผ๋ฉด ๋”๋ธ” ๊ฒฐ์ œ ์œ„ํ—˜.

  4. ํด๋ผ์ด์–ธํŠธ ํ‚ค โ†” ์‹œํฌ๋ฆฟ ํ‚ค ์Œ ์ผ์น˜. ํ”„๋ก ํŠธ์˜ test_ck_... ์™€ ์„œ๋ฒ„์˜ test_sk_... ๋Š” ๊ฐ™์€ ํ† ์Šค ๊ณ„์ •์ด์–ด์•ผ ํ•จ. ๋‹ค๋ฅธ ๊ณ„์ • ํ‚ค๋ฅผ ์„ž์œผ๋ฉด confirm ์ด ํ† ์Šค์—์„œ ๊ฑฐ๋ถ€๋จ(CLAUDE.md ์ฃผ์˜).

  5. ์›นํ›… ์„œ๋ช… ๊ฒ€์ฆ์€ ์šด์˜ ํ•„์ˆ˜. tosspayments.webhook-secret ์ด ๋น„๋ฉด TossWebhookController ๊ฐ€ ๊ฒ€์ฆ์„ skip ํ•จ โ†’ ์šด์˜์—์„œ ๋น„์›Œ๋‘๋ฉด ๋ˆ„๊ตฌ๋‚˜ ์œ„์กฐ ์›นํ›…์œผ๋กœ ์—์Šคํฌ๋กœ๋ฅผ REFUNDED ๋กœ ์กฐ์ž‘ ๊ฐ€๋Šฅ. ๋ฐ˜๋“œ์‹œ ์šด์˜์—์„œ ์‹œํฌ๋ฆฟ ์ฃผ์ž…. ๋˜ํ•œ ์„œ๋ช… ๊ฒ€์ฆ์€ ํŒŒ์‹ฑ ์ „ raw body๋กœ ํ•ด์•ผ ์ผ์น˜(๊ฐ์ฒด๋กœ ๋ฐ›์œผ๋ฉด ๊นจ์ง).

  6. ๊ธˆ์•ก ๊ฒ€์ฆ์˜ ์ตœ์ข… ์ฑ…์ž„์€ ์„œ๋ฒ„. ํ”„๋ก ํŠธ๊ฐ€ ๋ณด๋‚ธ amount ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ฏฟ์ง€ ๋ง๊ณ  VALID_PLANS ๋กœ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ๊ฒ€์ฆ ํ›„ ํ† ์Šค์— ์ „๋‹ฌ. ํ† ์Šค๋„ ๋ฐœ๊ธ‰ ๊ธˆ์•ก๊ณผ ๋Œ€์กฐํ•˜์ง€๋งŒ, ์šฐ๋ฆฌ ์ธก 1์ฐจ ๋ฐฉ์–ด๊ฐ€ ์šฐ์„ .

  7. ์‹œํฌ๋ฆฟ ํ‚คยท๋ณตํ˜ธํ™” ํ‰๋ฌธ์„ ๋กœ๊ทธ์— ์ฐ์ง€ ๋ง ๊ฒƒ. CryptoService ๊ฐ€ ํ‚ค๋ฅผ ์ง€๋ฌธ(ํ•ด์‹œ ์•ž 4๋ฐ”์ดํŠธ)์œผ๋กœ๋งŒ ๋กœ๊น…ํ•˜๋Š” ์ด์œ . confirm ๋กœ๊ทธ๋„ status/method ๋งŒ ๋‚จ๊ธฐ๊ณ  ์นด๋“œ์ •๋ณด๋Š” ์•ˆ ๋‚จ๊น€.

  8. CVCยท์ „์ฒด ์นด๋“œ๋ฒˆํ˜ธ ์ €์žฅ ๊ธˆ์ง€(PCI). PaymentMethodService ๋Š” ํ˜•์‹๋งŒ ๊ฒ€์‚ฌํ•˜๊ณ  last4 ๋งŒ ๋ณด๊ด€. ์ƒˆ ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€ํ•  ๋•Œ ์‹ค์ˆ˜๋กœ ์ „์ฒด๋ฒˆํ˜ธ๋ฅผ ์ €์žฅํ•˜์ง€ ์•Š๋„๋ก ์ฃผ์˜.

  9. Jackson3(Boot4) JsonNode ํ•จ์ •. TossPaymentsService ๊ฐ€ ์‘๋‹ต์„ String ์œผ๋กœ ๋ฐ›์•„ ์šฐ๋ฆฌ ObjectMapper ๋กœ ํŒŒ์‹ฑํ•˜๋Š” ๊ฑด ๋ฉ”๋ชจ๋ฆฌ "Spring Boot 4 Jackson JsonNode ๋ฒ„๊ทธ" ์šฐํšŒ. JsonNode.class ๋กœ ์ง์ ‘ ๋ฐ›์œผ๋ฉด ๊นจ์งˆ ์ˆ˜ ์žˆ์Œ.


๐Ÿš€ ๊ณ ๋„ํ™” ์•„์ด๋””์–ด

  • ๊ฒฐ์ œ ์˜ˆ์™ธ ์„ธ๋ถ„ํ™”: ์ง€๊ธˆ์€ ๋ชจ๋‘ RuntimeException. TossDeclinedException(ํ† ์Šค ๊ฑฐ์ ˆยท์žฌ์‹œ๋„ ๋ฌด์˜๋ฏธ) vs TossUnavailableException(๋„คํŠธ์›Œํฌยท์ผ์‹œ์žฅ์• ยท์žฌ์‹œ๋„ ๊ฐ€์น˜)๋กœ ๋‚˜๋ˆ  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.Tier enum ์€ 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,...})์„ Jackson ObjectMapper๋กœ ์ฝ์–ด 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: "์„œ๋ฒ„๊ฐ€ ์ž ๊น ๋ง›์ด ๊ฐ”๋‹ค". ์žฌ์‹œ๋„ ๋Œ€์ƒ.
  • 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:30 DailySignalGenerator๋Š” ์ด๊ฑธ ํ˜ธ์ถœํ•˜์ง€ ์•Š๊ณ  ์‚ฌ๋‚ด 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์—(์—”ํ‹ฐํ‹ฐ๊ฐ€ qty NOT 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):
    1. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‚ฌ์ „์ฒดํฌ(findByPaymentKey != null) โ€” ํ”ํ•œ ๋”๋ธ”ํด๋ฆญ/์ƒˆ๋กœ๊ณ ์นจ.
    2. ์„œ๋น„์Šค ๋ ˆ์ด์–ด ์žฌํ™•์ธ(activatePro ์•ˆ์˜ dup ์ฒดํฌ).
    3. 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 2 JsonNode๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋ฉด ๋ชจ๋“  ํ‚ค๊ฐ€ ๋นˆ bean ์†์„ฑ์œผ๋กœ ๊นจ์ ธ ์ง๋ ฌํ™”๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. analytics ์‘๋‹ต์ด ํ”„๋ก ํŠธ์—์„œ {}์ฒ˜๋Ÿผ ๋น„์–ด ๋ณด์ด๋ฉด ์ด ์›์ธ์„ ์˜์‹ฌํ•˜์„ธ์š”(ํ•ด๊ฒฐ: ์‘๋‹ต์„ ๋ฌธ์ž์—ด/Map๋กœ ๋ณ€ํ™˜ํ•˜๊ฑฐ๋‚˜ Jackson 3 JsonNode ์‚ฌ์šฉ).

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)๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  ์ง„ํ–‰๋ฅ ์„ ํด๋ง(Lean backtest/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๊ฐ€ ์•„๋‹ˆ๋ผ JDK HttpURLConnection์œผ๋กœ ๋‚ด๋ ค๊ฐ€ ๋ธŒ๋ผ์šฐ์ € 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๊ฐ€์ง€๋ฅผ ๊ฒ€์‚ฌ:
    1. REAL ์ž์ฒด /test ํ†ต๊ณผ(lastVerifiedAt != null)
    2. ๊ฐ™์€ ๋ธŒ๋กœ์ปค์˜ MOCK ๊ณ„์ • ์กด์žฌ โ€” findByUserIdAndBrokerTypeAndEnv๋กœ ์กฐํšŒ(PromotionGateService.java:69; ์ฃผ์„์— *"findByUserIdAndEnv ๋Š” ๋‹ค์ค‘๋ธŒ๋กœ์ปค ์‹œ NonUnique 500"* โ€” ๊ณผ๊ฑฐ ๋‹ค์ค‘ ๋ธŒ๋กœ์ปค์—์„œ ํ„ฐ์ง€๋˜ ๋ฒ„๊ทธ๋ฅผ ๊ณ ์นœ ํ”์ )
    3. MOCK ๋“ฑ๋ก ํ›„ 14์ผ ์ด์ƒ(MIN_MOCK_DAYS)
    4. MOCK EXECUTED 5๊ฑด ์ด์ƒ(MIN_MOCK_EXECUTED)
    5. MOCK ์‹คํŒจ์œจ < 30%(MAX_MOCK_FAIL_RATIO)
  • ํ•œ ํ•ญ๋ชฉ์ด๋ผ๋„ ์‹คํŒจํ•˜๋ฉด ๋ฌด์—‡์ด ๋ถ€์กฑํ•œ์ง€ ์ •ํ™•ํžˆ ๋ฐ˜ํ™˜(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์™€ ๋ณ„๊ฐœ)

โš ๏ธ ํ•จ์ •ยท๋ณด์•ˆ ์ฃผ์˜

  1. KIS ๊ธฐ๋ณธ Java UA ์ฐจ๋‹จ โ€” Spring RestClient์˜ Java-http-client/... UA๋ฅผ KIS GW๊ฐ€ 403 + EGW00002๋กœ ๊ฑฐ๋ถ€. ํ† ํฐ/์Šน์ธํ‚ค ๋ฐœ๊ธ‰์€ HttpURLConnection + ๋ธŒ๋ผ์šฐ์ € UA๋กœ ์šฐํšŒ(KisApiClient.java:222-226, 642). ์ด ํด๋ž˜์Šค๋ฅผ ์†๋Œˆ ๋•Œ UA๋ฅผ ์ง€์šฐ๋ฉด ์ธ์ฆ์ด ํ†ต์งธ๋กœ ๊นจ์ง‘๋‹ˆ๋‹ค. (๋ฉ”๋ชจ๋ฆฌ ๊ตํ›ˆ๊ณผ ์ผ์น˜.)

  2. KIS chunked ์ „์†ก = EGW00202 โ€” Map์„ ๊ทธ๋Œ€๋กœ ๋ณด๋‚ด๋ฉด chunked๋กœ ๋‚˜๊ฐ€ GW๊ฐ€ ๊ฑฐ๋ถ€. ๋ณธ๋ฌธ์„ byte[]๋กœ ์ง๋ ฌํ™”ํ•ด Content-Length ๋ช…์‹œ(jsonBytes). ์ฃผ๋ฌธ ๋ณธ๋ฌธ๋„ ๋™์ผ(KisApiClient.java:515-517).

  3. Binance env=REAL โ†’ ๋ฉ”์ธ๋„ท / FUTURES๋Š” ํ•ญ์ƒ ์‹ค๊ฑฐ๋ž˜ โ€” spotHost๋Š” MOCK์ด๋ฉด ํ…Œ์ŠคํŠธ๋„ท์ด์ง€๋งŒ futuresHost๋Š” MOCK์ด์–ด๋„ fapi.binance.com(์‹ค๊ฑฐ๋ž˜). ๊ทธ๋ž˜์„œ BinanceBrokerAdapter๊ฐ€ FUTURES ์ฃผ๋ฌธ์„ ํ†ต์งธ๋กœ ์ฐจ๋‹จ(BinanceBrokerAdapter.java:38-41). ์ด ์ฐจ๋‹จ์„ ํ’€๋ฉด ๋ชจ์˜๊ฐ€ ์ง„์งœ ๋ˆ์„ ์”๋‹ˆ๋‹ค.

  4. ๋‘ ๋ฒˆ์งธ ์ฃผ๋ฌธ ๊ฒฝ๋กœ ์šฐํšŒ ๋ฐฉ์ง€ โ€” ์ง์ ‘ ์ฃผ๋ฌธ(BrokerOrderController.place)์ด ์ œ์•ˆ ์Šน์ธ ๊ฒฝ๋กœ์™€ ๋‹ค๋ฅธ ํ•œ๋„ ์ •์ฑ…์„ ์“ฐ๋ฉด ์šฐํšŒ ๊ตฌ๋ฉ์ด ์ƒ๊น๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ KRW ํ•œ๋„ ๊ฒ€์‚ฌ๋ฅผ ProposalExecutionService.krwDailyLimitViolation public static ๋ฉ”์„œ๋“œ๋กœ ๊ณต์œ ํ•ด ์–‘ ๊ฒฝ๋กœ๊ฐ€ ๊ฐ™์€ ๊ทœ์น™์„ ์”๋‹ˆ๋‹ค(BrokerOrderController.java:198). REAL ํ•œ๋„ ์บก๋„ ๋“ฑ๋กยท์ˆ˜์ • ์–‘ ๊ฒฝ๋กœ์—์„œ ๋™์ผ ์ ์šฉ(BrokerAccountController.java:107, 260).

  5. ์‹œ์žฅ๊ฐ€ ํ•œ๋„ ์šฐํšŒ ๋ฐฉ์ง€ โ€” ์‹œ์žฅ๊ฐ€๋Š” ๊ธˆ์•ก์„ ๋ชจ๋ฅด๋ฏ€๋กœ ํ˜„์žฌ๊ฐ€๋ฅผ ์กฐํšŒํ•ด ์ถ”์ •(estimateUsd). ์•ˆ ํ•˜๋ฉด "์ง€์ •๊ฐ€๋Š” ๋ง‰ํžˆ๊ณ  ์‹œ์žฅ๊ฐ€๋Š” ๋ฌด์ œํ•œ" ๊ตฌ๋ฉ.

  6. 0์› ์ง€์ •๊ฐ€ ์‚ฌ๊ณ  โ€” KIS ๋ฏธ๊ตญ์ฃผ์‹์€ ์ง€์ •๊ฐ€๋งŒ ์“ฐ๋ฏ€๋กœ ๋‹จ๊ฐ€ 0 ์ „์†ก์€ ๊ฑฐ๋ถ€๋ฉ๋‹ˆ๋‹ค. ์‹œ์žฅ๊ฐ€ ์˜๋„๋ฉด ํ˜„์žฌ๊ฐ€๋ฅผ ์ง€์ •๊ฐ€๋กœ ๋ณ€ํ™˜ํ•˜๊ณ , ์กฐํšŒ ์‹คํŒจ ์‹œ 0์› ๋Œ€์‹  ๋ช…ํ™•ํ•œ ์‹คํŒจ ๋ฐ˜ํ™˜(KisBrokerAdapter.java:39-49).

  7. ํ‚ค ํ‰๋ฌธ ์ €์žฅ ๊ธˆ์ง€ โ€” appsecret/apiSecret์€ ๋“ฑ๋ก ์ฆ‰์‹œ crypto.encrypt ํ›„ *_enc ์ปฌ๋Ÿผ์— ์ €์žฅ. ๋กœ๊ทธ์—๋„ ๋งˆ์Šคํ‚น(previewWithSecretMasked, KisApiClient.java:155-162).

  8. MOCKโ†’REAL ์กธ์—… ๊ฒŒ์ดํŠธ โ€” ์ˆ˜๋™(PromotionGateService: 14์ผ+5๊ฑด+์‹คํŒจ์œจ<30%)๊ณผ ์ž๋™(setAutoExecute: 14์ผ+20ํšŒ)์ด ๋‹ค๋ฅธ ๊ธฐ์ค€. ํ—ท๊ฐˆ๋ฆฌ์ง€ ๋ง ๊ฒƒ.

  9. EXECUTED โ‰  FILLED โ€” ์ฃผ๋ฌธ ์ˆ˜๋ฝ๊ณผ ์‹ค์ œ ์ฒด๊ฒฐ์€ ๋‹ค๋ฆ„. ์ฒด๊ฒฐ์€ OrderFillService/ํด๋ง์žก์œผ๋กœ ๋ณ„๋„ ํ™•์ธ.

  10. 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๊ฐ€ ๋…ธ์ถœ์กฐ์ฐจ ์•ˆ ๋จ โ†’ ํƒˆ์ทจ ํ‘œ๋ฉด ์ถ•์†Œ.
  • .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๋‹จ:
    1. ์ฟ ํ‚ค์— refresh๊ฐ€ ์žˆ๋‚˜ โ†’ ์—†์œผ๋ฉด 401.
    2. DB์— ๊ทธ ํ† ํฐ์ด ์กด์žฌํ•˜๊ณ  ์•ˆ ๋งŒ๋ฃŒ๋๋‚˜(findByToken + isExpired) โ†’ ๋งŒ๋ฃŒ๋ฉด DB์—์„œ ์‚ญ์ œํ•˜๊ณ  401(์“ฐ๋ ˆ๊ธฐ ์ฒญ์†Œ).
    3. ๊ทธ ํ† ํฐ์˜ ์ฃผ์ธ 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ํ™‰):

  1. code โ†’ access_token ๊ตํ™˜(L269-290): ํ”„๋ก ํŠธ๊ฐ€ ๋ฐ›์€ code๋ฅผ ์šฐ๋ฆฌ client_id/secret๊ณผ ํ•จ๊ป˜ GitHub์— ๋ณด๋‚ด access_token์œผ๋กœ ๊ตํ™˜. (secret์€ ์„œ๋ฒ„๋งŒ ๋ณด๊ด€ โ†’ ํ”„๋ก ํŠธ๊ฐ€ ํ‰๋‚ด ๋ชป ๋ƒ„.)
  2. ์ธ์ฆ๋œ primary ์ด๋ฉ”์ผ ์กฐํšŒ(L298-321): /user/emails์—์„œ primary && verified์ธ ์ด๋ฉ”์ผ ์šฐ์„ , ์—†์œผ๋ฉด verified์ธ ๊ฒƒ โ€” ์ธ์ฆ๋œ ์ด๋ฉ”์ผ๋งŒ ์ฑ„ํƒ.
  3. ํ”„๋กœํ•„ ์กฐํšŒ(L323-330): /user์—์„œ login(=๊นƒํ—ˆ๋ธŒ username) ํ™•๋ณด.
  4. ๊ณ„์ • ์กฐํšŒ ๋˜๋Š” ์ž๋™ ์ƒ์„ฑ(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 ํ•ด์‹ฑํ•ด ์ฑ„์›€. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด โ‘  password NOT 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๊ฐœ = DB USERS ํ…Œ์ด๋ธ”, ์ธ์Šคํ„ด์Šค 1๊ฐœ = ํ•œ ํ–‰.
  • @Id @GeneratedValue(IDENTITY) = PK id๋ฅผ 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-username GET/PATCH๋Š” AuthContext๋กœ ๋ณธ์ธ๋งŒ ์ž๊ธฐ GitHub username ์กฐํšŒยท์ˆ˜์ •.
  • ProfileController/ProfileService(/api/profile): ํ”„๋กœํ•„ ์ƒ์„ธ ์ €์žฅ(PUT /me/detail)ยท์กฐํšŒ(GET /me/detail, public GET /{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
'๐Ÿ‘ฉ‍๐Ÿ’ปDeveloper ๐Ÿ’ก' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€
  • ํ‚ค-๊ฐ’ ์ €์žฅ์†Œ ์„ค๊ณ„
  • AWS ๋ฐฐํฌ A-Z ๋ฐฐํฌ ์—ฌ์ • — ์ฒ˜์Œ ๋ฐฐ์šฐ๋Š” ์‚ฌ๋žŒ์„ ์œ„ํ•œ ์ „์ฒด ์ •๋ฆฌ
  • ํ”„๋กœ์ ํŠธ2 ์žฌ๋Šฅ ๋ฐ ํ”„๋ฆฌ๋žœ์„œ ๋งค์นญ ํ”Œ๋žซํผ ๊ธฐํš๋ถ€ํ„ฐ ์„ค๊ณ„ ๋ฐ ๊ฐœ๋ฐœ
  • ํ˜„์‹ค์ ์œผ๋กœ ์ด์ง ๊ฐ€๋Šฅํ•ด.
fulfilling_enjoyable yeona๐Ÿถ๐Ÿฆซ
fulfilling_enjoyable yeona๐Ÿถ๐Ÿฆซ
Quantitative Research Engineer & Quantitative Strategist | Multimodal Alpha (Price/News/On-chain) | Regime-aware, Cost-included Backtests | Remote-first ์—ฐ๋ฆฌ์˜ ๋‚œ ๋จธ๋‹ˆ๐Ÿ’ฐ๊ฐ€ ์ข‹์•„๐Ÿ’™๐Ÿฅณ ์ถฉ๋งŒํ•˜๊ฒŒ ๊ทธ๋ฆฌ๊ณ  ์ฆ๊ฒ๊ฒŒ ๐Ÿถ ๐Ÿฆซ ๐Ÿ’›
  • Elina Yeon Lee
    Product & AI Engineer
    AboutMe ๋ชฉํ‘œ GitHub _yeonlee___ Elina_Eden
  • ์ „์ฒด
    ์˜ค๋Š˜
    ์–ด์ œ
    • ๐Ÿค ๐Ÿ˜Ž ์•ˆ๋…•โ˜ƒ๏ธ๐Ÿ’ž (125)
      • ๐Ÿ’™ ๐Ÿค Diary๐Ÿฐ ๐ŸŽ€ ๐Ÿงธ (37)
        • ๐Ÿ—ฝ๋ฏธ๊ตญ DS & CS ๋ฐ•์‚ฌ ์ด๋ฏผ๐Ÿ‹ (26)
        • ๐Ÿ›ซ์—ฌํ–‰ ์ด์•ผ๊ธฐ (1)
        • ๐Ÿ“’์˜ค๋Š˜ ์ด์•ผ๊ธฐ๐Ÿ’’๐Ÿซง๐ŸŒค๏ธ (6)
        • ๐Ÿฐโ˜˜๏ธ๐Ÿป‍โ„๏ธ๊ฐ•์•„์ง€ ์œก์•„ (0)
      • ๐ŸฌActuary๐Ÿคธ‍โ™€๏ธโœจ (1)
      • ๐Ÿ‘ฉ‍๐Ÿ’ปDeveloper ๐Ÿ’ก (65)
        • โš’๏ธ์‚ฝ์งˆ ๊ธฐ๋ก๊ธฐ๐Ÿ“[TIL] (6)
        • ๐Ÿ–ผ๏ธFront-end๐ŸŽจ (3)
        • ๐Ÿ’พBack-end๐Ÿ•Š๏ธ (29)
        • ๐Ÿคน‍โ™€๏ธAlgorithm & Coding Test๐Ÿ’ƒ (9)
        • ๐Ÿ—ปData๐Ÿ”๏ธ (1)
        • ๐Ÿ“Project๐Ÿ• (9)
      • ๐Ÿ’ฐ๊ฒฝ์ œ์  ์ž์œ  ๋‹ฌ์„ฑโœŒ๏ธ๐ŸคŸ (9)
        • ๐Ÿ“ŠQuant๐Ÿ“ˆ๐Ÿ‘ (5)
        • ๐Ÿฐ๐Ÿ›’๐Ÿฅ‡ (1)
        • ๐Ÿ’Ž ํˆฌ์ž ์‹ค์ „ ๊ฒฝํ—˜ โ˜บ๏ธ (1)
        • ๐Ÿ… Bitcoin 15๊ฐœ ๋ชจ์œผ๊ธฐ : 2040๋…„ 200์–ต+ (0)
        • ๐ŸŒŽ๋ฏธ๊ตญ ์‹œ์žฅ๐Ÿฆ (0)
      • ๐ŸŒค๏ธCloud๐ŸŒค๏ธโ˜๏ธ (2)
        • AWS (1)
        • Kubernetes (0)
        • Google Cloud Professional (0)
  • ๋ธ”๋กœ๊ทธ ๋ฉ”๋‰ด

    • ํ™ˆ
    • ํƒœ๊ทธ
    • ๋ฐฉ๋ช…๋ก
  • ๋งํฌ

    • git
  • ๊ณต์ง€์‚ฌํ•ญ

  • ์ธ๊ธฐ ๊ธ€

  • ํƒœ๊ทธ

    ๋ฏธ๊ตญ๋น„์ž
    AWS
    ๋ฏธ๊ตญ์œ ํ•™
    Java
    ํ”„๋กœ๊ทธ๋ž˜๋จธ์Šค
    ์‹œ์นด๊ณ ๋Œ€ํ•™๊ต
    ์‹œ์นด๊ณ ๋Œ€
    ์‹œ์นด๊ณ 
    ๊ฐ€์„ํ•™๊ธฐ
    CS
  • ์ตœ๊ทผ ๋Œ“๊ธ€

  • ์ตœ๊ทผ ๊ธ€

  • hELLOยท Designed By์ •์ƒ์šฐ.v4.10.5
fulfilling_enjoyable yeona๐Ÿถ๐Ÿฆซ
ah/backend
์ƒ๋‹จ์œผ๋กœ

ํ‹ฐ์Šคํ† ๋ฆฌํˆด๋ฐ”