← Writing

워크스페이스 격리를 한 메서드로 — WorkspaceResolver 패턴

Plan Do!는 모든 자원이 워크스페이스에 묶여 있습니다. 이번 글은 그 격리를 런타임에 강제하는 메커니즘에 대한 기록이에요. 가장 단순한 방법으로 시작해서, 새 엔드포인트를 더 이상 두렵지 않게 만든 패턴을 적어둡니다.

왜 — 격리는 코드에서 강제된다

협업 앱에서 거의 모든 자원은 워크스페이스에 속합니다. 할 일, 댓글, 첨부, 채팅, 알림 — 전부 특정 워크스페이스의 멤버에게만 보여야 합니다.

그런데 이 규칙은 데이터베이스 스키마가 아니라 애플리케이션 코드에서 강제됩니다. 하나의 테이블에 여러 워크스페이스의 행이 섞여 있고, 어느 행을 보여줄지를 결정하는 책임이 컨트롤러에 있어요.

가장 무서운 건 조용한 누수입니다. 에러가 안 나고, 그냥 다른 워크스페이스 일정이 함께 조회될 뿐 — 코드 리뷰가 없으면 잡을 길이 없습니다.

안티패턴 — 매번 if 체크

처음엔 컨트롤러마다 이렇게 적었어요.

@GetMapping("/todos")
public List<TodoResponseDto> getTodos(
        Authentication authentication,
        @RequestHeader("X-Workspace-Id") Long workspaceId
) {
    String email = authentication.getName();
    User user = userRepository.findByEmail(email)
            .orElseThrow(() -> new ApiException(404, "사용자를 찾을 수 없습니다."));

    Workspace workspace = workspaceRepository.findById(workspaceId)
            .orElseThrow(() -> new ApiException(404, "워크스페이스를 찾을 수 없습니다."));

    boolean isMember = workspaceMemberRepository.existsByWorkspaceAndUser(workspace, user);
    if (!isMember) throw new ApiException(403, "접근 권한이 없습니다.");

    return todoRepository.findByWorkspace(workspace).stream()
            .map(this::convertToDto).toList();
}

문제는 분명합니다.

  • 반복 — 모든 엔드포인트가 같은 8줄로 시작
  • 누락 위험 — 다섯 번째 신규 엔드포인트에서 isMember 체크를 빠뜨리면 그 순간 정보 누출
  • 테스트 어려움 — 격리 로직이 컨트롤러에 흩어져 있어 격리 자체를 단위 테스트할 수가 없음

신규 엔드포인트가 늘어날수록 누락 확률은 올라갑니다. 시간 문제일 뿐이에요.

후보들 — 두 가지를 두고 고민

격리를 강제하는 방법은 결국 두 갈래로 나뉘었어요.

  • 워크스페이스 검증을 자동으로 끼워 넣기 — 컨트롤러 호출부에 안 적어도 프레임워크가 알아서 끼워주는 방식. 코드는 깔끔해지지만, 어디서 검증이 일어났는지가 호출부에 안 보입니다. 디버깅할 때 “왜 여기서 이게 됐지?”를 찾으려면 설정 파일이나 어노테이션을 거꾸로 추적해야 해요.
  • 호출부에 한 줄 적기 — 모든 컨트롤러가 명시적으로 검증 메서드를 호출. 줄을 빠뜨리면 컴파일러가 안 잡지만, 코드 리뷰에서 한 줄 없는 게 바로 보입니다. 흐름이 코드에 그대로 드러나서 디버깅도 쉬워요.

1인 개발 단계에서는 “코드에 안 보이는 동작”이 디버깅 비용을 크게 만든다고 봐서 후자를 골랐습니다. 깔끔함보다 추적 가능성이 우선이었어요.

패턴 — WorkspaceResolver

결과적으로 정착한 형태입니다.

@Component
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class WorkspaceResolver {

    private final UserRepository userRepository;
    private final WorkspaceRepository workspaceRepository;
    private final WorkspaceMemberRepository workspaceMemberRepository;

    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, "소속 워크스페이스가 없습니다."));
    }

    public User getUser(String userEmail) {
        return userRepository.findByEmail(userEmail)
                .orElseThrow(() -> new ApiException(404, "사용자를 찾을 수 없습니다."));
    }
}

몇 가지 작은 결정이 들어가 있어요.

  • 입력은 userEmail (String) + workspaceId (nullable) — JWT 주체인 email 문자열을 받습니다. 컨트롤러는 Authentication.getName()로 email만 알고, 엔티티는 resolver 안에서 가져옵니다.
  • workspaceId는 옵션X-Workspace-Id 헤더가 없으면 OWNER인 첫 워크스페이스로 fallback, 그것도 없으면 멤버인 첫 워크스페이스. 로그인 직후처럼 클라이언트가 아직 ws를 고르지 않은 상태에서도 API가 동작합니다.
  • 403과 404를 구분 — 워크스페이스 자체가 없으면 404, 있는데 멤버가 아니면 403. 정보 누설을 막으려면 둘 다 404로 통일하는 게 정석이지만, 협업 앱은 “받은 초대 링크인데 왜 거절돼?” 상황이 자주 생겨서 명확한 메시지를 우선했습니다. 트레이드오프예요.
  • @Transactional(readOnly = true) — user/workspace/member 조회를 하나의 읽기 전용 트랜잭션으로 묶어 lazy fetch와 격리 수준을 일관되게.

