Spring과 Redis를 쓰다 발견한 것

Jeeho Park (aquashdw)·2024년 10월 28일
0

Spring Data Redis 삽질기이다. Generic에 대한 고찰, Spring Data Redis에 관한 내용이 주를 이룬다. 기초적인 Java, Kotlin 문법에 대해 알아야 하지만, 그 내용이 주제는 아니다.

서론

우선 본격적으로 이야기를 시작하기 전에, 글쓴이는 코린이이다. 코틀린을 아예 안써본것도, 일로서 안써본것도 아니지만, 마지막으로 코틀린을 일로 썻을 당시 시대는 이제 막 빅테크에서 코틀린 도입을 시도해보고 있던, Android가 "앞으로 Java 대신 Kotlin 쓸거임"하던 시절이기 때문에 빈말로도 Kotlin을 잘 쓴다고 하기는 어렵다. 다른 부분보다는 Kotlin을 쓰면 Java 최대의 적 NullPointerException을 매우 아름답게 피할 수 있다는 점 때문에 매력을 느꼇고, 지금도 "나도 언젠간 Kotlin을 좀더 다양한 방식으로 쓰겠지" 하면서 문법이라도 익혀두자 라는 생각으로 Kotlin을 열심히 사용하고 있다.

물론 일적으로 쓰는건 아니다. 일적으로 개발을 하고있지 않기도 하거니와, 나의 아쉬운점을 지적해줄 사람이 없는 상태에서는 업무용으로 쓰기 애매하니까.

지금 진행하는 사이드 프로젝트는 NextJS + Kotlin Spring Boot를 이용해 개발중이다. 사이드에서 스택을 선택하는 이유는 개발(開發)적인 부분 보다는 계발(啓發)적인 부분이 더 큰데, 이것도 마찬가지이다.

  1. NextJS가 React 진영에서 인기가 많아서 해볼려고
  2. Kotlin 문법에 익숙해지려고

기술적인 부분은 나중에 찾아서 채우고 다음 프로젝트에 적용하면 된다고 본다.

그리고 추가로 Redis를 사용한다. 복잡한건 아니고 Session Clustering을 위해서 사용한다. 물론 사이드 프로젝트에서 굳이 Session Clustering이 필요한가.....에 대한 의문이 있기는 하다. 그래도 이것도 마찬가지로 계발적 이유로 사용해보았다. 서비스에 로그인하는 과정이 있는데,

  1. 사용자가 로그인 요청 -> 세션 생성
  2. 사용자 이메일로 인증 요청
  3. 인증할때 세션이 존재하는지 확인 -> JWT 발급

요 과정을 위해 세션 정보를 Redis에 저장한다.

이때, Redis에서 세션 정보를 회수하는 과정에서 예상하지 못한 예외가 발생했고, 그것을 보고하기 위해 몇가지 상황을 상정하고 테스트 해봤다. 마지막으로 Spring Data Redis GitHub의 이슈 목록을 확인하는 과정을 거쳤다. 이 글은 그 과정을 기록한 글이다.

간단한 기술 리뷰

우선 유관 기술들의 리뷰를 간단하게 해보자. 잘 알고 있는 내용이라면 스킵 가능.

Redis

Redis는 REmote Dictionary Server의 약자이다. Dictionary는 Python의 Dictionary 자료형이나, Java의 Map 컬랙션을 생각하면 되는데, Key에 Value를 매핑해서 보관하는 것이다. Redis는 이런 방식으로 데이터를 저장하기 위한 Server라는 것이다. 즉 어떤 Key의 끝에 데이터를 연결하여 저장하고, 나중에 데이터를 찾고 싶을 때 그 Key를 사용하는 것이다.

# Redis에 Key Value를 저장한다.
SET name jeeho
SET age 100

# Redis에서 Key를 기준으로 데이터를 가져온다.
GET name  # jeeho
GET age   # 100

이때, 저장할 수 있는 데이터의 자료형은 String, List, Set, Hash 등 다양하게 존재하며, 데이터를 다루기 위한 다양한 명령을 제공한다. 위의 예시는 String 자료형으로 Redis에 데이터를 저장한 것이고, 다른 자료형들은 이런 String 들을 특정 구조로 저장하고 회수할 수 있도록 해주는 자료 구조에 가깝다.

  • List: String으로 구성된 Linked List
  • Set: String으로 구성된 Set
  • Hash: Key와 Value가 String인 Hash

