Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
efe768e
FIX : HQL DATE() 함수 제거로 리포지토리 빈 생성 실패 해결 (#53)
RosieOh Aug 3, 2026
6e23adb
TEST : 컨텍스트 로딩 검증 테스트 추가 (#53)
RosieOh Aug 3, 2026
3b6e5b7
FIX : 커뮤니티 게시글·댓글 소유권 검증 추가 (#54)
RosieOh Aug 3, 2026
d7349ac
FIX : JWT typ 클레임 도입으로 Access·Refresh 토큰 혼용 차단 (#54)
RosieOh Aug 3, 2026
3183329
FIX : UserDto 비밀번호 필드 WRITE_ONLY 처리 (#54)
RosieOh Aug 3, 2026
03d7abe
FIX : 로그인 응답 통일로 사용자 열거 차단 및 탈퇴 계정 로그인 방지 (#54)
RosieOh Aug 3, 2026
64515d6
REFACTOR : 어드민을 JWT 기반 REST API로 전환 (#54)
RosieOh Aug 3, 2026
79fe186
FIX : 커스텀 캐싱 AOP 제거하고 Spring Cache로 전환 (#55)
RosieOh Aug 3, 2026
ebeb018
CHORE : 설정 파일 통합 및 Redis 키 spring.data.redis 교정 (#55)
RosieOh Aug 3, 2026
7c9701b
FIX : Flyway 베이스라인에 실제 스키마 반영 (#55)
RosieOh Aug 3, 2026
12a7861
FIX : 예약 겹침 판정·취소 필터·정원 검증 정상화 (#56)
RosieOh Aug 3, 2026
3414f7d
FIX : Rate limiting Redis 전환 및 XFF 신뢰 정책 도입 (#56)
RosieOh Aug 3, 2026
008e41f
PERF : 목록 API 페이징 도입으로 전체 조회 제거 (#56)
RosieOh Aug 3, 2026
0e83696
FIX : 조회수 증가를 원자적 UPDATE로 전환 (#56)
RosieOh Aug 3, 2026
4669dcc
FEAT : 알림 발송 어댑터 추가로 실제 채널 전송 연결 (#57)
RosieOh Aug 3, 2026
7dbdfea
FEAT : 예방접종·예약 리마인더 및 토큰 정리 스케줄러 추가 (#57)
RosieOh Aug 3, 2026
2417f3f
FEAT : 파일 저장소 추상화 및 건강기록 첨부 업로드 추가 (#57)
RosieOh Aug 3, 2026
cbb02a4
FEAT : 챗봇 Claude API 및 RAG 근거 검색 연동 (#57)
RosieOh Aug 3, 2026
3dbd3c1
FEAT : 정책 관리자 CRUD 추가로 재배포 없는 수정 지원 (#57)
RosieOh Aug 3, 2026
05512df
CHORE : FCM·Claude SDK 의존성 및 커버리지 게이트 조정 (#57)
RosieOh Aug 3, 2026
6a81cae
FEAT : 아이 등록 API 추가 (#58)
RosieOh Aug 3, 2026
725a124
FEAT : 표준 예방접종 일정 자동 생성 추가 (#58)
RosieOh Aug 3, 2026
c3a3dce
FEAT : WHO 기준 성장 백분위 계산 추가 (#58)
RosieOh Aug 3, 2026
f853729
FEAT : 커뮤니티 신고·차단 모더레이션 추가 (#58)
RosieOh Aug 3, 2026
e49f561
FEAT : 개인정보 동의 이력 및 데이터 열람·파기 추가 (#58)
RosieOh Aug 3, 2026
79fa098
FEAT : 신규 기능 테이블 마이그레이션 V2 추가 (#58)
RosieOh Aug 3, 2026
00fdf8f
TEST : 보안·예약·성장·접종 회귀 테스트 추가 (#59)
RosieOh Aug 3, 2026
bfca254
CHORE : CI 테스트 스킵 제거 및 Trivy 게이트·Blue/Green 포트 교정 (#59)
RosieOh Aug 3, 2026
b1dd17a
DOCS : 어드민 API 경로 변경 반영 (#54)
RosieOh Aug 3, 2026
08fd16b
FEAT : 공공데이터 공급자 추상화 인터페이스 추가 (#61)
RosieOh Aug 4, 2026
2998c07
FEAT : 전국 어린이집 동기화 서비스 추가 (#61)
RosieOh Aug 4, 2026
22cc40d
FEAT : 보조금24 정부지원서비스 동기화 추가 (#61)
RosieOh Aug 4, 2026
37fb1a3
FEAT : 공공데이터 주기 동기화 스케줄러 및 관리자 API 추가 (#61)
RosieOh Aug 4, 2026
bf38632
REFACTOR : 미사용 공공데이터 상수 정리 및 시도 코드 조회 추가 (#61)
RosieOh Aug 4, 2026
132c18f
CHORE : 공공데이터 공급자별 설정 및 동기화 크론 추가 (#61)
RosieOh Aug 4, 2026
ff2bb52
TEST : 공공데이터 공급자 및 시도 코드 테스트 추가 (#61)
RosieOh Aug 4, 2026
07d823c
FIX : 존재하지 않는 Trivy 액션 버전 교정 (#59)
RosieOh Aug 4, 2026
9bcd022
Merge branch 'feat/RosieOh' into feat/public-data-integration
RosieOh Aug 4, 2026
e863f7e
FEAT : XML 응답 파서 추가 및 응답 포맷 파라미터 중복 방지 (#61)
RosieOh Aug 4, 2026
d5f931b
FEAT : 병원 외부 식별자 컬럼 및 마이그레이션 V3 추가 (#61)
RosieOh Aug 4, 2026
eab1317
FEAT : 소아청소년과 병원 동기화 서비스 추가 (#61)
RosieOh Aug 4, 2026
8691562
FEAT : 병원 동기화 스케줄러 및 관리자 API 연결 (#61)
RosieOh Aug 4, 2026
fbf1294
TEST : XML 응답 파서 테스트 추가 (#61)
RosieOh Aug 4, 2026
3c721ff
STYLE : 여러 줄 주석을 한 줄로 정리 (#61)
RosieOh Aug 4, 2026
080266b
PERF : 시설 위치 검색 바운딩 박스 적용 및 잘못된 테이블 참조 수정 (#63)
RosieOh Aug 4, 2026
34b9931
FEAT : 시설 전문 검색 도입 및 LIKE 폴백 (#63)
RosieOh Aug 4, 2026
2e0f5b5
FEAT : 챗봇 근거 검색 다중 키워드 및 조사 제거 (#63)
RosieOh Aug 4, 2026
a32d8bc
FEAT : 자녀 월령·거주지 기반 정책 개인화 추천 (#63)
RosieOh Aug 4, 2026
5033362
FEAT : 요청별 쿼리 수 측정으로 N+1 관측 기반 마련 (#63)
RosieOh Aug 4, 2026
28f518d
FEAT : 공공데이터 지원대상 텍스트에서 대상 연령 개월 환산 (#63)
RosieOh Aug 4, 2026
5fd1e48
REFACTOR : 공공데이터 페이징 수집 공통화 및 매핑 불일치 조기 중단 (#63)
RosieOh Aug 4, 2026
c5ccb97
FEAT : 데이터셋 경로 외부화 및 절대 URL 리소스 지원 (#63)
RosieOh Aug 4, 2026
bdc79df
FEAT : 전국 유치원 표준데이터 동기화 추가 (#63)
RosieOh Aug 4, 2026
43960e4
DOCS : 이슈 라벨·타입·마일스톤 운영 가이드 추가 (#34)
RosieOh Aug 4, 2026
883b40b
Merge pull request #62 from CareCode-Repo/feat/public-data-integration
RosieOh Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 60 additions & 28 deletions .github/workflows/ci-cd.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,17 +43,18 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}

# 이 잡은 SSH 를 사용하지 않는다. PR 트리거에서도 도는 잡에
# 배포용 개인키를 올리지 않도록 SSH 설정을 두지 않는다.

- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

- name: Cache Gradle packages
uses: actions/cache@v4
with:
Expand All@@ -66,10 +67,18 @@ jobs:

- name: Run tests
run: ./gradlew clean test jacocoTestReport


- name: Publish test report
uses: mikepenz/action-junit-report@v5
if: always()
with:
report_paths: 'build/test-results/test/TEST-*.xml'

# build 에서 -x test 로 테스트를 빼면 check 에 걸어둔 커버리지 검증까지 함께 무력화된다.
# 위에서 이미 test 를 돌렸으므로 여기서는 검증을 포함해 그대로 build 한다.
- name: Build application
run: ./gradlew build -x test
run: ./gradlew build

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
Expand All@@ -89,25 +98,35 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}

- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.30.0
# 이 잡도 SSH 를 사용하지 않으므로 배포용 개인키를 로드하지 않는다.

# 1) SARIF 리포트 생성 (Security 탭 업로드용) - 여기서는 실패시키지 않는다.
- name: Run Trivy vulnerability scanner (report)
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'

- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'

# 2) 게이트: CRITICAL/HIGH 취약점이 있으면 파이프라인을 실패시킨다.
# exit-code 를 지정하지 않으면 취약점이 나와도 그냥 통과해 스캔이 장식이 된다.
- name: Fail on CRITICAL/HIGH vulnerabilities
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'table'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '1'

# ===========================================
# Build Docker Image Job
# ===========================================
Expand DownExpand Up@@ -208,35 +227,48 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

# blue/green 두 컨테이너는 서로 다른 포트를 점유해야 한다.
# 둘 다 8082 를 쓰면 동시에 뜰 수 없어 무중단 전환 자체가 성립하지 않는다.
- name: Determine deployment strategy
id: strategy
run: |
CURRENT_ENV=$(curl -fsS "${{ secrets.PRODUCTION_ROUTER_STATUS_URL }}" || echo "blue")
if [ "$CURRENT_ENV" = "blue" ]; then
echo "target-env=green" >> $GITHUB_OUTPUT
echo "current-env=blue" >> $GITHUB_OUTPUT
echo "target-env=green" >> $GITHUB_OUTPUT
echo "current-env=blue" >> $GITHUB_OUTPUT
echo "target-port=8083" >> $GITHUB_OUTPUT
else
echo "target-env=blue" >> $GITHUB_OUTPUT
echo "target-env=blue" >> $GITHUB_OUTPUT
echo "current-env=green" >> $GITHUB_OUTPUT
echo "target-port=8082" >> $GITHUB_OUTPUT
fi

- name: Deploy to target environment
run: |
test -n "${{ secrets.PRODUCTION_DEPLOY_HOST }}"
TARGET="${{ steps.strategy.outputs.target-env }}"
PORT="${{ steps.strategy.outputs.target-port }}"
ssh -o StrictHostKeyChecking=no ${{ secrets.PRODUCTION_DEPLOY_USER }}@${{ secrets.PRODUCTION_DEPLOY_HOST }} \
"docker pull ${{ needs.build-docker.outputs.image-tag }} && docker stop carecode-${{ steps.strategy.outputs.target-env }} || true && docker rm carecode-${{ steps.strategy.outputs.target-env }} || true && docker run -d --name carecode-${{ steps.strategy.outputs.target-env }} -p 8082:8082 --env-file /opt/carecode/.env ${{ needs.build-docker.outputs.image-tag }}"

- name: Wait for deployment to be ready
"docker pull ${{ needs.build-docker.outputs.image-tag }} \
&& (docker stop carecode-$TARGET || true) \
&& (docker rm carecode-$TARGET || true) \
&& docker run -d --name carecode-$TARGET -p $PORT:8082 --env-file /opt/carecode/.env ${{ needs.build-docker.outputs.image-tag }}"

# 전환 전에는 아직 라우팅되지 않는 대기(target) 인스턴스를 직접 확인해야 한다.
# 공용 헬스 URL 을 보면 구버전이 UP 이라 항상 통과해버린다.
- name: Wait for target instance to be ready
run: |
test -n "$PRODUCTION_HEALTH_URL"
test -n "${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }}"
PORT="${{ steps.strategy.outputs.target-port }}"
TARGET_HEALTH_URL=$(echo "${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }}" | sed "s/{port}/$PORT/")
for i in {1..20}; do
if curl -fsS "$PRODUCTION_HEALTH_URL/actuator/health" | grep -q '"status":"UP"'; then
echo "Production target is healthy"
if curl -fsS "$TARGET_HEALTH_URL/actuator/health" | grep -q '"status":"UP"'; then
echo "Production target ($PORT) is healthy"
exit 0
fi
sleep 5
done
echo "Production health check failed"
echo "Production target health check failed"
exit 1

- name: Switch traffic to new environment
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -491,7 +491,7 @@ docker-compose up carecode-mariadb carecode-redis -d
| 정책 | `/policies/*` | 정책 조회, 검색 |
| 알림 | `/notifications/*` | 알림 조회, 설정 |
| 챗봇 | `/chatbot/*` | 챗봇 대화 |
| 관리자 | `/admin/*` | 관리자 기능 |
| 관리자 | `/api/admin/*` | 관리자 기능 (JWT + `ROLE_ADMIN` 필요) |

### 인증

Expand Down
29 changes: 28 additions & 1 deletion build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,12 @@ dependencies {
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// FCM 푸시 알림 (서비스 계정 자격증명이 없으면 런타임에 비활성화됨)
implementation 'com.google.firebase:firebase-admin:9.4.1'

// Claude API (챗봇). API 키가 없으면 룰 기반 응답으로 폴백한다.
implementation 'com.anthropic:anthropic-java:2.34.0'

// Logging - JSON 형식 로깅 지원
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'

Expand DownExpand Up@@ -99,13 +105,34 @@ tasks.named('check') {
}

tasks.named('jacocoTestCoverageVerification') {
dependsOn tasks.named('test')

violationRules {
// 1) 전체 커버리지: 후퇴 방지선(ratchet).
// 현재 라인 커버리지는 약 16% 다. 테스트를 늘릴 때마다 이 값을 함께 올린다.
// (기존 0.01 은 사실상 게이트가 없는 것과 같아 통과해도 의미가 없었다.)
rule {
element = 'BUNDLE'
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.01
minimum = 0.15
}
}

// 2) 보안·인증 핵심 클래스는 별도의 높은 기준을 적용한다.
// 이 경로들이 테스트 없이 수정되면 빌드가 실패해야 한다.
rule {
element = 'CLASS'
includes = [
'com.carecode.domain.user.service.JwtService',
'com.carecode.core.util.ClientIpResolver',
'com.carecode.core.util.PageRequestUtil'
]
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.60
}
}
}
Expand Down
109 changes: 109 additions & 0 deletions docs/ISSUE_MANAGEMENT.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
# 이슈 운영 가이드

이슈가 늘어나도 분류 기준이 흔들리지 않도록 라벨·타입·마일스톤·관계의 사용 규칙을 고정한다.

## 제목

`.github/ISSUE_TEMPLATE` 규칙을 따른다.

```
[ 영역 ] 내용
```

- 대괄호 안에 **공백**을 넣는다. `[ Auth ]` (O), `[Auth]` (X)
- 영역은 도메인 또는 관심사. `Core` `Auth` `Infra` `Booking` `Notification` `Child` `Policy` `Search` `PublicData` `CI`
- 커밋 메시지 규칙(`TYPE : 한글 설명 (#이슈번호)`)과는 별개다. 섞지 않는다.

## Type (GitHub Issue Type)

한 이슈에 하나만 붙는다. 라벨 `type:*` 과 중복돼 보이지만, Type 은 **GitHub 전역 집계·필터**에 쓰이고 `type:*` 라벨은 저장소 안에서 더 세분화된 성격을 나타낸다.

| Type | 기준 |
|---|---|
| `Bug` | 지금 동작이 잘못됐다. 기동 실패, 잘못된 결과, 보안 결함, 설정이 효력 없음 |
| `Feature` | 없던 것을 만든다 |
| `Task` | 동작은 그대로인데 주변을 정비한다. 테스트, CI, 문서, 리팩토링, 트래킹 |

성능 결함은 **의도한 결과가 나오지 않는 것**이면 `Bug`, 단순히 더 빠르게 만드는 것이면 `Task` 다.

## 라벨

### priority

실행 순서를 정하는 유일한 기준이다. 담당자가 다음에 뭘 할지 고를 때 이것만 본다.

| 라벨 | 기준 | 대응 |
|---|---|---|
| `priority:P0` | 서비스가 지금 망가져 있다. 기동 불가, 인가 우회, 데이터 유출 | 즉시, 다른 작업 중단 |
| `priority:P1` | 사용자가 체감하는 오류이거나 배포를 막는다 | 현재 스프린트 안 |
| `priority:P2` | 개선하면 좋지만 미루어도 손해가 누적되지 않는다 | 여유 있을 때 |

판단이 갈리면 **"내일 아침 배포를 막을 일인가"** 를 묻는다. 막으면 P0/P1.

### domain

건드리는 코드의 위치. 한 이슈가 여러 도메인에 걸치면 **가장 무게가 실린 쪽 하나**만 붙인다.

| 라벨 | 범위 |
|---|---|
| `domain:core` | 공통 유틸, 보안 필터, 전역 예외, 설정 클래스 |
| `domain:api` | 컨트롤러 계약, 요청/응답 스키마, 페이징 |
| `domain:infra` | 빌드, CI/CD, 배포, Docker, 마이그레이션 실행 |
| `domain:data` | 시드 데이터, 외부 데이터 연동, 마이그레이션 내용 |
| `domain:health` | 건강 기록, 예방접종, 성장, 병원 |
| `domain:search` | 검색, 추천, 랭킹 |

### type

Type 필드를 세분화한다. 복수 부착 가능.

`type:bug` `type:feature` `type:perf` `type:security` `type:test` `type:tech-debt`

`type:security` 는 항상 `priority:P0` 또는 `P1` 이다. 보안 결함에 P2 를 붙이지 않는다.

## 마일스톤

**언제 나가야 하는가**로 나눈다. 크기나 난이도가 아니다.

| 마일스톤 | 기준 |
|---|---|
| `Hotfix` | 다음 배포를 기다릴 수 없다. P0 는 여기로 |
| `API Stabilization` | 클라이언트가 붙기 전에 계약이 확정돼야 한다 |
| `Refactor Sprint` | 동작을 바꾸지 않고 구조를 정리한다 |
| `Production Readiness` | 운영에 올리기 전에 갖춰야 한다. 관측, 배포, 테스트, 데이터 |

## Relationships (하위 이슈)

여러 이슈에 걸친 작업은 **상위 트래킹 이슈**를 만들고 하위 이슈로 연결한다. 본문에 번호를 나열만 하지 말고 실제 sub-issue 로 등록한다 — 진행률이 자동 집계된다.

- 상위 이슈는 `Task` 타입
- 상위 이슈 본문의 마지막 To Do 는 항상 `PR 머지 후 본 이슈 종료`
- 하위 이슈는 **독립적으로 닫을 수 있는 단위**여야 한다. 나눌 수 없으면 하나로 둔다

예: #40 (검토 제안 트래킹) ← #41~#52 / #64 (백엔드 전수 점검) ← #53~#59, #61, #63

## 신규 이슈 체크리스트

- [ ] 제목이 `[ 영역 ] 내용` 형식인가
- [ ] Type 을 지정했는가
- [ ] `priority:*` 를 붙였는가 (없으면 실행 순서에서 누락된다)
- [ ] `domain:*` 을 하나 붙였는가
- [ ] 마일스톤을 배정했는가
- [ ] 프로젝트 보드에 올렸는가
- [ ] 상위 작업의 일부라면 sub-issue 로 연결했는가
- [ ] To Do 가 **검증 가능한 단위**로 쪼개져 있는가

## 종료 기준

- 계획한 To Do 가 전부 체크됐고 코드가 기본 브랜치에 올라갔을 때 닫는다
- PR 로 해소되는 이슈는 PR 본문에 `Closes #번호` 를 넣어 **머지 시 자동으로 닫히게** 한다. 수동으로 미리 닫지 않는다
- **부분 완료는 닫지 않는다.** 완료된 To Do 를 체크하고 남은 범위를 코멘트로 명시한 뒤 열어 둔다
- 다른 이슈가 같은 범위를 완전히 흡수했으면 `duplicate` 사유로 닫고 흡수한 이슈를 링크한다

## 정기 점검

월 1회 다음을 확인한다.

- Type/`priority:*`/마일스톤이 비어 있는 열린 이슈
- 90일 이상 갱신 없는 이슈 — 여전히 유효한지, 아니면 `not planned` 로 닫을지
- 상위 트래킹 이슈의 하위 진행률과 본문 To Do 가 어긋나지 않는지
44 changes: 17 additions & 27 deletions src/main/java/com/carecode/core/RateLimitInterceptor.java
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
package com.carecode.core;

import com.carecode.core.util.ClientIpResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
Expand All@@ -29,6 +31,7 @@
public class RateLimitInterceptor implements HandlerInterceptor {

private final StringRedisTemplate redisTemplate;
private final ClientIpResolver clientIpResolver;

private static final String RATE_LIMIT_KEY_PREFIX = "ratelimit:";
private static final int AUTHENTICATED_LIMIT = 300; // 인증 사용자 (userId 기준)
Expand DownExpand Up@@ -57,14 +60,19 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons

private boolean checkLimit(String keyBody, int limit, HttpServletResponse response) throws Exception {
String key = RATE_LIMIT_KEY_PREFIX + keyBody;
Long currentCount = redisTemplate.opsForValue().increment(key);

if (currentCount != null && currentCount == 1) {
redisTemplate.expire(key, WINDOW_DURATION);
long count;
try {
Long currentCount = redisTemplate.opsForValue().increment(key);
if (currentCount != null && currentCount == 1) {
redisTemplate.expire(key, WINDOW_DURATION);
}
count = currentCount != null ? currentCount : 0;
} catch (DataAccessException e) {
// Redis 장애가 전체 API 중단으로 번지지 않도록 fail-open 한다.
log.error("Rate limit 확인 실패 - Redis 장애로 제한을 건너뜁니다. key={}", key, e);
return true;
}

long count = currentCount != null ? currentCount : 0;

if (count > limit) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType("application/json;charset=UTF-8");
Expand DownExpand Up@@ -92,29 +100,11 @@ private String resolveUserId() {
}

/**
* 클라이언트 IP 추출 (프록시 고려)
* 클라이언트 IP 추출.
* 프록시 헤더 신뢰 여부는 {@link ClientIpResolver} 가 설정에 따라 판단한다.
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
return clientIpResolver.resolve(request);
}

/**
Expand Down
Loading
Loading