본문으로 건너뛰기
Forward Engineering
Go back

코어를 SaaS에서 떼어냈더니 경계가 하나 더 필요했다 — JDK-only 엔진과 thin host 설계

- views

Table of contents

Open Table of contents

0. 시작 — 복사해야만 재사용할 수 있다면 경계가 잘못된 것이다

먼저 이 프로젝트가 무엇을 하는지부터 설명해야 합니다.

크롤러는 요청에 사용할 프록시나 계정 같은 리소스를 여러 개 가지고 있습니다. 모든 리소스가 항상 건강한 것은 아닙니다. 어떤 프록시는 차단됐고, 어떤 계정은 잠시 쉬어야 하며, 어떤 리소스는 다른 작업자가 이미 사용 중일 수 있습니다.

reputation-pool은 이런 리소스의 성공·실패 기록을 바탕으로 상태를 평가하고, 지금 빌려줘도 되는 리소스 하나를 선택하는 Java 엔진입니다. 저장소 안에서는 역할을 다시 둘로 나눴습니다.

구성 요소하는 일
reputation-pool-core점수 계산, 차단 여부, 리소스 선택, lease처럼 “무엇을 결정할지” 담당합니다.
reference server공개된 core를 네트워크 너머에서 직접 호출해볼 수 있게 실행하는 최소 서버입니다.

여기서 lease는 리소스를 영구히 넘기는 것이 아니라 “이 작업자가 이 시각까지 사용해도 된다”고 부여하는 임시 사용권입니다. 동시에 여러 작업자가 같은 프록시를 집어 가는 일을 막고, 작업자가 응답 없이 사라져도 만료 뒤 다시 빌려줄 수 있게 합니다.

공개 reference server는 gRPC라는 통신 방식을 사용했습니다. 그래서 다른 프로그램은 Java 객체를 직접 만지지 않아도 네트워크 요청으로 다음 기능을 호출할 수 있었습니다.

이후 같은 엔진을 여러 고객에게 서비스하는 reputation-pool-cloud를 만들었습니다. cloud도 위 기능은 같았지만, 실행 환경과 책임은 달랐습니다.

두 서버가 주고받는 요청과 응답의 모양은 advisor.proto라는 파일에 정의했습니다. REST API의 명세서가 “이 주소에는 이 필드로 요청하고 이런 응답을 받는다”고 약속하듯, .proto 파일은 gRPC API의 서비스 이름, 기능과 필드를 정하는 기계가 읽을 수 있는 계약서입니다.

예를 들어 “리소스 하나를 빌려달라”는 기능이 있다면 계약서에는 요청에 어떤 정보가 들어가고, 응답에 선택된 리소스와 lease 정보가 어떻게 담기는지가 정의됩니다. gRPC 도구는 이 계약서를 읽어 서버와 클라이언트가 사용할 코드를 생성합니다.

문제는 cloud의 첫 구현에서 시작됐습니다. 빠르게 동작을 확인하려고 reference server의 계약서와 처리 코드를 cloud 저장소에 복사했습니다.

공개 reference server
└─ reputation-pool-server/advisor.proto

SaaS cloud
└─ reputation-pool-cloud/advisor.proto  ← 같은 계약서를 복사

겉으로는 두 서버가 같은 API를 제공했습니다. 하지만 이제 계약서의 원본이 두 개였습니다. 공개 서버의 응답에 lease_expires_at 필드를 추가해도 cloud의 복사본은 자동으로 바뀌지 않습니다. 같은 실패를 한쪽은 gRPC 오류로 보내고 다른 쪽은 빈 응답으로 보낼 수도 있습니다. 시간이 지날수록 이름은 같은 API인데 서버에 따라 요청 형식이나 동작이 달라지는 상태가 됩니다. 이것을 이 글에서는 계약 드리프트(contract drift)라고 부르겠습니다.

“cloud도 reputation-pool-core를 사용하니 중복이 아니다”라고 말할 수도 없었습니다. 두 서버가 공유한 것은 점수와 선택을 판단하는 엔진뿐이었습니다. 네트워크 요청을 core의 입력으로 바꾸고, 결과를 다시 응답으로 만드는 공식 gRPC 어댑터는 공유하지 않았습니다.

결국 첫 구현을 버리고 경계를 다시 그렸습니다. 해결하려던 질문은 단순했습니다.

core의 판단 규칙뿐 아니라, 그 판단 규칙을 외부에 노출하는 API 계약과 변환 코드도 두 서버가 하나만 공유하게 만들 수 없을까?

gRPC와 advisor.proto는 실제로 어떻게 연결되는가 (펼치기)

