Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
441dc75
feat(user): 프로필 이미지 backfill 실행 추가 (#199)
hwistlezz Sep 4, 2026
17073ab
docs(media): 프로필 이미지 전환 절차 추가 (#199)
hwistlezz Sep 4, 2026
780ac47
ci(config): 프로필 backfill MySQL 검증 추가 (#199)
hwistlezz Sep 4, 2026
52d010a
fix(user): backfill lease 상실 상태 보고 보완 (#199)
hwistlezz Sep 4, 2026
4691679
fix(user): 선행 backfill 보완 반영 (#199)
hwistlezz Sep 4, 2026
59cf88f
fix(user): 프로필 backfill 저장소 장애 처리 보완 (#199)
hwistlezz Sep 5, 2026
5f98b83
fix(user): 프로필 backfill 체크포인트 InnoDB 명시 (#199)
hwistlezz Sep 5, 2026
ad8e9de
docs(media): 프로필 backfill 복구 절차 보완 (#199)
hwistlezz Sep 5, 2026
85130b7
fix(user): 선행 backfill lease 보완 반영 (#199)
hwistlezz Sep 5, 2026
c59fea2
fix(user): 프로필 backfill pause lease 만료 검증 보완 (#199)
hwistlezz Sep 5, 2026
f0f2cf9
fix(user): 프로필 backfill 실행 로그 집계 보완 (#199)
hwistlezz Sep 5, 2026
8df797f
chore(user): 최신 이미지 전환 기반 반영 (#199)
hwistlezz Sep 19, 2026
807d40c
refactor(shared): 전환 배치 순회와 재시도 공통화 (#199)
hwistlezz Sep 19, 2026
6ec450d
refactor(restaurant): 이미지 전환 공통 실행 도구 적용 (#199)
hwistlezz Sep 19, 2026
8d96a51
refactor(user): 프로필 전환 공통 실행 도구 적용 (#199)
hwistlezz Sep 19, 2026
c99efb7
ci: 공통 전환 도구 변경 검증 추가 (#199)
hwistlezz Sep 19, 2026
02ced5f
docs(media): 전환 실행 도구 책임 범위 정리 (#199)
hwistlezz Sep 19, 2026
a57313c
test(media): MySQL 테스트 컨텍스트 종료 순서 정리 (#199)
hwistlezz Sep 19, 2026
52b534f
test(restaurant): 스키마 테스트 컨텍스트 종료 정리 (#199)
hwistlezz Sep 19, 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
34 changes: 23 additions & 11 deletions .github/workflows/ci-restaurant-media-backfill.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
name: Restaurant Media Backfill CI
name: Media Backfill CI

on:
pull_request:
paths:
- ".github/workflows/ci-restaurant-media-backfill.yml"
- "src/main/java/org/sopt/hashi/restaurant/**"
- "src/test/java/org/sopt/hashi/restaurant/**"
- "src/main/java/org/sopt/hashi/user/**"
- "src/test/java/org/sopt/hashi/user/**"
- "src/main/java/org/sopt/hashi/shared/migration/**"
- "src/test/java/org/sopt/hashi/shared/migration/**"
- "src/main/resources/db/migration/**"
- "src/main/resources/application.yml"
- "docs/media/restaurant-menu-backfill-runbook.md"
- "docs/media/user-profile-backfill-runbook.md"
- "docs/media/legacy-backfill-runbook.md"
- "build.gradle"

concurrency:
group: media-backfill-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verify:
name: Verify restaurant media backfill
name: Verify media backfill
runs-on: ubuntu-latest
timeout-minutes: 30

Expand Down Expand Up @@ -44,14 +55,15 @@ jobs:
from pathlib import Path
import xml.etree.ElementTree as ET

report = Path(
"build/test-results/test/"
"TEST-org.sopt.hashi.restaurant.migration."
"RestaurantMediaBackfillPersistenceIntegrationTest.xml"
reports = (
"TEST-org.sopt.hashi.restaurant.migration.RestaurantMediaBackfillPersistenceIntegrationTest.xml",
"TEST-org.sopt.hashi.user.migration.UserProfileBackfillPersistenceIntegrationTest.xml",
)
suite = ET.parse(report).getroot()
assert int(suite.attrib["tests"]) > 0, "MySQL backfill tests did not execute"
for outcome in ("failures", "errors", "skipped"):
assert int(suite.attrib[outcome]) == 0, f"MySQL backfill {outcome} must be zero"
print("MySQL backfill gate executed without failures, errors, or skips")
for name in reports:
report = Path("build/test-results/test") / name
suite = ET.parse(report).getroot()
assert int(suite.attrib["tests"]) > 0, f"{name}: MySQL backfill tests did not execute"
for outcome in ("failures", "errors", "skipped"):
assert int(suite.attrib[outcome]) == 0, f"{name}: {outcome} must be zero"
print(f"{name}: executed without failures, errors, or skips")
PY
7 changes: 6 additions & 1 deletion docs/conventions/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@
- 허용: 응답 래퍼(`BaseResponse`/`SuccessResponse`/`ErrorResponse`), 코드 계약 인터페이스(`BaseCode`/`ErrorCode`/`SuccessCode`), 공통 예외(`BusinessException`), 전역 핸들러(`GlobalExceptionHandler`), 도메인 무관 VO(`Money`/`Address`), 스토리지 포트(`FileStorage`).
- **MUST NOT**: 특정 도메인을 아는 타입(예: `RestaurantDto`, `User`, `ReservationStatus`)을 `shared`에 두지 않는다.
- **MUST**: 의존 방향은 **도메인 → shared 단방향**. `shared`는 어떤 도메인 모듈도 import하지 않는다.
- 하위 패키지: `response` · `error` · `exception` · `storage` · `swagger` · `vo`
- 하위 패키지: `response` · `error` · `exception` · `storage` · `swagger` · `vo` · `migration`
- **MAY**: `shared/migration`에는 한시적 전환 실행기에서 사용하는, 상태 없는 keyset 반복과 제한 재시도
도구만 둔다. 후보 읽기·항목 처리·재시도 대상 판단은 호출자가 전달한다.
- **MUST NOT**: 공통 전환 도구가 콘텐츠 또는 media 타입, Spring Bean, DB·S3 접근, 트랜잭션,
checkpoint·lease 저장이나 도메인 상태 전이를 소유하지 않는다. 이미지 연결과 진행 기록의 원자성은
각 소유 모듈이 유지한다. 전환 실행기를 제거할 때 이 도구의 남은 사용처도 함께 확인한다.

원칙: **"틀은 공유, 내용은 도메인."**

Expand Down
13 changes: 9 additions & 4 deletions docs/media/legacy-backfill-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

이 문서는 공통 기반의 사용 경계와 후속 runner의 요구사항이다. 실제 AWS 적용이나 운영
backfill을 승인하지 않는다. 식당·메뉴의 dry-run, checkpoint와 bounded runner는
[식당·메뉴 실행기](restaurant-menu-backfill-runbook.md)를 따른다. 다른 도메인 runner와 운영 전환
검증은 후속 작업이며, 리뷰 도메인 연동은 #179 병합 후 진행한다.
[식당·메뉴 실행기](restaurant-menu-backfill-runbook.md)를, 활성 회원의 프로필 전환은
[프로필 실행기](user-profile-backfill-runbook.md)를 따른다. 매거진 runner와 운영 전환 검증은
후속 작업이며, 리뷰 도메인 연동은 #179 병합 후 진행한다.

## 1. 소유 경계

Expand Down Expand Up @@ -152,7 +153,11 @@ WHERE creation_origin = 'SYSTEM_BACKFILL'
CloudFront 전달 E2E가 완료됐다고 보고하지 않는다. 배포 전에는 dev의 제한된 테스트 source로
IAM과 전체 변환·연결 흐름을 별도 검증해야 한다.

식당·메뉴 runner의 keyset batch·checkpoint·dry-run과 동시 수정 검증은 별도 실행기 문서를 따른다.
후속 작업은 다른 도메인 runner, 안전한 cleanup/reconciliation, dev E2E와 운영 승인이다.
식당·메뉴와 프로필 runner의 keyset batch·checkpoint·dry-run과 동시 수정 검증은 별도 실행기 문서를 따른다.
두 runner의 배치 순회와 제한 재시도는 `shared/migration`의 도메인 무관 도구를 사용한다.
항목 처리가 정상 반환한 뒤에만 메모리 cursor를 전진시키며, 처리 예외는 호출자에게 그대로 전달한다.
후보 선정, source 오류 분류, lease·checkpoint, 완료·중단 집계와 연결 transaction은 각 모듈에 남긴다.
DB 테이블이나 migration을 합치지 않는다. 매거진 실행기의 공통 도구 적용은 후속 PR에서 검증한다.
후속 작업은 매거진 runner, 안전한 cleanup/reconciliation, dev E2E와 운영 승인이다.
legacy 필드 제거와 원본 삭제는
별도 종료 조건과 승인을 충족하기 전에는 실행하지 않는다.
144 changes: 144 additions & 0 deletions docs/media/user-profile-backfill-runbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# 프로필 이미지 backfill 실행기

관련 이슈: #199. [공통 backfill 계약](legacy-backfill-runbook.md)을 사용하는 `user.migration`의
임시 실행기다. 코드 배포와 이 문서는 실제 AWS 적용, 운영 backfill, 개인정보 삭제를 승인하지 않는다.

## 1. 대상과 소유 경계

- `users.deleted=false`, legacy `profile_image_key` 존재, `profile_image_asset_id` 없음인 회원만 조사한다.
탈퇴 회원은 일반 User 조회에서 제외되는 기존 정책을 따른다. 과거 예약 표시를 위해 삭제된 식당도
처리하는 식당 runner와 의도적으로 다르다.
- 후보 조회는 ID와 legacy key만 읽는다. 별도 사용자 목록, 전화번호, 이메일 또는 이름을 적재하지 않는다.
- 공개 API, 로그인 actor, 반복 scheduler, 신규 dependency는 추가하지 않는다.
- `MediaBackfillTarget.USER_PROFILE`의 기존 identity marker를 그대로 사용한다. 콘텐츠 소유권은
User가 유지하며 media의 Entity/Repository를 production 코드에서 직접 참조하지 않는다.
- 기본 비활성이다. 명시적으로 opt-in한 애플리케이션의 `ApplicationReadyEvent` 이후 전용
`userProfileBackfillExecutor`에서 한 번만 실행하고 최대 batch 수에 도달하면 멈춘다.

## 2. 조사 → 준비 → 연결

| mode | 동작 | 쓰기 |
| --- | --- | --- |
| `DRY_RUN` | 후보 읽기, S3 HEAD와 기존 media 예약 조사 | DB·S3 쓰기 없음 |
| `PREPARE` | 동일 source identity의 private original copy·변환 job 준비 | media 예약·copy·job·checkpoint |
| `ATTACH` | 현재 source 재조사 후 READY asset 연결 | User 프로필 UUID·media claim·checkpoint |

PREPARE는 변환 완료를 기다리지 않으며 기존 프로필과 legacy URL을 유지한다. READY 이후 ATTACH로
연결하고 legacy key는 제거하지 않는다. 닉네임·이름·생일·전화번호·이메일·탈퇴 상태는 변경하지 않는다.

원본을 임시 공개하거나 PROCESSING/FAILED asset으로 기존 프로필을 교체하지 않는다.
ATTACH에서 PROCESSING은 건너뛰며, 준비 완료 후 새 ATTACH run으로 재조사한다.

## 3. 실행 전 확인

1. 공통 media·worker·result pipeline의 승인된 dev E2E를 먼저 완료한다. 실패 원인별 관측 보완(#203)도
반영되어 있어야 하며, 그전에는 운영 backfill을 실행하지 않는다.
2. SAM `BackfillAccessEnabled`와 Spring `AWS_MEDIA_BACKFILL_ENABLED`의 별도 승인을 확인한다.
3. `PREPARE` 전에는 `AWS_MEDIA_QUEUE_ENABLED=true`, `AWS_MEDIA_RECOVERY_ENABLED=true`, worker event
source와 Spring result consumer가 활성 상태인지 확인한다. request/result queue와 DLQ 지연·오류
alarm도 정상이어야 한다.
4. PREPARE는 DB `media_pipeline_config.issuance_enabled=true`와 배포 규격 일치가 필요하다.
조회와 READY ATTACH는 issuance pause 상태에서도 가능하다.
5. 프로필 runner 설정을 별도로 opt-in한다. V23 migration 자체는 작업을 실행하지 않는다.
6. legacy S3 객체를 같은 key로 직접 덮어쓰지 않는다. 이후 사진 변경은 새 key를 사용한다.

S3 HEAD/copy와 User DB 잠금은 하나의 원자적 작업이 아니다. 조사 이후 source가 바뀌면
prepare의 identity 검증이나 ATTACH의 잠금 아래 key 재검증으로 이전 사진 연결을 거부한다.
단, 같은 key를 콘솔에서 직접 덮어쓰는 작업은 위 운영 통제가 필요하다.

조사·준비 도중 사용자가 탈퇴하면 미연결 private copy가 남을 수 있다. ATTACH는 탈퇴한 User를
연결하지 않으며, 미연결 파일은 승인된 cleanup/reconciliation 정책의 대상이다. 이 실행기는
탈퇴 데이터 삭제나 보존 기간을 새로 정하거나 자동 삭제하지 않는다.

## 4. 실행 설정

| 환경변수 | 기본값 | 의미 |
| --- | --- | --- |
| `USER_PROFILE_BACKFILL_ENABLED` | `false` | one-shot runner 활성화 |
| `USER_PROFILE_BACKFILL_RUN_ID` | 비어 있음 | PREPARE/ATTACH의 소문자 canonical UUID |
| `USER_PROFILE_BACKFILL_MODE` | `DRY_RUN` | 조사·준비·연결 중 하나 |
| `USER_PROFILE_BACKFILL_BATCH_SIZE` | `50` | 1~500개 후보 |
| `USER_PROFILE_BACKFILL_MAX_BATCHES` | `10` | 한 기동당 1~1,000 batch |
| `USER_PROFILE_BACKFILL_LEASE_DURATION` | `5m` | 30초~30분 |
| `USER_PROFILE_BACKFILL_MAX_ATTEMPTS` | `3` | storage 일시 장애의 총 시도 횟수, 1~5 |
| `USER_PROFILE_BACKFILL_RETRY_INITIAL_DELAY` | `200ms` | 0~10초, 지수 backoff 시작값 |

- 프로필 슬롯만 처리하므로 target 선택 설정은 없다.
- PREPARE와 ATTACH는 서로 다른 run ID를 사용한다. 같은 run ID의 mode는 바꾸지 못한다.
- 여러 replica에는 같은 run ID를 사용한다. 유효한 lease를 얻은 한 실행기만 처리한다.
- 같은 mode를 서로 다른 run ID로 동시에 실행하지 않는다. User 잠금과 media claim이 이중 연결을
막더라도 불필요한 source 읽기·copy와 경합 비용이 발생한다.
- DRY_RUN은 checkpoint 없이 단일 조사 환경에서 수행한다.
- 지수 backoff의 총 대기 예산은 lease보다 짧아야 한다. source 지연까지 포함해 만료되면
현재 연결 transaction을 롤백하고 다음 기동에서 같은 run ID로 재개한다.

## 5. 중단·복구와 정합성

V23의 `user_profile_backfill_checkpoint`는 user 소유 실행 기록이다. 다른 모듈 FK, media DB join,
PII, legacy key, asset UUID 또는 source hash를 저장하지 않는다. run ID·진행 ID 범위·lease·집계만 둔다.

- 최초 후보 User ID 상한을 고정하고 `id > cursor AND id <= upper_bound`로 순회한다.
이후 생성된 회원은 다음 run에서 조사한다. 이미 있던 기본 프로필의 변경도 재조사가 필요할 수 있다.
- PREPARE는 항목별로 진행 위치를 커밋한다. copy 이후 중단되어도 같은 identity의 예약·copy·job을 재사용한다.
- ATTACH는 User 잠금 → 활성 상태·legacy key·기존 UUID 재검증 → media READY claim → cursor 갱신을
같은 transaction에서 처리한다. 연결·claim·cursor 중 하나라도 실패하면 모두 롤백한다.
- 탈퇴나 source 변경이 먼저 커밋되면 해당 항목을 SKIPPED로 기록한다. 새로운 사진으로 덮어쓰지 않는다.
- checkpoint 잠금 뒤 별도 SQL의 DB 시각으로 lease 만료를 판단한다. 대기 전에 읽은 시각으로 연장하지 않는다.
- 최대 batch 도달은 PAUSED다. 같은 run ID로 다음 기동에서 이어간다. 강제 종료로 RUNNING이 남으면
만료 후 새로운 token으로 인계받고, 이전 실행기는 진행 위치를 갱신하거나 새 lease를 해제할 수 없다.
- COMPLETED는 **정해진 ID 범위의 순회 완료**다. 전체 프로필 전환 완료를 뜻하지 않는다.
변환 대기·실패·동시 수정으로 남은 항목은 원인을 확인하고 새로운 run에서 처리한다.
- 종료 시 executor의 graceful-stop 대기 이후 진행 중 작업은 interrupt 대상이다. 커밋되지 않은
작업은 롤백 또는 멱등 재시도로 복구하며 lease 해제가 불가능하면 만료를 기다린다.

현재 별도 프로필 수정 API는 없다. 향후 프로필 수정·탈퇴 경로를 추가할 때에도 User 행의
동시 변경과 media claim/retire 경계를 함께 검토해야 한다. 이 작업에서 해당 API를 추가하지 않는다.

## 6. 관측과 실패 대응

로그에는 mode·상태·집계와 오류 클래스명 또는 고정 실패 원인만 남긴다. 원시 User ID, key, asset UUID, hash,
개인정보 또는 예외 payload를 출력하지 않는다. PREPARE/ATTACH 결과는 다음 집계로 확인한다.

```sql
SELECT mode, status, scanned_count, prepared_count, attached_count, skipped_count, failed_count
FROM user_profile_backfill_checkpoint
WHERE run_id = ?;
```

PREPARED는 변환 완료가 아니라 준비 단계 처리 수다. SKIPPED는 미준비 또는 변경된 프로필,
FAILED는 source 오류 또는 terminal media 상태다. `STORAGE_UNAVAILABLE`만 정해진 횟수 내에서
재시도하며 마지막 시도도 실패하면 현재 cursor를 전진시키지 않고 실행을 중단한다. DRY_RUN에서
`SOURCE_UNREADABLE`이 반복되면 source별 실패로 단정하지 말고 IAM과 암호화 권한부터 확인한다.
DB·설정·불변식 오류도 현재 cursor를 전진시키지 않고 실행을 중단한다. run ID만 바꿔 장애를
무한 반복하지 말고 원인을 확인한다.

## 7. 검증과 전환

```text
./gradlew test --tests 'org.sopt.hashi.user.migration.*' --tests '*UserProfileImageTest' --tests '*MediaBackfillBoundaryTest' --tests 'org.sopt.hashi.ModularityTests'
./gradlew clean build
```

실제 MySQL에서 V23 제약·상한·재개·lease takeover와 대기 중 만료, User/media/checkpoint 롤백,
탈퇴·source 수정·동일 슬롯 이중 연결 경쟁, keyset 실행 계획을 검증한다. 시작 이벤트와 executor
종료 테스트는 모킹한 storage를 사용한다. 운영의 종료 대기 시간이나 AWS 지연을 측정한 것은 아니다.

기존 backfill CI를 확장해 식당과 프로필 MySQL suite 모두 실행 수 > 0, 실패·오류·skip 0을 요구한다.
Docker가 없어서 건너뛴 결과는 검증 완료가 아니다.

실제 AWS dev dry-run → 제한 PREPARE → READY 확인 → 제한 ATTACH → 프로필 응답·전송량 확인은
별도 승인 후 실행한다. 운영 범위·일정 승인, legacy 제거, 원본 삭제는 이 PR에서 수행하지 않는다.

## 8. 실행 중단과 임시 권한 회수

1. 새 실행을 막기 위해 `USER_PROFILE_BACKFILL_ENABLED=false`로 배포한다. 실행 중인 background
task는 정상 종료로 interrupt하고, checkpoint가 `PAUSED`, `COMPLETED` 또는 lease 만료 상태인지 확인한다.
2. 이미 발급된 변환은 `AWS_MEDIA_QUEUE_ENABLED=true`, `AWS_MEDIA_RECOVERY_ENABLED=true`, worker
event source와 result consumer를 유지한 채 처리한다. target PROCESSING, 미완료 EPR,
request/result queue와 두 DLQ가 비었는지 확인하고, 실패 항목은 원인을 분류한 뒤 복구한다.
3. 더 이상 조사·복사·연결이 없으면 `AWS_MEDIA_BACKFILL_ENABLED=false`로 배포한다.
4. SAM `BackfillAccessEnabled=false` change set을 검토·적용해 임시 source 읽기·copy 권한을 회수한다.
5. V23 checkpoint는 실행 이력과 재개 판단을 위해 유지한다. rollback 과정에서 테이블을 삭제하거나
과거 migration을 수정하지 않는다. 이미지 pipeline 상태를 모르는 과거 바이너리로
되돌려야 한다면 공통 인프라 runbook의 drain 조건을 먼저 만족하고, 조건이 맞지 않으면 현재 계열
수정 release를 사용한다.
Loading
Loading