본문으로 바로가기

728x90

Access Token 및 Refresh Token을 발급하는 과정에서 Client 및 User의 검증을 위해 데이터베이스 질의를 하게 됩니다. 특히 Client 정보를 읽어오는 질의문은 신규 키 발급 시 5회 이상 이루어집니다. 이번 포스트에서는 메모리에 Client 및 User 정보를 캐싱하는 방법에 대해서 설명합니다.

 

초기 캐싱을 처리하기 위해 AOP를 고려하였습니다. 하지만, 설정의 잘못인지 작업 중인 프로젝트에서는 정상적으로 동작하지 않았습니다. 필자의 기술력 부족일 수도 있으나 데이터베이스를 통해 자체적으로 구현한 ClientDetailsService와 UserDetailsService 로직이 있어 굳이 AOP를 통해서 캐싱을 처리할 필요성을 크게 느끼지 못했습니다. 또한, AOP에 대해서 명확한 이해가 없을 경우에는 소스를 공유하는 구성원간의 능력에 따라 소스 분석의 어려움이 존재하여 본 프로젝트에서는 AOP를 배제합니다.
사족이나 개인적으로는 AOP와 같은 캐싱 방법보다는 복잡하지 않다면 개별적으로 구현하는 캐싱 방법을 선호합니다. 혼자서 작업하는 R&D 또는 학습성 프로젝트가 아니라면 "다른 구성원이 읽기 쉬운 코드가 좋은 코드다"라는 생각입니다.

 

아래에서 구현하는 캐싱 방법을 실제 업무에 사용하지 전에 몇가지 고려사항이 있습니다.

  • Client 및 User의 정보가 갱신되었을 경우 캐시를 다시 읽어 들이는 로직이 추가되어야 합니다.
  • Client 및 User의 정보가 삭제되었을 경우 캐시를 삭제하는 로직이 추가되어야 합니다.
  • 다중 서버 환경에서는 동기화 로직의 추가 개발 또는 다른 추가적인 방법이 병행되어야 합니다.

 

Client 및 User 캐시 적용

캐싱의 필요성

신규키를 발급하는 과정에서 대략 5회 이상 질의를 하게 됩니다. 따라서, 사용자가 많아지면 데이터베이스에 부하를 발생하게 됩니다. 현재까지 작업한 프로젝트에서는 신규 키 발급 시 Client 조회 7회, User 조회 1회가 발생하였습니다.

Client, User 질의 로그

ClientDetailsService

Hashtable를 이용하여 cache 변수를 생성합니다. addCache, removeCache함수를 아래와 같이 구성합니다. 데이터베이스 질의 후 결과값이 있을 경우에 addCache를 호출하여 캐시에 값을 추가합니다. removeCache는 데이터베이스의 값이 수정되거나 삭제되었을 경우 호출하게 합니다.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.ClientRegistrationException;
import org.springframework.stereotype.Service;

import kr.ejsoft.oauth2.server.dao.OAuthClientDetailsDAO;
import kr.ejsoft.oauth2.server.model.OAuthClientDetails;

@Service("oauthClientDetailsService")
public class OAuthClientDetailsService implements ClientDetailsService {
	private static final Logger log = LoggerFactory.getLogger(OAuthClientDetailsService.class);

	private static Hashtable<String, OAuthClientDetails> cache = null;
	
	@Autowired
	@Qualifier("oauthClientDetailsDAO")
	private OAuthClientDetailsDAO dao;

	@Override
	public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
		log.debug("Client Id : {}", clientId);
		OAuthClientDetails client = (cache != null && cache.containsKey(clientId)) ? cache.get(clientId) : dao.getClientById(clientId);
//		OAuthClientDetails client = clientDetailsDAO.getClientById(clientId);
		if (client == null) {
			log.debug("Client : null");
			throw new ClientRegistrationException(clientId);
		}
		
		addCache(clientId, client);
		
		log.debug("Client : {}", client.toString());
		return client;
	}

	private void addCache(String clientId, OAuthClientDetails details) {
		if(clientId == null || details == null) return;
		
		synchronized(OAuthClientDetailsService.class) {
			if(cache == null) {
				cache = new Hashtable<String, OAuthClientDetails>();
			}
			
			cache.put(clientId, details);
		}
	}

	public void removeCache(String clientId) {
		if(cache != null) {
			synchronized(OAuthClientDetailsService.class) {
				cache.remove(clientId);
			}
		}
	}
}

 

UserDetailsService

Hashtable를 이용하여 cache 변수를 생성합니다. addCache, removeCache함수를 아래와 같이 구성합니다. 데이터베이스 질의 후 결과값이 있을 경우에 addCache를 호출하여 캐시에 값을 추가합니다. removeCache는 데이터베이스의 값이 수정되거나 삭제되었을 경우 호출하게 합니다.

import java.util.Hashtable;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import kr.ejsoft.oauth2.server.dao.OAuthUserDetailsDAO;
import kr.ejsoft.oauth2.server.model.OAuthClientDetails;
import kr.ejsoft.oauth2.server.model.OAuthUserDetails;

@Service("oauthUserDetailsService")
public class OAuthUserDetailsService implements UserDetailsService {
	private static final Logger log = LoggerFactory.getLogger(OAuthUserDetailsService.class);

	private static Hashtable<String, OAuthUserDetails> cache = null;

	@Autowired
	@Qualifier("oauthUserDetailsDAO")
	private OAuthUserDetailsDAO dao;

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		log.debug("User Name : {}", username);
		OAuthUserDetails user = (cache != null && cache.containsKey(username)) ? cache.get(username) : dao.getUserById(username);
//		OAuthUserDetails user = dao.getUserById(username);
		if (user == null) {
			throw new UsernameNotFoundException(username);
		}

		addCache(username, user);
		
		log.debug("User : {}", user.toString());
		return user;
	}

	private void addCache(String username, OAuthUserDetails user) {
		if(username == null || user == null) return;

		synchronized(OAuthUserDetailsService.class) {
			if(cache == null) {
				cache = new Hashtable<String, OAuthUserDetails>();
			}
			
			cache.put(username, user);
		}
	}

	public void removeCache(String username) {
		if(cache != null) {
			synchronized(OAuthUserDetailsService.class) {
				cache.remove(username);
			}
		}
	}
}

 

캐싱 적용 후 로그

캐시 적용 후에 Client, User 데이터베이스 질의가 각각 1회씩 호출되었습니다. 초기 캐싱된 이후에는 같은 clientId, username에 대해여 데이터베이스 질의를 수행하지 않습니다.

캐싱 적용 후 로그

글을 마치며

본 글에서 Refresh Token을 데이터베이스에 대한 캐싱처리는 하지 않았습니다. Access Token에 비하여 호출하는 건수가 적을 것으로 예상하였기 때문입니다. 위 코드는 여러 Authorization Server를 운영 중 일 경우에는 1개의 서버에서만 값이 데이터베이스와 동기화됩니다. 따라서, 여러 서버를 운영 시에는 데이터베이스 값이 변경될 경우에는 각 서버에 이벤트를 전달하여 강제 동기화를 시킬 필요가 있습니다.

또한, 사용자가 많아지게 되면 서버의 메모리를 많이 점유할 수 있으므로 Cache의 저장위치를 Redis와 같은 메모리 데이터베이스에 저장하는 방법도 고려해봐야 합니다.

 

참고사이트

 

728x90