Spring Boot RedisTemplate 직렬화 대안 적용(Feat. 프로토콜 버퍼)

최민길(Gale)·2023년 7월 11일
1

Spring Boot 적용기

목록 보기
36/46

안녕하세요 오늘은 프로토콜 버퍼를 이용하여 RedisTemplate 직렬화 부분을 대체하는 작업에 대해 포스팅해보도록 하겠습니다.

우선 직렬화에 대해 알아보도록 하겠습니다. 자바에서 직렬화란 객체를 바이트 스트림으로 인코딩하는 프로세스를 의미합니다. 이렇게 변환된 바이트 스트림의 경우 전송 가능하기 때문에 다른 시스템이나 플랫폼 간에 객체를 전송하거나 프로세스 간에 공유가 필요할 때 사용합니다. 바이트 스트림을 다시 객체로 변환하는 것을 역직렬화라고 합니다. 직렬화로 변환한 바이트 스트림 데이터를 영속적으로 저장하고 역직렬화하여 객체를 복원할 수 있어 데이터를 보존하고 필요할 때 복원할 수 있습니다.

하지만 직렬화에도 단점이 존재합니다. 바로 보안 문제입니다. 자바 내에서 역직렬화를 진행하는 readObject() 메서드는 클래스패스 안의 거의 모든 타입의 객체를 만들어낼 수 있습니다. 이 뜻은 구현 가능한 타입들을 사용하고 있는 코드 전체가 공격 범위에 들어가는, 즉 굉장히 넓은 범위를 공격할 수 있다는 치명적인 문제가 있습니다. 이를 이용하여 직렬화된 데이터에 악성 코드나 악의저인 객체를 삽입하여 데이터 역직렬화 시 실행되어 시스템의 권한 탈취 및 데이터 유출을 야기시킬 수 있는 인젝션 공격이나, 크기가 매우 큰 직렬화된 데이터를 생성하여 시스템 자원을 고갈시키는 서비스 거부 공격(DoS) 등의 취약점에 노출되어 있습니다.

이는 RedisTemplate 객체 사용 시 문제가 되었습니다. 현재 코드에서는 Redis와의 데이터 통신을 위해 전송을 원하는 데이터를 직렬화하여 전송하고, 가져온 값을 역직렬화하여 복원하는 프로세스를 사용했습니다. 하지만 이 경우 직렬화의 취약점에 그대로 노출되기 때문에 다른 대안을 찾아봤습니다.

대안으로 프로토콜 버퍼를 선택했습니다. 프로토콜 버퍼란 구조화된 데이터를 직렬화하고 전송하기 위한 바이너리 포맷입니다. 스키마를 사용한 구조화된 데이터 형식을 정의하여 지정된 필드만 직렬화 및 역직렬화가 가능한 구조를 가져 악의적인 코드를 삽입하는 인젝션 공격을 방지할 수 있습니다. 또한 데이터를 작고 밀도 높은 이진 형식으로 직렬화하기 때문에 데이터 크기를 최소화하여 효율적인 전송을 가능하게 하며, 데이터를 필드 단위로 읽고 쓰는 것이 가능하기 때문에 데이터 구조의 일부만 변경하거나 확장하는 것이 가능하여 유지보수성이 향상됩니다. 하지만 이진 형식으로 데이터를 저장하기 때문에 가독성이 떨어지며, 정적 스키마를 사용하기 때문에 데이터 모델에 대한 변경이나 확장이 어렵다는 단점이 있습니다.

저는 현재 아키텍처에서 Redis를 사용하는 경우는 유저 인증 정보를 저장하는 경우이기 때문에 사용할 스키마가 한정적이고 인증 정보이기 때문에 보안 측면을 더 고려해야 한다고 판단하여 기존 직렬화 방식 대신 프로토콜 버퍼를 선택했습니다. 아래는 유저 정보를 Redis에서 사용하기 위한 프로토콜 버퍼 파일(.proto)의 예시입니다.

syntax = "proto3"; 부분을 명시하지 않으면 proto2로 실행되고 추가해준다면 proto3로 실행됩니다. 두 버전은 몇 가지 차이를 가지고 있는데, proto3가 proto2에 비해 더 간소화된 문법을 가지며 불필요한 기능을 제거한 버전입니다. 반면 proto2의 경우 필드에 기본값을 지정 가능하며 필드 옵션을 통해 필수 필드인지 선택적인 필드인지를 지정할 수 있습니다. 저는 모든 필드를 사용하고 복잡한 로직이 없기 때문에 proto3로 진행했습니다.

option java_package 부분은 .proto 파일을 통해 생성할 클래스의 패키지를 정의합니다. 프로토콜 버퍼는 .proto 파일만으로 실행이 가능한 것이 아니라 .proto 파일을 이용해서 여러 언어에 맞는 클래스를 생성하는 과정을 거쳐야 합니다.(이로 인해 이식성이 뛰어나다는 장점이 있습니다.) 따라서 생성될 클래스의 패키지를 정의하기를 원한다면 클래스가 위치할 패키지를 정의합니다.

option java_outer_classname 부분은 클래스 명을 정의합니다. 하나의 .proto 파일 내에 여러 스키마가 존재할 수 있기 때문에 message 부분은 상위 클래스 내에서 빌더 패턴으로 생성합니다. 이 때 int를 사용할 경우 int32, long을 사용할 경우 int64로 정의하고, 각 필드들의 값들이 비어 있으면 안되고 아래처럼 인덱스를 정의해주어야 합니다.

