Thin RPC gateway

Make the middle layer a thin hop, not a boundary that breaks on every upstream change.

Résumé bullet

Built a reverse proxy with a reflection-based RPC dispatcher consolidating 40 middleware endpoints into one, deployed across 8 sites — freeing 1 FTE of middleware development by eliminating redeployments.

Deep-dive
  • Problem: 1:1 API mirroring — every legacy change forced middleware redeploy
  • Concern: change isolation, only 3 engineers, didn't scale
  • Fix: thin gateway — frontend passes namespace/class/method as params
  • Fix: legacy parses + dispatches via reflection; end-to-end JSON, no transform
  • Result: 30 mirrored endpoints became 1 forwarder; zero incidents in 12mo
  • Result: stayed at 3 engineers, avoided a multi-month 4th-hire approval
Code
// thin gateway: forward by plant code, no business logic
public Object route(String plantCode, Payload body) {
    return legacy.forward(body);
}

// legacy WMS: parse params, dispatch by reflection
Object dispatch(String pkg, String cls, String method, Object[] args) throws Exception {
    Class<?> target = Class.forName(pkg + "." + cls);    // reflection
    Method m = target.getMethod(method, typesOf(args));
    return m.invoke(beanFor(target), args);                    // ~1-2ms/call
}
// one forwarding endpoint, not 30 mirrored ones;
// dispatch lives in legacy: only it can reach the DB
Say it (English)
  • ·The senior mapped 30 endpoints 1-to-1; every legacy change broke the middleware.
  • ·My concern was change isolation — with 3 engineers it wouldn't scale.
  • ·I proposed a thin gateway: middleware just forwards, the legacy parses and dispatches with reflection.
  • ·His expertise still mattered — I gave him gateway logging and monitoring.
  • ·Result: zero incidents in 12 months, stayed at 3 engineers, no 4th hire.
Why it matters — Opendoor / as an engineer
  • Opendoor: a thin edge/forwarding layer that doesn't break on every upstream change.
  • Proves: gateway/dispatcher design, change isolation, small-team leverage.
  • Use it for API-gateway / BFF design.
Trade-offs (what you give up)
  • Client now names legacy class/method directly — the frontend is coupled to legacy internals, so renames/refactors break callers silently.
  • No transform layer means no contract: a changed arg order or type surfaces as a runtime 500, not a compile error.
  • Reflection dispatch keyed on client-supplied strings leans on network perimeter for safety; one allowlist gap exposes arbitrary method invocation.
  • One forwarder for all calls hides per-endpoint visibility — metrics, rate limits, and auth become coarse unless you parse the payload back open.
Push it further
  • Add consumer-driven contract tests (Pact) so legacy method/arg changes fail CI instead of in production.
  • Whitelist dispatchable namespaces/classes in a registry, not raw reflection — cuts the arbitrary-invocation surface and enables per-method authz.
  • Cache resolved MethodInfo/handles per namespace+class+method to drop reflection lookup cost under load.
  • Emit per-method metrics by tagging spans with namespace.class.method, so the single endpoint still gives per-call latency and error rates.

Understand it step by step (한국어로 이해 → 영어로 말하기)