일반적인 REST API에서는 사람이 경로와 JSON 형식을 정하고 서버와 클라이언트 코드를 각각 작성합니다. gRPC에서는 먼저 .proto 파일에 호출할 기능과 메시지 구조를 적습니다.

아래 코드는 실제 파일을 단순화한 예시입니다.

service ReputationAdvisor {
  rpc Acquire(AcquireRequest) returns (AcquireResponse);
}

message AcquireRequest {
  string pool_id = 1;
}

message AcquireResponse {
  string resource_id = 1;
  string lease_id = 2;
}

Acquire는 원격 서버의 기능을 호출하는 RPC입니다. 클라이언트는 pool_id를 보내고, 서버는 선택된 resource_id와 임시 사용권인 lease_id를 돌려줍니다.

gRPC 도구는 이 파일에서 Java 인터페이스와 메시지 클래스를 생성합니다. 그래서 .proto가 달라진다는 것은 문서 한 장만 달라지는 일이 아닙니다. 클라이언트가 보내는 데이터, 서버가 읽는 데이터와 생성되는 코드가 함께 달라지는 일입니다.

이 프로젝트의 handler는 생성된 gRPC 요청을 core의 Java 값으로 변환하고, core를 호출한 뒤 결과를 다시 gRPC 응답으로 변환합니다. 따라서 두 host가 정말 같은 API를 제공하려면 .proto뿐 아니라 이 변환과 처리 규칙도 함께 공유해야 합니다.

계약 드리프트는 실제로 어떤 장애를 만드는가 (펼치기)

계약 드리프트는 같은 API의 복사본들이 따로 수정되면서 서로 다른 약속이 되는 현상입니다.

예를 들어 공개 서버가 lease 만료 시각을 새 필드로 제공하도록 바뀌었다고 가정해보겠습니다. cloud의 .proto가 예전 상태라면 cloud 사용자는 그 값을 받을 수 없습니다. 더 위험한 경우는 필드 이름은 같지만 의미가 달라지는 상황입니다. 한 서버는 만료 시각을 UTC로 보내고 다른 서버는 로컬 시각으로 보내면 컴파일과 요청은 성공해도 잘못된 만료 판단이 발생할 수 있습니다.

처리 코드도 드리프트할 수 있습니다.

  • 존재하지 않는 리소스를 한 서버는 NOT_FOUND로, 다른 서버는 빈 성공 응답으로 반환합니다.
  • 이벤트 구독을 한 서버는 정상 종료하지만, 다른 서버는 연결을 계속 유지합니다.
  • 새 필드를 core 값으로 옮기는 mapping이 한쪽에만 반영됩니다.

이 문제의 핵심은 “복사한 코드가 보기 싫다”가 아닙니다. 같은 API를 사용한 클라이언트가 어느 host에 연결했는지에 따라 다른 결과를 받는 것입니다. 그래서 계약과 변환 코드는 문서 복사본이 아니라, 두 서버가 함께 의존하는 하나의 배포 가능한 모듈이어야 했습니다.


1. 왜 코어를 JDK-only로 분리했나

reputation-pool-core가 답하는 질문은 좁습니다.

현재 상태와 이번 결과, 현재 시각이 주어졌을 때 이 리소스의 다음 상태와 대여 가능 여부는 무엇인가?

코어에는 ReputationEngine, ResourcePool, LeaseRegistry, SelectionStrategy와 불변 도메인 값이 있습니다. 반면 다음 질문에는 답하지 않습니다.

이 질문들을 제외한 이유는 중요하지 않아서가 아닙니다. 변경되는 이유가 다르기 때문입니다.

평판 계산식은 실패 유형과 회복 정책이 바뀔 때 수정됩니다. 인증 코드는 키 관리 정책이 바뀔 때 수정되고, persistence는 DB schema와 장애 복구 요구가 바뀔 때 수정됩니다. 이들을 한 모듈에 두면 SaaS 요구사항 하나가 판단 엔진의 배포와 검증 범위까지 흔듭니다.

그래서 core의 main runtime은 JDK API만 사용합니다. Spring, gRPC, JDBC, JSON 라이브러리를 모릅니다.

flowchart LR
    Input["Outcome + Current State + Now"] --> Core["reputation-pool-core<br/>JDK-only decision engine"]
    Core --> Decision["New State + Lease Decision + Event"]

    Spring["Spring Boot"] -. "알지 못함" .-> Core
    DB["PostgreSQL"] -. "알지 못함" .-> Core
    Auth["API Key / Tenant"] -. "알지 못함" .-> Core

