워크스페이스 격리를 한 메서드로 — 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인 운영 단계에서는 코드에 흐름이 그대로 보이는 패턴이 가장 오래 가는 것 같아요.