포스트

화면 단위 API를 도메인 책임 단위로 다시 나누기: 응답 1.5초에서 300ms까지

시리즈 음원 콘텐츠 플랫폼(MCP) 전면 개편 11편 중 6편 음원 콘텐츠 플랫폼(MCP) 전면 개편
  1. 1 MCP 전면 개편기: Map 입력을 DTO와 3단계 검증으로 바꾸기
  2. 2 MCP 전면 개편기: 계약 코드 채번의 락 경합을 줄여 2분을 10초로
  3. 3 MCP 전면 개편기: MyBatis와 JPA 공존 환경의 데이터 정합성 전략
  4. 4 MCP 전면 개편기: 화면 단위 API를 도메인 책임 API로 옮기기
  5. 5 MCP 전면 개편기: 판단 로직의 자리를 정하고 ArchUnit으로 지키기
  6. 6 화면 단위 API를 도메인 책임 단위로 다시 나누기: 응답 1.5초에서 300ms까지
  7. 7 Map 기반 입력을 DTO와 3단계 검증으로 바꾸기: Bean Validation, 매핑, 도메인 규칙
  8. 8 유통사가 늘 때마다 복사되던 File Observer 구현체 정리: 제네릭을 걷어낸 Base 클래스와 DB 기반 권리사 ID
  9. 9 Shadow Release로 조회를 옮기기: MyBatis 결과와 QueryDSL 결과를 나란히 비교하며 전환한 기록
  10. 10 위반 480건 위에 아키텍처 규칙을 도입하기: 신규 코드는 100% 강제, 레거시는 점진 정리
  11. 11 실행하지 않은 재설계: 권리이관 시스템의 요청 모델을 3계층으로 나누자는 제안
엔지니어링 요약 음원 콘텐츠 플랫폼(MCP)의 API가 화면 단위로 나뉘어 있어 같은 데이터를 여러 API가 반복 조회했고 슬로우 쿼리가 생겼다. 변경 로직도 여러 API에...

Problem

음원 콘텐츠 플랫폼(MCP)의 API가 화면 단위로 나뉘어 있어 같은 데이터를 여러 API가 반복 조회했고 슬로우 쿼리가 생겼다. 변경 로직도 여러 API에 흩어져 있어 운영자가 정해진 순서로 호출해야만 데이터 정합성이 유지됐다.

Decision

화면 중심 API를 도메인 책임 기반 REST API로 재설계하고, 흩어진 변경 로직을 도메인 단위로 모아 트랜잭션 경계를 한 메서드로 명확히 했다. 조회 구조와 쿼리도 함께 정리했다.

Result

Datadog 대시보드에서 재설계 전후를 관찰한 결과 API 응답 시간이 1.5초에서 300ms로 80% 줄었다. 중복 조회와 슬로우 쿼리가 사라졌고, 운영자가 호출 순서를 지켜야 하는 의존성이 없어졌다.

음원 콘텐츠 플랫폼(MCP) 전면 개편에서 가장 먼저 손댄 것은 API의 단위였다. 기존 API는 화면 하나에 맞춰 만들어져 있었고, 그 결과 같은 데이터를 여러 번 읽고, 하나의 변경을 여러 API에 나눠 담고 있었다. 이 글은 그 구조가 어떤 증상으로 드러났는지, 왜 API를 도메인 책임 단위로 다시 나눴는지, 트랜잭션 경계를 어디에 두었는지, 그리고 그 결과와 남은 한계를 정리한 기록이다.

이 글의 다이어그램과 코드는 회사의 실제 소스가 아니다. 구조를 설명하기 위한 재구성 예시이거나, 같은 구조를 처음부터 다시 만든 개인 랩의 코드이며, 랩 코드는 그렇다고 표시했다.


배경과 조건

MCP는 계약, 앨범, 곡(트랙) 메타데이터와 유통사·계약별 라이선스 권리를 관리하는 사내 플랫폼이다. 앨범 하위 트랙 중 서비스 상태가 “사용”으로 퍼블리싱된 곡만 8천만 건이 넘었다. 사용자는 외부 고객이 아니라 정산, 라이선스, 데이터 관련 부서의 운영자들이다.

