본문 바로가기
Dev Note/else

Protocol Buffers 로 Java - NodeJs 통신시키기

by iyos 2024. 2. 4.

 

 

     


     

     

    왜 REST Api 에 Protocol Buffer 를 적용했는가?

    얼마전 신규 오픈한 기능의 실시간 통신을 위해 구축해놓은 서버가 정상수치가 아니라는 인프라팀의 제보가 왔다.

    정확히는 nginx 에서 무리가 왔기 때문에 소켓 서버를 타겟으로 들어오는 부분에서 문제가 있다는 추측이다.

    PM2 로그를 통해 본 서버로그에 아래와 같은 문구가 엄청나게 많이 찍히고 있고 파일이나 크기가 큰 리스트를 주고받는 경우도 많았기 때문에 실시간 서버에 들어오는 데이터의 크기를 의심하고 리퀘스트 파라미터를 처리하는 개선 작업을 진행해보려고 가닥을 잡았다.

     

     


     

    어떻게 request entity 를 줄일 수 있나?

    처음에는 빨리 해결하는 방법과 제대로 해결하는 방법 2가지의 안을 가지고 있었다. 회사의 상황상 기능 개발이 아닌 유지보수에 그렇게 많은 시간을 쓸 수 없기 때문에, 며칠의 리소스가 주어질지 몰라 두가지를 모두 고려했다.

     

    1. 빨리 해결하는 방법.
      당장 쓰지 않는 값은 모두 제거하고 필수값만 그대로 파라미터로 넘기도록 정리한다.
      • 이 부분은 빨리 해결하려면 제거하는 방법으로 개선이 가능하지만, 언젠간 트리거 역할로 쓰는게 아니라 필요한 데이터만 보내서 DB 조회없이 갱신시키도록 작업되어야하고 언제 어떤 값이 추가될지 모르기때문에 확장성에 좋지않다.
      • 그리고 만약에 정리한다고 하더라도, 이 기능과 연관된 부분을 개발하려는 누군가는 다른 방식으로 개선이 필요할 것 같다.
    2. 제대로 해결하는 방법.
      통신 값은 유지하되 현재 json 으로 그대로 넘기는 방식이 아닌 protobuf 를 활용하여 압축시켜 통신한다.
      • 어쩌다가 새롭게 알게된 방법이다. js 와 nodejs 와 java 등등 다양한 언어에서도 교차로 쓸 수 있는 방식이 필요했는데, 찾아보니 보안적인 강점까지 갖출 수 있다.
      • 구현 기간이 2일 이상 주어진다면 이 방법으로 선택하자.

     

     


     

    그렇게 채택된 Google Protocol Buffer!

    이번 과제에 3일의 시간이 주어졌다. 넉넉한 기간을 아닐지라도 빨리 해결하는 파라미터를 제거하는 방법을 적용하고 넘어가기에는 아쉬운 시간이기에, 언어에 구애받지 않는 데이터 포맷인 Google Protocol Buffer 적용을 선택했다. 장단점을 제대로 알아보자.

    (Fig. from ‘Schema evolution in Avro, Protocol Buffers and Thrift’)

     

    Protobufjs 사용의 좋은점

    1. 효율적인 데이터 직렬화 및 역직렬화
      • Protocol Buffers는 JSON보다 더 작고 효율적인 이진 형식을 사용하므로, 데이터를 직렬화하고 역직렬화할 때 효율적이다.
    2. 데이터 구조의 버전 관리
      • Protobuf는 데이터 스키마의 변경을 용이하게 처리할 수 있다. 또한 새로운 필드를 추가하거나 기존 필드를 삭제하더라도 역호환성을 유지할 수 있다.
    3. 언어 간 상호 운용성
      • Protobuf는 여러 프로그래밍 언어에서 사용할 수 있도록 지원되므로, 클라이언트 (JavaScript)와 서버 (Node.js) 간의 상호 운용성을 보장한다.
    4. 클라이언트-서버 통신 최적화
      • 이진 형식의 사용으로 인해 데이터 크기가 작아지므로 네트워크 트래픽이 감소하고 전송 속도가 향상될 수 있다.
    5. 자동 코드 생성
      • Protobufjs는 프로토콜 버퍼 정의로부터 자동으로 코드를 생성할 수 있다. 이를 통해 데이터 모델을 쉽게 유지보수하고 사용할 수 있다.

     

     

    Protobufjs 사용의 안좋은점

    1. 학습 곡선
      • Protobuf 사용에 대한 학습 곡선이 존재한다. 특히 처음 사용하는 개발자들은 기존의 JSON과는 다른 문법 및 접근 방식을 익히는 데 시간이 필요할 수 있다.
    2. 디버깅의 어려움
      • 이진 형식으로 데이터가 직렬화되기 때문에, 디버깅이 좀 더 어려울 수 있다. JSON의 경우에는 읽기 쉬운 형태로 데이터를 확인할 수 있지만, Protobuf는 이진 형식이므로 해석이 어려울 수 있다.
    3. 단일플랫폼 의존성
      • Protobufjs는 특정 플랫폼에 의존하므로, 서로 다른 플랫폼 간에 데이터 교환에 사용할 경우에는 Protobuf를 지원하는 라이브러리가 필요하다.
    4. 직렬화 및 역직렬화 오버헤드
      • Protocol Buffers를 사용하면 데이터를 이진 형식으로 직렬화하고 역직렬화한다. 이러한 작업은 CPU에 부하를 주는데, 텍스트 형식에 비해 효율적이지만 어느 정도의 오버헤드가 있을 수 있다.
    5. 복잡성 및 대용량 데이터
      • 프로토콜 버퍼 정의가 복잡하고 대용량의 데이터를 처리해야 할 경우, 더 많은 시간과 자원을 요구할 수 있다.
    6. 네트워크 대역폭
      • Protocol Buffers는 텍스트 기반의 형식보다는 데이터를 더 작은 이진 형식으로 표현하므로 네트워크 대역폭이 감소하지만, 역직렬화가 서버에서 수행되기 때문에 전체적인 부하에는 여전히 영향을 미칠 수 있다.

     

     

    장단점이 확실히 있지만, 이진 형식으로 인해 다른 시스템 간에 메시지 개체를 보낼 때 네트워크 트래픽을 줄이고 대기 시간을 줄여 JSON 또는 XML과 같은 다른 방법보다 더 나은 성능을 제공하는 Protobuf는 정말 내가 원하는 모든 요구사항을 다 충족하는 방식이어서, 적용을 안하기엔 아까울 정도였다. 조사에 따르면 XML에 대해서는 1/3, JSON에 대해서는 1/2 크기를 차지한다고 한다. 혹시 몰라 GPT 에게 도입시 고려하고 조심해야하는 부분이 뭔지 물어봤다.

     


     

    GPT 가 알려준 Protobufjs 도입시 고려할 부분.

    1. 성능 측정
      • 실제로 성능 측정을 통해 부하에 어떤 영향을 미치는지 확인하는 것이 중요합니다. Protobuf를 사용한 경우와 사용하지 않은 경우의 성능을 비교해보세요.
    2. 캐싱
      • 복잡한 데이터 구조의 경우, 역직렬화된 결과를 캐싱하여 동일한 요청에 대한 중복 작업을 피할 수 있습니다.
    3. 비동기 처리
      • 복잡한 작업을 비동기적으로 처리하거나, 필요에 따라 역직렬화를 수행하는 서버에서 분리하여 부하를 분산시킬 수 있습니다.
    4. 데이터 양 줄이기
      • 필요한 데이터만 전송하고 받도록 최적화하면 전체적인 부하를 줄일 수 있습니다.
    5. 서버 확장성 고려
      • 서버 확장성을 고려하여 부하를 분산하거나, 클라우드 기반의 서비스를 사용하여 필요에 따라 리소스를 확장할 수 있습니다.

     

    역시 똑PT... 1~5번 사이의 과정을 고려하여 본격적으로 protobufjs 을 적용해보자.

     


    일단 과거에 구현해놓은 이 기능은 통신 구조상 Java ⇒ nodeJS 로 데이터를 넘겨주고 있기 때문에, 각 언어에서 모두 protobuf 를 인코딩/디코딩 할 수 있도록 처리를 해줘야한다.

     

    1. java 모듈 붙이기

    일단 java 용 라이브러리 추가를 위해 jar 를 찾아본다.

     

    우연히 알게된 포멧 치고는 data formats 카테고리중에 1위라 조금 놀랐다...!

    Maven Repository: com.google.protobuf » protobuf-java

    Maven Repository: com.google.protobuf » protobuf-java-util » 3.25.1

    버전은 가장 latest 중에서 가장 사용량이 많은 3.25.1 을 가져와서 넣어준다. (util 도 같이 넣어줘야한다)

     

    그 다음엔 먼저 Java 쪽에서 proto 패키지를 추가하며 아래와 같이 테스트 proto 를 생성해보겠다.

     

    공식문서를 통해 알 수 있겠지만, proto 파일을 Java 로 손쉽게 컴파일할 수 있다. 

    컴파일을 위해 homebrew로 프로토콜 버퍼 컴파일러 설치.

     

     

    정상적으로 설치가 되었는지는 버전 확인을 통해 알 수 있다.

     

    이제 아래 명령어로 테스트 컴파일을 시도해본다.

    protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/person.proto

     

    이 명령어를 통해 자동으로 protoc 명령어가 .proto 파일로부터 Java 출력 파일을 만들어낸다. -option을 이용하여 proto파일이 어디에 위치할지 정할 수 있다. java-out은 어디에 저장될지를 정하는 것을 도와준다.

     

    생성된 클래스는 getter, setter, constructor를 갖고 있다. 그리고 정의된 메세지를 위한 빌더도 갖고 있다. 또한 생성된 클래스는 protobuf 파일을 저장하기 위한 메소드와 binary 파일에서 자바 클래스 파일로 역직렬화 할 수 있는 메소드와 같은 유틸 메소드도 갖고 있다. 이제 Protobuf로 정의된 메세지의 인스턴스를 만들어보자.

     

    메세지 타입에 있는 newBuilder()라는 메소드를 이용해서 테스트해볼 수 있다. 모든 required 필드를 작성한 이후에 build() 메소드를 호출하면, Person 클래스를 만들수 있다. 

    해당 클래스를 통해 Builder 를 구축하고, jsonObject 로 받은 데이터를 parsor 를 활용해 builder 에 merge 시킨뒤, outputStream으로 보내주면 매우 간단하게 끝이다.

     

    이때, content-type을 맞춰주지 않고 json 으로 그냥 넘기면 당연하겠지만 application 에서 json 이 아니라는 오류가 뜬다.

     

    protobuf 의 컨텐츠 타입은 application/x-protobuf 이니 꼭 맞춰야한다.

    또한 나는 setFixedLengthStreamingMode 까지 설정을 해줬다. URLConnection은 기본적으로 클라이언트 메모리가 닫힐 때까지 클라이언트 메모리에 기록된 모든 내용을 버퍼링 한다. 하지만 콘텐츠 길이는 모든 바이트가 기록된 후에만 알 수 있기때문에, 응답 본문이 상대적으로 클 경우 메모리를 많이 차지할 수 있다. 기록되는 바이트의 정확한 양을 미리 알고 있다면 정확히 해당 바이트 양을 setFixedLengthStreamingMode()으로 설정하면 헤더를 훨씬 빨리 설정하고 더 자주 플러시할 수 있기때문에, 나는 body 의 length 로 지정해줬다.

     

    2. nodejs 모듈 붙이기

    이제 보내는 쪽에서는 잘 직렬화하여 보냈으니, 받는쪽에서 역직렬화하여 활용할 수 있도록 풀어주는 작업이 필요하다.

    nodejs 는 너무 쉽게도 npm에 protobufjs 라는게 올라와있어 바로 활용했다.

     

    java에서와 동일한 proto 파일을 작성해주면 되고, 나의 경우 nodejs 에서 express 를 활용하고 있는데 이 경우에는 post 통신으로 받는데이터의 형식을 지정해줘야 수신이 가능하다. 일단 테스트를 위해 아래와 같이 설정해준다.

    express.raw({ type: '*/*' })

     

    그 다음 수신부에서는 아래와 같이 decode 를 한 뒤에 값을 한번 더 검증하는 절차를 넣어줬다.

        // 1. 받은 데이터를 Protocol Buffers 메시지로 파싱
        let requestData;
        try {
            requestData = TestProto.decode(req.body);
        }catch (e) {
            console.error('decode fail')
        }
    
        //2. 데이터 필수값 검증
        const err = TestProto.verify(requestData);
        if (err){
            throw Error(err);
        }

     

    이렇게하면 끝! java에 비해 매우 쉽게 구현된다.

     

     

    사용 후기

    생각보다 적용하는게 쉽고 러닝커브가 높다는 단점을 많이 봤는데 그렇진 않았다. 그리고 당연히 json 에 비해 사람이 읽는건 어렵지만, 바로바로 인코딩 디코딩을 해서 보면 되기 때문에 딱히 개발과정에서의 불편함은 없었다. 또한 json 과 비교했을때 확실히 크기면에서의 차이가 컸다. 하지만 이 부분은 역직렬화시의 부하를 고려해야한다는 gpt 의 조언을 들었으니, 부하테스트를 통해 성능에서 우리 서비스의 구성상 장점으로 작용하는게 맞는지는 검증하는 절차가 더 필요하다.

     

    조금 아쉬웠던 점은, java에서 활용하는 경우 컴파일을 proto에서 한번 java 한번 총 2번의 과정이 필요해서 데이터 스키마를 변경하려고 할때 다소 번거롭다는 점이고, 받는 곳과 보내는 곳에서 모두 작업이 필요해서 한쪽에서라도 잘못보내면 문제가 발생한다. 하지만 이 부분 또한 더 명시적이고 확실하고 검증된 값으로 통신할 수 있는 절차가 생긴 것 같아 오히려 좋았다.

    반응형