Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ DB_MIGRATION_PASSWORD=
# React 개발 서버 또는 배포 Client 주소를 쉼표로 구분합니다.
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173

# Transactional Outbox worker 설정입니다.
# 일반 실행에서는 켜 두며, 운영 장애 조사 중 자동 처리를 멈춰야 할 때만 false로 둡니다.
OUTBOX_ENABLED=true
OUTBOX_POLL_INTERVAL=1s
OUTBOX_BATCH_SIZE=20
OUTBOX_LEASE_DURATION=30s
OUTBOX_MAX_ATTEMPTS=8
OUTBOX_INITIAL_BACKOFF=1s
OUTBOX_MAX_BACKOFF=5m

# Workflow Catalog는 fowoco/knowledge release로부터 만든 Server용 read-only projection입니다.
# local/test는 저장소의 개발용 DRAFT projection을 사용합니다.
# prod에서는 RELEASED projection 파일 위치를 반드시 지정해야 합니다.
Expand Down
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 체류·계약·서류·신
| 기술 | Java 17, Spring Boot 4.1.0, Gradle |
| 구현 API | Health, Auth 5개, Task Workflow 7개, Approval·Audit 8개. 전체 계약은 실행 중인 Swagger에서 확인 |
| 계획 API | Wiki API 카탈로그와 관련 Issue에서 설계·추적 |
| 로컬 DB | H2 + Flyway Auth·Company·Worker core·Task core·Approval·Audit schema |
| 로컬 DB | H2 + Flyway Auth·Company·Worker·Task·Approval·Audit·Outbox schema |
| 개발·배포 DB | PostgreSQL + Flyway |
| 보안 | JWT Access Token, `ADMIN`·`HR`·`VIEWER` 역할, `company_id` 기반 ActorContext |
| 개발 기반 | Swagger UI, 공통 오류, `request_id`, CI 구성 완료 |
| 개발 기반 | Swagger UI, 공통 오류, `request_id`, CI와 Transactional Outbox 구성 완료 |
| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사와 AI Runtime 계약·방어 검증 구현. Remote 연동·AiRun은 후속 Issue |

계획 문서는 현재 동작하는 API가 아닙니다. 구현의 원본은 코드·테스트와 실행 시 생성되는 OpenAPI이고, 장기 아키텍처 결정은 [ADR](docs/adr/README.md), 계획 범위와 예시는 [API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification)와 Issue에서 확인합니다.
Expand Down Expand Up @@ -140,6 +140,33 @@ POST /api/v1/tasks/{taskId}/approval-requests
- 상태 변경, 승인 기록, 감사 이벤트는 같은 DB transaction에 기록되므로 중간 하나가 실패하면 함께 되돌아갑니다.
- `GET /api/v1/tasks/{taskId}/activities`는 화면용 안전 타임라인이고, `GET /api/v1/audit-events`는 ADMIN용 필터·cursor 조회입니다. 내부 snapshot 원문은 두 API에 노출하지 않습니다.

### 이벤트 유실 방지와 재처리

Task 생성·취소처럼 후속 처리가 필요한 변경은 업무 데이터와 `event_publication`을
하나의 DB transaction에 저장합니다. 따라서 서버가 commit 직후 종료되어도
이벤트가 사라지지 않습니다.

```text
업무 transaction
→ 업무 데이터 + event_publication 함께 commit
→ Outbox worker가 lease 획득
→ handler 실행 + event_consumption 기록
→ COMPLETED
```

- 일시적 실패는 지수 backoff 후 `RETRY_WAIT`에서 다시 처리합니다.
- 처리 중 서버가 종료되면 lease 만료 후 다른 서버가 이어받습니다.
- `(event_id, handler_name)` 완료 기록으로 이미 성공한 handler를 다시 실행하지 않습니다.
- 재시도 한도 초과, 잘못된 payload 같은 영구 실패는 버리지 않고
`REVIEW_REQUIRED`로 남깁니다.
- Event payload는 기능별 allow-list를 통과한 작은 업무값만 허용합니다. 이름·이메일,
전화번호, 여권번호, 토큰, 비밀번호, 전체 Prompt는 저장할 수 없습니다.

