API설계하면서 부딪힌 3가지 — URL 설계, 검증 경계, 크로스 도메인 의존

승제·2026년 2월 22일
post-thumbnail

들어가며

사이드 프로젝트로 PT(Personal Training) 예약 관리 시스템을 만들고 있다. Spring Boot + Next.js 구성이다.

이번 글에서는 실제 기능을 구현하면서 마주친 설계 시행착오를 정리한다. 완성된 결과보다 왜 그렇게 결정했고, 어떤 트레이드오프가 있었는지에 집중한다.


1. /api/trainers/me/members — 그 URL이 정말 맞아?

상황

트레이너가 자기 회원 목록을 조회하는 API가 필요했다. 세 가지 후보가 있었다.

1. GET /api/trainers/members
2. GET /api/trainers/{trainerId}/members
3. GET /api/trainers/me/members

내 선택: 3번

이유는 두 가지였다.

  • me는 JWT에서 추출하니까 다른 트레이너의 ID를 넣을 여지 자체가 없다. IDOR(Insecure Direct Object Reference) 취약점이 구조적으로 차단된다.
  • URL만 봐도 "나의 회원"이라는 의미가 명확하다.

실제로 이 패턴은 널리 쓰인다:

  • Microsoft Graph: GET /v1.0/me
  • Spotify: GET /v1/me/playlists
  • Discord: GET /users/@me
  • GitHub: GET /user/repos (singular, ID 없이)
  • Google People: GET /v1/people/me

그런데 이건 RESTful한가?

엄밀히 말하면 아니다. Roy Fielding의 REST 원칙에서 URI는 하나의 리소스를 식별해야 한다. 그런데 /me는 토큰에 따라 다른 리소스를 가리키는 컨텍스트 의존적 별칭(alias)이다. 같은 GET /me가 사용자마다 다른 결과를 반환하니까, 하나의 URI = 하나의 리소스 원칙에 위배된다.

Kevin Dunglas(API Platform 메인테이너)는 이렇게 말했다:

"Creating an endpoint like /api/users/me isn't stateless, can create cache problems if you rely on some reverse proxies and break the REST pattern."

캐싱 문제도 있다. CDN이나 리버스 프록시가 URL을 캐시 키로 쓰면, User A의 /me 응답이 User B에게 전달될 수 있다. Vary: Authorization 헤더로 해결은 가능하지만, 기본 CDN 설정에서는 동작하지 않는 경우가 많다.

내가 인지한 트레이드오프

장점단점
IDOR 구조적 차단순수 REST 원칙 위배
의미가 명확HTTP 캐싱 복잡도 증가
클라이언트가 ID를 몰라도 됨Admin 기능 추가 시 /trainers/{id}/members 별도 필요
OAuth 부트스트랩 문제 해결기존 엔드포인트와 일관성 깨짐

기존 프로젝트의 다른 엔드포인트들(/api/reservations, /api/memberships)은 me 패턴을 안 쓰고, JWT에서 email을 추출해서 내부적으로 처리한다. 여기만 다른 패턴을 쓰는 셈이다.

일관성 문제를 알면서도 진행한 이유? 새로운 패턴을 경험해보고 싶었기 때문이다. 실무에서 쓸지 말지는 트레이드오프를 직접 느껴본 뒤에 판단해도 늦지 않다.

OCTO REST API Cookbook의 권장사항: /me초기 부트스트랩용으로만 제한적으로 사용하고, 이후에는 정규 URI(/users/{id}/...)를 쓰는 하이브리드 접근이 좋다.


2. "프론트에서 체크하면 되지 않나?" — 검증은 어디서 해야 하는가

상황

이용권(Membership)을 등록하는 API를 만들면서, 종료일이 시작일보다 앞서는 경우(날짜 역전)를 어디서 막을지 고민했다.

처음엔 당연히 "프론트에서 체크할 문제"라고 생각했다. Date Picker 컴포넌트에서 제한하면 되니까.

왜 그게 위험한가

