엠키스코어
경력근거: Multi-tenant SaaS 를 위한 성능, 가용성, 보안 아키텍처
공고는 멀티테넌트 SaaS 의 성능·가용성·보안 아키텍처를 주요업무에 두고 RBAC 와 ABAC 를 자격요건에 명시하며, 복잡한 비즈니스 요구사항을 구조로 옮기는 역량을 봅니다. 이 포트폴리오는 그 요구사항이 규칙으로 어떻게 서는지 만들어 봅니다. 역할과 리소스와 조건을 데이터로 두고 조건 평가와 우선순위와 기본 거부를 코드로 고정해, 정책이 바뀌어도 화면은 그대로 씁니다. 다만 이 포트폴리오는 조건 평가 규칙 엔진과 화면 구현을 보여줍니다. 기업 보안 감사나 인증 표준 적용에 대한 전문성은 주장하지 않습니다. 모르는 깊이는 면접에서 모른다고 말하는 편이 낫습니다.
멀티테넌트 SaaS 를 위한 성능·가용성·보안 아키텍처를 주요업무에 적고, 자격요건에도 멀티테넌트 경험을 따로 둡니다. 고객사끼리 자원이 섞이지 않게 만드는 일이 제품의 골조입니다.
OAuth2 와 OIDC 와 SSO 에 더해 RBAC 와 ABAC 경험을 자격요건에 나란히 적습니다. 로그인만이 아니라 어떤 조건에서 무엇을 열어 줄지 정하는 규칙을 다루는 자리입니다.
복잡한 비즈니스 요구사항을 구조화해 구현하는 역량을 자격요건에 두고, 프로토타입에서 프로덕션까지 처음부터 끝까지 만든다고 적습니다. 말로 적힌 규칙을 코드로 옮기는 판단이 곧 성과입니다.
플랫폼 정책 담당자가 정책 초안과 접근 요청 목록을 올리면, 요청마다 허용과 거부를 정한 규칙 번호와 그때 평가된 조건을 보여줍니다. 허용인지 거부인지는 조건 평가와 규칙 우선순위와 기본 거부가 정하고, 모델은 어느 규칙이 왜 걸렸는지 담당자가 읽을 한 줄로 요약합니다.
저는 저희 SaaS 제품의 고객사별 접근 정책을 씁니다. 역할과 리소스와 조건을 YAML 로 적어 배포하는데, 허용 규칙과 거부 규칙이 같이 걸릴 때 어느 쪽이 이기는지는 배포한 뒤 문의가 들어와야 압니다. 지난달에는 고객사 관리자가 자기 회사 감사 로그를 못 여는 일이 있었습니다.
배포한 뒤에야 문제를 아는 이유는 규칙 수가 많아서가 아니라 조건과 우선순위가 겹치는 자리를 사람이 머리로 따라갈 수 없기 때문입니다. 요청 목록에 초안을 돌려 어느 규칙이 판정을 정했는지 보면, 배포 전에 고칠 규칙이 정해집니다.
플랫폼 정책 담당자가 쓰는 화면 하나가 완결됩니다. 정책 초안과 요청 시나리오를 올리면 요청마다 허용과 거부를 정한 규칙 번호와 평가된 조건이 보입니다. 실제 인증 시스템 연동 대신 초안과 시나리오를 파일 두 개로 올립니다.
초안을 고치기 전후로 요청 시나리오 120건을 각각 돌려 판정이 뒤집힌 요청을 규칙별로 냅니다. 규칙 한 줄이 몇 건을 바꿨는지 그 표에서 보입니다. 화면은 AWS 에 올려 담당자가 주소로 엽니다.
조건식과 규칙 우선순위를 화면에서 고쳐 저장하면 판정이 다시 돌고, 이전 초안이 낸 판정과 무엇이 달라졌는지 보입니다. 배포하기 전에 영향 범위를 먼저 봅니다.
플랫폼 정책 담당자가 정책 초안과 요청 시나리오를 올리면 요청마다 허용과 거부가 갈리고, 판정을 정한 규칙 번호와 평가된 조건이 같이 보입니다.
조건 값이 비었거나 우선순위가 없거나 초안에 규칙이 없는 요청 30종을 올려도 화면이 살아 있고, 평가할 수 없는 요청은 판정 보류로 남습니다.
초안을 고치기 전후로 요청 시나리오 120건을 돌려 판정이 뒤집힌 요청 수를 냅니다. 단, 정책과 시나리오를 모두 작성자가 만든 합성 기준이라 성능 검증이 아니라 데모 조건 동작입니다.
위 수치는 더미데이터 자가채점이며 실제 정책 사고 감소는 엠키스코어의 실 정책과 접근 기록이 있어야 측정됩니다. 이 포트폴리오는 그 검증을 바로 돌릴 수 있는 상태로 만듭니다.
수치는 합성 데이터 자가채점이라 성능 증명이 아닙니다. 면접에서는 데모 동작과 실검증 준비 상태로 솔직히 설명합니다.
정답을 작성자가 만들었으니 잘 맞는 것이 당연합니다. 조건 값 결측·표기가 흔들린 테넌트 코드·초안에서 규칙을 찾지 못하는 요청을 더미에 일부러 섞어 쉬운 문제만 푸는 함정을 피합니다.
규칙이 허용으로 판정했다고 그 접근이 옳다는 뜻은 아닙니다. 초안을 잘못 쓰면 시뮬레이터도 그 초안대로 허용하므로, 판정을 결론으로 읽지 않게 걸린 규칙과 평가된 조건을 항상 같이 보여줍니다.
실제 도입에서는 고객사마다 역할 이름과 리소스 종류가 달라 조건식 문법이 하나로 서지 않습니다. 조건 종류가 늘어나면 평가기를 코드에서 빼 설정으로 옮겨야 하며, 그것은 도입 단계 과제로 분리합니다.
정책을 실제 인증 시스템에 배포하는 일은 운영 책임이 걸려 있어 이 포트폴리오의 범위 밖입니다. 여기서는 초안을 돌려 판정을 내는 데까지만 다룹니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
담당자가 YAML 로 쓴 정책 초안을 규칙 한 줄씩 편 표입니다. 판정의 기준이며 우선순위가 비어 있는 규칙과 표기가 흔들린 테넌트 코드를 의도적으로 포함합니다.
| rule_no | tenant_code_raw | effect | role_name | resource_kind | action | condition_expr | priority | note |
|---|---|---|---|---|---|---|---|---|
| PR-0001 | TN-ALPHA | allow | tenant_admin | audit_log | read | request.tenant_code == resource.tenant… | 20 | — |
| PR-0002 | TN-ALPHA | deny | any | audit_log | export | — | 10 | 감사 로그 내보내기는 역할과 무관하게 막습니다 |
| PR-0003 | TN-ALPHA | allow | analyst | dataset | read | request.network == internal | 30 | — |
| PR-0004 | TN-BETA | allow | support_agent | user_account | read | request_hour between 9 and 18 | 40 | — |
| PR-0005 | TN-BETA | deny | support_agent | user_account | write | — | 15 | 고객 계정 수정은 지원 담당자 권한 밖입니다 |
아래 JSON Schema 에 맞는 정책 규칙 행을 더 만들어 주세요. {"type":"object","properties":{"rule_no":{"type":"string"},"tenant_code_raw":{"type":"string"},"effect":{"type":"string","enum":["allow","deny"]},"role_name":{"type":"string","enum":["tenant_admin","analyst","support_agent","billing_manager","any"]},"resource_kind":{"type":"string","enum":["audit_log","billing","dataset","agent_config","user_account"]},"action":{"type":"string","enum":["read","write","export","delete"]},"condition_expr":{"type":["string","null"]},"priority":{"type":["integer","null"]},"note":{"type":["string","null"]}},"required":["rule_no","tenant_code_raw","effect","role_name","resource_kind","action"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) 같은 역할·리소스·동작에 allow 와 deny 가 함께 걸리고 priority 로만 갈리는 규칙 쌍, (2) priority 가 null 이라 우선순위를 비교할 수 없는 규칙, (3) condition_expr 가 null 인 무조건 규칙과 request_hour·network·mfa 를 쓰는 조건 규칙, (4) tenant_code_raw 의 구분자와 대소문자가 흔들린 규칙. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"rule_no":{"type":"string"},"tenant_code_raw":{"type":"string"},"effect":{"type":"string","enum":["allow","deny"]},"role_name":{"type":"string","enum":["tenant_admin","analyst","support_agent","billing_manager","any"]},"resource_kind":{"type":"string","enum":["audit_log","billing","dataset","agent_config","user_account"]},"action":{"type":"string","enum":["read","write","export","delete"]},"condition_expr":{"type":["string","null"]},"priority":{"type":["integer","null"]},"note":{"type":["string","null"]}},"required":["rule_no","tenant_code_raw","effect","role_name","resource_kind","action"]}담당자가 적은 접근 요청 시나리오입니다. 판정 대상이며 조건에 쓸 값이 비어 있는 요청과 초안에서 규칙을 찾지 못하는 테넌트 코드를 의도적으로 포함합니다.
| request_no | tenant_code_raw | actor_role_name | resource_kind | action | resource_tenant_code_raw | request_hour | network | mfa | note |
|---|---|---|---|---|---|---|---|---|---|
| RQ-1001 | TN-ALPHA | tenant_admin | audit_log | read | TN-ALPHA | 10 | internal | true | — |
| RQ-1002 | TN-ALPHA | tenant_admin | audit_log | export | TN-ALPHA | 11 | internal | true | 허용 규칙과 거부 규칙이 같이 걸립니다 |
| RQ-1003 | TN-ALPHA | analyst | dataset | read | TN-ALPHA | 14 | external | false | — |
| RQ-1004 | TN-ALPHA | tenant_admin | audit_log | read | TN-BETA | 9 | internal | true | 다른 고객사 자원을 봅니다 |
| RQ-1005 | TN-BETA | support_agent | user_account | read | TN-BETA | 21 | internal | true | — |
아래 JSON Schema 에 맞는 접근 요청 시나리오 행을 더 만들어 주세요. {"type":"object","properties":{"request_no":{"type":"string"},"tenant_code_raw":{"type":"string"},"actor_role_name":{"type":["string","null"],"enum":["tenant_admin","analyst","support_agent","billing_manager",null]},"resource_kind":{"type":"string","enum":["audit_log","billing","dataset","agent_config","user_account"]},"action":{"type":"string","enum":["read","write","export","delete"]},"resource_tenant_code_raw":{"type":"string"},"request_hour":{"type":["integer","null"]},"network":{"type":["string","null"],"enum":["internal","external",null]},"mfa":{"type":["boolean","null"]},"note":{"type":["string","null"]}},"required":["request_no","tenant_code_raw","resource_kind","action","resource_tenant_code_raw"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) request_hour·network·mfa 가 null 이라 조건을 평가할 수 없는 요청, (2) tenant_code_raw 와 resource_tenant_code_raw 가 다른 요청, (3) tenant_code_raw 의 구분자와 대소문자가 흔들린 요청, (4) 정책 초안 어디에도 규칙이 없는 테넌트 코드를 쓴 요청, (5) actor_role_name 이 null 인 요청. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"request_no":{"type":"string"},"tenant_code_raw":{"type":"string"},"actor_role_name":{"type":["string","null"],"enum":["tenant_admin","analyst","support_agent","billing_manager",null]},"resource_kind":{"type":"string","enum":["audit_log","billing","dataset","agent_config","user_account"]},"action":{"type":"string","enum":["read","write","export","delete"]},"resource_tenant_code_raw":{"type":"string"},"request_hour":{"type":["integer","null"]},"network":{"type":["string","null"],"enum":["internal","external",null]},"mfa":{"type":["boolean","null"]},"note":{"type":["string","null"]}},"required":["request_no","tenant_code_raw","resource_kind","action","resource_tenant_code_raw"]}| TypeScript | 조건 평가 규칙과 화면을 함께 쓰는 언어입니다. 조건식 평가기를 한 곳에 두고 러너와 화면이 같은 코드를 씁니다. |
| Next.js | 플랫폼 정책 담당자가 보는 화면입니다. 요청별 판정과 걸린 규칙을 한 화면에 둡니다. |
| Node.js | 정책 초안 접수와 판정 실행을 맡는 백엔드입니다. |
| PostgreSQL | 정책 초안과 요청 시나리오와 판정 이력을 담습니다. 테넌트와 규칙을 분리한 스키마가 우선순위 조회의 정확도를 좌우합니다. |
| AWS | 2주차부터 화면 배포와 초안 이력 보관을 맡습니다. |
| LLM | 판정이 끝난 뒤 걸린 규칙과 조건을 담당자가 읽을 한 줄로 요약합니다. 허용인지 거부인지는 규칙 엔진이 정하고 모델은 여기에 개입하지 않습니다. |
✓ pass → 그대로 멈춰도 완결입니다. 더 키우려면 초안을 고치기 전후의 판정을 대조하는 단계로 넘어갑니다.
✓ pass → 여기까지도 완결입니다. 더 키우려면 조건식과 우선순위를 화면에서 고치는 단계로 확장합니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
decision_code사유 라벨 · request_no + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
allowed허용policy_denied정책 거부condition_unmet조건 미충족tenant_isolated다른 고객사 자원default_deny기본 거부input_incomplete판정 보류접근 요청 한 건의 판정입니다. 판정을 정한 규칙 번호와 그때 평가된 값을 함께 남깁니다.
{"type":"object","properties":{"decision_id":{"type":"string"},"request_no":{"type":"string"},"decision_code":{"type":"string","enum":["allowed","policy_denied","condition_unmet","tenant_isolated","default_deny","input_incomplete"]},"matched_rule_no":{"type":["string","null"]},"evidence":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["policy","request"]},"field":{"type":"string"},"agg":{"type":["string","null"]},"value":{"type":["string","number","null"]}},"required":["source","field","value"]}},"summary":{"type":"string"},"hedge":{"type":["string","null"]}},"required":["decision_id","request_no","decision_code","matched_rule_no","evidence","summary"]}[
{
"decision_id": "pd_0001",
"request_no": "RQ-1001",
"decision_code": "allowed",
"matched_rule_no": "PR-0001",
"evidence": [
{
"source": "policy",
"field": "rule_no",
"agg": null,
"value": "PR-0001"
},
{
"source": "policy",
"field": "condition_expr",
"agg": null,
"value": "request.tenant_code == resource.tenant_code"
},
{
"source": "request",
"field": "resource_tenant_code_raw",
"agg": null,
"value": "TN-ALPHA"
}
],
"summary": "요청자 테넌트와 자원 테넌트가 같아 PR-0001 이 허용으로 정합니다.",
"hedge": null
},
{
"decision_id": "pd_0002",
"request_no": "RQ-1002",
"decision_code": "policy_denied",
"matched_rule_no": "PR-0002",
"evidence": [
{
"source": "policy",
"field": "rule_no",
"agg": null,
"value": "PR-0002"
},
{
"source": "policy",
"field": "priority",
"agg": null,
"value": 10
},
{
"source": "request",
"field": "action",
"agg": null,
"value": "export"
}
],
"summary": "허용 규칙과 거부 규칙이 같이 걸리는데 우선순위 10 인 PR-0002 가 앞서 거부로 정합니다.",
"hedge": null
},
{
"decision_id": "pd_0003",
"request_no": "RQ-1005",
"decision_code": "condition_unmet",
"matched_rule_no": "PR-0004",
"evidence": [
{
"source": "policy",
"field": "condition_expr",
"agg": null,
"value": "request_hour between 9 and 18"
},
{
"source": "request",
"field": "request_hour",
"agg": null,
"value": 21
}
],
"summary": "요청 시각 21 시가 PR-0004 의 시간 조건을 벗어나 허용까지 가지 못합니다.",
"hedge": null
},
{
"decision_id": "pd_0004",
"request_no": "RQ-1004",
"decision_code": "tenant_isolated",
"matched_rule_no": "PR-0001",
"evidence": [
{
"source": "request",
"field": "tenant_code_raw",
"agg": null,
"value": "TN-ALPHA"
},
{
"source": "request",
"field": "resource_tenant_code_raw",
"agg": null,
"value": "TN-BETA"
}
],
"summary": "요청자 테넌트 TN-ALPHA 와 자원 테넌트 TN-BETA 가 달라 다른 고객사 자원 접근으로 막힙니다.",
"hedge": null
},
{
"decision_id": "pd_0005",
"request_no": "RQ-1008",
"decision_code": "default_deny",
"matched_rule_no": null,
"evidence": [
{
"source": "request",
"field": "tenant_code_raw",
"agg": null,
"value": "TN-ZETA"
},
{
"source": "policy",
"field": "rule_no",
"agg": "count",
"value": 0
}
],
"summary": "TN-ZETA 테넌트를 다루는 규칙이 초안에 없어 기본 거부로 남습니다.",
"hedge": null
},
{
"decision_id": "pd_0006",
"request_no": "RQ-1009",
"decision_code": "input_incomplete",
"matched_rule_no": "PR-0004",
"evidence": [
{
"source": "policy",
"field": "condition_expr",
"agg": null,
"value": "request_hour between 9 and 18"
},
{
"source": "request",
"field": "request_hour",
"agg": null,
"value": null
}
],
"summary": "요청 시각이 비어 시간 조건을 평가하지 못해 판정을 미룹니다.",
"hedge": "합성 기준 판정이라 실제 운영에서는 시각이 없는 요청을 거부로 둘 수도 있습니다."
},
{
"decision_id": "pd_0007",
"request_no": "RQ-1010",
"decision_code": "input_incomplete",
"matched_rule_no": "PR-0011",
"evidence": [
{
"source": "policy",
"field": "rule_no",
"agg": null,
"value": "PR-0011"
},
{
"source": "policy",
"field": "rule_no",
"agg": null,
"value": "PR-0012"
},
{
"source": "policy",
"field": "priority",
"agg": null,
"value": null
}
],
"summary": "허용 규칙 PR-0011 과 거부 규칙 PR-0012 가 같이 걸리는데 PR-0011 에 우선순위가 없어 판정을 미룹니다.",
"hedge": null
},
{
"decision_id": "pd_0008",
"request_no": "RQ-1007",
"decision_code": "allowed",
"matched_rule_no": "PR-0007",
"evidence": [
{
"source": "request",
"field": "tenant_code_raw",
"agg": null,
"value": "tn gamma"
},
{
"source": "policy",
"field": "tenant_code_raw",
"agg": null,
"value": "TN-GAMMA"
},
{
"source": "policy",
"field": "rule_no",
"agg": null,
"value": "PR-0007"
}
],
"summary": "테넌트 표기가 달라도 같은 고객사로 맞춰져 PR-0007 이 허용으로 정합니다.",
"hedge": null
}
][자동 검증 · 코드가 판정합니다] 1. decision_code 가 enum 여섯 값 중 하나인지 확인합니다. 2. decision_id·request_no·decision_code·matched_rule_no·evidence·summary 가 모두 채워졌는지 확인합니다. 3. evidence 항목 수가 2 이상인지 확인합니다. 4. evidence 의 field 이름이 정책 초안과 요청 시나리오에 실재하는 컬럼인지 대조합니다. 5. agg 가 없는 evidence 의 value 가 그 rule_no 또는 request_no 행의 값과 같은지 확인합니다. 6. 어느 규칙에도 걸리지 않은 요청이 default_deny 로 남는지 확인합니다. 허용 규칙이 하나도 걸리지 않았는데 allowed 가 나오면 실패입니다. 7. 조건에 쓸 값이 비어 있거나 걸린 규칙에 우선순위가 없으면 input_incomplete 로 남는지 확인합니다. 8. summary 가 존댓말로 끝나는지 확인합니다. [평가 러너 · 2주차] 초안을 고치기 전후로 요청 시나리오 120건을 각각 돌려 판정이 뒤집힌 요청과 그 규칙을 냅니다. [수동 검증 · 사람이 봅니다] 1. 표기가 다른 테넌트 코드를 같은 고객사로 볼지 표본 20건을 눈으로 확인합니다. 2. 규칙이 없어 기본 거부로 남은 요청을 규칙 추가로 풀지 접근 차단으로 둘지는 담당자가 정합니다.