본문 바로가기

Back-end/Spring

서로 다른 스프링 시큐리티 버전 간 세션 동기화

최근 새로운 프로젝트를 진행하며 기존 레거시 서버와 동일한 인증 처리(레거시 서버에서 로그인을 하면 새로운 프로젝트의 서버에서도 로그인이 유지되도록)를 위해 세션 동기화를 진행해야 했었습니다.

기존 레거시는 이미 spring-session-data-redis을 사용해 세션 동기화를 해둔 상태였기 때문에 새로운 프로젝트에서도 해당 Redis 서버에 연결만 시켜주면 동기화가 될 것이라고 생각했었습니다.

그러나 실행 결과 두 가지 문제로 인해 동기화가 진행되지 않았는데 그 문제는 다음과 같습니다.

  • 레거시와 다른 프로젝트 패키지 구조
  • Spring Security 버전 차이로 인한 세션 정보 Deserialize 실패

이제부터 이 두 가지 문제를 해결한 과정을 설명드리도록 하겠습니다.

 

각 프로젝트의 패키지 구조는 다음과 같다고 가정하겠습니다.

레거시 : io.dori.old
신규 : io.dori.new

두 프로젝트의 패키지 구조가 다른게 왜 문제를 일으킬까요?

서비스에 로그인한 뒤 Redis에 저장된 세션을 보면 다음과 같이 밑줄 친 부분을 확인할 수 있습니다.

인증된 사용자 정보가 저장이 될 때 해당 객체의 전체 패키지 구조까지 같이 저장이 된 모습을 확인할 수 있습니다.

이 정보를 가지고 다시 사용자 인증정보를 User 오브젝트로 역직렬화하기 때문에 User는 io.dori.old.auth라는 패키지안에 있어야지 역직렬화가 정상적으로 이루어지게 되고 만약 해당 패키지에 User가 없다면 ClassNotFound라는 예외가 발생하게 됩니다.

이 문제는 간단하게 해결이 가능한데 신규 프로젝트에 io.dori.new.auth 패키지를 생성하고 해당 패키지 안에 레거시와 동일한 User를 정의해 주면 됩니다.

src
└── main
    └── kotlin
        └── io.dori.new.auth
	    └── User.java
	└── io.dori.old.auth
	    └── User.java

스프링 시큐리티를 사용해 인증을 완료하면 Authentication 객체를 감싼 SecurityContextImpl이라는 객체를 직렬화해서 메모리에 저장합니다.

그런데 SecurityContextImpl 내부를 보면 serialVersionUID가 선언이 되어있는 것을 볼 수 있습니다.

이 serialVersionUID가 스프링 시큐리티 버전마다 달라지기 때문에 새 프로젝트에서 SecurityContext를 역직렬화 하려할 때 InvalidClassException이 발생했습니다.

스프링 시큐리티 버전 차이로 인해 발생한 문제이기 때문에 시큐리티 버전만 맞춰주면 해결이 가능하지만 굳이 신규 프로젝트에서 이 문제 때문에 버전을 낮춰서 사용하고 싶지 않았기 때문에 다른 방식으로 해결을 했습니다.

해당 문제를 해결하기 위해 사용한 방법은 직렬화 방법을 바꾸는 것이었습니다.

레디스를 세션 저장소로 사용하기 위해 정의해 주는 @EnableRedisHttpSession 이 어노테이션을 보면 RedisHttpSessionConfiguration.class라는 설정 정보를 Import 해주는 것을 볼 수 있습니다.

이 RedisHttpSessionConfiguration 안을 보게 되면 다음과 같은 빈을 등록해 주는 것을 확인할 수 있습니다.

레디스와 통신하기 위해 RedisIndexedSessionRepository라는 객체를 빈으로 등록해 주고 있는데 이때 데이터를 어떻게 직렬화/역직렬화 할 것인지에 대해 정의할 수 있는 defaultRedisSerializer라는 serializer를 주입해 주고 있는 것을 볼 수 있습니다.

코드를 조금 더 내려보면 springSessionDefaultRedisSerializer라는 이름을 가진 serializer가 만약 빈으로 등록되어 있다면 defaultRedisSerializer에 할당해 주고 있는 것을 확인할 수 있습니다.