만약 저장된 데이터가 특정 자료형의 표현이라면, 그 데이터를 다루기 위한 기능도 제공한다. 예를 들어 저장된 문자열이 정수 데이터를 표현한다면(100), 이를 정수로 다루기 위한 기능(예: 1 증가하기)을 몇가지 제공하는 것이다.

INCR age
GET age  # 101

가장 중요한 특징 중 하나로 In-Memory 데이터베이스이기 때문에 디스크에 데이터를 저장하는 일반적인 데이터베이스에 비해 데이터를 저장, 회수하는 속도가 더 빠르다. 그래서 세션과 같이 자주 조정되는 데이터를 여러 프로세스에서 공유해야 할 때 많이 활용된다.

RedisTemplate

💡 이하의 코드는 Java로 작성되었다.

RedisTemplate은 Spring Data Redis를 이용해 Redis를 활용할 때 사용할 수 있는 class중 하나이다.

앞서 이야기했듯 기본적으로 Redis는 대부분의 데이터가 문자열을 기준으로 동작한다. 만약 정수의 데이터를 카운터 목적으로 사용한다고 하더라도, String 자료형으로 저장을 한다는 의미. 한편 Java를 비롯한 다양한 프로그래밍 언어는 다양한 자료형이 존재하기 때문에, 문자열을 다시 자신이 원하는 형태로 전환하는 과정이 필요하다는 의미이다.

RedisTemplate은 이런 상황에서 데이터의 변환을 도와주는 class라고 생각하면 된다. 예를들어 Redis에 현재 접속중인 사용자의 수를 저장하고 싶다면, 다음과 같이 RedisTemplate을 만들어볼 수 있다.

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Integer> counterTemplate (
            RedisConnectionFactory connectionFactory
    ) {
        RedisTemplate<String, Integer> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        return template;
    }
}

요렇게 만들어놓으면 우리가 필요한 시점에 RedisTemplate을 주입받을 수 있고, RedisTemplate이 제공하는 여러 Operations을 통해 Redis의 자료형들을 다룰 수 있다.

@Service
// Lombok
@Slf4j
@RequiredArgsConstructor
public class CountService {
    // Spring Container가 자동으로 주입해준다.
    private final RedisTemplate<String, Integer> counterTemplate;
    public void increment(String key) {
        // Redis의 문자열 작업을 할 수 있게 해주는 interface 
        ValueOperations<String, Integer> valueOperations =
                integerTemplate.opsForValue();
        // key에 해당하는 값을 1 증가시킨다.
        valueOperations.increment(key);
        log.info(valueOperations.get(key).toString());
    }
}

즉 원래라면 문자열로 주고받아야 할 데이터를 Java의 클래스로 자동으로 매핑해주는게 RedisTemplate이다. 관계형 데이터베이스의 테이블을 객체로 만드는 ORM이랑 유사하다 볼 수.....있나?

String, Integer, Long같은 단순한 자료형만이 아니라 일반적인 POJO 객체도 활용할 수 있다. 예를들어 쇼핑몰의 상품 같은 정보를 Redis에 저장한다고 하면?

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, ItemDto> itemTemplate(
            RedisConnectionFactory connectionFactory
    ) {
        RedisTemplate<String, ItemDto> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        return template;
    }
}

이렇게 만든 RedisTemplate은 별다른 설정 없이도 Key에 ItemDto 인스턴스를 저장할 수 있게 해준다.

+ 만약 단순 문자열 작업만 한다면 이를 위한 편의 class인 StringRedisTemplate도 제공한다. 이는 RedisTemplate<String, String> 이라고 생각할 수 있다.

@SpringBootTest
public class RedisTemplateTests {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Test
    public void stringValueOpsTest() {
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        ops.set("simplekey", "simplevalue");
        System.out.println(ops.get("simplekey"));
        ops.set("greeting", "hello redis!");
        System.out.println(ops.get("greeting"));
    }
}

RedisTemplate을 좀더 보자