현재 Task 모듈은 `TaskCreated`, `TaskCancelled`를 발행합니다. 새 handler를 추가하는
방법, 장애 확인 SQL과 설정값은
[Transactional Outbox 운영 가이드](docs/reliability/transactional-outbox.md)를
확인합니다.

### AI Runtime 계약 기반

Server는 AI Runtime에 보낼 수 있는 field를 typed DTO로 제한하고, 전송 전과 응답 후에
Expand Down Expand Up @@ -204,6 +231,7 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 또는 배포 Secret의 12자 이상 값
| Flyway | H2는 공통 migration만, PostgreSQL은 공통 및 PostgreSQL 전용 migration을 순서대로 실행합니다. | `db/migration`, `db/migration-postgresql` |
| Workflow Catalog | Knowledge release의 Server용 read-only projection을 시작 시 검증합니다. | `workflow/` |
| AI Runtime 계약 | 외부 AI 요청·응답을 allow-list와 version으로 다시 검증합니다. | `aiintegration/` |
| Transactional Outbox | 업무 변경과 후속 이벤트를 함께 저장하고 lease·재시도·멱등 기록으로 복구합니다. | `reliability/` |
| Security | JWT에서 ActorContext와 역할을 만들고 VIEWER의 쓰기 요청을 기본 차단합니다. | `SecurityConfig` |
| Swagger | Controller의 API 설명을 브라우저 문서로 보여줍니다. | `OpenApiConfig` |
| 공통 오류 | 모든 실패를 같은 JSON 구조로 반환합니다. | `common/error` |
Expand Down Expand Up @@ -331,7 +359,8 @@ server/
│ │ ├── V3__create_worker_document.sql # Worker·Document metadata
│ │ ├── V4__create_task_workflow_core.sql # Task·Checklist·전이 이력
│ │ ├── V5__create_approval_audit.sql # 승인·제출·증빙·감사
│ │ └── V6__add_user_display_name.sql # 회원가입 담당자 표시 이름
│ │ ├── V6__add_user_display_name.sql # 회원가입 담당자 표시 이름
│ │ └── V7__create_event_outbox.sql # 내구성 이벤트·handler 완료 기록
│ └── migration-postgresql/ # RLS 등 PostgreSQL 전용 migration
└── test/
└── java/com/fowoco/server/
Expand All @@ -340,7 +369,8 @@ server/
├── worker/
├── task/
├── aiintegration/
└── airun/
├── airun/
└── reliability/
```

기능 코드가 생기면 해당 기능 안에서 다음 방향으로 확장합니다.
Expand Down
6 changes: 5 additions & 1 deletion docs/adr/0003-task-airun-event-and-retry-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,11 @@ allow-list payload
- 영구 실패를 성공으로 바꾸지 않고 운영 검토 대상으로 남깁니다.
- Event에는 JWT, Worker Link 원본 token, 민감 식별정보, 전체 Prompt를 넣지 않습니다.

MVP는 `DomainEventPublisher` Port 뒤의 **PostgreSQL-backed durable publication**을 사용합니다. #25의 작은 spike에서 Spring Modulith Event Publication Registry와 Spring Boot 4.1 호환성을 확인하고, 적합하면 해당 adapter를 사용하며 부적합하면 Transactional Outbox adapter를 구현합니다. Adapter 선택 때문에 Domain 코드를 바꾸지 않으며 Kafka·RabbitMQ를 필수로 도입하지 않습니다.
MVP는 `DomainEventPublisher` Port 뒤의 **PostgreSQL-backed durable publication**을
사용합니다. #25 구현에서는 현재 모듈 경계와 Spring Boot 4.1 호환성을 유지하면서
lease, 실패 분류, 지수 backoff, handler별 멱등 완료 기록을 명시적으로 통제할 수 있는
Transactional Outbox adapter를 선택했습니다. Adapter 선택 때문에 Domain 코드를
바꾸지 않으며 Kafka·RabbitMQ를 필수로 도입하지 않습니다.

대표 event 이름은 과거형의 versioned business fact로 작성합니다.

Expand Down
13 changes: 12 additions & 1 deletion docs/database/postgresql-rls-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,23 @@ RLS는 기존 `ActorContext`, Repository의 `company_id` 조건, tenant-aware DB
transaction-local tenant context와 connection pool 비누수 테스트만 준비합니다.
아직 policy를 만들거나 RLS를 활성화하지 않습니다.

현재 `main`의 V1~V6에는 아래 12개 tenant table이 존재합니다. 기반 단계의 제한
현재 `main`의 V1~V7에는 아래 14개 tenant table이 존재합니다. 기반 단계의 제한
role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`·
`REFERENCES` 권한과 RLS 우회 권한이 없음을 확인합니다.