OWASP Input Validation Cheat Sheet에 이런 문장이 있다:

"Input validation must be implemented on the server-side before any data is processed, as any JavaScript-based input validation can be circumvented by an attacker who disables JavaScript or uses a web proxy."

프론트 검증은 UX(사용자 편의)를 위한 것이고, 백엔드 검증은 데이터 무결성을 위한 것이다. 이 둘은 목적이 다르다.

실제로 프론트 검증만 믿다가 터진 사례들이 있다:

  • OTP 우회: 클라이언트에서만 OTP를 검증 → 공격자가 API 응답의 상태 코드를 401에서 200으로 변조해서 인증 우회
  • 결제 금액 조작: JavaScript 변수에 저장된 가격을 Burp Suite로 변조 → 서버가 그대로 수용
  • HackerOne 최소 바운티 우회: 보안 플랫폼인 HackerOne 자체에서 클라이언트 입력을 신뢰해서 최소 금액 제한이 뚫림

검증 피라미드

Frank de Jonge의 "Where Does Validation Live?"에서 제안하는 3계층 검증:

[Frontend]     형태 검증, 즉각적 UX 피드백 (보안 아님)
     ↓
[Controller/DTO]  구조적 검증: @NotNull, @Min, 타입 체크
     ↓
[Service]      비즈니스 규칙: 권한, 중복, 정책
     ↓
[Entity]       불변식(invariant): 도메인 모델이 스스로를 보호
     ↓
[Database]     제약조건: NOT NULL, UNIQUE, FK (최후의 방어선)

Greg Young은 2009년 "Always Valid" 글에서 이렇게 주장했다:

Entity는 절대로 유효하지 않은 상태로 존재해서는 안 된다. 유효하지 않은 상태의 Entity는 설계 결함이다.

Vladimir Khorikov는 이걸 비유로 설명한다:

"삼각형은 변이 3개인 도형이다. 4번째 변을 추가하면, 그건 더 이상 삼각형이 아니라 사각형이다."

내가 적용한 구조

검증위치이유
endDate < startDateEntity create()어디서 생성하든 잘못된 수강권이 만들어지면 안 됨
totalCount ≤ 0Entity create()도메인 불변식 — 0회짜리 수강권은 존재할 수 없음
트레이너 소유 회원 확인Service비즈니스 규칙 (인가)
ACTIVE 수강권 중복Service비즈니스 정책 — 한 회원에 ACTIVE 수강권 1개만
필드 null/emptyDTO @NotNull, @Min구조적 검증
// Entity — 도메인 모델이 스스로를 보호한다
public static Membership create(Member member, Trainer trainer,
                                Integer totalCount,
                                LocalDate startDate, LocalDate endDate) {
    if (endDate.isBefore(startDate)) {
        throw new IllegalArgumentException("종료일은 시작일보다 이후여야 합니다.");
    }
    if (totalCount <= 0) {
        throw new IllegalArgumentException("총 PT 횟수는 1 이상이어야 합니다.");
    }
    return Membership.builder()
            .member(member)
            .trainer(trainer)
            .totalCount(totalCount)
            .remainingCount(totalCount)
            .startDate(startDate)
            .endDate(endDate)
            .status(MembershipStatus.ACTIVE)
            .build();
}

Jimmy Bogard는 반대 의견도 있다 — "Validate commands, not entities"(Entity가 아니라 커맨드에서 검증해라). 하지만 DDD 커뮤니티의 주류 의견은 Entity가 자신의 불변식을 보호해야 한다는 쪽이다. 특히 복잡한 도메인에서.

핵심은 이거다:

프론트 검증과 백엔드 검증은 중복이 아니다. 서로 다른 목적을 위한 서로 다른 방어선이다.


3. Controller는 trainer에, Service는 member에 — 크로스 도메인 의존의 딜레마

상황

회원 목록 API의 패키지 구조를 이렇게 잡았다:

  • TrainerMemberControllerdomain/trainer/controller/
  • MemberServicedomain/member/service/

