프론트엔드 프로젝트에 프로토버퍼 적용 고군분투기

1. 초기이슈

  • 어떻게 .proto 파일을 임포트 할 것인가
  • 어떻게 .proto 파일을 외부에 노출하지 않을 것인가
  • 인코딩과 디코딩의 자동화

2. 해결

2-1. 사전준비: 필요 라이브러리

yarn add protobufjs

끝!

2-2. .proto 파일 임포트 이슈

결론부터 말하면 .js 파일로 변환하여 임포트했다. 번거롭게 웹펙이나 번들러 설정을 건드릴 필요도 없고, 외부에 노출되지 않으며 동기적으로 호출할 수 있다.

# .proto 파일들이 ./assets/proto/src 디렉토리에 존재한다고 가정하고,
# 최종 산출물을 ./assets/proto/proto.js 로 익스포트 하는 npm 스크립트

npx pbjs -t json-module -w commonjs -o ./assets/proto/proto.js ./assets/proto/src/*.proto

위 package.json 의 scripts 부분에 삽입한 후 적당한 시점에 실행시켜 준다. 나의 경우에는 dev(start) 와 빌드 전에 실행하도록 했다.

산출된 결과문을 일반적인 모듈형태로 임포트 된다. 참고로 Protobuf.js 의 ProtoRoot 라는 이름의 객체가 반환된다.

import ProtoRoot from '@/assets/proto/proto';

2-3. 인코딩과 디코딩

protobuf.js 공식 사이트에 문서 페이지를 꼼꼼하게 읽어봐도 결국에는 장대한 삽질이 이어졌다. 다음은 인코딩과 디코딩 코드다.

export function encode (data, type) {
    const message = ProtoRoot.lookup(type);
    const buffer = message.encode(message.fromObject(data)).finish();

    return btoa(String.fromCharCode.apply(null, buffer));
}

export function decode (data, type) {
    const message = ProtoRoot.lookup(type);
    const buffer = Uint8Array.from(atob(data), c => c.charCodeAt(0));

    return message.decode(buffer);
}

자, 이제 인코딩, 디코딩용 모듈도 만들었으니 통신용 모듈에 연결하여 response와 request 내용을 가로채서 인코딩 디코딩을 해주면 된다. 보람찬 일이었다. 이제 아무일도 일어나지 않을 거라고 생각했으나...

3. Type: google.protobuf.

3-1. 이제 아무것도 두렵지 않아

복병이 등장하고야 말았던 것이다. 백엔드에서 프로토버퍼를 도입한 후 한가지 이슈가 있었는데 falsy 값을 인코딩하면 어째서인지 그 항목 자체가 사라져 버린다는 것이었다. 그래서 기본 타입말고 구글에서 제공하는 라이브러리를 사용해서 falsy 값이 들어갈 수 있는 항목에 적용을 했는데, 적용후 멀쩡하던 통신들이 에러를 뿜어내기 시작했다.

JSON 데이터의 내부를 살펴보니 금방 원인을 알 수 있었다. 데이터의 구조 자체가 바뀐것이다. 일단 혼란을 막기 위해 위의 타입을 "구글 타입" 이라고 하자.

구글 타입으로 지정된 데이터를 웹에서 받이 디코딩을 해보면 이런 형태가 된다.

// 서버에서 내려준 값
{
  foo: 'bar',
}

// 실제로 프론트에서 받은 값
{
  foo: { value: 'bar' },
}

기존에 받고 있던 데이터와 형태가 달라졌으니 에러가 발생하는 것. 게다가 어째서인지 iOS와 Android에서는 구글 타입 적용후 해당 빈값이 적용된 항목들이 정상적으로 내려오기 시작했으나 웹에서는 변함없이 내려오는 중이었으니, 웹 프론트 입장에서는 그야말로 개악이었다(일해라 구글... 저거 너네들이 관리하는 라이브러리 아니냐!)

일단, 앱 파트에서 제대로 받아진다고 하니 웹 프론트 파트에서 불편을 감수하기로 했다. 일단, 저 { value } 형태로 내려오는 값들은 원래 그렇게 생겼다고 자기 최면을 걸기로 했고, 내려오지 않는 값들은 디코딩시에 설정된 기본값들을 강제로 주입시켜 주기로 했다.

export function decode (data, type) {
    const message = ProtoRoot.lookup(type);
    const buffer = Uint8Array.from(atob(data), c => c.charCodeAt(0));
    const decoded = message.decode(buffer);

    return message.toObject(decoded, { defaults: true });
}

3-2. 인코딩은 어떻게 하지

문제는 인코딩이었다. JSON 데이터를 만들때 구글 타입에 해당하는 값들은 반드시 { value } 형태로 감싸줘야 인코딩이 됐던것이다. 에러가 발생하면 그때마다 수작업으로 해주면 못할것도 없지만 뭔가 스마트하지 않다. 길고긴 프로토버퍼 명세를 보면서 타입을 확인하는 걸 매번 하는 것도 귀찮다.

그냥 하던데로 하고, 인코딩할 때 해당 형태로 파싱하게 하자!

import fp from 'lodash/fp';
//  우리의 친구 Lodash를 사용했지만 로직만 이해한다면 그냥 Javascript 로 짜도 좋고, 다른 라이브러리를 써도 좋다.


const parseMessage = (data, type) => ({
    message: ProtoRoot.lookup(type),
    parsed: parseData(data, ProtoRoot.lookup(type).fields),
});

const parseData = (data, fields) => {
    const convert = (key, value) => (
        typeof value === 'object' && key !== 'value'
            ? parseMessage(value, fields[key].type).parsed
            : value
    );

    return fp.flow(
        fp.toPairs,
        fp.reduce((acc, [key, value]) => { 
            acc[key] = convert(key, value);
            return acc;
        }, {}),
        fp.toPairs,
        fp.reduce((acc, [key, value]) => {
            acc[key] = /^google\.protobuf\./.test(fields[key].type)
                ? { value: value }
                : value;
            return acc;
        }, {}),
    )({...data});
};

export function encode (data, type) {
    const { message, parsed } = parseMessage(data, type);
    const buffer = message.encode(message.fromObject(parsed)).finish();

    return btoa(String.fromCharCode.apply(null, buffer));
};

4. 어쨌든 잘 쓰고는 있습니다

잘 쓰고 있다. 겸사겸사 회사에서 걱정하고 있던 다른 회사에서 API 데이터를 무단으로 긁어가던 문제도 해결됐다(디코딩된 내용을 .proto 파일이 없다면 인간이 알아볼 수 있는 형태로 인코딩 할 수 없다는 점에서는 일종의 암호화일 수도 있다. 물론 암호화와는 근본적으로 다르고, 애초에 이 기술의 개발된 목적과 방향성 자체도 다르다).

근데, 구글님아... 꼭 이렇게 만들어야 했을까? 정말, 이게 최선이었어...?

좋은 웹페이지 즐겨찾기