개편 조건은 다음과 같았다.

  • 기간: 2024년 1월부터 11월까지 전면 개편
  • 인원: 백엔드 3명. 나는 백엔드 리딩을 맡았고, 원래 팀이 풀스택으로 담당하던 프로젝트라 프론트엔드와도 일정과 아키텍처를 공유하며 진행했다.
  • 내 구현 범위: 공통 기능(엑셀, 파일 업로드·다운로드 인터페이스, RabbitMQ 메시지 인터페이스), 아키텍처 규칙, 앨범·트랙·계약·지분율 관리 API. 이 글의 API 재설계는 내가 직접 구현한 범위다.
  • 스택: Java, Spring Boot, JPA, MapStruct, MySQL, RabbitMQ. 응답 시간은 Datadog 대시보드로 관찰했다.

계약 코드처럼 정산과 외부 유통에 연결되는 핵심 식별자도 같은 구조 위에 얹혀 있었다. 레거시를 그대로 두면 정합성과 성능 문제가 계속 쌓이는 상황이었다.


증상: 같은 데이터를 여러 번 읽고, 변경은 순서에 의존했다

문제는 두 갈래로 드러났다.

첫째는 조회였다. API가 화면 단위로 나뉘어 있어서, 앨범 화면 API와 계약 화면 API가 각자 필요한 데이터를 따로 읽었다. 두 화면이 보는 데이터는 상당 부분 겹쳤다. 그래서 같은 테이블 조합을 여러 API가 반복해서 조회했고, 이 과정에서 슬로우 쿼리가 생겼다. Datadog에서 본 재설계 전 API 응답 시간은 1.5초 수준이었다. 다만 이 값이 평균인지 P95(전체 요청의 95%가 이 시간 안에 끝나는 값)인지 같은 집계 기준은 기억나지 않는다. 대시보드에서 재설계 전후 응답 시간 추이를 관찰해 확인한 것까지가 확실한 부분이다.

아래 그림은 구조를 설명하기 위한 재구성 예시다. 화면마다 API가 있으면 한 번의 업무 흐름에서 같은 JOIN이 두 번 나간다.

sequenceDiagram
    participant UI as 운영 화면
    participant A as 앨범 화면 API
    participant B as 계약 화면 API
    participant DB as MySQL
    UI->>A: 앨범 상세 조회
    A->>DB: 앨범, 트랙, 계약 JOIN 조회
    DB-->>A: 결과
    UI->>B: 계약 상세 조회
    B->>DB: 앨범, 트랙, 계약 JOIN 재조회
    DB-->>B: 같은 데이터를 다시 반환

둘째는 변경이었다. 하나의 업무상 변경이 여러 API에 나뉘어 들어가 있었다. 그래서 운영자가 특정 순서로 API를 호출해야만 데이터 정합성이 유지됐다.

여기서 트랜잭션 경계(하나로 묶여 함께 커밋되거나 함께 롤백되는 작업의 범위)를 생각하면 위험이 분명해진다. 서로 다른 API 호출은 각자 별도의 트랜잭션이다. 첫 번째 호출이 커밋된 뒤 두 번째 호출이 실패하면, 첫 번째 변경은 이미 DB에 남는다. 순서를 바꿔 호출해도 마찬가지로 중간 상태가 남는다. 정합성을 지키는 책임이 코드가 아니라 운영자의 호출 순서에 있었던 셈이다.

flowchart TD
    subgraph Before["기존: 화면 단위 API"]
        OP1["fa:fa-user 운영자"] --> S1["fa:fa-display 화면 A 저장 API"]
        OP1 --> S2["fa:fa-display 화면 B 저장 API"]
        S1 --> T1["트랜잭션 1 커밋"]
        S2 --> T2["트랜잭션 2 커밋"]
        T1 --> DB1[("fa:fa-database MySQL")]
        T2 --> DB1
        N1["호출 순서가 틀리거나<br/>두 번째가 실패하면 중간 상태가 남는다"]
    end
    subgraph After["재설계: 도메인 책임 단위 API"]
        OP2["fa:fa-user 운영자"] --> D1["fa:fa-file-contract 계약 도메인 API"]
        D1 --> SV["fa:fa-gears 도메인 서비스<br/>트랜잭션 경계 1개"]
        SV --> DB2[("fa:fa-database MySQL")]
    end

원인: API의 단위가 화면이었다

