공개 API 설계 및 호환성
이 스킬을 사용해야 할 때
다음과 같은 상황에서 이 스킬을 사용하세요:
- NuGet 패키지 또는 라이브러리의 공개 API 설계
- 기존 공개 API 변경
- 분산 시스템의 와이어 형식 변경 계획
- 버전 관리 전략 구현
- 주요 변경 사항을 검토하는 Pull Request 검토
세 가지 유형의 호환성
| 유형 | 정의 | 범위 |
|---|---|---|
| API/소스 | 새 버전에서 코드 컴파일 가능 | 공개 메서드 시그니처, 타입 |
| 바이너리 | 컴파일된 코드가 새 버전에서 실행 가능 | 어셈블리 레이아웃, 메서드 토큰 |
| 와이어 | 직렬화된 데이터를 다른 버전에서 읽을 수 있음 | 네트워크 프로토콜, 영속성 형식 |
이 중 하나라도 깨지면 사용자에게 업그레이드 마찰이 발생합니다.
확장 전용 설계
안정적인 API의 기초: 절대 제거하거나 수정하지 말고, 확장만 하세요.
세 가지 기둥
- 이전 기능은 변경 불가 - 한 번 출시된 동작과 시그니처는 잠금
- 새로운 기능은 새로운 구문으로 - 오버로드 추가, 새 타입, 옵트인 기능
- 제거는 폐지 기간 후에만 - 릴리스가 아닌 년 단위
이점
- 이전 코드가 새 버전에서 계속 작동
- 새 경로와 이전 경로 공존
- 업그레이드가 기본적으로 비호환되지 않음
- 사용자가 자신의 일정에 따라 업그레이드
참고 자료:
API 변경 가이드라인
안전한 변경 (모든 릴리스)
// 새 오버로드 기본 매개변수 추가
public void Process(Order order, CancellationToken ct = default);
// 기존 메서드에 새 선택적 매개변수 추가
public void Send(Message msg, Priority priority = Priority.Normal);
// 새 타입, 인터페이스, 열거형 추가
public interface IOrderValidator { }
public enum OrderStatus { Pending, Complete, Cancelled }
// 기존 타입에 새 멤버 추가
public class Order
{
public DateTimeOffset? ShippedAt { get; init; } // 새 멤버
}
안전하지 않은 변경 (절대 또는 메이저 버전만)
// 공개 멤버 제거 또는 이름 변경
public void ProcessOrder(Order order); // 이전: Process()
// 매개변수 유형 또는 순서 변경
public void Process(int orderId); // 이전: Process(Order order)
// 반환 유형 변경
public Order? GetOrder(string id); // 이전: public Order GetOrder()
// 접근 한정자 변경
internal class OrderProcessor { } // 이전: public
// 기본값 없는 필수 매개변수 추가
public void Process(Order order, ILogger logger); // 호출자 깨짐!
폐지 패턴
// 1단계: 버전과 함께 사용 중단 표시 (모든 릴리스)
[Obsolete("v1.5.0부터 사용 중단됨. ProcessAsync를 사용하세요.")]
public void Process(Order order) { }
// 2단계: 새 권장 API 추가 (동일 릴리스)
public Task ProcessAsync(Order order, CancellationToken ct = default);
// 3단계: 다음 메이저 버전에서 제거 (v2.0+)
// 사용자가 마이그레이션할 시간을 가진 후에만
API 승인 테스트
자동화된 API 표면 테스트로 우발적인 주요 변경 방지.
ApiApprover + Verify 사용
dotnet add package PublicApiGenerator
dotnet add package Verify.Xunit
[Fact]
public Task ApprovePublicApi()
{
var api = typeof(MyLibrary.PublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}
ApprovePublicApi.verified.txt 파일 생성:
namespace MyLibrary
{
public class OrderProcessor
{
public OrderProcessor() { }
public void Process(Order order) { }
public Task ProcessAsync(Order order, CancellationToken ct = default) { }
}
}
모든 API 변경은 테스트를 실패시킵니다 - 검토자가 명시적으로 변경 사항을 승인해야 합니다.
PR 검토 프로세스
- PR에
*.verified.txt파일 변경 포함 - 검토자가 diff에서 정확한 API 표면 변경 확인
- 주요 변경 사항이 즉시 보임
- 의식적 결정 필요
와이어 호환성
분산 시스템의 경우, 직렬화된 데이터는 버전 간에 읽을 수 있어야 합니다.
요구 사항
| 방향 | 요구 사항 |
|---|---|
| 하위 호환성 | 이전 작성자 → 새 독자 (현재 버전이 이전 데이터 읽기) |
| 상위 호환성 | 새 작성자 → 이전 독자 (이전 버전이 새 데이터 읽기) |
제로 다운타임 롤링 업그레이드를 위해 둘 다 필요합니다.
와이어 형식 안전하게 진화시키기
1단계: 읽기 측 지원 추가 (옵트인)
// 새 메시지 유형 - 독자가 먼저 배포됨
public sealed record HeartbeatV2(
Address From,
long SequenceNr,
long CreationTimeMs); // 새 필드
// 역직렬화기가 이전 및 새 형식 모두 처리
public object Deserialize(byte[] data, string manifest) => manifest switch
{
"Heartbeat" => DeserializeHeartbeatV1(data), // 이전 형식
"HeartbeatV2" => DeserializeHeartbeatV2(data), // 새 형식
_ => throw new NotSupportedException()
};
2단계: 쓰기 측 활성화 (옵트아웃, 다음 마이너 버전)
// 새 형식 활성화 설정 (초기에는 기본적으로 꺼짐)
akka.cluster.use-heartbeat-v2 = on
3단계: 기본값으로 설정 (향후 버전)
설치 기반이 읽기 측 코드를 흡수한 후에.
스키마 기반 직렬화
리플렉션 기반보다 스키마 기반 형식을 선호하세요:
| 형식 | 유형 | 와이어 호환성 |
|---|---|---|
| Protocol Buffers | 스키마 기반 | 우수 - 명시적 필드 번호 |
| MessagePack | 스키마 기반 | 좋음 - 계약 사용 |
| System.Text.Json | 스키마 기반 (소스 생성 포함) | 좋음 - 명시적 속성 |
| Newtonsoft.Json | 리플렉션 기반 | 나쁨 - 페이로드에 타입 이름 포함 |
| BinaryFormatter | 리플렉션 기반 | 매우 나쁨 - 절대 사용하지 마세요 |
자세한 내용은 dotnet/serialization 스킬을 참조하세요.
캡슐화 패턴
내부 API
비공개 API를 명시적으로 표시:
// 문서화를 위한 특성
[InternalApi]
public class ActorSystemImpl { }
// 네임스페이스 규칙
namespace MyLibrary.Internal
{
public class InternalHelper { } // 확장성을 위해 공개, 사용자용 아님
}
명확하게 문서화:
.Internal네임스페이스의 유형 또는[InternalApi]로 표시된 유형은 공지 없이 릴리스 간에 변경될 수 있습니다.
클래스 봉인
// 올바른 방법: 상속용으로 설계되지 않은 클래스는 봉인
public sealed class OrderProcessor { }
// 잘못된 방법: 실수로 봉인되지 않은 상태로 두기
public class OrderProcessor { } // 사용자가 상속할 수 있어 변경 차단
인터페이스 분리
// 올바른 방법: 작고 집중된 인터페이스
public interface IOrderReader
{
Order? GetById(OrderId id);
}
public interface IOrderWriter
{
Task SaveAsync(Order order);
}
// 잘못된 방법: 거대한 인터페이스 (메서드 추가 시 모든 구현 깨짐)
public interface IOrderRepository
{
Order? GetById(OrderId id);
Task SaveAsync(Order order);
// 새 메서드 추가 시 모든 구현 깨짐!
}
버전 관리 전략
시맨틱 버전 관리 (실용적)
| 버전 | 허용되는 변경 |
|---|---|
| 패치 (1.0.x) | 버그 수정, 보안 패치 |
| 마이너 (1.x.0) | 새 기능, 사용 중단, 사용 중단된 기능 제거 |
| 메이저 (x.0.0) | 주요 변경, 이전 API 제거 |
핵심 원칙
- 예상치 못한 중단 없음 - 메이저 버전도 공지 및 계획되어야 함
- 언제든지 확장 - 새 API는 모든 릴리스에서 제공 가능
- 제거 전에 사용 중단 표시 - 최소 한 마이너 버전 동안
[Obsolete]사용 - 일정 전달 - 사용자가 업그레이드를 계획할 수 있어야 함
체스터턴의 울타리
무언가를 제거하거나 변경하기 전에, 그것이 왜 존재하는지 이해하세요.
모든 공개 API는 누군가에 의해 사용된다고 가정하세요. 변경하려면:
- GitHub에서 제안 홍보
- 마이그레이션 경로 문서화
- 사용 중단 기간 제공
- 계획된 릴리스에 제공
Pull Request 체크리스트
공개 API를 다루는 PR을 검토할 때:
- 제거된 공개 멤버 없음 (
[Obsolete]사용) - 변경된 시그니처 없음 (오버로드 추가)
- 새 필수 매개변수 없음 (기본값 사용)
- API 승인 테스트 업데이트됨 (
.verified.txt변경 검토) - 와이어 형식 변경은 옵트인 (읽기 측 먼저)
- 주요 변경 사항 문서화됨 (릴리스 노트, 마이그레이션 가이드)
안티 패턴
수정으로 위장한 주요 변경
// 사용자를 깨뜨리는 "버그 수정"
public async Task<Order> GetOrderAsync(OrderId id) // 이전에는 동기!
{
// "수정"됨 - 비동기로 변경되었지만 모든 호출자 깨짐
}
// 올바른 방법: 새 메서드 추가, 이전 메서드 사용 중단 표시
[Obsolete("GetOrderAsync를 대신 사용하세요")]
public Order GetOrder(OrderId id) => GetOrderAsync(id).Result;
public async Task<Order> GetOrderAsync(OrderId id) { }
묵시적 동작 변경
// 기본값 변경은 이전 동작에 의존하는 사용자를 깨뜨림
public void Configure(bool enableCaching = true) // 이전: false!
// 올바른 방법: 새 이름의 새 매개변수
public void Configure(
bool enableCaching = false, // 원래 기본값 유지
bool enableNewCaching = true) // 새 동작 옵트인
다형적 직렬화
// 피해야 할 방법: 와이어 형식에 타입 이름 포함
{ "$type": "MyApp.Order, MyApp", "Id": 123 }
// Order 클래스 이름 변경 = 와이어 중단!
// 선호하는 방법: 명시적 구분자
{ "type": "order", "id": 123 }