RedisTemplate은 자유도가 매우 높다. 자신이 원하는 Java 자료형을 대충 지정하면 큰 문제없이 동작한다. 다만 별다른 설정 없이 사용하게 되면, Redis를 열어봤을 때 이상한 모습을 볼 수 있을 것이다.

옴마야....이게 무슨말일까? ItemDto 같기는 한데 이런 형태로는 우리는 무슨 데이터인지 알아보기 힘들다. 이는 RedisTemplate의 기본 직렬화 방식이 Java Serializer를 사용하기 때문이다.

직렬화 & 역직렬화

직렬화와 역직렬화는 워낙 유명한 주제이니 대부분 들어봤을 것이다. 데이터를 나중에 다시 읽거나, 다른 프로세스에서 읽을 수 있는 형태로 변환하는 과정이 직렬화(Serialize), 직렬화된 데이터를 프로그램에서 사용할 수 있도록 읽는 과적이 역직렬화(Deserialize)이다.

JSON 같은걸 생각하면 된다. JSON은 특정 규칙을 가지고 작성하는 "문자열"의 일종이고, 이 문자열을 다시 해석해서 Java의 DTO 따위의 객체로 읽어들이는 것이 대표적인 직렬화 & 역직렬화의 과정이다.

// Java 클래스
public class ItemDto implements Serializable {
    private Long id;
    private String name;
    private String description;
    private Integer price;
    private Integer stock;
}
// 위의 클래스 인스턴스 하나를 표현한 JSON
{
  "id": 1,
  "name": "Mouse",
  "description": "Logitech MX Master 3S",
  "price": 130000,
  "stock": 100
}

위의 ItemDto 인스턴스를 아래의 JSON으로 바꾸는게 직렬화, 아래 JSON에서 ItemDto를 만드는게 역직렬화.

RedisTemplateSerializer

Java Serialization은 Java가 제공하는 직렬화 방식 중 하나이다. 우리가 사용하는 Java 객체를 바이트의 형식으로 저장하여, 네트워크 등을 통해 주고받을 수 있도록 해준다. 그리고 현재는 여러 문제로 인해 거의 사장된 방식의 직렬화 역직렬화 방식이다. 단순히 생각해봐도 Java만 벗어나면 사용할 수 없는 데이터가 아닌가. 이에 대한 자세한 내용은 주제를 벗어나니 찾아볼 것.

문제는 이게 RedisTemplate의 기본 동작 방식이라는 것이다. 즉 RedisTemplate도 Redis랑 데이터를 주고받기 위해, 주어진 데이터를 직렬화 & 역직렬화 하는 과정이 필요하다. 앞서 사용했던 예시를 보면,

// RedisConfig
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, ItemDto> itemTemplate(
            RedisConnectionFactory connectionFactory
    ) {
        RedisTemplate<String, ItemDto> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        return template;
    }
}

여기서 만든 itemTemplate을 사용하면, 내부적으로는 ItemDto의 데이터를 단순한 문자열로 저장하기 위해 직렬화 과정을 거치게 된다.

// ItemService
@Service
@RequiredArgsConstructor
public class ItemService {
    private final RedisTemplate<String, ItemDto> itemTemplate;

    public void setItem(String key, ItemDto dto) {
        ValueOperations<String, ItemDto> ops =
                itemTemplate.opsForValue();
        // 내부에서 DTO를 문자열로 변환한다.
        ops.set(key, dto);
    }
}

그리고 그 과정에서 사용하는 기본 Serializer가 JdkSerializationRedisSerializer인데, 이게 Java Serialization을 이용하는 클래스이다. 그러다보니 결국 Redis에 저장되는 데이터는 Java 객체가 직렬화된, 인간이 읽을 수 없는의 형태로 저장되는 것이다...

정말 다행인건 이는 매우 간단하게 설정이 가능하다. Spring Data Redis에서 제공하는 Serializer들도 다양하게 있다.

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, ItemDto> itemTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, ItemDto> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        // 단순 문자열로 Key를 직렬화 역직렬화 한다.
        template.setKeySerializer(RedisSerializer.string());
        // JSON 형태로 Value를 직렬화 역직렬화 한다.
        template.setValueSerializer(RedisSerializer.json());
        return template;
    }
}
  • RedisSerializer.string(): 문자열 기반 직렬화기를 반환하는 정적 메서드
  • RedisSerializer.json(): Jackson 기반 JSON 직렬화기를 반환하는 정적 메서드

