Node.js Backend Development Bootcamp · 강의

Proto 진화와 하위 호환성

필드 번호 규칙과 예약 필드를 사용하여 서비스 계약을 안전하게 발전시키는 방법을 배웁니다.

레슨 4/413개 단계

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

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

Why Proto Evolution Matters

In a gRPC microservice world, your .proto files are a contract shared between independently deployed services. A producer service and its many consumers rarely upgrade at the exact same moment.

During a rolling deploy you will have old clients talking to new servers and new clients talking to old servers simultaneously. Evolution rules exist so neither side crashes or silently corrupts data.

  • Backward compatible: new code can read data written by old code.
  • Forward compatible: old code can read data written by new code (Protobuf gives this for free if you follow the rules).

Break the rules and you get mismatched fields, lost data, or deserialization that maps bytes to the wrong field.

Field Numbers Are the Real Identity

The single most important idea: on the wire, Protobuf does not send field names. It sends field numbers. The name email is just a label for humans and generated code.

Each encoded field is a tag built from (field_number << 3) | wire_type followed by its value. That means:

  • Renaming a field is harmless on the wire (number unchanged).
  • Changing a field's number is a breaking change — old data for number 2 will be read as a different field.
// Decode a Protobuf tag byte to see how (number, wireType) are packed.
function decodeTag(tagByte) {
  const fieldNumber = tagByte >> 3;
  const wireType = tagByte & 0x07;
  return { fieldNumber, wireType };
}

// 0x12 = 18 = (2 << 3) | 2  => field 2, length-delimited (string/bytes)
console.log(decodeTag(0x12)); // { fieldNumber: 2, wireType: 2 }
// 0x08 = 8 = (1 << 3) | 0   => field 1, varint (int/bool/enum)
console.log(decodeTag(0x08)); // { fieldNumber: 1, wireType: 0 }

A Starting Contract

Here is a v1 message for a user service. Notice every field has an explicit, stable number. In a Node.js backend you typically load this with @grpc/proto-loader and @grpc/grpc-js.

The golden rule going forward: once a field number ships to production, it is permanent. Treat numbers like database primary keys — never reuse, never repurpose.

// user.proto (v1)
// syntax = "proto3";
// package user.v1;
//
// message User {
//   string id    = 1;
//   string name  = 2;
//   string email = 3;
// }

// Loading it in Node.js:
const protoLoader = require('@grpc/proto-loader');
const grpc = require('@grpc/grpc-js');

const pkgDef = protoLoader.loadSync('user.proto', {
  keepCase: true,
  longs: String,
  defaults: true,
});
const userProto = grpc.loadPackageDefinition(pkgDef).user.v1;

Adding Fields Safely

The safest evolution is adding a new field with a brand-new number. Old clients simply ignore tags they do not recognize, and new clients reading old data see the field's default value.

  • In proto3, an unset string defaults to "", numbers to 0, bools to false.
  • So always design new fields so that the default value is a safe, meaningful state.

Below, phone (field 4) is added. A v1 client ignores it; a v2 client reading a v1 payload gets "".

// user.proto (v2) — additive change
// message User {
//   string id    = 1;
//   string name  = 2;
//   string email = 3;
//   string phone = 4;   // NEW, never-before-used number
// }

// New server filling the new field:
function buildUserV2(row) {
  return {
    id: row.id,
    name: row.name,
    email: row.email,
    phone: row.phone ?? '', // safe default if missing
  };
}

module.exports = { buildUserV2 };

Forward Compatibility: Unknown Fields

When an old server receives a message containing fields it does not know about, Protobuf preserves them as unknown fields (in many runtimes) rather than erroring. This is what makes forward compatibility work.

The practical consequence: a service in the middle of a pipeline can receive a newer message, and as long as it does not re-serialize destructively, the new data can pass through untouched.

You should never rely on a field not existing. Always tolerate extra data.

The Danger of Reusing Numbers

Suppose you delete email = 3 and later add country = 3. Disaster: an old client still sends an email string tagged with field 3. The new server happily decodes those bytes as if they were country.

Because field numbers are the wire identity, reuse causes silent data corruption — no exception, just wrong values flowing through your system.

The fix is to permanently retire deleted numbers so no future engineer can accidentally recycle them.

// Simulate the bug: bytes meant for field 3 = 'email'
// get reinterpreted by a schema that now calls field 3 'country'.
const wire = { '1': 'u-1', '2': 'Ada', '3': 'ada@mail.com' };

const v1Schema = { '1': 'id', '2': 'name', '3': 'email' };
const badV2Schema = { '1': 'id', '2': 'name', '3': 'country' }; // reused 3!

function applySchema(wireMsg, schema) {
  const out = {};
  for (const [num, val] of Object.entries(wireMsg)) out[schema[num]] = val;
  return out;
}

console.log(applySchema(wire, v1Schema));
// { id: 'u-1', name: 'Ada', email: 'ada@mail.com' }
console.log(applySchema(wire, badV2Schema));
// { id: 'u-1', name: 'Ada', country: 'ada@mail.com' }  <-- corrupted!

Reserved Fields to the Rescue

Protobuf's answer is the reserved keyword. When you remove a field, you reserve its number and its name. The compiler then refuses to let anyone reuse them.

  • reserved 3; — blocks reusing field number 3.
  • reserved "email"; — blocks reusing the name email (helps if old generated code or JSON mapping relies on it).

This turns a silent runtime corruption into a compile-time error, which is exactly where you want failures to happen.

