Back

solvesk 해부

회사에서 고객사를 대응할 헬프데스크가 필요했다. 마땅한 대안을 못 찾아서 직접 만들겠다고 했고, 네 번 만든 끝에 지금의 구조가 나왔다.

  • 1차: DB 19개 테이블, API 56개까지 불어났다. 내부 댓글이 고객에게 노출되는 보안 이슈가 터졌고, 감사를 돌려도 범위가 너무 넓어서 수렴하지 않았다
  • 2차: MVP만 뽑아서 처음부터 다시 짰다. 하루 만에 포기 — v1에 있던 리치 에디터가 textarea로 퇴행하고, 이미 해결한 문제를 다시 만나고 있었다
  • 3차: v1을 복사해서 깎아내는 방식. 같은 파일을 세 번 감사하면 세 번 다 다른 지적이 나왔다. 수렴하지 않는 감사는 감사가 아니었다
  • 4차: 프로세스를 바꿨다. 한 페이지의 CRUD를 테스트까지 완성한 뒤에 다음으로 넘어가는 규칙

세 번의 실패가 알려준 건 같았다 — 코드가 아니라 코드를 쌓아가는 순서가 문제다. 이 글은 네 번째에서 내린 판단들을 영역별로 정리한다.

기획 — 헬프데스크와 이슈 트래커의 경계

처음엔 고객 대응용 헬프데스크였다. 그런데 내부 조사 기록이나 진행 현황을 남길 수 있어야 했고, 결국 이슈 트래커를 겸해야 했다. “고객 포털은 헬프데스크, 내부 화면은 이슈 트래커”라는 구분은 처음부터 있었지만, 요구사항이 계속 추가되면서 그 경계가 무너진 게 첫 실패 원인이었다.

네 번째에서는 이 경계를 다시 엄격하게 세웠다. 고객은 이슈를 등록하고 진행 상황을 확인하는 것만 한다. 상태 변경, 우선순위, 할당은 내부 Agent가 처리한다. 역할별 할 수 있는 것과 없는 것을 먼저 정의하니 RBAC 설계가 자연스럽게 따라왔다.

디자인 — AI 기본 템플릿에서 벗어나기

AI로 UI를 만들면 다 비슷해진다. shadcn/ui를 그대로 쓰면 흑백 기반의 차가운 화면이 나온다. 이건 내가 가장 빨리 판단할 수 있는 영역이었다. 어디가 문제인지 보면 바로 보이니까.

두 가지 제약을 걸었다. 첫째, 디자인 토큰과 시스템을 먼저 정의하고 AI에게 강제했다. shadcn/ui를 기반으로 쓰되, Notion의 따뜻한 톤을 참고해 색감과 여백을 커스텀했다. 둘째, 미니멀함을 기준으로 삼았다. 별도 모달이나 폼 없이, 인라인 드롭다운 한 번으로 상태를 바꿀 수 있는 구조.

// 인라인 드롭다운으로 상태 변경
<ConfigSelect
  value={issue.status}
  options={ISSUE_STATUSES}
  onChange={(status) => updateStatus({ issueKey, status })}
/>

AI에게 자유를 주면 컴포넌트마다 스타일이 달라진다. 디자인 시스템을 기준점으로 잡아두니 일관성이 유지됐다.

프론트엔드 — 같은 화면, 다른 세계

같은 이슈 목록이지만 Admin, Agent, Customer가 보는 화면이 다르다. Customer는 내부 댓글이 안 보이고, 직원 이름 대신 가명이 표시되고, 상태는 “resolved”로만 바꿀 수 있다. 이 분기를 컴포넌트마다 if (role === 'customer') 로 흩뿌리면 관리가 안 된다는 건 경험적으로 알고 있었다. 역할이 추가될 때마다 모든 컴포넌트를 뒤져야 하는 구조를 전에 겪어봤으니까.

권한 매트릭스를 config로 한 곳에 모아두고, 컴포넌트는 그걸 참조만 하게 만들었다.

// 권한 매트릭스 — 역할별로 할 수 있는 것을 한 곳에서 관리
const PERMISSIONS = {
  issues: {
    updateStatus: ['admin', 'agent'],
    updatePriority: ['admin', 'agent'],
    updateAssignee: ['admin', 'agent'],
    viewPrivate: ['admin', 'agent'],
  },
  comments: {
    create: ['admin', 'agent', 'customer'],
    deleteOwn: ['admin', 'agent', 'customer'],
    delete: ['admin'],
  },
}

역할이 추가되거나 권한이 바뀔 때 이 파일 하나만 고치면 된다.

