개발 환경 문제 해결
1. 화면이 비어 있고 콘솔에 does not provide an export named ...가 보인다
앱을 불러오지 못했습니다: Uncaught SyntaxError:
The requested module '/@fs/app/node_modules/.vite/deps/@hohyun94_portal-foundation.js?v=...'
does not provide an export named 'PortalDescriptions'원인은 설치된 vendor 패키지가 옛 버전인 상태(또는 그 상태로 만들어진 Vite 의존성 사전 번들 캐시)입니다. 소스는 새 export(PortalDescriptions, PortalMetricGrid 등)를 import 하는데 node_modules/@hohyun94/portal-foundation의 dist가 이전 tarball이라 모듈 로드 단계에서 앱이 마운트되지 않습니다. 렌더 오류가 아니므로 ErrorBoundary가 아니라 client/index.html의 부팅 폴백 메시지가 표시됩니다.
file:vendor/*.tgz 의존성은 저장소를 pull 해도 node_modules가 자동으로 갱신되지 않습니다. 컨테이너·볼륨에 node_modules가 남아 있는 환경에서 자주 발생합니다.
자동 복구(권장)
이제 vite.config.ts와 predev 훅이 기동 시점에 설치 버전을 확인하고 자동으로 맞춥니다.
git pull
pnpm dev # predev가 버전 확인 → 필요 시 pnpm install 또는 tarball 복구 → .vite 정리복구 순서는 다음과 같습니다.
pnpm install --prefer-offline시도- 실패하면(오프라인 등)
vendor/*.tgz를node_modules/<패키지>에 직접 추출 - 그래도 버전이 맞지 않으면 dev 서버를 띄우지 않고 원인 메시지를 출력
수동 복구(즉시 조치)
# 컨테이너(/app) 또는 저장소 루트에서
rm -rf node_modules/.vite
pnpm install --prefer-offline
# 네트워크가 없으면 vendor tarball에서 직접 추출
tar -xzf vendor/hohyun94-portal-foundation-0.6.139.tgz -C node_modules/@hohyun94/portal-foundation --strip-components=1
# 확인
node -p "require('./node_modules/@hohyun94/portal-foundation/package.json').version" # 0.6.139
node -e "const t=require('fs').readFileSync('node_modules/@hohyun94/portal-foundation/dist/index.js','utf8');console.log(t.includes('PortalDescriptions'))" # true
pnpm dev점검 명령
node scripts/portalFoundationGuard.mjs # 설치 버전·Vite 캐시 점검 및 자동 복구2. vendor 패키지를 올린 뒤 개발 서버가 이상하다
- vendor tarball을 교체하면
node_modules/.vite(Vite 의존성 사전 번들)에는 이전 export 목록이 남습니다. 위 가드가 자동으로 지우지만, 수동으로는rm -rf node_modules/.vite후 dev 서버를 재시작하면 됩니다. pnpm-lock.yaml과package.json이 어긋나면 CI가ERR_PNPM_OUTDATED_LOCKFILE로 실패합니다.pnpm install로 락파일을 함께 커밋하세요.
3. 테스트에서 React is not defined가 난다
vitest.config.ts에 React 플러그인이 없으면 esbuild가 JSX를 클래식 런타임(React.createElement)으로 변환합니다. 즉 테스트가 렌더하는 컴포넌트 파일은 React를 import 해야 합니다. 앱 빌드(vite.config.ts)는 automatic 런타임이라 브라우저에서는 문제가 없어, 테스트에서만 실패하는 함정이 됩니다.
- 현재
vitest.config.ts는 앱과 동일하게@vitejs/plugin-react를 사용하도록 맞춰 두었습니다. - 그래도 컴포넌트를 새로 만들 때는 리포지토리 관례대로
import React from "react";(또는import * as React)를 함께 두면 안전합니다.
5. vendor tarball을 추가할 때 (커밋 누락 주의)
.gitignore가 *.tgz를 제외하므로 git add -A로는 vendor tarball이 커밋되지 않습니다. package.json이 file:vendor/...tgz를 참조하는데 그 파일이 저장소에 없으면 다른 환경(CI·컨테이너·배포)에서 설치가 실패하거나 옛 버전이 남습니다.
cp <새 tarball> vendor/hohyun94-portal-foundation-X.Y.Z.tgz
git add -f vendor/hohyun94-portal-foundation-X.Y.Z.tgz # -f 필수
node scripts/portalFoundationGuard.mjs # 커밋 여부 경고 확인portalFoundationGuard가 커밋되지 않은 tarball을 찾으면 경고를 출력합니다(git ls-files 기준).
6. 화면이 예전 레이아웃으로 보인다 (예: 단계 카드가 4개/행)
vendor 패키지를 올렸는데 브라우저가 옛 번들을 실행하면 이전 레이아웃이 그대로 보인다. 실제로 어떤 버전이 실행 중인지 아래로 확인한다.
// 브라우저 콘솔
document.documentElement.dataset.pfUiVersion // 예: "0.6.145"- 표준프로세스 단계 카드 영역은 실행 중인 레이아웃 모드를 DOM에 남긴다.
- 최신(한 줄 유연 배치):
<div class="pf-metric-grid pf-metric-grid--fluid" data-pf-metric-layout="fluid" style="--pf-metric-min-width: 136px">+ 자식.pf-metric-card7개 - 옛 번들(반응형 기본값):
class="pf-metric-grid ant-row"+ant-col-*(넓은 화면에서 4개/행)
- 최신(한 줄 유연 배치):
- 값이 기대와 다르면 컨테이너/로컬에서 아래를 실행한다.
git pull
pnpm install # 또는 pnpm dev (predev 가드가 자동 수행)
rm -rf node_modules/.vite
pnpm dev
# 브라우저 강력 새로고침(Ctrl+Shift+R)node -p "require('@hohyun94/portal-foundation/package.json').version"이 package.json의 vendor 버전과 같은지도 확인한다.
7. does not provide an export named ...가 새로 뜬다 (dev 서버 실행 중 pull)
증상: 앱 소스는 새 export를 import 하는데 설치된 vendor 패키지가 옛 버전이라 모듈 로드가 실패한다.
does not provide an export named 'PORTAL_FOUNDATION_VERSION' — /@fs/app/node_modules/.vite/deps/@hohyun94_portal-foundation.js원인은 dev 서버가 실행 중인 상태에서 git pull한 경우다. Vite가 세션 중 재최적화를 해도 그 재료는 여전히 옛 설치본이다. 설치를 맞추는 가드(predev, vite.config.ts)는 서버가 시작될 때만 실행되므로 반드시 재시작해야 한다.
git pull
pnpm install # 또는 아래처럼 dev 서버 재시작(predev 가드가 자동 수행)
rm -rf node_modules/.vite
pnpm dev
# 브라우저 강력 새로고침(Ctrl+Shift+R)참고로 앱은 버전 상수를 네임스페이스 import로 읽어(client/src/lib/portalFoundationVersion.ts) 옛 패키지에서도 화면이 죽지 않고 unknown으로 표시한다. 콘솔에 관련 경고가 보이면 위 절차를 수행하면 된다.
8. 도커 개발 스택에서 vendor 버전이 반영되지 않을 때
docker-compose.dev.yml의 각 서비스는 소스만 바인드 마운트하고 node_modules는 익명 볼륨을 쓴다(- /app/node_modules). 그래서 호스트에서 pnpm install 해도 컨테이너 안 설치는 옛 버전으로 남는다. 증상은 "옛 레이아웃(예: 카드 4개/행)" 또는 새 export 오류다.
# 컨테이너 설치 버전 확인
docker exec process-center-dev node -e "console.log(require('/app/node_modules/@hohyun94/portal-foundation/package.json').version)"
# 가장 간단한 복구: 재시작 (predev 가드가 설치 버전을 맞추고 .vite를 정리한다)
docker restart process-center-dev
# 설치를 명시적으로 맞추고 싶을 때(오프라인 가능)
docker exec process-center-dev tar -xzf /app/vendor/hohyun94-portal-foundation-0.6.145.tgz -C /app/node_modules/@hohyun94/portal-foundation --strip-components=1
docker exec process-center-dev rm -rf /app/node_modules/.vite
docker restart process-center-dev컨테이너를 새로 만들어야 할 때는 docker compose -f docker-compose.dev.yml up -d --force-recreate process-center를 쓴다. 복구 후 확인:
docker exec process-center-dev grep -c pf-metric-grid--fluid /app/node_modules/@hohyun94/portal-foundation/dist/styles.css # 1 이상
docker exec process-center-dev grep -l PORTAL_FOUNDATION_VERSION /app/node_modules/.vite/deps/*.js # 1개 이상9. 배포 화면에서만 빈 화면일 때
- 서버 번들이 최신인지 확인합니다:
pnpm build후node dist/index.js가dist/public을 함께 제공해야 합니다. - 마이그레이션을 적용하지 않았으면 배포 프로파일(
deploymentProfile) 등 설정 화면 값이 어긋날 수 있습니다(PostgreSQL:pnpm db:postgres:migrate:node). - 브라우저 캐시로 옛 자산을 요청하는 경우가 있으므로 강력 새로고침(Ctrl+Shift+R)을 먼저 확인합니다.
10. 중앙 로그인(DirectoryService)이 안 될 때
로그인은 ProcessCenter → PortalConsole(/connect/authorize) → ProcessCenter(/auth/callback) 3단계다. 로그인 창에 표시되는 문구가 어느 단계에서 막혔는지 알려준다.
| 화면 문구 | 원인 | 조치 |
|---|---|---|
아이디 또는 비밀번호가 올바르지 않거나 로그인할 수 없는 계정입니다. | 디렉터리 계정이 아니거나 비밀번호 오류 | 디렉터리 계정(예: hohyun)으로 입력 |
이 계정은 해당 포털에 등록되어 있지 않습니다. | 콘솔 관리자 계정으로 포털 로그인을 시도했거나, 디렉터리 계정에 포털 접근 배정이 없음 | 콘솔(http://localhost:3100/login)에 먼저 로그인해 공유 세션을 만들거나, 디렉터리 계정에 접근 배정 |
DirectoryService 중앙 로그인이 설정되지 않았습니다. | PORTAL_CONSOLE_ORIGIN(또는 저장된 연동 주소) 없음 | 컨테이너 환경 변수 확인 |
로그인 요청이 만료되었거나 유효하지 않습니다. | state 쿠키 만료(10분)·쿠키 차단·리다이렉트 왕복 실패 | 같은 브라우저에서 다시 시도 |
핵심 규칙(콘솔 서버 server/centralAuth.ts): 콘솔 관리자 계정은 ui-center 포털에서만 통과하고, 다른 포털은 디렉터리 계정 + 포털 접근 배정(portal_access_assignments)이 있어야 한다. 개발용 콘솔 계정은 PORTAL_CONSOLE_SSO_MAP_JSON(예: admin → hohyun)으로 디렉터리 신원에 매핑되므로, 콘솔에 먼저 로그인하면 포털은 비밀번호 입력 없이 SSO로 통과한다.
# 1) 앱 쪽 인증 경로 점검 (정상: enabled=true, 302 + /connect/authorize, /auth/callback 302)
curl -s http://localhost:3000/api/auth/config
curl -s -i --max-redirs 0 "http://localhost:3000/api/auth/login?returnTo=%2F" | Select-String -Pattern 'HTTP/|location:'
# 2) 콘솔에 포털이 등록되어 있는지 (redirect_uris에 http://localhost:3000/auth/callback 필요)
docker exec portal-dev-postgres psql -U portal_dev -d directory_service \
-c "select portal_id, client_id, redirect_uris, enabled from portal_configurations where portal_id='process-center';"11. 로컬에서 로그인 왕복 없이 화면을 테스트할 때 (개발 전용 세션 발급)
SSO 3단계가 로컬에서 막혀도(로컬 callback 미등록·디렉터리 계정 자격 증명 없음) 화면 검증은 할 수 있다. .env에 아래를 넣고 dev 서버를 켠 뒤 /api/dev/login을 한 번 열면, 서버가 /auth/callback과 같은 서명 규칙으로 process_center_directory_session 쿠키를 발급하고 returnTo(기본 /)로 되돌린다.
DEV_AUTO_LOGIN=true
DEV_AUTO_LOGIN_OPEN_ID=directory:usr_ec283a37397d4663be87c1ed2fce2329 # users.openId
DEV_AUTO_LOGIN_SESSION_HOURS=12 # 선택(기본 12시간)# 발급 확인 (302 → /flow, 이어서 auth.me 200)
curl -s -c /tmp/dev.jar -o /dev/null -w '%{http_code} %{redirect_url}\n' 'http://localhost:3000/api/dev/login?returnTo=%2Fflow'
curl -s -b /tmp/dev.jar http://localhost:3000/api/trpc/auth.me- 브라우저는
http://localhost:3000/api/dev/login(원하는 경로는?returnTo=/flow)을 즐겨찾기해 두면 된다. - 해당
openId가users에 없으면 개발용 행(roleadmin)을 만들어 준다. - 안전장치:
NODE_ENV=production이면 경로가 등록되지 않고,DEV_AUTO_LOGIN=true가 없으면 아무 것도 하지 않는다. 이 플래그는.env(gitignore 대상)에만 두고 배포 환경 변수로 옮기지 않는다. - 실제 SSO 왕복까지 검증해야 할 때는 위 §10의 "검증된 재현 절차"로 콘솔에 포털 callback과 디렉터리 계정을 등록한다.
12. Coolify 등 컨테이너 배포에서 pnpm run build가 node_modules/.vite에서 실패할 때
증상: 로컬 pnpm build는 통과하는데 Coolify 배포만 실패한다.
#23 5.213 at _rmdirSync (node:internal/fs/rimraf:236:5)
#23 5.213 at rimrafSync (node:internal/fs/rimraf:194:7)
#23 5.213 at rmSync (node:fs:1239:10)
#23 5.213 at clearStaleViteCache (file:///app/node_modules/.vite-temp/vite.config.ts.timestamp-….mjs:69:5)
#23 5.213 at ensureVendoredDependencies (…:107:24)
#23 ERROR: process "pnpm run build" did not complete successfully: exit code: 1원인은 세 가지가 겹친 것이다.
- Coolify가 이 앱을 Railpack 빌더로 빌드한다(Coolify 로그의
build_railpack_image). Railpack은 Vite 프로젝트에서node_modules/.vite를 BuildKit 캐시 마운트로 붙인다. vite.config.ts가 모듈을 로드할 때 실행되는 vendor 자가 복구 가드가vite build에서도 돌았다.- 그 가드의 캐시 정리(
rmSync(node_modules/.vite))가 마운트 포인트를 지우려다EBUSY: resource busy or locked로 실패했고, 예외가pnpm run build를 종료시켰다. 로컬은.vite가 일반 디렉터리라 재현되지 않는다.
저장소 조치(반영됨):
vite.config.ts는 복구(allowInstall·allowRepair·clearCache)를 dev 서버(command === "serve")에서만 실행하고, 빌드에서는 설치 버전을 읽어 확인만 한다(불일치면 명확한 메시지로 중단).clearStaleViteCache는 예외를 던지지 않는다. 디렉터리를 지울 수 없으면 내용만 비우고, 그것도 안 되면deferred로 보고한 뒤 진행한다.- 회귀 검증:
npx vitest run server/portalFoundationGuard.test.ts
Coolify 설정으로만 우회하려면 해당 애플리케이션의 Build Pack을 Dockerfile로 지정한다(저장소의 Dockerfile은 캐시 마운트를 쓰지 않는다). 이미지 캐시 이점이 달라지므로 코드 수정을 먼저 적용하는 편이 낫다.
3) 디렉터리 계정에 포털 접근 배정이 있는지 (0행이면 로그인이 거부된다)
docker exec portal-dev-postgres psql -U portal_dev -d directory_service
-c "select * from portal_access_assignments where portal_id='process-center';"
4) 마지막 로그인 성공 시각
docker exec portal-dev-postgres psql -U portal_dev -d process_center
-c "select id, "openId", role, "lastSignedIn" from users order by id desc limit 5;"
5) 콘솔 로그에서 SSO 이벤트 확인 (authorized:true면 공유 세션 통과)
docker logs portal-console-dev 2>&1 | Select-String 'directory_sso'
`directory_sso_authorize_check ... authorized:false`는 브라우저에 공유 세션 쿠키(`directory.sso.session`)가 없다는 뜻이다. 콘솔 로그인을 **다른 브라우저/시크릿 창**에서 했거나 `127.0.0.1`로 접속해 쿠키 호스트가 어긋난 경우에 발생한다. ProcessCenter는 항상 `PORTAL_CONSOLE_ORIGIN`(개발: `http://localhost:3100`)으로 이동하므로 **콘솔도 `localhost`로 열어야** 세션이 공유된다.
콘솔 자체가 Vite 변환 오류(`Pre-transform error`, `/src/main.tsx` 504)를 내면 로그인 페이지가 열리지 않는다. 콘솔 저장소를 편집 중이었다면 편집을 멈추고 콘솔만 재시작한다.
```bash
docker restart portal-console-dev
docker logs --tail 20 portal-console-dev검증된 재현 절차 (로그인이 정말 되는지 확인)
콘솔 관리자 API Key(docker-compose.dev.yml의 PORTAL_CONSOLE_ADMIN_API_KEYS_JSON)로 테스트 계정을 만들고, 브라우저와 같은 순서로 3단계를 호출하면 어느 단계에서 막히는지 그대로 드러난다.
KEY="x-directory-admin-key: local-dev-directory-admin-key-0123456789"
# 1) 테스트 디렉터리 계정 생성 (부서 ID는 기존 값 사용)
curl -s -X POST http://localhost:3100/api/trpc/directory.createUser -H "$KEY" -H "content-type: application/json" \
--data-binary '{"json":{"loginId":"pc-login-test","name":"로그인 테스트","email":"pc-login-test@local.test","departmentId":"dept_de0054ecb99749379021e5b4fcdf02df","employmentStatus":"ACTIVE"}}'
# 2) 비밀번호 설정 + 포털 접근/관리자 지정 (userId는 1단계 응답의 id로 바꾼다)
curl -s -X POST http://localhost:3100/api/trpc/directory.setUserPassword -H "$KEY" -H "content-type: application/json" \
## 13. 앱은 떠 있는데 DB 조회만 실패할 때 (`Connection terminated ...`)
증상: 화면이 아무것도 못 불러오고(엔티티·목록 비어 있음), BPMN 내보내기 파일도 노드 없이 나온다. 서버 로그는 아래를 반복한다.[directory-gateway] auth config lookup failed DrizzleQueryError ... portal_runtime_settings cause: Error: Connection terminated unexpectedly (타임아웃 옵션 도입 후: ... due to connection timeout) [ProcessEditorRetry] scheduler run failed ...
### 먼저 분리 진단 (앱 컨테이너 안에서)
```bash
# 1) DB 자체는 정상인가 (postgres 컨테이너)
docker exec portal-dev-postgres psql -U portal_dev -d process_center -tAc "select 1;"
# 2) 앱 컨테이너에서 네트워크·DNS
docker exec -i process-center-dev node -e 'const dns=require("dns");dns.lookup("postgres",(e,a)=>console.log("DNS",e?e.message:a));const net=require("net");const s=net.connect(5432,"postgres");s.setTimeout(4000);s.on("connect",()=>{console.log("TCP_OK");s.end()});s.on("timeout",()=>{console.log("TCP_TIMEOUT");s.destroy()});s.on("error",e=>console.log("TCP_ERR",e.message));'
# 3) 앱과 같은 코드 경로를 별도 프로세스로 실행 (성공하면 코드·설정은 정상)
docker exec -i process-center-dev sh -c 'cat > /app/tmp-probe.mjs' <<'EOF'
import "dotenv/config";
import { sql } from "drizzle-orm";
import { createDatabase } from "/app/server/db/provider.ts";
const db = createDatabase(process.env.DATABASE_URL);
console.log("URL=", process.env.DATABASE_URL);
try { console.log("OK", (await db.execute(sql`select count(*)::int as users from users`)).rows); }
catch (e) { console.log("FAIL", e.message, "|", e.cause?.message); }
EOF
docker exec process-center-dev sh -c "cd /app && npx tsx /app/tmp-probe.mjs; rm -f /app/tmp-probe.mjs"실측(2026-09-16): 1) 정상 · 2) DNS/TCP 정상 · 3) 성공(users: 3). 즉 코드·자격증명·네트워크·풀 설정은 정상이고, 떠 있는 앱 프로세스의 풀만 연결을 못 맺는 상태였다. 이런 경우는 프로세스가 오래된 소켓/풀 상태를 붙들고 있는 것이므로 순서대로 시도한다.
- 앱 컨테이너 재시작 →
docker restart process-center-dev - 스택 네트워크 재생성(볼륨 유지) →
docker compose -f docker-compose.dev.yml down && docker compose -f docker-compose.dev.yml up -d - Docker Desktop 재시작(트레이 → Restart) — 엔진 레벨 소켓 프록시가 고착됐을 때의 최종 수단
- 그래도 안 되면 호스트에서 실행 →
.env의DATABASE_URL이127.0.0.1:5432(도커 postgres가 호스트 포트로 게시됨)를 가리키므로 컨테이너 없이 앱만 띄워 분리 확인
호스트에서 앱만 띄우기 (Windows 포함)
package.json의 dev 스크립트는 NODE_ENV=development tsx … 형태(유닉스식 접두사)라 Windows cmd/PowerShell에서 그대로는 실행되지 않는다('NODE_ENV'은(는) 내부 또는 외부 명령…). dev:host 스크립트가 NODE_ENV를 설정한 뒤 tsx를 실행하므로 플랫폼 구분 없이 쓸 수 있다.
cd C:\H_PROJECT\ProcessCenter
$env:PORTAL_CONSOLE_ORIGIN = "http://localhost:3100" # 중앙 로그인(콘솔) 주소
$env:PORTAL_CONSOLE_API_KEY = "<dev compose의 API Key>" # 조직·사용자 동기화용
pnpm run dev:host- 컨테이너 앱과 포트(3000)가 겹치므로 먼저
docker stop process-center-dev로 비운다. - 확인:
curl -s http://localhost:3000/api/auth/config→{"enabled":true,…}가 나오면 DB 조회까지 정상이다(실패 경로는enabled:false). - 되돌릴 때: 호스트 node 프로세스를 종료하고
docker start process-center-dev. - 실측(2026-09-16): 컨테이너 앱만 DB 풀 연결이 막힌 상태에서 위 방식으로 띄우니 마이그레이션·조회·로그인(302)까지 정상 동작했다 → 코드·설정·DB에는 문제가 없다는 증거.
- 스크립트 자체 검증:
PORT=3105 pnpm run dev:host로 다른 포트에서 띄워 부팅 로그(Server running on http://localhost:3105/)까지 확인했다.
14. 마이그레이션 추적과 실제 스키마가 어긋날 때 (column … does not exist)
증상: 화면에서 Failed query: select … from "workflow_transitions" … 처럼 쿼리 문장과 함께 오류가 뜬다. 연결 오류(Connection terminated …)와 달리 컬럼/테이블 이름 문제다.
원인: drizzle.__drizzle_migrations 추적 테이블에는 적용 완료로 기록돼 있는데 실제 테이블에 그 컬럼이 없는 상태(DB 복원·다른 브랜치의 스키마 적용 등). 실측(2026-09-16): 추적 15건인데 workflow_transitions."relationshipType"(마이그레이션 0009)이 없어 L5 흐름맵·하위 L6 동기화가 모두 실패했다.
# 1) 기대 컬럼과 실제 컬럼 비교
docker exec portal-dev-postgres psql -U portal_dev -d process_center -tAc \
"select column_name from information_schema.columns where table_name='workflow_transitions' order by ordinal_position;"
# 2) 누락 마이그레이션만 직접 적용 (파일은 멱등하게 작성돼 있으면 그대로, 아니면 실패 라인만 확인)
Get-Content drizzle\postgres-migrations\0009_explicit_workflow_relationship_type.sql |
docker exec -i portal-dev-postgres psql -U portal_dev -d process_center
# 3) 테이블 누락 여부 확인 (선언 vs 실제)
docker exec portal-dev-postgres psql -U portal_dev -d process_center -tAc \
"select table_name from information_schema.tables where table_schema='public' order by 1;"- 마이그레이션 러너(
pnpm db:postgres:migrate:node)는 추적 테이블을 믿으므로, 이 상황에서는 "적용 완료"라고만 출력하고 넘어간다. 컬럼 비교로 먼저 확인한다. - 새 컬럼을 스키마에 추가했다면 반드시
drizzle/postgres-migrations/00NN_*.sql(+drizzle/00NN_*.sql)과 journal을 함께 커밋한다.
원인은 저널 누락이었다 (2026-09-17 확인)
파일을 넣고 journal에 등재하지 않으면 러너는 그 파일을 읽지도 않는다. 실제로 그런 상태였다.
# 폴더에는 17개, journal에는 15개 → 0008 다음이 0011로 건너뛴다
(Get-ChildItem drizzle/postgres-migrations -Filter *.sql).Count
(Get-Content drizzle/postgres-migrations/meta/_journal.json -Raw | ConvertFrom-Json).entries.tag- 누락된 파일:
0009_explicit_workflow_relationship_type(컬럼 추가) ·0010_restore_explicit_l5_handoffs(기존 handoff 재분류) - 그래서
workflow_transitions."relationshipType"이 없는 DB에서 조회가column … does not exist로 실패했고, 러너는 계속 "successfully"만 출력했다. - 복구: 두 항목을 journal 끝에 추가(
idx15·16)한 뒤pnpm db:migrate→drizzle.__drizzle_migrations가 15 → 17건이 되었다. SQL이 재실행 안전(ADD COLUMN IF NOT EXISTS· 조건부UPDATE)해서, 이미 손으로 적용해 둔 DB에도 기록만 더해진다. - 주의: journal의
when은 적용 여부 판단에 쓰인다(마지막 적용 시각보다 큰 항목만 실행). 그래서 새 항목의when은 기존 마지막 값보다 크게 두고, 배열에서도 뒤에 둔다.
원격 배포에서 확인하기 (원격 DB에 직접 접근할 수 없을 때)
배포본도 시작할 때 같은 검사를 수행해 로그로 남긴다(server/services/schemaDriftCheck.ts).
- 일치:
[schema] 선언 스키마와 DB가 일치합니다(테이블 34개 확인) - 어긋남:
[schema] 선언 스키마와 DB가 어긋납니다 — drizzle/postgres-migrations의 저널(meta/_journal.json)에 빠진 마이그레이션이 있는지 확인하세요. [schema] 없는 컬럼: workflow_transitions -> relationshipType [schema] 참고: docs/DEV_TROUBLESHOOTING.md §14 · pnpm db:check-drift
기동은 계속되므로 서비스가 죽지 않고, Coolify·컨테이너 로그에서 [schema] 줄만 보면 원격 DB 상태를 알 수 있다.
원격(process.rlab.monster 등)에서 column … does not exist가 났다면:
- 저널에 빠진 마이그레이션을 등재한 커밋이 그 배포에 포함됐는지 확인한다(
drizzle/postgres-migrations/meta/_journal.json). - 재배포(또는 앱 재시작)하면 시작 시 마이그레이션이 실행되며 빠진 컬럼이 채워진다.
- 로컬 dev DB에 손으로 넣은 컬럼은 원격에 반영되지 않는다 — 배포 환경마다 따로 채워져야 한다.
재발 감지: pnpm db:check-drift
선언 스키마(drizzle/schema.pg.ts)와 실제 information_schema.columns를 비교한다. 차이가 있으면 [컬럼 없음]/[테이블 없음]을 출력하고 종료 코드 1로 끝난다(게이트로 쓸 수 있다).
pnpm db:check-drift
# 선언 테이블=34 · 실제 테이블=35
# [선언에 없는 테이블] process_editor_asset_delete_queue (마이그레이션 추적용 표)
# 결과: 없는 테이블 0개 · 없는 컬럼 0개재발 완화(코드에 반영됨)
server/db/provider.ts의 PostgreSQL 풀에 아래를 둔다. 없으면 유휴 클라이언트 오류가 unhandled 'error'로 올라와 dev 프로세스가 죽고(ELIFECYCLE Command failed), 이후 요청이 무한 대기한다.
pool.on("error", ...)— 끊긴 유휴 연결을 흡수idleTimeoutMillis(10s)·connectionTimeoutMillis(5s) — 끊긴 연결 재사용·무한 대기 방지(무응답 → 즉시 503)statement_timeout·query_timeout(30s) — 긴 쿼리가 풀 슬롯을 점유해 이후 요청이 멈추는 상황 방지환경 변수로 조정:
DB_POOL_IDLE_TIMEOUT_MS·DB_POOL_CONNECT_TIMEOUT_MS·DB_POOL_STATEMENT_TIMEOUT_MS·DB_POOL_QUERY_TIMEOUT_MS--data-binary '{"json":{"userId":"<usr_...>","password":"PcLoginTest!2026","mustChangePassword":false}}' curl -s -X POST http://localhost:3100/api/trpc/directory.setPortalAccessAssignment -H "$KEY" -H "content-type: application/json"
--data-binary '{"json":{"portalId":"process-center","userId":"<usr_...>","enabled":true}}' curl -s -X POST http://localhost:3100/api/trpc/directory.setPortalAdminAssignment -H "$KEY" -H "content-type: application/json"
--data-binary '{"json":{"portalId":"process-center","userId":"<usr_...>","enabled":true}}'
정상일 때의 3단계 결과(실측):
1. `GET /api/auth/login` → `302` + `Set-Cookie: directory_oauth_state=…`
2. `POST /connect/authorize`의 `centralAuth.login`(아이디·비밀번호) → `200` + `redirectUrl`(code 포함) + `directory.sso.session` 쿠키
3. 쿠키를 실은 `GET <redirectUrl>` → `302 /` + `Set-Cookie: process_center_directory_session=…`, 이어서 `auth.me` → `200` 사용자 JSON
공유 세션만으로 통과하는지도 확인할 수 있다. 2)에서 받은 `directory.sso.session` 쿠키로 `centralAuth.session`을 호출하면 `{"authorized":true,"redirectUrl":"…&code=…"}`가 온다.
### 로그인 화면(카드) 노출 규칙
중앙 인증이 활성이면 Process Center는 자체 로그인 카드를 띄우지 않고 **곧바로 DirectoryService 로그인 화면으로 이동**한다. `DirectoryService에서 계속` 버튼이 있는 카드는 자동 이동이 불가능할 때만 나타난다.
- 중앙 인증 비활성(`/api/auth/config`의 `enabled: false`)
- 설정 조회 실패
- 이미 실패한 왕복(`?auth_error=…`) 또는 `?login=manual`
- 같은 탭에서 자동 이동을 이미 시도했지만 세션이 열리지 않은 경우(무한 리다이렉트 방지)
로그인 방식(중앙 인증 여부)을 확인하는 동안에는 버튼 없이 `로그인 방식을 확인하고 있습니다…`만 표시한다. 카드를 강제로 보려면 `?login=manual`을 붙여 접속한다.
## 15. 로그아웃(단일 로그아웃)
- **앱 로그아웃**(헤더의 로그아웃 → `auth.logout`)은 ProcessCenter 세션 쿠키와 **공유 SSO 쿠키(`directory.sso.session`)를 함께** 지운다. 공유 쿠키를 남기면 다음 방문에서 SSO로 즉시 재로그인되어 "로그아웃이 되지 않은 것"처럼 보인다.
- **콘솔 로그아웃**은 콘솔이 등록된 포털들의 `/auth/end-session`을 숨김 iframe으로 호출한다. ProcessCenter도 이 경로를 구현해 자기 세션을 지운다(204 응답 + `Cache-Control: no-store`).
- 두 경로 모두 실측으로 확인할 수 있다.
```bash
curl -i http://localhost:3000/auth/end-session # 204 + 세션 쿠키 만료
curl -i -X DELETE http://localhost:3000/api/auth/logout # 세션 쿠키 + directory.sso.session 만료로그아웃 후에도 자동으로 로그인된다면 4)의 centralAuth.session이 authorized:true인지 확인한다. 그 값이 true면 공유 쿠키가 지워지지 않은 것이므로 위 두 경로의 Set-Cookie(만료)를 다시 점검한다.
16. 새로 만든 테스트가 실행되지 않을 때 (클라이언트는 .test.tsx만)
vitest.config.ts의 수집 대상은 다음과 같다.
include: ["server/**/*.test.ts", "server/**/*.spec.ts", "client/**/*.test.tsx"],- 클라이언트에서
.test.ts로 만들면 조용히 실행되지 않는다. 파일 하나가 통째로 빠져도 전체는 그대로 초록으로 보이므로 놓치기 쉽다.client아래 테스트 파일은 반드시.test.tsx로 만든다(내용에 JSX가 없어도tsx확장자를 쓴다 — 순수 로직 테스트인domain/normalize.test.tsx도 같은 이유). - 확인: 방금 추가한 테스트 수가 총계에 반영되는지 본다.
cd C:\H_PROJECT\ProcessCenter
pnpm exec vitest run client/src/features/workflow-map # 파일 수 + 테스트 수가 늘었는지 확인- 컨테이너가 멈춰 있어도(포트를 비워 둔 상태) 위처럼 호스트에서 그대로 돌릴 수 있다. 호스트
node_modules에는 테스트 도구가 모두 들어 있다. - 반대로 컨테이너 안에서 돌린 결과만 보고 판단하지 않는다 — 컨테이너
node_modules는 익명 볼륨이라 호스트에 새로 추가한 패키지가 없을 수 있다(§8). - 실측(2026-09-16):
workflowMapHelpers.test.ts로 만든 6개 테스트가 총계(95)에 반영되지 않아 발견했다..test.tsx로 바꾸자 101개가 되었고, 이후 L4 BPMN 테스트까지 더해 105개가 되었다.
17. 전체 클라이언트 테스트가 "멈춘 것처럼" 보일 때
pnpm exec vitest run client(또는 pnpm verify:full)가 파일 몇 개를 끝낸 뒤 요약 없이 멈춰 있는 것처럼 보이는 일이 있었다. 원인을 찾아 고쳤다(2026-09-17) — 아래는 재발 시 확인 절차다.
원인과 수정
client/src/components/DirectoryUserAccessPanel.tsx의 목록 effect가 매 렌더마다 새 배열 참조를 state에 넣고 있었다.
useEffect(() => {
if (usersQuery.isFetching || !usersQuery.data) return;
setLoadedUsers(current => cursor ? mergeUsers(current, users) : users); // ← 매번 새 배열
}, [cursor, users, usersQuery.data, usersQuery.isFetching]);- 실제 react-query는 데이터가 그대로면
data참조를 유지하므로 운영에서는 드러나지 않는다. - 테스트가 mock으로
useQuery를 만들면 호출마다 새 객체를 돌려주기 때문에 참조가 계속 달라진다 → effect가 매 렌더 실행 → 새 배열 state → 재렌더 → … 동기 무한 루프. - 동기 루프라 vitest의
testTimeout(5초)이 걸리지 않는다. 그래서 파일 결과가 영원히 안 찍히고 "수집 단계에서 멈춘 것"처럼 보였다(프로세스는 살아 있고 CPU만 증가). - 수정: 같은 사용자 목록이면 이전 참조를 그대로 반환(
sameUsers가드).vitest run client/src/components/DirectoryUserAccessPanel.test.tsx가 무한 대기 → 2.06초 통과가 되었다.
그래도 멈춘다면 (범위 좁히기)
cd C:\H_PROJECT\ProcessCenter
pnpm exec vitest run client/src/features/workflow-map # 모듈 단위
pnpm exec vitest run client/src/pages/Home.test.tsx # 의심 파일 단독
pnpm exec vitest run server # 서버 스위트는 영향 없다- 단독 실행에서 멈추면 그 파일의 (mock이 만든) 렌더 루프를 먼저 의심한다. mock이 매 호출 새 객체/배열을 돌려주면 실제 훅과 동작이 달라진다 — 참조를 고정(hoisted 상수·캐시)하거나 컴포넌트에 동일성 가드를 둔다.
--no-file-parallelism은 도움이 되지 않는다(병렬/순차 무관).
멈춘 프로세스 정리
dev 서버(tsx)를 죽이지 않도록 골라서 종료한다.
## 18. dev 화면이 오래된 번들로 보이거나 모듈 로드가 실패할 때 (`Pre-transform error: … /src/main.tsx`)
증상: 브라우저/서버 로그에 `Pre-transform error: Failed to load url /src/main.tsx?v=… Does the file exist?`가 반복되고, 화면은 **예전에 캐시된 번들**로 계속 동작한다(새 코드가 붙지 않는다). 버튼 클릭이 어긋나거나 예상 못 한 에러가 뜬다.
원인(2026-09-17 확인): `server/_core/vite.ts`의 `setupVite`가 vite 설정을 `{ ...viteConfig }`로 spread 하고 있었다. `vite.config.ts`는 `defineConfig(({ command }) => ({ … }))` = **함수**라서 spread로는 키가 하나도 복사되지 않는다.
- 결과: vite가 `root`를 저장소 루트로 잡고, react/tailwind **plugin·alias가 없는 상태**로 동작한다.
- 그러면 `/src/main.tsx`를 해석하지 못하고 catch-all이 index.html을 돌려준다 → 브라우저는 JS 자리에 HTML을 받는다(화면은 캐시된 옛 번들로 계속 돈다).
진단 — 파일(HTML)인지 모듈(JS)인지 바이트 수로 구분한다.
```powershell
curl.exe -s -o NUL -w '%{http_code} %{size_download}\n' http://localhost:3000/@vite/client # 200 + 큰 JS(정상)
curl.exe -s -o NUL -w '%{http_code} %{size_download}\n' http://localhost:3000/src/main.tsx # JS 수 KB가 정상
# 실패 시 두 요청 모두 index.html과 같은 바이트 수(예: 2395B)가 나온다.복구: resolveViteConfig()로 함수 설정을 command: "serve" 환경에서 실행해 객체로 만든 뒤 넘긴다. 적용하면 서버 로그에 Re-optimizing dependencies because vite config has changed가 뜨고 /src/main.tsx가 JS로 내려온다. 회귀 방지는 server/viteConfig.test.ts(root가 client로 끝나고 plugin이 1개 이상인지 확인).
- 같은 이유로 dev에서만 나타난다(빌드는
vite build가 설정 파일을 정상적으로 읽는다). - 적용 후에도 화면이 옛 상태라면 브라우저에서 강력 새로고침(Ctrl+Shift+R)을 한 번 한다. Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $.CommandLine -match 'vitest' } | ForEach-Object { Stop-Process -Id $.ProcessId -Force }
7. 프로세스 트리가 통째로 사라졌다 (번역 테이블 누락 · 실사고 2026-10-11)
증상: 특정 노드의 본문·첨부를 지운 뒤 프로세스 트리 전체가 빈 화면이 된다. 서버 로그에 relation "translations" does not exist(PostgreSQL) / Table 'translations' doesn't exist(MySQL).
⚠️ 첨부 삭제는 이 증상의 원인이 아니다. 첨부(
process-node자산)와 본문 이미지(process-editor자산)는 서로 다른 자산 계열이고, 첨부를 본문에 삽입하는 기능도 없다(실데이터 확인: 두 집합의 교집합 0). 본문 이미지 쪽에서 같은 증상이 나는 별개 원인은 아래 "본문 이미지 자산이 FileStore 에 없을 때" 항목을 보라.
원인: 번역 기능이 process.list 에서 범용 translations 테이블을 함께 읽는데, 마이그레이션이 적용되지 않은 DB 였다. 조회가 던지면서 목록 전체가 실패해 트리가 사라졌다(데이터는 무사하다 — 노드·관계는 그대로다).
복구
pnpm db:postgres:migrate # 또는 pnpm db:push (MySQL)
# 적용 확인
psql "$DATABASE_URL" -c "select count(*) from translations"- 기동 시
ensureDatabaseSchema()가 누락된 DB 에서도translations를 비파괴적으로 만들어 준다(자가치유). - 코드도 함께 고쳤다: 번역 읽기 실패는 경고만 남기고 빈 결과로 되돌린다(
server/repositories/translationRepository.ts의safeRead). 번역 테이블 문제로 프로세스 목록이 죽지 않는다. 화면에는process-list-error안내가 뜬다.
왜 마이그레이션이 조용히 건너뛰어졌나: drizzle-kit migrate 는 journal 의 when(ms) 값이 이미 적용된 마이그레이션의 created_at 보다 커야 적용한다. 새 마이그레이션의 when 을 과거로 넣으면 "적용 완료" 로그만 뜨고 아무것도 적용되지 않는다. 새 마이그레이션을 추가할 때는 반드시 drizzle/meta/_journal.json(또는 drizzle/postgres-migrations/meta/_journal.json)의 마지막 when 보다 큰 값을 쓴다.
19. 프로세스 트리가 빈 화면 — 본문 이미지 자산이 FileStore 에 없을 때 (HTTP 404 · 실사고 2026-10-12)
증상: 프로세스 트리가 빈 화면. 데이터(노드·관계)는 무사하다. 서버 로그에 [editor-asset] 본문 이미지 자산의 read-url 을 받지 못했습니다 — 원본 참조를 유지합니다(assetId=…): FileStore request failed: HTTP 404.
원인: process.list 는 노드마다 본문 이미지의 서명 URL 을 발급받는다(resolveProcessEditorContent → issueProcessAttachmentReadUrl). 그 자산이 그 환경의 FileStore 에 없는 경우(HTTP 404) 예외가 나고, 고치기 전에는 목록 전체가 실패해 트리가 사라졌다. 자산이 없어지는 대표 경로는 두 가지다.
- 다른 환경의 본문을 그대로 가져온 경우 — 카탈로그 이관/DB 복원은
detailContent·manualContentHTML 과 첨부assetId·storageKey를 그대로 복사한다(server/services/catalogTransferService.ts). 그런데 assetId 는 환경 로컬 값이다: 업로드마다 FileStore 가 새 UUID 자산을 만들고(randomUUID), 객체 키도{prefix}/{consumerId}/{assetId}/{fileName}로 자산별로 다르다. → 이관 대상 FileStore 에는 그 자산이 없어 읽기 URL 발급이 404 가 된다. - FileStore 를 새로 세우거나 리셋한 경우 — ProcessCenter 에는 참조가 남았는데 자산 기록이 사라진다.
첨부 삭제는 원인이 아니다. 첨부는
process-node, 본문 이미지는process-editor소유의 다른 자산이고, 첨부를 본문에 삽입하는 기능도 없다(첨부 삭제는 첨부 행과 그 자산만 지운다).
진단 (배포 DB·FileStore 를 그대로 바라본다)
pnpm db:diagnose-assets
# 예)
# [asset-doctor] ❌ FileStore 에 없는 자산 1개 …
# · 5ea6349a-… (HTTP 404) ← node #3 (L2-000001 마케팅) detailContent없는 자산과 그것을 쓰는 노드·필드 를 알려 준다. 종료 코드는 1(없음)·2(확인 불가).
복구
- 해당 노드를 편집기에서 열어 이미지를 다시 업로드한다(본문의 빈 자리에서 새 이미지를 넣으면 참조가 새 자산으로 바뀐다).
- 참조가 남은 채로 두어도 트리는 살아 있다(이미지만 화면에 안 보인다). 참조까지 지우려면 편집기에서 빈 이미지 노드를 삭제한다.
업로드 없이 죽은 참조만 정리하기
pnpm db:diagnose-assets --strip # 무엇을 지울지 미리보기 (쓰기 없음)
pnpm db:diagnose-assets --strip --apply # FileStore 에서 404 로 확인된 죽은 참조를 지운다 (updatedBy=asset-doctor)
pnpm db:diagnose-assets --strip --all # (선택) 상태와 무관하게 본문 이미지 참조를 전부 지운다- FileStore 에서 404 로 확인된 이미지 태그만 통째로 지운다(파일명
alt도 함께 사라지므로 미리보기로 먼저 확인한다). --all은 살아 있는 이미지도 지운다 — "이 포털 본문에는 이미지가 필요 없다"고 확인한 경우에만 쓴다. 이때는 FileStore 조회 없이 동작하므로, 연결이 안 되는 환경에서도 정리할 수 있다.- 확인하지 못한 자산(연결 실패·권한·정책)은 지우지 않는다 — "없음"과 "확인 불가"를 섞지 않는다.
- 태그를 지워도 남는 글자는 그대로라 번역 낡음 판정(
translationSourceHash는 마크업을 지운 텍스트로 계산)에 영향이 없다. - 노드 본문(
detailContent·manualContent)과 번역 본문(translations.content)을 함께 본다 — 번역 본문도 트리에서 같은 경로로 해석된다. - 첨부(
process-node자산)는 본문이 아니라 첨부 행이 가리키므로 이 도구가 고치지 않는다(첨부는 다시 올리거나 목록에서 삭제).
코드 반영(2026-10-12)
- 자산 하나가 실패해도 목록이 죽지 않는다: 실패한 자산은 원본 참조(
data-asset-id)를 유지하고 경고 로그만 남긴다 (server/fileStoreClient.ts의resolveProcessEditorContent). FileStore 순단·미설정에도 같은 보호가 걸린다. - 같은 자산 경고는 조회마다 반복하지 않는다 — 자산별 10분에 한 번만 남긴다(트리 조회는 화면을 열 때마다 본문 자산을 다시 해석하므로, 죽은 참조가 하나만 남아도 로그가 조회 횟수만큼 늘었다).
- 자산 참조 판정이 번역 본문까지 본다(
countAssetReferences) — 본문 이미지는 본문의 일부이므로 언어본도 같은 참조다. 한국어 본문에서 이미지를 지워도 영어 본문이 아직 가리키면 자산을 지우지 않는다(이 판정에서 번역 본문을 빼면 영문 화면이 깨지고 죽은 참조가 다시 생긴다). - 회귀 테스트:
server/fileStoreClient.test.ts— "본문 이미지 자산 하나가 사라져도(첨부 삭제) 목록용 본문을 돌려준다".
예방
- 다른 환경으로 카탈로그를 이관한 뒤에는 본문 이미지를 다시 올려야 한다(파일 자체는 이관되지 않는다). 이관 후
pnpm db:diagnose-assets로 확인한다. - 배포 환경에서는 첨부 기본값이
relay(ATTACHMENT_DOWNLOAD_MODE)라 첨부는 FileStore 를 거치지 않는다 → 트리 404 는 본문 이미지에서만 발생한다.
20. 본문을 고쳤는데 AI 변경요약이 예전 내용을 설명한다 (게시본은 불변 · 2026-10-12 보고)
증상: ACTIVE 노드의 본문을 완전히 다른 내용으로 고쳤는데, 상세 화면의 AI 변경요약 카드는 예전 본문을 그대로 설명한다.
원인: 정상 동작이다. 게시본은 불변이라 본문만 고쳐서는 게시본(과 그 요약)이 바뀌지 않는다.
- 게시(승인)가
payload.node로 그 시점 본문의 스냅샷을 저장한다(processGovernanceService.approveProcess). - AI 요약은 최신 게시본 ↔ 직전 게시본을 비교한다(
processVersionAiService.analyzeProcessVersionChange) — 현재 노드 본문은 보지 않는다. - 화면은 최신 게시본의 요약만 띄운다(
ProcessVersionHistory의index === 0).
→ 본문을 고친 뒤 검토 요청 → 승인(게시) 으로 새 버전을 확정해야 그 버전과의 비교로 요약이 새로 만들어진다.
화면 표시(2026-10-12 추가): 게시본과 현재 본문이 다르면 게시본 이력 위에 "현재 본문이 마지막 게시본(vN)과 다릅니다 — 아직 게시되지 않았습니다" 안내(process-unpublished-changes)가 뜬다. 판정은 AI 비교와 같은 필드·같은 정규화(마크업 제거)를 쓰므로, 이미지만 바뀌고 글자가 같으면 안내가 뜨지 않는다(AI 요약도 달라지지 않으므로).
확인
select version, ai_summary_status, left(ai_summary, 120) from process_node_versions where "processId" = <노드 id> order by version desc;
select status, "publishedVersion", left("detailContent", 80) from process_nodes where id = <노드 id>;가장 최신 버전이 하나뿐이면(=게시를 다시 하지 않음) 위 설명대로다.