// user.proto (v3) — email removed safely
// message User {
//   reserved 3;            // number can never be reused
//   reserved "email";      // name can never be reused
//
//   string id      = 1;
//   string name    = 2;
//   string phone   = 4;
//   string country = 5;    // new field gets a FRESH number
// }

Reserving Ranges

When you remove several fields at once, reserve them all in one statement. Ranges use to, and max covers the upper bound.

  • reserved 2, 15, 9 to 11; reserves individual numbers and a contiguous block.
  • You can reserve numbers and names in separate statements, but not mix them in a single one.

Keep a running 'graveyard' comment so the history of retired fields is visible to reviewers.

// Reserving multiple removed fields
// message Order {
//   reserved 2, 15, 9 to 11;        // numbers
//   reserved "coupon", "legacy_sku"; // names
//
//   string id        = 1;
//   string status    = 3;
//   int64  total_cents = 16;
// }

Type Changes: What Is and Isn't Safe

Some type changes preserve wire compatibility because the wire type stays the same; others silently break.

  • Safe: int32 ↔ int64 ↔ uint32 ↔ uint64 ↔ bool — all varints (just mind value ranges/truncation).
  • Safe: string ↔ bytes when bytes are valid UTF-8 (both length-delimited).
  • Unsafe: int32 → string, or changing between fixed32 and int32 — different wire types, garbled decode.

When in doubt, add a new field instead of mutating an existing one.

// Wire-type families: a quick reference table.
const wireTypes = {
  0: 'varint  (int32/int64/uint/bool/enum)',
  1: '64-bit  (fixed64/sfixed64/double)',
  2: 'length  (string/bytes/messages/packed)',
  5: '32-bit  (fixed32/sfixed32/float)',
};

// Changing a field is wire-safe only within the SAME family.
function sameFamily(aWire, bWire) {
  return aWire === bWire;
}
console.log(sameFamily(0, 0)); // int32 -> int64  => true (safe)
console.log(sameFamily(0, 2)); // int32 -> string => false (UNSAFE)

Evolving Enums Carefully

Enums evolve too. In proto3 every enum must have a zero value (the default), conventionally *_UNSPECIFIED = 0. You can append new values safely — old clients receiving an unknown enum number keep the raw integer and treat it as unrecognized.

  • Always handle the default/unknown case in your Node.js switch logic.
  • Never renumber existing enum values; reserve removed ones just like fields.
// enum Status { STATUS_UNSPECIFIED = 0; ACTIVE = 1; SUSPENDED = 2; }
// v2 appends CLOSED = 3.

function describeStatus(status) {
  switch (status) {
    case 1: return 'active';
    case 2: return 'suspended';
    case 3: return 'closed';
    default:
      // Covers 0 (UNSPECIFIED) AND any future value an old
      // build doesn't know about yet — forward compatible.
      return 'unknown';
  }
}

console.log(describeStatus(1)); // active
console.log(describeStatus(99)); // unknown (future value)

A Safe-Evolution Checklist in CI

Teams enforce these rules automatically. Tools like buf run breaking-change detection in CI, but you can also encode simple invariants yourself. The core invariants:

  • No field number is ever removed without a matching reserved.
  • No field number changes its type family.
  • New fields use numbers higher than any previously used or reserved.

Below is a tiny guard you might run against two parsed schema snapshots in a Node.js pipeline step.

// Detect a reused/removed number that wasn't reserved.
function checkBreaking(oldFields, newFields, reserved) {
  const issues = [];
  for (const [num, type] of Object.entries(oldFields)) {
    const stillPresent = newFields[num];
    if (!stillPresent && !reserved.includes(Number(num))) {
      issues.push(`field ${num} removed but not reserved`);
    } else if (stillPresent && stillPresent !== type) {
      issues.push(`field ${num} changed type ${type} -> ${stillPresent}`);
    }
  }
  return issues;
}

const oldF = { 1: 'string', 2: 'string', 3: 'string' };
const newF = { 1: 'string', 2: 'string' }; // dropped 3
console.log(checkBreaking(oldF, newF, []));
// [ 'field 3 removed but not reserved' ]
console.log(checkBreaking(oldF, newF, [3])); // []

Quick Check

You are removing the email field (number 3) from a deployed proto message. What is the correct way to keep the contract evolvable and prevent future corruption?

Recap

You now know how to evolve gRPC contracts without breaking running services:

  • Field numbers, not names, are the wire identity — they are permanent once shipped.
  • Add fields with fresh numbers; old clients ignore them and new clients see safe defaults.
  • Protobuf preserves unknown fields, giving you forward compatibility for free.
  • Reserve removed numbers and names (reserved 3; reserved "email";) to prevent silent corruption from reuse.
  • Change types only within the same wire-type family; otherwise add a new field.
  • Append enum values, always handle the unknown/UNSPECIFIED case, and enforce all of this in CI.

Follow these rules and old and new versions of your services can coexist safely through every rolling deploy.

무료로 시작

AI 튜터와 함께 JavaScript을(를) 배우세요 — 무료

브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.

코스
22
레슨
92

자주 묻는 질문

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

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

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

필드 번호 규칙과 예약 필드를 사용하여 서비스 계약을 안전하게 발전시키는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Node.js Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Node.js Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?

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

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

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

이 Node.js Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?

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

이 강의의 모든 강의

  1. Protobuf IDL로 서비스 및 메시지 정의하기
  2. 단항, 서버, 클라이언트 및 양방향 스트리밍 RPC
  3. 인터셉터, 데드라인 및 메타데이터
  4. Proto 진화와 하위 호환성
← Node.js Backend Development Bootcamp(으)로 돌아가기