포탈별 도메인 기능 개발·운영 가이드
적용 범위: Process Center, Guide Portal, ARISAM 등 Portal Foundation 공통 기반을 사용하는 모든 포탈의 공통 UI 이외 업무 기능입니다.
이 문서는 포탈별 업무 화면과 도메인 로직을 독립적으로 개발하면서도, PortalConsole 중심의 인증·보안·운영 정책을 일관되게 유지하기 위한 필수 기준을 정의합니다.
1. 기본 원칙
포탈마다 업무 도메인은 독립적으로 설계합니다. 예를 들어 Process Center의 프로세스·결재·전결규정, Guide Portal의 매뉴얼·번역 검토, ARISAM의 문서·아카이브 규칙은 각 포탈의 서버와 데이터베이스에 남깁니다. 반면 사용자·조직·권한·전문용어·암호화 상위키·운영 UI는 중앙 또는 공통 패키지를 통해 사용합니다.
| 구분 | 공통 기반에 둬야 하는 것 | 포탈 도메인에 둬야 하는 것 |
|---|---|---|
| 사용자·조직 | PortalConsole 조회·동기화·상태 보고 | 도메인별 책임자·담당 조직 매핑 |
| 인증·권한 | 중앙 로그인, 세션 검증, scope·관리자 판정 | 기능별 권한 조합과 업무 승인 규칙 |
| 번역 | 중앙 전문용어 조회, 캐시·폴백 정책 | 포탈 콘텐츠 유형별 번역 흐름과 검토 절차 |
| 데이터 | 암호화 어댑터, 감사 이벤트 형식 | 포탈 고유 테이블·도메인 이벤트·보존 기간 |
| UI | PortalShell, 상태·운영 컴포넌트, 테마 | 업무 편집기, 다이어그램, 특화 폼과 목록 |
| 운영 | 헬스 체크, 관측 이벤트, 작업 상태 계약 | 포탈별 SLO·업무 지표·알림 수신 대상 |
업무 기능을 만들 때는 먼저 다음 질문에 답해야 합니다.
- 이 기능이 다른 포탈에도 동일한 정책으로 필요할 가능성이 있는가?
- PortalConsole의 사용자·조직·권한·용어 데이터에 의존하는가?
- 개인정보, 인증 정보, 비밀값 또는 감사가 필요한 변경을 다루는가?
- 처리 시간이 길거나 재시도가 필요한가?
- 실패했을 때 관리자에게 보일 상태·조치가 필요한가?
첫 번째 질문만 예라면 공통 패키지 확장 후보입니다. 나머지 질문 중 하나라도 예라면 이 문서의 보안·운영 규칙을 반드시 적용합니다.
2. 패키지 사용 기준
포탈별 서버는 아래 패키지를 업무 로직의 기반으로 사용합니다. 각 패키지는 업무 규칙을 구현하지 않고, 재사용 가능한 계약과 안전장치만 제공합니다.
| 패키지 | 사용 목적 | 도메인 기능에서의 사용 예 |
|---|---|---|
@hohyun94/portal-foundation | 공통 관리자 프레임과 운영 UI | 관리자 화면의 PortalShell, 상태 배지, 운영 섹션 |
@hohyun94/directory-sync | PortalConsole 동기화, 전문용어 조회, 캐시·폴백 | 사용자·조직 동기화, 번역 전 사전 조회 |
@hohyun94/portal-security | scope 검증, 서명 세션, 감사 이벤트, 암호화 어댑터 | 관리자 권한 판정, 감사 로그, 상위키 암호화 연결 |
@hohyun94/portal-operations | 파일 검증, 알림 중복 방지, 비동기 작업 전이 | 문서 업로드, 동기화 실패 알림, 장기 작업 재시도 |
@hohyun94/portal-runtime | 검색 파라미터, 기능 플래그, 헬스 체크, 관측 이벤트 | 목록 API, 점진 배포, /health, 구조화 로그 |
@hohyun94/portal-filestore | FileStore 소비자 어댑터(업로드·read-url·삭제·원본 릴레이) | 노드·공지 첨부, 편집기 이미지 |
포탈별 기능은 공통 패키지 내부 파일을 직접 import하지 않습니다. 반드시 패키지의 공개 export만 사용합니다. 이 원칙은 내부 구현 교체, 보안 패치, 버전 호환성 검증을 가능하게 합니다.
// 허용: 공개 API 사용
import { createResilientGlossaryRuntime } from "@hohyun94/directory-sync";
import { hasAllScopes, createAuditEvent } from "@hohyun94/portal-security";
// 금지: 패키지 내부 경로 의존
// import { ... } from "@hohyun94/directory-sync/dist/glossaryRuntime.js";첨부파일 다운로드 (모든 포탈 공통 규칙)
첨부 원본은 포탈 서버가 중계(릴레이) 해 브라우저에 same-origin 으로 내려보냅니다. FileStore read-url 의 locator 는 FileStore(또는 R2/S3) 오리진이라 앱 오리진과 다르고, 브라우저가 그 주소를 직접 fetch 하면 CORS 에 막힙니다 — 업로드는 서버를 경유하므로 성공하고 다운로드만 실패하는 전형적인 증상이 됩니다. 서비스 토큰·서명 URL 이 브라우저로 나가지 않는 효과도 함께 얻습니다.
// 포탈 서버 라우트 — 세션 확인 후 원본을 받아 그대로 흘려보낸다.
const source = await adapter.openReadStream(assetId, actorSubject, { authorizationIssuer: "my-portal", authorizationAllowed: true, authorizationOperation: "read" });
res.status(200).set({
"Content-Type": source.contentType ?? "application/octet-stream",
"Content-Disposition": attachmentDisposition(fileName), // RFC 5987 이중 표기(한글 파일명)
"Cache-Control": "no-store",
});
Readable.fromWeb(source.stream).pipe(res);클라이언트에서는 진행률을 보여 주고 원래 파일명으로 저장합니다(공통 헬퍼를 씁니다).
// 포탈 클라이언트
const response = await fetch(downloadUrl, { credentials: "same-origin" });
if (!response.ok) throw new Error("다운로드 링크가 만료되었거나 권한이 없습니다.");
const blob = await readAttachmentWithProgress(response, percent => setProgress({ id, percent }));
saveAttachmentBlob(blob, attachmentFileNameFrom(response.headers.get("content-disposition"), fileName));// 목록의 해당 행에 진행률을 표시한다.
<AssetAttachmentList assets={assets} onDownload={...} downloadingId={downloadingId} downloadProgress={downloadProgress} />파일명 클릭 = 바로 열기(미리보기), 다운로드 버튼 = 저장 — PortalAsset.previewUrl 을 주면 파일명이 새 탭 링크가 됩니다. 서버는 Content-Disposition: inline 으로 내려보내되, 브라우저가 스크립트를 실행하지 않는 형식만 허용합니다 (PDF·이미지·평문 — HTML·SVG·XML·문서·압축은 제외). X-Content-Type-Options: nosniff 와 Cross-Origin-Resource-Policy: same-origin 을 함께 붙여 인라인 렌더링의 위험을 줄입니다.
// 포탈 서버 — 미리보기(파일명 클릭)와 저장(다운로드 버튼)을 같은 라우트에서 구분한다.
const inline = String(req.query.disposition ?? "") === "inline" && isPreviewableAttachment(contentType, fileName);
res.setHeader("Content-Disposition", inline ? `inline; filename*=UTF-8''${encodeURIComponent(fileName)}` : `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`);미리보기를 못 하는 형식은
previewUrl을 주지 않으므로 파일명이 링크가 되지 않고 다운로드 버튼만 남습니다.
blob URL 로 저장할 때
anchor.download를 비워 두면 브라우저가 blob URL 의 임의 식별자를 파일명으로 써서 원래 파일명이 사라집니다(실제로 발생한 문제).saveAttachmentBlob이 원본 이름 지정과revokeObjectURL지연을 함께 처리합니다. 진행률은Content-Length를 알 때만 계산되고, 모르면 진행 표시 없이 그대로 받습니다.
여러 파일 한 번에 (일괄 다운로드)
여러 개는 포탈 서버가 ZIP 으로 묶어 스트리밍합니다(단건을 브라우저에서 반복 fetch 하지 않습니다). 전체 ZIP 은 스트리밍이라 Content-Length 가 없으므로 진행률은 브라우저 내려받기 관리자가 보여 줍니다 — 그래서 클라이언트가 blob 으로 받지 않고 URL 을 브라우저에 넘깁니다(startAttachmentDownload).
// 포탈 서버 라우트 (예: GET /api/process-nodes/:nodeId/attachments.zip?ids=1,2)
const entries = await Promise.all(targets.map(async attachment => {
const source = await adapter.openReadStream(attachment.assetId, actorSubject, { authorizationIssuer: "my-portal", authorizationAllowed: true, authorizationOperation: "read" });
return { fileName: attachment.fileName, stream: source.stream };
}));
await streamAttachmentArchive(res, { archiveName: `process-node-${nodeId}-attachments.zip`, entries });// 포탈 클라이언트 — 선택 상태는 포탈이 소유하고, 공통 목록이 도구줄·체크박스를 그린다.
<AssetAttachmentList
assets={assets}
selectable
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
selectAllLabel="전체 선택"
downloadAllLabel="전체 다운로드"
downloadSelectedLabel="선택 다운로드"
onDownloadAll={() => startAttachmentDownload(archiveUrl())}
onDownloadSelected={assets => startAttachmentDownload(archiveUrl(assets.map(asset => asset.id)))}
/>서버는 요청한 id 를 자기 소유 행과 교차 확인한 뒤 묶습니다 — 다른 노드의 첨부 id 를 섞어 보내도 남의 파일이 나가지 않습니다. 선택이 비어 있으면 빈 ZIP 대신 404 로 알리고, 한 번에 묶는 개수 상한을 둡니다. 같은 이름의 파일은
이름(2).pdf로 중복을 푸십니다.
브라우저가 서명 URL 을 직접 받는 구성(대용량 파일이 포탈 서버를 경유하는 비용 회피)이 꼭 필요할 때만 FileStore 운영자에게 FILE_STORE_ALLOWED_ORIGINS(이 포탈의 공개 origin) 허용을 요청합니다. FileStore 는 기본적으로 CORS 헤더를 내지 않고, 켜도 * 나 쿠키 자격증명을 쓰지 않습니다 — 클라이언트는 credentials: "same-origin" 으로 호출해 SSO 쿠키가 FileStore 오리진으로 전송되지 않게 합니다.
3. 포탈별 도메인 아키텍처
각 신규 기능은 화면 → API 라우터 → 도메인 서비스 → 저장소 계층을 따릅니다. 라우터에는 인증·입력 검증·응답 변환만 두고, 업무 규칙은 서비스에, SQL/ORM 접근은 저장소에 둡니다. React 컴포넌트에서 DB 구조나 PortalConsole 호출 세부사항을 직접 알아서는 안 됩니다.
| 계층 | 책임 | 금지 사항 |
|---|---|---|
| 화면 | 사용자 입력, 상태 표현, 공통 UI 조합 | 비밀값 보관, DB 직접 접근, 권한 우회 |
| API 라우터 | 세션 확인, scope 확인, 입력 형식 검증 | 복잡한 트랜잭션·업무 규칙 혼합 |
| 도메인 서비스 | 업무 규칙, 트랜잭션 경계, 상태 전이 | HTTP 객체·React 상태 의존 |
| 저장소 | 조회·저장·잠금·정렬 | 권한 판정·화면 메시지 생성 |
| 공통 어댑터 | PortalConsole, 암호화, 파일·알림·관측성 | 포탈 고유 업무 규칙 포함 |
도메인 엔티티는 안정적인 식별자, 상태값, 생성·수정 시각, 버전 또는 낙관적 잠금 값을 원칙적으로 갖습니다. 상태가 있는 엔티티는 가능한 상태 전이만 서비스에서 강제해야 합니다. 화면에서 버튼을 숨기는 것만으로는 권한·상태 통제가 되지 않습니다.
4. 사용자·조직·권한 규칙
사용자와 조직의 원장은 PortalConsole입니다. 포탈은 자신의 업무에 필요한 최소 데이터를 암호화된 로컬 저장소에 동기화하거나, 실시간 조회 계약을 사용합니다. 포탈이 사용자 이름, 이메일, 조직명 등을 독자적으로 원장처럼 수정해서는 안 됩니다.
- 사용자·조직 표시에는 PortalConsole 식별자와 동기화된 로컬 사본을 사용합니다.
- 도메인 책임자·담당 조직 매핑에는 사용자 ID·조직 코드를 저장하고, 표시명은 동기화 데이터에서 읽습니다.
- 포탈 관리자 여부와 API 접근은 중앙 세션 claim 및 scope를 검증합니다.
- 화면의 메뉴 노출은 보조 수단일 뿐이며, 모든 변경 API에서 서버 측 권한 검사를 다시 수행합니다.
- 한 포탈에서만 의미가 있는 역할은 포탈 도메인 역할로 관리하되, 중앙 사용자 식별자와 연결합니다.
if (!hasAllScopes(principal, "directory:read")) {
throw new Error("권한이 없습니다.");
}
// 도메인별 추가 규칙은 별도로 적용한다.
if (!canManageApprovalRule(principal, approvalRule)) {
throw new Error("전결규정 변경 권한이 없습니다.");
}5. 전문용어·LLM 번역 규칙
전문용어 사전은 PortalConsole가 관리하는 중앙 원장입니다. 포탈은 사전 전체를 장기 복제하지 않으며, @hohyun94/directory-sync의 복원력 있는 런타임을 사용합니다.
| 순서 | 필수 처리 |
|---|---|
| 1 | 원본·대상 언어로 중앙 사전을 조회한다. |
| 2 | 정상 캐시, ETag 재검증, 마지막 정상 사전, 필수 정적 용어 순으로 폴백한다. |
| 3 | 긴 용어와 높은 우선순위 용어를 먼저 적용한다. |
| 4 | LLM에는 용어가 확정된 문장 또는 변경 불가 용어 목록을 전달한다. |
| 5 | 결과에서 필수 용어 보존 여부를 검증하고, 불일치 시 검토 대기열로 보낸다. |
PortalConsole가 일시적으로 불가하더라도 일반 번역을 즉시 중단하지 않습니다. 다만 마지막 정상 사전의 사용 시각이 최대 허용 기간을 넘겼거나 필수 정적 용어도 없는 경우에는 번역 결과를 검토 필요 상태로 저장해야 합니다. 관리자에게는 정상, 캐시 사용, 정적 폴백, 사전 사용 불가를 명확히 표시합니다.
6. 데이터·암호화·감사 규칙
포탈은 자체 마스터키를 새로 만들거나 소스코드·DB에 저장해서는 안 됩니다. 암호화가 필요한 필드는 PortalConsole를 상위키 제공자로 하는 Envelope Encryption 어댑터를 통해 처리합니다. 암호화 문맥은 portal:entity:id:field처럼 안정적이고 재현 가능한 형식으로 설계합니다.
| 데이터 유형 | 저장 규칙 | 감사 규칙 |
|---|---|---|
| 사용자 PII | 암호화 저장, 필요한 경우 blind index 사용 | 이메일·전화·주소 원문을 남기지 않음 |
| API 키·토큰·쿠키 | 비밀 저장소 또는 암호화 envelope만 저장 | 항상 [REDACTED] 처리 |
| 관리자 변경 | 변경 전·후 스냅샷을 마스킹 후 저장 | actor, action, entity, 결과, 상관관계 ID 기록 |
| 일반 업무 데이터 | 도메인 보존 정책에 따라 저장 | 민감 필드만 마스킹 |
모든 관리자 변경은 createAuditEvent와 같은 공통 형식을 사용합니다. 감사 로그에는 원문 암호·세션·토큰·키·쿠키를 절대 넣지 않습니다. 대량 변경은 하나의 상관관계 ID를 공유하여 사후 추적할 수 있어야 합니다.
7. 파일 업로드 규칙
파일 업로드는 파일명만 믿지 않습니다. 서버에서 용량, 허용 확장자, MIME 타입, 지원 가능한 형식의 magic number를 함께 검사해야 합니다. 업로드 후 저장 경로는 사용자 입력 파일명을 경로에 포함하지 않고 createSafeObjectKey로 생성합니다.
- 허용 형식과 최대 용량은 기능별 정책으로 명시합니다.
- 경로 탐색 문자열, 제어문자, 이중 확장자를 포함한 파일명은 표시용으로만 사용합니다.
- 객체 저장소 키는 포탈·엔티티·날짜·무작위 식별자로 생성합니다.
- 다운로드는 직접 public URL 대신 시간 제한 서명 URL 또는 권한 확인 API를 사용합니다.
- 바이러스 검사나 문서 변환이 필요하면 업로드 요청 안에서 수행하지 않고 비동기 작업으로 분리합니다.
8. 비동기 작업·알림 규칙
동기화, 대량 번역, 문서 변환, 대량 알림처럼 오래 걸리는 업무는 HTTP 요청에서 끝까지 처리하지 않습니다. 도메인 DB에 작업 레코드를 만들고 QUEUED → RUNNING → SUCCEEDED/FAILED/CANCELLED 상태 전이를 사용합니다.
재시도는 오류 원인을 저장하고 지수 backoff를 적용합니다. 재시도 한도를 넘기면 FAILED 상태로 두고 관리자 조치가 가능해야 합니다. 작업 재실행 API는 idempotency key 또는 도메인 식별자를 사용해 중복 실행을 막아야 합니다.
알림은 포탈·수신자·업무 이벤트를 결합한 dedupeKey를 둡니다. 동일한 동기화 실패나 동일한 검토 요청을 반복 발송하지 않습니다. 알림 전송 채널이 실패하더라도 업무 트랜잭션 자체를 되돌리지 말고, 알림 작업을 실패·재시도 가능한 별도 상태로 관리합니다.
9. 검색·목록·기능 플래그 규칙
목록 API는 normalizeSearchQuery로 검색어, 페이지, 페이지 크기, 정렬 키를 정규화합니다. 클라이언트가 전달한 정렬 필드를 그대로 SQL에 붙이면 안 됩니다. 기능별 허용 정렬 키 목록을 서버에서 선언합니다.
신규 기능은 전 포탈·전 사용자에게 즉시 노출하지 않고 기능 플래그를 사용합니다. 플래그는 다음 순서로 평가합니다.
- 기능 자체가 비활성인지 확인합니다.
- 대상 포탈 제한이 있는지 확인합니다.
- 명시적 허용 사용자 목록이 있는지 확인합니다.
- 점진 배포 비율이 있으면 포탈·사용자·플래그 키 기반의 안정적 hash로 판정합니다.
플래그는 보안 권한을 대체하지 않습니다. 플래그가 켜져 있더라도 API 권한과 도메인 상태 검증은 항상 수행해야 합니다.
10. 관측성·헬스 체크·장애 대응 규칙
각 포탈은 중앙 연동 상태, DB, 객체 저장소, 비동기 작업, 번역 사전 캐시 상태를 헬스 체크에 포함합니다. 외부 시스템 장애가 포탈 전체 장애로 번지지 않도록 타임아웃, 재시도, 회로 차단, 마지막 정상값 폴백을 적용합니다.
구조화 관측 이벤트에는 안정적인 이벤트명, 수준, 포탈 ID, 상관관계 ID, 숫자·불리언·문자열 형태의 제한된 필드만 기록합니다. PII와 비밀값은 이벤트 필드에 넣지 않습니다.
| 상황 | 필수 사용자 경험 | 필수 운영 기록 |
|---|---|---|
| PortalConsole 일시 장애 | 마지막 정상 사용자·용어 데이터 사용 여부 표시 | directory.unavailable, 캐시 나이, 회로 상태 |
| 사전 캐시 폴백 | 번역 계속, 검토 필요 여부 표시 | glossary.cache_stale, source, ageMs |
| 파일 검사 실패 | 안전한 오류 메시지와 재업로드 안내 | 파일 형식·크기 정책 위반 이벤트 |
| 작업 재시도 | 진행 중/재시도/실패 상태 표시 | 작업 ID, 시도 횟수, 오류 분류 |
| 권한 거부 | 기능 접근 불가 메시지 | actor, 요구 scope, 대상 엔티티 |
11. 테스트·리뷰·배포 체크리스트
새 도메인 기능은 PR 전에 아래 항목을 모두 충족해야 합니다.
- [ ] 화면·API·서비스·저장소 책임이 분리되어 있다.
- [ ] 모든 변경 API가 서버에서 인증·권한을 확인한다.
- [ ] 입력값, 정렬 키, 페이지 크기, 파일 형식과 용량을 검증한다.
- [ ] 민감 필드는 암호화·마스킹되고 로그·감사 기록에 원문이 없다.
- [ ] 관리자 변경에 감사 이벤트와 상관관계 ID가 있다.
- [ ] 오래 걸리는 업무가 작업 상태·재시도·중복 방지 규칙을 가진다.
- [ ] PortalConsole·용어사전 장애 시 폴백과 사용자 메시지가 정의되어 있다.
- [ ] 기능 플래그 또는 점진 배포 계획이 있다.
- [ ] 성공·권한 거부·유효성 실패·외부 장애·재시도 시나리오를 자동 테스트한다.
- [ ] 헬스 체크와 구조화 관측 이벤트가 추가되었다.
- [ ] 공통 UI 이외 도메인 UI라도 Portal Foundation의 색상·여백·상태 표현 규칙을 준수한다.
12. 포탈 적용 순서
개별 포탈 적용은 공통 패키지 설치 권한이 준비된 뒤 아래 순서로 진행합니다.
| 순서 | 대상 | 우선 적용 기능 |
|---|---|---|
| 1 | Process Center | DirectorySync 런타임, 보안·감사 어댑터, 운영 관리자 패널 |
| 2 | Guide Portal | 중앙 용어사전 런타임, 번역 작업·검토 큐, 기능 플래그 |
| 3 | ARISAM | 중앙 인증·권한, 파일 보안 정책, 비동기 문서 처리·알림 |
| 4 | 전체 포탈 | 공통 헬스 체크, 관측 이벤트, 검색 계약, 관리자 UI 확장 |
각 포탈은 read:packages 권한이 있는 패키지 토큰을 CI와 배포 환경에 설정해야 합니다. 설치 후에는 패키지 버전을 명시적으로 고정하고, 공통 패키지 업데이트는 별도 PR·테스트·점진 배포를 거쳐 적용합니다.