- `company`, `user_account`, `refresh_token`
- `worker`, `worker_document`
- `task`, `task_checklist_item`, `task_transition_history`
- `approval_request`, `external_submission`, `task_evidence`, `audit_event`
- `event_publication`, `event_consumption`

`event_publication`은 여러 tenant의 미완료 row를 찾는 background queue이므로 일반
요청 table과 같은 policy를 바로 활성화하면 worker가 아무 이벤트도 claim하지 못할 수
있습니다. RLS 활성화 전 #34에서 “처리 가능한 `event_id + company_id`만 반환하는 최소
claim 함수” 또는 동등한 제한된 queue bootstrap 계약을 먼저 확정합니다. claim 뒤
handler·완료·실패 transaction은 event에 저장된 `company_id`를 tenant context로
설정하고 일반 policy를 따릅니다. Runtime role에 전체 Outbox RLS 우회 권한을 주지는
않습니다.

## 설정 계약

Expand Down Expand Up @@ -70,6 +79,8 @@ DDL, `TRUNCATE`, `REFERENCES` 권한을 갖지 않습니다. 실제 값은 배
- commit, rollback, 예외, timeout 뒤 같은 physical connection을 재사용해도 이전
context가 남지 않습니다.
- Login·Refresh와 구현된 Worker Link 정상 흐름이 유지됩니다.
- Outbox claim이 다른 tenant payload를 노출하지 않고, claim된 event의 handler
transaction이 해당 `company_id` context에서만 실행됩니다.
- 오류 응답과 일반 로그에 SQL, JWT, token, email, 개인정보가 노출되지 않습니다.

로컬 또는 CI PostgreSQL 기반 검증은 다음 환경변수를 사용합니다.
Expand Down
138 changes: 138 additions & 0 deletions docs/reliability/transactional-outbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Transactional Outbox 운영 가이드

## 한눈에 보기

Transactional Outbox는 “DB 저장은 성공했는데 후속 이벤트가 사라지는 문제”를
막습니다. 업무 데이터와 발행할 이벤트를 같은 transaction에 저장한 뒤, 별도 worker가
안전하게 처리합니다.

```text
Task 생성·취소
→ Task·Audit·event_publication을 한 번에 commit
→ Outbox worker가 처리할 row를 lease
→ 각 handler 실행
→ event_consumption으로 handler 완료를 기록
→ event_publication을 COMPLETED로 종료
```

Kafka나 RabbitMQ가 필요한 구조는 아닙니다. MVP는 기존 PostgreSQL을 내구성 저장소로
사용하고, 호출 코드는 `DomainEventPublisher` Port에만 의존합니다.

## 테이블을 쉽게 이해하기

| 테이블 | 의미 |
| --- | --- |
| `event_publication` | 처리해야 할 이벤트와 현재 상태, 시도 횟수, 다음 시각, lease를 저장 |
| `event_consumption` | 어느 handler가 어느 이벤트를 이미 성공했는지 저장 |

`event_publication`의 상태는 다음과 같습니다.

| 상태 | 의미 |
| --- | --- |
| `PENDING` | 아직 처리하지 않음 |
| `PROCESSING` | 특정 서버가 제한 시간 동안 처리 권한을 가짐 |
| `RETRY_WAIT` | 일시 실패 후 다음 처리 시각을 기다림 |
| `COMPLETED` | 모든 handler 처리가 끝남 |
| `REVIEW_REQUIRED` | 자동 처리를 멈추고 개발자·운영자 확인이 필요함 |

## 새 이벤트를 발행하는 방법

1. 기능 모듈에서 과거형 업무 사실 이름을 정합니다. 예: `TaskCreated`.
2. payload field를 상수 allow-list로 선언합니다.
3. `DomainEventEnvelope`를 만들고 application service의 기존 `@Transactional`
method 안에서 `DomainEventPublisher.publish()`를 호출합니다.
4. 업무 저장, Audit, Event 중 하나라도 실패하면 전체가 rollback되는 통합 테스트를
작성합니다.