두 증상의 원인은 같았다. API를 나눈 기준이 데이터의 책임이 아니라 화면이었다.

화면 기준으로 API를 만들면 화면이 필요로 하는 데이터 묶음마다 조회가 생긴다. 화면끼리 데이터가 겹치면 조회도 겹친다. 변경도 마찬가지로 화면의 저장 버튼 단위로 만들어진다. 하나의 도메인 개념, 예를 들어 계약의 조건 변경이 여러 화면에 걸쳐 있으면 그 변경 로직도 여러 API에 나뉜다. 어느 API가 어떤 도메인 규칙을 책임지는지 코드에서 드러나지 않으니, 규칙을 지키는 방법이 “이 순서로 호출한다”는 운영 지식으로 남는다.

쿼리 하나하나를 빠르게 만들어도 같은 데이터를 여러 번 읽는 구조와, 호출 순서에 의존하는 변경은 그대로 남는다. 그래서 고칠 대상은 쿼리가 아니라 API의 단위였다.


결정: 도메인 책임으로 API를 나누고, 변경은 한 트랜잭션에 모은다

결정은 세 가지였다.

  1. 화면 중심 API를 도메인 책임 기반 REST API로 재설계한다. 앨범, 트랙, 계약, 지분율처럼 데이터를 책임지는 도메인이 API의 단위가 된다.
  2. 여러 API에 흩어진 변경 로직을 도메인 단위로 모은다. 하나의 업무상 변경은 하나의 서비스 메서드, 하나의 트랜잭션 안에서 끝나게 한다.
  3. 조회 구조와 쿼리를 함께 정리한다. 반복 조회를 없애려면 API 단위만 바꿔서는 안 되고, 그 API가 실행하는 쿼리도 같이 다시 봐야 한다.

화면이 도메인 API를 조합해서 쓰게 되므로 프론트엔드의 호출부도 바뀐다. 백엔드만의 결정으로 끝나지 않는 변경이어서, 배포 전략과 도메인 규칙을 내가 설계해 프론트엔드를 포함한 팀과 공유하고 협의하며 진행했다.


구현: 트랜잭션 경계를 서비스 메서드 하나로

재설계 후의 구조는 계층이 분명하다. Controller는 요청을 받아 서비스에 넘기고, 서비스 메서드 하나가 트랜잭션 경계가 되며, 도메인 객체가 규칙을 검증하고, 저장소가 영속화를 맡는다.

flowchart TD
    UI["fa:fa-display 운영 화면"] --> C["fa:fa-plug ContractController<br/>/api/v1/contracts"]
    C --> S["fa:fa-gears ContractService<br/>@Transactional 경계"]
    S --> D["fa:fa-file-contract Contract<br/>도메인 규칙 검증"]
    S --> R["fa:fa-box-archive ContractRepository"]
    R --> DB[("fa:fa-database MySQL")]

아래는 개인 랩(mcp-platform)에서 같은 구조를 다시 만든 코드다. 실제 회사 코드가 아니고, 랩은 계약 도메인 하나만 최소 범위로 둔다. Controller는 서비스만 호출한다.

1
2
3
4
5
6
7
8
// 랩 코드: contract/api/ContractController.java
@PostMapping("/api/v1/contracts")
@ResponseStatus(HttpStatus.CREATED)
public ContractResponse create(@Valid @RequestBody ContractCreateRequest request) {
    String contractCode = contractService.createContract(
            request.counterpartyName(), request.startsAt(), request.expiresAt());
    return new ContractResponse(contractCode);
}

트랜잭션 경계는 서비스 메서드 하나다. 계약 코드 확보, 도메인 객체 생성과 규칙 검증, 저장이 모두 이 메서드 안에서 함께 커밋되거나 함께 롤백된다.

1
2
3
4
5
6
7
8
9
10
// 랩 코드: contract/application/ContractService.java
@Transactional
public String createContract(String counterpartyName, LocalDate startsAt, LocalDate expiresAt) {
    AllocatedRange range = contractCodeAllocator.allocateBlock("CONTRACT_CODE", 1);
    String contractCode = "MCP-%d-%06d".formatted(Year.now().getValue(), range.rangeStart());

    Contract contract = Contract.create(contractCode, counterpartyName, startsAt, expiresAt);
    contractRepository.save(contract);
    return contract.contractCode();
}

