Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ services:

SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
REDIS_CONNECT_TIMEOUT: ${REDIS_CONNECT_TIMEOUT:-200ms}
REDIS_COMMAND_TIMEOUT: ${REDIS_COMMAND_TIMEOUT:-200ms}

CACHE_ENABLED: ${CACHE_ENABLED:-true}

Expand Down
12 changes: 7 additions & 5 deletions docs/01-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이
| FR-004 | 서로 다른 URL에 동일한 단축 코드를 할당하지 않는다. | Must |
| FR-005 | 유효하지 않은 URL 입력에는 400 응답을 반환한다. | Must |
| FR-006 | 존재하지 않는 단축 코드에는 404 응답을 반환한다. | Must |
| FR-007 | Hash, 순차 ID 기반 Base62, 난수 Base62 생성 방식을 비교한다. | Should |
| FR-007 | Sequence, Hash, Snowflake 기반 Base62 생성 방식을 비교한다. | Should |
| FR-008 | Redis를 적용해 반복적인 원본 URL 조회를 캐시한다. | Should |
| FR-009 | Bloom Filter 또는 Negative Cache로 잘못된 코드의 반복 조회를 줄인다. | Could |
| FR-010 | 사용자가 생성된 URL을 수정하거나 삭제하는 기능은 제공하지 않는다. | Won't |
Expand All @@ -70,9 +70,11 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이

### 가용성

* Redis 장애 시 MySQL 조회로 우회한다.
* 캐시 데이터가 유실되어도 원본 URL을 복구할 수 있어야 한다.
* 초기 구조의 단일 장애 지점은 Spring Boot, MySQL, Redis이다.
- Redis 조회 실패 시 MySQL 원본 저장소로 Fallback한다.
- Redis 장애 중에도 리다이렉트 요청의 오류율을 1% 미만으로 유지한다.
- Redis 복구 후 Cache Aside 조회 경로로 자동 복귀한다.
- 캐시 데이터가 유실돼도 MySQL을 통해 원본 URL을 복구할 수 있어야 한다.
- 현재 구조의 단일 장애 지점은 Spring Boot, MySQL, Redis이다.

### 확장성

Expand Down Expand Up @@ -109,7 +111,7 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이
* Redis Cache-Aside
* k6 부하 테스트
* 개선 전후 동일 조건 재측정
* Redis 장애 및 Hot Key 실험
* Redis 장애 시 MySQL Fallback 실험

### 제외

Expand Down
94 changes: 48 additions & 46 deletions docs/03-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,33 @@
* 가장 중요한 비기능 요구사항: 단축 코드 유일성, 읽기 성능, 확장 가능성
* 설계에서 우선한 요소: 단순한 초기 구조와 측정 가능한 베이스라인
* 감수한 트레이드오프: 초기에는 모든 조회 요청이 MySQL에 집중된다.
* 초기 코드 생성 전략: 순차 ID 기반 Base62
* 최종 코드 생성 전략: Hash 충돌 해소 및 난수 Base62 방식과 비교한 뒤 결정한다.
* 초기 코드 생성 전략: Sequence ID + Base62
* 최종 설계: 단일 인스턴스에서는 Sequence를 기본으로 사용하고, 다중 인스턴스에서는 Snowflake 적용을 검토한다.

## 2. 전체 아키텍처

```mermaid
flowchart LR
Client[Client]
API[Spring Boot API]
Redis[(Redis)]
DB[(MySQL)]
Metrics[Prometheus]
Dashboard[Grafana]
LoadTest[k6]
Prometheus[Prometheus]
Grafana[Grafana]
k6[k6]

Client --> API
API --> Redis
API --> DB
Metrics --> API
Dashboard --> Metrics
LoadTest --> API
Prometheus --> API
Grafana --> Prometheus
k6 --> API
```

초기 구조에는 Redis와 Message Broker를 포함하지 않는다.
MySQL은 원본 데이터를 보관하는 Source of Truth이며,
Redis는 리다이렉트 조회 성능을 위한 보조 저장소로 사용한다.

Redis Cache Miss 또는 연결 실패 시 MySQL을 조회한다.

