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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ build/libs/*plain.jar
*.iml
.DS_Store
.env
.env.*
.env-*
deploy-bundle
docker-compose.yml
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ jobs:
- name: Test
run: ./gradlew test

- name: Verify deployment secret persistence
run: python3 scripts/deployment_bundle_test.py

# build 는 test 를 포함하지만 앞 단계에서 이미 실행돼 UP-TO-DATE 로 넘어간다.
# 단계를 나눠 둬야 테스트 실패와 컴파일·패키징 실패가 로그에서 구분된다.
- name: Build
Expand Down
18 changes: 10 additions & 8 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
- name: Test and build application jar
run: ./gradlew test bootJar

- name: Verify deployment secret persistence
run: python3 scripts/deployment_bundle_test.py

# 멀티스테이지 Dockerfile 이 이미지 안에서 bootJar 를 다시 빌드한다.
# CI 의 jar 는 최종 아티팩트가 아니라 테스트 통과 증명이다.
- name: Resolve image name
Expand Down Expand Up @@ -93,14 +96,8 @@ jobs:
- name: Create deployment bundle
env:
PROD_ENV: ${{ secrets.PROD_ENV }}
run: |
test -n "$PROD_ENV"
mkdir -p deploy-bundle
cp docker-compose.prod.yml deploy-bundle/docker-compose.yml
cp scripts/check-hair-analysis-model.sh deploy-bundle/check-hair-analysis-model.sh
printf '%s\n' "$PROD_ENV" > deploy-bundle/.env
# Non-secret analysis settings are versioned; existing secrets stay in GitHub.
cat config/hair-analysis.prod.env >> deploy-bundle/.env
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: python3 scripts/create-deployment-bundle.py

- name: Upload compose and environment
env:
Expand All @@ -112,7 +109,9 @@ jobs:
ssh -i ~/.ssh/deploy_key -p "$EC2_PORT" "$EC2_USER@$EC2_HOST" "mkdir -p '$DEPLOY_PATH'"
scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/docker-compose.yml "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/docker-compose.yml"
scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/check-hair-analysis-model.sh "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/check-hair-analysis-model.sh"
scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/check-recommendation-ai.sh "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/check-recommendation-ai.sh"
scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/.env "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/.env"
ssh -i ~/.ssh/deploy_key -p "$EC2_PORT" "$EC2_USER@$EC2_HOST" "chmod 600 '$DEPLOY_PATH/.env'"

- name: Pull and restart containers
env:
Expand All @@ -127,8 +126,11 @@ jobs:
cd "$DEPLOY_PATH"
echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
APP_IMAGE_TAG="${{ github.sha }}" docker compose pull app
APP_IMAGE_TAG="${{ github.sha }}" docker compose config --quiet
APP_IMAGE_TAG="${{ github.sha }}" docker compose run --rm -T --no-deps --entrypoint sh app -s < check-recommendation-ai.sh
# 기존 앱을 내리기 전에 후보 컨테이너의 사용자/마운트로 모델 접근을 검사한다.
APP_IMAGE_TAG="${{ github.sha }}" docker compose run --rm -T --no-deps --entrypoint sh app -s < check-hair-analysis-model.sh
APP_IMAGE_TAG="${{ github.sha }}" docker compose up -d --remove-orphans --wait --wait-timeout 120
APP_IMAGE_TAG="${{ github.sha }}" docker compose exec -T app sh -s < check-recommendation-ai.sh
docker image prune -f
EOF
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ AGENTS.md
.env
.env.*
.env-*
/deploy-bundle/
!.env.example
.DS_Store

Expand Down
4 changes: 4 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ services:
# 사진 조회 Presigned GET URL 의 만료(초). 짧을수록 URL 유출 창이 좁지만,
# 화면 체류 시간보다 짧으면 이미지가 403 으로 깨진다.
DOWNLOAD_URL_TTL_SECONDS: ${DOWNLOAD_URL_TTL_SECONDS:-300}
OPENAI_API_KEY: ${OPENAI_API_KEY:?OPENAI_API_KEY is required}
RECOMMENDATION_AI_ENABLED: ${RECOMMENDATION_AI_ENABLED:-true}
OPENAI_RECOMMENDATION_MODEL: ${OPENAI_RECOMMENDATION_MODEL:-gpt-4.1-nano-2025-04-14}
OPENAI_RECOMMENDATION_TIMEOUT_MS: ${OPENAI_RECOMMENDATION_TIMEOUT_MS:-8000}
# 로컬 ONNX 얼굴 파싱 모델. 호스트의 읽기 전용 파일을 컨테이너에 마운트한다.
HAIR_PHOTO_LENGTH_ENABLED: ${HAIR_PHOTO_LENGTH_ENABLED:-true}
HAIR_PHOTO_LENGTH_MODEL_PATH: /opt/heddy/photo-models/selfie.onnx
Expand Down
45 changes: 43 additions & 2 deletions docs/deploy-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

`deploy.yml` 는 `main` 푸시와 수동 실행(`workflow_dispatch`)으로 동작한다.
워크플로가 서버에 접속하려면 아래 시크릿이 먼저 등록돼 있어야 한다.
**시크릿 등록과 v1 폐기는 사람이 직접 실행한다.**
시크릿 등록은 저장소 관리 권한으로 수행한다. v1 폐기는 별도 수동 작업이다.

## 1. 저장소 시크릿 등록 (수동)

레포 시크릿 5종. 로컬에서 한 번만 실행하면 된다.
레포 시크릿 6종. 로컬에서 한 번만 등록하면 이후 배포에 재사용된다.

```bash
gh secret set EC2_HOST --body "<EC2 퍼블릭 IP 또는 도메인>"
Expand All @@ -17,6 +17,12 @@ gh secret set PROD_ENV --body "$(cat ./prod.env)" # 아래 2번
```

- `EC2_SSH_KEY` 는 EC2 인스턴스에 SSH 로 접속 가능한 키의 **전체 내용**(헤더 포함)이어야 한다.
- `OPENAI_API_KEY`는 별도 Actions Secret으로 등록한다. 키를 커밋하거나 명령줄 인자로 넘기지 않는다.
`scripts/create-deployment-bundle.py`가 `PROD_ENV`의 이전 키를 제거하고 별도 시크릿을 넣으므로,
매 배포에서 서버 `.env`가 교체되어도 유지된다. 키 교체는 이 시크릿만 갱신하면 된다.
시크릿 누락·잘못된 형식은 기존 서버를 변경하기 전 번들 생성 단계에서 차단한다.
CI/Deploy 모두 반복 배포·키 교체·누락 시 보존 테스트를 실행한다.
배포 전후 컨테이너에서 키 존재 여부를 확인하고, 값은 로그에 출력하지 않는다.
- 워크플로가 쓰는 배포 디렉터리는 `/home/<EC2_USER>/heddy-server-v2` 다. v1 의 디렉터리와 섞이지 않게 별도 경로를 쓴다.

## 2. `PROD_ENV` 필수 값 체크리스트 (10종 + S3 자격증명 페어)
Expand Down Expand Up @@ -58,6 +64,41 @@ SHARE_PUBLIC_BASE_URL=https://heddy.site/s # 선택: 미지정 시에도 이
기능별 선택 변수(소셜 로그인 `KAKAO_APP_ID` · `GOOGLE_CLIENT_ID` · `APPLE_CLIENT_ID`,
SMS `SOLAPI_*` 등)는 해당 기능을 켤 때 같은 파일에 추가하면 `.env` 째로 컨테이너에 전달된다.

### 추천 설명 AI

Spring에서 OpenAI Responses API를 직접 호출한다. 추천 점수·순위는 기존 규칙으로 결정하고,
선택된 최대 3개 후보의 근거만 한 번에 보내 한국어 설명을 생성한다.
이름·연락처·사용자/시술 UUID·사진·메모는 보내지 않는다. `store=false`를 사용한다.

| 설정 | 기본값 | 용도 |
|---|---|---|
| `OPENAI_API_KEY` | 없음 | 별도 GitHub Actions Secret, 운영 필수 |
| `RECOMMENDATION_AI_ENABLED` | `true` | `PROD_ENV`에서 `false`로 끌 수 있음 |
| `OPENAI_RECOMMENDATION_MODEL` | `gpt-4.1-nano-2025-04-14` | 추론 단계 없이 짧은 설명을 생성하는 저비용 모델 |
| `OPENAI_RECOMMENDATION_TIMEOUT_MS` | `8000` | 전체 호출 제한 시간, 최대 15000ms |

- 출력 상한은 요청당 384토큰이다. 호출을 자동 재시도하지 않는다.
- 키 누락(로컬), 시간 초과, 429, API 오류, 거절·불완전·잘못된 JSON 응답은 기본 문구로 대체한다.
- 설명은 `recommendation_items.reasons_json`에 저장한다. 조회/동일 입력 재사용에는 추가 호출이 없다.
강제 새로고침에는 다시 호출하며, 모델·프롬프트 버전 변경도 재생성을 유발한다.
- 화면은 `items[].explanation`을 표시하면 된다. 기존 클라이언트를 위해
`items[].reasons[0].message`에도 같은 설명을 반환한다. 클라이언트가 `code`만 보고 자체 문구를
만드는 경우 프론트 수정이 필요하다.
- 생성 실패 시에도 해당 실행의 기본 문구는 저장·재사용된다. 설정 복구 후 강제 새로고침으로 다시 생성한다.
- AI가 문장화해도 부족한 추천 메타데이터가 보완되지는 않는다. 시술 종류 일치는 세부 스타일 유사성의 근거가 아니다.
- `429 credit_balance_exhausted`/`insufficient_quota`는 일시적인 속도 제한과 다르다.
[API 결제](https://platform.openai.com/settings/organization/billing)에서 잔액을 확인하고 충전한 후 다시 생성한다.
ChatGPT 구독과 API 결제는 별개다. 간단한 실험 모델로 `gpt-6-luna`도 선택할 수 있지만,
모델을 바꾸는 것만으로 크레딧 부족이 해결되지는 않는다.

로컬 키는 Git에서 제외된 `.env.local`의 `OPENAI_API_KEY`에 저장한다. Spring이 자동으로 읽으며
Docker 빌드 컨텍스트에서는 제외한다. 테스트 프로필은 AI를 끈다.

모델/요금 근거: [GPT-4.1 nano](https://developers.openai.com/api/docs/models/gpt-4.1-nano),
[OpenAI 요금](https://developers.openai.com/api/docs/pricing). 2026-10-17 확인 기준 표준 요금은
입력 100만 토큰당 $0.10, 출력 $0.40이다. 추론 토큰이 없어 설명 생성의 토큰 사용량을 제한하기 쉽다.
예를 들어 입력 1,000토큰 + 출력 384토큰이면 요청당 약 $0.000254이며 실제 비용은 입력량에 따라 다르다.

헤어 분석을 켤 때는 아래 값도 추가한다. 모델 파일은 이미지에 넣지 않고 EC2의
`/home/ubuntu/heddy-models`에서 컨테이너로 읽기 전용 마운트한다.

Expand Down
8 changes: 8 additions & 0 deletions scripts/check-recommendation-ai.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
# Runs inside the candidate/running app container; never print the key.
set -eu
test -n "${OPENAI_API_KEY:-}" || {
echo 'OPENAI_API_KEY is missing from app container' >&2
exit 1
}
echo 'Recommendation API key is present in app container.'
49 changes: 49 additions & 0 deletions scripts/create-deployment-bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Build every release's environment from GitHub Secrets; never print secret values."""
import os
from pathlib import Path
import re
import shutil
import sys
import tempfile


def main():
prod_env = os.environ.get("PROD_ENV", "")
api_key = os.environ.get("OPENAI_API_KEY", "")
if not prod_env.strip():
raise ValueError("PROD_ENV repository secret is required")
if not re.fullmatch(r"sk-[A-Za-z0-9_-]+", api_key):
raise ValueError("OPENAI_API_KEY repository secret is missing or malformed")

root = Path(__file__).resolve().parent.parent
overrides = (root / "config/hair-analysis.prod.env").read_text()
# A previous value in PROD_ENV must never override the dedicated repository secret.
lines = [line for line in (prod_env + "\n" + overrides).splitlines()
if not re.match(r"\s*(?:export\s+)?OPENAI_API_KEY\s*=", line)]
content = "\n".join(lines) + "\nOPENAI_API_KEY=" + api_key + "\n"
bundle = root / "deploy-bundle"
bundle.mkdir(mode=0o700, exist_ok=True)
os.chmod(bundle, 0o700)
temporary = None
try:
with tempfile.NamedTemporaryFile(mode="w", dir=bundle, delete=False) as output:
temporary = Path(output.name)
os.fchmod(output.fileno(), 0o600)
output.write(content)
temporary.replace(bundle / ".env")
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
shutil.copyfile(root / "docker-compose.prod.yml", bundle / "docker-compose.yml")
for name in ("check-hair-analysis-model.sh", "check-recommendation-ai.sh"):
shutil.copyfile(root / "scripts" / name, bundle / name)
print("Deployment bundle created; OPENAI_API_KEY supplied by repository secret.")


if __name__ == "__main__":
try:
main()
except ValueError as error:
print(str(error), file=sys.stderr)
sys.exit(1)
75 changes: 75 additions & 0 deletions scripts/deployment_bundle_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Regression checks for repeated deployment and missing-secret failures (synthetic keys only)."""
import os
from pathlib import Path
import shutil
import stat
import subprocess
import tempfile
import unittest

ROOT = Path(__file__).resolve().parent.parent


class DeploymentBundleTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
for name in ("scripts/create-deployment-bundle.py", "scripts/check-hair-analysis-model.sh",
"scripts/check-recommendation-ai.sh", "config/hair-analysis.prod.env",
"docker-compose.prod.yml"):
target = self.root / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / name, target)

def tearDown(self):
self.temp.cleanup()

def bundle(self, key="sk-test-current", prod="JWT_SECRET=test\nOPENAI_API_KEY=sk-obsolete"):
return subprocess.run(["python3", str(self.root / "scripts/create-deployment-bundle.py")],
env=dict(os.environ, PROD_ENV=prod, OPENAI_API_KEY=key),
text=True, capture_output=True)

def test_repeated_deploy_keeps_dedicated_secret_and_other_configuration(self):
for revision in ("first", "second"):
result = self.bundle(prod=f"JWT_SECRET={revision}\nOPENAI_API_KEY=sk-obsolete")
self.assertEqual(result.returncode, 0)
content = (self.root / "deploy-bundle/.env").read_text()
self.assertEqual(content.count("OPENAI_API_KEY="), 1)
self.assertIn("OPENAI_API_KEY=sk-test-current\n", content)
self.assertIn(f"JWT_SECRET={revision}\n", content)
self.assertIn("HAIR_ANALYSIS_ENABLED=true", content)
self.assertNotIn("sk-test-current", result.stdout + result.stderr)
self.assertEqual(stat.S_IMODE((self.root / "deploy-bundle/.env").stat().st_mode), 0o600)
self.assertTrue((self.root / "deploy-bundle/check-recommendation-ai.sh").is_file())

def test_missing_or_malformed_secret_leaves_previous_bundle_intact(self):
self.assertEqual(self.bundle().returncode, 0)
previous = (self.root / "deploy-bundle/.env").read_bytes()
for key in ("", "sk-line\nINJECTED=value", "sk-with space"):
result = self.bundle(key=key)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / "deploy-bundle/.env").read_bytes(), previous)
self.assertNotEqual(self.bundle(prod="").returncode, 0)

def test_key_rotation_replaces_stale_values(self):
self.assertEqual(self.bundle().returncode, 0)
self.assertEqual(self.bundle(key="sk-rotated").returncode, 0)
content = (self.root / "deploy-bundle/.env").read_text()
self.assertIn("OPENAI_API_KEY=sk-rotated\n", content)
self.assertNotIn("sk-test-current", content)
self.assertNotIn("sk-obsolete", content)

def test_workflows_keep_secret_injection_and_checks(self):
deploy = (ROOT / ".github/workflows/deploy.yml").read_text()
ci = (ROOT / ".github/workflows/ci.yml").read_text()
self.assertIn("OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}", deploy)
self.assertIn("python3 scripts/create-deployment-bundle.py", deploy)
self.assertIn("check-recommendation-ai.sh", deploy)
self.assertIn("python3 scripts/deployment_bundle_test.py", ci)
self.assertIn("python3 scripts/deployment_bundle_test.py", deploy)
self.assertIn("OPENAI_API_KEY: ${OPENAI_API_KEY:?OPENAI_API_KEY is required}",
(ROOT / "docker-compose.prod.yml").read_text())


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public record Item(
@JsonProperty("recommended_color") UUID recommendedColor,
@JsonProperty("management_difficulty") String managementDifficulty,
@JsonProperty("estimated_daily_care_minutes") int estimatedDailyCareMinutes,
String explanation,
List<Reason> reasons,
@JsonProperty("reference_records") List<ReferenceRecord> referenceRecords
) {
Expand All @@ -107,6 +108,7 @@ static Item from(RecommendationResult.Item value) {
value.thumbnailUrl() == null ? null : value.thumbnailUrl().toString(),
value.hairstyle().arMode(), value.hairstyle().assetVersion()), item.colorId(),
item.managementDifficulty().name(), item.estimatedDailyCareMinutes(),
item.reasons().isEmpty() ? "기본 추천 스타일입니다." : Reason.message(item.reasons().getFirst()),
item.reasons().stream().map(Reason::from).toList(),
reference == null ? List.of() : List.of(ReferenceRecord.from(reference)));
}
Expand Down Expand Up @@ -138,6 +140,8 @@ private static String serviceNames(String types) {
}

private static String message(RecommendationReason reason) {
String generated = reason.params().get("ai_message");
if (generated != null && !generated.isBlank()) return generated;
return switch (reason.code()) {
case PHOTO_SHAPE_MATCH -> "최근 시술 사진의 머리 윤곽이 ‘"
+ reason.params().getOrDefault("style_name", "이 스타일") + "’의 참고 사진과 유사합니다.";
Expand All @@ -150,7 +154,7 @@ private static String message(RecommendationReason reason) {
case SAVED_STYLE_MATCH -> "이전에 저장한 스타일입니다.";
case PREFERRED_TAG_MATCH -> "선호하는 ‘"
+ reason.params().getOrDefault("tag_name", "스타일") + "’ 태그와 일치합니다.";
case HAIR_PROFILE_COMPATIBLE -> "현재 모발 특성과 잘 맞습니다.";
case HAIR_PROFILE_COMPATIBLE -> "등록한 모발 특성 중 일부 조건과 일치합니다.";
case CARE_TIME_FIT -> "평소 관리 가능 시간에 적합합니다.";
case EDITORIAL_FALLBACK -> "기본 추천 우선순위를 반영했습니다.";
};
Expand Down
Loading
Loading