퀵스타트

API 키를 발급하고, 리소스를 등록하고, 컨텍스트에 가장 건강한 리소스를 확보하고, 결과를 보고하기까지 — 한 바퀴 전체.

1. 스택 띄우기

서비스는 docker compose 파일 하나입니다. 앱(REST 8083, gRPC 9093), PostgreSQL, 대시보드, 그리고 둘을 :8080 한 오리진으로 묶어 주는 Caddy 리버스 프록시로 구성됩니다. 첫 부팅에 Flyway 가 스키마를 마이그레이션하므로 별도 준비 단계는 없습니다.

bashclone, configure, run
git clone https://github.com/PreAgile/reputation-pool-cloud
cd reputation-pool-cloud
cp .env.example .env      # REPUTATION_POOL_API_KEY has no default — compose refuses to start without it
docker compose up --build -d

http://localhost:8080 을 열면 대시보드가 있습니다. REST 컨트롤플레인은 한 단계가 더 필요합니다 — .envREPUTATION_POOL_ADMIN_* 세 줄의 주석을 해제하세요. 이 값을 비워 두는 것은 의도된 기본 차단(fail closed) 입니다. 관리자 콘솔이 비활성으로 남고 모든 /api/** 호출이 거절되며, gRPC 데이터플레인은 그대로 동작합니다.

bash.env — enabling the control plane
REPUTATION_POOL_ADMIN_USERNAME=admin
REPUTATION_POOL_ADMIN_PASSWORD=change-me-local-dev
# HS256 needs a 256-bit key: anything shorter than 32 bytes fails fast at startup.
REPUTATION_POOL_ADMIN_JWT_SECRET=0123456789abcdef0123456789abcdef

# Then re-create the container. Not `docker compose restart` — that reuses the container
# with the environment it was created with, so the new variables would appear to do nothing.
docker compose up -d

이 페이지의 나머지가 쓰는 값들입니다.

bashthe addresses and credentials used throughout
export RP_ORIGIN=http://localhost:8080     # control plane + dashboard, one origin (Caddy)
export RP_GRPC=localhost:9093              # data plane — loopback only, by design
export RP_TENANT=default                   # the tenant compose seeds on startup
export RP_API_KEY=local-dev-key            # REPUTATION_POOL_API_KEY from your .env

2. API 키 받기

이미 하나 있습니다. 앱은 시작할 때 REPUTATION_POOL_API_KEYdefault 테넌트의 활성 키로 심어 두므로, $RP_API_KEY 로 gRPC 호출이 바로 인증됩니다. 이 변수를 바꾸고 재시작하면 같은 단계에서 기존 키가 폐기됩니다 — 이 부트스트랩 키 하나에 대해서는 환경변수가 단일 출처입니다.

첫 실행을 넘어가면 워커마다 키를 따로 두고 싶어지고, 그 키는 컨트롤플레인에서 나옵니다. 먼저 로그인해 토큰을 받으세요. 토큰은 expiresInSeconds 뒤에 만료됩니다(기본 한 시간).

bashPOST /api/auth/login
curl -sS -X POST "$RP_ORIGIN/api/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"change-me-local-dev"}'

# {"token":"eyJhbGciOiJIUzI1NiJ9…","tokenType":"Bearer","expiresInSeconds":3600}
export RP_JWT=eyJhbGciOiJIUzI1NiJ9…

이제 키를 발급합니다. 응답의 rawToken 은 키 원문을 볼 수 있는 유일한 순간입니다 — 암호화가 아니라 해시로 저장되므로 나중에 다시 읽어낼 수 없습니다. 곧바로 비밀 저장소에 넣으세요.

bashPOST /api/tenants/{tenantId}/api-keys
curl -sS -X POST "$RP_ORIGIN/api/tenants/$RP_TENANT/api-keys" \
  -H "Authorization: Bearer $RP_JWT" \
  -H 'Content-Type: application/json' \
  -d '{"label":"worker-01"}'

# 201 Created
# {
#   "id": "5f1c…-…",
#   "rawToken": "rp_9Q3xK7bT…",       <-- shown once, never again
#   "label": "worker-01",
#   "prefix": "rp_9Q3xK7bT",          <-- what listings show
#   "createdAt": "2026-07-29T09:12:44Z"
# }
export RP_API_KEY=rp_9Q3xK7bT…

대시보드의 API 키 화면도 버튼 하나로 같은 일을 합니다. 어느 쪽이든 경로의 tenantId 는 토큰이 묶여 있는 테넌트여야 합니다 — 다른 테넌트를 향한 키 발급 요청은 그 테넌트가 존재하든 아니든 403 으로 거절됩니다.

3. 리소스 등록하기

