5분 AI.
TRENDSHIFT #8 딥다이브 · 2026-08-22 분석

Plane 딥다이브
— 내 서버에 짓는 Jira, 그리고 프론트엔드 대이동의 실물 표본

Jira · Linear · Monday · ClickUp을 대신하는 오픈소스 프로젝트 관리 플랫폼. 그런데 진짜 볼거리는 제품이 아니라 Next.js를 걷어내고 React Router 7 + Vite로 갈아엎은 프론트엔드와, Django 5 + Celery + Yjs가 맞물린 백엔드입니다. (저장소: makeplane/plane · ⭐57.0k · TypeScript · AGPL-3.0 · v1.4.2 (2026-08-22 기준))
목차
  1. 프로젝트 한줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트 (기술별)
  7. 하드웨어 / 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한줄 요약

Plane이 정확히 무엇을 하는 물건인가

PlaneJira · 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개를 직접 띄우고 관리해야 하는 물건이라, "무료"의 실제 단가는 운영 시간입니다.

용어
AGPL-3.0
GPL의 강화판. 소스를 고쳐서 웹 서비스로 제공만 해도 그 소스를 공개해야 합니다. "우리 회사 내부용으로 쓴다"는 문제없지만, "Plane을 고쳐서 SaaS로 판다"면 고친 코드를 공개해야 합니다. 저장소의 모든 소스 파일 머리에 SPDX-License-Identifier: AGPL-3.0-only가 박혀 있습니다.

2왜 주목받는가

트렌딩 이유와 경쟁 제품 대비 강점 — 그리고 개발자에게 더 중요한 이유
기존 방식의 문제
좌석 과금 + 데이터 종속 + 무거움

Jira·Linear·Monday·ClickUp은 전부 사용자당 월정액입니다. 팀 30명이면 매달 고정비가 발생하고, 협력사·인턴·외부 리뷰어를 초대할 때마다 좌석을 더 삽니다. 이슈 이력·첨부파일·문서는 벤더 클라우드에 고정되고, 규제 산업(금융·의료·공공)은 애초에 외부 SaaS 반입이 막혀 있는 경우가 많습니다.

Plane의 해법
셀프호스팅 + 좌석 무제한 + 표준 스택

AGPL 커뮤니티 에디션은 좌석 제한이 없습니다. 스택도 특수한 게 없어서 — Django, Postgres, React — 사내 인프라 팀이 이미 아는 물건들로만 구성됩니다. 폐쇄망(air-gapped) 설치 스크립트(restore-airgapped.sh)까지 저장소에 들어 있어, 인터넷이 끊긴 내부망 배포를 애초에 상정하고 만들었습니다.

비교표 — 무엇이 다른가

항목Jira / LinearPlane (CE)
과금사용자당 월정액없음 (서버 비용만)
데이터 위치벤더 클라우드내 Postgres
커스터마이징플러그인 SDK / 앱 마켓소스 포크 (단, AGPL 의무)
공개 뷰유료 플랜 기능/spaces/ 전용 앱 내장
실시간 문서제품에 따라 별매Hocuspocus + Yjs 내장
외부 API있음/api/v1/ + API 키 내장
운영 부담없음컨테이너 11개 직접 운영

개발자 입장에서 진짜 볼거리 — 2026년 프론트엔드 재편의 실물 표본

제품 소개만 보면 "또 하나의 Jira 클론"입니다. 그런데 코드를 열면 이야기가 달라집니다. Plane은 최근 프론트엔드를 통째로 갈아엎었고, 그 결과가 2026년 프런트엔드 판이 어디로 움직이는지를 한 저장소 안에서 다 보여줍니다.

영역흔한 선택 (2023년경)Plane의 현재 (v1.4.2)
프레임워크Next.js (App Router)React Router 7.18 + Vite 8 — Next.js 없음
린터·포매터ESLint + Prettieroxlint 1.51 + oxfmt 0.35 (Rust)
CSSTailwind 3Tailwind 4.1 (@tailwindcss/postcss)
버전 관리패키지마다 각자 명시pnpm catalog 중앙 고정
패키지 빌드tsup / rolluptsdown
상태 관리Zustand / Redux ToolkitMobX 6 클래스 스토어 (유지)