기존 구조에서 운영자가 API A, B를 순서대로 호출해야 했던 변경은, 이 구조에서는 서비스 메서드 하나의 호출이 된다. 중간에 실패하면 전체가 롤백되므로 중간 상태가 남지 않는다. 순서를 지키는 책임이 운영자에게서 코드로 옮겨진 것이다.

한 가지 주의할 점이 있다. Spring의 @Transactional은 프록시를 통해 적용된다. Spring 문서는 “only external method calls coming in through the proxy are intercepted”라고 설명한다(프록시를 거쳐 들어오는 외부 호출만 가로챈다). 같은 클래스 안의 메서드가 다른 @Transactional 메서드를 직접 호출하면 새 트랜잭션 설정이 적용되지 않는다. 그래서 변경 로직을 모을 때 트랜잭션 경계는 Controller가 호출하는 서비스의 public 메서드 하나로 정하고, 그 안의 세부 단계는 같은 트랜잭션 안에서 실행되는 일반 메서드나 도메인 객체의 메서드로 두었다.

도메인 규칙의 검증 위치도 이 재설계와 함께 정리했다. 입력 형식은 요청 DTO에서, 다른 필드나 다른 엔티티 상태를 알아야 판단할 수 있는 규칙은 도메인 객체 안에서 최종 검증한다. 이 부분은 Map 기반 입력을 DTO로 바꾼 작업과 이어져 있어 다음 글에서 따로 다룬다.


조회 쪽 정리

API 단위를 바꾸는 것만으로 반복 조회가 사라지지는 않는다. 도메인 API가 화면의 필요를 한 번에 채울 수 있도록 조회 구조와 쿼리를 함께 정리했다.

구분기존재설계 후
API 단위화면도메인 책임 (앨범, 트랙, 계약, 지분율)
같은 데이터 조회화면 API마다 반복도메인 API 한 번
변경 로직 위치여러 API에 분산도메인 서비스 메서드 하나
트랜잭션 경계API 호출마다 따로업무상 변경 하나에 하나
정합성 유지 방법운영자의 호출 순서코드의 트랜잭션

조회 쪽은 이후 데이터 접근 구조 자체를 바꾸는 작업으로 이어졌다. 변경은 MyBatis에 두고 조회를 별도 모델로 분리한 과정은 이후 글에서 따로 정리한다.


결과

Datadog 대시보드에서 재설계 전후 응답 시간을 관찰한 결과, 재설계 대상 API의 응답 시간이 1.5초에서 300ms로 줄었다. 80% 단축이다. 중복 조회와 슬로우 쿼리가 해소됐고, 운영자가 특정 순서로 API를 호출해야 하는 의존성이 없어졌다. 변경 로직이 도메인 단위로 모이면서 새 기능을 추가하거나 규칙을 바꿀 때 어디를 고쳐야 하는지 찾기 쉬워졌다.


한계와 비용

이 결과를 읽을 때 알아야 할 조건이 있다.

  • 응답 시간의 집계 기준(평균, P95 등)은 기억나지 않는다. 같은 대시보드에서 전후 추이를 비교했다는 것까지만 말할 수 있다.
  • API 단위 변경과 쿼리 정리를 함께 적용했기 때문에, 1.5초에서 300ms로 줄어든 폭 중 어느 쪽이 얼마를 기여했는지는 이 측정으로 나눌 수 없다.
  • 화면이 호출하는 API가 바뀌므로 프론트엔드 변경이 함께 필요했다. 백엔드 단독으로 끝낼 수 있는 개선보다 일정 조율 비용이 컸다.
  • 개인 랩은 계약 도메인 하나와 레이어 구조만 재현한다. 화면 단위 API와 도메인 API의 응답 시간을 같은 조건에서 비교하는 실험은 랩에 아직 없다.

같은 시기에 진행한 입력 구조 개선은 Map 기반 입력을 DTO와 3단계 검증으로 바꾸기에서 이어서 정리한다.

참고한 자료외부 출처 4

외부 출처

데이터베이스 내부와 트랜잭션 Spring과 JVM 백엔드 아키텍처와 마이그레이션
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

변경이력

2번 수정

  1. docs(posts): separate the sections of every post with a thematic break
  2. docs(posts): write every reference entry as title, then publisher

댓글

아직 댓글이 없습니다