결제서버 분리하기 - 2: Golang + gRPC

개발 끄적끄적 .. ✍️·2024년 2월 18일
1
post-thumbnail

지난 편(결제서버 분리하기 - 1: Monolithic to MSA)에는 결제서버를 분리하기로한 배경을 적어보았습니다. 이번에는 실제로 어떻게 결제서버를 분리했는지 실제 개발 과정에서 겪었던 과정과 고민을 적어보려고 합니다. 일반적인 REST API 서버보다는 상대적으로 gRPC 서버 관련된 레퍼런스가 적어, 이미 상용환경에서 gRPC 서버를 운영하고 있는 뱅크샐러드 테크블로그를 많이 참조하여 초기 방향을 잡아보았습니다.

gRPC과 IDL

gRPC는 서비스 정의를 protobuf 파일로 생성한 후 이를 스텁(stub)으로 컴파일하여 사용합니다. 애플리케이션 내부에 stub 파일을 정의하여 사용 할수도 있지만 stub파일이 한 애플리케이션에 종속되게 되면서 공통으로 사용 가능성이 있는 Interval과 같은 message가 애플리케이션마다 중복으로 발생합니다. 매번 정의해야하는 비효율도 있지만 모든 서비스에서 사용하는 타입을 한 번 수정하게 된다면 모든 애플리케이션에서 이를 재정의해야하기 때문에 그 비용이 크게 증가합니다. 또한 새로운 프로그래밍 언어로 MSA 프로젝트를 구성하게 된다면 동일한 protobuf 파일을 옮기는 과정에서 발생할 수 있는 휴먼에러의 가능성을 늘 염두에 두어야합니다.

이러한 문제들을 효율적으로 핸들링하고자 IDL(Interface Definition Langauge) 프로젝트를 생성했습니다. IDL프로젝트는 모든 gRPC 서비스에서 사용하는 protobuf파일과 이를 컴파일한 stub파일을 정의한 프로젝트입니다. stub은 각 프로젝트, 프로그래밍 언어별로 구분되어 관리됩니다. 이를통해 모든 gRPC 프로젝트는 IDL을 바라보면서 중복을 제거하고 관리의 효율성을 챙길 수 있었습니다. 이 idl 프로젝트를 어떻게 활용했는지는 다음편에 작성하도록 하겠습니다.

PG사와 통신하기: PGClient Interface 정의하기

현재 사용하고 있는 PG사는 NicePay와 NaverPay입니다. 각 PG사의 디테일한 부분을 제외하면 PG사의 통신은 크게 아래 동작을 수행하고 있었습니다.

  1. Header를 정의한다.
  2. RequestBody를 생성한다.
  3. HTTP 요청을 보낸다.
  4. HTTP Response Body를 파싱한다 .

우선 이 4가지 동작을 추상화 하기로 결정했습니다. 현재 사용하고 있는 PG사 이외의 새로운 PG사를 추가하더라도 큰 틀에서는 우리가 정의한 4가지 동작을 수행할 것이고, 각 PG사의 디테일한 부분만 챙긴다면 안전하고 통일성 있는 로직을 추가할 수 있을 것이라 생각했습니다.

Golang의 interface는 추상화된 상호 작용으로 관계를 표현하는데 사용됩니다. 기본적으로 Golang에서 interface는 타입이며 변수로 선언할 수도 있습니다. 기본적으로 duck typing을 지원하면서 다형성을 구현할 수 있습니다. interface의 기본 선언은 아래와 같습니다.

type Interface interface {
	Do()   (Dto, error)
	Close() 
}

맨 처음 정의한 PGClient interface 입니다.

type PGClient interface {
  // Header 생성
  CreateHeader(map[string]string) (http.Header, error)    
                              
  // Request Body 생성 
  CreateBody(gRPCRequest interface{}) (io.Reader, error)

  // HTTP 요청
  Call(ctx context.Context, header http.Header, body io.Reader) (*http.Response, error)

  // HTTP Response를 파싱하여 DTO로 변환
  ParseBody(pgResponseBody *http.Response) (interface{}, error)
}

인터페이스를 정의하고 막상 로직을 구현하다보니 문제가 발생했습니다. 각 PG사로 HTTP요청을 보내기 위한 값이 서로 달랐고, PG사로 응답을 받아 이를 다음 로직으로 넘겨줄 때 반환값이 각 PG사 별로 다르다는 것이었습니다. 더군다나 NaverPay의 경우 결제 예약(Reserve) 이후 결제 승인(Approval)을 하는 이중요청의 구조를 가지고 있기 때문에, NicePay / NaverPay 별로 interface를 선언한다고 하더라도 타입 이슈를 해결할 수 없었습니다.

