ETC

protobuf를 이용한 "자동 함수 생성" 프로젝트

partner_jun 2024. 6. 2. 18:19

이전부터 강조했듯 FE는 필연적으로 통계(클라이언트 로그)와 친숙해질 수 밖에 없다. 이번 상반기의 주요 프로젝트는 이 클라이언트 로그와 관련된 프로젝트로 내가 소속된 웹 팀 뿐 아니라 앱 팀, 그리고 다른 부서의 웹 팀까지 사용하는 것을 목표로 하는 프로젝트를 진행했다. 간단하게 말하자면 protobuf 정의를 이용해 Java, Kotlin, Swift, Typescript 4개 언어의 소스 파일을 생성하는 프로젝트였다.
 

 

들어가며

다니고 있는 회사에는 통계와 관련된 작업을 하는 DIA팀이 있다. DIA팀은 상반기 프로젝트로 엑셀로 작성하던 클라이언트 로그 명세서를 protobuf로 작성하는 프로젝트를 진행했다. 많은 팀이 참조하는 클라이언트 로그 정의가 변하면 업무 프로세스도 변할 수 밖에 없다. 왜 하필 상속 정의가 지원되는, 명세의 한계가 명확한 protobuf를 선택했는지는 모르겠지만(서버간 통신을 하지도 않고 심지어 유효성 검사는 JAVA로 작성했다!)... 자세한 내막은 모른다. 다만 앞으로 귀찮아 지겠거니 싶었다. 예상대로 얼마 지나지 않아 귀찮은 작업이 할당됐다.

또냐

 
protobuf로 정의된 파일에는 보내야 하는 값(로그)의 타입 정의가 되어 있으니 이를 각 플랫폼의 함수로 생성하고, 그 함수를 호출하는 것만으로 클라이언트 로그를 전송할 수 있게 하자는 것이다.

/* 
* 예시 코드
* 아래 버튼을 클릭하면 { value: 1234, test: 'test' } 와 같은 값이 전송된다. (test 필드는 함수 내에 선언)
*/
<Button onClick={Log.button.click({ value: 1234; })}>
  클릭
</Button>

 
생각보다 사용자의 입력에 따라 변하는 값이 적기 때문에 위처럼 호출할 수 있게 된다면 관리가 편해진다는 것은 명백하다. 항상 고정된 값을 보낸다면 함수 내에 static하게 정의해둘 수 있고, 특히 가장 많은 실수를 유발하는 타입 유효성이 해결된다. 예를 들자면 number 타입으로 로그를 정의했지만 개발자가 신경쓰지 않아 string으로 전달되고 있는 경우다.  
 
 
 

목표

간단하게 보자면 proto, textproto(txtpb)로 작성된 클라이언트 로그 정의를 읽어내 각 언어별로(java, swift, kotlin, typescript) 로그 전송 함수가 정의된 파일을 생성하는 것이 목표다. 머릿속으로 생각해 보았을 때 크게 어렵지 않다. 템플릿을 작성해 두고, 전송해야 하는 로그의 각 필드의 이름과 타입을 읽어와 작성된 템플릿으로 파일을 만들면 된다. 
 

 
 

진행과정

POC와 언어 선택

protobuf의 주요 구성 요소인 proto와 txtpb는 go 언어로 작성되어 있다. 그렇다보니 자연스럽게 go로 poc를 진행했다. 하지만 금새 문제를 발견했다. proto3가 상속 정의를 지원하지 않아 일종의 트릭을 사용했는데, 이 때문이다. 로그의 필드 값을 message로 선언한 것 까지는 좋지만 각 로그별로 message를 정의하고 구조체의 이름을 입력한 것이다. 아래 예시를 보면 뭔가 묘한 것을 느낄 것이다.
 

// txtpb 파일

events {
  object_name: "TestTarget"
  object_type: OBJECT_TYPE_UNSPECIFIED
  event_type: EVENT_TYPE_CLICK
  custom_json_name: "TestDetail"
}
// proto 파일

syntax = "proto3";

message Events {
  string object_name = 1;
  ObjectType object_type = 2;
  EventType event_type = 3;
  optional string custom_json_name = 4;
}