이렇게 설정하면 Key는 문자열로, Value는 JSON으로 변환되 저장되어 좀더 편하게 읽을 수 있다.

그래서 문제가 뭐냐면

💡 이하의 코드는 Kotlin으로 작성되었다.

오캄의 면도날은 간단한 것을 요구한다. 나도 마찬가지다. 복잡한건 지옥이다. 애초에 기능 자체가 복잡할 필요가 없다.

Redis를 이용하는 이유는 로그인 요청 세션을 저장하기 위해서였다. 여기서 내가 Redis에 저장해야 하는 데이터는 하나 뿐이었는데, 바로 로그인을 한 사용자의 PK이다. 그래서 만든 RedisTemplate도 다음과 같은 형식이었다.

@Configuration
class RedisConfig() {
    @Bean
    fun signInSessionTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<String, Long> {
        val template = RedisTemplate<String, Long>()
        template.connectionFactory = connectionFactory
        template.keySerializer = RedisSerializer.string()
        template.valueSerializer = RedisSerializer.json()
        return template
    }
}

즉 Key는 문자열로 저장하고, PK는 JPA에서 정의한것과 동일하게 Long으로 설정해준 것이다. Key는 문자열인 만큼 그냥 String Serializer를, Value는 습관처럼 JSON Serializer를 사용했다.

다음은 로직을 담당한 AuthService이다. 두개의 메서드가 등장하는데,

  1. requestSignIn: 사용자가 로그인을 요청하면서 email을 제공하면, Redis에 로그인을 시도한 사용자 정보를 저장한다.
  2. finalizeSignIn: 사용자가 이메일로 보내진 링크를 클릭하면, 링크에 포함된 토큰을 바탕으로 Redis에서 사용자 정보, PK를 회수한다. 회수된 PK를 바탕으로 JWT를 만들어 발급한다.
@Service
class AuthService(
    val userRepo: UserRepo,
    val jwtUtils: JwtUtils,
    signInTemplate: RedisTemplate<String, Long>,
) {
    private final val signInOps: ValueOperations<String, Long>
    init {
        signInOps = signInTemplate.opsForValue()
    }

    // 1. 사용자가 email을 제출해서 로그인을 요청한다.
    fun requestSignIn(jwtRequestDto: JwtRequestDto) {
        val user = userRepo.findByEmail(jwtRequestDto.email)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
        val token = UUID.randomUUID().toString()
        // java의 `set` 메서드이다. (set(token, user.getId());) 
        signInOps[token] = user.id!!
        logger.debug("issuing session for ${user.id} - $token")
        // TODO send email for jwt issue link
    }

    // 2. 이메일에 전달된 링크를 클릭하면 이 메서드로 이어진다.
    fun finalizeSignIn(sessionToken: String): String {
        // java의 `get` 메서드이다. (get(token);)
        val userId = signInOps[sessionToken]
            ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST)
        if(!userRepo.existsById(userId))
            throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR)

        val jwt = jwtUtils.generateToken(userId)
        logger.debug("issue jwt for: $userId - $jwt")
        return jwt
    }
}

이때, 1번 과정은 문제없이 진행되었다. 문제는 2번 과정이었다.

java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long (java.lang.Integer and java.lang.Long are in module java.base of loader 'bootstrap')
	at AuthService.finalizeSignIn(AuthService.kt:44) ~[main/:na]

그러니까 Redis에서 회수하려는 데이터가 Long이 아니라 Integer라는 소리다....! 사실 지금와서 생각해보면 얼마든지 가능한 일이긴 하다. 이는 나중에 설명한다 치자. 근데 더 신기한건 이후 이 현상을 테스트 해보려 간단한 테스트 코드를 작성했다.

@Test
@DisplayName("redis template test?")
fun testSerializer(
    @Autowired
    connectionFactory: RedisConnectionFactory
) {
    val template = RedisTemplate<String, Long>()
    template.connectionFactory = connectionFactory
    template.setDefaultSerializer(RedisSerializer.json())
    template.afterPropertiesSet()
    val ops = template.opsForValue()
    // set
    ops["asdf"] = 1
    // get
    println(ops["asdf"])
}