transaction 밖에서 `publish()`하면 서버가 즉시 거부합니다. 이벤트를 먼저 commit한 뒤
업무 저장을 따로 수행하면 원자성이 깨지므로 금지합니다.

## 새 handler를 추가하는 방법

`DomainEventHandler`를 구현한 Spring Bean을 기능 모듈의 infrastructure 또는
application adapter에 둡니다.

- `handlerName()`은 배포 뒤 의미를 바꾸지 않는 고유 이름을 사용합니다.
- `supports(eventType)`으로 처리할 이벤트를 명시합니다.
- `handle(event)`는 다른 모듈 Entity를 직접 수정하지 않고 해당 모듈의 application
command 또는 Port를 호출합니다.
- handler의 DB 변경과 `event_consumption` 저장은 같은 transaction입니다.
- 외부 API는 상대 시스템에도 `event_id` 또는 별도 업무 unique key를 idempotency
key로 전달해야 합니다. DB 완료 기록만으로 상대 시스템의 중복 실행까지 되돌릴 수는
없습니다.
- 일시 오류는 `RetryableEventHandlingException`, 입력·계약 오류처럼 반복해도
성공하지 않는 오류는 `NonRetryableEventHandlingException`으로 분류합니다.

이미 완료된 `(event_id, handler_name)`은 재전달 시 건너뜁니다. handler 이름을
변경하면 새 handler로 인식되므로 단순 refactoring 때 이름을 바꾸지 않습니다.

## 개인정보와 로그 규칙

Event payload는 `SafeEventPayload.of(allowedFields, values)`를 통과해야 합니다.

- 필요한 작은 업무값만 넣습니다. 예: `status`, `workflow_id`, `task_type`.
- Worker 이름·이메일·전화번호·여권번호·외국인등록번호·계좌번호를 넣지 않습니다.
- JWT, Worker Link 원본 token, 비밀번호, API Key, 전체 Prompt를 넣지 않습니다.
- 큰 객체, 중첩 JSON, Entity 전체를 넣지 않습니다.
- 실패 로그에는 payload나 예외 원문 대신 `event_id`, `event_type`, 안전한
`error_code`, 시도 횟수만 기록합니다.

## 기본 설정

| 환경변수 | 기본값 | 설명 |
| --- | --- | --- |
| `OUTBOX_ENABLED` | `true` | scheduler 실행 여부 |
| `OUTBOX_POLL_INTERVAL` | `1s` | 처리할 이벤트를 확인하는 간격 |
| `OUTBOX_BATCH_SIZE` | `20` | 한 번에 lease할 최대 row 수 |
| `OUTBOX_LEASE_DURATION` | `30s` | 한 서버의 처리 권한 유효시간 |
| `OUTBOX_MAX_ATTEMPTS` | `8` | 자동 시도 한도 |
| `OUTBOX_INITIAL_BACKOFF` | `1s` | 첫 재시도 대기시간 |
| `OUTBOX_MAX_BACKOFF` | `5m` | 재시도 대기시간 상한 |

lease는 정상 handler 최대 처리시간보다 길어야 합니다. 값을 줄이기 전에 느린 handler와
외부 API timeout을 확인합니다.

## 장애 확인

Actuator가 노출되는 내부 운영 환경에서는 다음 Micrometer 지표를 확인합니다.

- `fowoco.outbox.publications.backlog`: 미완료 이벤트 수
- `fowoco.outbox.publications.oldest.delay.seconds`: 가장 오래된 미완료 이벤트 지연
- `fowoco.outbox.publications.processed{result=completed|retry|review_required}`:
처리 결과 누적 수

DB에서는 payload를 출력하지 않고 상태와 안전한 오류만 확인합니다.

```sql
SELECT event_id, company_id, event_type, status, attempt_count,
next_attempt_at, lease_owner, lease_expires_at, last_error_code, updated_at
FROM event_publication
WHERE status <> 'COMPLETED'
ORDER BY occurred_at;
```