message TestDetail {
  string name = 1;
  int64 age = 2;
  repeated string favorite = 3;
}

 
로그의 기본 형식으로 Events라는 메시지를 정의해 놓고, 로그의 상세 값에 해당하는 message의 이름(custom_json_name)을 문자열로 정의해둔 것이다. custom_json_name에 다른 메시지의 이름을 입력하면 다른 필드를 입력받는 "다른 로그"의 정의가 되는 것이다.
 

"import" 한 것이 아니라 이름을 입력했다!

 
custom_json_name으로 정의한 구조체의 이름을 단순 문자열로 입력했으므로 Events와 TestDetail의 프로그래밍적인 연결이 없다(논리적인 연결만이 있다). 그렇기 때문에 dynamic import가 불가능한 컴파일 언어인 go로 txtpb 파일을 읽으면 TestDetail이 정의된 proto 파일은 읽지 않는 것이다. 파일 스캔 등 다소 무리한 방법으로 메모리에 올리더라도 go의 리플랙션으로는 원하는 정도로 필드들을 뽑아낼 수 없었다. 특히 치명적인 것은 프로그래밍적인 연결이 없어 protobuf에서 지원하는 각 언어별 정의로 전환하는 protoc로도 모든 타입을 추출해니지 못한다는 것이다.
 
결국 이 프로젝트에서 장점을 보이지도 않는 go는 선택지에서 제외되었다(딱히 익숙하지도 않고). dynamic import가 가능하며 proto와 txtpb 파일을 읽어낼 수 있는 언어는 node(ts)와 python이 남는다. 유지보수는 FE 개발팀이 하게 될 것이 명백하고 어떤 타입이 들어올지, 앞으로 어떤 변화가 있을지 장담할 수 없어 타입 정의에서 더 자유롭고 라이브러리가 넘쳐나는 node를 선택했다. 심지어 정 안되겠다 싶으면 라이브러리를 대충 만들어서 써도 되니까.
 
 

개발

작성해야 하는 프로그램의 절차는 아주 명확했다.

  1. proto, txtpb가 정의된 깃 리포지토리를 받아온다.
  2. proto로 작성된 structure, enum 등 정의를 메모리에 올린다(타입으로써의 정의)
  3. txtpb에 작성된 실제 "클라이언트 로그" 명세를 모아 객체화한다.
  4. 객체화한 클라이언트 로그 명세를 각 언어별 전송 함수 정의로 변환한다.
  5. 함수 정의를 파일로 생성한다.
  6. 생성된 파일에 lint를 적용하여 문법적 오류를 확인하고 코드 스타일을 보정한다.

 
 
1번은 간단하다. 쉘 파일을 만들고 git clone을 실행하면 됐다. 어차피 옵션을 입력 받아야 하니 쉘 파일 작성은 당연했다.

오랜만에 쉘 파일을 작성하자니 매우 고통스러웠다

 
2번부터 고민이 시작됐다. protobuf 파일을 읽어올 수 있는 라이브러리가 있을까?
당연히 있다. protobufjs 라는 라이브러리다. txtpb를 지원하지 않는다거나, comment 추출시 오류가 발생한다거나 하는 문제가 있었지만 이런 문제를 해결할 수 있게 node를 플랫폼으로 선정했었다. 하지만 txtpb는 명쾌한 답이 없었다. txtpb를 ts로 읽어내는 케이스가 너무 드물다보니 메인터넌스 되고 있는 라이브러리가 발견되지 않았다.
결국 아주 오래된 라이브러리를 사용할 수 밖에 없었다. prototxt-parser라는 라이브러리인데, proto2 시절에 작성되었는지 proto3에 추가된 기능들이 지원되지 않았다. 특히 치명적인 것은 인라인 주석으로 작성된 내용이 있으면 파일을 읽어올 때 오류가 발생하는 것이다. 직접 라이브러리를 작성하거나 수정하기엔 시간이 부족했다. 주석을 떼었다 붙이는 식으로 해결했다.
 

문제는 없다. 문제는.

 
 
이제 3번이다. 어떻게든 읽어온 txtpb의 정의를 객체화한다. 위에서 본 것과 같이 txtpb 파일은 아래처럼 정의되어 있다.

// txtpb/testTarget.txtpb

# 테스트 로그임
events {
  object_name: "TestTarget"
  object_type: OBJECT_TYPE_UNSPECIFIED // types/objectType.proto에 정의된 enum 값
  event_type: EVENT_TYPE_CLICK // types/eventType.proto에 정의된 enum 값
  custom_json_name: "TestDetail"
}

 
이 파일에서 참조하는 message는 아래처럼 정의되어 있다.