이를 유연하게 대처하고자 interface 타입으로 정의했지만 실제 로직에서 interface 타입 데이터를 핸들링하는 불필요한 로직이 생성되면서 코드의 양이 증가하고 읽기 어려운 코드로 작성되기 시작했습니다. 이 문제를 해결하기 위해 도입한 것이 generic입니다.

Generic 사용하기

generic은 데이터나 함수를 추상적으로 다룰 수 있게 하는 기능입니다. 이전까지는 Golang에서 generic을 지원하지 않았지만 1.18 버전부터 generic을 지원하기 시작했습니다. generic을 사용하면 특정 타입에 크게 종속되지 않고 일반적인 형태로 코드를 작성할 수 있습니다. 이를 통해 특정 함수나 데이터를 재사용할 수 있게 되면서 코드의 유연성이나 재사용성을 높일 수 있습니다.

generic을 활용하여 새롭게 정의한 PGClient interface입니다.

type PGClient[T, V any] interface {
  // Header 생성
  CreateHeader(map[string]string) (http.Header, error)    
                              
  // Request Body 생성 
  CreateBody(grpcRequest *T) (io.Reader, error)

  // HTTP 요청
  Call(ctx context.Context, header http.Header, body io.Reader) (*http.Response, error)

  // HTTP Response를 파싱하여 DTO로 변환
  ParseBody(pgResponseBody *http.Response) (V, error)
}

이전에 interface 선언되던 CreateBody의 인자값과 ParseBody의 응답이 T, V와 같은 타입으로 정의되었습니다.

실제 이를 구현한 곳에서 이 interface 타입은 아래와 같이 정의할 수 있습니다. 아래의 protocol.NicePayRequest가 T, *protocol.NicePayResponse가 V 이며 generic 타입입니다. 이를 통해 추상적이며 유연하게 구조를 선언했고, 코드의 재사용성을 높였습니다.

type NicePayServiceServer struct {
  client PGClient[protocol.NicePayRequest, *protocol.NicePayResponse]
}

Dependency Injection: Golang Wire

DI를 사용하면 각 컴포넌트가 직접적으로 의존하는 객체를 생성하지 않고 외부에서 주입 받을 수 있습니다. 이를 통해 컴포넌트간의 결합도가 낮아지며 좀 더 유연한 구조를 만들 수 있습니다. 개발자입장에서도 매번 객체를 생성하는 번거로움과 발생할 수 있는 휴먼 에러를 줄이고, 일관성 있는 형태를 구현할 수 있습니다.

하나의 serviceServer를 구성하기 위해 우리가 정의한 컴포넌트는 4가지입니다.

  • config: pg사 service key 및 각종 설정
  • pgClient: pg사 구조체
  • logger: 결제 승인 로깅
  • serviceServer: gRPC ServiceServer

생성자 매서드등을 통해 의존 관계를 주입해 줄 수 있지만 매번 정의를 해줘야하는점, 재사용을 하기 위해서는 매번 새롭게 손수 정의 해줘야 한다는 점을 고려해보았을 때 DI 모듈을 사용하기로 결정했습니다.

Golang 의 여러 DI module이 존재하지만 github 기준 가장 star 도 많고 레퍼런스도 많은 wire를 도입하기로 했습니다. 아래와 같이 wire는 의존성을 정의한 파일을 선언하고 이를 컴파일하게 되면 의존성이 정의된 새로운 파일이 생성됩니다.

// ex) wire.go: 의존 관계 정의
func InitializeNicePayServiceServer(logger_ *logger.MongoLogger) handler.NicePayServiceServer {
	wire.Build(
		config.NewNicePayConfig,
		handler.NewNicePayClient,
		handler.NewNicePayServiceServer,
	)
	return handler.NicePayServiceServer{}
}

// wire_gen.go: wire.go 컴파일 후 생성된 파일. 의존 관계 정의 
func InitializeNicePayServiceServer(logger_ *logger.MongoLogger) handler.NicePayServiceServer {
	nicePayConfig := config.NewNicePayConfig()
	pgClient := handler.NewNicePayClient(nicePayConfig, logger_)
	nicePayServiceServer := handler.NewNicePayServiceServer(pgClient)
	return nicePayServiceServer
}

이렇게 생성된 wire_gen.go 파일은 실제 로직에서 아래와 같이 사용 할 수 있습니다

nicePayServiceServer := injector.InitializeNicePayServiceServer(logger)

wire를 사용하면서 보다 가독성 있는 코드를 작성 할 수 있었고, 일관성 있는 의존 관계를 정의할 수 있었습니다.

마무리

이번 글에서는 비즈니스 로직을 개발하면서 마주했던 고민과 이를 해결했던 방법에 대해서 적어보았습니다. 다음 마지막 글에서는 인프라 설계와 효율적인 운영을 하기 위해 마주했던 고민을 적어보겠습니다

0개의 댓글