```text
요구사항 정의
Expand All @@ -47,6 +52,7 @@ flowchart LR
| Prometheus | 애플리케이션 지표 수집 | 현재는 단일 인스턴스 | 성능 지표 수집 불가 |
| Grafana | 성능 지표 시각화 | 현재는 단일 인스턴스 | 대시보드 조회 불가 |
| k6 | 부하 테스트 실행 | VU 단계적 증가 | 서비스에는 영향 없음 |
| Redis | 원본 URL 조회 캐시 | Sentinel, Cluster | 장애 시 MySQL Fallback으로 지연 증가 |

## 4. 요청 흐름

Expand All @@ -60,24 +66,26 @@ flowchart LR

### URL 리다이렉트

1. 클라이언트가 단축 코드로 요청한다.
2. API 서버가 Base62 코드를 숫자 ID로 변환한다.
3. MySQL에서 ID를 이용해 원본 URL을 조회한다.
4. 원본 URL을 `302 Found`로 반환한다.
1. `shortCode`로 Redis를 조회한다.
2. Cache Hit이면 원본 URL을 반환한다.
3. Cache Miss이면 MySQL의 `short_code` 인덱스로 조회하고 Redis에 저장한다.
4. Redis 조회에 실패하면 MySQL로 Fallback한다.
5. 원본 URL을 `302 Found`로 반환한다.

### 실패 흐름

1. URL 형식이 잘못된 경우 `400 Bad Request`를 반환한다.
2. 단축 코드 형식이 잘못됐거나 데이터를 찾을 수 없으면 `404 Not Found`를 반환한다.
3. DB 연결에 실패하면 `503 Service Unavailable`을 반환한다.
3. DB 연결에 실패하면 `503 Service Unavailable`을 반환한다.
4. Redis 연결에 실패하면 MySQL 조회로 전환한다.

## 5. 데이터 모델

### 주요 엔티티

| 엔티티 | 주요 필드 | 설명 |
| -------- | ---------------------- | -------------------- |
| ShortUrl | id, longUrl, createdAt | 원본 URL과 생성 정보를 저장한다. |
| 엔티티 | 주요 필드 | 설명 |
|---|---|---|
| ShortUrl | id, shortCode, longUrl, createdAt | 단축 코드와 원본 URL을 저장한다. |

### 관계

Expand All @@ -87,27 +95,23 @@ flowchart LR
erDiagram
SHORT_URL {
BIGINT id PK
VARCHAR short_code UK
VARCHAR long_url
DATETIME created_at
}
```

초기 구조에서는 단축 코드를 별도 컬럼에 저장하지 않는다.

```text
shortCode = Base62(id)
```

Redirect 요청에서는 단축 코드를 다시 ID로 변환해 Primary Key로 조회한다.
모든 생성 전략이 동일한 조회 경로를 사용하도록 `short_code`에 Unique Index를 적용했다.
Base62의 대소문자를 구분하기 위해 `ascii_bin` Collation을 사용한다.

Hash와 난수 Base62 방식에서는 생성된 코드를 저장해야 하므로 이후 `short_code` 컬럼과 Unique Index를 사용하는 별도 구조를 적용한다.

## 6. API 설계

| Method | Endpoint | 설명 | 멱등성 |
| ------ | -------------- | ----------------- | --- |
| POST | `/api/v1/urls` | 긴 URL을 단축 URL로 변환 | No |
| GET | `/{shortCode}` | 원본 URL로 리다이렉트 | Yes |
| POST | `/api/v1/data/shorten` | 긴 URL을 단축 URL로 변환 | 전략에 따라 다름 |
| GET | `/api/v1/{shortCode}` | 원본 URL로 리다이렉트 | Yes |

동일한 원본 URL을 여러 번 요청하면 서로 다른 단축 URL이 생성될 수 있다.

Expand Down Expand Up @@ -189,20 +193,9 @@ Redirect 요청이 계속 서버에 전달되므로 301보다 서버 부하가

| 전략 | 생성 방식 | 주요 확인 항목 |
| --------------- | ------------------------------ | --------------------- |
| Sequence Base62 | Auto Increment ID를 Base62로 변환 | 처리량, 조회 성능, 예측 가능성 |
| Hash 충돌 해소 | URL Hash를 Base62로 표현하고 7자리로 제한 | 충돌 수, DB 조회 수, 재시도 횟수 |
| Random Base62 | 난수 기반 고정 길이 코드 생성 | 충돌 수, 확장성, 예측 가능성 |

