0Pricing
NestJS Enterprise Backend APIs · 강의

계약 진화와 하위 호환성

기존 소비자를 중단하지 않고 프로토 스키마의 버전을 관리하고 필드 변경을 처리합니다.

계약 진화와 하위 호환성은(는) CoddyKit의 무료 NestJS Enterprise Backend APIs 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 NestJS Enterprise Backend APIs 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why Contract Evolution Matters

In a microservice mesh, a gRPC service contract is a shared API used by many independent consumers that deploy on their own schedule. You can almost never coordinate a simultaneous upgrade of every client and server.

The goal of contract evolution is to change a .proto schema so that:

  • Old clients keep working against a new server (backward compatibility).
  • New clients keep working against an old server (forward compatibility).

Protobuf was designed precisely for this. The wire format encodes fields by tag number, not by name, and unknown fields are tolerated rather than rejected. Master a few rules and you can evolve contracts for years without a breaking flag day.

The Golden Rule: Never Reuse Field Numbers

On the wire, every field is identified by its tag number plus a wire type. The field name is irrelevant once compiled. This single fact drives almost every evolution rule.

  • You may freely rename a field — the number is what travels.
  • You may add a new field with a fresh, never-before-used number.
  • You must never reuse a number that was previously assigned to a different field.

Reusing a number means an old client that still sends data under that tag will be silently misinterpreted by the new schema as a completely different field — corrupting data with no error.

// user.proto (v1)
syntax = "proto3";
package user.v1;

message User {
  string id = 1;
  string email = 2;
  string display_name = 3;
}

// Tag numbers 1,2,3 are now a permanent part of the contract.
// They may never be re-assigned to a different field.

Adding Fields Is Safe

The most common evolution is adding a field. Because proto3 has no required fields and every scalar has a default zero value, a new field is automatically safe in both directions:

  • An old client talking to a new server simply does not send the field — the server reads its default (empty string, 0, false).
  • A new client talking to an old server sends the field — the old server does not recognize the tag and stores it as an unknown field, preserving it on round-trips.

Always pick the next free tag number and document it. Treat display_name readers as if the field might be absent.

// user.proto (v2) — added phone_number, backward compatible
syntax = "proto3";
package user.v1;

message User {
  string id = 1;
  string email = 2;
  string display_name = 3;
  string phone_number = 4; // NEW: old clients omit it, default ""
}

Removing Fields: Reserve, Don't Delete

To remove a field, do not just delete its line. If a future developer adds a new field and accidentally picks the old number, you get silent corruption. Protobuf gives you the reserved keyword to permanently fence off retired tag numbers and names.

  • reserved 3; blocks the number from ever being reused.
  • reserved "display_name"; blocks the name so no new field can claim it.

The compiler will reject any future message that tries to reuse a reserved tag or name — turning a silent runtime bug into a build-time error.

// user.proto (v3) — display_name retired safely
syntax = "proto3";
package user.v1;

message User {
  string id = 1;
  string email = 2;
  reserved 3;                  // tag 3 can never be reused
  reserved "display_name";     // name can never be reused
  string phone_number = 4;
  string full_name = 5;        // replacement lives at a NEW number
}

proto3 Defaults and the Presence Problem

In proto3, scalar fields have no built-in concept of presence. A field equal to its default (0, "", false) is indistinguishable from a field that was never set. This matters during evolution: you cannot tell "client deliberately sent 0" from "old client that does not know this field."

When you genuinely need to distinguish absent from zero — for example a nullable balance or an optional toggle — use the optional keyword (restored in proto3.15) or a wrapper type. optional generates explicit has* presence semantics on the wire.

// Explicit presence so 'absent' != 'false'
syntax = "proto3";
package account.v1;

message AccountSettings {
  string account_id = 1;
  optional bool marketing_opt_in = 2; // hasMarketingOptIn() now exists
  optional int64 credit_limit = 3;    // distinguish unset from 0
}

Defensive Reads in the NestJS Service

Backward compatibility is not only a schema concern — your NestJS gRPC handlers must read defensively. Never assume a newly added field is populated by every caller. Use presence checks and explicit fallbacks rather than trusting non-empty values.

Below, the handler tolerates clients compiled against any schema version: old clients omit fullName, so we fall back; marketingOptIn uses presence semantics from the generated type.

import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';

interface UpdateUserRequest {
  id: string;
  fullName?: string;
  marketingOptIn?: boolean; // optional => may be undefined
}

@Controller()
export class UserController {
  @GrpcMethod('UserService', 'UpdateUser')
  updateUser(req: UpdateUserRequest) {
    // Old clients won't send fullName at all
    const name = req.fullName?.trim() || 'Anonymous';
    // Presence check: undefined means 'unset', not 'false'
    const optIn = req.marketingOptIn ?? false;
    return { id: req.id, fullName: name, marketingOptIn: optIn };
  }
}

Wire-Compatible Type Changes

Some scalar type changes are safe because they share the same wire encoding; others corrupt data. Knowing which is which lets you evolve a field's type without bumping its number.

  • Compatible: int32, uint32, int64, uint64, and bool are all varint-encoded and interchangeable (watch for truncation when narrowing 64-bit to 32-bit).
  • Compatible: string and bytes are interchangeable if the bytes are valid UTF-8.
  • NOT compatible: int32 ↔ fixed32, or any varint ↔ fixed-width change — different wire types, silent garbage.

When a change is not wire-compatible, the correct move is to add a new field at a new number and deprecate the old one.

// Safe: widening int32 -> int64 (both varint)
// BEFORE
message Order { int32 quantity = 1; }
// AFTER — old & new clients still interoperate
message Order { int64 quantity = 1; }

// UNSAFE: int32 -> fixed32 (varint vs 4-byte fixed)
// Don't do this; add a new field instead:
message Order { int64 quantity = 1; sfixed32 legacy_qty = 2; }