여기서 JDK-only는 “외부 라이브러리는 나쁘다”는 선언이 아닙니다. 코어가 실행 환경에 관해 알아야 할 내용을 의도적으로 제한한 아키텍처 제약입니다.

JDK-only와 zero runtime dependency는 정확히 무엇인가 (펼치기)

JDK-only는 코어의 실제 실행 코드가 Java 표준 라이브러리만 사용한다는 뜻입니다. 예를 들어 java.time.Clock, java.util.concurrent.ConcurrentHashMap, java.util.random.RandomGenerator는 사용할 수 있지만 Spring의 @Service, Jackson의 ObjectMapper, JDBC 구현체에는 의존하지 않습니다.

그렇다고 테스트에서도 외부 도구를 쓰지 않는다는 뜻은 아닙니다. 이 프로젝트는 테스트에서 JUnit, jqwik, AssertJ, ArchUnit, Lincheck와 PIT를 사용합니다. 이 도구들은 코어 jar를 사용하는 애플리케이션의 runtime classpath에는 포함되지 않습니다.

애플리케이션 runtime
└─ reputation-pool-core
   └─ JDK

core 테스트 실행
├─ JUnit
├─ jqwik
├─ Lincheck
├─ ArchUnit
└─ PIT

이 제약으로 얻고 싶었던 것은 jar 크기 자체가 아니었습니다.

  1. Spring이 없는 CLI와 다른 JVM host에서도 같은 판단 로직을 사용합니다.
  2. 현재 시각과 난수를 주입해 같은 입력을 반복 검증할 수 있습니다.
  3. 프레임워크 upgrade와 도메인 정책 변경의 배포 이유를 분리합니다.
  4. core API에 DataSource, HTTP request, tenant session 같은 host 개념이 새어 들어오는 것을 막습니다.

반대로 모든 프로젝트에 필요한 제약은 아닙니다. 한 애플리케이션에서만 쓰이고 도메인과 framework 수명이 동일하다면 모듈 분리 비용이 더 클 수 있습니다. 이 프로젝트에서는 공개 라이브러리, reference server, SaaS라는 세 소비자가 생겼기 때문에 비용을 감수할 이유가 있었습니다.


2. 처음 그린 경계 — core와 server만으로는 부족했다

초기 공개 저장소는 역할을 다음처럼 나눴습니다.

모듈책임
reputation-pool-core판단, 선택, lease, blocklist, event 생성
reputation-pool-persistencesnapshot과 audit event의 PostgreSQL 저장
reputation-pool-servergRPC 서버 실행과 전체 조립

문제는 reputation-pool-server가 두 역할을 동시에 가지고 있었다는 점입니다.

  1. 실행 가능한 애플리케이션을 시작하는 composition root
  2. 모든 JVM host가 재사용해야 할 gRPC 계약과 adapter

서버를 실행하는 방식은 host마다 달라도 됩니다. reference server는 raw gRPC ServerBuilder를 사용할 수 있고, cloud는 Spring의 @GrpcService로 등록할 수 있습니다.

하지만 다음 코드는 host마다 달라질 이유가 없었습니다.

이 코드가 server 실행 파일 안에 있으니 cloud가 의존할 수 없었습니다. 실행 애플리케이션 전체를 라이브러리처럼 가져오는 것도 잘못이고, 필요한 클래스만 복사하는 것도 잘못이었습니다.

composition root와 adapter는 어떻게 다른가 (펼치기)

Adapter는 서로 다른 표현을 연결합니다. 이 프로젝트의 gRPC adapter는 protobuf 요청을 core의 ResourceId, Context, Outcome으로 바꾸고, core의 결과를 다시 protobuf 응답으로 바꿉니다.

Composition root는 애플리케이션 시작 지점에서 실제 구현들을 선택하고 연결합니다.

Clock은 system UTC를 사용한다
ResourceStore는 PostgreSQL 구현을 사용한다
EventSink는 audit + metrics + broadcaster를 묶는다
gRPC service를 9093 port에 등록한다

adapter의 변환 규칙은 여러 host가 공유할 수 있습니다. 반면 composition root는 배포 환경에 따라 달라집니다. Spring Boot는 객체를 bean으로 조립하고 lifecycle을 관리하지만, 작은 reference server는 이를 직접 생성할 수 있습니다.

초기 구조에서는 이 두 역할이 reputation-pool-server에 함께 있었습니다. cloud가 필요했던 것은 adapter였지만, 가져올 수 있는 단위는 실행 서버였습니다. 이것이 중간 모듈을 추출하게 된 이유입니다.


3. 경계를 다시 그리다 — reputation-pool-grpc 추출

