PK-only payload

Send identifiers, not state — let the server derive the rest from its source of truth.

Résumé bullet

Resolved HTTP 413 errors on mobile WMS scans without infra changes using PK-only payloads with fresh-state retrieval, handling 50 LOTs per batch (validated to 100) and keeping inventory accurate.

Deep-dive
  • Problem: batch save hit HTTP 413 past ~20 items
  • Refused easy fix: raising shared-server limit risks others
  • Root cause: client sent 10+ redundant columns per item
  • Second cause: scan-time data went stale before save
  • Fix: send PK only, server re-fetches fresh from DB
  • Result: ~95% smaller payload, 413s gone, 20->50
Code
// Before: client ships full UI state (10+ cols/item) -> 413 Payload Too Large
// POST /save  { lots: [{ plant, lot, qty, status, loc, ... }] }

// After: client ships only identifiers (PK only)
// POST /save  { lots: [{ plant, lot }] }

// Server derives the rest, fresh, at save time
List<Row> save(List<Key> keys) {
    List<Row> rows = jdbc.query(MASTER_QUERY, keys);   // indexed lookup, sub-ms
    return persist(rows);
}
Say it (English)
  • ·The fast fix was raising the server limit. I refused it.
  • ·It was shared infra. My fix would become everyone's risk.
  • ·I found two problems, not one: payload size and stale data.
  • ·Client now sends only primary keys. Server reads fresh from the DB.
  • ·Payload dropped ~95%, 413s stopped, batch went 20 to 50.
Why it matters — Opendoor / as an engineer
  • Opendoor: bulk actions on offers/listings send IDs; the server loads current state, not the client's snapshot.
  • Proves: API-contract judgment, source-of-truth over client state, frugality.
  • Use it when the client sends data the server already owns, or staleness comes up.
Trade-offs (what you give up)
  • Adds N extra indexed DB reads per save; sub-ms now but scales with batch size.
  • Server-derived master data silently overrides what the scanner saw; no diff surfaced to the user.
  • Couples save latency and correctness to MASTER_QUERY's index health; a missing index regresses every save.
  • Dormant old dispatch handler kept two weeks doubles the contract surface to test.
Push it further
  • Batch the per-item lookups into one WHERE (plant,lot) IN (...) query to avoid N round-trips.
  • Add idempotency: dedupe keys server-side and make persist upsert so retries are safe.
  • Send a scan-time hash/version per PK; reject or flag on optimistic-lock mismatch.
  • Contract test the PK-only schema in CI to catch clients regressing to full-state payloads.

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

