← Writing

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

Plan Do! 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년 뒤에도 이 결정들이 여전히 맞는지 다시 돌아보려고 합니다.