From f7f16e2d1898cd7d7d419bd936adcc756b9b1fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B8=EC=9B=90?= Date: Wed, 22 Oct 2025 14:37:25 +0900 Subject: [PATCH] =?UTF-8?q?User=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20=EC=8B=9C=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입: 사업자번호 암호화 위치 명시, 성능 지표 추가, 에러 코드 표준화 (USER_001, USER_002) - 로그인: 비동기 처리 설명 추가, Rate Limiting 보안 강화, 성능 목표 추가, 에러 코드 표준화 (AUTH_001) - 프로필수정: 트랜잭션 범위 명확화, Optimistic Locking 추가, 동시성 제어, 에러 코드 표준화 (USER_003~005) - 로그아웃: API Gateway 연계 시나리오, 로그아웃 이벤트 발행, 성능 목표 추가, 에러 코드 표준화 (AUTH_002) - .gitignore 추가: .npm-global 및 기타 임시 파일 제외 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 32 +++++ .../backend/sequence/inner/user-로그아웃.puml | 51 +++++++- .../backend/sequence/inner/user-로그인.puml | 26 +++- .../sequence/inner/user-프로필수정.puml | 111 ++++++++++++------ .../backend/sequence/inner/user-회원가입.puml | 23 +++- 5 files changed, 196 insertions(+), 47 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a41541 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm-global/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build +dist/ +build/ +*.log + +# Environment +.env +.env.local +.env.*.local + +# Temporary +tmp/ +temp/ +*.tmp diff --git a/design/backend/sequence/inner/user-로그아웃.puml b/design/backend/sequence/inner/user-로그아웃.puml index 0b719c8..7793e02 100644 --- a/design/backend/sequence/inner/user-로그아웃.puml +++ b/design/backend/sequence/inner/user-로그아웃.puml @@ -36,7 +36,7 @@ deactivate JwtProvider alt JWT 토큰 무효 AuthService --> Controller: throw InvalidTokenException\n("유효하지 않은 토큰입니다") - Controller --> Client: 401 Unauthorized\n{"error": "유효하지 않은 토큰입니다"} + Controller --> Client: 401 Unauthorized\n{"code": "AUTH_002",\n"error": "유효하지 않은 토큰입니다"} deactivate AuthService deactivate Controller @@ -70,6 +70,14 @@ else JWT 토큰 유효 - 만료되지 않은 JWT 토큰을 강제로 무효화 - Redis에 토큰을 Blacklist에 추가 (TTL: 남은 만료 시간) - API Gateway에서 Blacklist 확인 + + **API Gateway 연계 시나리오** + 1. 로그아웃: AuthService가 Blacklist에 토큰 추가 + 2. 후속 API 요청: API Gateway가 Blacklist 확인 + - Redis GET jwt:blacklist:{token} + - 존재하면: 401 Unauthorized 즉시 반환 + - 존재하지 않으면: 백엔드 서비스로 라우팅 + 3. 만료 시간 도달: Redis TTL 만료로 자동 삭제 end note AuthService -> JwtProvider: getRemainingExpiration(token) @@ -85,9 +93,26 @@ else JWT 토큰 유효 deactivate Redis end - == 4단계: 응답 반환 == + == 4단계: 로그아웃 이벤트 발행 (선택적) == - AuthService -> AuthService: 로그아웃 성공 로그 기록\n(userId, timestamp) + note right of AuthService + **로그아웃 로깅 및 이벤트** + - 감사 로그 기록: userId, timestamp, IP + - 이벤트 발행: LOGOUT_SUCCESS + - 분석 데이터 수집: 세션 지속 시간, 활동 통계 + end note + + AuthService -> AuthService: 로그아웃 성공 로그 기록\n(userId, timestamp, sessionDuration) + + AuthService ->> AuthService: publishEvent(LOGOUT_SUCCESS) + note right of AuthService + **이벤트 활용** + - 비동기 이벤트 처리 + - 분석 시스템 연동 + - 감사 로그 저장소 전송 + end note + + == 5단계: 응답 반환 == AuthService --> Controller: LogoutResponse\n(success: true) deactivate AuthService @@ -101,14 +126,30 @@ note over Controller, Redis - JWT 토큰 Blacklist: 만료 전 토큰 강제 무효화 - 멱등성 보장: 중복 로그아웃 요청에 안전 - 세션 완전 삭제: Redis에서 세션 정보 제거 +- 감사 로그: userId, timestamp, IP, sessionDuration 기록 + +**API Gateway 연계** +- Blacklist 확인: GET jwt:blacklist:{token} +- 존재 시: 401 Unauthorized 즉시 반환 +- TTL 자동 관리: 만료 시간 도달 시 자동 삭제 **클라이언트 측 처리** - 프론트엔드: LocalStorage 또는 Cookie에서 JWT 토큰 삭제 - 로그인 화면으로 리다이렉트 +- 모든 인증 헤더 제거 -**성능 최적화** +**성능 목표** - Redis 삭제 연산: O(1) 시간 복잡도 -- 응답 시간: 0.1초 이내 +- 평균 응답 시간: 0.1초 이내 +- P95 응답 시간: 0.2초 이내 + +**이벤트 처리** +- LOGOUT_SUCCESS 이벤트 발행 (비동기) +- 감사 로그 저장소 전송 +- 분석 데이터 수집 + +**에러 코드** +- AUTH_002: JWT 토큰 무효 end note @enduml diff --git a/design/backend/sequence/inner/user-로그인.puml b/design/backend/sequence/inner/user-로그인.puml index 01f9819..5165ead 100644 --- a/design/backend/sequence/inner/user-로그인.puml +++ b/design/backend/sequence/inner/user-로그인.puml @@ -47,7 +47,7 @@ deactivate Service alt 사용자 없음 AuthService --> Controller: throw AuthenticationFailedException\n("전화번호 또는 비밀번호를 확인해주세요") - Controller --> Client: 401 Unauthorized\n{"error": "전화번호 또는 비밀번호를\n확인해주세요"} + Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"error": "전화번호 또는 비밀번호를\n확인해주세요"} deactivate AuthService deactivate Controller @@ -63,7 +63,7 @@ else 사용자 존재 alt 비밀번호 불일치 AuthService --> Controller: throw AuthenticationFailedException\n("전화번호 또는 비밀번호를 확인해주세요") - Controller --> Client: 401 Unauthorized\n{"error": "전화번호 또는 비밀번호를\n확인해주세요"} + Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"error": "전화번호 또는 비밀번호를\n확인해주세요"} deactivate AuthService deactivate Controller @@ -92,6 +92,7 @@ else 사용자 존재 **비동기 처리** - @Async 어노테이션 사용 - 로그인 응답 지연 방지 + - 별도 스레드풀에서 실행 end note Service ->> UserRepo: updateLastLoginAt(userId) activate UserRepo @@ -104,6 +105,12 @@ else 사용자 존재 Service -->> AuthService: void (비동기 완료) deactivate Service + note over AuthService, Service + **비동기 화살표 설명** + - `->>`: 비동기 호출 (호출 후 즉시 반환) + - `-->>`: 비동기 응답 (별도 스레드에서 완료) + end note + == 6단계: 응답 반환 == AuthService -> AuthService: 응답 DTO 생성\n(LoginResponse) @@ -118,12 +125,23 @@ end note over Controller, UserDB **보안 처리** - 비밀번호: bcrypt compare (원본 노출 안 됨) -- 에러 메시지: 전화번호/비밀번호 구분 없이 동일 메시지 반환 (보안 강화) +- 에러 메시지: 전화번호/비밀번호 구분 없이 동일 메시지 반환 (Timing Attack 방어) - JWT 토큰: 7일 만료, 서버 세션과 동기화 +**보안 강화 (향후 구현)** +- Rate Limiting: IP당 5분에 5회 로그인 실패 시 임시 차단 (15분) +- Account Lockout: 동일 계정 10회 실패 시 계정 잠금 (관리자 해제) +- MFA: 2단계 인증 추가 (SMS/TOTP) +- Anomaly Detection: 비정상 로그인 패턴 감지 (지역, 디바이스 변경) + **성능 최적화** - 최종 로그인 시각 업데이트: 비동기 처리 (@Async) -- 응답 시간: 0.5초 목표 +- 평균 응답 시간: 0.5초 이내 +- P95 응답 시간: 1.0초 이내 +- Redis 세션 조회: 0.1초 이내 + +**에러 코드** +- AUTH_001: 인증 실패 (전화번호 또는 비밀번호 불일치) end note @enduml diff --git a/design/backend/sequence/inner/user-프로필수정.puml b/design/backend/sequence/inner/user-프로필수정.puml index 63c98c7..e6917ee 100644 --- a/design/backend/sequence/inner/user-프로필수정.puml +++ b/design/backend/sequence/inner/user-프로필수정.puml @@ -43,7 +43,7 @@ deactivate UserRepo alt 사용자 없음 Service --> Controller: throw UserNotFoundException\n("사용자를 찾을 수 없습니다") - Controller --> Client: 404 Not Found\n{"error": "사용자를 찾을 수 없습니다"} + Controller --> Client: 404 Not Found\n{"code": "USER_003",\n"error": "사용자를 찾을 수 없습니다"} deactivate Service deactivate Controller @@ -62,7 +62,7 @@ else 사용자 존재 alt 현재 비밀번호 불일치 Service --> Controller: throw InvalidPasswordException\n("현재 비밀번호가 일치하지 않습니다") - Controller --> Client: 400 Bad Request\n{"error": "현재 비밀번호가\n일치하지 않습니다"} + Controller --> Client: 400 Bad Request\n{"code": "USER_004",\n"error": "현재 비밀번호가\n일치하지 않습니다"} deactivate Service deactivate Controller @@ -79,7 +79,14 @@ else 사용자 존재 end end - == 3단계: 기본 정보 업데이트 == + == 3단계: 엔티티 수정 준비 (메모리상 변경) == + + note right of Service + **JPA Dirty Checking** + - 트랜잭션 시작 전 엔티티 수정 (메모리상) + - 트랜잭션 커밋 시 변경 감지하여 UPDATE 자동 실행 + - 변경된 필드만 UPDATE 쿼리에 포함 + end note alt 이름 변경 Service -> Service: user.setName(newName) @@ -98,7 +105,7 @@ else 사용자 존재 Service -> Service: user.setEmail(newEmail) end - == 4단계: 매장 정보 업데이트 == + == 4단계: 매장 정보 수정 준비 (메모리상 변경) == Service -> StoreRepo: findByUserId(userId) activate StoreRepo @@ -130,62 +137,98 @@ else 사용자 존재 Service -> UserDB: BEGIN TRANSACTION activate UserDB + note right of Service + **Optimistic Locking** + - @Version 필드로 동시 수정 감지 + - 다른 트랜잭션이 먼저 수정한 경우 + - OptimisticLockException 발생 + end note + Service -> UserRepo: save(user) activate UserRepo - UserRepo -> UserDB: UPDATE users\nSET name = ?, phone_number = ?,\nemail = ?, password_hash = ?,\nupdated_at = NOW()\nWHERE user_id = ? - UserDB --> UserRepo: 업데이트 완료 + UserRepo -> UserDB: UPDATE users\nSET name = ?, phone_number = ?,\nemail = ?, password_hash = ?,\nupdated_at = NOW(),\nversion = version + 1\nWHERE user_id = ? AND version = ? + UserDB --> UserRepo: 업데이트 완료 (1 row affected) UserRepo --> Service: User 엔티티 deactivate UserRepo - Service -> StoreRepo: save(store) - activate StoreRepo - StoreRepo -> UserDB: UPDATE stores\nSET store_name = ?, industry = ?,\naddress = ?, business_hours = ?,\nupdated_at = NOW()\nWHERE store_id = ? - UserDB --> StoreRepo: 업데이트 완료 - StoreRepo --> Service: Store 엔티티 - deactivate StoreRepo + alt 동시성 충돌 (version 불일치) + UserRepo --> Service: throw OptimisticLockException + Service --> Controller: throw ConcurrentModificationException\n("다른 사용자가 수정 중입니다") + Controller --> Client: 409 Conflict\n{"code": "USER_005",\n"error": "다른 세션에서 프로필을\n수정했습니다.\n새로고침 후 다시 시도하세요"} + Service -> UserDB: ROLLBACK TRANSACTION + deactivate UserDB + deactivate Service + deactivate Controller + else 정상 업데이트 - Service -> UserDB: COMMIT TRANSACTION - UserDB --> Service: 트랜잭션 커밋 완료 - deactivate UserDB + Service -> StoreRepo: save(store) + activate StoreRepo + StoreRepo -> UserDB: UPDATE stores\nSET store_name = ?, industry = ?,\naddress = ?, business_hours = ?,\nupdated_at = NOW(),\nversion = version + 1\nWHERE store_id = ? AND version = ? + UserDB --> StoreRepo: 업데이트 완료 (1 row affected) + StoreRepo --> Service: Store 엔티티 + deactivate StoreRepo - == 6단계: 캐시 무효화 (선택적) == - - note right of Service - **캐시 무효화 전략** - - 세션 정보는 변경 없음 (JWT 유지) - - 프로필 캐시가 있다면 무효화 - end note - - alt 프로필 캐시 사용 중 - Service -> Redis: DEL user:profile:{userId} - activate Redis - Redis --> Service: 캐시 삭제 완료 - deactivate Redis + Service -> UserDB: COMMIT TRANSACTION + UserDB --> Service: 트랜잭션 커밋 완료 + deactivate UserDB end - == 7단계: 응답 반환 == + == 6단계: 캐시 무효화 (선택적) == - Service -> Service: 응답 DTO 생성\n(UpdateProfileResponse) - Service --> Controller: UpdateProfileResponse\n(userId, userName, email,\nstoreId, storeName) - deactivate Service + note right of Service + **캐시 무효화 전략** + - 세션 정보는 변경 없음 (JWT 유지) + - 프로필 캐시가 있다면 무효화 + end note - Controller --> Client: 200 OK\n{"userId": 123,\n"userName": "홍길동",\n"email": "hong@example.com",\n"storeId": 456,\n"storeName": "맛있는집"} - deactivate Controller + alt 프로필 캐시 사용 중 + Service -> Redis: DEL user:profile:{userId} + activate Redis + Redis --> Service: 캐시 삭제 완료 + deactivate Redis + end + + == 7단계: 응답 반환 == + + Service -> Service: 응답 DTO 생성\n(UpdateProfileResponse) + Service --> Controller: UpdateProfileResponse\n(userId, userName, email,\nstoreId, storeName) + deactivate Service + + Controller --> Client: 200 OK\n{"userId": 123,\n"userName": "홍길동",\n"email": "hong@example.com",\n"storeId": 456,\n"storeName": "맛있는집"} + deactivate Controller + end end note over Controller, UserDB **Transaction Rollback 처리** - 트랜잭션 실패 시 자동 Rollback - User/Store UPDATE 중 하나라도 실패 시 전체 롤백 +- OptimisticLockException 발생 시 409 Conflict 반환 + +**동시성 제어** +- Optimistic Locking: @Version 필드로 동시 수정 감지 +- 충돌 감지 시: 409 Conflict 반환 (사용자에게 재시도 안내) +- Lost Update 방지: version 필드 자동 증가 **보안 처리** - 비밀번호 변경: 현재 비밀번호 확인 필수 - JWT 인증: Controller에서 @AuthenticationPrincipal로 userId 추출 - 권한 검증: 본인만 수정 가능 +**성능 목표** +- 평균 응답 시간: 0.3초 이내 +- P95 응답 시간: 0.5초 이내 +- 트랜잭션 격리 수준: READ_COMMITTED + **향후 개선사항** - 전화번호 변경: SMS/이메일 재인증 구현 - 이메일 변경: 이메일 인증 구현 +- 변경 이력 추적: Audit Log 기록 + +**에러 코드** +- USER_003: 사용자 없음 +- USER_004: 현재 비밀번호 불일치 +- USER_005: 동시성 충돌 (다른 세션에서 수정) end note @enduml diff --git a/design/backend/sequence/inner/user-회원가입.puml b/design/backend/sequence/inner/user-회원가입.puml index 5071ba9..2c8627d 100644 --- a/design/backend/sequence/inner/user-회원가입.puml +++ b/design/backend/sequence/inner/user-회원가입.puml @@ -45,7 +45,7 @@ deactivate UserRepo alt 중복 사용자 존재 Service --> Controller: throw DuplicateUserException\n("이미 가입된 전화번호입니다") - Controller --> Client: 400 Bad Request\n{"error": "이미 가입된 전화번호입니다"} + Controller --> Client: 400 Bad Request\n{"code": "USER_001",\n"error": "이미 가입된 전화번호입니다"} deactivate Service deactivate Controller else 신규 사용자 @@ -104,7 +104,7 @@ else 신규 사용자 deactivate Validator Service --> Controller: BusinessNumberInvalidException - Controller --> Client: 400 Bad Request\n{"error": "유효하지 않은 사업자번호입니다.\n휴폐업 여부를 확인해주세요."} + Controller --> Client: 400 Bad Request\n{"code": "USER_002",\n"error": "유효하지 않은 사업자번호입니다.\n휴폐업 여부를 확인해주세요."} deactivate Service deactivate Controller @@ -122,7 +122,13 @@ else 신규 사용자 == 4단계: 사업자번호 암호화 == - Service -> Service: encryptBusinessNumber(businessNumber)\n(AES-256 암호화) + Service -> Service: EncryptionUtil 호출 준비 + note right of Service + **암호화 처리** + - AES-256-GCM 모드 사용 + - 환경변수에서 암호화 키 로드 + end note + Service -> Service: encryptedBusinessNumber =\nEncryptionUtil.encrypt(businessNumber) == 5단계: 데이터베이스 트랜잭션 == @@ -187,7 +193,16 @@ note over Controller, NTSApi **보안 처리** - 비밀번호: bcrypt 해싱 (Cost Factor 10) -- 사업자번호: AES-256 암호화 +- 사업자번호: AES-256-GCM 암호화 (EncryptionUtil) + +**성능 목표** +- 평균 응답 시간: 2.0초 이내 (국세청 API 포함) +- P95 응답 시간: 3.0초 이내 +- 캐시 HIT 시: 0.8초 이내 + +**에러 코드** +- USER_001: 중복 사용자 +- USER_002: 사업자번호 검증 실패 end note @enduml