리소스는 kind(PROXY, ACCOUNT, SESSION)와 여러분이 정하는 불투명한 value 의 조합입니다 — 프록시 엔드포인트, 계정 id, 세션 핸들 같은 것. 등록은 멱등이므로 워커가 부팅할 때마다 다시 등록하는 것이 정상적인 패턴입니다.

데이터플레인은 gRPC 라서 아래 요청은 curl 이 아니라 grpcurl 을 씁니다. 서버 리플렉션이 켜져 있어 grpcurl 이 서비스를 스스로 찾아냅니다 — 받아 올 .proto 파일이 없습니다. 인터셉터가 리플렉션도 함께 지키므로 목록을 보는 데도 키가 필요합니다.

bashcheck you can reach the data plane
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" "$RP_GRPC" list

# io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor   <-- plus health and reflection

여기서 -plaintext 는 편법이 아니라 맞는 선택입니다. 포트가 loopback 에 있고 앞에 TLS 가 없습니다. 실제로 TLS 종단을 붙였을 때만 이 플래그를 빼세요.

bashReputationAdvisor/Register
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
  -d '{"resource":{"kind":"PROXY","value":"proxy-1.example.net:8080"}}' \
  "$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Register

# {}   <-- RegisterResponse is empty; the call succeeding is the result

4. 컨텍스트에 맞는 리소스 확보하기

컨텍스트는 지금 하려는 일에 이름을 붙인 문자열입니다 — checkout-us, search-eu 처럼, 리소스를 독립적으로 태울 수 있는 목적지나 워크로드마다 하나씩. 이 값을 Acquire 에 넘기면 풀이 그 컨텍스트에서 가장 건강한 리소스를 골라, 여러분에게만 배타적으로 리스해 줍니다.

bashReputationAdvisor/Acquire
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
  -d '{"context":{"value":"checkout-us"}}' \
  "$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Acquire

# {
#   "granted": true,
#   "lease": {
#     "resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
#     "context":  {"value":"checkout-us"},
#     "token":    "1",                      <-- int64 is a string in proto3 JSON
#     "leasedAt":  "2026-07-29T09:13:02Z",
#     "expiresAt": "2026-07-29T09:13:32Z"   <-- 30s lease TTL by default
#   }
# }

5. 리소스를 쓰고, 결과를 보고하기

lease.resource 로 할 일을 하고, 무슨 일이 있었는지 풀에 알려 주세요. 평판을 움직이는 것은 이 호출입니다. 성공은 점수를 조금 올리고, 실패는 실패 유형에 따라 정해진 만큼 점수를 내리며, 임계치를 넘으면 그 리소스를 쿨다운 동안 빼 둡니다.

bashReputationAdvisor/Report — success
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
  -d '{
        "resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
        "context":  {"value":"checkout-us"},
        "outcome":  {"success":{"latency":"0.412s"}}
      }' \
  "$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Report
bashReputationAdvisor/Report — failure
# type: CONNECTION_RESET | TLS_HANDSHAKE | TIMEOUT | BLOCKED | SLOW
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
  -d '{
        "resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
        "context":  {"value":"checkout-us"},
        "outcome":  {"failure":{"type":"BLOCKED","latency":"1.2s"}}
      }' \
  "$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Report

실패 유형은 정직하게 고르세요 — 장식이 아닙니다. BLOCKED 는 점수 30 점과 한 시간 쿨다운을, SLOW 는 2 점과 30 초를 뜻합니다. 전부 BLOCKED 로 보고하면 풀이 비어 버립니다. 정확한 수치는 핵심 개념에 있습니다.

마지막으로 Release 로 리소스를 돌려주면 다른 워커가 가져갈 수 있습니다. 반납은 정확성을 위해 필수는 아닙니다 — 리스는 TTL 이 지나면 스스로 만료됩니다 — 하지만 제때 반납하는 것이 풀을 TTL 만료를 기다리는 상태가 아니라 일하는 상태로 유지합니다. 작업이 TTL 보다 길어지면 Renew 를 부르세요.

bashReputationAdvisor/Release
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
  -d '{"lease":{"resource":{"kind":"PROXY","value":"proxy-1.example.net:8080"},
                "context":{"value":"checkout-us"},"token":"1",
                "leasedAt":"2026-07-29T09:13:02Z","expiresAt":"2026-07-29T09:13:32Z"}}' \
  "$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Release

# {"released": true}

같은 루프, Java 로

생성된 스텁은 퍼블리시된 io.github.preagile:reputation-pool-grpc 아티팩트에서 오므로 여러분 쪽에 codegen 단계가 없습니다. 와이어 타입이 한 겉 클래스(AdvisorProto) 아래에 중첩된 것은 의도적입니다 — 단순 이름이 엔진의 도메인 타입과 충돌하기 때문입니다.