"React 앱 = Next.js"라는 기본값을 깨고 SPA로 되돌아간, 규모 있는 실서비스 사례입니다. 2,040개 .tsx 파일 · 19만 줄짜리 프론트엔드가 실제로 그 선택으로 굴러가고 있다는 게 이 저장소의 학습 가치입니다.

읽기 전 주의
인터넷의 Plane 자료 절반은 이미 틀렸다

블로그·튜토리얼 상당수가 아직 "Next.js 기반, apiserver/ 폴더"라고 씁니다. 현재 코드에는 Next.js가 없고, 백엔드도 최상위 apiserver/가 아니라 apps/api/에 있습니다. packages/editor/package.json의 keywords에 "nextjs"가 남아 있는 것도 지우지 않은 흔적일 뿐 실제 의존이 아닙니다. 자료를 읽을 땐 항상 코드로 대조하세요.

3기술 스택 전체 지도

백엔드 · 프론트엔드 · 실시간 · 인프라를 버전까지 — 전부 소스에서 확인한 값

백엔드 — Django 5.2 + DRF (apps/api/)

구성요소버전역할
Django5.2.15웹 프레임워크·ORM. 소스 651개 · 10.4만 줄(마이그레이션 제외 8.1만)
djangorestframework3.17.1REST API 계층
psycopg3.3.4Postgres 드라이버 (psycopg2 아님 — 3세대)
celery5.5.3백그라운드 작업 큐
django_celery_beat2.9.0주기 작업 스케줄러 (DB 기반)
django-redis / redis7.0.0 / 5.0.4캐시 — 큐가 아님 (아래 주의 참고)
channels4.3.2ASGI·웹소켓 지원
boto3 / django-storages1.34.96 / 1.14.6S3·MinIO 파일 저장
drf-spectacular0.29.0OpenAPI 스키마 자동 생성
zxcvbn / cryptography / PyJWT4.4.28 / 50.0.0 / 2.13.0비밀번호 강도 · 암호화 · 토큰
nh30.2.18HTML 새니타이저 (Rust ammonia 바인딩, XSS 방어)
opentelemetry-*1.28.1분산 추적 (OTLP 익스포터 포함)
gunicorn / uvicorn23.0.0 / 0.29.0운영 WSGI / 개발 ASGI 서버
흔한 오해
Celery 브로커는 Redis가 아니라 RabbitMQ다

Django + Celery 조합에서는 Redis를 브로커로 쓰는 게 흔해서 그렇게 읽기 쉽지만, Plane의 CELERY_BROKER_URLRABBITMQ_* 환경변수(또는 AMQP_URL)로 조립됩니다. Redis(정확히는 Valkey)는 Django 캐시와 Hocuspocus의 인스턴스 간 pub/sub에만 씁니다. 이 둘을 헷갈리면 "큐가 안 도는데 Redis는 멀쩡하다"에서 한참 헤맵니다.

프론트엔드 — React Router 7 + Vite (apps/web · space · admin)

구성요소버전역할
React18.3.1UI 라이브러리 (아직 19 아님)
react-router7.18.1라우팅 — Next.js 자리를 대체
Vite8.0.16번들러·개발 서버
MobX6.12.0상태 관리 (클래스 스토어 + computedFn)
Tailwind CSS4.1.17스타일 (+ tailwind-merge 3.4, cva 0.7.1)
TipTap2.22.3리치 텍스트 에디터 (ProseMirror 위)
Yjs / y-prosemirror13.6.20 / 1.3.7CRDT 동시 편집 (+ y-indexeddb 오프라인)
@atlaskit/pragmatic-drag-and-drop1.7.4칸반 드래그앤드롭 — Atlassian이 만든 라이브러리
react-hook-form7.51.5
recharts^2.15.1차트 (+ @plane/propel 자체 래퍼)
i18next / react-i18next25.10.9 / 16.6.6다국어 — 한국어 포함 19개 로케일
SWR / axios2.2.4 / 1.18.1데이터 패칭
TypeScript5.8.3타입
Turborepo / pnpm2.9.18 / 11.3.0모노레포 빌드 · 패키지 매니저
oxlint / oxfmt1.51.0 / 0.35.0Rust 린터·포매터 (ESLint·Prettier 대체)
용어
pnpm catalog
패키지 20여 개가 각자 "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).

