트리노드
경력근거: 구성원의 업무 효율을 높이는 AI 기반 사내 플랫폼 및 도구의 설계
트리노드 코어셀은 구성원의 업무 효율을 높이는 사내 플랫폼과 도구를 직접 설계하고 운영하면서 프론트엔드부터 백엔드 API 와 배포까지 한 사람이 끝내는 팀이라, 사내 담당자 한 명의 반복 작업을 골라 규칙 엔진과 화면과 확정 게이트까지 혼자 완결한 이 도구가 그 일의 축소판과 같은 모양입니다. 공고가 게임 서비스와 라이브 운영 환경에 대한 관심을 우대로 적어 두었기 때문에 입력 소재를 이벤트 등록 요청서로 잡았고, 조직의 문제를 먼저 발견해 해결 방향을 기술로 제안하는 팀이라는 설명에 맞춰 결과를 보여 주고 끝나는 화면이 아니라 사람의 답이 있어야 다음이 진행되는 흐름으로 만들었습니다. 운영 표준 스키마나 이벤트 분류 체계를 새로 제정하는 일은 여러 팀이 합의해야 존재하는 결과물이라 전제하지 않고, 이미 운영툴에 있는 아이템 마스터를 그대로 입력으로 받는 한 단계 앞에서 시작합니다. 이 포트폴리오가 보여주는 것은 업무 규칙을 검증 가능한 모델로 다루고 사람의 답마다 즉시 다시 검사하는 사내 도구를 백엔드부터 배포까지 혼자 만드는 풀스택 기술이며, 게임 라이브 운영 기획이나 재화 밸런싱 같은 도메인 전문성은 주장하지 않습니다. 모르는 깊이는 면접에서 모른다고 말하는 편이 낫습니다.
회사 구성원이 더 정확하고 빠르게 일하도록 사내 플랫폼과 도구를 직접 설계하고 운영하는 팀이라서, 사외 고객이 아니라 사내 담당자가 매주 여는 화면 하나를 제품으로 잡습니다.
기능 하나를 프론트엔드부터 백엔드 API 와 배포까지 끝까지 완성하는 자리라서, 규칙 엔진과 응답 화면과 확정 게이트를 한 덩어리로 세우고 배포까지 마친 상태를 1주차 완결품으로 둡니다.
게임 서비스와 라이브 운영 환경에 대한 관심을 우대로 적어 둔 자리라서, 이벤트를 운영툴에 올리기 전 담당자가 매번 손으로 대조하던 보상 코드와 기간과 지역을 입력 소재로 삼습니다.
라이브 운영 담당자가 올린 이벤트 등록 요청서를 보상 아이템 마스터와 지역 지급 규정과 대조해 빠진 값과 어긋난 값을 전부 찾아내고, 결함 하나마다 선택지가 붙은 확인 질문 한 개를 만들어 담당자가 화면에서 답하게 하는 1주 포트폴리오입니다. 답을 고르면 그 자리에서 같은 규칙이 다시 돌아 해소되었는지 확인하고 새 결함이 생기면 질문이 추가되며, 답하지 않은 질문이 하나라도 남으면 운영툴 등록 파일 내보내기는 잠긴 채로 있습니다.
게임 이벤트를 운영툴에 올리기 전에 보상 아이템 코드를 마스터 시트에서 하나씩 찾아 확인하고, 기간이 겹치는지 지난 이벤트 표를 뒤져 보고, 배너 문구에 치환되지 않은 자리표시자가 남았는지 눈으로 훑던 라이브 운영 담당자입니다. 무엇이 잘못됐는지는 도구가 찾아 주되, 보상 수량을 줄일지 기간을 미룰지는 본인이 정하고 싶은 사람입니다.
이벤트 등록에서 사람이 판단해야 하는 지점은 무엇이 빠졌는지가 아니라 그것을 어떻게 채울지이므로, 결함을 규칙으로 남김없이 찾아 선택지 붙은 질문으로 바꿔 두면 담당자는 답만 고르고 등록까지 한 화면에서 끝난다는 가정입니다.
이벤트 등록 요청서 묶음과 아이템 마스터를 넣으면 빠진 값과 어긋난 값이 결함 코드를 달고 확인 질문 목록으로 뜨고, 담당자가 질문마다 선택지를 고르거나 값을 채우면 그 자리에서 규칙이 다시 돌아 해소 여부를 판정하며, 남은 질문이 없어야 운영툴 등록 파일 내보내기가 열리는 웹 화면이 배포까지 끝난 완결품입니다. 만드는 덩어리는 결함 검출 규칙 엔진과 질문 응답 화면 둘이며 평일 5일에 혼자 끝나는 범위입니다.
확정 이력에서 같은 결함 코드에 담당자가 늘 같은 답을 고른 경우를 찾아 기본값 후보로 제안하고, 담당자가 그 기본값을 켜야 다음 요청서부터 해당 질문이 미리 채워져 오는 규칙 관리 화면입니다. 이 주차부터 화면과 API 를 AWS 에 올리고 GitHub Actions 로 배포 파이프라인을 붙여 실제 주소로 운영합니다.
사내 운영 규정 문서를 문단 단위로 색인해 질문마다 그 판정의 근거가 되는 문단을 찾아 인용해 보여 주고, 담당자가 근거가 어긋난다고 표시하면 그 질문을 규정 담당자 확인 대기로 넘기는 흐름입니다. 검색과 요약은 인용한 문단 원문을 함께 내보내 사람이 대조하게 둡니다.
이벤트 등록 요청서 12건과 아이템 마스터 12행을 넣으면 결함이 확인 질문 목록으로 뜨고, 답을 고칠 때마다 해소 여부가 다시 판정되며, 미해소가 없어야 등록 파일이 나오는 흐름을 처음부터 끝까지 시연합니다.
손으로 만든 정답표 40건과 결함 코드 집합이 정확히 같고, 답을 넣은 뒤의 해소와 미해소 판정도 정답표와 한 건도 어긋나지 않는 것을 자동 대조로 보입니다. 다만 정답과 데이터를 작성자가 만든 합성 기준이라 성능 검증이 아니라 데모 조건 동작입니다.
마스터에 없는 코드와 비활성 아이템과 지급 가능 지역이 비어 있는 아이템과 한도가 정해지지 않은 아이템과 끊긴 대체 코드 참조가 섞인 하드 케이스 18종에서, 화면이 검사를 멈추지 않고 그 항목을 미해소로 표시하며 사유와 사람이 할 일을 내는 것을 보입니다.
답변 이력에서 뽑은 기본값 후보가 다음 요청서에서 담당자의 실제 선택과 같았던 비율을 냅니다 (측정: 미리 채운 답과 담당자의 최종 확정 답의 일치율, 표본: 질문 300건). 2주차 결과입니다.
위 수치는 더미데이터 자가채점이며, 실제 등록 반려율과 검수 시간 단축은 트리노드의 실제 운영툴 마스터와 실제 요청서와 담당자의 실사용이 있어야 측정 가능합니다. 이 포트폴리오는 그 검증을 바로 돌릴 수 있는 상태로 만듭니다.
수치는 합성 데이터 자가채점이라 성능 증명이 아닙니다. 면접에서는 데모가 도는 상태와 실검증 준비 상태로 솔직히 설명합니다.
정답을 작성자가 만들었으니 잘 맞는 것이 당연합니다. 마스터에 없는 코드와 비활성 아이템과 지급 가능 지역이 비어 있는 행과 한도가 비어 있는 행과 끊긴 대체 코드 참조를 더미에 일부러 섞어 두었습니다.
결함을 규칙으로 찾는 만큼 규칙에 없는 문제는 질문으로 뜨지 않습니다. 화면에 담당자가 직접 질문을 추가하는 자리를 두고, 규칙이 놓친 항목을 사람이 적어 두면 그것도 미해소로 잡혀 내보내기를 막게 했습니다.
보상 수량을 줄일지 기간을 미룰지는 계산으로 결정되지 않습니다. 매출 계획과 이벤트 일정이 걸린 판단이라, 시스템은 결함과 선택지까지만 내고 무엇을 고를지는 담당자가 화면에서 정해야 다음이 진행되게 두었습니다.
이 포트폴리오가 보여주는 것은 업무 규칙을 검증 가능한 모델로 다루고 사람의 답마다 다시 검사하는 사내 도구를 프론트엔드부터 배포까지 혼자 만드는 기술이며, 게임 라이브 운영 기획이나 재화 밸런싱 같은 도메인 전문성은 주장하지 않습니다. 운영 표준 스키마를 제정하는 일도 여러 팀이 합의해야 하는 결과물이라 이 포트폴리오의 범위 밖입니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
라이브 운영 담당자가 사내 도구에 올린 이벤트 등록 요청서 한 건입니다. 이 도구가 만든 값이 아니라 밖에서 받아 오는 입력이며, 값이 빠지거나 서로 어긋나 검사가 막히는 요청을 일부러 섞어 두었습니다.
| req_id | batch_id | title_raw | event_type | region | starts_at | ends_at | rewards | banner_copy | locales | requested_by | source |
|---|---|---|---|---|---|---|---|---|---|---|---|
| req_101 | b_2608 | 여름 로그인 보상 이벤트 | 로그인보상 | KR | 2026-08-05T00:00 | 2026-08-18T23:59 | [{"item_code":"it_gold","qty":5000},{"item_code":"it_ticket_s","qty":3}] | 여름을 맞아 매일 접속하면 [[보상명]]을 드립니다. | ["ko"] | ops.live@example.com | ai_draft |
| req_102 | b_2608 | 여름 로그인보상(수정) | 로그인보상 | KR | 2026-08-10T00:00 | 2026-08-22T23:59 | [{"item_code":"it_gold","qty":3000}] | 매일 접속하고 골드를 받아 가세요. | ["ko"] | ops.live@example.com | manual |
| req_103 | b_2608 | 8월 상점 한정 패키지 | 상점판매 | JP | 2026-08-09T00:00 | — | [{"item_code":"it_pass_season","qty":1}] | 기간 한정 패키지를 만나 보세요. | ["ja"] | ops.shop@example.com | ai_draft |
| req_104 | b_2608 | 가을 랭킹전 사전 공지 | 랭킹전 | GLOBAL | 2026-09-01T00:00 | 2026-08-25T23:59 | [{"item_code":"it_gem","qty":300}] | 가을 랭킹전 참가자를 모집합니다. | ["ko","ja","en"] | ops.pvp@example.com | manual |
| req_105 | b_2608 | 복귀 유저 성장 지원 | 이벤트던전 | KR | 2026-08-12T00:00 | 2026-08-26T23:59 | [{"item_code":"it_growth_x","qty":10}] | 돌아온 여러분께 성장 재료를 드립니다. | ["ko"] | ops.crm@example.com | ai_draft |
아래 Zod 스키마에 맞는 이벤트 등록 요청서 120행을 JSON 배열로 생성합니다.
{"type":"object","properties":{"req_id":{"type":"string"},"batch_id":{"type":"string"},"title_raw":{"type":"string"},"event_type":{"type":"string","enum":["로그인보상","출석","상점판매","랭킹전","이벤트던전"]},"region":{"type":"string"},"starts_at":{"type":["string","null"]},"ends_at":{"type":["string","null"]},"rewards":{"type":"array","items":{"type":"object","properties":{"item_code":{"type":"string"},"qty":{"type":["integer","null"]}},"required":["item_code","qty"]}},"banner_copy":{"type":"string"},"locales":{"type":"array","items":{"type":"string"}},"requested_by":{"type":"string"},"source":{"type":"string","enum":["ai_draft","manual"]}},"required":["req_id","batch_id","title_raw","event_type","region","starts_at","ends_at","rewards","banner_copy","locales","requested_by","source"]}
쉬운 데이터만 만들지 않습니다. 하드 케이스를 반드시 섞습니다: (1) ends_at 이 starts_at 보다 앞서는 행 4건, (2) starts_at 또는 ends_at 이 null 인 행 4건, (3) 같은 batch_id 와 같은 region 과 같은 event_type 에서 기간이 겹치는 요청 쌍 3쌍, (4) 아이템 마스터에 없는 item_code 를 쓴 행 4건, (5) item_code 를 대문자나 앞뒤 공백이 섞인 표기로 쓴 행 3건, (6) qty 가 null 이거나 0 이거나 음수인 행 5건, (7) banner_copy 에 [[보상명]] 이나 {{기간}} 같은 치환되지 않은 자리표시자가 남은 행 4건, (8) region 이 SEA 나 EU 처럼 지원 목록에 없는 코드인 행 3건, (9) region 이 GLOBAL 인데 locales 에 ko 하나만 있는 행 3건, (10) rewards 가 빈 배열인 행 2건, (11) 같은 batch_id 안에서 title_raw 만 다르고 나머지가 같은 중복 요청 1쌍. JSON 배열만 출력합니다.{"type":"object","properties":{"req_id":{"type":"string"},"batch_id":{"type":"string"},"title_raw":{"type":"string"},"event_type":{"type":"string","enum":["로그인보상","출석","상점판매","랭킹전","이벤트던전"]},"region":{"type":"string"},"starts_at":{"type":["string","null"]},"ends_at":{"type":["string","null"]},"rewards":{"type":"array","items":{"type":"object","properties":{"item_code":{"type":"string"},"qty":{"type":["integer","null"]}},"required":["item_code","qty"]}},"banner_copy":{"type":"string"},"locales":{"type":"array","items":{"type":"string"}},"requested_by":{"type":"string"},"source":{"type":"string","enum":["ai_draft","manual"]}},"required":["req_id","batch_id","title_raw","event_type","region","starts_at","ends_at","rewards","banner_copy","locales","requested_by","source"]}운영툴에서 내려받은 보상 아이템 마스터 한 행입니다. 요청서의 보상이 지급 가능한지가 전부 이 표의 활성 여부와 지급 가능 지역과 1인 한도에서 대조로 나오므로, 대조가 막히는 행을 일부러 섞어 두었습니다.
| item_code | name_ko | category | per_user_cap | regions_allowed | is_active | replaced_by |
|---|---|---|---|---|---|---|
| it_gold | 골드 | 재화 | 30,000 | ["KR","JP","GLOBAL","TW"] | true | — |
| it_gem | 보석 | 재화 | 5,000 | ["KR","JP","GLOBAL","TW"] | true | — |
| it_gem_old | 구 보석 | 재화 | 5,000 | ["JP"] | false | it_gem |
| it_ticket_s | 던전 입장권(소) | 소모품 | 10 | ["KR","JP","GLOBAL","TW"] | true | — |
| it_ticket_l | 던전 입장권(대) | 소모품 | 3 | ["KR"] | true | — |
아래 Zod 스키마에 맞는 보상 아이템 마스터 90행을 JSON 배열로 생성합니다.
{"type":"object","properties":{"item_code":{"type":"string"},"name_ko":{"type":"string"},"category":{"type":"string","enum":["재화","소모품","코스튬","성장재료"]},"per_user_cap":{"type":["integer","null"]},"regions_allowed":{"type":"array","items":{"type":"string"}},"is_active":{"type":"boolean"},"replaced_by":{"type":["string","null"]}},"required":["item_code","name_ko","category","per_user_cap","regions_allowed","is_active","replaced_by"]}
쉬운 데이터만 만들지 않습니다. 하드 케이스를 반드시 섞습니다: (1) is_active 가 false 이면서 replaced_by 가 채워진 행 5건, (2) is_active 가 false 인데 replaced_by 가 null 이라 대체안을 제시할 수 없는 행 3건, (3) replaced_by 가 이 표에 없는 코드를 가리키는 끊긴 참조 3건, (4) regions_allowed 가 빈 배열이라 어느 지역에도 지급할 수 없는 행 3건, (5) per_user_cap 이 null 이라 한도가 정해지지 않은 행 4건, (6) per_user_cap 이 0 이라 지급이 사실상 막힌 행 3건, (7) item_code 가 대소문자만 다른 쌍 2쌍, (8) name_ko 가 빈 문자열인 행 2건, (9) replaced_by 가 자기 자신을 가리키는 행 1건. JSON 배열만 출력합니다.{"type":"object","properties":{"item_code":{"type":"string"},"name_ko":{"type":"string"},"category":{"type":"string","enum":["재화","소모품","코스튬","성장재료"]},"per_user_cap":{"type":["integer","null"]},"regions_allowed":{"type":"array","items":{"type":"string"}},"is_active":{"type":"boolean"},"replaced_by":{"type":["string","null"]}},"required":["item_code","name_ko","category","per_user_cap","regions_allowed","is_active","replaced_by"]}| React 또는 Next.js | 질문 목록과 선택지와 재검사 결과와 확정 게이트가 한 화면에서 맞물리는 검수 화면을 만들고, 답을 고를 때마다 재검사 결과를 다시 받아 옵니다. |
| 백엔드 API | 결함 검사와 질문 생성과 답변 저장과 등록 파일 내보내기를 각각의 엔드포인트로 나눠 화면과 규칙 엔진의 경계를 분명히 둡니다. |
| Python 또는 Go | 요청서와 아이템 마스터를 대조하는 규칙 엔진을 Python 으로 별도 서비스로 두고, 규칙마다 손으로 만든 정답표와 대조하는 테스트를 붙입니다. |
| AWS | 화면과 API 와 규칙 엔진을 2주차부터 AWS 에 올려 실제 주소로 운영하고 요청서 첨부 파일을 S3 에 둡니다. |
| CI/CD | GitHub Actions 로 규칙 정답표 테스트가 통과해야 배포되도록 묶어, 규칙을 고칠 때마다 기존 판정이 깨지지 않는 것을 자동으로 확인합니다. |
| Kubernetes(EKS) | 3주차에 규칙 엔진과 문서 색인 작업을 각각의 워커로 나눠 EKS 에 올리고 요청서 묶음이 커져도 화면 응답이 밀리지 않게 합니다. |
| RAG·AI 에이전트 | 3주차에 사내 운영 규정 문서를 문단 단위로 색인해 질문마다 근거 문단을 찾아 인용과 함께 붙이며, 판정 자체는 규칙이 그대로 맡습니다. |
✓ pass → 그대로 멈춰도 완결입니다. 더 키우려면 2주차에서 답변 이력을 쌓아 기본값 후보를 뽑는 화면을 붙입니다.
✓ pass → 여기까지도 완결입니다. 더 키우려면 3주차에서 운영 규정 문서의 근거 문단을 질문 옆에 붙이는 검색을 만듭니다.
담당자가 화면에서 확인 질문에 답해 확정한 요청서 한 건의 결과입니다. 어떤 결함이 무슨 질문이 되었고 사람이 무엇을 골랐으며 그 답으로 다시 검사했을 때 해소되었는지를 함께 담아, 다음 사람이 판정을 손으로 검산하게 합니다.
{"type":"object","properties":{"req_id":{"type":"string"},"batch_id":{"type":"string"},"reviewed_by":{"type":["string","null"]},"questions":{"type":"array","items":{"type":"object","properties":{"q_id":{"type":"string"},"code":{"type":"string","enum":["ITEM_NOT_FOUND","ITEM_INACTIVE","REGION_NOT_ALLOWED","QTY_OVER_CAP","QTY_MISSING","PERIOD_REVERSED","PERIOD_MISSING","PERIOD_OVERLAP","PLACEHOLDER_LEFT","REGION_UNSUPPORTED","LOCALE_MISSING","MANUAL"]},"field":{"type":"string"},"severity":{"type":"string","enum":["block","warn"]},"prompt":{"type":"string"},"options":{"type":"array","items":{"type":"string"}},"answer":{"type":["object","null"],"properties":{"kind":{"type":"string","enum":["option","value","waive"]},"value":{"type":"string"}},"required":["kind","value"]},"answered_by":{"type":["string","null"]},"recheck":{"type":["object","null"],"properties":{"passed":{"type":"boolean"},"new_codes":{"type":"array","items":{"type":"string"}}},"required":["passed","new_codes"]},"status":{"type":"string","enum":["open","resolved","waived"]}},"required":["q_id","code","field","severity","prompt","options","answer","answered_by","recheck","status"]},"minimum":1},"unresolved_questions":{"type":"array","items":{"type":"string"}},"export_locked":{"type":"boolean"},"registration_payload":{"type":["object","null"],"properties":{"title":{"type":"string"},"region":{"type":"string"},"starts_at":{"type":"string"},"ends_at":{"type":"string"},"rewards":{"type":"array","items":{"type":"object","properties":{"item_code":{"type":"string"},"qty":{"type":"integer"}},"required":["item_code","qty"]}},"banner_copy":{"type":"string"},"locales":{"type":"array","items":{"type":"string"}}},"required":["title","region","starts_at","ends_at","rewards","banner_copy","locales"]}},"required":["req_id","batch_id","reviewed_by","questions","unresolved_questions","export_locked","registration_payload"]}[
{
"req_id": "req_112",
"batch_id": "b_2609",
"reviewed_by": "ops.shop@example.com",
"questions": [
{
"q_id": "q_112_1",
"code": "ITEM_INACTIVE",
"field": "rewards[0].item_code",
"severity": "block",
"prompt": "it_gem_old 는 운영툴에서 비활성 처리된 아이템입니다. 대체 코드로 지정된 it_gem 으로 바꿀까요, 아니면 이 보상을 뺄까요?",
"options": [
"it_gem 으로 교체",
"보상에서 제외",
"다른 코드 직접 입력"
],
"answer": {
"kind": "option",
"value": "it_gem 으로 교체"
},
"answered_by": "ops.shop@example.com",
"recheck": {
"passed": true,
"new_codes": []
},
"status": "resolved"
},
{
"q_id": "q_112_2",
"code": "ITEM_INACTIVE",
"field": "rewards[1].item_code",
"severity": "block",
"prompt": "it_legacy_coin 은 비활성 아이템이고 대체 코드로 적힌 it_coin_v2 는 마스터에 없습니다. 이 보상을 뺄까요, 아니면 지급 가능한 다른 코드를 지정할까요?",
"options": [
"보상에서 제외",
"다른 코드 직접 입력"
],
"answer": {
"kind": "option",
"value": "보상에서 제외"
},
"answered_by": "ops.shop@example.com",
"recheck": {
"passed": true,
"new_codes": []
},
"status": "resolved"
},
{
"q_id": "q_112_3",
"code": "QTY_OVER_CAP",
"field": "rewards[0].qty",
"severity": "block",
"prompt": "교체한 it_gem 의 1인 한도는 5000 인데 요청 수량이 100 이라 한도 안에 들어옵니다. 수량을 그대로 둘까요, 아니면 조정할까요?",
"options": [
"100 유지",
"수량 직접 입력"
],
"answer": {
"kind": "option",
"value": "100 유지"
},
"answered_by": "ops.shop@example.com",
"recheck": {
"passed": true,
"new_codes": []
},
"status": "resolved"
},
{
"q_id": "q_112_4",
"code": "LOCALE_MISSING",
"field": "locales",
"severity": "warn",
"prompt": "지역이 JP 인데 배너 문구가 한국어로만 등록되어 있습니다. 일본어 문구를 지금 넣을까요, 아니면 번역 대기로 남길까요?",
"options": [
"일본어 문구 직접 입력",
"번역 대기로 남김"
],
"answer": null,
"answered_by": null,
"recheck": null,
"status": "open"
}
],
"unresolved_questions": [
"q_112_4"
],
"export_locked": true,
"registration_payload": null
}
]자동: (1) Zod parse, (2) status 가 open 인 q_id 집합이 unresolved_questions 와 정확히 같은지, (3) unresolved_questions 가 비어 있지 않으면 export_locked 가 참이고 registration_payload 가 null 인지, (4) status 가 resolved 인 모든 질문에 answer 와 recheck 가 있고 recheck.passed 가 참인지, (5) severity 가 block 인 질문은 answer.kind 가 waive 로 저장되지 않는지, (6) registration_payload 의 모든 item_code 가 마스터에 있고 is_active 가 참이며 regions_allowed 에 region 이 들어 있고 qty 가 1 이상 per_user_cap 이하인지, (7) starts_at 이 ends_at 보다 앞서고 둘 다 비어 있지 않은지, (8) banner_copy 에 [[ 나 {{ 로 시작하는 자리표시자가 남아 있지 않은지, (9) 손으로 만든 정답표와 결함 코드 집합이 한 건도 어긋나지 않는지 대조합니다. 사람 확인: 보상 수량을 줄일지 기간을 미룰지와 번역을 지금 넣을지는 담당자가 화면에서 판단합니다. severity 가 warn 인 질문을 waive 로 넘긴 항목은 사유가 비어 있으면 저장이 거절되며 확정본과 함께 예외 목록으로 따로 남습니다.