Hash 방식은 다음 순서로 동작한다.

```text
원본 URL
→ SHA-256 Hash
→ Hash 결과를 Base62로 표현
→ 7자리 코드 생성
→ 중복 확인
→ 충돌 시 다시 생성
```
| Sequence + Base62 | INSERT → ID 발급 → UPDATE | 단순하고 충돌 없음 |
| Hash + Base62 | 중복 조회 → SHA-256 → INSERT | 고정 길이, 충돌 재시도 필요 |
| Snowflake + Base62 | 분산 ID 생성 → INSERT | DB ID 비의존, nodeId 관리 필요 |

초기 구현은 Sequence Base62로 진행하고, 이후 세 방식의 RPS, p95, DB 조회 수와 충돌 횟수를 비교한다.

Expand All @@ -224,12 +217,19 @@ Hash와 난수 방식에서는 `short_code`에 Unique Constraint를 적용해
| DB 장애 | URL 생성 및 조회 불가 | Connection 오류, Actuator | 503 반환, DB 복구 |
| Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 컨테이너 재시작 |
| Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 컨테이너 재시작 |
| Redis 장애 | 응답 지연 및 DB 부하 증가 | Cache Error, Fallback 지표 | MySQL Fallback 후 자동 복귀 |

초기 구조에서는 DB 장애 시 요청을 처리할 대체 저장소가 없다.

Redis GET에 실패하면 MySQL로 Fallback한다.

Redis 장애가 확인된 요청에서는 Redis SET을 생략해 Timeout이 중복되지 않도록 했다.

이는 기능 지속을 위한 Graceful Degradation이며 Redis 자체의 고가용성을 구성한 것은 아니다.

## 11. 단일 장애 지점

* 현재 존재하는 SPOF: Spring Boot 단일 인스턴스, MySQL 단일 인스턴스
* 현재 존재하는 SPOF: Spring Boot 단일 인스턴스, MySQL, Redis
* 프로젝트 범위에서 허용한 이유: 로컬 환경에서 초기 구조의 병목을 확인하기 위한 실험이기 때문이다.
* 운영 환경에서의 개선 방법: Load Balancer, 다중 API 서버, MySQL Replica와 장애 조치 구성

Expand All @@ -251,8 +251,6 @@ Prometheus와 Grafana도 단일 인스턴스지만 서비스 요청 처리에는

### 캐시 확장

초기 구조에서는 캐시를 사용하지 않는다.

부하 테스트를 통해 DB 조회 병목이 확인되면 Redis Cache-Aside를 적용한다.

```text
Expand All @@ -262,9 +260,11 @@ Client
→ Cache Miss 시 MySQL
```

* 캐시 키: `short-url:{shortCode}`
* 만료 정책: TTL 기반
* Cache Stampede 대응: TTL Jitter 또는 요청 병합
- 전략: Redis Cache Aside
- 키: `short-url:{shortCode}`
- TTL: 1시간
- Redis 장애: MySQL Fallback
- 한계: 장애 중 DB 부하와 응답 지연 증가

## 13. 보안

Expand Down Expand Up @@ -293,6 +293,8 @@ Client
* HikariCP Pending Connection
* 코드 충돌 횟수
* 코드 생성 재시도 횟수
* Redis Cache Error 수
* MySQL Fallback 수

### Logs

Expand Down
53 changes: 51 additions & 2 deletions docs/04-experiment.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,8 +503,51 @@ DB Auto Increment 의존성이 없는 Snowflake 방식을 적용할 수 있다.
단, 전략별 한 번만 측정했으며 Sequence와 Snowflake의 차이가 작으므로
이번 결과만으로 Snowflake의 성능 우위를 일반화할 수는 없다.

## 17. Redis 장애 시 MySQL Fallback

## 17. 실험 한계
Redis 장애가 리다이렉트 전체 장애로 이어지지 않도록
Redis GET 실패 시 MySQL로 전환하는 Fallback을 적용했다.

| 항목 | 조건 |
|---|---|
| VU | 100 |
| 실행 시간 | 120초 |
| 정상 | 0~30초 |
| Redis 중지 | 30~60초 |
| 복구 관찰 | 60~120초 |
| Redis Timeout | 200ms |
| HikariCP | 최대 10개 |

