JPA Entity Serialize Exception with Redis Session

4 분 소요


RECOMMEND POSTS BEFORE THIS

1. Problem Context

레디스(redis)를 위한 스프링 세션(spring session)을 사용하는 중 아래 로그처럼 엔티티(entity)를 직렬화(serialize)할 수 없다는 에러를 만났습니다.

org.springframework.data.redis.serializer.SerializationException: Cannot serialize
    at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:96) ~[spring-data-redis-3.0.6.jar!/:3.0.6]
    at org.springframework.data.redis.core.AbstractOperations.rawHashValue(AbstractOperations.java:186) ~[spring-data-redis-3.0.6.jar!/:3.0.6]
    at org.springframework.data.redis.core.DefaultHashOperations.putAll(DefaultHashOperations.java:161) ~[spring-data-redis-3.0.6.jar!/:3.0.6]
    at org.springframework.session.data.redis.RedisSessionRepository$RedisSession.saveDelta(RedisSessionRepository.java:298) ~[spring-session-data-redis-3.0.1.jar!/:3.0.1]
    at org.springframework.session.data.redis.RedisSessionRepository$RedisSession.save(RedisSessionRepository.java:276) ~[spring-session-data-redis-3.0.1.jar!/:3.0.1]
      ...
Caused by: org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer
    at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:64) ~[spring-core-6.0.9.jar!/:6.0.9]
    at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:33) ~[spring-core-6.0.9.jar!/:6.0.9]
    at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:94) ~[spring-data-redis-3.0.6.jar!/:3.0.6]
    ... 37 common frames omitted
Caused by: java.io.NotSerializableException: action.in.blog.domain.entity.UserEntity
    at java.base/java.io.ObjectOutputStream.writeObject0(Unknown Source) ~[na:na]
    at java.base/java.io.ObjectOutputStream.writeObject(Unknown Source) ~[na:na]
      ...

위 예외가 왜 발생했는지 알아보고 이를 해결한 방법에 대해 정리해보았습니다. 프로젝트 개발 환경은 다음과 같습니다.

  • 코틀린 based on JDK 17
  • 스프링 부트 3.0.7 버전
  • 문제가 발생한 의존성
    • spring-boot-starter-data-jpa
    • spring-boot-starter-data-redis

문제가 발생한 상황은 다음과 같습니다.

  1. 사용자 정보를 데이터베이스에서 조회한다.
  2. 사용자 정보 엔티티를 POJO 객체로 변환한 후 세션에 저장한다.
  3. 요청에 대한 응답을 커밋하는 시점에 세션에 데이터를 저장하게 되는데 객체를 직렬화하는 과정에서 예외가 발생한다.

1.1. UserController Class

실제 프로덕트 코드를 사용할 수 없으므로 일부 각색하여 작성하였습니다. 먼저 컨트롤러 클래스를 살펴보겠습니다.

  • 사용자 정보가 담긴 User 객체를 세션에 저장합니다.
package action.`in`.blog.controller

import action.`in`.blog.service.UserService
import jakarta.servlet.http.HttpServletRequest
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController

@RestController
class UserController(
    private val userService: UserService
) {

    private val sessionKeyUser: String = "SESSION_KEY_USER"

    @GetMapping("/users/{id}")
    fun users(
        servletRequest: HttpServletRequest,
        @PathVariable("id") id: Long
    ) {
        val user = userService.getUser(id)
        val session = servletRequest.getSession(true)
        session.setAttribute(sessionKeyUser, user)
    }
}

1.2. DefaultUserService Class

  • JpaRepository 객체를 사용해 사용자 정보를 조회합니다.
  • User 클래스를 사용해 엔티티를 DTO 객체로 변환합니다.
package action.`in`.blog.service

import action.`in`.blog.domain.dto.User
import action.`in`.blog.repository.UserRepository
import org.springframework.stereotype.Service

interface UserService {
    fun getUser(id: Long): User
}

@Service
class DefaultUserService(
    private val userRepository: UserRepository
) : UserService {
    
    override fun getUser(id: Long): User {
        val userEntity = userRepository.findById(id).orElseThrow()
        return User.of(userEntity)
    }
}

1.3. UserEntity Class

  • @ElementCollection, @CollectionTable 애너테이션을 사용합니다.
    • 좋아하는 포스트(post)들의 아이디를 컬렉션으로 저장합니다.
  • 컬렉션을 저장하기 위한 별도 테이블이 생성됩니다.
    • 테이블 이름은 tb_favorite_posts 입니다.
package action.`in`.blog.domain.entity

import jakarta.persistence.*

@Entity
@Table(name = "tb_user")
class UserEntity(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L,
    val name: String,
    @ElementCollection
    @CollectionTable(name = "tb_favorite_posts")
    val favoritePosts: MutableList<Long>
)

1.4. User Class

  • 정적 팩토리 메소드 패턴이 적용된 of 메소드를 통해 User 객체를 생성합니다.
  • 엔티티 프로퍼티들을 참조 값으로 복사합니다.
package action.`in`.blog.domain.dto

import action.`in`.blog.domain.entity.UserEntity
import java.io.Serializable

data class User(
    val id: Long,
    val name: String,
    val favoritePosts: List<Long>
) : Serializable {

    companion object {

        private const val serialVersionUID: Long = 1L

        fun of(userEntity: UserEntity): User {
            return User(
                id = userEntity.id,
                name = userEntity.name,
                favoritePosts = userEntity.favoritePosts
            )
        }
    }
}

1.5. Test

