핵심 문제 인식: 왜 동적 클라이언트가 필요한가?
다수의 마이크로서비스 환경에서 서로 다른 서비스 간에 HTTP 기반 통신이 빈번하게 발생합니다. 전통적인 방식으로는 HttpClient를 직접 주입하고, 문자열 기반의 URL과 강타입이 아닌 파라미터를 다루며, 매번 요청/응답 검증 로직을 반복해야 합니다. 이는 유지보수성과 코드 품질에 악영향을 미칩니다.
특히 서비스 갯수가 증가하면, 각기 다른 엔드포인트에 대한 수동 관리가 불가피해지고, 실수 유발 가능성이 커집니다. 따라서 다음과 같은 요구사항이 자연스럽게 등장합니다:
- 원격 메서드 호출을 마치 로컬 메서드처럼 사용할 수 있어야 한다.
- 인터페이스 기반의 명세가 존재하여, 클라이언트와 서버 간 일관된 계약이 보장되어야 한다.
- HTTP 세부 사항(메서드 타입, 쿼리, 바디 등)은 구현 세부사항이 되어야 하며, 개발자는 관심 없이 사용할 수 있어야 한다.
ABP vNext의 동적 클라이언트 솔루션
ABP Framework는 이러한 문제를 해결하기 위해 동적 클라이언트 프록시를 제공합니다. 이는 실제 HttpClient를 직접 다루지 않고도, 원격 서비스의 메서드를 마치 로컬 인터페이스처럼 호출할 수 있게 해줍니다.
1. 인터페이스 정의 및 서버 구현
먼저, 공유 계약을 위한 인터페이스를 정의합니다. 이 인터페이스는 IRemoteService를 상속받아야 하며, 프레임워크가 이를 식별할 수 있도록 합니다.
public interface IMoneyAppService : IRemoteService
{
Task<List<Money>> GetMoneyAsync();
}
public class MoneyAppService : IMoneyAppService
{
[HttpGet]
public async Task<List<Money>> GetMoneyAsync()
{
return new List<Money> { new Money(5000) };
}
}
2. 클라이언트 모듈 구성
클라이언트 측에서는 Volo.Abp.Http.Client 패키지를 통해 프록시 생성 기능을 활성화합니다. 모듈에 다음과 같이 의존성 주입을 설정합니다.
[DependsOn(typeof(AbpHttpClientModule), typeof(MoneyApplicationContractsModule))]
public class ClientModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddHttpClientProxies(
assembly: typeof(MoneyApplicationContractsModule).Assembly,
remoteServiceName: "MoneyService",
isDefault: false
);
}
}
3. 구성 파일 설정
서비스 이름과 기본 주소를 연결하는 설정을 추가합니다.
{
"RemoteServices": {
"MoneyService": {
"BaseUrl": "http://localhost:8080/"
}
}
}
이 설정은 프레임워크가 MoneyService라는 이름으로 특정 엔드포인트에 접근할 수 있도록 도와줍니다. 내부적으로는 이름-주소 매핑을 관리하는 딕셔너리 구조를 활용합니다.
4. 실제 사용 예시
사용자는 단순히 인터페이스를 주입하고, 마치 로컬 메서드처럼 호출하면 됩니다.
public class MyService : ITransientDependency
{
private readonly IMoneyAppService _moneyService;
public MyService(IHttpClientProxy<IMoneyAppService> proxy)
{
_moneyService = proxy.Service;
}
public async Task DoWork()
{
var result = await _moneyService.GetMoneyAsync(); // 원격 호출!
}
}
내부 동작 원리 분석
이제 핵심인 동적 프록시의 작동 방식을 살펴봅니다. 이 기능은 Castle.Core의 동적 프록시 기술에 기반합니다.
1. 프록시 생성 과정
프레임워크는 지정된 어셈블리에서 IRemoteService를 상속하는 인터페이스를 찾습니다. 이 과정에서 다음과 같은 필터링이 이루어집니다:
- 공개된 클래스만 대상
- 제네릭 타입 제외
- 실제 구현체 없이 인터페이스만 존재
이러한 인터페이스들은 CreateInterfaceProxyWithoutTarget로 동적으로 프록시 객체로 변환됩니다. 이 프록시는 실제 메서드 구현 없이, 인터셉터(Interceptor)를 통해 요청을 가로챕니다.
2. 인터셉터 체인 처리
프록시 호출 시 두 가지 인터셉터가 순차적으로 실행됩니다:
- 검증 인터셉터: 입력 파라미터 유효성 검사 (예:
Required어노테이션 등). - HTTP 호출 인터셉터: 실제 원격 서비스에 대한 요청을 생성하고 전송합니다.
이 두 인터셉터는 DynamicHttpProxyInterceptor 내부에서 처리되며, 요청 정보를 ClientProxyRequestContext로 추상화합니다.
3. 원격 메서드 매핑
프록시는 요청을 보내기 전, API 정의 메타데이터를 조회합니다. 이는 다음 경로에서 얻을 수 있습니다:
GET /abp/api-definition
응답은 JSON 형식의 메타데이터이며, 각 메서드의 HTTP Method, URL 패턴, 파라미터 형식 등을 포함합니다. 이를 바탕으로 현재 호출되는 메서드에 해당하는 원격 엔드포인트를 정확히 결정합니다.
4. 요청/응답 처리
메서드 반환값이 Task<T>라면, 응답 본문을 T로 역직렬화합니다. 만약 반환 타입이 void이거나 Task라면, 단순히 성공 여부만 확인합니다.
핵심 기술 요소 요약
- 반사(Reflection): 인터페이스 및 메서드 메타데이터 수집
- 제네릭과 위임: 프록시의 유연한 타입 처리
- 의존성 주입: 클라이언트 프록시의 생명주기 관리
- 프록시 패턴 + AOP: 요청 가로채기 및 비즈니스 로직 삽입
- Castle.Core: 실제 동적 프록시 생성 기반
고려 사항 및 한계
- 메타데이터 캐싱: 서버 변경 후 클라이언트가 즉시 반영되지 않음. 재시작 또는 외부 캐시(예: Redis) 활용 필요.
- 타임아웃 설정: 최근 버전에서는 개별 요청에 타임아웃 설정 가능.
- 호환성: 일부 특수 타입(예:
IFormFile)은 추가 처리 필요.
결론적으로, ABP의 동적 클라이언트는 의사코드(코드 생성 없이)로 원격 서비스 호출을 가능하게 하는 매우 우아한 설계입니다. 이 구조를 이해하면, 자체적으로 유사한 시스템을 구현할 수도 있으며, Refit나 gRPC 등의 다른 도구들과 비교하며 장단점을 평가할 수 있습니다.