// proto/testDetail.proto

syntax = "proto3";

message TestDetail {
  # 작성자 이름
  string name = 1;
  # 작성자 나이
  int64 age = 2;
  # 취미
  repeated string favorite = 3;
}

 
 
events로 정의된 로그와 "TestTarget" message 정의를 함수로 만들기 위해 데이터를 모아 객체화한다. 로그에서 참조하고 있는 enum과(위 예시에서의 object_type, event_type) message, 각 파일의 위치, 그리고 작성된 주석 엄청나게 많은 데이터를 포함한다. 
 

/*
* 템플릿 엔진으로 전달되는 객체의 간단한(?) 예시. 실제로는 훨씬 많은 정보가 포함되었다.
*/

{
  "importModules": [ // 템플릿 파일에서 import 해야 하는 값
    "types/objectType.ts",
    "types/eventType.ts",
  ],
  "fileName": "test.txtpb", // 이 객체에 해당하는 실제 txtpb 파일
  "filePath": "test/test.txtpb",
  "events": {
    "TestTarget": {
   	  "githubLinks": [
        "....", // clone 받은 파일의 위치를 blob으로 전환하여 github 링크를 JSDOC 스타일로 작성
      ],
      "objectName": "OBJECT_TYPE_UNSPECIFIED", // enum 값으로 import하여 사용
      "eventType": "EVENT_TYPE_CLICK", // enum 값으로 import하여 사용
      "customJsonName": "TestDetail",
      "parameters": { // 입력받아야 하는 값 (testDetail에 정의된 필드)
        "name": {
          "type": "string",
          "isOptional": false,
          "isArray": false,
        },
        "age": {
          "type": "number",
          "isOptional": false,
          "isArray": false,
        },
        "favorite": {
          "type": "string",
          "isOptional": false,
          "isArray": true, // 배열 처리
        }
      }
    }
  }
}

 
나는 이 객체의 데이터가 많을 수록 유리하다고 생각했다. 각 언어별로 요구되는 데이터가 다르기 때문이다. 하지만 아래에서 설명할 템플릿 엔진이 자동완성을 지원하지 않아 아무런 의미가 없어졌다. 뭐가 있는지를 모르는데 어떻게 쓸 수 있겠는가.
 
이쯤 와서 한숨을 돌렸다. 필요한 protobuf 데이터들을 읽어왔고, 객체로 뽑아냈다. 이제 언어별로 템플릿을 작성하고 템플릿 엔진을 이용해 파일로 생성하면 된다. 함수는 실제로 로그를 전송하는 로직이 담긴 함수를 호출하고, 파라미터로 입력받은 값이나 기본 값들을 모두 전달하는 것이다. 각 플랫폼이 모두 동일하게 map 형태로 담아 보내면 되기에 전송 자체는 어려움이 없었다. 다만 플랫폼별(언어별) 템플릿을 모두 작성해야 하니 이게 꽤나 복잡한 일이었다. 언어별 data class나 function, 그리고 optional이나 array 정의 문법을 모두 알아야 하니까.
 

결국 4개 언어 템플릿을 모두 내가 작성했다!!!

 
여기서 좋지 않았던 선택이 있었다. 템플릿 엔진으로 EJS를 선택한 것이다. EJS는 템플릿 내에서 JS 문법을 사용한다. 그 말인 즉슨, JS에 친숙하지 않은 개발자에게는 벽이 된다는 것이다. 템플릿에 어떤 값이 필요할지 알 수 없으니 템플릿 엔진에서 데이터를 다루도록 EJS를 선택했지만, 어느정도 안정화 되어 템플릿에 필요한 값이 구체화된 이후에는 의미없는 벽만 남게 되었다. 최근 회사에서 목표하는 다른 팀의 작업, 다른 팀의 서비스에 영향을 주는 개발 문화에 어긋나는 것이다. 아주 간단한 문법만을 지원하는 mustche나 jade 같은 것을 사용했으면 어땠을까 하는 아쉬움이 남는다. 
 

