C#

C#/.NET 택배API 연동 가이드

HttpClient를 사용한 택배 조회 및 ASP.NET Core 웹훅
JavaScript Python Java PHP C#
이 가이드에서 다루는 내용
• HttpClient를 사용한 API 호출
• System.Text.Json을 사용한 JSON 파싱
• ASP.NET Core 웹훅 컨트롤러
• 에러 처리 및 재시도 패턴 (Polly)

1. NuGet 패키지

dotnet add package System.Text.Json
dotnet add package Polly  # 재시도 로직용 (선택)

2. 환경 변수 설정

// appsettings.json
{
  "DeliveryApi": {
    "PublicKey": "pk_live_your_public_key",
    "SecretKey": "sk_live_your_secret_key",
    "BaseUrl": "https://api.deliveryapi.co.kr/v1",
    "WebhookSecret": "your_webhook_secret"
  }
}
보안 주의
Secret Key를 코드에 직접 하드코딩하지 마세요. User Secrets 또는 환경 변수를 사용하세요.

3. 배송 조회 클라이언트

모델 클래스

using System.Text.Json.Serialization;

public class TrackingResponse
{
    [JsonPropertyName("success")]
    public bool Success { get; set; }

    [JsonPropertyName("data")]
    public TrackingData Data { get; set; }
}

public class TrackingData
{
    [JsonPropertyName("courierCode")]
    public string CourierCode { get; set; }

    [JsonPropertyName("courierName")]
    public string CourierName { get; set; }

    [JsonPropertyName("trackingNumber")]
    public string TrackingNumber { get; set; }

    [JsonPropertyName("deliveryStatus")]
    public string DeliveryStatus { get; set; }

    [JsonPropertyName("deliveryStatusText")]
    public string DeliveryStatusText { get; set; }

    [JsonPropertyName("isDelivered")]
    public bool IsDelivered { get; set; }

    [JsonPropertyName("progresses")]
    public List<Progress> Progresses { get; set; } = new();
}

public class Progress
{
    [JsonPropertyName("dateTime")]
    public string DateTime { get; set; }

    [JsonPropertyName("status")]
    public string Status { get; set; }

    [JsonPropertyName("location")]
    public string Location { get; set; }

    [JsonPropertyName("statusCode")]
    public string? StatusCode { get; set; }

    [JsonPropertyName("description")]
    public string Description { get; set; }
}

API 클라이언트

using System.Net.Http.Headers;
using System.Text.Json;

public class DeliveryApiClient
{
    private readonly HttpClient _httpClient;
    private readonly string _publicKey;
    private readonly string _secretKey;
    private readonly string _baseUrl;

    public DeliveryApiClient(IConfiguration configuration)
    {
        _publicKey = configuration["DeliveryApi:PublicKey"];
        _secretKey = configuration["DeliveryApi:SecretKey"];
        _baseUrl = configuration["DeliveryApi:BaseUrl"];

        _httpClient = new HttpClient();
        _httpClient.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", $"{_publicKey}:{_secretKey}");
        _httpClient.DefaultRequestHeaders.Accept.Add(
            new MediaTypeWithQualityHeaderValue("application/json"));
    }

    public async Task<TrackingData> TrackDeliveryAsync(
        string courierCode,
        string trackingNumber,
        CancellationToken cancellationToken = default)
    {
        var url = $"{_baseUrl}/tracking/trace";
        var payload = new
        {
            items = new[]
            {
                new { courierCode, trackingNumber }
            }
        };

        var json = JsonSerializer.Serialize(payload);
        var content = new StringContent(json, Encoding.UTF8, "application/json");

        var response = await _httpClient.PostAsync(url, content, cancellationToken);
        response.EnsureSuccessStatusCode();

        var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
        var apiResponse = JsonSerializer.Deserialize<TrackingBulkResponse>(responseJson);

        var firstResult = apiResponse?.Results?.FirstOrDefault();
        if (firstResult?.Success == true)
        {
            return firstResult.Data;
        }

        throw new Exception(firstResult?.Message ?? "조회 실패");
    }

    public async Task<List<Courier>> GetCouriersAsync(
        CancellationToken cancellationToken = default)
    {
        var url = $"{_baseUrl}/tracking/couriers";

        var response = await _httpClient.GetAsync(url, cancellationToken);
        response.EnsureSuccessStatusCode();

        var json = await response.Content.ReadAsStringAsync(cancellationToken);
        var result = JsonSerializer.Deserialize<CourierResponse>(json);

        return result?.Data ?? new List<Courier>();
    }
}

public class Courier
{
    [JsonPropertyName("code")]
    public string Code { get; set; }

    [JsonPropertyName("name")]
    public string Name { get; set; }
}

public class CourierResponse
{
    [JsonPropertyName("data")]
    public List<Courier> Data { get; set; }
}

4. 의존성 주입 등록

// Program.cs 또는 Startup.cs
builder.Services.AddSingleton<DeliveryApiClient>();

// 또는 HttpClientFactory 사용 (권장)
builder.Services.AddHttpClient<DeliveryApiClient>((sp, client) =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var publicKey = config["DeliveryApi:PublicKey"];
    var secretKey = config["DeliveryApi:SecretKey"];
    client.BaseAddress = new Uri(config["DeliveryApi:BaseUrl"]);
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", $"{publicKey}:{secretKey}");
});