놀랍게도, 이 코드는 아무 문제 없이 동작해 버린 것이다! 왜 이런 것일까?

원인을 찾아보자.

그래서 이 문제를 좀더 파보기로 했다. 먼저 다음의 가정을 세우고 확정하기 위해 테스트 코드를 작성했다.

  1. Long Value를 변수에 할당하려고 할때는 예외가 발생한다.
  2. println(System.out.println)에서 데이터를 출력하려고 할때는 예외가 발생하지 않는다.
@Test
@DisplayName("redis template test?")
fun testSerializer(
    @Autowired
    connectionFactory: RedisConnectionFactory
) {
    val template = RedisTemplate<String, Long>()
    template.connectionFactory = connectionFactory
    template.setDefaultSerializer(RedisSerializer.json())
    template.afterPropertiesSet()
    val ops = template.opsForValue()
    ops["asdf"] = 1
    // 다음 코드는 예외를 발생시키지 않아야 한다.
    assertDoesNotThrow {
        println(ops["asdf"])
    }
    // 다음 코드는 예외를 발생 시켜야 한다.
    assertThrows<Exception> {
        val result = ops["asdf"]
        println(result)
    }
}

그리고 테스트는 매우 잘 통과했다...여기서부터 찾아 들어가기 시작했다. 그리고 결과는 생각보다 매우 기초적인 Java의 작동 방식 때문이었다.

Generic과 Type Erasure

💡 이하의 코드는 Java로 작성되었다.

먼저 이 현상을 이해하기 위해서 한가지 이해해야 하는 부분은 Generic의 동작 방식이다. Generic은 "일반적인" 이라는 뜻을 가지고 있고, 부르는 명칭은 몰라도 Java 개발자라면 맨날 사용하는 기능이다.

Linked List로 설명을 해보자. Linked List는 일반적으로 우리가 알고 있는 자료 구조이며, 다음의 특성을 가지고 있다.

  • 여러 데이터를 선형으로 보관할 수 있다.
  • 각 데이터가 노드의 형태로 보관된다.
  • 하나의 노드에는 다음 순서의 데이터가 담긴 노드의 위치가 담긴다.

논리적인 의미의 Linked List에서는 데이터의 자료형은 중요하지 않다. 하지만 구현하고자 하는 입장에서는 Linked List가 담을 데이터의 자료형을 알아야 하는게 본래의 개발 방식이었다. 이 말은 즉, LinkedList를 만드는 입장에서 노드를 구현하고자 한다면

public class Node {
    private /*자료형*/ data;
    private Node nextNode;
}

따위로 만들어야 한다. 여기서 담고자 하는 데이터가 정수라면 int를, 실수라면 double/*자료형*/ 부분에 넣어야 할것이다. 그리고 자료형 만큼의 Linked List 구현체가 등장하게 될 것이다. 초기의 Java(1.5 이전)에서는 이런 불편함에서 회피하기 위해, 모든 자료형의 조상 class인 Object형으로 구현체를 만듦으로서, 하나의 자료구조당 하나의 구현 클래스만 만드는 방식을 활용했다.

public class Node {
    private Object data;
    private Node nextNode;
}

Generic은 이렇게 자료형과 상관없이 동일하게 작동하는 기능을, 자료형과 상관없이 "일반적으로" 동작하게끔 구현하는 방식을 제공해준다.

public class Node<T> {
    private T data;
    private Node nextNode;
}

여기서 T는 타입 파라미터라고 불리며, 문자 그대로 타입, 즉 자료형을 매개변수로 전달할 수 있다는 의미이다. 자료형과 관계없는 기능은 기능대로 구현하고, 타입 파라미터의 위치에 나중에 코드를 사용하는 입장에서 타입을 전달할 수 있도록 하는 것이다. 우리가 평소에 사용하는 Java의 Collection, Optional, Stream 등 많은 곳에서 Generic은 활용된다.

List<Integer> intList = new ArrayList<>();
Optional<Long> optionalLong = Optional.empty();
// BufferedReader reader;
Stream<String> lineStream = reader.lines();