javaWorker.java
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.AcquireRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.AcquireResponse;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.Context;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.FailureType;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.LeaseHandle;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.Outcome;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.RegisterRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ReleaseRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ReportRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ResourceId;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ResourceKind;
import io.github.preagile.reputationpool.grpc.v1.ReputationAdvisorGrpc;
import io.grpc.ChannelCredentials;
import io.grpc.Grpc;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.stub.MetadataUtils;

final class Worker {

    private final ReputationAdvisorGrpc.ReputationAdvisorBlockingStub advisor;

    /**
     * Loopback data plane: InsecureChannelCredentials. Behind a TLS terminator this becomes
     * TlsChannelCredentials.create() and nothing else in this class changes.
     */
    Worker(String target, String apiKey) {
        var headers = new Metadata();
        headers.put(Metadata.Key.of("x-api-key", Metadata.ASCII_STRING_MARSHALLER), apiKey);
        ChannelCredentials credentials = InsecureChannelCredentials.create();
        ManagedChannel channel = Grpc.newChannelBuilder(target, credentials).build();
        this.advisor = ReputationAdvisorGrpc.newBlockingStub(channel)
                .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(headers));
    }

    void runOnce() {
        var proxy = ResourceId.newBuilder()
                .setKind(ResourceKind.PROXY)
                .setValue("proxy-1.example.net:8080")
                .build();
        var context = Context.newBuilder().setValue("checkout-us").build();

        advisor.register(RegisterRequest.newBuilder().setResource(proxy).build()); // idempotent

        AcquireResponse acquired = advisor.acquire(AcquireRequest.newBuilder().setContext(context).build());
        if (!acquired.getGranted()) {
            return; // nothing eligible right now — back off and retry
        }
        LeaseHandle lease = acquired.getLease();

        long startedAt = System.nanoTime();
        try {
            useProxy(lease.getResource().getValue()); // your work
            report(lease, Outcome.newBuilder()
                    .setSuccess(Outcome.Success.newBuilder().setLatency(elapsed(startedAt)))
                    .build());
        } catch (java.io.IOException failed) {
            report(lease, Outcome.newBuilder()
                    .setFailure(Outcome.Failure.newBuilder()
                            .setType(FailureType.CONNECTION_RESET)
                            .setLatency(elapsed(startedAt)))
                    .build());
        } finally {
            advisor.release(ReleaseRequest.newBuilder().setLease(lease).build());
        }
    }

    private void report(LeaseHandle lease, Outcome outcome) {
        advisor.report(ReportRequest.newBuilder()
                .setResource(lease.getResource())
                .setContext(lease.getContext())
                .setOutcome(outcome)
                .build());
    }

    /** google.protobuf.Duration from a nanosecond stopwatch reading. */
    private static com.google.protobuf.Duration elapsed(long startedAtNanos) {
        long nanos = System.nanoTime() - startedAtNanos;
        return com.google.protobuf.Duration.newBuilder()
                .setSeconds(nanos / 1_000_000_000L)
                .setNanos((int) (nanos % 1_000_000_000L))
                .build();
    }
}

같은 루프, TypeScript 로

퍼블리시된 JS 클라이언트는 아직 없으므로 @grpc/proto-loader 로 런타임에 advisor.proto 를 읽어 씁니다. keepCase: false 로 두면 위 JSON 예제와 같은 lowerCamelCase 필드 이름을 쓸 수 있습니다. proto 파일은 퍼블리시된 reputation-pool-grpc 아티팩트 안에 들어 있고 원본은 엔진 레포에 있습니다.

typescriptworker.ts
import { credentials, loadPackageDefinition, Metadata, type ServiceClientConstructor } from "@grpc/grpc-js";
import { loadSync } from "@grpc/proto-loader";

// proto-loader hands back a plain object nested by proto package segment, typed only as GrpcObject.
// Declare the one path you use rather than casting the whole tree away — the cast is what makes a
// snippet like this stop compiling the moment you touch it.
interface AdvisorPackage {
  io: {
    github: {
      preagile: {
        reputationpool: { grpc: { v1: { ReputationAdvisor: ServiceClientConstructor } } };
      };
    };
  };
}

/** Your work. Replace this with the request you actually make through the leased resource. */
declare function useProxy(endpoint: string): Promise<void>;

const definition = loadSync("advisor.proto", { keepCase: false, defaults: true, longs: String });
const advisorPackage = loadPackageDefinition(definition) as unknown as AdvisorPackage;
const { ReputationAdvisor } = advisorPackage.io.github.preagile.reputationpool.grpc.v1;

// Loopback data plane: plaintext. Point this at a TLS-terminating endpoint and it becomes
// credentials.createSsl() — nothing else in this file changes.
const advisor = new ReputationAdvisor(process.env.RP_GRPC ?? "localhost:9093", credentials.createInsecure());