백엔드 — Row-Level Isolation

Multi-tenant에서 가장 중요한 건 “고객 A가 고객 B의 데이터를 절대 볼 수 없는 것”이다. 이 영역은 처음 설계해보는 거라 선택지부터 정리했다. 테넌트마다 DB를 분리하는 방법도 있지만, 헬프데스크 특성상 프로젝트 수가 많아질 수 있어서 row-level 격리를 선택했다.

// Customer는 자신의 projectId만, Agent는 membership 기반
async requireProjectAccess(user, projectId) {
  if (user.role === 'admin') return;
  if (user.role === 'customer') {
    if (user.projectId !== projectId) throw new ForbiddenError();
    return;
  }
  // Agent: project_members 테이블 확인
  const membership = await db.query.projectMembers.findFirst({
    where: and(
      eq(projectMembers.projectId, projectId),
      eq(projectMembers.userId, user.id)
    ),
  });
  if (!membership) throw new ForbiddenError();
}

Customer는 users.projectId로 1:1 바인딩, Agent는 project_members로 M:N 바인딩. 모든 쿼리가 이 검증을 거친다. 이 검증이 빠진 API가 하나라도 있으면 데이터 유출이다. 그래서 CLAUDE.md에 No missing customer isolation checks를 규칙으로 박아뒀다.

프론트에서 권한 매트릭스를 한 곳에 모은 것처럼, 백엔드에서도 접근 제어를 한 곳에 모은 셈이다. 영역은 다르지만 문제의 구조는 같았다 — 흩어지면 빠뜨린다.

테스트 — 다각도로 검증하기

Unit 302, Integration 152, E2E 167개. 이 숫자가 처음부터 목표였던 건 아니다. 네 번째 시도의 규칙 — “서비스 파일에는 반드시 테스트 파일이 따라붙는다” — 을 지키다 보니 쌓인 것이다. 이전 세 번의 시도에서 테스트를 나중에 몰아서 붙이려다 구조가 꼬인 경험이 이 규칙을 만들었다.

  • Unit — 서비스 로직이 입력에 대해 올바른 출력을 내는가
  • Integration — 역할 A가 역할 B의 데이터에 접근하면 정말 차단되는가
  • E2E — 사용자가 브라우저에서 실제로 이슈를 만들고 상태를 바꿀 수 있는가

특히 Integration에서는 프로젝트 2개, 사용자 5명(Admin 1, Agent 2, Customer 2)의 토폴로지를 고정해두고, 모든 역할 조합을 매트릭스로 검증했다.

// 고객 1이 프로젝트 B의 이슈에 접근하면 차단
it('customer1 cannot access project B issue', async () => {
  await expect(
    issueService.getByKey(w.customer1, issueBKey)
  ).rejects.toThrow(ForbiddenError);
});

이 테스트가 없었으면 “되는 것 같은데요”로 넘어갔을 버그가 여러 개 있었다.

오픈소스 — 처음 열어본 사람이 5분 안에 돌릴 수 있는가

오픈소스로 공개하기로 한 순간부터 기준이 바뀌었다. “내가 돌릴 수 있는가”가 아니라 “처음 보는 사람이 돌릴 수 있는가”가 됐다.

  • docker compose up -d 한 줄로 실행 가능하게 만들었다
  • 데모 계정 3개(Admin, Agent, Customer)를 시드에 포함했다
  • README에 역할별 계정 정보를 테이블로 정리했다

가장 고민한 건 데모였다. Vercel에 라이브 데모를 올려두고 admin@demo.com / password123으로 바로 들어갈 수 있게 했다. README만 읽고 판단하는 사람은 거의 없고, 직접 클릭해본 사람이 star를 누른다.

돌아보며

이 프로젝트를 통해 느낀 건, FE에서 해오던 판단이 다른 레이어에서도 쓸 수 있다는 거였다. 권한 분기를 한 곳에 모으는 건 FE에서 익숙한 패턴인데, 그게 백엔드 접근 제어에서도 같은 구조로 작동했다. 디자인 시스템으로 일관성을 강제하는 것도, AI에게 규칙을 명시하는 것도, 결국 “흩어지면 빠뜨린다”는 같은 원칙이었다.

반대로 처음 해본 영역에서는 삽질이 있었다. row-level 격리 설계는 선택지 정리부터 시작해야 했고, 테스트 토폴로지는 세 번 실패한 뒤에야 잡혔다. 네 번 만든 건 코드를 네 번 쓴 게 아니라, FE에서 통하던 판단이 어디까지 적용되고 어디서부터 새로 배워야 하는지를 확인한 과정이었다.