이제 springSessionDefaultRedisSerializer라는 이름을 가진 serializer를 오브젝트를 Json으로 컨버팅해 주는 serializer를 등록해 주도록 하겠습니다.

전체 코드는 다음과 같습니다.

ObjectMapper에 SecurityJacson2Modules.getModules(classLoader)를 통해 가져온 모듈을 등록해 주지 않으면 역직렬화 시 정상적으로 Security 관련 객체를 생성해 주지 못하기 때문에 등록이 필요합니다.

설정을 완료한 뒤 다시 인증을 하고 세션에 저장된 데이터를 확인해 보면 다음과 같이 Json 형식으로 저장이 된 것을 확인할 수 있습니다.

또한, 시큐리티 버전에 영향을 받지 않고 세션이 동기화가 되는 것을 확인할 수 있습니다.

추가로 평문의 문자열이 저장되는 것을 원하지 않는다면 직접 Serializer를 구현해 빈으로 등록해 줄 수 있습니다.

public interface RedisSerializer<T>

위 인터페이스를 직접 구현하면 원하는 방식대로 직렬화/역직렬화가 가능합니다.

아래는 간단하게 객체를 Json 형태의 문자열로 변환한 뒤 Base64로 변환해주는 Serailizer 예제 입니다.

class Base64GenericJsonRedisSerializer(
    private val objectMapper: ObjectMapper
): RedisSerializer<Any> {
    override fun serialize(source: Any?): ByteArray? {
        if (source == null) {
            return ByteArray(0)
        }

        val jsonValue = this.objectMapper.writeValueAsString(source)
        return Base64.getEncoder().encode(
            jsonValue.toByteArray(StandardCharsets.UTF_8)
        )
    }

    override fun deserialize(bytes: ByteArray?): Any? {
        if (bytes == null || bytes.isEmpty()) {
            return null
        }

        return this.objectMapper.readValue(
            Base64.getDecoder().decode(bytes),
            Any::class.java
        )
    }
}

여기까지만 하면 코드에서는 더 해줄 작업이 남아있지는 않습니다.

이 상태로 Localhost에서 레거시 서버와 신규 서버간 세션 동기화가 잘 이루어졌는지 테스트를 해보면 정상적으로 동기화가 이루어지는것을 볼 수 있을 것 입니다.

하지만 보통 새로운 프로젝트는 기존 서버와 다른 새로운 DNS를 부여해주기 때문에 막상 서버를 배포한다음에 테스트 해보면 동기화가 이루어지지 않을 것 입니다.

그 이유는 서버의 도메인이 달라서인데 쿠키는 요청을 보내는 도메인의 정보를 가지고 있기 때문입니다.

기존 서버는 legacy.dori.io 라는 DNS를 가지고있고
신규 서버는 new.dori.io 라는 DNS를 가지고 있으면 서로 도메인 정보가 다르기 때문에 다른 세션이라고 인지하게 되어 동기화가 되지 않는 것 입니다.

이를 해결할 수 있는 방법도 두 가지가 있는데 첫 번째 방법은 서브도메인을 무시하고 dori.io 만 도메인 정보로 사용하여 쿠키를 공유하는 방법 입니다.

해당 방법은 간단하게 다음과 같이 CookieSerializer를 빈으로 정의해주기만 하면 됩니다.

@Bean
fun cookieSerializer(): CookieSerializer {
    val serializer = DefaultCookieSerializer()
    serializer.setCookiePath("/")
    serializer.setDomainName("dori.io")
    return serializer
}

하지만 이 방법은 기존에 *.dori.io 라는 DNS를 사용중인 다른 서비스들과 충돌을 일으킬 수 있는 위험이 존재합니다.

위 문제를 해결할 수 있는 방법이 두 번째 방법인데 기존 서버와 신규 서버로 들어오는 요청을 대신 받는 프록시 서버를 두고 해당 프록시 서버의 DNS로만 통신하는 방법 입니다.

위 그림과 같이 SCG를 앞에 두고 모든 요청을 SCG를 통해 기존 서버와 신규 서버로 분기처리 해주면 됩니다.

신규서버를 만들었는데 기존의 서버와 동일한 세션인증을 요구할 경우 위의 방법들을 참고해보시면 좋을 것 같습니다