[입 개발] spring-security-oauth의 RedisTokenStore의 사용은 서비스에 적합하지 않습니다.

안녕하세요. 입개발 CharSyam 입니다. 저의 대부분의 얘기는 한귀로 듣고 한귀를 씻으시면 됩니다.(엉?) 일단 제목만 보면, 많은 Spring 유저들에게, 저넘의 입개발, 스프링도 모르면서라는 이야기를 들을듯 합니다.(아, 아이돌 까던 분들이 집단 따돌림을 당할 때의 느낌을 미리 체험할 수 있을듯 합니다. – 강해야 클릭율이 올라가는!!!)

먼저, 내용을 시작하기 전에 저는 Spring 맹, Java 맹으로 무식하다는 걸 미리 공개하고 넘어갑니다. 흑흑흑 (나의 봄님이 이럴 리 없어!!!)

일단 아시는 분이, Redis 와 oauth가 궁합이 안맞느냐라는 이야기를 들으면서 시작하게 됩니다. 아니, Redis와 oauth는 철자부터 다른데 무슨 얘기십니까? 라는 질문을 하다보니, Redis 에 과부하가 발생되서 처리가 잘 안된다는 얘기였습니다. 물론 수 많은 이유로 Redis가 느려질 수 있기 때문에, 자세한 정보를 요청했더니, spring-security-oauth 를 이용하고 계시다는 이야기를 들었습니다. 시간이 지나갈 수록 점점 느려진다는 느낌을 받고, 어떨 때는 Redis가 처리를 못한다고… 일반적인으로 아주 일반적으로 Redis는 아주 짧은 get/set 등의 요청은 초당 8~10만 정도는 가볍게 처리가 가능합니다.(일단 어떤식으로 문제에 접근했는가는 다음 번 주제로 미르고)

결론부터 얘기하자면, 당연히 사용하는 사람의 어느정도 실수가 발생하기 때문이긴 하지만, spring-security-oauth 의 RedisTokenStore 는 서비스에서 장애를 일으키기 쉽습니다.(보통은 라이브러리보다는 우리의 문제를 확인하는 것이 첫번째, 두번째도 우리문제, 세번째는 내 잘못인지 확인이… 정답입니다.)

먼저 Redis 는 Single Threaded 입니다. 즉, 하나의 명령이 많은 시간을 소모하면 그 동안은 아무런 작업을 하지 못합니다. 즉 Redis를 잘 쓰고 적합하게 이용하는 것은, 명령어를 빨리 수행해서 결과를 빨리 줄 수 있는 상황에서 이용하는 것이 적합하다는 것입니다.

사실 spring-security-oauth 자체가 큰 문제라기 보다는, 이 코드를 작성한 분은 사용자가 이런식으로 사용하게 되면 문제가 될 것이라는 고려를 하지 않은 것이 가장 큰 이슈입니다. 내부적으로 RedisTokenStore는 단순히 키 자체를 저장하는 get/set 커맨드와, 현재까지 발급되었던 키 정보를 저장하는 List 자료구조를 쓰고 있습니다. 아래 보면 rPush를 사용하는 approvalKey 와 clientId 입니다.

@Override
public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
......
    if (!authentication.isClientOnly()) {
	conn.rPush(approvalKey, serializedAccessToken);
    }
    conn.rPush(clientId, serializedAccessToken);
......
}

그런데 저 approvalKey와 clientId는 OAuth2Authentication -> OAuth2Request -> getClientId() 함수를 통해서 만들어집니다. 그런데 여기서 흔히들 하는 실수가 getClientId를 동일한 값으로 셋팅하는 것입니다. 그렇게 되면, 단순히 key가 있는지 확인할 때 말고 해당 List에 위와 같이 데이터가 들어가는 경우는 엄청나게 많은 아이템을 가진 List 자료구조가 생길 가능성이 높습니다.

그런데 Redis의 List 자료구조는 앞이나 뒤로 넣고, 앞이나 뒤에서 빼는 것은 빠르지만 O(1), 그 안의 데이터를 찾거나 모두 가져오면 결국 선형 탐색이 일어납니다. O(N), 그러면 그 안에 백만개 천만개가 들어있다고 가정하면 엄청난 속도 저하를 가져오게 됩니다. 그리고 그 안에 다른 명령을 하나도 처리할 수 가 없게됩니다.

