์ค๋ฌธ์ ๋ง๋ค๊ณ ์ถ์ ์ฌ๋๊ณผ ์๋ตํ๊ณ ์ฐธ์ฌ ๋ณด์์ ๋ฐ๊ณ ์ถ์ ์ฌ๋์ ์ฐ๊ฒฐํ๋ ์๋น์ค. ํ ์ค ์ฑ ์์์ ๋์ํ๋ ๋ฏธ๋์ฑ(Apps in Toss)์ผ๋ก, ๊ฒฐ์ ยท์ธ์ฆยทํฌ์ธํธ ์ง๊ธ์ด ๋ชจ๋ ํ ์ค ํ๋ซํผ๊ณผ ์ฐ๋๋ฉ๋๋ค.
| Language / Runtime | Java 21, Spring Boot 3.5.6 |
| Persistence | MySQL 8(AWS RDS), Spring Data JPA, QueryDSL 7 |
| Cache / Lock | Redis 7, Redisson(๋ถ์ฐ๋ฝ) |
| Security | Spring Security |
| Infra | Docker, Nginx, NCP Server, NCP Object Storage, AWS Lambda |
| CI/CD | GitHub Actions |
| Observability | Sentry, Discord Webhook, Spring Actuator |
flowchart LR
A["์ค๋ฌธ ์๋ขฐ์"] -->|"์ฝ์ธ ์ถฉ์ (ํ ์ค IAP)"| B["์ค๋ฌธ ์์ฑ<br/>๋ฌธํญยทํ๊ฒํ
ยท๋ชฉํ ์๋ต ์ ์ค์ "]
B --> C["๊ฒ์ ํ ๊ฒ์"]
C --> D["์ฐธ์ฌ์์๊ฒ ๋
ธ์ถ"]
D --> E["์คํฌ๋ฆฌ๋ ํต๊ณผ โ ์๋ต"]
E --> F["๋ชฉํ ์๋ต ์ ๋๋ฌ ์ ์๋ ๋ง๊ฐ"]
E --> G["์ฐธ์ฌ์์๊ฒ ํ๋ก๋ชจ์
์ง๊ธ"]
F --> H["์๋ขฐ์: ๊ฒฐ๊ณผ ์ง๊ณยทCSV ๋ค์ด๋ก๋"]
flowchart TB
subgraph CLIENT["ํด๋ผ์ด์ธํธ"]
TOSS["ํ ์ค ๋ฏธ๋์ฑ WebView"]
BO["๋ฐฑ์คํผ์ค ๋ธ๋ผ์ฐ์ "]
end
subgraph SERVER
NGX["Nginx 1.25<br/>TLS ์ข
๋ฃ ยท ๋ฆฌ๋ฒ์ค ํ๋ก์"]
BLUE["onsurvey-blue :8080"]
GREEN["onsurvey-green :8081"]
REDIS[("Redis 7<br/>๋ถ์ฐ๋ฝ ยท ์นด์ดํฐ")]
end
subgraph DATA["๋ฐ์ดํฐ"]
RDS[("AWS RDS<br/>MySQL 8")]
OBS[("NCP Object Storage<br/>์ด๋ฏธ์ง ยท ๋ณํ JSON")]
end
subgraph EXT["์ธ๋ถ ์ฐ๋"]
TAPI["Toss Partner API (mTLS)<br/>OAuth2 ยท IAP ยท ํ๋ก๋ชจ์
ยท ํธ์"]
LAMBDA["AWS Lambda<br/>๊ตฌ๊ธํผ ํ์ฑ"]
SENTRY["Sentry"]
DISCORD["Discord Webhook"]
end
TOSS --> NGX
BO --> NGX
NGX -->|"active"| BLUE
NGX -.->|"standby"| GREEN
BLUE --> REDIS
BLUE --> RDS
BLUE --> OBS
BLUE --> TAPI
BLUE --> LAMBDA
BLUE --> SENTRY
BLUE --> DISCORD
LAMBDA -->|"ํ์ฑ ๊ฒฐ๊ณผ ์ ์ฌ"| OBS
ํจํค์ง๋ฅผ domain(๋น์ฆ๋์ค) ๊ณผ global(๊ณตํตยท์ธ๋ถ์ฐ๋) ๋ก ๋๋๊ณ ,
์ธ๋ถ ์์คํ
์ ์์กดํ๋ ์์ญ์ ํฌํธ-์ด๋ํฐ ๋ฐฉ์์ผ๋ก ์ธํฐํ์ด์ค์ ๊ตฌํ์ ๋ถ๋ฆฌํ์ต๋๋ค.
OneQ/OnSurvey/
โโโ domain/ # ๋ฐ์ด๋๋ ์ปจํ
์คํธ
โ โโโ survey/ # ์ค๋ฌธ ์์ฑยท๊ฒ์ยท๊ด๋ฆฌยท์ง๊ณยทํ๋ถ
โ โโโ question/ # ๋ฌธํญ 11์ข
๋ฐ ๋ณด๊ธฐยท์น์
โ โโโ participation/ # ์คํฌ๋ฆฌ๋ยท์๋ต ์ ์ฅยท์๋ต ์๋ฃ
โ โโโ member/ # ํ์ยท์ฝ์ธยทํฌ์ธํธ
โ โโโ discount/ # ํ ์ธ ์ฝ๋
โ โโโ admin/ # ๋ฐฑ์คํผ์ค (port/adapter ๊ตฌ์กฐ)
โโโ global/
โโโ auth/ # ์ธ์ฆ ์ ๋ต ยท ํํฐ ยท ํ ํฐ
โโโ payment/ promotion/ push/ # ํ ์ค ์ฐ๋ ์ ์ค์ผ์ด์ค (port/out)
โโโ infra/ # toss ยท redis ยท ncp ยท discord ยท transaction
โโโ common/ # config ยท exception ยท response ยท util
flowchart TB
subgraph L1["Presentation"]
C1["REST Controller"]
C2["Backoffice (Thymeleaf SSR)"]
end
subgraph L2["Security Filter Chain"]
F1["#1 /connect-out โ ๊ณ ์ ํค๋ ๊ฒ์ฆ"]
F2["#2 ๋ฐฑ์คํผ์ค โ ์ธ์
+ RateLimiter"]
F3["#3 ๊ทธ ์ธ โ STATELESS + AuthStrategy"]
end
subgraph L3["Application"]
A1["Facade/UseCase"]
A2["Command/Query ์๋น์ค ๋ถ๋ฆฌ"]
end
subgraph L4["Domain"]
D1["Entity"]
D2["Port(out) ์ธํฐํ์ด์ค"]
end
subgraph L5["Infrastructure"]
I1["JPA + QueryDSL"]
I2["TossApiClient (WebClient / mTLS)"]
I3["RedisAgent (Redisson)"]
I4["NcpS3Client / Discord Notifier"]
end
C1 --> F3 --> A1
C2 --> F2 --> A1
A1 --> A2 --> D1
A1 --> D2
D2 -. ๊ตฌํ .-> I2
D2 -. ๊ตฌํ .-> I1
A2 --> I3
A1 --> I4
์๋
- Command / Query ๋ถ๋ฆฌ โ ์กฐํ๋
@Transactional(readOnly = true)๋ก ์ก๊ณ QueryDSLProjection์ผ๋ก ํ์ํ ์ปฌ๋ผ๋ง ๊ฐ์ ธ์ต๋๋ค. ์ฐ๊ธฐ ๊ฒฝ๋ก๋ ์ ํฉ์ฑ, ์ฝ๊ธฐ ๊ฒฝ๋ก๋ ์๋ต์๋๋ฅผ ์ค์ ์ผ๋ก ๋ชจ๋ธ์ ๋ถ๋ฆฌํ์ต๋๋ค. RedisAgent๋ฅผ ์ธํฐํ์ด์ค(RedisLockAction,RedisCacheAction)๋ก ๋ ธ์ถ์ํต๋๋ค.- ํํฐ ์ฒด์ธ 3๋ถ๋ฆฌ โ ์ฌ์ฉ์์ ์ด์์์ ์ธ์ฆ ๋ฐฉ์์ด ๋ค๋ฆ
๋๋ค. ๊ฐ ์ธ์ฆ ๋ฐฉ์์ ๋ฐ๋ผ
@Order๊ฐ ๋ค๋ฅธSecurityFilterChain์ธ ๊ฐ๋ก ๋ถ๋ฆฌํ์ต๋๋ค.
erDiagram
MEMBER ||--o{ SURVEY : "์์ฑ"
MEMBER ||--o{ RESPONSE : "์ฐธ์ฌ"
MEMBER ||--o{ PAYMENT : "๊ฒฐ์ "
MEMBER ||--o{ PROMOTION_GRANT : "ํ๋ก๋ชจ์
์๋ น"
SURVEY ||--|| SURVEY_INFO : "ํ๊ฒํ
/๋ชฉํ์๋ต์"
SURVEY ||--o{ SECTION : "๊ตฌ์ฑ"
SURVEY ||--o{ QUESTION : "ํฌํจ"
SURVEY ||--o| SCREENING : "์ฌ์ ์ง๋ฌธ"
SURVEY ||--o{ RESPONSE : "์๋ต ์ด๋ ฅ"
QUESTION ||--o{ CHOICE_OPTION : "๊ฐ๊ด์ ๋ณด๊ธฐ"
QUESTION ||--o{ GRID_OPTION : "๊ทธ๋ฆฌ๋ ํ/์ด"
QUESTION ||--o{ QUESTION_ANSWER : "๋ต๋ณ"
SCREENING ||--o{ SCREENING_ANSWER : "๋ต๋ณ"
SURVEY_INFO }o--o| DISCOUNT_CODE : "ํ ์ธ ์ ์ฉ"
MEMBER {
bigint member_id PK
bigint user_key UK "ํ ์ค ์ฌ์ฉ์ ์๋ณ์"
string name
enum gender
enum status "ACTIVE / WITHDRAWN ๋ฑ"
bigint coin "์ถฉ์ ์ฝ์ธ"
bigint promotion_point "์ง๊ธ ํ๋ก๋ชจ์
"
bool onboarding_completed
}
SURVEY {
bigint id PK
bigint member_id FK
text title
datetime deadline
enum status "WRITING/ONGOING/CLOSED/REFUNDED/REVIEW"
bool is_temporary
bool is_free
int total_coin
}
SURVEY_INFO {
bigint info_id PK
bigint survey_id FK
int due_count "๋ชฉํ ์๋ต ์"
int completed_count
enum gender "ํ๊ฒํ
์กฐ๊ฑด"
int promotion_amount
bool refundable
}
QUESTION {
bigint question_id PK
bigint survey_id FK
string type "๋จ์ผ ํ
์ด๋ธ ์์ ํ๋ณ์"
int question_order
int section
bool is_required
}
RESPONSE {
bigint survey_id PK "๋ณตํฉ PK"
bigint member_id PK
bool is_responded
bool is_screened
}
PROMOTION_GRANT {
bigint id PK
bigint user_key "UK(user_key, survey_id, promotion_code)"
bigint survey_id
string promotion_code
enum status "PENDING/SUCCESS/FAILED"
string exec_key "ํ ์ค ์คํ ํค"
bool point_granted "ํฌ์ธํธ ๋ฐ์ ์ฌ๋ถ"
}
PAYMENT {
bigint payment_id PK
string order_id "ํ ์ค ์ฃผ๋ฌธ ๋ฒํธ"
bigint user_key
int total_amount
enum status "PENDING/PAID/REFUNDED"
enum purpose "COIN_CHARGE/HOME_REGISTRATION"
}
- ๋ฌธํญ 11์ข
์ ๋จ์ผ ํ
์ด๋ธ ์์(
SINGLE_TABLE)์ผ๋ก โ ๊ฐ๊ด์, ํ์ , NPS, ๊ทธ๋ฆฌ๋, ๋ ์ง, ์๊ฐ ๋ฑ ํ์ ๋ง๋ค ํ์ํ ์์ฑ์ด ๋ค๋ฆ ๋๋ค. ์กฐ์ธ ์ ๋ต์ ์ฐ๋ฉด ๋ฌธํญ ์กฐํ๋ง๋ค ์กฐ์ธ์ด ๋ถ๋๋ฐ, ์ค๋ฌธ ์ฐธ์ฌ ์ ๋ค๊ฑด์ ๋ฌธํญ ์กฐํ๊ฐ ๋ฐ์ํ๋ฏ๋ก ์กฐ์ธ ๋น์ฉ์ ์ต์ํํ๊ณ ์ ํ์ต๋๋ค. ์ด์ ๋ํ ํธ๋ ์ด๋ ์คํ๋ก ํ์ ํ์ ์ ์ปฌ๋ผ์ด nullable์ด ๋๋ ๊ฒ์ ๊ฐ์ํ์ต๋๋ค. RESPONSE์ ๋ณตํฉ PK(survey_id, member_id)โ ํ ์ฌ๋์ ํ ์ค๋ฌธ์ ํ ๋ฒ๋ง ์๋ตํ๋๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ ๋ฒจ์์ ๋ณด์ฅํฉ๋๋ค.PROMOTION_GRANT์ ๋ณตํฉ ์ ๋ํฌ ์ ์ฝ โ ํฌ์ธํธ ์ค๋ณต ์ง๊ธ์ ๋ง๋๋ก ์ ๋ํฌ ์ ์ฝ์ ์ถ๊ฐํ์ต๋๋ค.SURVEY์SURVEY_INFO๋ถ๋ฆฌ โ ์ค๋ฌธ ๋ณธ๋ฌธ(์ ๋ชฉ, ๋ฌธํญ)์ ์์ฑ ์ค ์์ฃผ ๋ณ๊ฒฝ๋๊ณ ํ๊ฒํ , ๊ฐ๊ฒฉ ์ ๋ณด ๋ฑ์ ๊ฒฐ์ ๋ฐ ๊ฒ์ ์์ ์ ํ์ ๋ฉ๋๋ค. ๋ณ๊ฒฝ ์ฃผ๊ธฐ๊ฐ ๋ค๋ฅธ ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฆฌํด ์ ๊ธ ๊ฒฝํฉ์ ์ค์์ต๋๋ค.