중간 서버가 옛 시스템의 기능 하나하나를 똑같이 따라 만들었다. 옛 시스템이 바뀔 때마다 중간 서버도 같이 고쳐야 했다. 사람이 3명뿐이라 이걸 줄이는 게 목표였다.
  1. 1

    앱(폰) → 중간 서버 → 옛 창고 시스템, 이렇게 3단 구조다. 옛 시스템만 DB에 닿을 수 있다.

    // 3-tier: app -> middleware -> legacy WMS (only legacy reaches the DB)
    public Object route(String plantCode, Payload body) {
        return legacy.forward(body);   // dispatch + DB access live in the legacy
    }
    🗣 영어로 말해

    The app calls a middle server, which calls the old warehouse system.

    checking microphone…

  2. 2

    선배는 옛 시스템 기능 30개를 중간 서버에 똑같이 30개로 다시 만들었다. 한 개에 하나씩 짝지은 거울 구조다.

    ⚖️ Trade-off: 옛 시스템에서 하나만 바뀌어도 중간 서버의 짝꿍도 매번 같이 고쳐야 한다.

    🔧 도구:1:1 API mapping

    // 1:1 mirroring: one middleware endpoint per legacy method
    Object getLot(args)    { return legacy.getLot(args); }
    Object saveLot(args)   { return legacy.saveLot(args); }
    Object closeOrder(args){ return legacy.closeOrder(args); }
    // every legacy change forces a matching middleware redeploy
    🗣 영어로 말해

    The senior mirrored 30 endpoints one-to-one, so every change broke the middle layer.

    checking microphone…

  3. 3

    사람이 3명뿐인데 옛 시스템이 바뀔 때마다 중간 서버를 또 배포해야 한다. 일이 끝없이 늘어나서 감당이 안 된다.

    ⚖️ Trade-off: 거울 구조는 변경이 한쪽에서 다른 쪽으로 다 번진다. 격리가 안 된다.

    ✅ Fix: 그래서 중간 서버가 '내용을 아는' 구조 자체를 버리기로 했다.

    🗣 영어로 말해

    With only three engineers, redeploying on every change just doesn't scale.

    checking microphone…

  4. 4

    해결: 중간 서버는 그냥 통과만 시킨다. 앱이 '어느 클래스, 어느 함수를 불러줘'를 글자로 같이 보낸다.

    ✅ Fix: 중간 서버는 내용을 안 봐도 된다. 공장 코드만 보고 옛 시스템으로 그대로 넘긴다.

    🔧 도구:thin gateway / pass-through forwarder

    // thin forwarder: middleware passes the call through, no per-method logic
    Object route(String plantCode, Payload p) {
      return legacy.forward(plantCode, p); // app names what to call inside p
    }
    🗣 영어로 말해

    I made the middle layer a thin forwarder — the app just names what to call.

    checking microphone…

  5. 5

    옛 시스템이 그 글자(클래스/함수 이름)를 읽고, 리플렉션으로 그때그때 맞는 함수를 찾아서 실행한다. 끝까지 JSON 그대로라 변환이 없다.

    ⚖️ Trade-off: 이제 앱이 옛 시스템 내부 함수 이름을 직접 부른다 → 옛 시스템이 이름을 바꾸면 앱이 조용히 깨진다. 중간에 변환층이 없으니 인자 순서·타입이 틀리면 컴파일 에러가 아니라 실행 중 500 에러로 터진다.

    ✅ Fix: 거울 30개가 통과 1개로 줄었다. 옛 시스템이 바뀌어도 중간 서버는 손 안 댄다.

    🔧 도구:reflection dispatchend-to-end JSON

    // legacy WMS: parse params, dispatch dynamically by reflection
    Object dispatch(String namespace, String cls, String method, Object[] args) {
      Object target = load(namespace, cls);            // reflection lookup
      Method m = target.getClass().getMethod(method);
      return m.invoke(target, args);                    // ~1-2ms/call, JSON in/out
    }
    🗣 영어로 말해

    The old system reads those names and dispatches by reflection — end-to-end JSON, no transform.

    checking microphone…

  6. 6

    위험: 함수 이름을 앱이 글자로 정해서 보내니, 막으면 아무 함수나 부를 수 있는 구멍이 된다.

    ⚖️ Trade-off: 글자로 함수를 부르는 건 공격에 약하다. 허용 목록 한 칸만 빠져도 위험하다.

    ✅ Fix: 닫힌 망 + IP 허용 목록으로 막고, 게이트웨이 인증을 한 겹 더 뒀다. 한 겹이 뚫려도 다른 겹이 막는 식(다중 방어).

    🔧 도구:IP allowlistclosed networkgateway authentication

    // defense in depth: closed network + IP allowlist + gateway auth
    if (!ipAllowlist.contains(remoteIp)) return reject(403);
    if (!gatewayAuth.verify(token))      return reject(401);
    return dispatch(namespace, cls, method, args);
    🗣 영어로 말해

    The risk is exposing internal methods, so I added a closed network plus gateway auth — defense in depth.

    checking microphone…

  7. 7

    통과 하나로 합치니 함수별로 따로 보던 지표·속도·에러를 못 본다. 그래서 선배에게 게이트웨이 로깅·모니터링을 맡겼다.

    ⚖️ Trade-off: 통로가 하나라 어느 함수가 느린지·터졌는지 따로 안 보인다.

    ✅ Fix: 선배의 강점을 살려 로깅·모니터링을 맡겼다. 그의 전문성도 그대로 쓰였다.

    🔧 도구:span tags: namespace.class.method

    // tag spans so the single forwarder still gives per-method visibility
    span.setTag("rpc", namespace + "." + cls + "." + method);
    log.info("dispatch {}.{}.{} latencyMs={}", namespace, cls, method, ms);
    🗣 영어로 말해

    His expertise still mattered — I gave him the gateway's logging and monitoring.

    checking microphone…

  8. 8

    결과: 30개가 통과 1개로 줄었고, 12개월간 사고 0건. 사람도 3명 그대로, 4번째 채용을 안 해도 됐다.

    🗣 영어로 말해

    Result: zero incidents in twelve months, stayed at three engineers, no fourth hire.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.