PR #66에서 reputation-pool-grpc 모듈을 새로 만들었습니다.

옮긴 대상은 다음과 같습니다.

그 뒤 의존성 방향은 다음처럼 바뀌었습니다.

flowchart TB
    Core["reputation-pool-core<br/>도메인 판단"]
    Persistence["reputation-pool-persistence<br/>PostgreSQL adapter"]
    Grpc["reputation-pool-grpc<br/>proto + mapping + handler"]
    Server["reputation-pool-server<br/>reference composition root"]
    Cloud["reputation-pool-cloud<br/>SaaS composition root"]

    Grpc --> Core
    Persistence --> Core
    Server --> Core
    Server --> Persistence
    Server --> Grpc
    Cloud --> Core
    Cloud --> Persistence
    Cloud --> Grpc

중요한 점은 화살표가 반대로 향하지 않는다는 것입니다.

reputation-pool-grpcReputationAdvisorService는 특정 framework annotation을 가지지 않습니다. cloud는 이를 상속하고 @GrpcService로 등록합니다.

@GrpcService
public class ReputationAdvisorService
    extends io.github.preagile.reputationpool.grpc.ReputationAdvisorService {

    private final TenantPoolRegistry registry;

    @Override
    protected ResourcePool pool() {
        String tenantId = TenantContext.TENANT_ID.get();
        return registry.poolFor(tenantId);
    }
}

공통 base는 decode → core 호출 → encode를 담당합니다. cloud가 추가하는 핵심은 인증된 요청을 어느 tenant pool로 보낼지 결정하는 일입니다.

이 추출 뒤 reference server도 기존 내부 복사본을 제거하고 같은 공개 모듈을 소비했습니다. 한쪽만 새 모듈을 사용했다면 여전히 두 계약이 남았을 것입니다.


4. 무엇을 core에 넣고 무엇을 host에 남겼나

경계를 정할 때 “재사용할 것인가”만 묻지 않았습니다. 재사용 가능해 보여도 core가 책임져서는 안 되는 기능이 있습니다.

다음 네 질문을 사용했습니다.

  1. 이 규칙이 없으면 도메인 불변식이 깨지는가?
  2. 실행 환경이 바뀌어도 같은 규칙이어야 하는가?
  3. 판단에 필요한 입력으로 표현할 수 있는가?
  4. 특정 고객·배포·판매 정책 때문에 바뀌는가?

그 결과는 다음과 같습니다.

기능위치이유
점수와 cooldown 계산core실행 환경과 무관한 판단 규칙
lease 배타성과 fencingcore같은 리소스의 중복 대여를 막는 불변식
후보 선택 전략core의 interface와 기본 구현판단 과정의 교체 가능한 정책
상태 저장 계약core의 portcore가 필요로 하지만 구현 방식은 모름
PostgreSQL snapshot/auditpersistence adapterDB와 schema에 종속
protobuf 변환과 RPC handlergRPC adapter전송 형식에 종속되지만 host 간 공유
API key 인증cloudSaaS 접근 정책
tenant별 pool routingcloud고객 격리와 상품 구조
사용량 집계와 제한cloud운영·과금 정책
dashboard·alert·backupcloud서비스 운영 책임

여기서 port를 core에 둔 것은 persistence를 core에 넣었다는 의미가 아닙니다. core는 “snapshot을 저장하고 불러올 수 있어야 한다”는 필요만 interface로 표현합니다. PostgreSQL을 사용할지 파일을 사용할지는 바깥 adapter가 결정합니다.

port와 adapter, 의존성 역전은 무엇인가 (펼치기)

코어가 PostgreSQL에 직접 저장한다면 다음과 같은 방향이 됩니다.

core → JDBC → PostgreSQL

이 구조에서는 DB가 없는 환경에서 core를 사용하기 어렵고, 저장 기술을 바꾸면 core도 수정해야 합니다.

대신 core 안에 필요한 동작만 interface로 둡니다.

public interface ResourceStore {
    Optional<PoolSnapshot> load();
    void save(PoolSnapshot snapshot);
}

그리고 바깥 모듈이 이를 구현합니다.

core ← ResourceStore 계약 ← PostgresResourceStore

소스 코드의 import 방향을 보면 persistence module이 core를 의존합니다. 하지만 실행 중에는 core가 ResourceStore를 호출합니다. 세부 기술이 핵심 정책을 향해 의존하도록 방향을 뒤집었기 때문에 의존성 역전이라고 부릅니다.

  • port: core가 필요로 하는 동작의 경계
  • adapter: 그 경계를 특정 기술로 구현하거나 외부 표현과 연결하는 코드
  • host: 실제 adapter들을 선택해 실행 가능한 서비스로 조립하는 애플리케이션

