회고록

Dapp의 진입장벽을 개선하는 방법

페이마스터 리팩토링 회고록

작성자: Choi.eth

14 min read

image.png

Intro

물류 서비스는 굉장히 레거시한 플랫폼입니다. 기존의 프로세스가 고착화되어 있고, 사용자 연령대 또한 web3에 익숙한 얼리어답터와 거리가 멀기 때문에, 이를 서비스에 녹여내기는 쉽지 않았습니다. 그럼에도 물류 업계 종사자에게 한걸음 한걸음 다가가기 위해 노력했고, 그중 하나는 바로 온체인 활동의 첫번째 진입장벽인 가스비를 Paymaster Server를 통해 대납 해주는 것이였습니다.

Paymaster Server란?

Paymaster는 이더리움 ERC-4337(Account Abstraction:AA)에서 정의된 구성 요소로, 사용자가 트랜잭션 실행에 필요한 가스비를 직접 지불하지 않아도 되도록 대신 비용을 부담해주는 역할을 합니다. 애플리케이션 서버가 특정 조건(예: 사용자 인증, 멤버십, 서비스 정책 등)을 검증한 뒤 가스비를 대신 납부해 사용자 경험을 자연스럽게 만들 수 있는 핵심 인프라입니다.

프로젝트 기획 단계에서 우리는 물류 서비스 이용자에게 기존의 프라이빗 키·니모닉 기반 지갑이 아닌 AA 기반 스마트 지갑을 제공하고, Paymaster를 통해 가스비를 지원함으로써 블록체인 접근에 대한 부담을 느끼지 않도록 만드는 것을 초기 목표로 삼았습니다.

Paymaster Server 초기 모델

제가 속한 디카르고는 블록체인 기반 물류 프로토콜로서, 협업을 어렵게 만드는 산업 내 문제들을 해결하기 위해 지속적으로 발전해 왔습니다. 현재는 기존에 운영중인 dFull(WMS·OMS SaaS) 서비스와 블록체인 기술을 자연스럽게 연결하는 작업을 진행하고 있었습니다.

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2025-12-08_%E1%84%8B%E1%85%A9%E1%84%8C%E1%85%A5%E1%86%AB_8.54.59.png

Paymaster Server의 핵심 역할은 “해당 사용자가 가스비 대납을 받을 자격이 있는가?”를 검증하는 일입니다. 이 검증이 없다면 불특정 다수의 가스비를 무제한으로 대신 지불하는 디도스 공격이 발생할 수 있습니다. 그러나 기존 서비스 환경에서 ERC-4337 규격의 Paymaster Server를 안정적으로 유지보수하는 일은 쉽지 않았습니다. 특히 dFull은 타 부서가 개발한 플랫폼이었기 때문에 코드를 수정하려면 많은 협의와 정책 검토가 필요했습니다.

이를 해결하기 위해 가장 현실적인 방법으로 별도의 서버를 구축하고, 서비스가 가스비 대납이 필요할 때 필요한 데이터를 전달할 수 있는 Endpoint를 제공하는 구조였습니다. 기존에 잘 정의된 JWT 토큰 시스템을 활용해 사용자 자격을 검증함으로써 기존 프로세스를 그대로 재사용할 수 있도록 설계했습니다. 기능 자체가 복잡하지 않고 경량이었기에 초기에는 Node/Express 기반의 단순한 API 서버로 구성했습니다.

초기 모델의 한계점

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2025-12-08_%E1%84%8B%E1%85%A9%E1%84%8C%E1%85%A5%E1%86%AB_9.19.11.png

이러한 초기 모델은 단일 서비스에 연결될 때는 충분히 효과적이었지만, 사내 로드맵이 확장되면서 여러 물류 서비스가 순차적으로 오픈될 예정이었기에 운영 측면에서 한계가 드러나기 시작했습니다.

여러 서비스가 동일한 Endpoint를 사용하게 되면, 서비스마다 구조가 다른 회원 정보 기반 JWT 토큰을 구분하기 위한 추가 설계가 필요했고, 각 서비스가 발급하는 여러 JWT secret을 관리해야 하는 부담도 급격히 증가했습니다. 그 결과 운영 과정에서 관리해야 할 포인트가 빠르게 늘어나며 구조적 개선의 필요성이 제기되었습니다.

문제해결: 재사용성이 미흡한 express를 NestJS 모듈단위로 리팩토링

이를 해결하기 위해 Paymaster 기능을 NestJS 모듈 단위로 리팩토링하여, 각 서비스가 공통적으로 사용할 수 있는 JSON-RPC 기반 Paymaster API를 독립적으로 운영할 수 있는 구조로 개선했습니다. 이를 통해 서비스별 환경 구성, 인증 방식, 검증 로직을 모듈 단위로 분리함으로써 재사용성을 높이고 유지보수 부담을 줄일 수 있는 방향으로 설계했습니다.