이유는 명확했다:

  • Controller는 URL 경로를 따른다 → /api/trainers/me/members니까 trainer 패키지
  • Service는 데이터 소유자를 따른다 → 회원 데이터를 다루니까 member 패키지

Martin Fowler가 경고한 빈약한 도메인 모델(Anemic Domain Model) 안티패턴을 피하려면, 데이터를 소유한 주체가 비즈니스 로직도 가져야 한다.

그런데 의존성 그래프를 보면

TrainerMemberController (trainer 도메인)
    └── MemberService (member 도메인)
            ├── MemberRepository (member 도메인)       ← 같은 도메인, OK
            ├── MembershipRepository (membership 도메인) ← 크로스!
            └── TrainerRepository (trainer 도메인)      ← 크로스!

3개의 도메인이 하나의 Service에서 만난다. 이건 문제인가?

이게 문제가 되는 시점

지금은 문제없다. 프로젝트가 작고, 도메인 간 경계가 명확하다.

문제가 되는 시점은:

  • MembershipRepository의 API가 바뀌면 MemberService도 깨진다 (다른 도메인의 내부 구현에 의존)
  • membertrainer 패키지가 서로를 참조하기 시작하면 순환 의존이 생긴다
  • Spring Modulith를 도입하면 다른 모듈의 하위 패키지 접근이 차단된다

해결 패턴들

Philipp Hauer의 "Package by Feature" 글과 Spring Modulith 가이드에서 제안하는 패턴들:

패턴 A: 인터페이스로 의존성 역전

// member 모듈이 인터페이스를 정의
public interface MembershipInfoProvider {
    MembershipSummary getActiveMembership(Long memberId);
}

// membership 모듈이 구현
@Service
public class MembershipInfoProviderImpl implements MembershipInfoProvider {
    // MembershipRepository는 여기서만 사용
}

패턴 B: 이벤트 기반

// 직접 호출 대신, 이벤트를 발행하고 구독하는 방식
// Spring Modulith가 권장하는 모듈 간 통신
applicationEventPublisher.publishEvent(new MemberQueryEvent(memberId));

패턴 C: 모듈 합치기
membermembership이 항상 함께 쓰인다면, 애초에 하나의 바운디드 컨텍스트(Bounded Context)일 수 있다.

내가 인지한 트레이드오프

현재는 가장 단순한 방법(직접 의존)을 선택했다. PT 예약 시스템 규모에서 인터페이스 분리나 이벤트 기반은 과설계(over-engineering)다.

하지만 이 의존 관계를 인지하고 있는 것모르고 지나가는 것은 다르다. 나중에 도메인이 커지면, 위 패턴들이 필요한 시점이 온다.

Eric Evans는 Bounded Context 사이에는 Anti-Corruption Layer를 두라고 했다. 지금은 모노리스 안에서의 패키지 경계지만, 마이크로서비스로 분리한다면 이 크로스 도메인 의존이 가장 먼저 문제가 된다.


정리

설계 판단선택인지한 리스크
URL 패턴/api/trainers/me/membersREST 원칙 위배, 프로젝트 내 일관성 깨짐
검증 위치Entity + Service + DTO (계층별)검증 로직 분산, 하지만 각 레이어의 목적이 다름
패키지 구조Controller ≠ Service 패키지크로스 도메인 의존, 순환 의존 가능성

세 가지 판단 모두 정답이 아닐 수 있다. 하지만 왜 그렇게 결정했고, 어떤 문제가 생길 수 있는지 알고 있다. 그게 중요하다고 생각한다.


참고 자료

URL 설계 / /me 패턴

검증 / 보안

패키지 구조 / DDD


프로젝트: FitLink — PT 예약 관리 시스템
기술 스택: Spring Boot 3.3 + Java 21 + Next.js 16 + Zustand + TanStack Query

profile
[⚙️ + 💡] 효율 속에서 창의성을 실험합니다.

0개의 댓글