diff --git a/design/backend/sequence/inner/ai-트렌드분석및추천.puml b/design/backend/sequence/inner/ai-트렌드분석및추천.puml index e8818c9..4cd369a 100644 --- a/design/backend/sequence/inner/ai-트렌드분석및추천.puml +++ b/design/backend/sequence/inner/ai-트렌드분석및추천.puml @@ -14,7 +14,6 @@ participant "CircuitBreakerManager" as CB <> participant "ExternalAIClient" as AIClient <> participant "JobStateManager" as JobState <> participant "Redis" as Redis <> -participant "Event DB" as EventDB <> participant "External AI API" as ExternalAPI <> participant "Kafka Producer" as Producer <> @@ -66,24 +65,17 @@ else 유효한 메시지 end note else 캐시 미스 - TrendEngine -> EventDB: 과거 이벤트 데이터 조회\n(업종과 지역으로 필터링,\n최근 3개월 이벤트,\nROI 내림차순 정렬) - EventDB --> TrendEngine: 이벤트 통계 데이터\n{성공 이벤트 리스트, ROI 정보} + note right of TrendEngine + **트렌드 분석 입력 데이터** + - 업종 정보 + - 지역 정보 + - 현재 시즌 (계절, 월) + - 이벤트 목적 - TrendEngine -> TrendEngine: 트렌드 패턴 분석 - note right - 분석 항목: - 1. 업종 트렌드 - - 최근 3개월 성공 이벤트 유형 - - 고객 선호 경품 Top 5 - - 효과적인 참여 방법 - - 2. 지역 특성 - - 해당 지역 이벤트 성공률 - - 지역 고객 연령대/성별 분포 - - 3. 시즌 특성 - - 계절별 추천 이벤트 - - 특별 시즌 (명절, 기념일) + **외부 AI API 호출** + - 과거 이벤트 데이터 사용 안 함 + - 실시간 시장 트렌드 분석 + - 업종별/지역별 일반적 특성 end note TrendEngine -> CB: executeWithCircuitBreaker(\nAI API 트렌드 분석 호출) @@ -91,35 +83,44 @@ else 유효한 메시지 CB -> CB: Circuit Breaker 상태 확인 note right - Circuit Breaker 설정: + **Circuit Breaker 설정** - Failure Rate Threshold: 50% - - Timeout: 30초 - - Half-Open Wait Duration: 30초 + - Timeout: 5분 (300초) + - Half-Open Wait Duration: 1분 (60초) - Permitted Calls in Half-Open: 3 + - Sliding Window Size: 10 end note alt Circuit CLOSED (정상) - CB -> AIClient: callAIAPI(\nmethod: "trendAnalysis",\nprompt: 트렌드 분석 프롬프트,\ntimeout: 30초) + CB -> AIClient: callAIAPI(\nmethod: "trendAnalysis",\nprompt: 트렌드 분석 프롬프트,\ntimeout: 5분) activate AIClient AIClient -> AIClient: 프롬프트 구성 - note right - 프롬프트 예시: + note right of AIClient + **AI 프롬프트 구성** "당신은 마케팅 트렌드 분석 전문가입니다. - 업종: {업종} - 지역: {지역} - 과거 데이터: {이벤트 통계} - 다음을 분석하세요: - 1. 업종 트렌드 (성공 이벤트 유형) - 2. 지역 특성 (고객 특성) - 3. 시즌 특성 (현재 시기 추천)" + **입력 정보** + - 업종: {업종} + - 지역: {지역} + - 현재 시즌: {계절/월} + - 이벤트 목적: {목적} + + **분석 요청사항** + 1. 업종별 일반적 트렌드 + (업종 특성 기반 효과적인 이벤트 유형) + + 2. 지역별 특성 + (지역 고객 특성, 선호도) + + 3. 시즌별 추천 + (현재 시기에 적합한 이벤트)" end note - AIClient -> ExternalAPI: POST /api/v1/analyze\nAuthorization: Bearer {API_KEY}\nTimeout: 30초 + AIClient -> ExternalAPI: AI API 호출\nPOST /api/v1/analyze\nAuthorization: Bearer {API_KEY}\nTimeout: 5분\nPayload: {업종, 지역, 시즌, 목적} activate ExternalAPI - ExternalAPI --> AIClient: 200 OK\n{트렌드 분석 결과} + ExternalAPI --> AIClient: 200 OK\n{"industryTrend": "...",\n"regionalCharacteristics": "...",\n"seasonalRecommendation": "..."} deactivate ExternalAPI AIClient -> AIClient: 응답 검증 및 파싱 @@ -153,9 +154,15 @@ else 유효한 메시지 CB -> CB: 연속 성공 시 CLOSED로 전환 CB --> TrendEngine: 트렌드 분석 결과 - else Timeout (30초 초과) + else Timeout (5분 초과) CB --> TrendEngine: TimeoutException - TrendEngine -> TrendEngine: Fallback 실행 + note right of TrendEngine + **Timeout 처리** + - 5분 초과 시 즉시 실패 + - Fallback: 기본 트렌드 사용 + - 사용자에게 안내 메시지 제공 + end note + TrendEngine -> TrendEngine: Fallback 실행\n(기본 트렌드 템플릿 사용) end end @@ -178,7 +185,7 @@ else 유효한 메시지 group parallel RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 1: 저비용) activate CB - CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 저비용 추천 프롬프트,\ntimeout: 10초) + CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 저비용 추천 프롬프트,\ntimeout: 5분) activate AIClient AIClient -> AIClient: 프롬프트 구성 @@ -207,7 +214,7 @@ else 유효한 메시지 RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 2: 중비용) activate CB - CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 중비용 추천 프롬프트,\ntimeout: 10초) + CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 중비용 추천 프롬프트,\ntimeout: 5분) activate AIClient AIClient -> AIClient: 프롬프트 구성 @@ -236,7 +243,7 @@ else 유효한 메시지 RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 3: 고비용) activate CB - CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 고비용 추천 프롬프트,\ntimeout: 10초) + CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 고비용 추천 프롬프트,\ntimeout: 5분) activate AIClient AIClient -> AIClient: 프롬프트 구성 @@ -299,23 +306,38 @@ end == 예외 처리 == note over Handler, Producer -1. AI API 장애 시: - - Circuit Breaker Open - - Fallback: 기본 트렌드 데이터 사용 - - Job 상태: COMPLETED (안내 메시지 포함) +**AI API 장애 시** +- Circuit Breaker Open +- Fallback: 기본 트렌드 템플릿 사용 +- Job 상태: COMPLETED (안내 메시지 포함) +- 사용자에게 "AI 분석이 제한적으로 제공됩니다" 안내 -2. Timeout (30초 초과): - - Circuit Breaker로 즉시 실패 - - Retry 없음 (비동기 Job) - - Job 상태: FAILED +**Timeout (5분 초과)** +- Circuit Breaker로 즉시 실패 +- Retry 없음 (비동기 Job) +- Job 상태: FAILED +- 사용자에게 재시도 요청 안내 -3. Kafka 메시지 처리 실패: - - DLQ로 이동 - - 수동 검토 및 재처리 +**Kafka 메시지 처리 실패** +- DLQ(Dead Letter Queue)로 이동 +- 수동 검토 및 재처리 +- 에러 로그 기록 -4. Redis 장애: - - 캐싱 스킵, DB만 사용 - - Job 상태는 메모리에 임시 저장 +**Redis 장애** +- 캐싱 스킵 +- Job 상태는 메모리에 임시 저장 +- 성능 저하 가능 (매 요청마다 AI API 호출) + +**성능 목표** +- 평균 응답 시간: 2분 이내 +- P95 응답 시간: 4분 이내 +- Circuit Breaker Timeout: 5분 +- Redis 캐시 TTL: 24시간 + +**데이터 처리 원칙** +- 과거 이벤트 데이터 사용 안 함 +- 외부 AI API로 실시간 트렌드 분석 +- 업종/지역 기반 일반적 마케팅 트렌드 활용 end note @enduml diff --git a/design/backend/sequence/inner/analytics-대시보드조회.puml b/design/backend/sequence/inner/analytics-대시보드조회.puml index 6df4b82..bdfd31e 100644 --- a/design/backend/sequence/inner/analytics-대시보드조회.puml +++ b/design/backend/sequence/inner/analytics-대시보드조회.puml @@ -34,7 +34,7 @@ note right of Cache - Redis GET 호출 - Cache Key 구조: analytics:dashboard:{eventId} - - TTL: 300초 (5분) + - TTL: 3600초 (1시간) end note Cache -> Redis: GET analytics:dashboard:{eventId} @@ -265,23 +265,24 @@ else Cache MISS ||| == 4. Redis 캐싱 및 응답 == - Service -> Cache: set(\n "analytics:dashboard:{eventId}",\n dashboardData,\n TTL=300\n) + Service -> Cache: set(\n "analytics:dashboard:{eventId}",\n dashboardData,\n TTL=3600\n) activate Cache - Cache -> Redis: SET analytics:dashboard:{eventId}\nvalue={통합 데이터}\nEX 300 + Cache -> Redis: 캐시 저장\nSET analytics:dashboard:{eventId}\nvalue={통합 데이터}\nEX 3600 (1시간) activate Redis - Redis --> Cache: OK + Redis --> Cache: OK (저장 완료) deactivate Redis - Cache --> Service: OK + Cache --> Service: OK (캐싱 완료) deactivate Cache note right of Service **캐싱 완료** - - TTL: 300초 (5분) + - TTL: 3600초 (1시간) - 다음 조회 시 Cache HIT - 예상 크기: 5KB + - 갱신 주기: 1시간마다 새 데이터 조회 end note Service --> Controller: DashboardResponse\n(200 OK) diff --git a/design/backend/sequence/inner/event-목적선택.puml b/design/backend/sequence/inner/event-목적선택.puml index fc114d7..2c65eaa 100644 --- a/design/backend/sequence/inner/event-목적선택.puml +++ b/design/backend/sequence/inner/event-목적선택.puml @@ -3,49 +3,108 @@ title Event Service - 이벤트 목적 선택 및 저장 (UFR-EVENT-020) -participant "EventController" as Controller <> -participant "EventService" as Service <> -participant "EventRepository" as Repo <> +participant "EventController" as Controller <> +participant "EventService" as Service <> +participant "EventRepository" as Repo <> participant "Redis Cache" as Cache <> database "Event DB" as DB <> participant "Kafka Producer" as Kafka <> +actor Client + +note over Controller, DB +**UFR-EVENT-020: 이벤트 목적 선택 및 저장** +- 목적 선택: 신규 고객 유치, 재방문 유도, 매출 증대, 인지도 향상 +- Redis 캐시 사용 (TTL: 30분) +- Kafka 이벤트 발행 (EventDraftCreated) +- 사용자 및 매장 정보는 User Service에서 조회 후 전달됨 +end note + +Client -> Controller: POST /api/events/purposes\n{"userId": 123,\n"objective": "신규 고객 유치",\n"storeName": "맛있는집",\n"industry": "음식점",\n"address": "서울시 강남구"} +activate Controller + +Controller -> Controller: 입력값 검증\n(필수 필드, 목적 유효성 확인) -note over Controller: POST /api/events/purposes Controller -> Service: createEventDraft(userId, objective, storeInfo) activate Service -Service -> Cache: get("purpose:" + userId) +== 1단계: Redis 캐시 확인 == + +Service -> Cache: 캐시 조회\nKey: draft:event:{userId}\n(기존 작성 중인 이벤트 확인) activate Cache Cache --> Service: null (캐시 미스) deactivate Cache -Service -> Service: validate(objective, storeInfo) -note right: 목적 유효성 검증\n- 신규 고객 유치\n- 재방문 유도\n- 매출 증대\n- 인지도 향상 +== 2단계: 목적 유효성 검증 == + +Service -> Service: 목적 유효성 검증\n- 신규 고객 유치\n- 재방문 유도\n- 매출 증대\n- 인지도 향상 + +Service -> Service: 매장 정보 유효성 검증\n(매장명, 업종, 주소) + +== 3단계: 이벤트 초안 저장 == Service -> Repo: save(eventDraft) activate Repo -Repo -> DB: 이벤트 초안 저장\n(사용자ID, 목적, 매장정보,\n상태를 저장) +Repo -> DB: 이벤트 초안 저장\n(사용자ID, 목적, 매장명,\n업종, 주소, 상태=DRAFT,\n생성일시)\n저장 후 이벤트초안ID 반환 activate DB -DB --> Repo: eventDraftId +DB --> Repo: 생성된 이벤트초안ID deactivate DB -Repo --> Service: EventDraft entity +Repo --> Service: EventDraft 엔티티\n(eventDraftId 포함) deactivate Repo -Service -> Cache: set("purpose:" + userId, eventDraft, TTL=30분) +== 4단계: Redis 캐시 저장 == + +Service -> Cache: 캐시 저장\nKey: draft:event:{eventDraftId}\nValue: {목적, 매장정보, 상태}\nTTL: 24시간 activate Cache -Cache --> Service: OK +Cache --> Service: 저장 완료 deactivate Cache -Service -> Kafka: publish(EventDraftCreated,\n{eventDraftId, userId, objective, createdAt}) +== 5단계: Kafka 이벤트 발행 == + +Service -> Kafka: 이벤트 발행\nTopic: event-topic\nEvent: EventDraftCreated\nPayload: {eventDraftId,\nuserId, objective,\ncreatedAt} activate Kafka -note right: Kafka Event Topic:\nevent-topic\n\nEvent: EventDraftCreated\n(목적 선택 시 발행)\n\n※ EventCreated는\n최종 승인 시 발행 -Kafka --> Service: ACK +note right of Kafka +**Kafka Event Topic** +- Topic: event-topic +- Event: EventDraftCreated +- 목적 선택 시 발행 + +**구독자** +- Analytics Service (선택적) + +**참고** +- EventCreated는 + 최종 승인 시 발행 +end note +Kafka --> Service: ACK (발행 확인) deactivate Kafka -Service --> Controller: EventDraftResponse\n{eventDraftId, objective, status} -deactivate Service -Controller --> Client: 200 OK\n{eventDraftId} +== 6단계: 응답 반환 == -note over Controller, Kafka: 캐시 히트 시:\n1. Redis에서 조회 → 즉시 반환\n2. DB 조회 생략\n\n※ EventDraftCreated 이벤트는\nAnalytics Service가 선택적으로 구독\n(통계 초기화는 EventCreated 시) +Service -> Service: 응답 DTO 생성 +Service --> Controller: EventDraftResponse\n{eventDraftId, objective,\nstoreName, status=DRAFT} +deactivate Service + +Controller --> Client: 200 OK\n{"eventDraftId": "draft-123",\n"objective": "신규 고객 유치",\n"storeName": "맛있는집",\n"status": "DRAFT"} +deactivate Controller + +note over Controller, Kafka +**캐시 전략** +- Key: draft:event:{eventDraftId} +- TTL: 24시간 +- 캐시 히트 시: DB 조회 생략, 즉시 반환 + +**이벤트 발행 전략** +- EventDraftCreated: 목적 선택 시 발행 (Analytics Service 선택적 구독) +- EventCreated: 최종 승인 시 발행 (통계 초기화 시작) + +**성능 목표** +- 평균 응답 시간: 0.3초 이내 +- P95 응답 시간: 0.5초 이내 +- Redis 캐시 조회: 0.05초 이내 + +**에러 코드** +- EVENT_001: 유효하지 않은 목적 +- EVENT_002: 매장 정보 누락 +end note @enduml diff --git a/design/backend/sequence/inner/user-로그인.puml b/design/backend/sequence/inner/user-로그인.puml index 18be315..5afba33 100644 --- a/design/backend/sequence/inner/user-로그인.puml +++ b/design/backend/sequence/inner/user-로그인.puml @@ -15,30 +15,30 @@ participant "User DB\n(PostgreSQL)" as UserDB <> note over Controller, UserDB **UFR-USER-020: 로그인** -- 입력: 전화번호, 비밀번호 +- 입력: 이메일, 비밀번호 - 비밀번호 검증 (bcrypt compare) - JWT 토큰 발급 - 세션 저장 (Redis) - 최종 로그인 시각 업데이트 end note -Client -> Controller: POST /api/users/login\n(LoginRequest DTO) +Client -> Controller: POST /api/users/login\n{"email": "user@example.com",\n"password": "password123"} activate Controller -Controller -> Controller: @Valid 어노테이션 검증\n(필수 필드 확인) +Controller -> Controller: 입력값 검증\n(필수 필드, 이메일 형식 확인) -Controller -> AuthService: authenticate(phoneNumber, password) +Controller -> AuthService: authenticate(email, password) activate AuthService == 1단계: 사용자 조회 == -AuthService -> Service: findByPhoneNumber(phoneNumber) +AuthService -> Service: findByEmail(email) activate Service -Service -> UserRepo: findByPhoneNumber(phoneNumber) +Service -> UserRepo: findByEmail(email) activate UserRepo -UserRepo -> UserDB: 전화번호로 사용자 조회\n(사용자ID, 비밀번호해시, 역할,\n이름, 이메일 조회) +UserRepo -> UserDB: 이메일로 사용자 조회\n(사용자ID, 비밀번호해시, 역할,\n이름, 전화번호 조회) activate UserDB -UserDB --> UserRepo: 사용자 정보 또는 NULL +UserDB --> UserRepo: 사용자 정보 반환 또는 없음 deactivate UserDB UserRepo --> Service: Optional deactivate UserRepo @@ -46,8 +46,8 @@ Service --> AuthService: Optional deactivate Service alt 사용자 없음 - AuthService --> Controller: throw AuthenticationFailedException\n("전화번호 또는 비밀번호를 확인해주세요") - Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"error": "전화번호 또는 비밀번호를\n확인해주세요"} + AuthService --> Controller: throw AuthenticationFailedException\n("이메일 또는 비밀번호를 확인해주세요") + Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"message": "이메일 또는 비밀번호를\n확인해주세요"} deactivate AuthService deactivate Controller @@ -62,8 +62,8 @@ else 사용자 존재 deactivate PwdEncoder alt 비밀번호 불일치 - AuthService --> Controller: throw AuthenticationFailedException\n("전화번호 또는 비밀번호를 확인해주세요") - Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"error": "전화번호 또는 비밀번호를\n확인해주세요"} + AuthService --> Controller: throw AuthenticationFailedException\n("이메일 또는 비밀번호를 확인해주세요") + Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"message": "이메일 또는 비밀번호를\n확인해주세요"} deactivate AuthService deactivate Controller @@ -79,9 +79,9 @@ else 사용자 존재 == 4단계: 세션 저장 == - AuthService -> Redis: SET user:session:{token}\n(userId, role, TTL 7일) + AuthService -> Redis: 세션 정보 저장\nKey: user:session:{token}\nValue: {userId, role}\nTTL: 7일 activate Redis - Redis --> AuthService: 세션 저장 완료 + Redis --> AuthService: 저장 완료 deactivate Redis == 5단계: 최종 로그인 시각 업데이트 (비동기) == @@ -96,7 +96,7 @@ else 사용자 존재 end note Service ->> UserRepo: updateLastLoginAt(userId) activate UserRepo - UserRepo ->> UserDB: 최종 로그인 시각 업데이트\n(현재 시각으로 갱신) + UserRepo ->> UserDB: 사용자 최종 로그인 시각 갱신\n(현재 시각으로 업데이트) activate UserDB UserDB -->> UserRepo: 업데이트 완료 deactivate UserDB @@ -113,11 +113,11 @@ else 사용자 존재 == 6단계: 응답 반환 == - AuthService -> AuthService: 응답 DTO 생성\n(LoginResponse) - AuthService --> Controller: LoginResponse\n(token, userId, userName,\nrole, email) + AuthService -> AuthService: 로그인 응답 DTO 생성 + AuthService --> Controller: LoginResponse\n{token, userId, userName, role, email} deactivate AuthService - Controller --> Client: 200 OK\n{"token": "jwt_token",\n"userId": 123,\n"userName": "홍길동",\n"role": "OWNER",\n"email": "hong@example.com"} + Controller --> Client: 200 OK\n{"token": "eyJhbGc...",\n"userId": 123,\n"userName": "홍길동",\n"role": "OWNER",\n"email": "hong@example.com"} deactivate Controller end end @@ -125,7 +125,7 @@ end note over Controller, UserDB **보안 처리** - 비밀번호: bcrypt compare (원본 노출 안 됨) -- 에러 메시지: 전화번호/비밀번호 구분 없이 동일 메시지 반환 (Timing Attack 방어) +- 에러 메시지: 이메일/비밀번호 구분 없이 동일 메시지 반환 (Timing Attack 방어) - JWT 토큰: 7일 만료, 서버 세션과 동기화 **보안 강화 (향후 구현)** @@ -141,7 +141,7 @@ note over Controller, UserDB - Redis 세션 조회: 0.1초 이내 **에러 코드** -- AUTH_001: 인증 실패 (전화번호 또는 비밀번호 불일치) +- AUTH_001: 인증 실패 (이메일 또는 비밀번호 불일치) end note @enduml diff --git a/design/backend/sequence/inner/user-회원가입.puml b/design/backend/sequence/inner/user-회원가입.puml index 0f818e4..dfd6c71 100644 --- a/design/backend/sequence/inner/user-회원가입.puml +++ b/design/backend/sequence/inner/user-회원가입.puml @@ -5,112 +5,68 @@ title User Service - 회원가입 내부 시퀀스 (UFR-USER-010) participant "UserController" as Controller <> participant "UserService" as Service <> -participant "BusinessValidator" as Validator <> participant "UserRepository" as UserRepo <> participant "StoreRepository" as StoreRepo <> participant "PasswordEncoder" as PwdEncoder <> participant "JwtTokenProvider" as JwtProvider <> participant "Redis\nCache" as Redis <> participant "User DB\n(PostgreSQL)" as UserDB <> -participant "국세청 API" as NTSApi <> actor Client -note over Controller, NTSApi +note over Controller, UserDB **UFR-USER-010: 회원가입** - 기본 정보: 이름, 전화번호, 이메일, 비밀번호 - 매장 정보: 매장명, 업종, 주소, 영업시간, 사업자번호 -- 사업자번호 검증 (국세청 API) +- 이메일/전화번호 중복 검사 - 트랜잭션 처리 - JWT 토큰 발급 end note -Client -> Controller: POST /api/users/register\n(RegisterRequest DTO) +Client -> Controller: POST /api/users/register\n{"name": "홍길동",\n"phoneNumber": "01012345678",\n"email": "hong@example.com",\n"password": "password123"} activate Controller -Controller -> Controller: @Valid 어노테이션 검증\n(이메일 형식, 비밀번호 8자 이상 등) +Controller -> Controller: 입력값 검증\n(이메일 형식, 비밀번호 8자 이상 등) Controller -> Service: register(RegisterRequest) activate Service -== 1단계: 중복 사용자 확인 == +== 1단계: 이메일 중복 확인 == -Service -> UserRepo: findByPhoneNumber(phoneNumber) +Service -> UserRepo: findByEmail(email) activate UserRepo -UserRepo -> UserDB: 전화번호로 사용자 조회\n(중복 가입 확인) +UserRepo -> UserDB: 이메일로 사용자 조회\n(중복 가입 확인) activate UserDB -UserDB --> UserRepo: 조회 결과 +UserDB --> UserRepo: 조회 결과 반환 또는 없음 deactivate UserDB UserRepo --> Service: Optional deactivate UserRepo -alt 중복 사용자 존재 - Service --> Controller: throw DuplicateUserException\n("이미 가입된 전화번호입니다") - Controller --> Client: 400 Bad Request\n{"code": "USER_001",\n"error": "이미 가입된 전화번호입니다"} +alt 이메일 중복 존재 + Service --> Controller: throw DuplicateEmailException\n("이미 가입된 이메일입니다") + Controller --> Client: 400 Bad Request\n{"code": "USER_001",\n"message": "이미 가입된 이메일입니다"} deactivate Service deactivate Controller -else 신규 사용자 - == 2단계: 사업자번호 검증 == +else 이메일 신규 - Service -> Validator: validateBusinessNumber(businessNumber) - activate Validator + == 2단계: 전화번호 중복 확인 == - Validator -> Redis: GET user:business:{사업자번호} - activate Redis - Redis --> Validator: 캐시 확인 결과 - deactivate Redis + Service -> UserRepo: findByPhoneNumber(phoneNumber) + activate UserRepo + UserRepo -> UserDB: 전화번호로 사용자 조회\n(중복 가입 확인) + activate UserDB + UserDB --> UserRepo: 조회 결과 반환 또는 없음 + deactivate UserDB + UserRepo --> Service: Optional + deactivate UserRepo - alt 캐시 HIT (검증 결과 있음) - Validator -> Validator: 캐시된 검증 결과 사용\n(응답 시간: 0.1초) - - else 캐시 MISS (검증 필요) - - note right of Validator - **Circuit Breaker 설정** - - 실패율: 50% 초과 시 Open - - 타임아웃: 5초 - - Half-Open: 30초 후 전환 - end note - - Validator -> NTSApi: POST /사업자번호_검증\n(사업자번호)\n[Circuit Breaker, Timeout 5초] - activate NTSApi - - alt 국세청 API 정상 응답 - NTSApi --> Validator: 200 OK\n{"valid": true, "status": "영업중"} - deactivate NTSApi - - Validator -> Redis: SET user:business:{사업자번호}\n검증 결과 (TTL 7일) - activate Redis - Redis --> Validator: 캐싱 완료 - deactivate Redis - - else 국세청 API 장애 (Circuit Breaker Open) - NTSApi --> Validator: 500 Internal Server Error\n또는 Timeout - deactivate NTSApi - - note right of Validator - **Resilience 패턴 적용** - - Circuit Breaker: Open - - Retry: 최대 3회 (1초, 2초, 4초) - - Fallback: 검증 스킵 (수동 확인 안내) - end note - - Validator -> Validator: Fallback 실행:\n사업자번호 검증 스킵\n(수동 확인 안내 플래그 설정) - end - end - - alt 사업자번호 검증 실패 (휴폐업 등) - Validator --> Service: throw BusinessNumberInvalidException\n("유효하지 않은 사업자번호입니다") - deactivate Validator - - Service --> Controller: BusinessNumberInvalidException - Controller --> Client: 400 Bad Request\n{"code": "USER_002",\n"error": "유효하지 않은 사업자번호입니다.\n휴폐업 여부를 확인해주세요."} + alt 전화번호 중복 존재 + Service --> Controller: throw DuplicatePhoneException\n("이미 가입된 전화번호입니다") + Controller --> Client: 400 Bad Request\n{"code": "USER_002",\n"message": "이미 가입된 전화번호입니다"} deactivate Service deactivate Controller - else 사업자번호 검증 성공 - Validator --> Service: ValidationResult\n(valid: true, needsManualCheck: false) - deactivate Validator + else 신규 사용자 == 3단계: 비밀번호 해싱 == @@ -120,40 +76,30 @@ else 신규 사용자 PwdEncoder --> Service: passwordHash deactivate PwdEncoder - == 4단계: 사업자번호 암호화 == - - Service -> Service: EncryptionUtil 호출 준비 - note right of Service - **암호화 처리** - - AES-256-GCM 모드 사용 - - 환경변수에서 암호화 키 로드 - end note - Service -> Service: encryptedBusinessNumber =\nEncryptionUtil.encrypt(businessNumber) - - == 5단계: 데이터베이스 트랜잭션 == + == 4단계: 데이터베이스 트랜잭션 == Service -> UserDB: 트랜잭션 시작 activate UserDB - Service -> UserRepo: save(User)\n(name, phoneNumber, email,\npasswordHash, createdAt) + Service -> UserRepo: save(User) activate UserRepo - UserRepo -> UserDB: 사용자 정보 저장\n(이름, 전화번호, 이메일,\n비밀번호해시, 생성일시)\n사용자ID 반환 - UserDB --> UserRepo: user_id + UserRepo -> UserDB: 사용자 정보 저장\n(이름, 전화번호, 이메일,\n비밀번호해시, 생성일시)\n저장 후 사용자ID 반환 + UserDB --> UserRepo: 생성된 사용자ID UserRepo --> Service: User 엔티티\n(userId 포함) deactivate UserRepo - Service -> StoreRepo: save(Store)\n(userId, storeName, industry,\naddress, businessNumberEncrypted,\nbusinessHours) + Service -> StoreRepo: save(Store) activate StoreRepo - StoreRepo -> UserDB: 매장 정보 저장\n(사용자ID, 매장명, 업종,\n주소, 암호화된사업자번호,\n영업시간)\n매장ID 반환 - UserDB --> StoreRepo: store_id + StoreRepo -> UserDB: 매장 정보 저장\n(사용자ID, 매장명, 업종,\n주소, 사업자번호, 영업시간)\n저장 후 매장ID 반환 + UserDB --> StoreRepo: 생성된 매장ID StoreRepo --> Service: Store 엔티티\n(storeId 포함) deactivate StoreRepo Service -> UserDB: 트랜잭션 커밋 - UserDB --> Service: 트랜잭션 커밋 완료 + UserDB --> Service: 커밋 완료 deactivate UserDB - == 6단계: JWT 토큰 생성 == + == 5단계: JWT 토큰 생성 == Service -> JwtProvider: generateToken(userId, role) activate JwtProvider @@ -161,48 +107,43 @@ else 신규 사용자 JwtProvider --> Service: JWT 토큰 deactivate JwtProvider - == 7단계: 세션 저장 == + == 6단계: 세션 저장 == - Service -> Redis: SET user:session:{token}\n(userId, role, TTL 7일) + Service -> Redis: 세션 정보 저장\nKey: user:session:{token}\nValue: {userId, role}\nTTL: 7일 activate Redis - Redis --> Service: 세션 저장 완료 + Redis --> Service: 저장 완료 deactivate Redis - == 8단계: 응답 반환 == + == 7단계: 응답 반환 == - Service -> Service: 응답 DTO 생성\n(RegisterResponse) - Service --> Controller: RegisterResponse\n(token, userId, userName,\nstoreId, storeName) + Service -> Service: 회원가입 응답 DTO 생성 + Service --> Controller: RegisterResponse\n{token, userId, userName, storeId, storeName} deactivate Service - Controller --> Client: 201 Created\n{"token": "jwt_token",\n"userId": 123,\n"userName": "홍길동",\n"storeId": 456,\n"storeName": "맛있는집"} + Controller --> Client: 201 Created\n{"token": "eyJhbGc...",\n"userId": 123,\n"userName": "홍길동",\n"storeId": 456,\n"storeName": "맛있는집"} deactivate Controller end + end end -note over Controller, NTSApi +note over Controller, UserDB **Transaction Rollback 처리** - 트랜잭션 실패 시 자동 Rollback - User/Store INSERT 중 하나라도 실패 시 전체 롤백 - 예외: DataAccessException, ConstraintViolationException -**Resilience 패턴 요약** -- Circuit Breaker: 국세청 API (실패율 50% 초과 시 Open) -- Retry: 최대 3회 (지수 백오프: 1초, 2초, 4초) -- Timeout: 5초 -- Fallback: 사업자번호 검증 스킵 (수동 확인 안내) - **보안 처리** - 비밀번호: bcrypt 해싱 (Cost Factor 10) -- 사업자번호: AES-256-GCM 암호화 (EncryptionUtil) +- JWT 토큰: 7일 만료, 서버 세션과 동기화 **성능 목표** -- 평균 응답 시간: 2.0초 이내 (국세청 API 포함) -- P95 응답 시간: 3.0초 이내 -- 캐시 HIT 시: 0.8초 이내 +- 평균 응답 시간: 1.0초 이내 +- P95 응답 시간: 1.5초 이내 +- 트랜잭션 처리: 0.5초 이내 **에러 코드** -- USER_001: 중복 사용자 -- USER_002: 사업자번호 검증 실패 +- USER_001: 이메일 중복 +- USER_002: 전화번호 중복 end note @enduml diff --git a/design/backend/sequence/outer/이벤트생성플로우.puml b/design/backend/sequence/outer/이벤트생성플로우.puml index fda5856..a1933c9 100644 --- a/design/backend/sequence/outer/이벤트생성플로우.puml +++ b/design/backend/sequence/outer/이벤트생성플로우.puml @@ -21,14 +21,18 @@ participant "배포 채널 APIs" as ChannelApis == 1. 이벤트 목적 선택 (UFR-EVENT-020) == User -> FE: 이벤트 목적 선택 -FE -> UserSvc: GET /api/users/{userId}/store\n회원 및 매장정보 조회 +FE -> Gateway: GET /api/users/{userId}/store\n회원 및 매장정보 조회 +activate Gateway +Gateway -> UserSvc: GET /api/users/{userId}/store\n회원 및 매장정보 조회 activate UserSvc UserSvc -> UserDB: 사용자 및 매장 정보 조회 activate UserDB UserDB --> UserSvc: 사용자, 매장 정보 반환 deactivate UserDB -UserSvc --> FE: 200 OK\n{userId, storeName, industry, address} +UserSvc --> Gateway: 200 OK\n{userId, storeName, industry, address} deactivate UserSvc +Gateway --> FE: 200 OK\n{userId, storeName, industry, address} +deactivate Gateway FE -> Gateway: POST /events/purposes\n{목적, userId, storeName, industry, address} Gateway -> Event: 이벤트 목적 저장 요청 Event -> Redis: 이벤트 목적 정보 저장\nKey: draft:event:{eventDraftId}\n(목적, 매장정보 저장)\nTTL: 24시간