### 결과

| 지표 | 결과 |
|---|---:|
| 요청 수 | 656,623 |
| 평균 RPS | 5,471.30 |
| 평균 응답 시간 | 18.09ms |
| 전체 p95 | 31.92ms |
| 최대 응답 시간 | 345.44ms |
| 실패율 | 0% |

Redis 장애 구간에는 GET Error, MySQL Fallback과 DB Lookup이 함께 증가했다.
장애 구간의 p95는 약 200ms, p99는 약 220ms까지 증가했지만
5xx 오류는 발생하지 않았다.

Redis 복구 후 Error, Fallback과 DB Lookup은 다시 0으로 감소했고,
Cache Hit와 응답시간도 정상 수준으로 복귀했다.

이를 통해 Redis 장애 시 성능 저하를 감수하면서 기능을 유지하고,
Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다.

현재는 모든 요청이 Redis Timeout을 기다린 뒤 Fallback하므로,
향후 Circuit Breaker를 적용하면 장애 구간의 반복 대기를 줄일 수 있다.

![Redis 장애 성능 지표](images/redis-fallback-100vu-performance.png)

![Redis 장애 캐시 및 DB 지표](images/redis-fallback-100vu-cache-db.png)


## 18. 실험 한계

- 로컬 Docker 환경에서 실행했다.
- k6, 애플리케이션, MySQL, Redis가 같은 장비의 자원을 사용했다.
Expand All @@ -517,8 +560,10 @@ DB Auto Increment 의존성이 없는 Snowflake 방식을 적용할 수 있다.
- Redis Stress Test의 처리량 한계가 애플리케이션, Redis 또는 로컬 환경 중 어디에서 발생했는지는 추가로 분리하지 않았다.
- 단축 코드 생성 전략도 조건별 한 번만 측정해 Sequence와 Snowflake의 작은 차이가 실행 환경의 변동인지 확인하지 못했다.
- Snowflake 전략은 단일 애플리케이션 인스턴스에서만 실행했으며, 서로 다른 nodeId를 사용하는 다중 인스턴스 환경은 검증하지 않았다.
- Redis 장애 실험은 프로세스 중지만 재현했으며 네트워크 지연과 패킷 손실은 검증하지 않았다.
- Redis 장애 중 더 높은 부하에서는 MySQL과 커넥션 풀이 포화될 수 있다.

## 18. 후속 실험
## 19. 후속 실험

- [x] Redis Cache Aside 적용
- [x] Redis 적용 전후 부하 테스트
Expand All @@ -528,3 +573,7 @@ DB Auto Increment 의존성이 없는 Snowflake 방식을 적용할 수 있다.
- [x] HikariCP Pool 크기 비교
- [x] 더 높은 VU로 Stress Test 수행
- [x] Sequence ID + Base62, Hash, Snowflake ID + Base62 비교
- [x] Redis 장애 시 MySQL Fallback 및 자동 복구 검증
- [ ] Circuit Breaker를 통한 Redis 장애 구간 Timeout 감소
- [ ] Redis Sentinel 또는 Cluster 기반 고가용성 구성
- [ ] 다중 애플리케이션 인스턴스와 장애 전환 검증
Binary file added docs/images/redis-fallback-100vu-cache-db.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/redis-fallback-100vu-performance.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/redis-fallback-run1-cache-db.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/redis-fallback-run1-performance.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions k6/redis-fallback-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import http from 'k6/http';
import { check } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
const SHORT_CODE = __ENV.SHORT_CODE;

if (!SHORT_CODE) {
throw new Error('SHORT_CODE 환경변수가 필요합니다.');
}

export const options = {
vus: Number(__ENV.VUS || 100),
duration: __ENV.DURATION || '90s',

thresholds: {
http_req_failed: ['rate<0.01'],
checks: ['rate>0.99'],
http_req_duration: ['p(95)<1000'],
},
};

export default function () {
const response = http.get(
`${BASE_URL}/api/v1/${SHORT_CODE}`,
{
redirects: 0,
tags: {
experiment: 'redis-fallback',
},
},
);

check(response, {
'redirect status is 302': (res) => res.status === 302,
'location header exists': (res) =>
typeof res.headers.Location === 'string'
&& res.headers.Location.length > 0,
});
}
Loading