const auth = new Metadata();
auth.set("x-api-key", process.env.RP_API_KEY ?? "");

const call = <T>(method: string, request: unknown): Promise<T> =>
  new Promise((resolve, reject) => {
    advisor[method](request, auth, (error: Error | null, response: T) =>
      error === null ? resolve(response) : reject(error),
    );
  });

// google.protobuf.Duration accepts the "12.345s" JSON form.
const seconds = (ms: number): string => (ms / 1000).toFixed(3) + "s";

interface LeaseHandle {
  token: string;
  leasedAt: string;
  expiresAt: string;
}

export async function runOnce(): Promise<void> {
  const resource = { kind: "PROXY", value: "proxy-1.example.net:8080" };
  const context = { value: "checkout-us" };

  await call("Register", { resource }); // idempotent

  const acquired = await call<{ granted: boolean; lease?: LeaseHandle }>("Acquire", { context });
  if (!acquired.granted || acquired.lease === undefined) {
    return; // nothing eligible right now — back off and retry
  }
  const lease = acquired.lease;

  const startedAt = Date.now();
  try {
    await useProxy(resource.value);
    await call("Report", {
      resource,
      context,
      outcome: { success: { latency: seconds(Date.now() - startedAt) } },
    });
  } catch {
    await call("Report", {
      resource,
      context,
      outcome: { failure: { type: "TIMEOUT", latency: seconds(Date.now() - startedAt) } },
    });
  } finally {
    await call("Release", { lease: { resource, context, ...lease } });
  }
}

6. 반영됐는지 확인하기

컨트롤플레인으로 풀을 되읽어 보세요. 등록한 리소스가 보이고, 보고한 컨텍스트마다 셀이 하나씩 있고, 이벤트 피드에 리스와 쿨다운이 찍혀 있어야 합니다.

bashGET /api/pools/resources and GET /api/events
curl -sS "$RP_ORIGIN/api/pools/resources" -H "Authorization: Bearer $RP_JWT"
curl -sS "$RP_ORIGIN/api/events?limit=10" -H "Authorization: Bearer $RP_JWT"
  • resources 가 비어 있나요? Register 호출이 지금 읽고 있는 테넌트에 닿지 않았습니다 — API 키와 JWT 가 같은 테넌트에 속하는지 확인하세요.
  • 행에 contexts: 0 이 보이나요? 등록은 했지만 보고를 한 번도 하지 않은 것입니다. 셀은 Acquire 가 아니라 Report 가 만듭니다.
  • 모든 REST 호출이 401 인가요? 토큰이 만료됐거나(다시 로그인하세요) 관리자 환경변수 세 개가 설정되지 않아 콘솔이 통째로 비활성입니다. 전체 에러 표는 REST API 레퍼런스에 있습니다.
  • grpcurl 이 UNAVAILABLE 을 주나요? 9093 에서 아무것도 듣고 있지 않습니다. docker compose ps 를 확인하세요. 앱은 그 포트를 127.0.0.1 에만 공개하므로 다른 머신에서는 의도적으로 닿지 않습니다.

호스티드 데이터플레인에서 달라지는 것

루프에서는 아무것도 달라지지 않습니다. RPC, 메시지 모양, API 키 헤더, 평판 모델은 모두 엔진의 것이고 이 서비스는 엔진을 fork 하지 않고 퍼블리시된 의존성으로 소비합니다 — 두 곳에서 같은 코드가 돕니다. 워커를 여러분의 스택에서 호스티드로 옮기는 일은 두 군데를 고치는 것입니다.

  • 대상 주소 — localhost:9093 이 호스티드 엔드포인트로 바뀝니다.
  • 채널 자격증명 — -plaintext / InsecureChannelCredentials / credentials.createInsecure() 가 각각의 TLS 짝으로 바뀝니다.

없는 것은 그 엔드포인트 자체입니다. 호스티드 데이터플레인의 호스트명·포트·TLS 계약이 아직 공개된 바 없고, 이 페이지는 그것을 지어내지 않습니다. 그게 도입을 막는 부분이라면 [email protected] 로 알려 주세요 — 알려진 공백이고, 누가 필요한지가 언제 만들지를 정합니다. 평판 상태도 아직 배포 사이를 옮겨가지 않습니다. 풀은 실제 트래픽으로 다시 예열됩니다.

이 페이지의 모든 내용은 PreAgile/reputation-pool-cloud 의 배포 파일 — compose.yaml, compose.prod.yaml, Caddyfile.prod — 에서 나왔습니다. 포트 바인딩에 대한 설명을 그냥 믿는 대신 직접 확인하고 싶다면 그곳을 보세요.