템플릿은 대충 이렇게 생겼다. 끔찍하다.

 
마지막으로는 쉘에서 생성된 파일들에 대해 lint를 추가했다. autofix를 통해 들여쓰기 문법을 고치는 것도 중요하지만 템플릿 엔진 특성상 실수하기 쉬운 문법 문제를 아주 손쉽게 파악하고 해결할 수 있었다. 그런데 주석 줄바꿈시에 들여쓰기가 망가지는 것은 어째선지 4개 언어 모두에서 발견되었다. 심지어 어떻게 수정하는지조차 알 수 가 없었다. 한참을 고민하다 찾아보니 주석의 들여쓰기는 lint의 기본 요소가 아니라는 말이 있었다. 그럼 지금까지 우리가 봐 온 주석 스타일은 prettier의 기능인걸까? 아니면 IDE? 깊게 생각하지 않기로 했다. 쓰는 쪽에서 고치며 되니까.

언어별 lint를 모두 적용해둔 것으로 내 할 일은 다했다...

 
 

개발은 끝났지만, 프로젝트가 끝나지는 않았다

당연히 한번에 끝나는 작업은 아니다. 꾸준한 유지보수가 필요하다. 앱 팀의 요구에 따라 템플릿 수정도 필요했고, 이 함수 자동 생성 프로그램과 로그 정의 전환이 동시에 이루어졌으니 예외 케이스가 간간히 발견되고 있다. 또 회사에서는 올해 상반기부터 서버 바이패스 로그를 도입하고자 하고 있다. API의 응답을 FE가 조립해서 전송하는 것이 아니라 API에서 "이 값을 로그로 전달해라"는 식으로 묶어서 응답하는 것이다. FE에서는 그 값을 ㅊ마조하지 않고 로그로 전송하도록 하는 것이 목표다. 물론 모든 상황에서 적용이 가능하지는 않다. 사용자의 동작에 따라 결정되는 값은 따로 전송하게 되니 어찌보면 두 벌의 작업이 필요하다. 이러한 API 바이패스 로그 데이터는 어떻게 정의할지, proto 파일을 정의할 때 "이 부분은 API의 데이터에요" 라는 의견을 어떻게 주고 받을지에 대해서 논의하고 있다.
 
 
 

마치며

지금까지 했던 작업과 가장 큰 차이점은 팀 내의 작업이 아닌 실 단위, 심지어 실을 넘는 논의와 질답이 이어지는 범위가 큰 작업이라는 것이다. 결과물도 꽤나 만족스럽다. 엑셀 파일을 보며 어떤 파라미터가 어떤 타입으로 전송되어야 하는지 하나씩 맞춰 보는 것이 아니라, 쉘 파일을 실행시켜 생성된 함수를 호출하는 방식으로 로그를 전송하게 되었다. 로그의 필드가 누락될 확률도 크게 줄어들고 타입의 유효성은 보장되며, 불필요하게 다시 입력해야 하는 고정된 값은 입력할 필요조차 없다.
 
작업 당시에는 너무 귀찮았지만 끝내고 나니 꽤나 재미있고 가치있는 작업이었다고 생각한다. 아쉬운 것도 몇가지 있다. 먼저 코드의 품질이다. 로그 정의를 protobuf로 전환하는 작업과 동시에 개발을 시작했더니 중간중간 어설프게 쫓게 된 부분이 있다. 그런 부분들은 코드의 품질을 떨어뜨렸고, 결과적으로 수정이 쉬운 코드 작성과 거리가 멀어졌다고 생각한다. 또 다른 점은 앞서 말한 템플릿 엔진 선정이다. 이 프로젝트를 누가, 또 어떻게 사용할 것인지에 대해 먼저 고민했다면 다른 엔진을 선택했을지도 모른다. 이미 너무 많은 작업이 있었기에 이제와서 변경하기에도 어려움이 있다. EJS로 전달된 객체의 타입 추론이 불가능한 것을 생각하면 더욱 아쉽다. 여유가 된다면 리팩토링을 진행하는 수 밖에.
 
 
 

'ETC' 카테고리의 다른 글

팀장이 되면 어떻게 해야 할까  (2) 2024.09.17
2024년 상반기를 보내며  (0) 2024.06.09
H2에서 청크 개수 제한은 성능에 영향을 줄까? 주는 것 같다.  (1) 2023.12.07
gRPC에 대해  (1) 2023.12.04
2023년을 보내며  (0) 2023.11.18