`PROCESSING` lease가 만료되면 다음 poll에서 자동 복구됩니다. `RETRY_WAIT`도
`next_attempt_at` 이후 자동 처리됩니다. `REVIEW_REQUIRED`는 원인을 수정했다고 해서
DB를 임의로 `PENDING`으로 바꾸지 않습니다. 별도 관리 command와 감사로그가 구현되기
전에는 담당 개발자가 원인을 확인하고 forward migration 또는 후속 Issue로 복구합니다.

`OUTBOX_ENABLED=false`는 자동 처리를 멈출 뿐 새 이벤트 저장을 막지 않습니다. 장애 중
이벤트가 계속 누적될 수 있으므로 backlog를 함께 관찰하고, 수정 배포 후 다시 활성화해
순서대로 처리합니다.

## 테스트 기준

- H2 통합 테스트: 업무 변경·이벤트 원자성, 재시도 rollback, 중복 전달, lease 만료
복구, 수동 검토 전환
- PostgreSQL CI 테스트: V7 migration, 상태 CHECK, tenant-aware FK, handler unique
constraint, index
- 기능 통합 테스트: 실제 command가 올바른 event type과 최소 payload를 발행하는지
검증

로컬 전체 검증:

```bash
./gradlew clean test
```

CI는 PostgreSQL 17 service에도 모든 Flyway migration을 적용하고 계약을 검증합니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.fowoco.server.reliability.application;

public class NonRetryableEventHandlingException extends RuntimeException {

private final String errorCode;

public NonRetryableEventHandlingException(String errorCode) {
super("Non-retryable event handler failure.");
this.errorCode = requireErrorCode(errorCode);
}

public String errorCode() {
return errorCode;
}

private static String requireErrorCode(String errorCode) {
if (errorCode == null || !errorCode.matches("^[A-Z][A-Z0-9_]{1,79}$")) {
throw new IllegalArgumentException("Invalid safe event error code.");
}
return errorCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.fowoco.server.reliability.application;

import com.fowoco.server.reliability.config.OutboxProperties;
import java.time.Duration;
import org.springframework.stereotype.Component;

@Component
public class OutboxBackoffPolicy {

private final Duration initialBackoff;
private final Duration maxBackoff;

public OutboxBackoffPolicy(OutboxProperties properties) {
initialBackoff = properties.getInitialBackoff();
maxBackoff = properties.getMaxBackoff();
if (maxBackoff.compareTo(initialBackoff) < 0) {
throw new IllegalArgumentException(
"Outbox maxBackoff cannot be shorter than initialBackoff."
);
}
}

public Duration delayForAttempt(int attemptCount) {
if (attemptCount < 1) {
throw new IllegalArgumentException("attemptCount must be positive");
}
long multiplier = 1L << Math.min(attemptCount - 1, 30);
try {
Duration calculated = initialBackoff.multipliedBy(multiplier);
return calculated.compareTo(maxBackoff) > 0 ? maxBackoff : calculated;
} catch (ArithmeticException exception) {
return maxBackoff;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.fowoco.server.reliability.application;

import com.fowoco.server.reliability.application.port.EventPublicationRepository;
import com.fowoco.server.reliability.config.OutboxProperties;
import com.fowoco.server.reliability.domain.EventPublication;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OutboxClaimService {

private final EventPublicationRepository repository;
private final OutboxProperties properties;
private final OutboxMetrics metrics;
private final Clock clock;

public OutboxClaimService(
EventPublicationRepository repository,
OutboxProperties properties,
OutboxMetrics metrics,
Clock clock
) {
this.repository = repository;
this.properties = properties;
this.metrics = metrics;
this.clock = clock;
}

@Transactional
public List<UUID> claimBatch(String owner) {
Instant now = clock.instant();
List<EventPublication> candidates =
repository.lockClaimable(now, properties.getBatchSize());
List<UUID> claimed = new ArrayList<>(candidates.size());
for (EventPublication publication : candidates) {
publication.claim(owner, now, properties.getLeaseDuration());
if (publication.attemptCount() > properties.getMaxAttempts()) {
publication.requireReview(owner, "EVENT_ATTEMPTS_EXHAUSTED", now);
metrics.recordReviewRequired();
} else {
claimed.add(publication.eventId());
}
repository.save(publication);
}
return List.copyOf(claimed);
}
}
Loading
Loading