C#/.NET 택배API 연동 가이드
이 가이드에서 다루는 내용
• HttpClient를 사용한 API 호출
• System.Text.Json을 사용한 JSON 파싱
• ASP.NET Core 웹훅 컨트롤러
• 에러 처리 및 재시도 패턴 (Polly)
• 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 또는 환경 변수를 사용하세요.
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 키가 유효하지 않습니다",
_ => "일시적인 오류가 발생했습니다"
};
}