연관된 코드들은 모두 살펴봤으므로 동일한 예외가 발생하도록 테스트 코드를 실행시켜보겠습니다.

  • 실제 동작 환경과 유사하도록 테스트 컨테이너를 사용합니다.
    • 레디스 컨테이너를 실행 후 스프링 어플리케이션에서 찾을 수 있도록 프로퍼티를 변경합니다.
  • setup 메소드에서 테스트에 필요한 사용자 데이터를 준비합니다.
  • 문제가 발생하는 경로로 API 요청을 수행합니다.
    • 정상 응답을 기대합니다.
package action.`in`.blog.controller

import action.`in`.blog.domain.entity.UserEntity
import action.`in`.blog.repository.UserRepository
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Testcontainers

@Testcontainers
@AutoConfigureMockMvc
@SpringBootTest(
    properties = [
        "spring.datasource.url=jdbc:h2:mem:test",
        "spring.datasource.driver-class-name=org.h2.Driver",
        "spring.datasource.username=sa"
    ]
)
class RestControllerIT {

    private final val redis = GenericContainer("redis:5.0.3-alpine")
        .withExposedPorts(6379)

    init {
        redis.start()
        System.setProperty("spring.data.redis.host", redis.host)
        System.setProperty("spring.data.redis.port", redis.getMappedPort(6379).toString())
    }

    @Autowired
    lateinit var userRepository: UserRepository

    @Autowired
    lateinit var sut: MockMvc

    @BeforeEach
    fun setup() {
        userRepository.save(
            UserEntity(
                name = "Junhyunny",
                favoritePosts = mutableListOf(1L, 2L)
            )
        )
    }

    @Test
    fun saveUserEntityInSession() {

        sut.perform(get("/users/1"))
            .andExpect(status().isOk)
    }
}
Test Result

테스트 코드를 실행하면 다음 에러 로그를 확인할 수 있습니다.

  • 엔티티 객체를 직렬화하는 과정에서 에러가 발생합니다.
01:24:55.120 [Test worker] INFO  action.in.blog.controller.RestControllerIT - Started RestControllerIT in 3.073 seconds (process running for 5.694)

org.springframework.data.redis.serializer.SerializationException: Cannot serialize
    at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:96)
    at org.springframework.data.redis.core.AbstractOperations.rawHashValue(AbstractOperations.java:186)
    at org.springframework.data.redis.core.DefaultHashOperations.putAll(DefaultHashOperations.java:161)
    at org.springframework.session.data.redis.RedisSessionRepository$RedisSession.saveDelta(RedisSessionRepository.java:298)
    at org.springframework.session.data.redis.RedisSessionRepository$RedisSession.save(RedisSessionRepository.java:276)
    ...
Caused by: org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer
    at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:64)
    at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:33)
    at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:94)
    ... 101 more
Caused by: java.io.NotSerializableException: action.in.blog.domain.entity.UserEntity
    at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1187)
    at java.base/java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1572)

2. Cause

문제의 원인은 살펴보겠습니다.

  • UserEntity 객체를 User 객체로 변환하는 과정에서 객체에 대한 참조 값 복사만 이뤄집니다.
  • favoritePosts 변수가 참조하는 객체가 일반적인 리스트가 아닌 PersistentBag이라는 객체입니다.
    • 일반적인 컬렉션이 아니라 하이버네이트(hibernate)에서 엔티티들을 관리하기 위한 컬렉션입니다.
    • 객체 내부에서 다른 엔티티들을 참조하고 있어서 직렬화 문제가 발생합니다.
    fun of(userEntity: UserEntity): User {
        return User(
            id = userEntity.id, // 값 복사
            name = userEntity.name, // 참조 값 복사
            favoritePosts = userEntity.favoritePosts // 참조 값 복사
        )
    }

2.1. Inside PersistentBag Instance

PersistentBag 객체 내부에서 다음과 같은 참조 연결이 존재합니다. 이 참조 객체를 통해 엔티티들이 직렬화 대상에 포함됩니다.

  • PersistentBag 객체은 owner 객체를 가지고 있습니다.
    • owner 객체는 UserEntity 객체입니다.
  • owner 객체까지 직렬화 대상이 됩니다.
    • UserEntity 객체가 참조하는 다른 객체들도 모두 직렬화 대상입니다.

3. Solve the problem

문제를 해결하는 방법은 두 가지 존재합니다. 두 번째 방법을 사용해 이 문제를 해결하였습니다.

  1. UserEntity 클래스가 Serializable 인터페이스를 상속받는다.
    • 첫 번째 방법인 UserEntity 클래스가 Serializable 인터페이스를 구현하는 방법은 임시 방편입니다.
    • UserEntity 클래스 내부에 다른 엔티티들에 대한 참조 필드가 추가되는 경우 다른 엔티티들도 직렬화 에러를 겪게 됩니다.
  2. User 객체로 변환할 때 favoritePosts 리스트에 대한 참조 값 복사를 값 복사로 변경한다.
    • ArrayList 생성자를 통해 값을 복사한 새로운 리스트 객체를 할당합니다.
package action.`in`.blog.domain.dto

import action.`in`.blog.domain.entity.UserEntity
import org.hibernate.collection.spi.PersistentBag
import java.io.Serializable

data class User(
    val id: Long,
    val name: String,
    val favoritePosts: List<Long>
) : Serializable {

    companion object {

        private const val serialVersionUID: Long = 1L

        fun of(userEntity: UserEntity): User {
            return User(
                id = userEntity.id,
                name = userEntity.name,
                // favoritePosts = userEntity.favoritePosts // Serializable 에러
                favoritePosts = ArrayList(userEntity.favoritePosts) // 정상
            )
        }
    }
}

TEST CODE REPOSITORY

REFERENCE

댓글남기기