Plane은 Jira · Linear · Monday · ClickUp의 오픈소스 대안입니다. 할 일(Work Item)을 만들고, 스프린트(Cycle)로 묶고, 기능 단위(Module)로 쪼개고, 문서(Page)를 같이 쓰는 — 이른바 "팀 업무 관리" 도구 전체를 담고 있습니다. 라이선스는 AGPL-3.0이고, docker compose up 한 번이면 내 서버에서 통째로 돕니다.
구조는 4층입니다. Workspace(회사) 안에 Project(팀·제품)가 있고, 그 안에 Work Item(할 일)이 쌓이며, 이 할 일들을 기간으로 자르면 Cycle(스프린트), 목적으로 자르면 Module(기능 묶음)이 됩니다. 여기에 실시간 협업 문서 Page와 외부 요청 접수함 Intake가 붙습니다.
Jira는 사람 수만큼 자릿세를 받는 임대 사무실입니다. 팀이 커지면 요금이 선형으로 늘고, 나가려 하면 지난 3년치 이슈 기록이 남의 서버에 있습니다. Plane은 같은 평면도를 내 서버라는 땅 위에 직접 짓는 것입니다. 자릿세가 없고, 데이터는 내 Postgres에 있습니다.
다만 짓는 값은 내가 냅니다 — Postgres · Valkey · RabbitMQ · MinIO · Caddy까지 서버 프로세스 11개를 직접 띄우고 관리해야 하는 물건이라, "무료"의 실제 단가는 운영 시간입니다.
SPDX-License-Identifier: AGPL-3.0-only가 박혀 있습니다.Jira·Linear·Monday·ClickUp은 전부 사용자당 월정액입니다. 팀 30명이면 매달 고정비가 발생하고, 협력사·인턴·외부 리뷰어를 초대할 때마다 좌석을 더 삽니다. 이슈 이력·첨부파일·문서는 벤더 클라우드에 고정되고, 규제 산업(금융·의료·공공)은 애초에 외부 SaaS 반입이 막혀 있는 경우가 많습니다.
AGPL 커뮤니티 에디션은 좌석 제한이 없습니다. 스택도 특수한 게 없어서 — Django, Postgres, React — 사내 인프라 팀이 이미 아는 물건들로만 구성됩니다. 폐쇄망(air-gapped) 설치 스크립트(restore-airgapped.sh)까지 저장소에 들어 있어, 인터넷이 끊긴 내부망 배포를 애초에 상정하고 만들었습니다.
| 항목 | Jira / Linear | Plane (CE) |
|---|---|---|
| 과금 | 사용자당 월정액 | 없음 (서버 비용만) |
| 데이터 위치 | 벤더 클라우드 | 내 Postgres |
| 커스터마이징 | 플러그인 SDK / 앱 마켓 | 소스 포크 (단, AGPL 의무) |
| 공개 뷰 | 유료 플랜 기능 | /spaces/ 전용 앱 내장 |
| 실시간 문서 | 제품에 따라 별매 | Hocuspocus + Yjs 내장 |
| 외부 API | 있음 | /api/v1/ + API 키 내장 |
| 운영 부담 | 없음 | 컨테이너 11개 직접 운영 |
제품 소개만 보면 "또 하나의 Jira 클론"입니다. 그런데 코드를 열면 이야기가 달라집니다. Plane은 최근 프론트엔드를 통째로 갈아엎었고, 그 결과가 2026년 프런트엔드 판이 어디로 움직이는지를 한 저장소 안에서 다 보여줍니다.
| 영역 | 흔한 선택 (2023년경) | Plane의 현재 (v1.4.2) |
|---|---|---|
| 프레임워크 | Next.js (App Router) | React Router 7.18 + Vite 8 — Next.js 없음 |
| 린터·포매터 | ESLint + Prettier | oxlint 1.51 + oxfmt 0.35 (Rust) |
| CSS | Tailwind 3 | Tailwind 4.1 (@tailwindcss/postcss) |
| 버전 관리 | 패키지마다 각자 명시 | pnpm catalog 중앙 고정 |
| 패키지 빌드 | tsup / rollup | tsdown |
| 상태 관리 | Zustand / Redux Toolkit | MobX 6 클래스 스토어 (유지) |
즉 "React 앱 = Next.js"라는 기본값을 깨고 SPA로 되돌아간, 규모 있는 실서비스 사례입니다. 2,040개 .tsx 파일 · 19만 줄짜리 프론트엔드가 실제로 그 선택으로 굴러가고 있다는 게 이 저장소의 학습 가치입니다.
블로그·튜토리얼 상당수가 아직 "Next.js 기반, apiserver/ 폴더"라고 씁니다. 현재 코드에는 Next.js가 없고, 백엔드도 최상위 apiserver/가 아니라 apps/api/에 있습니다. packages/editor/package.json의 keywords에 "nextjs"가 남아 있는 것도 지우지 않은 흔적일 뿐 실제 의존이 아닙니다. 자료를 읽을 땐 항상 코드로 대조하세요.
apps/api/)| 구성요소 | 버전 | 역할 |
|---|---|---|
| Django | 5.2.15 | 웹 프레임워크·ORM. 소스 651개 · 10.4만 줄(마이그레이션 제외 8.1만) |
| djangorestframework | 3.17.1 | REST API 계층 |
| psycopg | 3.3.4 | Postgres 드라이버 (psycopg2 아님 — 3세대) |
| celery | 5.5.3 | 백그라운드 작업 큐 |
| django_celery_beat | 2.9.0 | 주기 작업 스케줄러 (DB 기반) |
| django-redis / redis | 7.0.0 / 5.0.4 | 캐시 — 큐가 아님 (아래 주의 참고) |
| channels | 4.3.2 | ASGI·웹소켓 지원 |
| boto3 / django-storages | 1.34.96 / 1.14.6 | S3·MinIO 파일 저장 |
| drf-spectacular | 0.29.0 | OpenAPI 스키마 자동 생성 |
| zxcvbn / cryptography / PyJWT | 4.4.28 / 50.0.0 / 2.13.0 | 비밀번호 강도 · 암호화 · 토큰 |
| nh3 | 0.2.18 | HTML 새니타이저 (Rust ammonia 바인딩, XSS 방어) |
| opentelemetry-* | 1.28.1 | 분산 추적 (OTLP 익스포터 포함) |
| gunicorn / uvicorn | 23.0.0 / 0.29.0 | 운영 WSGI / 개발 ASGI 서버 |
Django + Celery 조합에서는 Redis를 브로커로 쓰는 게 흔해서 그렇게 읽기 쉽지만, Plane의 CELERY_BROKER_URL은 RABBITMQ_* 환경변수(또는 AMQP_URL)로 조립됩니다. Redis(정확히는 Valkey)는 Django 캐시와 Hocuspocus의 인스턴스 간 pub/sub에만 씁니다. 이 둘을 헷갈리면 "큐가 안 도는데 Redis는 멀쩡하다"에서 한참 헤맵니다.
apps/web · space · admin)| 구성요소 | 버전 | 역할 |
|---|---|---|
| React | 18.3.1 | UI 라이브러리 (아직 19 아님) |
| react-router | 7.18.1 | 라우팅 — Next.js 자리를 대체 |
| Vite | 8.0.16 | 번들러·개발 서버 |
| MobX | 6.12.0 | 상태 관리 (클래스 스토어 + computedFn) |
| Tailwind CSS | 4.1.17 | 스타일 (+ tailwind-merge 3.4, cva 0.7.1) |
| TipTap | 2.22.3 | 리치 텍스트 에디터 (ProseMirror 위) |
| Yjs / y-prosemirror | 13.6.20 / 1.3.7 | CRDT 동시 편집 (+ y-indexeddb 오프라인) |
| @atlaskit/pragmatic-drag-and-drop | 1.7.4 | 칸반 드래그앤드롭 — Atlassian이 만든 라이브러리 |
| react-hook-form | 7.51.5 | 폼 |
| recharts | ^2.15.1 | 차트 (+ @plane/propel 자체 래퍼) |
| i18next / react-i18next | 25.10.9 / 16.6.6 | 다국어 — 한국어 포함 19개 로케일 |
| SWR / axios | 2.2.4 / 1.18.1 | 데이터 패칭 |
| TypeScript | 5.8.3 | 타입 |
| Turborepo / pnpm | 2.9.18 / 11.3.0 | 모노레포 빌드 · 패키지 매니저 |
| oxlint / oxfmt | 1.51.0 / 0.35.0 | Rust 린터·포매터 (ESLint·Prettier 대체) |
"react": "^18.2.0"을 적으면 미묘하게 버전이 갈립니다. catalog는 pnpm-workspace.yaml 한 곳에 버전을 모아 두고 각 패키지는 "react": "catalog:"라고만 씁니다. 모노레포에서 버전 표류를 막는 최신 방법이고, Plane은 프런트 의존성 전부를 이 방식으로 고정했습니다.apps/live/)독립 Node 서비스로, Hocuspocus 2.15.2(Yjs용 협업 백엔드)를 Express 위에 올렸습니다. 특이하게 함수형 이펙트 라이브러리 effect + @effect/platform-node를 쓰고, PDF 내보내기까지 이 서버가 맡습니다(@react-pdf/renderer).
| 서비스 | 이미지 / 베이스 | 메모 |
|---|---|---|
| proxy | caddy 2.11.3 (xcaddy 빌드) | 바깥으로 열리는 유일한 포트 (80/443) |
| web / admin / space | node:22-alpine → nginx:1.31-alpine | 빌드 후 정적 파일만 nginx로 서빙 |
| api / worker / beat / migrator | python:3.12.10-alpine | 같은 이미지, 엔트리포인트만 다름 |
| live | Node | Hocuspocus 웹소켓 |
| plane-db | postgres:15.7-alpine | max_connections=1000 |
| plane-redis | valkey/valkey:7.2.11-alpine | Redis 포크 — 이미지가 redis가 아님 |
| plane-mq | rabbitmq:3.13.6-management-alpine | Celery 브로커 |
| plane-minio | minio/minio | S3 호환 파일 저장 |
재밌는 지점은 프록시가 두 겹이라는 것입니다. 바깥은 Caddy(자동 HTTPS·라우팅), 안쪽은 각 프론트 앱이 자기 nginx로 정적 파일을 서빙합니다. Caddy는 xcaddy로 caddy-dns/cloudflare·caddy-dns/digitalocean·caddy-l4 플러그인을 넣어 직접 빌드합니다 — DNS-01 인증서 발급을 위해서입니다.
apps/api/plane/urls.py는 용도에 따라 완전히 다른 Django 앱을 각각의 경로에 붙입니다.
urlpatterns = [
path("api/", include("plane.app.urls")), # 내부용 (세션 인증) · 233개
path("api/public/", include("plane.space.urls")), # 공개 뷰 · 25개
path("api/instances/", include("plane.license.urls")), # 인스턴스 관리
path("api/v1/", include("plane.api.urls")), # 외부 공개 API (API 키) · 75개
path("auth/", include("plane.authentication.urls")), # 37개
]
여기서 배울 점은 "내부 API"와 "외부 공개 API"를 같은 뷰로 겸용하지 않았다는 것입니다. 내부용(plane.app)은 화면이 바뀌면 마음대로 깨도 되지만, 외부용(plane.api)은 남의 스크립트가 물려 있어 못 깹니다. 실제로 외부 API 파일에는 old_url_patterns라는 목록에 "Deprecated" 주석을 달고 옛 경로를 지우지 않은 채 남겨 둡니다. 총 라우트는 약 370개입니다.
| 앱 | 경로 | 누가 보는가 |
|---|---|---|
| web | / | 로그인한 팀원 — 기능 대부분 |
| space | /spaces/ | 로그인 안 한 외부인 — 공개된 보드/문서만 |
| admin | /god-mode/ | 서버 관리자 — 인스턴스 설정, SMTP, 인증 방식 |
한 앱에 조건문으로 섞을 수도 있었지만, 나누면 공개 뷰 번들에 내부 기능 코드가 애초에 들어가지 않습니다. 보안이 "권한 체크"가 아니라 "빌드 산출물 분리"로 보장되는 구조고, 셋 다 각자 Docker 이미지·nginx를 가집니다.
모델 93개가 전부 같은 뼈대를 물려받습니다. TimeAuditModel(생성·수정 시각) → UserAuditModel(생성·수정자) → SoftDeleteModel(삭제 표시) → 이 셋을 합친 AuditModel → UUID 기본키를 얹은 BaseModel 순입니다.
class SoftDeletionManager(models.Manager):
def get_queryset(self):
return SoftDeletionQuerySet(self.model, using=self._db).filter(deleted_at__isnull=True)
class SoftDeleteModel(models.Model):
deleted_at = models.DateTimeField(null=True, blank=True)
objects = SoftDeletionManager() # 기본 매니저: 삭제된 건 안 보임
all_objects = models.Manager() # 탈출구: 삭제된 것까지 전부
def delete(self, using=None, soft=True, *args, **kwargs):
if soft:
self.deleted_at = timezone.now()
self.save(using=using)
soft_delete_related_objects.delay(...) # 연관 객체는 Celery가 뒤따라 처리
else:
return super().delete(using=using, *args, **kwargs)
핵심은 기본 매니저가 삭제된 행을 자동으로 걸러준다는 점입니다. 개발자가 filter(deleted_at__isnull=True)를 매번 안 써도 되고, 빠뜨려서 유령 데이터가 노출되는 사고도 안 납니다. 대신 실제 삭제 조회가 필요할 때만 all_objects라는 명시적 탈출구를 씁니다. 그리고 연쇄 삭제는 요청 안에서 처리하지 않고 Celery에 넘겨서, 이슈 1개 삭제가 트랜잭션을 몇 초씩 물고 있는 사태를 막습니다.
소프트 삭제는 휴지통입니다. 파일을 지워도 디스크에서 즉시 사라지지 않고 "지움" 표시만 붙습니다. 탐색기는 휴지통 안 파일을 안 보여주지만(=기본 매니저), 휴지통 폴더를 직접 열면 보입니다(=all_objects). 실제 디스크 정리는 밤에 따로 돕니다(=매일 UTC 00:00 하드 삭제 작업).
칸반에서 카드를 3번과 4번 사이로 끌어놓으면, 순진한 구현은 뒤의 모든 행 번호를 1씩 밀어 올립니다(1000개면 1000번 UPDATE). Plane은 sort_order를 정수가 아닌 FloatField(default=65535)로 두고, 새 항목은 같은 그룹 최대값 + 10000을 받습니다. 사이에 끼울 땐 앞뒤 값의 중간값을 계산해 단 한 행만 UPDATE합니다. State.sequence도 같은 방식(15000 간격)입니다.
문서 동시 편집은 CRDT(Yjs)로 합니다. 보통은 별도 문서 저장소를 두는데, Plane은 Issue/Page 행에 컬럼 3개를 나란히 둡니다.
| 컬럼 | 내용 | 누가 쓰는가 |
|---|---|---|
description_binary | Yjs CRDT 상태 (BinaryField) | 동시 편집 복원의 진짜 원본 |
description_html | 렌더링용 HTML | 목록·검색·메일 알림 |
description_json | TipTap 문서 트리 | 프로그램적 가공 |
apps/live/src/extensions/database.ts가 이 셋을 관리합니다 — 문서를 열 때 바이너리가 비어 있으면 HTML에서 역변환해 채워 넣고, 저장할 때는 세 표현을 한 번에 API로 되돌려 씁니다. Redis는 여러 live 인스턴스 사이의 전달만 담당하고, 영속 저장은 Postgres입니다. 즉 협업 서버를 몇 대로 늘려도 데이터는 한 곳에 모입니다.
class ROLE(Enum):
ADMIN = 20
MEMBER = 15
GUEST = 5
def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
... # WorkspaceMember / ProjectMember 의 role 값을 대조
워크스페이스든 프로젝트든 같은 3단계를 씁니다. 숫자로 둔 덕에 "MEMBER 이상"을 role >= 15로 표현할 수 있고, 나중에 등급을 끼워 넣어도(예: 10) 기존 비교가 안 깨집니다. 프론트엔드도 packages/types/src/enums.ts에 EUserPermissions로 똑같은 숫자를 복제해 둡니다 — 화면 표시와 서버 판정이 같은 사전을 보게 하려는 것입니다.
Jira의 ENG-142 같은 번호는 프로젝트마다 1씩 올라갑니다. 동시에 두 명이 이슈를 만들면 번호가 겹칠 수 있는데, Plane은 Postgres의 pg_advisory_xact_lock(트랜잭션 단위 자문 잠금)으로 sequence_id 발급 구간을 직렬화합니다. 애플리케이션 락이나 재시도 루프가 아니라 DB가 제공하는 원자적 수단을 쓴 사례입니다.
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
json.dumps(payload).encode("utf-8"),
hashlib.sha256,
)
headers["X-Plane-Signature"] = hmac_signature.hexdigest()
받는 쪽이 "이 요청이 정말 내 Plane에서 왔는지" 검증할 수 있게 HMAC 서명을 붙입니다. 동시에 settings/common.py에는 WEBHOOK_ALLOWED_IPS · WEBHOOK_ALLOWED_HOSTS · WEBHOOK_DISALLOWED_DOMAINS가 있습니다 — 사용자가 웹훅 주소를 http://169.254.169.254/(클라우드 메타데이터) 같은 내부 주소로 넣어 서버를 대신 심부름 보내는 SSRF 공격을 막기 위한 것입니다. 여기에 취약한 SECRET_KEY 하드코딩 목록(GHSA-cmwv-pjmw-8483)을 감지해 경고를 띄우는 코드까지 있습니다.
| 목적 | 먼저 열 파일 |
|---|---|
| 데이터 모델을 이해 | apps/api/plane/db/models/issue.py (30KB, 가장 큰 모델 파일) |
| 공통 뼈대 패턴 | apps/api/plane/db/mixins.py · db/models/base.py |
| API 전체 지도 | apps/api/plane/urls.py → 각 앱의 urls/ |
| 권한 | apps/api/plane/app/permissions/base.py |
| 주기 작업 | apps/api/plane/celery.py (beat 스케줄 전체) |
| 프론트 상태 흐름 | apps/web/core/store/root.store.ts → store/issue/ |
| 실시간 편집 | apps/live/src/hocuspocus.ts · extensions/database.ts |
| 배포 | docker-compose.yml · apps/proxy/Caddyfile.ce |
store/issue/ 하나만 봐도 규모가 보입니다 — archived/, cycle/, module/, profile/, project/, project-views/, workspace/, workspace-draft/, issue-details/, helpers/에 더해 보기 방식별 스토어(캘린더·간트·칸반) 3개가 따로 있습니다. "이슈 목록"이 어디서 오느냐가 맥락마다 다르기 때문에 스토어를 그 맥락 수만큼 나눈 구조입니다.
모델 93개에 created_at·created_by·deleted_at을 손으로 붙였다면 곧 표류가 시작됩니다. Plane은 5단 상속으로 이걸 봉인하고, BaseModel.save()에서 crum.get_current_user()로 현재 요청 사용자를 자동으로 채웁니다. 서비스 코드는 "누가 만들었는지"를 신경 쓸 필요가 없습니다.
실습 아이디어: 내 Django 프로젝트에 AuditModel 한 겹을 만들어 기존 모델 2~3개를 갈아 끼우고, all_objects 탈출구를 붙여 보세요.
celery.py의 스케줄을 읽으면 "이 제품이 시간이 지나면 무엇이 쌓이는지"가 그대로 보입니다.
| 5분마다 | 이메일 알림 묶어 보내기 |
| 6시간마다 | 인스턴스 원격 측정 전송 |
| UTC 00:00 | 소프트 삭제된 데이터 하드 삭제 |
| UTC 01:00 | 오래된 이슈 자동 보관·마감 |
| UTC 01:30 / 03:45 | 만료된 내보내기 S3 링크 정리 |
| UTC 02:00 / 02:30 / 02:45 / 03:30 | 미업로드 파일 · API 로그 · 메일 로그 · 웹훅 로그 정리 |
| UTC 03:00 / 03:15 | 페이지·이슈 설명 버전 정리 |
배울 점: 로그·버전·임시파일은 "쌓아 두고 나중에 지운다"가 아니라 처음부터 지우는 작업을 같이 만들어 둔다는 원칙입니다. 개인 프로젝트가 몇 달 뒤 디스크가 터지는 건 대개 이 시간표가 없어서입니다.
export class IssueStore implements IIssueStore {
issuesMap: { [issue_id: string]: TIssue } = {};
issueService;
constructor() {
makeObservable(this, {
issuesMap: observable,
addIssue: action, updateIssue: action, removeIssue: action,
});
this.issueService = new IssueService();
}
}
목록을 배열이 아니라 id를 키로 한 맵으로 들고 있는 게 핵심입니다. 이슈 하나가 바뀌면 그 키만 갱신되고, 배열 전체를 다시 만들지 않습니다. 파생 값은 mobx-utils의 computedFn으로 "인자를 받는 계산값"을 만들어 캐싱합니다.
실습 아이디어: Zustand로 짠 목록 스토어를 맵 기반으로 바꿔 보고, 항목 1000개에서 1개 수정 시 리렌더 횟수를 React DevTools Profiler로 비교해 보세요.
apps/web/core/services/에 issue.service.ts, cycle.service.ts, webhook.service.ts… 29개가 있고, 각각이 백엔드 앱과 짝을 이룹니다. 컴포넌트는 axios를 직접 부르지 않습니다. 덕분에 인증 헤더·에러 처리·베이스 URL을 공통 APIService 한 곳에서만 바꾸면 됩니다.
Google Docs 같은 동시 편집을 직접 짜려면 OT(운영 변환)나 CRDT가 필요합니다. Plane은 Yjs(CRDT)를 쓰고, 서버 쪽은 Hocuspocus에 저장 어댑터만 갈아 끼우는 방식으로 붙였습니다. Hocuspocus의 Database 확장이 fetch/store 콜백을 열어 주므로, Plane은 그 자리에서 Django API를 호출합니다 — 협업 서버가 DB에 직접 붙지 않아 권한 로직이 한 곳(Django)에 남습니다.
debounce: 10000 설정도 눈여겨볼 만합니다. 타이핑할 때마다 저장하지 않고 10초 묶음으로 씁니다.
turbo prune 3단계모노레포를 그냥 COPY . . 하면 web 이미지를 만드는데 admin·space 소스까지 들어가 캐시가 매번 깨집니다. Plane의 Dockerfile.web은 ① turbo prune --scope=web --docker로 web에 필요한 부분만 잘라내고 ② 그 결과로 의존성 설치·빌드 ③ 산출물만 nginx:1.31-alpine에 복사합니다.
실습 아이디어: 내 Turborepo에 이 3단계를 적용하고, 관계없는 패키지를 고쳤을 때 빌드 캐시가 유지되는지 확인해 보세요.
대부분의 보안 교육은 들어오는 입력만 다룹니다. Plane의 웹훅 설정은 반대 방향 — 사용자가 지정한 주소로 서버가 요청을 보내는 기능이라, 허용 IP·허용 호스트·금지 도메인 목록을 함께 둡니다. HTML 새니타이징도 nh3(Rust)로 하고, 의존성 핀에는 특정 CVE 대응이 주석으로 남아 있습니다.
| 항목 | CONTRIBUTING.md (개발 문서) | 실제 이미지·락파일 (권위 있음) |
|---|---|---|
| Node | 20+ LTS | 22.18.0 (.mise.toml · engines) |
| Python | 3.8+ | 3.12.10 (Dockerfile.api) |
| Postgres | 14 | 15.7 |
| Redis | 6.2.7 | Valkey 7.2.11 |
| pnpm | — | 11.3.0 (SHA512 고정) |
| RabbitMQ | 언급 없음 | 3.13.6 |
CONTRIBUTING.md는 "최소 12GB RAM 권장, 8GB면 설치 실패나 메모리 크래시가 날 수 있다"고 씁니다. 반면 deployments/cli/community/README.md는 EC2 2 vCPU · 4GB RAM이면 된다고 합니다. 모순이 아니라 서로 다른 상황을 말하는 것입니다 — 12GB는 소스에서 프론트 3개를 동시에 빌드하는 개발 환경, 4GB는 이미 빌드된 이미지를 받아 돌리는 운영 환경입니다. 문서 두 개를 같은 문장으로 읽으면 "요구사항이 이상하다"는 결론이 나옵니다.
현실적인 권장값을 정리하면:
node_modules 별도)docker compose up -d 후 docker compose ps로 서비스 목록을 확인하고, docker compose logs -f api worker beat live로 로그를 동시에 봅니다. 이슈를 하나 만들고 담당자를 지정했을 때 어느 컨테이너가 반응하는지 관찰하세요.
확인 포인트: 이슈 생성은 api가 처리하는데, 알림·활동기록은 왜 worker 로그에서 나오는가?
워크스페이스 설정에서 API 토큰을 발급받고, /api/v1/ 경로로 프로젝트 목록 조회 → 이슈 생성 → 상태 변경까지 파이썬 스크립트로 자동화합니다. 그 다음 같은 일을 내부 API(/api/)로 시도해 보고 왜 막히는지 확인하세요.
확인 포인트: 인증 방식이 어떻게 다른가(API 키 vs 세션). 왜 두 API를 나눴는지 몸으로 이해하게 됩니다.
작은 Flask/Express 서버를 띄우고 Plane 웹훅 대상으로 등록합니다. 받은 요청의 X-Plane-Signature를 직접 HMAC-SHA256으로 재계산해 대조하고, 일부러 페이로드를 한 글자 바꾸면 검증이 깨지는지 확인하세요.
확장: 웹훅 주소로 http://localhost:8000/을 넣어 보고, SSRF 방어가 어떻게 반응하는지 관찰합니다.
Issue에 risk_level 같은 필드를 하나 추가해서 DB → 시리얼라이저 → API → 서비스 → MobX 스토어 → 컴포넌트까지 전 구간을 통과시킵니다. 마이그레이션도 직접 만들어 보세요.
배우는 것: 이 저장소의 "한 기능이 지나가는 경로"를 몸으로 익히는 가장 빠른 방법입니다. 대형 코드베이스에 새로 합류했을 때 쓰는 표준 전술이기도 합니다.
두 브라우저로 같은 Page를 열고 동시에 타이핑하면서, 브라우저 개발자도구 Network 탭의 WS 프레임을 관찰합니다. 그다음 apps/live를 참고해 최소 Hocuspocus 서버(TipTap + Yjs + 저장 어댑터)를 별도 프로젝트로 재현하세요.
난관 예고: 저장 어댑터에서 "빈 바이너리를 HTML에서 복원"하는 부분과, 여러 서버 인스턴스를 띄웠을 때 Redis 확장이 없으면 편집이 갈라지는 현상을 반드시 겪게 됩니다.
| 주차 | 주제 | 구체적으로 할 것 |
|---|---|---|
| 1주차 | Django ORM과 모델 설계 | 추상 모델 상속·매니저·QuerySet 커스터마이징을 공식 문서로 학습 → db/mixins.py와 db/models/issue.py 정독 → 내 프로젝트에 AuditModel 이식 |
| 2주차 | 비동기 작업과 큐 | Celery 공식 튜토리얼 → RabbitMQ와 Redis 브로커의 차이 이해 → bgtasks/ 34개 파일 중 웹훅·알림·정리 작업 3개 골라 흐름 추적 → beat 스케줄 하나 추가해 보기 |
| 3주차 | React 상태 관리와 모노레포 | MobX 6 공식 문서(makeObservable·computedFn) → store/root.store.ts에서 스토어 조립 방식 파악 → Turborepo + pnpm catalog로 작은 모노레포 직접 구성 |
| 4주차 | 실시간 협업과 배포 | CRDT 개념(Yjs 문서) → Hocuspocus 확장 구조 분석 → Caddyfile 읽고 리버스 프록시 라우팅 이해 → turbo prune 3단계 Docker 빌드 재현 |
| 용어 | 뜻 |
|---|---|
| Work Item | Plane의 "할 일" 단위. 예전 이름은 Issue였고 코드에는 아직 Issue로 남아 있습니다(모델·스토어 전부). 문서와 코드의 이름이 다르니 주의. |
| Cycle | 기간으로 자른 작업 묶음 = 스프린트. 시작·종료일과 진행률 스냅샷을 가집니다. |
| Module | 목적으로 자른 작업 묶음 = 기능 단위(에픽에 가까움). Cycle과 직교합니다. |
| Intake | 외부/비팀원 요청을 일단 받아 두는 접수함. 예전 이름은 Inbox이며 코드에는 IntakeIssue로 있습니다. |
| State / StateGroup | 작업 상태. 그룹은 6개 고정: BACKLOG · UNSTARTED · STARTED · COMPLETED · CANCELLED · TRIAGE. 그 안의 이름은 프로젝트마다 자유. |
| Deploy Board / Space | 로그인 없이 볼 수 있게 공개한 보드·문서. /spaces/ 앱이 담당합니다. |
| God Mode | 인스턴스 전체 관리 화면(/god-mode/). 서버 관리자용 — SMTP, 로그인 방식, 파일 크기 제한 등. |
| Instance | 셀프호스팅 설치 1벌을 가리키는 단일 행 모델. 버전·에디션·설정을 담습니다. 이 저장소의 에디션 값은 PLANE_COMMUNITY 하나뿐입니다. |
| CE / EE | Community Edition(공개) / Enterprise Edition(비공개). 이 저장소에서 둘로 갈라진 폴더는 packages/editor/src/뿐입니다. |
| 소프트 삭제 | 행을 실제로 지우지 않고 deleted_at에 시각을 찍는 방식. 복구 가능하고 참조 무결성이 안 깨집니다. |
| 분수 정렬 (sort_order) | 순서를 실수로 저장해, 사이에 끼울 때 앞뒤 중간값만 계산하는 기법. 재정렬 UPDATE가 1건으로 끝납니다. |
| CRDT | Conflict-free Replicated Data Type. 여러 사람이 동시에 고쳐도 서버 중재 없이 같은 결과로 수렴하는 자료구조. Yjs가 구현체. |
| Hocuspocus | Yjs 문서를 여러 클라이언트에 중계·저장해 주는 서버. TipTap 팀이 만듦. |
| Valkey | Redis가 라이선스를 바꾼 뒤 갈라져 나온 오픈소스 포크. 프로토콜은 Redis와 호환. |
| SSRF | Server-Side Request Forgery. 서버가 공격자가 지정한 내부 주소로 대신 요청을 보내게 만드는 공격. |
| pg_advisory_xact_lock | Postgres가 제공하는 트랜잭션 범위 잠금. 테이블 행이 아니라 임의의 숫자 키에 락을 걸 수 있어 번호 발급 같은 구간 직렬화에 씁니다. |
| turbo prune | 모노레포에서 특정 패키지 빌드에 필요한 파일만 잘라내는 Turborepo 명령. Docker 캐시 효율을 위해 씁니다. |
| xcaddy | 플러그인을 넣어 Caddy를 직접 컴파일하는 빌드 도구. Plane은 DNS 인증서 발급 플러그인 때문에 씁니다. |
master가 아니라 preview)