바코드를 여러 개 찍어서 한 번에 저장하려는데, 20개가 넘으면 서버가 "너무 크다"며 거절했다. 손쉬운 방법 대신, 보낼 데이터를 확 줄였다.
  1. 1

    현장에서 항목을 여러 개 스캔해서 한 번에 저장한다. 그런데 20개쯤 넘으면 서버가 거부한다(HTTP 413, '요청이 너무 큼').

    // Batch save shipping full rows fails once the body grows past the server cap
    // POST /save  ->  HTTP 413 Payload Too Large
    List<LotDto> lots = grid.getRows();   // ~20+ items, each fat
    restClient.post("/save", new SaveRequest(lots));
    🗣 영어로 말해

    A batch save started failing once it had more than about twenty items.

    checking microphone…

  2. 2

    가장 쉬운 길은 서버의 크기 제한을 그냥 올리는 것이다. 근데 나는 안 했다.

    ⚖️ Trade-off: 그 서버는 여러 서비스가 같이 쓰는 공용이다. 내가 제한을 올리면 다른 서비스도 영향을 받는다(메모리 압박 등).

    ✅ Fix: 내 편의 때문에 남들 위험까지 떠안기는 건 아니다 → 제한을 안 올리고 다른 방법을 찾았다.

    🔧 도구:IIS maxRequestLength / maxAllowedContentLength

    <!-- web.config: the shared-server knob I refused to raise -->
    <httpRuntime maxRequestLength="4096" />
    <requestLimits maxAllowedContentLength="4194304" />
    <!-- raising this is global: every co-hosted service pays the memory cost -->
    🗣 영어로 말해

    The fast fix was raising the server limit, but I refused it.

    checking microphone…

  3. 3

    진짜 원인은 두 개였다. 하나: 항목 하나마다 화면에 있는 컬럼 10개 넘게 다 같이 보냄 → 그래서 요청이 너무 컸다.

    ⚖️ Trade-off: 화면 상태를 통째로 보내면 보내는 양이 항목 수만큼 커진다.

    // Bloated: one item carries 10+ columns of UI state
    class LotDto { String plant, lot; int qty; String status, loc, /* ...10+ */; }
    
    // Correct: ship only what identifies the row
    class LotKey { String plant; String lot; }
    🗣 영어로 말해

    First problem: the client sent ten-plus columns per item, so the payload was huge.

    checking microphone…

  4. 4

    둘: 스캔한 시점과 저장하는 시점 사이에 시간이 떠서, 화면에 들고 있던 값이 이미 옛날 값(stale)이 될 수 있었다.

    ⚖️ Trade-off: 화면 값을 그대로 저장하면 몇 시간 전 값을 저장할 수도 있다.

    🗣 영어로 말해

    Second problem: the scanned data could already be stale by save time.

    checking microphone…

  5. 5

    해결: 클라이언트는 식별자(기본키)만 보낸다. 예전엔 {plant, lot, qty, status, loc, ...} 다 보냈는데, 이제 {plant, lot}만 보낸다.

    ⚖️ Trade-off: 서버가 나머지 값을 직접 알아내야 한다 → 저장할 때 DB를 한 번 더 읽는 일이 생긴다.

    ✅ Fix: 그 컬럼들은 plant+lot로 색인된 조회라 1ms도 안 걸린다. 저장 자체가 50~100ms라 그 정도는 그냥 노이즈 수준 → 무시 가능.

    🔧 도구:primary key only payloadindexed lookup (plant + lot)WHERE (plant,lot) IN (...) 배치 조회

    -- Client sends primary keys only; server re-fetches the rest in one batch
    SELECT plant, lot, qty, status, loc
    FROM   lot
    WHERE  (plant, lot) IN ((:p1,:l1), (:p2,:l2), /* ... */);
    -- composite index on (plant, lot) -> sub-ms indexed lookup
    🗣 영어로 말해

    The client now sends only primary keys; the server reads the rest fresh from the DB.

    checking microphone…

  6. 6

    키만 받으면 서버가 그때그때 '진짜 최신 값'을 DB에서 다시 읽는다. 두 문제(크기 + 옛날 값)가 한 번에 풀린다.

    ⚖️ Trade-off: 스캐너가 본 값과 DB 값이 다르면, 서버 값으로 조용히 덮어쓴다. 사용자에겐 그 차이를 안 보여준다.

    ✅ Fix: 그 컬럼들은 시스템이 주인인 마스터 데이터라, 옛 스캔 값보다 새 DB 값이 더 맞다. 사람이 직접 입력한 칸은 그대로 보내서 안 건드린다.

    🔧 도구:source-of-truth re-fetchsystem-owned master data

    // Server derives everything from its source of truth at save time
    List<LotRow> save(List<LotKey> keys) {
        List<LotRow> fresh = lotRepository.findByKeys(keys); // fresh master data
        return lotRepository.persist(fresh);                 // never trust UI snapshot
    }
    🗣 영어로 말해

    The server re-derives the rest from its source of truth, so both problems vanish.

    checking microphone…

  7. 7

    결과: 보내는 양이 약 95% 줄고, 413 에러가 사라지고, 한 번에 저장 가능한 개수가 20개 → 50개로 늘었다.

    🗣 영어로 말해

    Payload dropped about ninety-five percent, the errors stopped, and the batch went from twenty to fifty.

    checking microphone…

  8. 8

    그래서 내 API 규칙이 생겼다: '클라이언트가 보내는 데이터를 서버가 스스로 알아낼 수 있나?' 그렇다면 설계가 틀린 거다.

    ⚖️ Trade-off: 롤백 대비도 했다: 옛 처리기를 2주간 꺼두지 않고 살려둬서, 문제 생기면 몇 분 만에 되돌릴 수 있게 했다.

    ✅ Fix: 단, 옛 처리기를 살려두면 테스트할 계약(contract)이 두 개로 늘어난다 → 2주만 두고 정리.

    🔧 도구:legacy dispatch handler (dormant fallback)contract test in CI

    // Keep the old handler dormant for fast rollback; a contract test pins the new shape
    @Test
    void saveAcceptsPkOnlyPayload() {
        var req = new SaveRequest(List.of(new LotKey("P1", "L1")));
        assertEquals(200, client.post("/save", req).status());
    }
    🗣 영어로 말해

    My rule now: if the server can derive what the client sends, the design is wrong.

    checking microphone…

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