하지만 Generic이 소개된 시점은 Java 1.5였고, 그 시점에는 이미 Generic이 없는 시절에 작성된 코드가 많이 퍼지게 된 상황이었다. 그래서 Generic은 이전 코드와의 호환성을 유지하기 위해, Generic의 타입은 컴파일 단계에서만 반드시 지키게 만들고, 컴파일 하는 과정에서 타입 파라미터를 지우고 대신 그 자리에 Object를 넣는 방식으로 개발되었다. 이 현상이 Type Erasure이다.

RedisSerializer 탐험기

이제 Generic과 Type Erasure를 알았으면, RedisSerializer 인터페이스를 살펴보자. 다른건 필요없고, deserializejson()만 찾아보면 된다.

public interface RedisSerializer<T> {
    // ...
    
    static RedisSerializer<Object> json() {
        return new GenericJackson2JsonRedisSerializer();
    }
    
    // ...

    @Nullable
    T deserialize(@Nullable byte[] bytes) throws SerializationException;

    // ...
}

문제가 보이나? RedisSerializerdeserialize 메서드는 타입 파라미터 T를 반환하도록 만들어져 있지만, json() 메서드가 반환하는 Serializer는 타입 파라미터가 ObjectRedisSerializer 구현체를 반환하도록 만들어져 있었고, 결국 실제로 역직렬화를 담당하는 GenericJackson2JsonRedisSerializer 인스턴스는 내가 선언한 타입 파라미터가 전혀 전달이 안되는 것이었다.

그리고 그렇게 만들어진 GenericJackson2JsonRedisSerializer를 살펴보면 오버라이딩 된 deserialize 메서드를 찾을 수 있는데,

@Override
public Object deserialize(@Nullable byte[] source) throws SerializationException {
    return deserialize(source, Object.class);
}

@Nullable
@SuppressWarnings("unchecked")
public <T> T deserialize(@Nullable byte[] source, Class<T> type) throws SerializationException {

    Assert.notNull(type, "Deserialization type must not be null;"
            + " Please provide Object.class to make use of Jackson2 default typing.");

    if (SerializationUtils.isEmpty(source)) {
        return null;
    }

    try {
        return (T) reader.read(mapper, source, resolveType(source, type));
    } catch (Exception ex) {
        throw new SerializationException("Could not read JSON:%s ".formatted(ex.getMessage()), ex);
    }
}

...내부적으로는 타입을 전달받아 사용할 수 있는 deserialize(source, type) 메서드를 타입을 전달받지 않고 사용하는 deserialize(source)로 감싸서 사용하고 있음을 발견하였다. 즉, 타입 파라미터를 전달하더라도, json()의 결과로 반환된 Serializer를 사용하게 된다면, 내부적으로는 대충 Object로 취급하면서 돌리다가, 마지막의 마지막에 내가 필요로 하는 순간 Long으로 변환을 시도, 그러면서 ClassCastException이 발생하고 있는 것이었다...

이걸 좀더 자세히 살펴보면,

  1. Long 형으로 직렬화를 진행하면 어차피 숫자(JS의 number)이기 때문에 숫자의 형식으로 직렬화가 된다.

  1. 근데 이렇게 저장된 숫자를 나타낸 문자열 "1"은 역직렬화를 할때 Integer로 역직렬화를 하게 된다.
  2. 이후 Integer라는 자료형은 Object의 그늘에서 조용히 숨어있다가 내가 "Long 주세요!"할 때, 자신의 정체를 드러내는 것이다.

이게 가능한 이유는 결국 Type Erasure 때문이다. 코드만 봤을때는 T가 반환되어야 할것 같아 보이지만, 컴파일 단계에서는 T가 아니라 Object가 되버리기 때문에, Object를 반환하는 Serializer를 그대로 활용할 수 있다는 의미이다!

그럼 왜 println은 되는거지?

문제가 왜 발생하는지는 깨달았다. 그 다음 의문은 println은 된다는 사실이었다. 이건 사실 좀더 간단한 문제였고, 원래 알고있던 내용이기도 하다. 단순히 println 코드를 따라가면 된다.

public void println(Object x) {
    String s = String.valueOf(x);
    if (getClass() == PrintStream.class) {
        // need to apply String.valueOf again since first invocation
        // might return null
        writeln(String.valueOf(s));
    } else {
        synchronized (this) {
            print(s);
            newLine();
        }
    }
}