아래 findTokensByClientIdAndUserName 와 findTokensByClientId 두 개의 함수는 대표적으로 모든 아이템을 가져오도록 하고 있습니다. 안쓰는걸로 하셔야 합니다.

	@Override
	public Collection findTokensByClientIdAndUserName(String clientId, String userName) {
		byte[] approvalKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(clientId, userName));
		List byteList = null;
		RedisConnection conn = getConnection();
		try {
			byteList = conn.lRange(approvalKey, 0, -1);
		} finally {
			conn.close();
		}
		if (byteList == null || byteList.size() == 0) {
			return Collections. emptySet();
		}
		List accessTokens = new ArrayList(byteList.size());
		for (byte[] bytes : byteList) {
			OAuth2AccessToken accessToken = deserializeAccessToken(bytes);
			accessTokens.add(accessToken);
		}
		return Collections. unmodifiableCollection(accessTokens);
	}

	@Override
	public Collection findTokensByClientId(String clientId) {
		byte[] key = serializeKey(CLIENT_ID_TO_ACCESS + clientId);
		List byteList = null;
		RedisConnection conn = getConnection();
		try {
			byteList = conn.lRange(key, 0, -1);
		} finally {
			conn.close();
		}
		if (byteList == null || byteList.size() == 0) {
			return Collections. emptySet();
		}
		List accessTokens = new ArrayList(byteList.size());
		for (byte[] bytes : byteList) {
			OAuth2AccessToken accessToken = deserializeAccessToken(bytes);
			accessTokens.add(accessToken);
		}
		return Collections. unmodifiableCollection(accessTokens);
	}

위의 함수들은 안쓴다고 하더라도, 아래와 같이 removeAccessToken 함수는 자주 불리는데 여기서도 lRem을 통한 선형 탐색이 발생합니다.(lRem이 문제…)

	public void removeAccessToken(String tokenValue) {
		byte[] accessKey = serializeKey(ACCESS + tokenValue);
		byte[] authKey = serializeKey(AUTH + tokenValue);
		byte[] accessToRefreshKey = serializeKey(ACCESS_TO_REFRESH + tokenValue);
		RedisConnection conn = getConnection();
		try {
			conn.openPipeline();
			conn.get(accessKey);
			conn.get(authKey);
			conn.del(accessKey);
			conn.del(accessToRefreshKey);
			// Don't remove the refresh token - it's up to the caller to do that
			conn.del(authKey);
			List results = conn.closePipeline();
			byte[] access = (byte[]) results.get(0);
			byte[] auth = (byte[]) results.get(1);

			OAuth2Authentication authentication = deserializeAuthentication(auth);
			if (authentication != null) {
				String key = authenticationKeyGenerator.extractKey(authentication);
				byte[] authToAccessKey = serializeKey(AUTH_TO_ACCESS + key);
				byte[] unameKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(authentication));
				byte[] clientId = serializeKey(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId());
				conn.openPipeline();
				conn.del(authToAccessKey);
				conn.lRem(unameKey, 1, access);
				conn.lRem(clientId, 1, access);
				conn.del(serialize(ACCESS + key));
				conn.closePipeline();
			}
		} finally {
			conn.close();
		}
	}

당연히 그러면 이건 사용자 실수가 더 큰거 아니냐 라고 하실 수 있습니다. 그런데 꼭 사용자의 실수가 아니더라도 특정 사용자가 비정상적으로 이런 동작을 하면 비슷한 이슈가 발생할 수 있습니다.

그러면 어떻게 해야하는가? 해당 로직들에서 list 를 사용하는 부분을 좀 더 속도가 빠르거나 하는 것들로 바뀌고 모든 데이터를 가져오는 부분은 사용하지 못하게 하는 것이 방법입니다.

어떤 분들은 이미 이런걸 겪으셔서 내부적으로 해당 모듈을 수정해서 쓰시는 분도 계셨습니다. 그러나 이런부분 때문에 관련 부분을 어느정도는 직접 구현해서 쓰시는 거나, 이런 부분을 수정해서 쓰셔야 할듯 합니다. 결론적으로 spring-security-oauth 의 RedisTokenStore를 쓰는 부분은 서비스 부하가 늘어날 때 장애를 일으킬 확률이 높습니다.

그 외에도 refreshToken의 경우는 보통 expire 기간이 훨씬 긴데, 이것들은 전부 메모리에 저장하는 부분들 또한 이슈가 생길 수 있습니다.(돈 많은면, 메모리 빵빵한 장비 쓰시면, 이 부분은 신경 안쓰셔도…)

보통 Spring이라는 이름을 붙이면 굉장히 안정적인데, 소스를 보고, 문제가 생길만한 부분에 대한 가정을 너무 약하게 하고 넘어간 부분이 문제인듯합니다.(하지만 해당 코드는 벌써 3년 전에 만들어진 코드라는 거…)

쉬시면서 Spring 코드 한번 읽어보시는 것도 좋을듯합니다.(전 읽어본 적 없습니다. ㅋㅋㅋ), 해당 글을 작성하는데 도움을 주신 우아한 형제들의 엯촋 이수홍 선생님께 감사드립니다.

Advertisements