인프라 — 컨테이너 11개

서비스이미지 / 베이스메모
proxycaddy 2.11.3 (xcaddy 빌드)바깥으로 열리는 유일한 포트 (80/443)
web / admin / spacenode:22-alpine → nginx:1.31-alpine빌드 후 정적 파일만 nginx로 서빙
api / worker / beat / migratorpython:3.12.10-alpine같은 이미지, 엔트리포인트만 다름
liveNodeHocuspocus 웹소켓
plane-dbpostgres:15.7-alpinemax_connections=1000
plane-redisvalkey/valkey:7.2.11-alpineRedis 포크 — 이미지가 redis가 아님
plane-mqrabbitmq:3.13.6-management-alpineCelery 브로커
plane-miniominio/minioS3 호환 파일 저장

재밌는 지점은 프록시가 두 겹이라는 것입니다. 바깥은 Caddy(자동 HTTPS·라우팅), 안쪽은 각 프론트 앱이 자기 nginx로 정적 파일을 서빙합니다. Caddy는 xcaddycaddy-dns/cloudflare·caddy-dns/digitalocean·caddy-l4 플러그인을 넣어 직접 빌드합니다 — DNS-01 인증서 발급을 위해서입니다.

4아키텍처 심화 분석

시스템 구조도 + 실제로 배울 만한 설계 패턴 7가지

전체 시스템 구조도

