회사 일을 개인 서버에 두면 생기는 일 — 워크스페이스를 어떻게 나눌 것인가
TL;DR
- 개인 홈랩의 셀프호스팅 이슈 트래커에 개인 일과 회사 일을 같이 올릴 수 있나를 따졌다
- 통합 조회가 목적이면 인스턴스는 하나가 유리하다. 둘로 나누면 키도 엔드포인트도 두 벌이 된다
- 진짜 쟁점은 다른 데 있었다 — 퇴사하면 회사가 그 데이터를 회수할 수 없다
- 질문을 다시 세우니 답이 바뀌었다. 통합할 대상은 “회사 데이터”가 아니라 **“내가 한 일”**이었다
- “나중에 분리한다”는 계획은 실제로 가능한지 재봐야 계획이 된다. 스키마를 재보니 난이도를 정하는 건 구조가 아니라 사용자 수였다
개인 홈랩에 이슈 트래커를 띄워놓고 보니 욕심이 생겼다. 회사 일까지 여기서 보면 “내가 뭘 했는지”를 한곳에서 볼 수 있지 않을까. 기술적으로는 쉬웠다. 막힌 것은 기술이 아니었다.
배경: 한곳에서 보고 싶었다
여러 프로젝트에서 코딩 에이전트 세션을 돌린다. 개인 사이드 프로젝트가 몇 개 있고, 회사 일도 있다. 이슈 트래커를 홈랩에 셀프호스팅해두고 나니 자연스럽게 든 생각이 있었다.
여기에 회사 프로젝트도 올리면, 내가 전체적으로 어떤 작업을 했는지 한곳에서 볼 수 있다.
주간 회고를 쓸 때, 이력서를 갱신할 때, “지난 분기에 뭐 했더라”를 되짚을 때 — 개인과 회사를 오가며 두 시스템을 뒤지는 것이 매번 번거로웠다.
그런데 회사 데이터를 개인 홈랩 서버에 두는 게 걸렸다. 이 찜찜함이 무엇인지 정확히 짚지 못한 채로 설계부터 시작한 것이 이 글의 출발점이다.
처음 답: 인스턴스를 나눈다
처음엔 인스턴스를 둘로 두는 쪽을 생각했다. 회사 것은 회사 클라우드에, 개인 것은 홈랩에. 데이터가 물리적으로 분리되니 깔끔하다.
그런데 되짚어보니 목적과 어긋났다.
통합 조회가 목적인데 인스턴스를 나누면 조회할 곳이 둘이 된다. API 키도 두 벌, 엔드포인트도 두 벌, 업그레이드 시점이 갈리면 스키마도 갈린다. “한곳에서 보고 싶다”고 시작해놓고 볼 곳을 늘리는 셈이다.
인스턴스가 하나면 SQL 한 번이다.
select w.slug, p.name, i.name, i.created_at
from issues i join projects p on p.id = i.project_id
join workspaces w on w.id = p.workspace_id
order by i.created_at desc;
대부분의 이슈 트래커는 워크스페이스라는 최상위 구획을 준다. 한 인스턴스 안에서 워크스페이스로 나누면 데이터는 섞이지 않으면서 조회는 한 번에 된다. 기술적으로는 여기서 끝나야 했다.
진짜 쟁점은 조회가 아니었다
이 설계를 설명하다가 지적을 받았다.
퇴사하면 회사 데이터를 두고 나와야 하는데, 개인 서버에 있으면 회사가 회수할 수 없다.
이게 핵심이었다. 내가 찜찜해했던 것의 정체이기도 하다.
회사 업무 기록이 개인 홈랩 DB에만 있으면, 퇴사할 때 회사는 그 기록에 접근할 방법이 없다. 내가 덤프를 떠서 넘기겠다고 해도 그건 선의에 기대는 절차지 회사가 통제하는 자산이 아니다. 반대로 내 쪽에서도 문제다 — 퇴사 후에도 회사 업무 상세가 내 서버에 남아 있으면 그건 들고 나온 것이 된다.
조회 편의를 따지느라 자산의 소유와 회수라는 더 큰 축을 안 보고 있었다.
질문을 다시 세웠다
지적을 받고 나서야 질문이 잘못됐다는 걸 알았다. 원래 질문은 이랬다.
회사 데이터를 개인 서버에 어떻게 안전하게 둘 것인가?
이 질문에는 좋은 답이 없다. 어떻게 두든 회수 문제가 남는다. 그래서 질문을 바꿨다.
내가 통합해서 보고 싶은 것이 “회사 데이터”인가, “내가 한 일”인가?
후자였다. 회고를 쓸 때 필요한 건 티켓 본문이나 고객사 정보가 아니라 “언제 무엇을 했다” 수준이다.
그러면 답이 나온다.
- 회사 업무의 실체는 회사 시스템에 둔다 — 티켓, 논의, 산출물 전부
- 개인 쪽에는 “언제 무엇을 했다” 수준만 남긴다 — 날짜, 한 줄 제목, 어떤 종류의 일이었는지
이러면 퇴사할 때 회사에 넘길 것이 이미 회사 쪽에 다 있다. 개인 쪽에 남는 건 업무 데이터가 아니라 커리어 이력에 가까워진다. 이력서에 적을 수 있는 수준만 남는 셈이다.
목적이 바뀐 게 아니라 목적을 정확히 적었더니 설계가 따라 바뀌었다.
”나중에 분리한다”는 계획인가, 말인가
당장은 개인 워크스페이스 하나로 시작하고, 회사 일이 늘면 그때 분리하기로 했다. 그런데 이런 유예에는 함정이 있다.
“나중에 분리하면 된다”는 문장은, 나중에 정말 분리할 수 있는지 확인하기 전까지는 계획이 아니라 희망이다.
실제로는 데이터가 얽혀서 못 떼는 경우가 훨씬 많다. 그래서 미루기로 한 김에 스키마를 재봤다.
| 항목 | 값 |
|---|---|
| 전체 테이블 | 110 |
workspace_id를 가진 테이블 | 81 |
| FK 삭제 규칙 | 474개 전부 NO ACTION |
| 상호 참조 쌍 | 10 |
users 참조 FK / 사용자 수 | 236 / 1명 |
워크스페이스가 실질적인 경계였다
110개 중 81개가 workspace_id를 갖고 있다. where workspace_id = X 하나로 걸러진다는 뜻이다.
나머지 29개는 프레임워크 내부 테이블과 인스턴스 전역 설정이라 애초에 옮길 대상이 아니다.
워크스페이스가 이름뿐인 구획이 아니라 데이터 모델에 실제로 박힌 경계라는 것이 확인됐다. 이게 안 되면 “나중에 분리”는 그 자리에서 폐기됐어야 한다.
CASCADE가 하나도 없었다
FK 474개가 전부 NO ACTION이었다. 처음엔 의외였는데, 이유가 있었다.
이 앱은 Django로 만들어졌고 Django는 삭제를 애플리케이션 레이어에서 처리한다.
ORM이 on_delete=CASCADE를 선언해도 그건 파이썬 코드로 도는 것이지 DB 제약으로 내려가지 않는다.
실무적으로 이게 뜻하는 바가 있다. “덤프를 반대쪽에 복원한 뒤 원본에서 지운다”는 손쉬운 길이 막힌다. DB가 알아서 딸린 행을 지워주지 않으니까.
대신 리프 테이블부터 역순으로 지우면 되고, 그 순서는 스키마에서 뽑을 수 있다. 상호 참조 10쌍만 한쪽 FK를 먼저 끊어야 한다. 번거롭지만 막힌 건 아니다.
겁먹었던 숫자가 사실은 쉬웠다
users를 참조하는 FK가 236개라는 걸 보고 잠깐 멈췄다. 계정이 이렇게 많은 테이블에 물려 있으면
분리가 지옥이겠다 싶었다.
그런데 사용자가 1명이다. 혼자 쓰는 인스턴스니까. 236개가 전부 같은 사람을 가리킨다. UUID 치환 한 번이면 끝난다.
여기서 남은 문장이 있다.
실행 난이도를 정하는 건 구조가 아니라 사용자 수와 데이터 양이다.
FK 236개는 무섭게 생겼지만 행이 1개면 아무것도 아니다. 반대로 FK가 3개여도 사용자가 500명이고 서로 교차 참조하면 그게 지옥이다. 스키마 그림만 보고 난이도를 판단하면 양쪽으로 다 틀린다.
그리고 혼자 쓰는 지금이 가장 쉽다. 미루는 결정을 내릴 때 이건 중요한 정보다 — 미룰수록 어려워지는 종류의 일이라면 “나중에”의 비용이 계속 올라간다는 뜻이니까.
프로젝트 하나만 옮기는 건 더 쉬웠다
워크스페이스 통째가 아니라 프로젝트 하나만 옮기는 경우도 재봤다.
project_id를 가진 61개 테이블이 전부 workspace_id도 갖고 있었다. 그 열만 갈아끼우면 된다.
계정을 건드릴 일이 없으니 워크스페이스 이동보다 쉽다.
이 결과를 ADR로 남겼다. 나중에 실제로 옮길 때 “왜 이렇게 정했고 무엇을 확인했는지”가 남아 있으면 그때의 내가 처음부터 다시 재지 않아도 된다.
세션이 자기 워크스페이스만 보게 하기
설계가 정해지고 나니 운영 문제가 남았다. 레포마다 코딩 에이전트 세션을 여는데, 각 세션이 자기 워크스페이스만 보게 해야 한다. 개인 레포 세션이 회사 워크스페이스를 조회하면 애초에 나눈 의미가 없다.
이 앱의 MCP 서버는 환경변수로 대상 워크스페이스를 받는다. 그래서 .mcp.json을 레포별로 두면 된다.
그런데 상위 디렉토리의 설정이 하위로 상속되는지 몰라서 실험해봤다.
임시 디렉토리에 .mcp.json을 두고 하위 두 단계에서 목록을 조회했더니 세 위치 모두에서 잡혔다.
상속된다. 그래서 이렇게 뒀다.
~/Projects/.mcp.json → 개인 워크스페이스 (기본값)
~/Projects/<회사레포>/.mcp.json → 회사 워크스페이스 (덮어쓰기)
기본값을 위에 두고 예외만 아래에서 덮는 구조다.
추측 하나가 틀렸다
여기서 오판을 하나 했다. api_tokens 테이블에 workspace_id 컬럼이 있는 걸 보고
**“워크스페이스마다 토큰을 따로 발급해야 한다”**고 판단했다.
실제 발급 화면을 열어보니 Personal Access Token이었다. 계정 단위다. 같은 토큰으로 두 워크스페이스를 조회해보니 둘 다 200이 떨어졌다.
스키마는 의도를 말해주지 않는다.
컬럼이 있어도 UI가 안 쓸 수 있다. 반대로 UI에 없는 제약이 코드 안에 있을 수도 있다. 확인은 그 경로를 실제로 밟아봐야 끝난다 — 테이블 정의를 읽는 것으로는 안 끝난다.
지정과 격리는 다르다
이 오판이 중요했던 이유는 따로 있다. 토큰이 계정 단위라는 건 .mcp.json을 나눈 게 격리가 아니라
지정이라는 뜻이기 때문이다.
이 앱의 API는 /api/v1/workspaces/{slug}/... 형태라 워크스페이스가 URL 경로로 정해진다.
정리하면 이렇다.
- 토큰 = “누구인가” (계정 단위, 모든 워크스페이스에 접근 가능)
- 슬러그 = “어디를 볼 것인가” (요청마다 정해짐)
즉 .mcp.json을 나눠둔 것은 세션이 엉뚱한 워크스페이스를 건드리지 않게 하는 실수 방지이지
권한 경계가 아니다. 마음먹으면(혹은 실수로 다른 슬러그를 넘기면) 넘어갈 수 있다.
진짜 경계가 필요하면 계정을 나누거나 인스턴스를 나눠야 한다. 이 구분을 흐리면 “격리했으니 괜찮다”고 착각한 채로 민감한 데이터를 올리게 된다. 앞에서 회사 업무의 실체를 개인 쪽에 두지 않기로 한 판단이 여기서 다시 유효해진다 — 경계가 실수 방지 수준이라면, 애초에 넘어가도 곤란하지 않을 것만 두는 게 맞다.
참고로 토큰 값은 .mcp.json에 직접 넣지 않았다. env 필드가 ${VAR} 확장을 지원해서,
값은 권한 걸린 파일 하나에 두고 설정 파일에는 변수 이름만 남겼다. 설정 파일은 git에 들어가니까.
배운 것들
- 목적을 정확히 적으면 설계가 따라온다 — “회사 데이터를 어떻게 둘까”에는 좋은 답이 없었고, “내가 한 일을 어떻게 볼까”로 바꾸니 답이 나왔다. 막혔을 때 답을 더 찾기 전에 질문을 다시 읽어보는 게 빠르다
- 조회 편의보다 소유와 회수가 큰 축이다 — 남의 자산을 내 인프라에 두는 순간, 편의는 회수 문제 앞에서 밀린다. 이건 기술 결정이 아니라 경계 결정이다
- “나중에 한다”는 가능한지 확인하기 전까지 계획이 아니다 — 한 번 재보는 데 몇 시간이면 된다. 못 뗀다는 걸 나중에 알면 그때는 선택지가 없다
- 난이도는 구조가 아니라 데이터가 정한다 — FK 236개도 행이 1개면 쉽고, FK 3개도 행이 많고 얽히면 어렵다. 그리고 대개 지금이 가장 쉽다
- 스키마는 의도를 말해주지 않는다 — 컬럼이 있어도 UI가 안 쓸 수 있다. 실제 경로를 밟아봐야 끝난다
- 지정과 격리를 구분한다 — 설정으로 대상을 정하는 것과 권한으로 막는 것은 다르다. 섞어 생각하면 “격리했다”는 착각 위에 민감한 것을 올리게 된다
마치며
기술적으로는 쉬운 문제였다. 워크스페이스로 나누고 설정 파일을 레포별로 두면 끝이다. 실제로 막힌 것은 **“이 데이터가 누구 것이고 헤어질 때 어떻게 되는가”**였고, 그건 스키마를 아무리 봐도 안 나온다.
셀프호스팅은 통제권을 준다. 그런데 그 통제권이 남의 자산에까지 미치는 순간 성격이 달라진다. 내 서버에 있다는 건 내가 지킬 수 있다는 뜻이기도 하지만, 상대가 회수할 수 없다는 뜻이기도 하다.
덧붙이면, 이 이슈 트래커는 결국 27일 만에 걷어냈다. 관리 비용이 값어치를 넘어서서 다른 글에 그 기록을 따로 남겼다. 그래도 여기서 한 판단은 도구가 바뀌어도 그대로 쓴다 — 어차피 다음 도구에서도 같은 질문을 받게 된다.
이 글은 코딩 에이전트와 작업하며 남긴 worklog·ADR을 바탕으로 정리했다.