Enums: Always Keep a Zero Default

Enums evolve too. The cardinal rules:

  • The zero value must be an UNSPECIFIED / default member. Unknown enum values from newer clients are decoded by older proto3 code as their numeric value but conceptually fall back to handling-as-unknown.
  • Add new members with new numbers; never renumber existing ones.
  • Reserve removed enum numbers and names, exactly like message fields.

Because a new server may return an enum constant an old client has never heard of, clients must treat unrecognized enum values gracefully — typically by mapping them to the UNSPECIFIED branch rather than crashing.

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0; // mandatory safe default
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_SHIPPED = 2;
  ORDER_STATUS_DELIVERED = 3;
  // v2 adds a value old clients won't recognize:
  ORDER_STATUS_RETURNED = 4;
  reserved 5;                 // a removed status, fenced off
  reserved "ORDER_STATUS_LEGACY";
}

Evolving RPCs and Versioned Packages

You can freely add new RPC methods to a service — old clients ignore methods they do not call. But you must never change a method's request or response message type or its name; that breaks the generated stubs.

For breaking changes that cannot be done additively, version the package, not the field. Run user.v1 and user.v2 side by side so consumers migrate on their own timeline, then retire v1 once telemetry shows it is unused.

  • Additive change → same package, new field/method.
  • Breaking change → new versioned package, dual-run, deprecate.
// Additive: new method, old clients unaffected
service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc UpdateUser(UpdateUserRequest) returns (User);
  rpc SearchUsers(SearchUsersRequest) returns (stream User); // NEW
}

// Breaking reshape? Don't mutate v1 — introduce a new package:
// package user.v2;  (deployed alongside user.v1)

Demonstrating Tag-Based Decoding

To build intuition for why tag numbers (not names) define the contract, here is a tiny standalone simulation of protobuf-style decoding. The encoder stores values keyed by tag number; a renamed schema reading the same tags still resolves correctly, while a reused tag silently mismatches.

This is plain TypeScript modelling the principle — no gRPC runtime required.

// Wire = list of (tag, value). Names live only in the schema.
type Wire = Array<{ tag: number; value: string }>;

const encodeV1 = (email: string, displayName: string): Wire => [
  { tag: 2, value: email },
  { tag: 3, value: displayName },
];

// v2 schema RENAMED tag 3 to full_name (safe: same number)
const v2Schema: Record<number, string> = { 2: 'email', 3: 'full_name' };

function decode(wire: Wire, schema: Record<number, string>) {
  const out: Record<string, string> = {};
  for (const { tag, value } of wire) {
    const name = schema[tag] ?? `unknown_${tag}`;
    out[name] = value; // unknown tags preserved, not dropped
  }
  return out;
}

const wire = encodeV1('a@x.com', 'Ada Lovelace');
console.log(decode(wire, v2Schema));
// { email: 'a@x.com', full_name: 'Ada Lovelace' } — rename is transparent

A Workflow for Safe Evolution

Enterprise teams encode these rules into automated guardrails so a human never has to remember them all. A practical checklist:

  • Store .proto files in a central schema registry or shared repo as the single source of truth.
  • Run a breaking-change linter (e.g. buf breaking) in CI against the previous published schema — it rejects reused numbers, removed fields without reserved, and incompatible type changes.
  • Generate TypeScript stubs from the registry, never by hand.
  • Roll out server first, then clients — a backward-compatible server can serve old and new clients during the transition.

With these in place, additive evolution becomes routine and breaking changes become a deliberate, versioned event.

# CI gate: fail the build on any breaking proto change
buf lint
buf breaking --against '.git#branch=main'
# then regenerate typed stubs for NestJS
buf generate

Quick Check: Removing a Field

A teammate needs to remove the display_name field (tag 3) from a widely consumed User message, while many old clients still send it. What is the correct, safe approach?

Recap: Evolving Contracts Without Breaking Consumers

You now have the core toolkit for backward- and forward-compatible gRPC contracts:

  • Tag numbers are the contract — rename freely, never reuse a number.
  • Add fields at fresh numbers; proto3 defaults make them safe in both directions.
  • Remove via reserved on both the number and the name — never bare-delete.
  • Use optional when you must distinguish unset from default.
  • Read defensively in NestJS handlers: presence checks and fallbacks, never assume a field is populated.
  • Only some type changes are wire-compatible (varint family, string↔bytes); otherwise add a new field.
  • Keep a zero UNSPECIFIED enum and reserve removed members.
  • Add RPCs freely; for truly breaking changes, version the package and dual-run.
  • Enforce it all with a schema registry + breaking-change linter in CI.

Follow these and your services can evolve continuously, with consumers upgrading on their own schedule.

자주 묻는 질문

“계약 진화와 하위 호환성” 강의는 무료인가요?

네 — “계약 진화와 하위 호환성” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 NestJS Enterprise Backend APIs 강의 전체를 잠금 해제할 수 있습니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

“계약 진화와 하위 호환성”에서 뭘 배우나요?

기존 소비자를 중단하지 않고 프로토 스키마의 버전을 관리하고 필드 변경을 처리합니다. 브라우저에서 직접 실행하는 실습 코드로 NestJS Enterprise Backend APIs을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

NestJS Enterprise Backend APIs을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 NestJS Enterprise Backend APIs은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.

“계약 진화와 하위 호환성” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 NestJS Enterprise Backend APIs 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 NestJS Enterprise Backend APIs 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. Protobuf에서 서비스와 메시지 정의
  2. gRPC 메서드 구현과 사용
  3. 스트리밍 RPC와 백프레셔
  4. 계약 진화와 하위 호환성
← NestJS Enterprise Backend APIs(으)로 돌아가기