5. thin host는 코드가 적다는 뜻이 아니었다

“cloud는 thin host로 만든다”고 하면 생성자 몇 줄만 있는 애플리케이션을 떠올리기 쉽습니다. 실제 reputation-pool-cloud에는 인증, tenant lifecycle, metering, metrics, alert, dashboard, backup과 운영 제어 코드가 있습니다.

그렇다면 더 이상 thin host가 아닌 것일까요?

이 프로젝트에서 thin의 기준은 코드 줄 수가 아닙니다.

core가 이미 내린 도메인 결정을 cloud가 다시 구현하지 않는다.

cloud의 EngineConfiguration은 공개 artifact를 Spring bean으로 조립합니다.

@Bean
Function<String, ResourceStore> resourceStoreFactory(
        DataSource dataSource,
        Clock clock
) {
    return tenantId ->
        new PostgresResourceStore(dataSource, clock, tenantId);
}

ApiKeyAuthInterceptor는 API key를 tenant로 해석해 gRPC Context에 넣습니다. gRPC service는 그 tenant의 ResourcePool을 찾습니다. 이후 점수 계산과 lease 판단은 다시 core가 담당합니다.

sequenceDiagram
    participant Client
    participant Auth as "Cloud Auth Interceptor"
    participant Host as "Cloud gRPC Host"
    participant Core as "JDK-only ResourcePool"
    participant DB as "PostgreSQL Adapter"

    Client->>Auth: "x-api-key + Acquire"
    Auth->>Auth: "API key → tenant"
    Auth->>Host: "tenant context와 요청 전달"
    Host->>Core: "tenant의 pool.acquire(context)"
    Core-->>Host: "lease 또는 empty"
    Host-->>Client: "gRPC response"
    Host->>DB: "주기적 tenant snapshot"

인증 실패와 DB 장애를 판단 엔진에 알려 “점수를 낮출지” 묻지 않습니다. 반대로 core의 cooldown 공식을 cloud controller에서 다시 계산하지 않습니다. 각 복잡성이 자기 이유로 존재하는 상태가 thin host의 의미였습니다.


6. SaaS가 반드시 소유해야 했던 책임

core를 재사용한다고 해서 SaaS의 책임까지 공개 라이브러리에 밀어 넣을 수는 없습니다.

6.1 인증과 tenant 식별

API key가 없거나 유효하지 않으면 UNAUTHENTICATED로 요청을 거절합니다. 유효한 key는 tenant ID로 변환되어 gRPC Context에 저장됩니다.

core에는 tenantId가 없습니다. core 관점에서는 자신이 하나의 독립된 pool을 관리할 뿐입니다. 어느 pool을 선택할지는 host의 책임입니다.

6.2 tenant별 상태와 lifecycle

cloud는 tenant마다 ResourcePool과 tenant namespace를 가진 PostgresResourceStore를 만듭니다. 시작할 때 복원하고, 주기적으로 checkpoint하며, 종료 전에 마지막 snapshot을 저장합니다.

이 lifecycle은 순수 판단 규칙이 아닙니다. process 시작과 종료, DB 장애, 운영 주기에 관한 정책이므로 host에 남겼습니다.

6.3 공용 JVM의 자원 보호

여러 tenant pool이 같은 process heap을 사용하므로 한 tenant가 resource와 reputation cell을 계속 늘리면 전체 서비스가 영향을 받습니다. cloud는 registerreport 앞에서 process 전체 budget을 검사합니다.

이는 core의 “resource를 등록할 수 있는가”라는 도메인 규칙이 아니라, 현재 SaaS 배포 구조에서 공유 메모리를 보호하는 운영 규칙입니다.

6.4 관측성과 판매 기능

Prometheus metric, webhook alert, 사용량 집계, dashboard, admin JWT와 API key lifecycle은 SaaS가 소유합니다. OSS 사용자가 반드시 같은 운영 stack과 판매 정책을 받아들일 이유가 없습니다.


7. 공유 모듈을 추출해 실제로 확인한 것

모듈을 나눈 뒤 “컴파일된다”에서 검증을 끝내지 않았습니다.

검증 시나리오와 관찰 결과를 다음처럼 남겼습니다.

