Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ DB_RUNTIME_PASSWORD=
DB_MIGRATION_USERNAME=
DB_MIGRATION_PASSWORD=

# PostgreSQL Runtime Connection의 SQL 실행·lock 대기 안전선입니다.
# 0으로 비활성화할 수 없으며 lock timeout은 statement timeout보다 짧아야 합니다.
DB_STATEMENT_TIMEOUT=30s
DB_LOCK_TIMEOUT=3s

# React 개발 서버 또는 배포 Client 주소를 쉼표로 구분합니다.
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173

Expand Down
8 changes: 8 additions & 0 deletions docs/development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export DB_RUNTIME_USERNAME='제한된 애플리케이션 계정'
export DB_RUNTIME_PASSWORD='로컬 Secret'
export DB_MIGRATION_USERNAME='Flyway 전용 계정'
export DB_MIGRATION_PASSWORD='로컬 Secret'
export DB_STATEMENT_TIMEOUT='30s'
export DB_LOCK_TIMEOUT='3s'
export SPRING_PROFILES_ACTIVE=dev
./gradlew bootRun
```
Expand All @@ -151,6 +153,11 @@ export SPRING_PROFILES_ACTIVE=dev
runtime 계정은 업무 DML, migration 계정은 Flyway 적용만 담당합니다. 실제
비밀번호·API Key·Token은 Git, Issue, Discussion과 로그에 올리지 않습니다.

Runtime Hikari Connection에는 기본 `statement_timeout=30s`, `lock_timeout=3s`가
적용됩니다. 값의 검증 규칙, 적용 경계, 운영 확인 및 rollback 절차는
[PostgreSQL Runtime Timeout 운영 가이드](reliability/postgresql-runtime-timeouts.md)를
확인합니다.

## 선택 사항: local Demo Seed

local H2에서만 사용할 사업장과 `user_account` 더미 계정 20명이 필요할 때
Expand Down Expand Up @@ -254,5 +261,6 @@ Client가 안전한 형식의 `X-Request-Id`를 보내면 Server가 응답과
- [Database 문서 사용법](database-documentation.md)
- [프로젝트 구조](project-structure.md)
- [Transactional Outbox 운영 가이드](reliability/transactional-outbox.md)
- [PostgreSQL Runtime Timeout 운영 가이드](reliability/postgresql-runtime-timeouts.md)
- [ADR 목록](adr/README.md)
- [PostgreSQL RLS 적용 가이드](database/postgresql-rls-rollout.md)
149 changes: 149 additions & 0 deletions docs/reliability/postgresql-runtime-timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# PostgreSQL Runtime Connection Timeout 운영 가이드

FOWOCO Server가 생성하는 PostgreSQL Runtime Hikari Connection에는 장시간 SQL과
lock 대기가 Connection Pool 전체 장애로 확산되지 않도록 Session 범위 안전선을
적용합니다.

## 설정

| 환경변수 | 기본값 | 의미 |
| --- | ---: | --- |
| `DB_STATEMENT_TIMEOUT` | `30s` | 개별 SQL의 최대 실행시간 |
| `DB_LOCK_TIMEOUT` | `3s` | row/table lock 획득 최대 대기시간 |

Spring Boot `Duration` 형식을 사용합니다. `30s`, `3000ms`, `PT30S`, `1000us`를
사용할 수 있으며 접미사가 없는 정수는 millisecond입니다. 운영에서는 혼동을
막기 위해 단위를 명시합니다.

두 값은 다음 조건을 모두 만족해야 합니다.

- 0보다 크고 정확한 정수 millisecond로 표현 가능
- `2147483647ms` 이하
- `DB_LOCK_TIMEOUT < DB_STATEMENT_TIMEOUT`

`1000us`는 정확히 `1ms`이므로 허용하지만 `1500us`는 millisecond 미만 remainder가
있어 시작 단계에서 거부합니다. `0`으로 guard를 비활성화할 수 없습니다.

## 지원하는 DataSource 구성

PostgreSQL mode에서는 `spring.datasource.url` 기반의 직접적인
`jdbc:postgresql:` URL과 HikariCP만 지원합니다. URL, username, password와 선택적인
driver는 `spring.datasource.*`를 단일 설정 원본으로 사용합니다.

다음 구성은 중복 설정 또는 물리 Connection 초기화 보장 상실을 막기 위해 시작
단계에서 거부합니다.

- JNDI DataSource
- Hikari namespace의 JDBC URL, username, password, driver 중복 설정
- Hikari `data-source-class-name`, `data-source-j-n-d-i`, `connection-init-sql`
- Hikari 이외의 명시적인 `spring.datasource.type`
- p6spy 등 wrapper/proxy JDBC URL
- `spring.datasource.connection-fetch=lazy`

향후 DataSource proxy나 decorator를 도입하면 물리 Connection 생성 시점의 Session
초기화가 유지되는지 다시 검토해야 합니다.

## 적용 경계

Runtime Pool은 새 물리 Connection을 등록하기 전에 다음과 같은 하나의 초기화 SQL을
실행합니다.

```sql
SELECT
pg_catalog.set_config('statement_timeout', '30000ms', false),
pg_catalog.set_config('lock_timeout', '3000ms', false)
```

환경변수 원문은 SQL에 들어가지 않으며 검증된 정수 millisecond만 사용합니다.
초기화 SQL이 실패한 Connection은 정상 Pool Connection으로 제공되지 않습니다.

- PostgreSQL Runtime Hikari Connection에만 적용
- Flyway 전용 Connection에는 미적용
- local/test H2에는 미적용
- 새 물리 Connection마다 한 번 실행
- transaction 또는 checkout마다 반복 실행하지 않음

## Session 변경 제한

HikariCP는 Connection 반환 시 PostgreSQL의 모든 임의 Session parameter를 reset하지
않습니다. Runtime production 코드에서 `SET statement_timeout`,
`SET lock_timeout` 또는 Session 범위 `set_config(..., false)`를 사용하면 다음
요청에 변경값이 남을 수 있으므로 금지합니다.

업무별 예외가 필요하면 별도 Issue에서 Spring Transaction 내부의 `SET LOCAL`만
사용합니다. `SET LOCAL`은 commit 또는 rollback 후 Session 기본값으로 돌아가야
합니다. 이번 구현에는 checkout interceptor나 Session reset proxy가 없습니다.

## Timeout 분류와 HTTP 계약

PostgreSQL의 SQLState만으로 원인을 확정하지 않습니다.

- `57014`와 canonical statement-timeout diagnostic이 함께 있으면 confirmed
statement timeout
- `55P03`과 canonical lock-timeout diagnostic이 함께 있으면 confirmed lock timeout
- SQLState만 일치하면 ambiguous cancellation 또는 lock failure
- 권한·RLS `42501`, bootstrap 인자 `22023`은 timeout이 아님

Confirmed timeout은 안전한 공개 오류 `503 SERVICE_TEMPORARILY_UNAVAILABLE`로
응답합니다. `Retry-After`는 제공하지 않습니다. 복구 시점을 신뢰할 수 없기
때문입니다. Ambiguous cancellation은 기존 `500 INTERNAL_SERVER_ERROR`를 유지합니다.

PostgreSQL `lc_messages`가 영어가 아니면 canonical diagnostic을 확인하지 못해 실제
timeout도 ambiguous로 처리될 수 있습니다. False positive를 막기 위한 보수적
정책이며 confirmed metric이 과소 집계될 수 있습니다.

Client와 Gateway는 모든 503을 자동 재시도하지 않습니다. GET처럼 본질적으로
idempotent한 요청만 exponential backoff와 jitter로 제한적으로 재시도할 수 있습니다.
POST·PATCH 등 변경 요청은 `Idempotency-Key`로 동일 요청이 보장될 때만 재시도하며,
그렇지 않으면 자동 재시도하지 않습니다.

## 로그와 Metric

Confirmed 및 ambiguous 분류 로그에는 `requestId`, HTTP method, 안전한 route pattern,
classification, SQLState, exception type만 기록합니다. Raw URI, SQL, bind parameter,
PostgreSQL diagnostic message, credential, 개인정보와 stack trace는 기록하지 않습니다.
상세 원인은 request ID와 발생 시각을 기준으로 PostgreSQL 서버 로그에서 조사합니다.

Confirmed HTTP timeout은 다음 low-cardinality counter에 집계합니다.

```text
fowoco.database.timeouts{type="statement"}
fowoco.database.timeouts{type="lock"}
```

Ambiguous cancellation과 Background Outbox failure는 이 metric 범위가 아닙니다.
DB Transaction과 metric 증가는 원자적이지 않으므로 사건당 exactly-once 집계를
보장하지 않습니다. Outbox의 retry, backoff, `maxAttempts`와 기존 metric 정책은
변경하지 않습니다.

## 설정 변경과 관측

```text
환경변수 변경
→ 애플리케이션 전체 재시작
→ 새 Runtime Pool 생성
→ current_setting 확인
→ metric과 정상 Query 관측
```

기존 물리 Connection에는 환경변수 변경이 즉시 반영되지 않습니다. Staging에서는
confirmed timeout 횟수, 정상 Query 실행시간, Pool 사용량과 Outbox 지연을 함께
확인합니다.

정상 workload가 반복해서 timeout되면 다음 순서로 대응합니다.

1. Query와 index 개선
2. Transaction 범위 축소
3. workload 분리
4. 후속 Issue에서 `SET LOCAL` override 검토
5. 승인 후 전역 기본값 조정

전역값 상향을 첫 대응으로 사용하지 않습니다.

## Rollback

문제가 발생하면 이전 애플리케이션 버전을 재배포하고 Runtime Pool 전체를
재생성한 뒤 Session 적용값을 확인합니다. `ALTER ROLE`, `ALTER DATABASE` 또는
timeout `0` 설정으로 우회하지 않습니다.

실제 배포 환경변수, Runtime/Flyway credential과 Secret 연결은 Issue #9의 범위입니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.fowoco.server.common.database;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;

@Component
public class DatabaseTimeoutMetrics {

private final Counter statementTimeouts;
private final Counter lockTimeouts;

public DatabaseTimeoutMetrics(MeterRegistry meterRegistry) {
statementTimeouts = timeoutCounter(meterRegistry, "statement");
lockTimeouts = timeoutCounter(meterRegistry, "lock");
}

public void recordConfirmed(DatabaseTimeoutType type) {
switch (type) {
case CONFIRMED_STATEMENT_TIMEOUT -> statementTimeouts.increment();
case CONFIRMED_LOCK_TIMEOUT -> lockTimeouts.increment();
default -> throw new IllegalArgumentException(
"Only confirmed database timeouts can be recorded"
);
}
}

private Counter timeoutCounter(MeterRegistry registry, String type) {
return Counter.builder("fowoco.database.timeouts")
.description("Confirmed PostgreSQL Runtime Connection timeouts")
.tag("type", type)
.register(registry);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.fowoco.server.common.database;

public enum DatabaseTimeoutType {
CONFIRMED_STATEMENT_TIMEOUT,
CONFIRMED_LOCK_TIMEOUT,
AMBIGUOUS_QUERY_CANCELED,
AMBIGUOUS_LOCK_NOT_AVAILABLE,
OTHER_DATABASE_FAILURE,
NOT_DATABASE_FAILURE
}
Loading
Loading