수 많은 형태의 오버로딩된 System.out.println 메서드 중 하나는 Object x를 인자로 받는 메서드가 있다. 그러니까, 아무 객체나 던져 넣으면 동작하는 메서드이다. 얘는 타입 파라미터랑은 상관없이 처음부터 Object를 기다리고 있었고, String.valueOf() 메서드를 통해 타입과 상관없이 출력할 문자열을 만들기 때문에, 돌아온 데이터가 IntegerLong이냐가 상관 없었던 것이다...Type Erasure를 통해 Long이어야 할 반환형이 Object로 변환되었지만 println은 처음부터 그런건 신경 안썼다는 의미!


Spring Boot를 쓰다가 어쩌다보니 Generic, Type Erasure의 Edge Case에 빠져들었던 것이다. 사실 Generic과 Type Erasure는 이론으로는 항상 설명하긴 하지만, 실제로 이렇게 만날일은 많지 않으니까 어찌보면 좋은 경험이라고 할 수 있지 않을까?!

결말

이 문제를 처음 봤을때는 난 "이슈다!"하고 나름 긍정적으로 생각할려고 했다. 다만......언제나 그렇듯 결말은 그렇게 행복하지 않다. 나는 "나중에 조사해보자!"라는 생각만 남겨둔 채 우선 userId만 저장하는 단순한 data class를 만들어서 저장을 하고, 다음 기능들로 넘어갔다.

얼마의 시간이 지난 뒤, 난 다시 이 현상으로 돌아왔다. 그리고 현상을 정리하면서, 나는 비슷한 이슈를 찾아보기로했고...이슈를 발견하였다.

Jackson2JsonRedisSerializer is intended to store values of a specific type (e.g. Person via new Jackson2JsonRedisSerializer(Person.class)) allowing a signature of RedisTemplate<String, Person>.
...
Deserializers do not receive any typing hints as all type information must be encapsulated in the serialized payload.

즉 기본적으로 RedisSerializerdeserialize는 타입을 전달하지 않고, 오로지 직렬화된 데이터를 바탕으로 데이터를 유추하고 역직렬화 되어야 한다는 설명과, 원한다면 Jackson2JsonRedisSerializer를 사용하라는 의미였다. 좀더 간단히 말하면......너무 편한 길로 갈려고 하지 마라? json()의 Serializer는 애초에 가장 기초적인 Use Case를 위해서 만들어진 것이고, 특정 클래스에 맞는 형태는 Jackson2JsonRedisSerializer를 사용하라는 응답이었다. 실제로 나중에 이를 테스트해본 결과,

@Test
@DisplayName("redis template test?")
public void testSerializer(
        @Autowired
        RedisConnectionFactory connectionFactory
) {
    RedisTemplate<String, Long> template = new RedisTemplate<>();
    template.setConnectionFactory(connectionFactory);
    template.setDefaultSerializer(new Jackson2JsonRedisSerializer<>(Long.class));
    template.afterPropertiesSet();
    ValueOperations<String, Long> ops = template.opsForValue();
    ops.set("asdf", 1L);
    // 예외가 발생하지 않아야 한다.
    assertDoesNotThrow(() -> {
        System.out.println(ops.get("asdf"));
        // 원래 예외가 발생하던 코드
        Long result = ops.get("asdf");
        System.out.println(result);
    });
}

...별다른 문제없이 잘 작동했다는 사실! 내가 원하는 방향은 결국 이쪽이 올발랐다고 할 수 있을것 같다. 지금은 결국 불필요한 데이터 묶음용 클래스를 하나 더 사용하고 있으니...

그래도 참 잘 알고있다고 생각한 대상도 어느시점에 튀어나와 나를 당황시킬 수 있다는게 개발이라는 직업의 묘미이지 않을까 싶다. 이번 사건을 계기로 잊고있던 Generic과 Type Erasure를 다시 돌아보았다는걸 의의로 삼고, 글을 마무리한다.

+ 아마도 Java의 Checked Exception 체계도, 이런 상황을 방지하기 위해 만들어진 기능일텐데.....정작 이런 문제는 막지 못한다는 아이러니한거 같기도 하고.

0개의 댓글

관련 채용 정보