검증 시나리오확인한 결과이 검증이 필요했던 이유
공개 저장소 전체 buildserver가 내부 복사본 없이 새 gRPC 모듈을 사용compile dependency와 source 이동 누락 확인
ProtoMapping mutation test25개 mutation 중 생존 0개mapping test가 단순 실행만 하는지 확인
Maven publication dry rungRPC artifact와 dependency가 publication 대상에 포함로컬 project dependency로만 우연히 동작하는 상태 방지
cloud Docker round tripRegister → Acquire → granted: true실제 Spring 등록·gRPC runtime·DB 조립 확인
인증 포함 container 호출key 없음은 UNAUTHENTICATED, 유효 key는 grantinterceptor가 실제 RPC 앞에서 동작하는지 확인

cloud Docker round trip은 다음 경로로 실행했습니다.

Docker PostgreSQL 기동
→ cloud application 기동
→ 공유 gRPC service 등록 확인
→ Register RPC
→ Acquire RPC
→ granted: true

뒤이은 인증 PR에서는 실제 container에 key 없이 호출하면 UNAUTHENTICATED, 올바른 key를 넣고 Register → Acquire하면 grant되는 경로를 확인했습니다.

이 검증이 중요했던 이유는 모듈 경계가 Gradle dependency graph에서만 맞고 runtime에서는 깨질 수 있기 때문입니다. 실제로 gRPC와 protobuf는 host가 가져오는 transitive dependency의 version이 어긋나면 NoClassDefFoundErrorAbstractMethodError가 발생할 수 있습니다. 현재 cloud는 gRPC BOM으로 관련 모듈을 같은 version에 맞추고, 공개 artifact 0.5.0을 Maven Central에서 소비합니다.


8. 경계를 나눈 대가

좋아진 점만 기록하면 다음 설계에서 같은 판단을 재사용하기 어렵습니다.

8.1 release 순서가 생겼다

여기서 공유 계약은 reference server와 cloud가 함께 지켜야 하는 약속을 뜻합니다. 구체적으로는 advisor.proto에 정의한 gRPC 요청·응답 구조, protobuf와 core 값을 오가는 mapping, 두 서버가 공통으로 사용하는 handler와 확장 지점입니다. 이 중 하나를 바꾸면 두 서버에 같은 변경이 적용되어야 합니다.

이 공유 코드는 reputation-pool-grpc라는 artifact로 배포합니다. artifact는 다른 프로젝트가 내려받아 사용할 수 있도록 빌드하고 version을 붙인 결과물입니다. 이 프로젝트에서는 Maven Central에 공개되는 Java jar 파일이 artifact입니다. cloud는 소스코드를 복사하는 대신 Gradle에 사용할 version을 적어 이 jar를 dependency로 가져옵니다. 이것을 “cloud가 새 version을 소비한다”고 표현했습니다.

따라서 공유 계약을 변경해 cloud에 반영하는 과정에는 다음 순서가 생겼습니다.

  1. 공개 저장소에서 advisor.proto, mapping과 handler를 수정하고 호환성을 검증합니다.
  2. 변경된 reputation-pool-grpc jar에 새 version을 붙여 Maven Central에 release합니다.
  3. cloud의 Gradle 설정에서 dependency version을 올리고, 인증과 tenant routing을 포함한 전체 흐름을 다시 검증합니다.

한 저장소에서 계약과 구현을 동시에 고치던 때보다 느립니다. 새 jar가 공개되기 전에는 cloud가 그 API를 사용할 수 없기 때문입니다.

하지만 이 순서는 호환성을 확인하게 만드는 통과 조건이기도 합니다. cloud에서만 급히 필요한 기능 때문에 공개 API를 바로 깨뜨리는 대신, reference server와 기존 사용자도 계속 사용할 수 있는 변경인지 먼저 확인하게 됐습니다.

8.2 framework 중립 API에도 확장 지점이 필요했다

초기 gRPC base service는 하나의 pool만 받았습니다. 멀티테넌시가 생기면서 cloud는 호출마다 다른 pool을 선택해야 했고, pool() hook이 필요해졌습니다. tenant별 event stream을 위해 subscriptionPoolId() hook도 공개 모듈에 추가했습니다.

framework를 모른다고 해서 host 요구를 전혀 모르는 API가 되는 것은 아닙니다. 여러 host가 공유할 수 있는 최소 확장 지점을 공개 계약으로 설계해야 했습니다.

8.3 cloud에서 일부 변환이 중복됐다

공용 JVM budget을 적용하려면 core에 위임하기 전에 요청이 새 resource나 cell을 만들지 확인해야 합니다. 그런데 ProtoMapping의 일부 decode 기능은 외부 subclass에서 사용할 수 없었습니다. cloud service에는 budget 검사에 필요한 최소 decode가 중복됐습니다.

