브이엔티지
경력근거: Backstage 기반 Developer Portal의 설계, 개발, 운영
공고는 주요업무 첫 항목에 Backstage 기반 Developer Portal 의 설계·개발·운영을 적고, Software Catalog 와 Service Template 을 IDP 핵심 기능으로 꼽습니다. 개발자 150명이 쓰는 포털에서 Software Catalog 는 만들고 끝나는 기능이 아니라 저장소의 실제 파일과 계속 벌어지는 기능이라, 그 벌어짐을 파일에서 직접 읽어 좁히는 일이 플랫폼 개발팀의 상시 과제가 됩니다. 이 포트폴리오는 Backstage 카탈로그 규격·CODEOWNERS·배포 매니페스트를 파싱하고 이름을 정규화해 잇는 부분을 보여줍니다. 브이엔티지의 인프라 구성이나 개발자 150명 규모 조직을 운영해 본 경험은 주장하지 않습니다. 모르는 깊이는 면접에서 모른다고 말하는 편이 낫습니다.
Software Catalog 와 Service Template 과 TechDocs 같은 사내 개발자 플랫폼의 핵심 기능 구현이 주요업무 첫 항목입니다. 서비스 목록을 관리하는 일이 곧 제품이 되는 자리입니다.
SDD 를 지원하는 도구로 스펙 자동 생성과 스펙 기반 코드 생성, 그리고 스펙과 코드의 정합성 검증을 나란히 적습니다. 문서와 구현이 벌어지는 지점을 기계로 잡는 것이 업무입니다.
150명 이상의 개발자가 쓰는 내부 플랫폼을 만들고 개발자 경험을 책임진다고 적혀 있습니다. 사용자가 개발자라 명령 한 줄로 끝나는 도구가 화면보다 잘 맞습니다.
플랫폼 엔지니어가 저장소 폴더를 가리켜 명령을 한 번 실행하면, 서비스마다 catalog-info.yaml 이 적어 둔 소유 팀과 CODEOWNERS 가 실제로 가리키는 소유 팀을 나란히 보여줍니다. 같은 팀인지 아닌지는 파일을 읽는 파서와 팀 명부 대조가 정하고, 모델은 두 소유 팀이 왜 갈라졌는지 한 줄로 요약합니다.
저는 플랫폼 개발팀에서 사내 개발자 포털을 맡고 있습니다. 카탈로그에 서비스가 백 개 넘게 올라와 있는데, 장애가 터져 소유 팀을 찾으면 카탈로그가 알려준 팀이 이미 해체된 팀일 때가 있습니다. 실제 소유는 저장소의 CODEOWNERS 가 쥐고 있고 배포 대상은 매니페스트에 적혀 있어서, 셋을 맞춰 보려면 저장소를 하나씩 열어 스크롤해야 합니다.
카탈로그가 낡는 이유는 등록이 번거로워서가 아니라 소유가 바뀌는 자리와 소유를 적어 둔 자리가 서로 다른 파일이기 때문입니다. 세 파일을 파싱해 이름을 같은 키로 모으면 어긋난 서비스만 남습니다.
플랫폼 엔지니어가 쓰는 CLI 하나가 완결됩니다. 제 GitHub 계정에 만든 예시 저장소 여섯 개를 훑어 catalog-info.yaml·CODEOWNERS·배포 매니페스트를 파싱하고, 이름을 정규화해 같은 서비스끼리 모은 뒤 소유 팀이 같은지 판정해 터미널 표와 JSON 파일로 냅니다. GitHub API 로 조직 전체를 훑는 일은 로컬 폴더 스캔으로 대신하고 3주차로 미룹니다.
1주차 판정 결과를 제가 손으로 정한 정답과 맞춰 일치율과 갈린 사유 분포를 냅니다. 정규화가 놓친 표기를 접두·접미 규칙 사전으로 뽑고, 소유 팀이 갈린 서비스에는 catalog-info.yaml 의 owner 줄을 고치는 수정 초안을 diff 로 같이 냅니다.
2주차까지 만든 판정기를 GitHub Actions 워크플로에 걸어 PR 마다 돌리고, 직전 실행과 견줘 새로 벌어진 서비스만 코멘트로 답니다. 같은 판정을 MCP(Model Context Protocol) 인터페이스로 내보내 사내 AI 도구가 카탈로그 상태를 물어볼 수 있게 합니다.
플랫폼 엔지니어가 저장소 폴더를 가리켜 명령을 한 번 실행하면 서비스별 판정이 터미널 표로 나오고, 소유 팀이 갈린 서비스는 카탈로그 줄과 CODEOWNERS 줄을 나란히 보여줍니다.
규칙 마흔 줄짜리 CODEOWNERS 에서 파일 경로마다 마지막에 일치한 줄이 승자로 뽑히는지 단위 테스트 스물네 개로 확인합니다. 정답을 제가 적은 합성 기준이라 성능 검증이 아니라 데모 조건 동작입니다.
들여쓰기가 어긋난 YAML·주석만 있는 CODEOWNERS·kind 가 없는 문서를 섞은 망가진 입력 서른 종을 넣어도 그 파일만 건너뛰고 나머지 서비스 판정이 이어집니다.
진짜 성능 검증은 여기서 멈춥니다. 위 수치는 제가 만든 예시 저장소로 잰 자가채점이라 실제 카탈로그 정확도는 브이엔티지의 저장소와 팀 명부가 있어야 잽니다. 이 포트폴리오는 그 검증을 바로 돌릴 상태까지 만듭니다.
수치는 제가 만든 예시 저장소로 잰 자가채점이라 성능 증명이 아닙니다. 면접에서는 데모 동작과 실검증 준비 상태로 솔직히 설명합니다.
정답을 제가 만들었으니 잘 맞는 것이 당연합니다. 폴더 이름이 하이픈과 밑줄로 갈린 쌍, Backstage 그룹이 비어 있는 팀, 카탈로그에 짝을 찾지 못한 배포 매니페스트를 예시 저장소에 일부러 섞었습니다.
CODEOWNERS 의 glob 해석은 GitHub 구현과 어긋날 수 있습니다. 특히 별표 두 개와 디렉터리 규칙의 우선순위는 실제 저장소에서 다시 맞춰 봐야 합니다.
이름 정규화는 규칙이라 접두어와 접미어 관행이 회사마다 다릅니다. 규칙 사전을 갈아끼우지 않으면 서로 다른 서비스를 한 키로 묶을 위험이 남습니다.
이 포트폴리오는 카탈로그 규격을 읽는 파서와 정규화 매칭을 보여줍니다. 사내 인프라 운영이나 대규모 조직의 플랫폼 운영 경험은 주장하지 않습니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
예시 저장소에서 그대로 가져온 파일 원문입니다. catalog-info.yaml 과 CODEOWNERS 와 배포 매니페스트가 섞여 있고, 폴더 이름이 하이픈과 밑줄로 갈린 쌍과 짝을 찾지 못하는 이름을 일부러 담았습니다. service_id 는 CLI 가 정규화해 채우는 자리라 입력에서는 비어 있습니다.
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payments-api
description: 결제 승인과 취소를 맡는 API 입니다
annotations:
github.com/project-slug: vntg/payments-api
backstage.io/kubernetes-id: payments-api
tags:
- nestjs
- typescript
spec:
type: service
lifecycle: production
owner: group:default/payments-platform
system: payments# 기본 소유는 플랫폼 개발팀입니다 * @vntg/team-platform /docs/ @vntg/team-docs /src/ @vntg/team-payments /src/settlement/ @vntg/team-billing catalog-info.yaml @vntg/team-billing
아래 JSON Schema 에 맞는 저장소 파일 행을 더 만들어 주세요. {"type":"object","properties":{"file_no":{"type":"string"},"dir_raw":{"type":"string"},"path":{"type":"string"},"title":{"type":"string"},"body":{"type":"string"},"service_id":{"type":["string","null"]}},"required":["file_no","dir_raw","path","title","body"]} body 에는 실제 파일 원문을 그대로 넣습니다. catalog-info.yaml 은 Backstage 카탈로그 규격(apiVersion·kind·metadata.name·metadata.annotations·spec.owner·spec.type·spec.lifecycle)을 지키고, CODEOWNERS 는 주석 줄과 경로 패턴 줄이 섞이며 뒤쪽 줄이 앞쪽 줄을 덮습니다. 배포 매니페스트는 kind 가 Deployment 인 문서와 아닌 문서를 함께 담습니다. 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) 같은 서비스인데 폴더 이름이 하이픈과 밑줄로 갈린 쌍, (2) 카탈로그는 있는데 배포 매니페스트가 없는 서비스, (3) 배포 매니페스트만 있고 카탈로그에 올라온 적 없는 서비스, (4) 배포 이름에 회사 접두가 붙어 폴더 이름과 이어지지 않는 서비스, (5) service_id 는 CLI 가 채우는 자리라 입력에서는 비웁니다. 형식 깨뜨리기 시도 6건도 따로 만들어 주세요: 들여쓰기가 어긋난 YAML, 문서 구분선만 있고 본문이 없는 파일, 주석만 있는 CODEOWNERS, 소유자 없이 경로만 적힌 줄, UTF-8 BOM 이 붙은 파일, 빈 파일.{"type":"object","properties":{"file_no":{"type":"string"},"dir_raw":{"type":"string"},"path":{"type":"string"},"title":{"type":"string"},"body":{"type":"string"},"service_id":{"type":["string","null"]}},"required":["file_no","dir_raw","path","title","body"]}CODEOWNERS 의 팀 핸들과 카탈로그의 그룹 이름을 잇는 팀 명부입니다. 소유 팀 판정의 기준이 되며, GitHub 팀은 있는데 Backstage 그룹이 비어 있는 팀을 담았습니다.
| team_no | github_handle_raw | backstage_group_raw | display_name | registered_on |
|---|---|---|---|---|
| TEAM-01 | vntg/team-platform | platform-tools | 플랫폼 개발팀 | 2025-03-04 |
| TEAM-02 | vntg/team-payments | payments-platform | 결제 개발팀 | 2025-05-12 |
| TEAM-03 | vntg/team-billing | billing-ops | 정산 운영팀 | 2025-06-30 |
| TEAM-04 | vntg/team-docs | docs-guild | 기술문서 길드 | 2025-08-19 |
| TEAM-05 | vntg/team-design | design-system | 디자인 시스템팀 | 2026-01-08 |
아래 JSON Schema 에 맞는 팀 명부 행을 더 만들어 주세요. {"type":"object","properties":{"team_no":{"type":"string"},"github_handle_raw":{"type":"string"},"backstage_group_raw":{"type":["string","null"]},"display_name":{"type":"string"},"registered_on":{"type":["string","null"]}},"required":["team_no","github_handle_raw","display_name"]} github_handle_raw 는 CODEOWNERS 에 적히는 조직/팀 핸들 표기이고 backstage_group_raw 는 카탈로그 spec.owner 가 쓰는 그룹 이름이라 표기 체계가 서로 다릅니다. 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) GitHub 팀은 있는데 Backstage 그룹이 비어 있는 팀, (2) 팀 이름이 하이픈과 밑줄로 갈린 표기, (3) 등록일이 비어 있는 팀, (4) 어느 저장소도 가리키지 않는 팀.{"type":"object","properties":{"team_no":{"type":"string"},"github_handle_raw":{"type":"string"},"backstage_group_raw":{"type":["string","null"]},"display_name":{"type":"string"},"registered_on":{"type":["string","null"]}},"required":["team_no","github_handle_raw","display_name"]}| TypeScript | CLI 본체와 파서 세 개를 쓰는 언어입니다. 공고가 필수로 적은 언어입니다. |
| Node.js | 로컬 폴더를 훑고 판정 결과를 JSON 파일로 쓰는 런타임입니다. |
| Backstage | catalog-info.yaml 의 필드 이름과 annotation 규격이 판정 기준입니다. 카탈로그가 쓰는 이름을 그대로 읽습니다. |
| Kubernetes | 배포 매니페스트에서 라벨과 컨테이너 이미지 이름을 뽑아 서비스 키로 씁니다. |
| GitHub Actions | 3주차에 PR 마다 같은 명령을 돌려 새로 벌어진 서비스를 코멘트로 답니다. |
| MCP | 3주차에 사내 AI 도구가 같은 판정을 부르도록 인터페이스를 답니다. |
| LLM | 판정이 끝난 뒤 불일치 사유를 한 줄로 요약합니다. 같은 팀인지는 파서와 명부 대조가 정하고 모델은 여기에 개입하지 않습니다. |
✓ pass → 그대로 멈춰도 완결입니다. 더 키우려면 판정과 실제 소유를 맞춰 보는 단계로 넘어갑니다.
✓ pass → 여기까지도 완결입니다. 더 키우려면 PR 마다 돌려 새로 벌어진 서비스만 알리는 단계로 넘어갑니다.
서비스 하나에 대한 확인 결과 한 건입니다. 판정과 함께 어느 파일의 어느 줄에서 나왔는지를 남깁니다.
{"type":"object","properties":{"check_id":{"type":"string"},"service_key":{"type":"string"},"verdict":{"type":"string","enum":["owner_matched","owner_conflict","owner_unregistered","deploy_matched","deploy_absent","catalog_absent","name_unmatched"]},"source_file_no":{"type":"string"},"related_file_no":{"type":["string","null"]},"team_no":{"type":["string","null"]},"owner_from_catalog":{"type":["string","null"]},"owner_from_codeowners":{"type":["string","null"]},"codeowners_rule_line":{"type":["integer","null"]},"matched_by":{"type":"string"},"evidence":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string"},"field":{"type":"string"},"quote":{"type":["string","null"]},"value":{"type":["string","number","null"]},"agg":{"type":["string","null"]}},"required":["source","field"]}},"summary":{"type":"string"},"hedge":{"type":["string","null"]}},"required":["check_id","service_key","verdict","source_file_no","matched_by","evidence","summary"]}[
{
"check_id": "chk_0001",
"service_key": "payments-api",
"verdict": "owner_conflict",
"source_file_no": "SRC-001",
"related_file_no": "SRC-002",
"team_no": "TEAM-03",
"owner_from_catalog": "payments-platform",
"owner_from_codeowners": "vntg/team-billing",
"codeowners_rule_line": 6,
"matched_by": "폴더 이름과 카탈로그 metadata.name 이 정규화 후 같은 키입니다",
"evidence": [
{
"source": "catalog-info.yaml",
"field": "body",
"quote": " owner: group:default/payments-platform"
},
{
"source": "CODEOWNERS",
"field": "body",
"quote": "catalog-info.yaml @vntg/team-billing"
},
{
"source": "repo_files",
"field": "file_no",
"value": "SRC-002"
},
{
"source": "org_teams",
"field": "backstage_group_raw",
"value": "billing-ops"
}
],
"summary": "카탈로그는 결제 개발팀을 소유로 적었는데 CODEOWNERS 의 마지막 일치 규칙은 정산 운영팀을 가리킵니다.",
"hedge": null
},
{
"check_id": "chk_0002",
"service_key": "terroir-portal",
"verdict": "owner_matched",
"source_file_no": "SRC-009",
"related_file_no": "SRC-010",
"team_no": "TEAM-01",
"owner_from_catalog": "platform-tools",
"owner_from_codeowners": "vntg/team-platform",
"codeowners_rule_line": 2,
"matched_by": "폴더 이름과 카탈로그 metadata.name 이 정규화 후 같은 키입니다",
"evidence": [
{
"source": "catalog-info.yaml",
"field": "body",
"quote": " owner: group:default/platform-tools"
},
{
"source": "CODEOWNERS",
"field": "body",
"quote": "* @vntg/team-platform"
},
{
"source": "org_teams",
"field": "github_handle_raw",
"value": "vntg/team-platform"
},
{
"source": "repo_files",
"field": "file_no",
"value": "SRC-009"
}
],
"summary": "카탈로그의 그룹과 CODEOWNERS 의 팀 핸들이 팀 명부에서 같은 플랫폼 개발팀으로 이어집니다.",
"hedge": null
},
{
"check_id": "chk_0003",
"service_key": "search-api",
"verdict": "owner_unregistered",
"source_file_no": "SRC-005",
"related_file_no": "SRC-004",
"team_no": "TEAM-07",
"owner_from_catalog": "search-platform",
"owner_from_codeowners": "vntg/team-search",
"codeowners_rule_line": 2,
"matched_by": "폴더 이름과 카탈로그 metadata.name 이 정규화 후 같은 키입니다",
"evidence": [
{
"source": "CODEOWNERS",
"field": "body",
"quote": "* @vntg/team-search"
},
{
"source": "org_teams",
"field": "team_no",
"value": "TEAM-07"
},
{
"source": "org_teams",
"field": "backstage_group_raw",
"value": null
},
{
"source": "catalog-info.yaml",
"field": "body",
"quote": " owner: group:default/search-platform"
}
],
"summary": "CODEOWNERS 가 가리킨 검색 개발팀은 팀 명부에 Backstage 그룹이 비어 있어 카탈로그의 그룹과 이을 수 없습니다.",
"hedge": "팀 명부에 그룹을 채운 뒤 다시 돌려야 소유 팀 판정이 나옵니다."
},
{
"check_id": "chk_0004",
"service_key": "payments-api",
"verdict": "deploy_matched",
"source_file_no": "SRC-003",
"related_file_no": "SRC-001",
"team_no": null,
"owner_from_catalog": null,
"owner_from_codeowners": null,
"codeowners_rule_line": null,
"matched_by": "폴더 이름 payments_api 와 컨테이너 이미지 이름이 구분자를 지운 뒤 같은 키입니다",
"evidence": [
{
"source": "deployment.yaml",
"field": "body",
"quote": " app.kubernetes.io/name: payments-api"
},
{
"source": "deployment.yaml",
"field": "body",
"quote": " image: ghcr.io/vntg/payments-api:1.14.2"
},
{
"source": "repo_files",
"field": "dir_raw",
"value": "payments_api"
},
{
"source": "repo_files",
"field": "file_no",
"value": "SRC-003"
}
],
"summary": "배포 폴더 이름은 밑줄 표기지만 라벨과 이미지 이름이 카탈로그의 payments-api 와 같은 키로 모입니다.",
"hedge": null
},
{
"check_id": "chk_0005",
"service_key": "terroir-portal",
"verdict": "deploy_absent",
"source_file_no": "SRC-009",
"related_file_no": null,
"team_no": null,
"owner_from_catalog": "platform-tools",
"owner_from_codeowners": null,
"codeowners_rule_line": null,
"matched_by": "카탈로그 annotation 의 kubernetes-id 로 매니페스트를 찾았습니다",
"evidence": [
{
"source": "catalog-info.yaml",
"field": "body",
"quote": " backstage.io/kubernetes-id: terroir-portal"
},
{
"source": "repo_files",
"field": "path",
"agg": "count",
"value": 0
},
{
"source": "repo_files",
"field": "file_no",
"value": "SRC-009"
}
],
"summary": "카탈로그는 운영 중인 서비스라고 적었는데 그 이름을 가리키는 배포 매니페스트가 없습니다.",
"hedge": null
},
{
"check_id": "chk_0006",
"service_key": "notification-worker",
"verdict": "catalog_absent",
"source_file_no": "SRC-007",
"related_file_no": "SRC-012",
"team_no": "TEAM-06",
"owner_from_catalog": null,
"owner_from_codeowners": "vntg/team-alerts",
"codeowners_rule_line": 5,
"matched_by": "배포 폴더 이름과 매니페스트 라벨이 같은 키입니다",
"evidence": [
{
"source": "deployment.yaml",
"field": "body",
"quote": " app.kubernetes.io/name: notification-worker"
},
{
"source": "CODEOWNERS",
"field": "body",
"quote": "/notification-worker/ @vntg/team-alerts"
},
{
"source": "repo_files",
"field": "file_no",
"value": "SRC-007"
},
{
"source": "repo_files",
"field": "path",
"agg": "count",
"value": 0
}
],
"summary": "운영에 떠 있는 워커인데 카탈로그에 올라온 적이 없어 포털에서 검색되지 않습니다.",
"hedge": null
},
{
"check_id": "chk_0007",
"service_key": "vntglegacybatch",
"verdict": "name_unmatched",
"source_file_no": "SRC-011",
"related_file_no": null,
"team_no": null,
"owner_from_catalog": null,
"owner_from_codeowners": null,
"codeowners_rule_line": null,
"matched_by": "serverless.yml 의 service 이름을 소문자로 낮춰 키로 삼았습니다",
"evidence": [
{
"source": "serverless.yml",
"field": "body",
"quote": "service: vntgLegacyBatch"
},
{
"source": "repo_files",
"field": "dir_raw",
"value": "legacy-batch"
},
{
"source": "repo_files",
"field": "file_no",
"value": "SRC-011"
}
],
"summary": "폴더 이름은 legacy-batch 인데 배포 이름에 회사 접두가 붙어 있어 어느 카탈로그 항목과도 이어지지 않습니다.",
"hedge": "접두어 규칙을 사전에 넣을지는 사람이 정해야 합니다."
}
][자동 검증 · 코드가 판정합니다] 1. verdict 가 enum 일곱 값 중 하나인지 확인합니다. 2. check_id·service_key·verdict·source_file_no·matched_by·evidence·summary 가 모두 채워졌는지 확인합니다. 3. codeowners_rule_line 이 그 파일의 줄 수 안에 드는 정수인지 확인합니다. 4. evidence 의 field 가 repo_files·org_teams 의 컬럼 이름에 실재하는지 확인합니다. 5. evidence 의 quote 가 그 파일 원문에 그대로 있는 한 줄인지 확인합니다. 6. summary 가 존댓말로 끝나는지 확인합니다. 7. verdict 가 owner_conflict 이면 owner_from_catalog 와 owner_from_codeowners 가 둘 다 차 있고 서로 다릅니다. owner_unregistered 이면 team_no 가 가리킨 팀의 backstage_group_raw 가 비어 있습니다. [수동 검증 · 사람이 봅니다] 1. 소유 팀이 갈린 서비스에서 어느 쪽이 실제 소유인지 그 팀에 물어 확인합니다. 2. 이름 정규화가 서로 다른 서비스를 한 키로 묶지 않았는지 표본 열 건을 눈으로 봅니다. [평가 러너 · 2주차] 1. 사람이 정한 정답과 판정을 맞춰 일치율과 갈린 사유 분포를 냅니다.