┌──────────────┐ │ 브라우저 │ └──────┬───────┘ │ 443 (자동 HTTPS) ┌──────────▼───────────┐ │ proxy (Caddy 2.11) │ ← 바깥으로 열린 유일한 문 └──┬───┬───┬───┬───┬───┘ / ────────────┘ │ │ │ └──────── /{BUCKET}/* /spaces/─┘ │ └─ /god-mode/ └─ /live/ , /api/ , /auth/ , /static/ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ web:3000 │ │ space:3000 │ │ admin:3000 │ │ live:3000 │ │ React SPA │ │ 공개 뷰 │ │ 인스턴스 │ │ Hocuspocus │ │ (nginx) │ │ (nginx) │ │ 관리(nginx)│ │ Yjs 웹소켓 │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ axios/SWR │ │ │ HTTP └───────────────┴───────┬───────┴───────────────┘ ▼ ┌────────────────────────┐ │ api:8000 (Django) │ │ /api/ /api/public/ │ │ /api/v1/ /api/instances/ /auth/ └───┬────────┬────────┬──┘ │ │ │ ┌───────────────┘ │ └──────────────┐ ▼ ▼ ▼ ┌─────────────┐ ┌────────────────┐ ┌────────────────┐ │ plane-db │ │ plane-mq │ │ plane-minio │ │ Postgres 15 │ │ RabbitMQ 3.13 │ │ S3 호환 저장 │ └─────────────┘ └───────┬────────┘ └────────────────┘ ▲ │ 작업 투입 │ ┌──────┴──────┬──────────────┐ │ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────┐ │ └────────┤ worker │ │ beat │ │ │ Celery 34개 │ │ 주기 스케줄 │ │ │ 작업 모듈 │ │ (DB 기반) │ │ └──────────────┘ └──────────────┘ │ │ ┌────────────────┐ 캐시 + live 인스턴스 간 pub/sub │ │ plane-redis │◄─────────────────────────────────┘ │ Valkey 7.2 │ └────────────────┘

패턴 1 — API를 네임스페이스 4개로 물리 분리

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개입니다.

패턴 2 — 프론트엔드를 앱 3개로 쪼갠 이유

경로누가 보는가
web/로그인한 팀원 — 기능 대부분
space/spaces/로그인 안 한 외부인 — 공개된 보드/문서만
admin/god-mode/서버 관리자 — 인스턴스 설정, SMTP, 인증 방식

한 앱에 조건문으로 섞을 수도 있었지만, 나누면 공개 뷰 번들에 내부 기능 코드가 애초에 들어가지 않습니다. 보안이 "권한 체크"가 아니라 "빌드 산출물 분리"로 보장되는 구조고, 셋 다 각자 Docker 이미지·nginx를 가집니다.

패턴 3 — 추상 모델 상속 사슬과 소프트 삭제

모델 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 하드 삭제 작업).

패턴 4 — 분수 정렬(fractional ordering)

칸반에서 카드를 3번과 4번 사이로 끌어놓으면, 순진한 구현은 뒤의 모든 행 번호를 1씩 밀어 올립니다(1000개면 1000번 UPDATE). Plane은 sort_order를 정수가 아닌 FloatField(default=65535)로 두고, 새 항목은 같은 그룹 최대값 + 10000을 받습니다. 사이에 끼울 땐 앞뒤 값의 중간값을 계산해 단 한 행만 UPDATE합니다. State.sequence도 같은 방식(15000 간격)입니다.

패턴 5 — Yjs 바이너리를 행에 그대로 저장

문서 동시 편집은 CRDT(Yjs)로 합니다. 보통은 별도 문서 저장소를 두는데, Plane은 Issue/Page 행에 컬럼 3개를 나란히 둡니다.

컬럼내용누가 쓰는가
description_binaryYjs CRDT 상태 (BinaryField)동시 편집 복원의 진짜 원본
description_html렌더링용 HTML목록·검색·메일 알림
description_jsonTipTap 문서 트리프로그램적 가공

apps/live/src/extensions/database.ts가 이 셋을 관리합니다 — 문서를 열 때 바이너리가 비어 있으면 HTML에서 역변환해 채워 넣고, 저장할 때는 세 표현을 한 번에 API로 되돌려 씁니다. Redis는 여러 live 인스턴스 사이의 전달만 담당하고, 영속 저장은 Postgres입니다. 즉 협업 서버를 몇 대로 늘려도 데이터는 한 곳에 모입니다.

패턴 6 — 권한은 숫자 3개가 전부

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.tsEUserPermissions똑같은 숫자를 복제해 둡니다 — 화면 표시와 서버 판정이 같은 사전을 보게 하려는 것입니다.

패턴 7 — 프로젝트별 일련번호를 DB 락으로

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)을 감지해 경고를 띄우는 코드까지 있습니다.

5디렉토리 구조 해부

5,257개 파일을 어디부터 열어야 하는가
plane/ ├── apps/ ← 실행되는 것들 │ ├── api/ Django 백엔드 │ │ └── plane/ │ │ ├── db/models/ 93개 모델 (26개 파일) ★ 여기부터 │ │ ├── app/ 내부 API (라우트 233) │ │ ├── api/ 외부 API (라우트 75) │ │ ├── space/ 공개 뷰 API(라우트 25) │ │ ├── authentication/ 로그인·SSO (라우트 37) │ │ ├── license/ 인스턴스 설정 (Instance 모델) │ │ ├── bgtasks/ Celery 작업 34개 파일 │ │ ├── middleware/ 본문 크기 제한·읽기 복제본 라우팅·로깅 │ │ └── settings/ common / production / local / storage │ ├── web/ 메인 SPA (React Router + Vite) │ │ └── core/ │ │ ├── store/ MobX 스토어 ★ 프론트는 여기부터 │ │ ├── services/ API 호출 29개 (백엔드와 1:1) │ │ ├── components/ 화면 조각 │ │ ├── hooks/ layouts/ lib/ │ ├── space/ 공개 뷰 SPA │ ├── admin/ 인스턴스 관리 SPA │ ├── live/ Hocuspocus 실시간 서버 │ │ └── src/extensions/ database · redis · title-sync · force-close │ └── proxy/ Caddy (Dockerfile.ce · Caddyfile.ce) ├── packages/ ← 공유 라이브러리 15개 (@plane/*) │ ├── ui/ 컴포넌트 125개 / 29개 폴더 │ ├── propel/ 신형 디자인 시스템 (차트 등) │ ├── editor/ TipTap 에디터 (core/ ce/ ee/ 로 분기) │ ├── types/ constants/ utils/ hooks/ services/ │ ├── i18n/locales/ 19개 언어 (ko 포함) │ ├── shared-state/ logger/ decorators/ codemods/ │ └── tailwind-config/ typescript-config/ ├── deployments/ aio · cli · kubernetes · swarm ├── docs/ .github/workflows/ 문서 · CI 9종 ├── docker-compose.yml 서비스 11개 ├── pnpm-workspace.yaml catalog(버전 중앙 고정) + 워크스페이스 정의 └── turbo.json setup.sh LICENSE.txt(AGPL-3.0)
목적먼저 열 파일
데이터 모델을 이해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.tsstore/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개가 따로 있습니다. "이슈 목록"이 어디서 오느냐가 맥락마다 다르기 때문에 스토어를 그 맥락 수만큼 나눈 구조입니다.

6학습 포인트 (기술별)

이 저장소에서 무엇을 훔쳐 올 수 있는가
DJANGO / 백엔드

1. 추상 모델 사슬로 "공통 컬럼"을 한 번만 쓰기

모델 93개에 created_at·created_by·deleted_at을 손으로 붙였다면 곧 표류가 시작됩니다. Plane은 5단 상속으로 이걸 봉인하고, BaseModel.save()에서 crum.get_current_user()현재 요청 사용자를 자동으로 채웁니다. 서비스 코드는 "누가 만들었는지"를 신경 쓸 필요가 없습니다.

실습 아이디어: 내 Django 프로젝트에 AuditModel 한 겹을 만들어 기존 모델 2~3개를 갈아 끼우고, all_objects 탈출구를 붙여 보세요.

DJANGO / 백엔드

2. 무거운 일은 전부 요청 밖으로 — Celery beat 시간표

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페이지·이슈 설명 버전 정리

배울 점: 로그·버전·임시파일은 "쌓아 두고 나중에 지운다"가 아니라 처음부터 지우는 작업을 같이 만들어 둔다는 원칙입니다. 개인 프로젝트가 몇 달 뒤 디스크가 터지는 건 대개 이 시간표가 없어서입니다.

REACT / 프론트엔드

3. MobX 클래스 스토어 — 훅 일색인 세상에서 다시 보는 OOP

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-utilscomputedFn으로 "인자를 받는 계산값"을 만들어 캐싱합니다.

실습 아이디어: Zustand로 짠 목록 스토어를 맵 기반으로 바꿔 보고, 항목 1000개에서 1개 수정 시 리렌더 횟수를 React DevTools Profiler로 비교해 보세요.

REACT / 프론트엔드

4. 서비스 레이어를 백엔드와 1:1로 맞추기

apps/web/core/services/issue.service.ts, cycle.service.ts, webhook.service.ts… 29개가 있고, 각각이 백엔드 앱과 짝을 이룹니다. 컴포넌트는 axios를 직접 부르지 않습니다. 덕분에 인증 헤더·에러 처리·베이스 URL을 공통 APIService 한 곳에서만 바꾸면 됩니다.

실시간 / CRDT

5. Yjs + Hocuspocus로 동시 편집 만들기

Google Docs 같은 동시 편집을 직접 짜려면 OT(운영 변환)나 CRDT가 필요합니다. Plane은 Yjs(CRDT)를 쓰고, 서버 쪽은 Hocuspocus에 저장 어댑터만 갈아 끼우는 방식으로 붙였습니다. Hocuspocus의 Database 확장이 fetch/store 콜백을 열어 주므로, Plane은 그 자리에서 Django API를 호출합니다 — 협업 서버가 DB에 직접 붙지 않아 권한 로직이 한 곳(Django)에 남습니다.

debounce: 10000 설정도 눈여겨볼 만합니다. 타이핑할 때마다 저장하지 않고 10초 묶음으로 씁니다.

인프라 / 빌드

6. 모노레포 Docker 빌드 — turbo prune 3단계

모노레포를 그냥 COPY . . 하면 web 이미지를 만드는데 admin·space 소스까지 들어가 캐시가 매번 깨집니다. Plane의 Dockerfile.web은 ① turbo prune --scope=web --docker로 web에 필요한 부분만 잘라내고 ② 그 결과로 의존성 설치·빌드 ③ 산출물만 nginx:1.31-alpine에 복사합니다.

실습 아이디어: 내 Turborepo에 이 3단계를 적용하고, 관계없는 패키지를 고쳤을 때 빌드 캐시가 유지되는지 확인해 보세요.

보안

7. "나가는 요청"도 위험하다 — SSRF 방어 실물

대부분의 보안 교육은 들어오는 입력만 다룹니다. Plane의 웹훅 설정은 반대 방향 — 사용자가 지정한 주소로 서버가 요청을 보내는 기능이라, 허용 IP·허용 호스트·금지 도메인 목록을 함께 둡니다. HTML 새니타이징도 nh3(Rust)로 하고, 의존성 핀에는 특정 CVE 대응이 주석으로 남아 있습니다.

7하드웨어 / 시스템 요구사항

문서에 적힌 값과 실제 이미지에 박힌 값이 다르다
항목CONTRIBUTING.md (개발 문서)실제 이미지·락파일 (권위 있음)
Node20+ LTS22.18.0 (.mise.toml · engines)
Python3.8+3.12.10 (Dockerfile.api)
Postgres1415.7
Redis6.2.7Valkey 7.2.11
pnpm11.3.0 (SHA512 고정)
RabbitMQ언급 없음3.13.6
문서 간 충돌
RAM 요구가 12GB인가 4GB인가

CONTRIBUTING.md는 "최소 12GB RAM 권장, 8GB면 설치 실패나 메모리 크래시가 날 수 있다"고 씁니다. 반면 deployments/cli/community/README.md는 EC2 2 vCPU · 4GB RAM이면 된다고 합니다. 모순이 아니라 서로 다른 상황을 말하는 것입니다 — 12GB는 소스에서 프론트 3개를 동시에 빌드하는 개발 환경, 4GB는 이미 빌드된 이미지를 받아 돌리는 운영 환경입니다. 문서 두 개를 같은 문장으로 읽으면 "요구사항이 이상하다"는 결론이 나옵니다.

현실적인 권장값을 정리하면:

8직접 해볼 수 있는 실습 과제

읽기만 하면 안 남습니다 — 난이도순 5개
과제 1★☆☆☆☆ · 30분

띄우고, 컨테이너 11개가 각자 무슨 말을 하는지 보기

docker compose up -ddocker compose ps로 서비스 목록을 확인하고, docker compose logs -f api worker beat live로 로그를 동시에 봅니다. 이슈를 하나 만들고 담당자를 지정했을 때 어느 컨테이너가 반응하는지 관찰하세요.

확인 포인트: 이슈 생성은 api가 처리하는데, 알림·활동기록은 왜 worker 로그에서 나오는가?

과제 2★★☆☆☆ · 1~2시간

외부 API 키로 이슈를 스크립트로 만들기

워크스페이스 설정에서 API 토큰을 발급받고, /api/v1/ 경로로 프로젝트 목록 조회 → 이슈 생성 → 상태 변경까지 파이썬 스크립트로 자동화합니다. 그 다음 같은 일을 내부 API(/api/)로 시도해 보고 왜 막히는지 확인하세요.

확인 포인트: 인증 방식이 어떻게 다른가(API 키 vs 세션). 왜 두 API를 나눴는지 몸으로 이해하게 됩니다.

과제 3★★★☆☆ · 반나절

웹훅 받아서 서명 검증하기

작은 Flask/Express 서버를 띄우고 Plane 웹훅 대상으로 등록합니다. 받은 요청의 X-Plane-Signature직접 HMAC-SHA256으로 재계산해 대조하고, 일부러 페이로드를 한 글자 바꾸면 검증이 깨지는지 확인하세요.

확장: 웹훅 주소로 http://localhost:8000/을 넣어 보고, SSRF 방어가 어떻게 반응하는지 관찰합니다.

과제 4★★★★☆ · 1~2일

모델에 필드 하나 추가하고 화면 끝까지 잇기

Issuerisk_level 같은 필드를 하나 추가해서 DB → 시리얼라이저 → API → 서비스 → MobX 스토어 → 컴포넌트까지 전 구간을 통과시킵니다. 마이그레이션도 직접 만들어 보세요.

배우는 것: 이 저장소의 "한 기능이 지나가는 경로"를 몸으로 익히는 가장 빠른 방법입니다. 대형 코드베이스에 새로 합류했을 때 쓰는 표준 전술이기도 합니다.

과제 5★★★★★ · 며칠

실시간 편집을 뜯어보고 내 앱에 이식하기

두 브라우저로 같은 Page를 열고 동시에 타이핑하면서, 브라우저 개발자도구 Network 탭의 WS 프레임을 관찰합니다. 그다음 apps/live를 참고해 최소 Hocuspocus 서버(TipTap + Yjs + 저장 어댑터)를 별도 프로젝트로 재현하세요.

난관 예고: 저장 어댑터에서 "빈 바이너리를 HTML에서 복원"하는 부분과, 여러 서버 인스턴스를 띄웠을 때 Redis 확장이 없으면 편집이 갈라지는 현상을 반드시 겪게 됩니다.

9관련 기술 심화 학습 로드맵

4주 코스 — 이 저장소를 교재로 쓴다면
주차주제구체적으로 할 것
1주차 Django ORM과 모델 설계 추상 모델 상속·매니저·QuerySet 커스터마이징을 공식 문서로 학습 → db/mixins.pydb/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 빌드 재현

더 깊이 가고 싶다면

10핵심 키워드 사전

문서·코드에서 계속 마주칠 말들
용어
Work ItemPlane의 "할 일" 단위. 예전 이름은 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 / EECommunity Edition(공개) / Enterprise Edition(비공개). 이 저장소에서 둘로 갈라진 폴더는 packages/editor/src/뿐입니다.
소프트 삭제행을 실제로 지우지 않고 deleted_at에 시각을 찍는 방식. 복구 가능하고 참조 무결성이 안 깨집니다.
분수 정렬 (sort_order)순서를 실수로 저장해, 사이에 끼울 때 앞뒤 중간값만 계산하는 기법. 재정렬 UPDATE가 1건으로 끝납니다.
CRDTConflict-free Replicated Data Type. 여러 사람이 동시에 고쳐도 서버 중재 없이 같은 결과로 수렴하는 자료구조. Yjs가 구현체.
HocuspocusYjs 문서를 여러 클라이언트에 중계·저장해 주는 서버. TipTap 팀이 만듦.
ValkeyRedis가 라이선스를 바꾼 뒤 갈라져 나온 오픈소스 포크. 프로토콜은 Redis와 호환.
SSRFServer-Side Request Forgery. 서버가 공격자가 지정한 내부 주소로 대신 요청을 보내게 만드는 공격.
pg_advisory_xact_lockPostgres가 제공하는 트랜잭션 범위 잠금. 테이블 행이 아니라 임의의 숫자 키에 락을 걸 수 있어 번호 발급 같은 구간 직렬화에 씁니다.
turbo prune모노레포에서 특정 패키지 빌드에 필요한 파일만 잘라내는 Turborepo 명령. Docker 캐시 효율을 위해 씁니다.
xcaddy플러그인을 넣어 Caddy를 직접 컴파일하는 빌드 도구. Plane은 DNS 인증서 발급 플러그인 때문에 씁니다.

11참고 링크

공식 자료 우선 — 3자 튜토리얼은 구버전 정보가 많습니다

프로젝트

핵심 의존 기술

비교해 볼 만한 저장소