Plan Do! 데이터 모델 — Workspace 중심의 멀티테넌시
Plan Do!의 데이터 모델은 처음부터 Workspace를 중심에 둔 멀티테넌시로 설계됐습니다. 1년 가까이 운영하면서 처음에 잘 잡힌 것과 운영 중에 바꾼 것이 갈렸어요. 그 결정들과 부딪힌 것들을 기록해둡니다.
출발점 — Workspace를 중심에 둔 설계
기획 단계부터 팀 협업이 일급 기능이었기 때문에, 데이터 모델의 축을 Workspace에 두고 시작했습니다. 대안(user 직속 모델, project 단위, organization-project 3단)을 비교하는 과정 없이 워크스페이스 한 축으로 쭉 갔고, 그 선택이 지금까지의 거의 모든 후속 결정을 자연스럽게 끌고 갔어요.
전제는 셋이었습니다.
- 혼자 쓸 때도 어색하지 않을 것 — 신규 가입 시 기본 워크스페이스가 자동 생성
- 한 사람이 여러 워크스페이스에 속할 수 있을 것 — 개인용·팀용·사이드 프로젝트 분리
- 권한 경계가 명확할 것 — 다른 워크스페이스의 데이터가 절대 새지 않기
축을 일찍 고정한 게 가장 큰 보상이었어요. Todo도 DM도 Notification도 알아서 그 축을 따라 붙는 구조가 됐습니다.
다섯 박스로 정리되는 33개 엔티티
엔티티는 30개를 넘는데, 멘탈 모델에선 다섯 그룹으로 들어와요.
- 계정 / 인증 —
User,UserSettings,PasswordResetToken,PushSubscription,ApnsToken - 워크스페이스 —
Workspace,WorkspaceMember,WorkspaceRole(enum) - 할 일 —
Todo,TodoChecklist,TodoAttachment,TodoRecurrence,TodoTemplate,TodoFocus,TodoLog,Comment,LocationTrigger - 메시지(DM) —
DmRoom,DmMessage,DmMessageReaction - 알림 / 팀모집 / 기타 —
Notification,NotificationType,Recruitment*시리즈
User·UserSettings 같은 계정 본체는 워크스페이스 밖에 살고, 나머지 대부분은 워크스페이스에 묶입니다. 새 엔티티를 추가할 때 어느 박스에 들어갈지가 한 번에 보이는 게 모델링이 잘 됐다는 신호 같아요.
ERD