호출부 — 두 줄

신규 컨트롤러는 항상 같은 두 줄로 시작합니다.

@GetMapping("/todos")
public List<TodoResponseDto> getTodos(
        Authentication authentication,
        @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId
) {
    String userEmail = authentication.getName();
    Workspace workspace = workspaceResolver.resolve(userEmail, workspaceId);

    return todoRepository.findByWorkspaceAndArchivedFalse(workspace).stream()
            .map(this::convertToDto).toList();
}

8줄이 2줄이 됐는데, 줄 수보다 중요한 건 코드 리뷰에서 누락이 보인다는 점입니다. 신규 엔드포인트가 todoRepository.findBy...()를 호출하면서 workspaceResolver를 안 부르면, 코드 리뷰에서 자연스럽게 *“왜 여기엔 워크스페이스 검증이 없지?”*가 따라옵니다.

이 두 줄이 컨트롤러의 격리 계약이 됐어요.

흐름 — 한 장 다이어그램

요청
 │  Authorization: Bearer <jwt>
 │  X-Workspace-Id: 17    (옵션)

컨트롤러
 │  String email = authentication.getName();
 │  Long   wsId  = @RequestHeader(...);

WorkspaceResolver.resolve(email, wsId)

 │  1. User 조회 ───────────────── 없으면 404

 │  2-a. wsId 있음
 │      ├─ Workspace 조회 ─────── 없으면 404
 │      └─ Member 검증 ────────── 아니면 403

 │  2-b. wsId 없음 (fallback)
 │      ├─ OWNER 첫 워크스페이스
 │      ├─ 그래도 없으면 멤버 첫 워크스페이스
 │      └─ 그래도 없으면 ───────── 404

 ▼ Workspace
컨트롤러: 자원 조회 always by workspace


응답

읽는 사람이 언제 어떤 상태 코드가 나가는지를 한눈에 잡을 수 있게 분기 위주로 그렸어요.

실수가 잡힌 지점

이론보다 실제로 패턴이 일을 했나가 중요해서, 운영 중에 잡힌 누락을 몇 개 적어둡니다.

  • DM 컨트롤러 — 처음엔 roomId만 받아 메시지를 보냈는데, roomId가 워크스페이스에 묶여 있다는 점을 깜빡했어요. 패턴이 항상 두 줄로 시작하는 게 익숙해진 시점이라, 리뷰에서 *“왜 여기엔 resolver가 없지?”*가 즉시 따라왔습니다.
  • 알림 조회 — 처음엔 알림이 user 단위라서 워크스페이스 안 거쳐도 된다고 생각했습니다. 그런데 알림에 따라오는 링크된 자원(todoId, roomId)이 다른 워크스페이스일 수 있다는 걸 운영 중에 깨달았어요. 알림 자체도 워크스페이스에 묶고 패턴을 따랐습니다.
  • 스케줄러는 패턴 밖@Scheduled 안에서는 JWT 주체가 없습니다. 이 경우엔 워크스페이스를 반복문으로 돌며 직접 처리해요. 인증된 사용자 요청에만 적용되는 패턴이라는 한계는 분명히 있습니다.

한계

이 패턴이 만능은 아닙니다.

  • 컴파일러가 안 잡는다 — 호출을 빠뜨리면 런타임에 누수. 결국 코드 리뷰와 관습에 의존합니다.
  • 자원 자체가 워크스페이스를 모를 때Notification처럼 처음엔 user 단위로 모델링한 자원은 나중에 workspaceId 컬럼을 추가하며 마이그레이션해야 했어요. 모델링을 처음부터 워크스페이스 기준으로 잡는 게 정답이었습니다.
  • 인증 없는 엔드포인트 — 공개 모집 페이지(/api/recruitments)는 이 패턴 밖에 있습니다. 그 경우엔 자원 자체의 공개 여부 컬럼으로 격리합니다.

다음에 해볼 것

  • 테스트 자동화WorkspaceResolver 자체는 이미 단위 테스트가 쉽고, “다른 워크스페이스 ID로 호출 시 403” 패턴을 컨트롤러 통합 테스트에 표준 케이스로 넣을 계획.
  • 클라이언트 표준화 — axios 인터셉터에 X-Workspace-Id 자동 주입. 지금은 일부 호출이 헤더 없이 가서 fallback에 의존하고 있어요. 명시적으로 보내는 게 의도가 분명합니다.
  • DB 차원의 두 번째 방어선 — 사용자가 더 늘면 데이터베이스 단계에서도 격리를 한 번 더 거는 걸 검토. 지금은 애플리케이션 한 줄에 의존하고 있어서, 그 한 줄이 빠지면 끝까지 갑니다.

격리 방법은 여러 가지지만 팀 단계에 맞는 걸 고르는 게 핵심이라고 봤습니다. 1인 운영 단계에서는 코드에 흐름이 그대로 보이는 패턴이 가장 오래 가는 것 같아요.