이것은 현재 경계의 마찰입니다. 곧바로 모든 mapping을 public으로 열기보다 다음을 관찰해야 합니다.

한 번의 중복만으로 추상화를 넓히지 않았습니다. 반복되는 두 번째 사용 사례가 생길 때 공개 seam을 다시 설계할 수 있습니다.

8.4 “JDK-only”가 목적이 되면 잘못된 추상화가 생긴다

표준 라이브러리만 쓴다는 목표를 지키려고 필요한 adapter까지 직접 구현한다면 오히려 유지보수 비용이 커집니다. 그래서 gRPC와 PostgreSQL 코드는 각각 별도 공개 모듈에서 검증된 라이브러리를 사용합니다.

순수해야 하는 것은 판단 엔진의 경계입니다. 시스템 전체가 외부 의존성 없이 동작해야 한다는 뜻은 아닙니다.


9. 자가진단 체크리스트와 의사결정 매트릭스

기존 애플리케이션에서 core와 host의 경계를 나눌 때 다음 순서로 확인할 수 있습니다.

  1. 같은 판단 규칙이 두 controller나 두 서비스에 복사되어 있는지 찾습니다.
  2. 그 규칙의 입력에 HTTP request, DB connection, framework context가 섞여 있는지 확인합니다.
  3. 현재 시각, 난수, 외부 상태를 값이나 interface로 주입할 수 있는지 확인합니다.
  4. 다른 host가 재사용해야 할 adapter가 실행 애플리케이션 안에 갇혀 있는지 찾습니다.
  5. 모듈을 나눈 뒤 source import 방향이 core를 향하는지 확인합니다.
  6. 컴파일뿐 아니라 실제 transport와 저장소를 통과하는 round trip을 검증합니다.
  7. 새 추상화 때문에 release 순서와 호환성 비용이 얼마나 늘어나는지 기록합니다.

의사결정 매트릭스

상황권장 경계
한 서비스만 사용하고 framework와 도메인의 수명이 같다먼저 package 경계로 충분한지 검토
같은 판단 로직을 batch, server, SaaS가 함께 사용framework 중립 core 모듈 검토
transport 변환이 여러 host에서 반복됨공유 adapter 모듈 추출
인증·과금·tenant 정책이 고객별로 바뀜SaaS host에 유지
DB 종류가 달라도 같은 상태 계약을 사용core에 port, 외부에 persistence adapter
첫 사용 사례에서 한 줄이 중복됨성급한 public abstraction보다 중복 관찰
여러 host에서 같은 확장 요구가 반복됨공유 모듈에 최소 hook 또는 port 추가

10. 한계 — 현재 경계가 영구적인 답은 아니다

현재 구조는 한 JVM 안에 tenant별 ResourcePool을 두고 cloud가 routing합니다. 따라서 다음 변화가 생기면 경계를 다시 검토해야 합니다.

특히 수평 확장에서는 JDK 내부 ConcurrentHashMap의 원자성이 process 밖으로 이어지지 않습니다. 이 문제를 cloud에 lock 몇 줄 추가하는 방식으로 덮으면 배타성 계약이 host마다 달라집니다. 분산 lease가 core 계약인지 별도 coordination adapter의 책임인지 다시 정해야 합니다.

따라서 현재 모듈 구조를 “완성된 클린 아키텍처”라고 부르지 않습니다. 지금 확인된 세 소비자와 단일 process 배포에서 변화의 이유를 가장 잘 분리한 현재의 답입니다.


11. FAQ

Q. Spring Boot를 core에서 사용하면 개발 속도가 더 빠르지 않나요?

A. 한 애플리케이션만 만들 때는 그럴 수 있습니다. 이 프로젝트에는 공개 라이브러리, reference server, SaaS라는 서로 다른 소비자가 있습니다. core에서 Spring type을 사용하면 모든 소비자가 같은 framework 수명주기와 dependency를 받아들여야 합니다. 대신 cloud의 조립과 운영 기능에서는 Spring Boot를 적극적으로 사용합니다.

Q. zero dependency가 성능을 위한 선택인가요?

A. 직접적인 성능 최적화가 목적은 아닙니다. 실행 환경과 판단 규칙을 분리하고, framework 없이 검증하고, 여러 host에서 재사용하기 위한 제약입니다. 이 글에서는 dependency 제거 전후의 성능을 측정하지 않았으므로 더 빠르다고 주장하지 않습니다.

Q. gRPC 모듈도 외부 dependency가 많은데 공개 core 원칙과 충돌하지 않나요?