다이어그램 자체의 디테일보다, 읽는 사람이 모델의 모양을 한눈에 잡을 수 있게 위성형 배치로 갔습니다.
Workspace 격리는 컨트롤러 레이어에서
모든 워크스페이스 스코프 컨트롤러는 두 줄로 시작합니다.
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId
Workspace workspace = workspaceResolver.resolve(userEmail, workspaceId);
WorkspaceResolver.resolve()가 하는 일은 단순합니다.
- 헤더로 들어온
workspaceId에 대해 현재 사용자가 멤버인지 확인 — 아니면 403 - 헤더가 없으면 사용자의 첫 OWNER 워크스페이스로 fallback (없으면 그냥 첫 워크스페이스)
- 어디에도 속하지 않으면 404
public Workspace resolve(String userEmail, Long workspaceId) {
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new ApiException(404, "사용자를 찾을 수 없습니다."));
if (workspaceId != null) {
Workspace workspace = workspaceRepository.findById(workspaceId)
.orElseThrow(() -> new ApiException(404, "워크스페이스를 찾을 수 없습니다."));
boolean isMember = workspaceMemberRepository.existsByWorkspaceAndUser(workspace, user);
if (!isMember) throw new ApiException(403, "해당 워크스페이스에 접근 권한이 없습니다.");
return workspace;
}
return workspaceMemberRepository
.findFirstByUserAndRoleOrderByIdAsc(user, WorkspaceRole.OWNER)
.map(WorkspaceMember::getWorkspace)
.or(() -> workspaceMemberRepository.findFirstByUserOrderByIdAsc(user)
.map(WorkspaceMember::getWorkspace))
.orElseThrow(() -> new ApiException(404, "소속 워크스페이스가 없습니다."));
}
이 두 줄을 빠뜨리는 순간 다른 워크스페이스 데이터가 새는 게 가능합니다. 그래서 새 워크스페이스 스코프 엔드포인트를 추가할 때 이 패턴을 깨지 않는 게 코드 리뷰 시 가장 먼저 보는 두 줄이 됐어요.
예외적으로 이 패턴을 안 쓰는 컨트롤러도 있습니다.
**WorkspaceController**— 워크스페이스 자체를 만들고 관리하는 곳이라 resolver의 전제(멤버십)가 의미 없음**RecruitmentController**— 모집글이 비로그인 공개 열람이라 워크스페이스 스코프 밖
반복 할 일 — 원본과 인스턴스를 분리
매일·매주·매월 반복되는 할 일을 어떻게 저장할지가 초기에 갈림길이었어요.
- 한 row 재사용 — 현재 상태만 의미 있고 과거는 사라짐
- 매번 새 row를 만듦 — 인스턴스마다 상태(완료/스킵) 가 독립적
협업·로그·통계가 필요했기 때문에 인스턴스 분리로 갔습니다.
TodoRecurrenceScheduler가 매일 자정에 다음 인스턴스를 생성하고, 인스턴스의 recurrenceSourceId 필드가 원본 TodoRecurrence.id를 가리킵니다. 체크리스트는 복사하되 checked는 false로 초기화, 원본의 startAt → dueDate 간격은 그대로 유지.
@Scheduled(cron = "0 0 0 * * *") // 매일 자정
public void generateRecurringInstances() { ... }
대신 row 수가 빠르게 늘어나는 트레이드오프가 있고, 인덱스 설계가 중요해집니다.
운영하면서 바꾼 두 가지
코드에서 보이지 않는 결정들은 실제로 써본 뒤에야 드러나요. 두 가지를 운영 중에 바꿨습니다.
1. 글로벌 focused에서 per-user TodoFocus로
처음엔 Todo.focused boolean 한 컬럼으로 모든 사용자가 같은 포커스 상태를 공유했습니다. 혼자 쓸 땐 충분했어요.
협업을 도입하고 테스트하면서 발견한 건 단순한 사실이었습니다 — 팀원마다 포커싱하고 싶은 일정이 다를 수 있다. 거기에 iOS 위젯이 내가 포커싱한 일정을 보여주는 구조라, 동료의 포커스가 내 위젯에 섞이면 안 됐어요.
별도 테이블로 분리했습니다.
@Entity
@Table(name = "todo_focus",
uniqueConstraints = @UniqueConstraint(
name = "uk_todo_focus_user_todo",
columnNames = {"user_id", "todo_id"}))
public class TodoFocus { ... }
존재 = focused for that user. row가 있으면 포커스 on, 없으면 off.
기존 Todo.focused 컬럼은 @Deprecated로 표시하되 호환을 위해 남겨두고, 부팅 시점에 한 번 FocusMigrationRunner가 작성자에게 이관했어요. 컬럼은 지우지 않고 두는 게 지금 단계에선 더 안전했습니다.
/**
* @deprecated per-user 포커스로 마이그됨. TodoFocus 테이블이 진실
* (existence = focused for that user).
* 컬럼은 호환을 위해 남겨두지만 코드에서 더 이상 읽거나 쓰지 않는다.
*/
2. ArchiveScheduler 임계값 — 7일에서 30일로
초기엔 완료 후 7일이 지난 할 일을 자동으로 아카이브하게 잡았습니다. 실제로 써보니 주간 단위로 잡혀있던 할 일들이 일주일 만에 그냥 사라져서 확인이 번거로웠어요. “이번 주에 뭐 했는지” 다시 보려고 보드를 열면 이미 아카이브 영역으로 가 있는 식.
한 달 회고 느낌으로 임계값을 30일로 늘렸습니다. 통계 쿼리는 archived 무관하게 돌게 분리해두고, 보드에서는 최근 한 달의 완료 작업이 자연스럽게 함께 보이도록.
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
public void autoArchive() {
// 완료 후 30일 경과한 todo 아카이브
}
코드 주석에도 결정을 남겨뒀어요 — 몇 달 뒤의 나를 위해.
/**
* 임계값을 7→30일로 늘린 이유: 대시보드의 "이번 주/지난 주 완료율" 같은
* 단기 통계가 자동 아카이브 시점 이후 회고적으로 떨어지는 문제를 완화...
*/
샘플 데이터 보호 — 테스트에서 시작된 요구사항
신규 가입 시 온보딩용 샘플 할 일이 자동으로 만들어집니다. 그런데 직접 테스트하면서 샘플 데이터가 제대로 안 지워지거나, 다시 만들어지지 않거나 하는 문제들이 반복됐어요.
원인은 사용자가 온보딩 안내를 보지 않고 곧장 삭제하는 흐름이었습니다. 그러면 다음 부팅 때 또 생성되거나, 어떤 경로에선 “샘플 생성됨” 상태만 남고 실제 데이터가 없는 모순이 생겼어요.
해결은 단순했습니다 — Todo.isSample() 플래그를 두고 수정·삭제 시 거부. 정말 정리하고 싶은 사용자는 별도의 “샘플 일괄 삭제” 흐름으로 유도.
if (todo.isSample()) {
throw new ApiException(403, "샘플 데이터는 수정할 수 없습니다.");
}
사용자는 안내를 안 본다는 전제로 구조를 짜야 한다는 걸 이 사건에서 배웠어요.
트레이드오프
지금 모델에 남아있는 부담들.
**ddl-auto=update에 기대고 있음** — dev는 엔티티만 고치면 스키마가 따라가서 빠른데, prod는validate라 큰 변경마다 SQL을 손으로 만들어 적용해야 합니다. Flyway/Liquibase 도입은 아직.- 레거시 FK 패턴 —
Comment,TodoAttachment같은 초기 엔티티는 외래키 대신 todoId 필드만 가지고 있어요. 초기에 그렇게 들어간 게 굳은 상태. 새 코드는@ManyToOne관계로 잡지만 정리는 점진적. - N+1 가능성 — JPA + 다대다 관계가 많아서 연관 로딩 전략을 신경 안 쓰면 곧장 N+1로 빠집니다. fetch join,
@EntityGraph, projection DTO를 케이스마다 결정. - Lombok 의존 —
@Getter / @Setter / @Builder사용량이 많아서, 새로 코드를 보는 사람이 IDE 없이는 자동 생성된 메서드 추적이 번거로울 수 있어요.
돌아보면
- 격리 축을 처음부터 하나로 잡아둔 게 제일 잘한 것 같아요. Workspace 한 축이 있으니 권한도 통계도 UI도 알아서 그 축을 따라가는 구조가 됐어요.
- 컨트롤러 패턴이 사실상 규칙 역할을 했어요.
WorkspaceResolver두 줄을 빠뜨리면 바로 데이터가 샐 수 있어서, 코드 리뷰 때 이 두 줄부터 보게 되더라고요. - 운영해보고 나서야 보이는 결정도 있었어요. 글로벌 focus → per-user, 7일 → 30일 같은 조정은 코드만 봐선 안 보이고, 실제로 써보면서 사용자 입장으로 다시 봤을 때 드러났어요.
- 사용자는 안내를 잘 안 읽더라고요. 샘플 데이터 사건에서 느낀 건데, 문구로 해결하려 하기보다 구조적으로 막는 쪽이 맞았어요.
다음에 해볼 것
- Flyway 마이그레이션 도입 — 운영 DDL을 코드로 관리
- 인덱스 전수 점검 —
created_at기반 정렬이 많아서 복합 인덱스가 더 필요할 가능성 - 레거시 FK 패턴 정리 —
Comment,TodoAttachment의 todoId 필드를@ManyToOne관계로 점진 이전 - 이벤트 소싱 부분 도입 —
TodoLog가 이미 audit trail 비슷한 역할인데, 공식 이벤트 스토어로 승격하면 통계와 알림이 단순해질 가능성
데이터 모델은 서비스가 굴러가는 동안 계속 자라는 것 같아요. 1년 뒤에도 이 결정들이 여전히 맞는지 다시 돌아보려고 합니다.