모듈화 라이프사이클 설계

https://docs.nestjs.com/faq/request-lifecycle#summary

NestJS 프레임워크는 API의 요청(request)부터 응답(response)까지 정해진 모듈에 따라 순차적으로 실행되는 라이프사이클 구조를 갖고 있습니다.

이번에 구현한 Paymaster 서버의 API는 이더리움 업계에서 준수하는 ERC-7477 표준을 기반으로 JSON-RPC 방식으로 설계되었으며, 이를 통해 viem.ts와 자연스럽게 호환될 수 있도록 구성했습니다. 이를 통해 향후 지갑 SDK 개발에 필요한 리소스를 최소화하는 것을 목표로 했습니다.

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2025-12-09_%E1%84%8B%E1%85%A9%E1%84%8C%E1%85%A5%E1%86%AB_8.43.13.png

위 그림처럼 각 모듈의 활용성과 라이프사이클 순서를 기준으로, JSON-RPC API 구현에 필요한 기능들을 독립적인 모듈로 분리해 개발했습니다. 이 중에서 JSON-RPC 핵심을 다루기 위해, Pipe, Controller, Service 모듈을 어떤 방식으로 설계하고 구현해 나갔는지 회고해보고자 합니다.

Pipe: JSON RPC Protocol 검사

자원(Resource) 중심의 REST API와 달리 JSON-RPC는 함수 호출을 네트워크로 확장한 형태의 아키텍처 스타일입니다. 요청 메시지에 호출할 함수(method)와 파라미터를 함께 담아 전송하며, 일반적으로 HTTP POST 요청의 body에 함수 이름과 인자를 JSON 형태로 포함해 서버로 전달하는 방식으로 동작합니다.

params 검사

if (jsonrpc !== '2.0') {
  return new JsonRpcException(
    rpcError(JsonRpcErrorCode.InvalidRequest, JsonRpcErrorMessage[JsonRpcErrorCode.InvalidRequest], id),
  );
}

if (typeof method !== 'string' || method.trim() === '') {
  return new JsonRpcException(
    rpcError(JsonRpcErrorCode.MethodNotFound, JsonRpcErrorMessage[JsonRpcErrorCode.MethodNotFound], id),
  );
}

if (params !== undefined && typeof params !== 'object' && !Array.isArray(params)) {
  return new JsonRpcException(
    rpcError(JsonRpcErrorCode.InvalidParams, JsonRpcErrorMessage[JsonRpcErrorCode.InvalidParams], id),
  );
}

정해진 프로토콜에 따라 클라이언트와 서버 간의 요청과 응답은 JSON RPC 스펙에서 정의한 구조를 반드시 따라야 합니다. 요청에는 jsonrpc 값이 반드시 2.0 이어야 하며 method 항목이 포함되어 있어야 하고 함수 호출에 필요한 params는 json 형태로 전달되어야 합니다. 이러한 조건을 검증하기 위해 파이프에 구조 검사 로직을 구현했습니다.

Batch 요청 정책 수립

/**
 * @dev 이 파이프는 들어오는 RPC 요청 본문이 JSON-RPC 2.0 명세를 준수하는지 여부를 검증합니다..
 *      검증에 실패하더라도, 오류 메시지를 반환할 뿐이며, 그 경우에도 응답은 JSON-RPC 2.0 오류 객체 형식을 엄격하게 따릅니다.-
 *      see. https://www.jsonrpc.org/specification#batch
 * @batchPolicy
 *      JSON-RPC 2.0은 배치 요청(batch requests)을 지원합니다.
 *      배치를 처리할 때, 하나의 요청에서 오류가 발생하더라도 전체 배치가 실패하지 않습니다.  배치 내의 각 요청은 독립적으로 처리되며,
 *      유효하지 않은 요청은 JSON-RPC 2.0 오류 객체 형식에 맞춘 응답으로 반환됩니다.
 *      파이프라인은 계속해서 생명주기를 이어가며, 유효한 요청은 다음 프로세스로 전달됩니다.
 */

JSON RPC 2.0에 따르면 여러 요청을 한 번에 처리하는 배치 형태를 지원해야 합니다. 또한 배치 처리 시 하나의 요청에서 오류가 발생하더라도 전체 요청이 모두 실패해서는 안 됩니다. 이에 따라 검증 단계에서 즉시 요청을 중단하는 방식이 아니라 rpcError 타입의 오류 메시지만 반환하고 Service에서 요청 본문이 rpcError 타입인지 확인해 해당 요청만 중단하는 정책으로 만들었습니다.

Controller: JSON RPC 모듈 정의

@Controller('rpc')
export class RpcController {
  constructor(private readonly rpcService: RpcService) {}