A. 충돌하지 않습니다. JDK-only 제약은 판단 엔진인 reputation-pool-core의 runtime 경계에 적용합니다. gRPC adapter가 protobuf와 grpc-java를 사용하는 것은 역할에 필요한 의존성입니다. 중요한 것은 그 dependency가 core 안쪽으로 역류하지 않는 것입니다.

Q. thin host인데 cloud 코드가 많은 것은 설계 실패 아닌가요?

A. 먼저 이 글에서 host는 core 라이브러리를 불러와 실제 애플리케이션으로 실행하는 바깥 프로그램을 뜻합니다. 공개 reference server도 하나의 host이고, Spring Boot 기반 cloud도 또 다른 host입니다. core가 “어떤 리소스를 빌려줄지” 결정한다면, host는 요청을 받고 core를 호출하며 결과를 네트워크 응답으로 돌려줍니다.

Thin host는 이 바깥 프로그램의 코드가 적다는 뜻이 아닙니다. core가 이미 내리는 도메인 결정을 host가 중복해서 구현하지 않는다는 뜻입니다.

cloud에는 사용자 인증, tenant lifecycle, 사용량 측정, 관측성, 대시보드와 배포처럼 SaaS가 반드시 소유해야 할 코드가 많습니다. 이 코드는 reference server에는 필요하지 않으므로 cloud에 있는 것이 맞습니다. 설계 실패는 cloud의 전체 코드량이 많은 것이 아니라, cloud가 점수 계산이나 lease 판단까지 다시 구현해 같은 입력에 공개 core와 다른 결과를 만드는 경우입니다.

Q. 처음부터 gRPC 모듈을 예상하지 못한 것이 문제 아닌가요?

A. 첫 구조가 부족했던 것은 맞습니다. 다만 실제 두 번째 host가 생기기 전에 모든 adapter를 미리 분리했다면 사용되지 않는 추상화를 만들 가능성도 있었습니다. cloud에서 실제 복사가 발생한 시점에 공유 대상과 host별 차이가 구체적으로 드러났고, 그 근거로 모듈을 추출했습니다.

Q. 공개 core와 상용 cloud의 기준은 어떻게 정했나요?

A. 리소스 평판을 판단하고 안전하게 빌려주는 일반 문제와 여러 host가 재사용할 계약·adapter는 공개 영역에 둡니다. 특정 SaaS의 tenant 운영, 인증, dashboard, metering, 배포와 판매 기능은 cloud에 둡니다. 단순히 수익이 될 것 같은 기능을 닫는 기준이 아니라, 일반화 가능한 엔진과 특정 서비스 운영 책임의 차이를 기준으로 삼았습니다.


12. 마치며 — 얇게 만든 것은 코드가 아니라 변경의 연결이었다

처음에는 core만 분리하면 SaaS가 자연스럽게 얇아질 것이라고 생각했습니다. 실제로는 transport adapter라는 두 번째 경계가 필요했습니다.

reference server 안에 갇힌 gRPC 코드를 cloud가 복사했던 일은 경계가 잘못됐다는 가장 구체적인 신호였습니다. 이를 공개 모듈로 옮기자 server와 cloud가 하나의 wire 계약을 공유하게 됐고, cloud에는 tenant routing과 운영 정책만 남길 수 있었습니다.

그렇다고 cloud가 작은 애플리케이션이 된 것은 아닙니다. 인증, 격리, 저장, checkpoint, metering, alert, dashboard와 배포는 여전히 복잡합니다. 이 복잡성은 SaaS가 책임져야 합니다.

분리로 얻은 것은 코드 수의 감소보다 다음 관계였습니다.

평판 판단을 바꿔야 하는 이유와 SaaS 운영을 바꿔야 하는 이유가 서로의 내부 구현까지 끌고 가지 않는다.

그리고 이 경계도 완성본은 아닙니다. 멀티테넌시가 pool()과 event subscription hook을 요구했고, 공용 자원 budget은 사전 검사 seam의 부족함을 드러냈습니다. 다음 요구가 생기면 다시 경계를 조정해야 합니다.

좋은 모듈 경계는 처음부터 모든 미래를 맞히는 선이 아니었습니다. 복사가 시작되는 지점, 변경 이유가 섞이는 지점, host마다 계약이 달라지는 지점을 발견했을 때 근거를 가지고 다시 그을 수 있는 선에 가까웠습니다.


References

프로젝트 설계와 구현

공식 개념 자료


Share this post on:

Previous Post
데이터를 나눴는데 장애는 함께 났다 — 멀티테넌트 격리와 noisy neighbor 대응
Next Post
32스레드 테스트는 통과했지만 명세가 틀렸다 — Lincheck가 동시성 계약을 세 번 기각한 과정