5. 사용 예시

public class DeliveryService
{
    private readonly DeliveryApiClient _apiClient;

    public DeliveryService(DeliveryApiClient apiClient)
    {
        _apiClient = apiClient;
    }

    public async Task<string> GetDeliveryStatusAsync(string courier, string trackingNumber)
    {
        try
        {
            var result = await _apiClient.TrackDeliveryAsync(courier, trackingNumber);

            Console.WriteLine($"배송 상태: {result.DeliveryStatusText}");
            Console.WriteLine($"배송완료: {result.IsDelivered}");

            foreach (var progress in result.Progresses)
            {
                Console.WriteLine($"  {progress.DateTime} - {progress.Description}");
            }

            return result.DeliveryStatus;
        }
        catch (HttpRequestException ex)
        {
            Console.WriteLine($"API 에러: {ex.Message}");
            throw;
        }
    }
}

6. ASP.NET Core 웹훅 컨트롤러

using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

[ApiController]
[Route("webhook")]
public class DeliveryWebhookController : ControllerBase
{
    private readonly IConfiguration _configuration;
    private readonly ILogger<DeliveryWebhookController> _logger;

    public DeliveryWebhookController(
        IConfiguration configuration,
        ILogger<DeliveryWebhookController> logger)
    {
        _configuration = configuration;
        _logger = logger;
    }

    [HttpPost("delivery")]
    public async Task<IActionResult> HandleWebhook()
    {
        // 요청 본문 읽기
        using var reader = new StreamReader(Request.Body);
        var payload = await reader.ReadToEndAsync();

        // 서명 검증
        var signature = Request.Headers["X-Webhook-Signature"].FirstOrDefault();
        var timestamp = Request.Headers["X-Webhook-Timestamp"].FirstOrDefault();
        if (!VerifySignature(payload, signature, timestamp))
        {
            return Unauthorized(new { error = "Invalid signature" });
        }

        // JSON 파싱
        var webhookData = JsonSerializer.Deserialize<WebhookPayload>(payload);

        if (webhookData?.Items != null)
        {
            foreach (var item in webhookData.Items)
            {
                switch (webhookData.Event)
                {
                    case "tracking.polled":
                        HandleStatusChanged(item);
                        break;

                    case "tracking.completed":
                        HandleDelivered(item);
                        break;
                }
            }
        }

        return Ok(new { received = true });
    }

    private bool VerifySignature(string payload, string? signature, string? timestamp)
    {
        if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp)) return false;

        // timestamp 유효성 검증 (5분 허용)
        var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(now - long.Parse(timestamp)) > 300) return false;

        // 서명 대상: timestamp.payload
        var secret = _configuration["DeliveryApi:WebhookSecret"];
        var signedPayload = $"{timestamp}.{payload}";
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
        var expected = Convert.ToHexString(hash).ToLower();

        // 헤더값: sha256={hex} → prefix 제거 후 비교
        var received = signature.Replace("sha256=", "");

        return expected == received;
    }

    private void HandleStatusChanged(JsonElement item)
    {
        var trackingNumber = item.GetProperty("trackingNumber").GetString();
        var status = item.GetProperty("currentStatus").GetString();

        _logger.LogInformation("배송 상태 변경: {TrackingNumber} -> {Status}",
            trackingNumber, status);

        // 비즈니스 로직 처리
    }

    private void HandleDelivered(JsonElement item)
    {
        var trackingNumber = item.GetProperty("trackingNumber").GetString();

        _logger.LogInformation("배송 완료: {TrackingNumber}", trackingNumber);

        // 배송 완료 처리
    }
}

public class WebhookPayload
{
    [JsonPropertyName("event")]
    public string Event { get; set; }

    [JsonPropertyName("items")]
    public JsonElement[] Items { get; set; }
}

7. Polly를 사용한 재시도 로직

using Polly;
using Polly.Extensions.Http;

// HttpClientFactory와 Polly 통합
builder.Services.AddHttpClient<DeliveryApiClient>()
    .AddPolicyHandler(GetRetryPolicy())
    .AddPolicyHandler(GetCircuitBreakerPolicy());

static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(3, retryAttempt =>
            TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // 지수 백오프
            onRetry: (outcome, timespan, retryAttempt, context) =>
            {
                Console.WriteLine($"재시도 {retryAttempt}, {timespan.TotalSeconds}초 후...");
            });
}

static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}

8. 에러 처리

public class DeliveryApiException : Exception
{
    public string ErrorCode { get; }
    public int StatusCode { get; }

    public DeliveryApiException(string message, string errorCode, int statusCode)
        : base(message)
    {
        ErrorCode = errorCode;
        StatusCode = statusCode;
    }
}

public static string GetErrorMessage(string errorCode)
{
    return errorCode switch
    {
        "INVALID_TRACKING_NUMBER" => "잘못된 송장번호입니다",
        "COURIER_NOT_SUPPORTED" => "지원하지 않는 택배사입니다",
        "RATE_LIMIT_EXCEEDED" => "요청 한도를 초과했습니다",
        "UNAUTHORIZED" => "API 키가 유효하지 않습니다",
        _ => "일시적인 오류가 발생했습니다"
    };
}

다른 언어 가이드

지금 바로 시작하세요!

무료 플랜으로 API를 바로 테스트해보세요

무료로 시작하기 →