Files
egbim_qa_platform/FEEDBACK_SYNC_GUIDE.md

260 lines
15 KiB
Markdown

# 피드백 작성 서버 ↔ 관리 콘솔 동기화 운영 가이드
이 문서는 이 저장소의 보안형 피드백 작성 페이지를 `https://feedback.hmac.kr/`의 기존 관리 콘솔과 연결하고, Gitea Actions로 스테이징에 배포하는 방법을 설명합니다.
## 1. 먼저 이해할 구조
이 연동은 별도 배치 작업이나 DB 복제가 아닙니다. 사용자의 작성 요청마다 작성 서버가 관리 콘솔의 Support API를 호출하는 서버사이드 proxy 방식입니다.
```text
브라우저
│ same-origin 요청
작성 서버의 Next.js API
│ Authorization 전달 + multipart 중계
https://feedback.hmac.kr/api/support
├─ Secretary: 관리 티켓·권한·첨부파일 메타데이터
├─ ABC: 피드백 원본
└─ 관리 콘솔: 목록·상세·상태·댓글·내부 메모
```
따라서 작성 서버에는 운영용 ABC API, Secretary API, MySQL, OpenSearch를 함께 배포하지 않습니다. 피드백 저장의 최종 확인 지점은 `feedback.hmac.kr`입니다.
## 2. 연동 전 준비
### 2.1 관리 콘솔 API 계약
관리 콘솔은 다음 경로를 제공해야 합니다.
```text
GET /api/support/access
GET /api/support/workspaces/{workspaceCode}/form-template
POST /api/support/workspaces/{workspaceCode}/tickets
POST /api/support/tickets/{ticketId}/completion-confirmation?workspaceCode={workspaceCode}
```
작성 서버가 브라우저에 제공하는 내부 경로는 다음과 같습니다.
```text
GET /api/support/access
GET /api/support/workspaces/{workspaceCode}/form-template
POST /api/support/workspaces/{workspaceCode}/submit
POST /api/support/tickets/{ticketId}/completion-confirmation?workspaceCode={workspaceCode}
```
`submit`은 multipart 요청을 파싱한 뒤 외부의 `/tickets`로 다시 전송합니다. 외부 API의 URL은 `SUPPORT_CONSOLE_API_BASE_URL`에서만 읽습니다.
완료 확인 요청은 작성자가 처리 결과를 확인했을 때 호출합니다. 요청 본문은 `{}`이며 작성 서버 proxy가 현재 로그인 세션의 Bearer 토큰과 `workspaceCode`를 관리 콘솔로 전달합니다. 관리 콘솔은 토큰의 `user_id`, `tenant_id`와 Q&A 작성자를 비교하고, 진행중·완료 안내 댓글·연결 이슈 완료·보류 여부를 검증해야 합니다. 같은 요청은 중복 상태 변경 없이 멱등하게 처리해야 합니다.
성공 응답은 다음 필드를 포함합니다.
```json
{
"feedback_id": 47,
"feedback_status": "COMPLETED",
"completed_at": "2026-09-04T12:00:00.000Z"
}
```
`409`는 진행중 상태가 아니거나 완료 안내 댓글이 없거나 연결 이슈가 아직 완료되지 않은 경우입니다. 기존 상태값을 사용하는 환경은 `COMPLETED` 대신 `RESOLVED`를 반환할 수 있으며, 작성페이지는 두 값을 완료로 표시합니다.
### 2.2 BARON-SSO RP 등록
작성 서버 전용 confidential client를 BARON-SSO에 등록합니다. 관리 콘솔의 client secret을 재사용하지 않습니다.
현재 스테이징 기본 주소를 직접 사용하는 경우 redirect URI는 다음과 같습니다.
```text
http://10.13.10.4:8864/api/auth/baron-sso/callback
```
실제 공개 주소가 HTTPS 도메인이라면 그 주소로 바꾸고, scheme·host·port·path를 한 글자도 다르게 등록하지 않습니다. 코드가 요청의 `Host``X-Forwarded-Proto`를 이용해 callback URI를 만들므로, 앞단 reverse proxy가 원래 scheme을 전달해야 합니다.
`10.13.10.4:8864`는 내부 스테이징 주소로 취급합니다. 인터넷에 직접 노출하지 말고 외부 공개가 필요하면 TLS reverse proxy를 앞에 두어 HTTPS, `Host`, `X-Forwarded-Proto`를 일관되게 전달합니다.
현재 production Compose와 workflow에는 `SSO_REDIRECT_URI`를 전달하지 않습니다. 고정 callback URI를 사용해야 하는 경우 BARON-SSO만 수정하지 말고 `docker-compose.prod.yml`, workflow, `local-sso.ts`의 동작을 함께 변경한 뒤 검증합니다.
### 2.3 JWT 계약
작성 서버는 BARON-SSO userinfo를 바탕으로 HS256 세션 JWT를 만들고 `HttpOnly` 쿠키에 넣습니다. proxy는 이 JWT를 `Authorization: Bearer ...`로 관리 콘솔에 전달합니다.
다음 두 조건이 모두 맞아야 합니다.
- 작성 서버의 `JWT_SECRET`이 설정되어 있어야 합니다.
- `feedback.hmac.kr`의 Support API가 같은 `JWT_SECRET`으로 토큰을 검증해야 합니다.
두 서버의 값이 다르면 SSO callback까지 성공해도 `/api/support/access`에서 `401`이 발생합니다. secret은 문서·이슈·로그에 기록하지 않고 보안 채널로 교환합니다.
### 2.4 workspace와 프로젝트 매핑
작성 URL의 `{workspaceCode}`는 관리 콘솔의 workspace 식별자입니다. 첫 예시는 다음입니다.
```text
/support/EGBIM_DEMO/new
```
이전 연동 확인에서 `EGBIM_DEMO`는 관리 콘솔의 ABC `projectId=8`, `channelId=9`에 매핑되었습니다. 이 값은 환경별 데이터에 속하므로 배포 때 숫자를 코드에 고정하지 말고, 관리 콘솔에서 현재 mapping을 다시 확인합니다. 프로젝트나 채널 이름이 중복되거나 workspace가 비활성 상태면 양식 조회와 등록이 실패할 수 있습니다.
## 3. 작성 서버 환경값
운영에서 중요한 값은 다음과 같습니다.
```text
NEXT_PUBLIC_API_BASE_URL=
NEXT_PUBLIC_FEEDBACK_ONLY=true
SUPPORT_CONSOLE_API_BASE_URL=https://feedback.hmac.kr/api/support
SSO_ISSUER=https://sso.hmac.kr/oidc
SSO_AUTHORIZATION_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/auth
SSO_TOKEN_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/token
SSO_USERINFO_ENDPOINT=https://sso.hmac.kr/oidc/userinfo
SSO_SCOPE=openid profile email
SSO_CLIENT_ID=<BARON-SSO 작성 서버 client id>
SSO_CLIENT_SECRET=<BARON-SSO 작성 서버 client secret>
JWT_SECRET=<관리 콘솔 Support API와 공유하는 HS256 secret>
SUPPORT_TENANT_ID=<userinfo에 tenant_id가 없을 때만>
WEB_PORT=8864
```
`NEXT_PUBLIC_` 접두사가 붙은 값은 브라우저 빌드에 포함될 수 있습니다. `SSO_CLIENT_SECRET`, `JWT_SECRET`은 절대 `NEXT_PUBLIC_`로 시작하게 만들지 않습니다. 운영에서 외부 API URL을 브라우저용 `NEXT_PUBLIC_API_BASE_URL`에 넣으면 CORS와 secret 노출 경계가 깨집니다.
## 4. Gitea Actions 배포
등록해야 하는 키와 `Variables`/`Secrets` 구분은 [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md)에 있습니다. workflow는 다음 순서로 실행됩니다.
1. `main` push 또는 수동 실행으로 시작합니다.
2. `STAGING_HOST`, `WEB_PORT`가 현재 고정된 스테이징 대상과 맞는지 검사합니다.
3. SSH private key와 `known_hosts`를 runner 임시 디렉터리에 만들고 strict host key 검증으로 접속합니다.
4. `.git`, `node_modules`, `.next`, `dist`, `uploads`, `.venv` 등을 제외한 소스를 원격 서버로 전송합니다.
5. 환경값을 base64로 인코딩해 원격 Bash에 전달하고, 서버 디스크에 `.env` 파일로 저장하지 않습니다.
6. `docker compose ... config --quiet``up -d --build`를 실행합니다.
7. Web의 `/api/health`를 최대 60회 확인하고 실패하면 Web 상태와 최근 로그를 출력합니다.
원격 사용자의 login shell이 zsh이어도 동작하도록 마지막 배포 스크립트는 명시적으로 `bash -s`로 실행합니다. 이 방식은 이전에 대형 명령 문자열을 SSH로 전달할 때 발생한 중첩 따옴표·shell 문법 오류를 피하기 위한 것입니다.
## 5. 최초 배포 절차
### 5.1 코드와 설정 검사
```bash
git status --short
pnpm lint
pnpm typecheck
pnpm build
```
`.env`, `.env.*` 실제 환경파일, API key, secret, private key가 변경 목록에 없는지 확인합니다. `apps/web/.env.example`에는 빈 값 또는 설명용 placeholder만 둡니다.
### 5.2 Gitea 등록
Gitea 저장소의 Actions 변수/Secret 화면에서 이름을 정확히 입력합니다. 따옴표를 값에 포함하지 않습니다. multiline private key와 `known_hosts`는 줄바꿈을 보존합니다.
최소 등록 세트:
```text
Variables: STAGING_HOST, WEB_PORT, SUPPORT_CONSOLE_API_BASE_URL,
SSO_ISSUER, SSO_AUTHORIZATION_ENDPOINT, SSO_TOKEN_ENDPOINT,
SSO_USERINFO_ENDPOINT, SSO_SCOPE, SSO_CLIENT_ID
Secrets: STAGING_USER, STAGING_SSH_PRIVATE_KEY, STAGING_SSH_KNOWN_HOSTS,
SSO_CLIENT_SECRET, JWT_SECRET
```
값의 기본값, 선택 여부, 등록 화면은 [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md)를 기준으로 합니다.
### 5.3 BARON-SSO redirect와 네트워크 확인
- Gitea runner에서 `10.13.10.4:22`로 SSH 접속할 수 있어야 합니다.
- 스테이징 서버의 배포 사용자가 Docker 명령을 실행할 수 있어야 합니다.
- 스테이징 서버에서 `feedback.hmac.kr``sso.hmac.kr`로 HTTPS 요청을 보낼 수 있어야 합니다.
- BARON-SSO에 실제 접속 주소의 callback URI가 등록되어 있어야 합니다.
### 5.4 배포 실행
`main`에 push하거나 Gitea Actions에서 `workflow_dispatch`를 실행합니다. workflow가 성공해도 기능 검증은 별도로 진행합니다.
수동으로 원격에서 실행할 때는 secret을 shell history나 로그에 남기지 말고, 동일한 runtime 환경을 주입합니다.
```bash
docker compose -f docker/docker-compose.prod.yml config --quiet
docker compose -f docker/docker-compose.prod.yml up -d --build
docker compose -f docker/docker-compose.prod.yml ps
```
## 6. 배포 후 검증
1. `http://10.13.10.4:8864/api/health``{ "status": "ok" }`를 반환하는지 확인합니다.
2. `/support/EGBIM_DEMO/new` 접속 시 로그인 화면으로 이동하는지 확인합니다.
3. 로그인 후 양식이 관리 콘솔의 workspace template에서 표시되는지 확인합니다.
4. 작성페이지에서 제목·구분·내용만 입력할 수 있는지 확인합니다. IP·MAC 필드는 작성자 화면에 표시하지 않습니다.
5. 이미지뿐 아니라 PDF·문서 등 일반 파일도 첨부합니다.
6. 성공 응답의 `ticket_id`, `sync_status`를 확인합니다.
7. `feedback.hmac.kr`에서 같은 제목·작성자·첨부파일을 조회합니다.
8. 관리 콘솔에서 처리 완료 안내 댓글을 등록한 뒤 작성자 상세페이지에 완료 확인 버튼과 요청 시각이 표시되는지 확인합니다.
9. 완료 확인 버튼을 한 번 눌러 `COMPLETED`(또는 호환 환경의 `RESOLVED`)와 `completed_at` 응답을 확인합니다.
10. 같은 버튼 요청을 반복해도 중복 완료 처리 없이 성공 응답이 유지되는지 확인합니다.
11. 파일당 30MB 초과, 전체 30MB 초과, 10개 초과가 `413`으로 차단되는지 확인합니다.
관리 콘솔에 표시되지 않으면 작성 서버에서 데이터를 재생성하거나 DB를 초기화하지 않습니다. 브라우저 응답, 관리 콘솔 API 로그, workspace mapping, 두 서버의 JWT secret을 순서대로 확인합니다.
## 7. 시행착오에서 정리한 원칙
### `localhost` API 주소를 production build에 넣지 않기
처음에는 브라우저용 `NEXT_PUBLIC_API_BASE_URL`과 서버 내부 주소가 섞여 CORS 또는 잘못된 대상 호출이 발생할 수 있었습니다. 현재 피드백 전용 빌드는 브라우저 API base URL을 빈 값으로 고정하고, 외부 주소는 서버 전용 변수로만 전달합니다.
### 관리 콘솔 login을 재사용하지 않기
작성 페이지 주소가 달라졌다고 관리 콘솔의 브라우저 세션을 그대로 사용하면 callback과 cookie 범위가 꼬입니다. 작성 서버가 별도 BARON-SSO RP로 code 교환을 수행하고, 관리 콘솔 API에는 세션 JWT만 전달합니다.
### API key를 작성 브라우저에 넣지 않기
피드백 전용 작성 흐름은 사용자의 SSO 세션으로 관리 콘솔 API를 호출합니다. `MASTER_API_KEY`, `SECRETARY_ABC_API_KEY`, ABC API key를 브라우저 bundle이나 운영 Compose에 추가하지 않습니다. API key가 필요한 별도 서버 연동은 관리 콘솔 서버의 책임입니다.
### 첨부파일은 JSON이 아닌 multipart로 전달하기
첨부파일이 이미지에만 한정되거나 JSON으로 변환되면 PDF·문서 등록이 깨질 수 있습니다. 작성 서버의 proxy는 `bodyParser: false`와 formidable을 사용해 multipart를 읽고, 외부 API에 `FormData`로 전달합니다. 파일 제한은 클라이언트와 서버 양쪽에서 확인합니다.
### 원격 shell의 quoting에 의존하지 않기
SSH 한 줄 명령 안에 secret, 배열, loop를 모두 넣으면 login shell에 따라 문법이 달라질 수 있습니다. 현재 workflow는 SSH 전송 후 명시적인 `bash -s` 블록에서 환경을 복원하고 Compose를 실행합니다. 배포 workflow를 수정할 때 이 경계를 유지합니다.
### 작업 이력에서 확인할 수 있는 전환점
다음 commit들이 현재 운영 방식을 결정한 주요 기록입니다.
| commit | 의미 |
| --- | --- |
| `33453ec` | 최초 스테이징 배포 구성 |
| `aaddfc7` | 피드백 작성 API와 서버사이드 SSO/proxy 적용 |
| `483f5a0` | 첨부파일 전달 경로 수정 |
| `090e9fd` | 원격 login shell 차이를 피하기 위해 Bash script 실행 방식 수정 |
이전 workflow는 API·Secretary·DB 환경값을 대량으로 전송하고 ABC API key를 요구했지만, 현재 피드백 전용 workflow는 외부 관리 콘솔의 API 계약만 사용합니다. 과거 문서의 `172.16.10.175:3030`, `NEXT_PUBLIC_API_BASE_URL` 외부 주소, `MASTER_API_KEY` 필수 표기는 현재 배포 기준으로 사용하지 않습니다.
## 8. 장애 대응과 롤백
```bash
docker compose -f docker/docker-compose.prod.yml ps
docker compose -f docker/docker-compose.prod.yml logs --tail=200 web
curl -i http://127.0.0.1:8864/api/health
```
- `401`: SSO userinfo의 `sub`/`tenant_id`, `JWT_SECRET` 일치 여부, 관리 콘솔 access API를 확인합니다.
- `404`: 외부 base URL에 `/api/support`가 포함되는지와 workspace code를 확인합니다.
- `413`: 파일 개수·용량 제한을 확인합니다.
- `502`: 작성 서버에서 관리 콘솔로의 네트워크, DNS, TLS, 외부 API 상태를 확인합니다.
- callback 오류: BARON-SSO redirect URI와 `Host`/`X-Forwarded-Proto`를 확인합니다.
롤백은 마지막 정상 commit을 기준으로 다시 배포합니다. 작성 서버에는 운영 DB가 없으므로 DB rollback을 수행하지 않지만, 관리 콘솔 DB migration이 포함된 버전과 이 서버의 피드백 전용 배포를 혼동하지 않습니다. 기존 데이터가 있는 어떤 서버에서도 `docker compose down -v`를 무심코 실행하지 않습니다.
## 9. 변경 시 함께 확인할 파일
- `docker/docker-compose.prod.yml`: production이 `web`만 실행하는지
- `.gitea/workflows/deploy-staging.yml`: 실제로 읽는 Variables/Secrets와 기본값
- `docker/web.dockerfile`: build-time 공개 변수와 runtime server 변수의 경계
- `apps/web/src/server/local-sso.ts`: callback, state, 세션 JWT, cookie
- `apps/web/src/server/support-external.ts`: 외부 Support API URL 조립
- `apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts`: multipart 중계와 제한
- `FEEDBACK_ONLY_TASKS.md`: 작업 당시의 완료/보류 기록