syntax = "proto3";
option java_package = "com.toda.api.TODASERVERSPRINGBOOT.models.dao";
option java_outer_classname = "UserInfoProto";

message UserInfo {
  int64 userID = 1;
  string userCode = 2;
  string email = 3;
  string password = 4;
  string userName = 5;
  string appPassword = 6;
}

.proto 파일을 통해 클래스를 생성하기 위해선 프로토콜 버퍼 컴파일러를 설치해야 합니다. 맥의 경우 Homebrew를 이용해서 아래의 명령어로 설치할 수 있습니다.

brew install protobuf

아래는 .proto 파일을 java 클래스로 변환하는 명령어입니다. cli에서 아래 명령어를 수행하면 java 클래스 파일이 생성됩니다. 여기서 주의할 점은 패키지를 추가할 상위 경로와 .proto 파일 내에서 정의한 option java_package 과 겹치게 하면 안된다는 점입니다. java_package에서 정의한 부분은 protoc 명령에서 입력받은 상위 경로 내에서 정의된 패키지 내에 클래스를 생성한다는 의미입니다. 따라서 두 경로가 겹치게 된다면 패키지 상위 경로에서 새롭게 java_package에서 정의한 경로대로 폴더를 만들어 그 내부에 존재하게 됩니다. 따라서 이 점을 주의해서 생성해주시면 됩ㄴ디ㅏ.

protoc --java_out={패키지 추가할 상위 경로} {.proto 파일 경로}

아래는 생성된 UserInfoProto 클래스 내부입니다. 보시는 것처럼 message에서 정의한 UserInfo 클래스가 정적 팩토리 메서드로 정의되어 있으며 객체 생성은 빌더 패턴을 이용하여 정의되고 있습니다.

public static final class UserInfo extends
      com.google.protobuf.GeneratedMessageV3 implements
      // @@protoc_insertion_point(message_implements:UserInfo)
      UserInfoOrBuilder {
      ...

아래는 RedisTemplate를 사용하는 예시입니다. 기존 방식의 경우 RedisTemplate 클래스를 @Bean 주입 시 직렬화 과정을 통해 내부 로직에서는 객체를 파라미터로 하여 통신했습니다. 하지만 프로토콜 버퍼로 변경하게 되면서 Redis에 저장된 바이트 스트림을 변환을 원하는 객체에 매핑시킵니다. 역직렬화 과정에서 다른 타입이 들어올 경우 성공하지 않기 때문에 예외를 발생시킬 수 있어 InvalidProtocolBufferException을 던지게 됩니다.

    default <T> T getRedis(String key, Class<T> c) throws InvalidProtocolBufferException {
        byte[] bytes = getValueOperations().get(key);
        if(c == UserInfoAllDao.class){
            UserInfoProto.UserInfo userProto = UserInfoProto.UserInfo.parseFrom(bytes);
            UserInfoAllDao userInfoAllDao = UserInfoAllDao.builder()
                    .userID(userProto.getUserID())
                    .userCode(userProto.getUserCode())
                    .email(userProto.getEmail())
                    .password(userProto.getPassword())
                    .userName(userProto.getUserName())
                    .appPassword(userProto.getAppPassword())
                    .build();
            @SuppressWarnings ("unchecked") T res = (T) userInfoAllDao;
            return res;
        }
        else throw new WrongArgException(WrongArgException.of.WRONG_TYPE_EXCEPTION);
    }

반대의 경우도 비슷하게 동작합니다. Redis로 넘겨주고자 하는 객체를 .proto 파일을 이용해서 생성한 클래스를 생성한 후 이를 바이트 스트림으로 변환하여 RedisTemplate에서 직접 참조하게 합니다.

    default <T> void setRedis(T obj, Class<T> clazz){
        String key = "";
        if(clazz == Claims.class){
            Claims claims = (Claims) obj;
            key = claims.getSubject();
        }
        else if(clazz == String.class) key = (String) obj;

        UserInfoAllDao userInfoAllDao = getRepository().getUserInfoAll(key);
        UserInfoProto.UserInfo userProto = UserInfoProto.UserInfo.newBuilder()
                .setUserID(userInfoAllDao.getUserID())
                .setUserCode(userInfoAllDao.getUserCode())
                .setEmail(userInfoAllDao.getEmail())
                .setPassword(userInfoAllDao.getPassword())
                .setUserName(userInfoAllDao.getUserName())
                .setAppPassword(userInfoAllDao.getAppPassword())
                .build();

        getValueOperations().set(key,userProto.toByteArray());
    }

이 경우 앞서 설명드린 프로토콜 버퍼의 장점을 누릴 수 있으나, 단점에서 언급했듯이 Redis 내에 저장되는 데이터가 바이트 스트림 형식으로 되어 있기 때문에 가독성이 떨어집니다. 따라서 프로토콜 버퍼의 장단점을 잘 고려하여 프로젝트에 적용하면 좋은 결과가 있을 것 같습니다. 이상으로 포스팅 마치도록 하겠습니다!

profile
저는 상황에 맞는 최적의 솔루션을 깊고 정확한 개념의 이해를 통한 다양한 방식으로 해결해오면서 지난 3년 동안 신규 서비스를 20만 회원 서비스로 성장시킨 Software Developer 최민길입니다.

0개의 댓글