DSRV HR/GA Hub
흩어져 있던 사내 도구와 시트를 회사 Google SSO 하나로 들어가는 단일 웹앱으로 통합. 회의실·이벤트·디렉토리·제휴식당·자기계발비·근속알림 6개 모듈이 라이브.
인사총무 담당자가 자기 팀의 반복 업무를 직접 프로덕션 웹앱으로 만들어 운영 중인 사내 통합 플랫폼. 구현은 AI 페어프로그래밍(Claude Code), 설계·검증·의사결정은 직접.
1. 한눈에
| 역할 | 기획 · 설계 · 구현 · 배포 · 운영 (1인) |
| 기간 | 2026-03 ~ 운영 중 (약 5개월) |
| 상태 | 프로덕션 라이브 · 전사 대상 배포 |
| 관리 대상 | 구성원 약 90명 (2026-03 기준) |
| 스택 | Next.js 16 (App Router) · React 19 · TypeScript · Tailwind 4 · NextAuth v5 · Drizzle ORM · Postgres(Neon) · Vercel |
| 규모 | 소스 203파일 36,904줄 · 화면 13 · API 28 · DB 5테이블 · 마이그레이션 9 |
| 품질 | 자동 테스트 978개 (42파일, 전부 통과) · 커밋 166 · 머지된 PR 37 |
⚠️ 실사용자 수와 사용 빈도는 측정하지 않았다. 위 인원 수는 Hub가 관리하는 구성원 로스터의 규모이지 활성 사용자 수가 아니다. 팀 수는 인사발령·조직개편으로 시점마다 달라지므로 항상 기준 시점과 함께 쓴다.
2. 배경 — 무엇이 문제였나
인사총무 업무가 서로 다른 4곳에 흩어져 있었다.
- 회의실 예약 현황은 Google Calendar를 사람이 눈으로 훑어야 했다.
- 사내 이벤트(블라인드 런치·치맥파티) 조 편성은 별도 웹앱이었고, 인증이 공유 비밀번호 하나라 누가 무엇을 했는지 알 수 없었다.
- 자기계발비는 구글시트로 운영돼, 시트 안에서 두 숫자가 서로 맞지 않는 상태가 방치돼 있었다.
- 구성원 명단·조직도·제휴식당 목록은 엑셀 파일로 돌아다녔다.
공통점은 “매달 같은 일을 사람이 손으로 다시 한다“는 것, 그리고 틀려도 조용하다는 것이었다.
3. 무엇을 만들었나
로그인 한 번(회사 Google 계정)으로 들어가는 단일 웹앱. 기능 6종이 라이브다.
| 기능 | 하는 일 | 대체한 것 |
|---|---|---|
| 회의실 대시보드 | 예약 현황·오늘 일정·이용 통계·빈 시간 추천 | Calendar 눈으로 훑기 |
| 이벤트 랜덤픽 | 블라인드 런치·치맥파티 조 편성, 제약조건·히스토리·뱃지 | 공유 비밀번호로 열던 별도 앱 |
| 구성원 디렉토리 | 전 직원 명단 + 팬·줌 조직도, 관리자 로스터 CRUD | 돌아다니던 엑셀 |
| 식권대장 제휴식당 | 제휴식당 337곳을 카테고리·지역·사무실 거리순으로 검색 | 엑셀 목록 |
| 자기계발비 | 개인 한도·잔여·사용내역 + 전사 집행률 관리자 대시보드 + CSV | 구글시트 |
| 근속·생일 | 이번 달 생일·입사기념일 + 매일 아침 Slack 자동 알림 | 사람이 기억하기 |
설계 원칙 하나: 포털은 기록·관리, 전달은 채널. 아무도 대시보드를 켜두지 않으므로 알림은 Slack이 맡는다.
4. 아키텍처
구성원 브라우저 ──HTTPS──> [Vercel] [외부]
DSRV HR/GA Hub ──OAUTH────> Google OAuth
(Next.js·기능 6종)
│ FETCH
▼
API Routes ────CALENDAR────> Google Calendar
(28 handlers · (회의실 예약)
requireRole())
│ SQL
▼
Neon Postgres <──READ/WRITE── 생일 알림 크론 ──HOLIDAYS──> 공공데이터포털
(drizzle + raw) (매일 1회) ──WEBHOOK───> Slack 채널
- 사람 경로와 기계 경로가 같은 DB를 공유하는 구조.
- 크론에는 로그인 세션이 없다 → 공휴일을 Google Calendar가 아니라 공공데이터포털 특일정보 API에서 받는다. (Calendar 호출이 로그인한 사용자의 OAuth 토큰에 묶여 있기 때문)
- 상세 다이어그램:
architecture.html/architecture.png
5. 어떻게 만들었나 — AI 페어프로그래밍
코드 대부분은 Claude Code가 썼다. 그것을 숨기지 않는다. 대신 이 프로젝트의 실제 난이도는 다른 데 있었다.
| 내가 한 것 | AI가 한 것 |
|---|---|
| 무엇을 만들지, 무엇을 안 만들지 정하기 | 코드 작성 |
| 사내 규정을 조문 단위로 대조해 계산 로직 확정 | 테스트 작성 |
| 아키텍처·데이터 모델·권한 경계 결정 | 리팩터링 |
| AI 산출물을 검증하는 체계 설계 | 문서 초안 |
| prod 마이그레이션·배포·롤백 판단 |
검증 체계 — 이 프로젝트에서 가장 값어치 있는 부분
모든 기능은 PR로 올리고, 구현과 분리된 세션에서 독립 리뷰 2종(설계·보안)을 받는다. 이 저장소에서 리뷰는 매번 MEDIUM 이상 결함을 잡았다.
실제로 잡힌 것들:
- 두 리뷰가 정면 충돌한 BLOCKER 1건을 직접 실측해 판정했다.
db.transaction()이 이 앱에서는 원자적이지 않았다 — 공유 DB 핸들이 드라이버의 Proxy라 ORM이 전용 커넥션을 잡지 못한다. 순차 실행에서는 우연히 통과하고 동시 쿼리를 섞어야 깨진다. 방치했으면 데이터 전량 교체 중 삭제만 커밋돼 프로덕션이 0행이 될 수 있었다. - 머지 직전 보안 리뷰가 저장소 공개 사고를 잡았다. 코드 저장소가 의도치 않게 PUBLIC이었고, 작업 브랜치가 인프라 식별자를 노출하고 있었다. 리뷰가 main 오염을 막았고, 전 브랜치 재검색·PRIVATE 전환까지 당일 종결했다.
가장 크게 배운 것 — “전제를 아무도 검사하지 않는다”
하루에 같은 모양의 실수가 6번 나온 것을 발견하고 규칙으로 만들었다.
| 무엇이 | 무엇을 주장했나 | 검사되지 않은 전제 |
|---|---|---|
| 화면 배너 | “다음 근무일이 재시도합니다” | 크론이 그 날짜를 실제로 집는가 → 안 집었다 |
| 배포 규율 | “preview에서 눈으로 확인했다” | preview가 prod와 같은 코드인가 → 2,426줄 뒤처져 있었다 |
| 문서 수치 | “좌표 337/337 수집 완료” | 세어봤는가 → 실제 334 |
전부 자기가 검증할 수 없는 것을 참이라고 말했고, 전부 오래 조용했다.
규칙: 주장·약속·규율·기록·수치를 만들 때, 그 전제를 무엇이 검사하는지를 같이 정한다. 검사할 수 없으면 주장을 약화시킨다.
이 규칙이 실제로 값을 했다. 크론 발송 시각이 정확한지 확인되지 않은 상태에서 안내 문구를 미리 “09:00”에서 “아침”으로 약화해 뒀는데, 나중에 플랜 제약 때문에 정각 발송이 불가능한 것으로 밝혀졌다 — 고칠 코드가 없었다.
AI가 만든 코드는 그럴듯하게 틀린다. 그래서 “무엇이 이걸 검사하는가”를 묻는 습관이 코딩 능력보다 중요했다.
6. 기술적 의사결정
① 두 앱을 합칠 때 모노레포·마이크로프론트엔드를 기각했다 두 앱이 사실상 같은 스택이라 통합은 “재작성”이 아니라 “파일 이동”이었고, 진짜 작업은 인증 통합 하나뿐이었다. 단일 Next.js 앱 + 라우트 그룹을 택했다. 수십 명 쓰는 사내 도구에 분산 아키텍처는 순수 오버헤드다.
② 공유 비밀번호를 폐기하고 Google SSO로 단일화했다
누가 로그인했는지 모르면 감사도 권한도 불가능하다. 역할은 member/admin 2개만 뒀다(5개 역할은 과설계). 권한 검사는 미들웨어 접두어가 아니라 각 라우트의 requireRole() 로 — 미들웨어만 믿으면 새로 추가되는 라우트가 무방비다.
③ 완성한 기능을 머지하지 않고 되돌렸다 제휴식당 지도 뷰를 다 만들어 놓고 폐기했다. 동작 조건이 외부 콘솔에 회사 도메인을 등록하는 것인데, 지도의 효용이 그 부담을 넘지 못한다고 판단했다. 거리순 정렬은 서버가 좌표를 미리 DB에 적재하는 방식이라 그 제약을 받지 않는다 — 요구사항을 쪼개니 90%는 부담 없이 얻을 수 있었다.
④ “알림의 실패는 조용하다”를 전제로 설계했다 크론은 실패해도 재시도되지 않고, 같은 실행이 두 번 들어올 수 있으며, 배달이 누락될 수 있다. 그래서 ① 멱등성(DB unique 제약으로 강제) ② 캐치업(마지막 커버 날짜 이후 추적) ③ 실패 가시화 2축 — 실패 건수만 보면 크론 자체가 죽었을 때 0으로 조용하므로, “마지막 커버 날짜 신선도”를 함께 본다.
⑤ 로컬 개발이 프로덕션 DB에 직접 쓰고 있던 것을 닫았다 DB의 copy-on-write 클론 브랜치 + 개발 전용 OAuth 클라이언트 + 기동 가드(prod 엔드포인트를 감지하면 dev 서버가 뜨지 않는다)로 격리했다. 로컬 Postgres를 쓰지 않은 이유는 드라이버를 분기하면 “로컬에선 됐는데”가 생겨 회귀 검증 가치가 떨어지기 때문이다.
7. 숫자로 본 결과
| 항목 | 수치 |
|---|---|
| 프로덕션 운영 기간 | 5개월 · 무중단 |
| 통합한 사내 앱 | 2개 → 1개 |
| 라이브 기능 | 6종 |
| 이관한 수기 관리 자산 | 구성원 로스터 88명 · 제휴식당 337곳 · 자기계발비 집행 내역 66건 |
| 자동 테스트 | 978개 (전부 통과) |
| 머지된 PR | 37 (전건 독립 리뷰 통과) |
| 프로덕션 장애 | 0 |
채우면 좋을 칸(내가 만들 수 없는 수치): 월 절감 시간, 사용자 수/빈도, 인사총무팀 체감 피드백. 이 셋이 붙으면 문서가 훨씬 강해진다.