  @Post()
  @UsePipes(ValidationRpcPipe)
  rpc(@Body() body: JsonRpcRequest | JsonRpcFailure | (JsonRpcRequest | JsonRpcFailure)[]) {
    return this.rpcService.handler(body);
  }
}

Bundler나 Paymaster처럼 애플리케이션 목적의 서버는 일반적으로 POST rpc 엔드포인트를 사용하기에 이번 구현에서도 동일한 방식으로 구성했습니다. 또한 Controller 모듈에 Pipe를 독립적으로 연결해 다른 서비스 API와 분리된 상태에서 JSON RPC 요청이 들어올 때 독립적으로 처리될 수 있도록 설계했습니다.

Service: Paymaster에 필요한 API 기능 구현

@Injectable()
export class RpcService {
...

  async handler(body: JsonRpcRequest | JsonRpcFailure | (JsonRpcRequest | JsonRpcFailure)[]) {
    if (Array.isArray(body)) {
      return Promise.all(body.map((b) => this.#_handle(b)));
    }
    return await this.#_handle(body);
  }
  ...
  
}

Service는 Controller에서 전달된 API 요청을 실제로 처리하는 역할을 담당합니다. 이때 요청이 배치 형태인지 단일 형태인지에 따라 로직을 분기해 처리하도록 구현했습니다.

async #_handle(body: JsonRpcRequest | JsonRpcFailure) {
    /**
     * @dev If the incoming request does not comply with the JSON-RPC 2.0 specification,
     *       an Error object will be returned from the ValidationRpcPipe.
     */
    if (body instanceof JsonRpcException) {
      return body;
    }
    
    ...

Pipe에서 설명했던것 처럼, JSON RPC에서 정의한 예외 처리 규칙을 반영해, 요청 본문에 JsonRpcException 타입의 오류 메시지가 포함되어 있다면 해당 메시지를 그대로 반환하도록 했습니다.

...
    const req = body as JsonRpcRequest;
    const { method, id } = req;
    const fn = this.methods.get(method);
    if (!fn)
      return rpcError(
        JsonRpcErrorCode.MethodNotFound,
        JsonRpcErrorMessage[JsonRpcErrorCode.MethodNotFound],
        id ?? null,
      );
...

JSON RPC 파라미터에 지정된 method 이름이 서버에 구현되어 있지 않은 경우에는 MethodNotFound 오류를 반환해 불필요한 요청을 빠르게 차단하고 리소스 낭비를 방지하도록 설계했습니다.

export class MethodHandlers {
...

  onModuleInit() {
    this.registry.register('pm_getPaymasterData', this.pm_getPaymasterData);
    this.registry.register('pm_getPaymasterStubData', this.pm_getPaymasterStubData);
    this.registry.register('pm_sponsorUserOperation', this.pm_sponsorUserOperation);
    this.registry.register('pm_getERC20TokenQuotes', this.pm_getERC20TokenQuotes);
  }
  
...

구현된 method들은 NestJS의 onModuleInit 함수를 활용해 서버 빌드 시 등록되도록 구성했으며, 이를 통해 서버 재배포 및 로드밸런싱 상황에서 컴파일 비용을 줄이는 효과를 기대했습니다.

본 자료는 Express에서 NestJS로의 리팩토링 과정에 초점을 맞추고 있으며, Paymaster API의 세부 기능 구현은 자세히 다루지 않습니다. 다만 해당 기능은 ERC4337 EntryPoint 0.7.0과 호환되도록 설계되었으며, 결과물은 Github 저장소에서 확인하실 수 있습니다.

ERC4337 로컬 환경 구축 및 테스트

image.png

사내 서버를 대상으로 리팩토링 작업을 진행하다 보니 전체 코드를 모두 공개할 수는 없었지만, Paymaster Server가 정상적으로 동작하는지를 로컬 환경에서 테스트할 수 있도록 별도의 레포지토리를 구성해 두었습니다.

bookmark

ERC4337 표준에 따라 동작하려면 EVM 환경, EntryPoint 컨트랙트, Bundler까지 모두 구축되어 있어야 하기에 이를 한 번에 셋업할 수 있는 Make 기반의 레포지토리를 마련했습니다. 이 환경에서는 단순히 인프라를 구축하는 것뿐만 아니라 UserOperation 전송을 통해 가스비 대납이 실제로 이루어지는지까지 확인할 수 있습니다.

Paymaster 애플리케이션의 작동 방식을 간단하게 확인해보고 싶으시다면 해당 레포지토리를 가볍게 사용해보시는 것을 권장드립니다.

마무리

지금까지 지난 3개월 동안 Paymaster API를 개발하고 이를 NestJS로 리팩토링한 프로젝트에 대한 회고였습니다. 긴 글 읽어주셔서 감사합니다.

Keep reading

모두 보기