aop를 이용한 oauth2 캐시 적용하기

Mar.05.2019 송재욱

Backend

서론

저는 현재 미래사업부문에서 신규 서비스를 만들고 있으며,
신규 서비스는 기존 우아한 형제들에서 서비스중인 배달의민족과 디펜던시를 갖지 않는 독립적인 서비스다 보니 모든 것을 처음부터 새 판에서 고민하며 만들어 가고 있습니다.
이 글에서는 그 과정에서 겪은 하나의 주제를 골라 이야기 할까 합니다.

oauth2를 선택한 계기

회원기반 서비스를 만들기 위해서 어떤 인증체계를 구축할지 선택해야 합니다.
전통적인 웹서비스에서는 ID/PW로 인증을 확인받고 세션을 유지하는 방식을 취해왔습니다. 이 방식은 쿠키를 날린다거나 세션 유효기간이 만료되면 재인증해야 하는 번거로움이 있습니다.
물론 이런 번거로움이 나쁘다는 것이 아니라, 유저의 사용환경상 그러는 편이 나은 선택일 것입니다. 웹은 어디서건 접속할 수 있고, 때문에 본인만 사용하는 PC라는 것을 확인할 수 없기 때문에 적절한 시점에 로그아웃을 해줘야 보안상 안전할 것입니다.

그러나 앱 사용환경은 조금 다릅니다.
앱은 스마트폰을 통해 설치하며, 스마트폰은 ‘자신의 것’이라는 것에 컨센서스가 있기 때문입니다. 따라서 앱-서버간 인증에서는 매번 로그아웃을 하는 것은 일부상황(보안수준이 매우 높아야 하는 금융 혹은 private 서비스)을 제외한 유저에게 더욱 번거로운 경험이라고 생각됩니다.

그렇다면 최초 가입-로그인 후 (앱을 재설치하는 등의 이슈가 없다면)

  • 유저가 매번 로그인할 필요가 없도록 하면서
  • 인증키를 주기적으로 바꿔주면서 (앱에 단일 키로 가지고 있으면서 계속 접속을 유지하지 않도록 하는것 포함)
  • 유저가 직접 재인증 하지 않아도 되는
    oauth2를 선택하게 되었습니다.

다행히(너무나 고맙게도), spring eco system에서는 이러한 것들을 쉽고 빠르게 적용할 수 있는 방법을 제공하고 있습니다.
이와 관련된 springboot의 주요 의존성은 아래와 같습니다.

spring-boot-starter-aop
spring-boot-starter-security
spring-boot-starter-jpa
spring-boot-starter-redis
spring-security-oauth2

이 글에서는 oauth2 세부 설정에 대한 설명은 생략합니다.
그보다 코드를 직접 건드리기 곤란한 프레임워크 혹은 라이브러리의 메소드 호출 전후로 원하는 처리를 추가하고 싶을 때, 그리고 그 과정에서 신경써야 할 부분은 무엇인지에 대한 얘기를 하려고 합니다.

oauth2에서 하고 싶은 것

oauth2를 이용해서 얻고 싶은 기본 기능은 아래 세 가지입니다.

  • 토큰 발급
  • 토큰 검증
  • 토큰 재발급

spring security의 oauth2를 적용하니 어렵지 않게 기본 동작을 구현할 수 있었습니다.
토큰의 발급, 검증, 재발급에 관한 컨트롤러를 구현할 필요도 없습니다. 라이브러리에 이미 구현돼 있습니다.
oauth2 구성은 AuthorizationServerConfigurerAdapter, UserDetailsService, ResourceServerConfigurerAdapter, WebSecurityConfigurerAdapter등을 이용해 필요한 configure를 마쳤습니다.
테스트해보니 잘 됩니다.

DB연동

처음에는 기본 동작을 확인하기 위해서 in-memory로 가볍게 확인했습니다.
이를 서비스에서 쓰기 위해서는 DB연동이 필요하기 때문에, 현 시점에서는 아래 세 개의 인증 테이블을 셋팅했습니다.

CREATE TABLE IF NOT EXISTS `oauth_access_token` (
  `token_id` VARCHAR(256) NULL,
  `token` BLOB NULL,
  `authentication_id` VARCHAR(256) NOT NULL,
  `user_name` VARCHAR(256) NULL,
  `client_id` VARCHAR(256) NULL,
  `authentication` BLOB NULL,
  `refresh_token` VARCHAR(256) NULL,
  PRIMARY KEY (`authentication_id`)
);

CREATE TABLE IF NOT EXISTS `oauth_client_details` (
  `client_id` VARCHAR(256) NOT NULL,
  `resource_ids` VARCHAR(256) NULL,
  `client_secret` VARCHAR(256) NULL,
  `scope` VARCHAR(256) NULL,
  `authorized_grant_types` VARCHAR(256) NULL,
  `web_server_redirect_uri` VARCHAR(256) NULL,
  `authorities` VARCHAR(256) NULL,
  `access_token_validity` INT NULL,
  `refresh_token_validity` INT NULL,
  `additional_information` VARCHAR(4096) NULL,
  `autoapprove` VARCHAR(256) NULL,
  PRIMARY KEY (`client_id`)
);

CREATE TABLE IF NOT EXISTS `oauth_refresh_token` (
  `token_id` VARCHAR(256) NULL,
  `token` BLOB NULL,
  `authentication` BLOB NULL
);

ClientDetailsServiceConfigurer에서 jdbc설정해준 후 DB연동까지 마치고 동작을 다시 확인했습니다.
잘 됩니다.

로그 확인

일찍 퇴근할 수 있겠다는 기쁜 마음으로 로그를 살펴보다가 무지막지한 쿼리를 보게 됩니다.

  1. 토큰 발급 시, 쿼리 9번.

    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select token_id, token from oauth_access_token where authentication_id = ?] 
    [select token_id, authentication from oauth_access_token where token_id = ?] 
    [select token_id, token from oauth_access_token where token_id = ?] 
    [delete from oauth_access_token where token_id = ?]
    [insert into oauth_access_token (token_id, token, authentication_id, user_name, client_id, authentication, refresh_token) values (?, ?, ?, ?, ?, ?, ?)] 
  2. 토큰 검증 시, 쿼리 4번.

    [select token_id, token from oauth_access_token where token_id = ?] 
    [select token_id, token from oauth_access_token where token_id = ?] 
    [select token_id, authentication from oauth_access_token where token_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
  3. 토큰 재발급 시, 쿼리 14번.

    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select token_id, token from oauth_refresh_token where token_id = ?] 
    [select token_id, authentication from oauth_refresh_token where token_id = ?] 
    [delete from oauth_access_token where refresh_token = ?] 
    [delete from oauth_refresh_token where token_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?] 
    [select token_id, token from oauth_access_token where token_id = ?] 
    [insert into oauth_access_token (token_id, token, authentication_id, user_name, client_id, authentication, refresh_token) values (?, ?, ?, ?, ?, ?, ?)] 
    [insert into oauth_refresh_token (token_id, token, authentication) values (?, ?, ?)] 
  4. 잘못된 토큰으로 재발급 시, 쿼리 5번.

    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?]
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?]
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?]
    [select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?]
    [select token_id, token from oauth_refresh_token where token_id = ?]

반복되는 select는 아래 쿼리네요.

[select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?]

1), 3)번인 토큰 발급과 재발급은 토큰 유효기간을 적당히 늘려서 횟수를 줄이는 것으로 회피해 본다고 쳐도(물론 이런 단순한 합리화는 결국 장애와 야근으로 이어질 우려가 큽니다..), 2)번 항목인 토큰 검증 부분은 반드시 대응이 필요합니다. 클라이언트에서 API호출마다 인증여부를 체크하는데, 그 때마다 인증DB에 부담이 되는건 염려스러운 일이니까요.
그래서 캐시를 적용하기로 합니다.

aop를 이용한 캐시 적용하기

우선 토큰 발급, 검증, 재발급에 해당하는 호출지점을 찾아봅니다.
oauth2관련 URI인 /oauth/token, /oauth/check_token은 앞서 디펜던시를 둔 라이브러리에 이미 컨트롤러가 구현돼 있습니다.
TokenEndpoint.java와 CheckTokenEndpoint.java가 그것입니다. 이 컨트롤러에서부터 흐름을 따라가며 동작을 확인해 볼 수 있습니다.

  • 토큰 발급과 재발급은 동일하게 /oauth/token을 호출하고 있고, 이 위치는 TokenEndPoint.java:postAccessToken(..)입니다.
    저는 mysql에 인증정보를 저장토록했기 때문에 DB쿼리는 JdbcTokenStore.java에 있는 메소드를 호출하게 됩니다.
  • 검증은 /oauth/check_token을 호출하고 있고, 이 위치는 CheckTokenEndpoint.java:checkToken(..)입니다.
    역시 DB쿼리는 JdbcTokenStore.java의 메소드를 호출합니다.
  • 제일 많은 select쿼리가 발생하는 loadClientByClientId(..)는 JdbcClientDetailsService.java입니다.

다 찾았습니다.
각 메소드가 호출되는 시점에 캐시를 입히면 되겠네요. 캐시는 레디스를 씁니다.
메소드가 호출되는 시점을 감싸서 호출 전후에 원하는 로직을 처리할 수 있도록, AOP를 이용해 보겠습니다.

캐시를 적용할 aop클래스를 하나 만듭니다.

@Component
@Aspect
public class OAuthTokenCaching {
...
} 

캐시를 적용할 메소드 위치가 많으니까, 각각의 포인트컷을 주르륵 등록해 줍니다.

가장 호출이 많은 loadClientByClientId(..)만 적용해줘도 어느정도 효과를 얻을 수 있습니다.

@Pointcut("execution(* org.springframework.security.oauth2.provider.client.JdbcClientDetailsService.loadClientByClientId(..))")
public void loadClientByClientId() {
}

@Pointcut("execution(* org.springframework.security.oauth2.provider.token.TokenStore.readAuthentication(..))")
public void readAuthentication() {
}

@Pointcut("execution(* org.springframework.security.oauth2.provider.token.TokenStore.storeAccessToken(..))")
public void storeAccessToken() {
}

@Pointcut("execution(* org.springframework.security.oauth2.provider.token.TokenStore.readAccessToken(..))")
public void readAccessToken() {
}

@Pointcut("execution(* org.springframework.security.oauth2.provider.token.TokenStore.removeAccessToken(..))")
public void removeAccessToken() {
}

@Pointcut("execution(* org.springframework.security.oauth2.provider.token.TokenStore.removeRefreshToken(..))")
public void removeRefreshToken() {
}

@Pointcut("execution(* org.springframework.security.oauth2.provider.token.TokenStore.removeAccessTokenUsingRefreshToken(..))")
public void removeAccessTokenUsingRefreshToken() {
}

다음으로 아래 psudo코드와 같은 방식으로 캐시 플로우를 작성 할 수 있을텐데요.

value = getCache(key)
if value is null {
  value = proceed()
  setCacheIfNotNull(value)
}
return value

위 플로우를 기본으로 하는 아래와 같은 코드를 작성했습니다.
캐시로 저장할 타입이 각각 ClientDetails, OAuth2AccessToken, OAuth2Authentication, String이었기 때문에 이들을 enum으로 따로 정의한 뒤, 호출되는 상황에 따라 적절한 타입을 취하는 방식입니다.
상황에 따라 캐시 만료 시간을 지정해야할 때와 그렇지 않을 때가 있어서 파라미터와 분기도 포함돼 있습니다.

private <T> T getOrProgress(ProceedingJoinPoint pjp, String key, Class<T> returnType, long expireSecond) throws Throwable {
   T value;
   Object cacheValue = redisService.get(key);
   if (cacheValue == null) {
       value = returnType.cast(pjp.proceed());
       if (value != null) {
           if (expireSecond > 0) {
               redisService.set(key, value, expireSecond, TimeUnit.SECONDS);
           } else {
               redisService.set(key, value);
           }
       }
   } else {
       value = returnType.cast(cacheValue);
   }

   return value;
}

@Around에서는 필요한 값을 셋팅한 뒤, 위에서 정의한 getOrProgress(..)를 호출합니다.

@Around("loadClientByClientId() || readAuthentication() || readAccessToken()")
public Object readHandler(ProceedingJoinPoint pjp) throws Throwable {
   // …(생략)
   return getOrProgress(pjp, key, returnType, expireSecond);
}

토큰을 제거 또는 저장하는 메소드들에 대해서는 @Before를 통해 적절히 캐시를 조정하는 것도 잊지 않습니다.

@Before("removeAccessToken()")
public void removeAccessTokenHandler(JoinPoint pjp) {
   // delete cache
}

@Before("removeRefreshToken() || removeAccessTokenUsingRefreshToken()")
public void removeRefreshHandler(JoinPoint pjp) {
   // delete cache
}

@Before("storeAccessToken()")
public void storeHandler(JoinPoint pjp) {
   // set cache
}

캐시적용 후 쿼리수는 어떻게 변했을까요?

  • 토큰 발급 쿼리 : 9회 => 5회
  • 토큰 검증 쿼리 : 4회 => 0회
  • 토큰 재발급 쿼리 : 14회 => 7회
  • 잘못된 토큰으로 재발급 쿼리 : 5회 => 1회

전반적으로 쿼리수가 줄어들긴 했습니다만, insert, update, delete같은 DML이 발생하는 로직을 걷어낼 순 없기 때문에 전체 쿼리수가 다이나믹하게 줄어들진 않아 보일 수 있습니다.
그래도 만족스러운건 매 API요청에서 감당해줘야 하는 토큰 검증 부분은 괄목할 수준(쿼리수:4회->0회)으로 줄었기 때문에, 이후에는 적절한 토큰 유효기간과 어떤 캐시 운용 정책을 세울 것인가에 대한 고민으로 풀어가면 될 듯 합니다.

aop는 만능이 아니다.

이렇게 aop를 이용해서 oauth2에 캐시를 적용해 봤습니다.
사실, loadClientByClientId(..) 호출로 생기는 반복적인 쿼리만 잡아줘도 어느정도 성과는 낼 수 있을 겁니다.
마음같아서는 모든 쿼리를 자~알 캐싱하고 만료시킬 수 있으면 좋겠다는 생각에 이것저것 더 챙겨보려고 하다보니 @Pointcut, @Before들이 덧붙게 됐습니다.
그러나 애석하게도 모든 부분을 aop로 감싸서 처리할 순 없었습니다. 정확히는, 해서는 안되는 부분들이 있었기 때문에 제외시킨 것들도 있습니다.
거기에는 두 가지 이유가 있는데요.

  1. aop는 proxy기반으로 동작하기 때문에 self-invocation되는 상황에서는 동작하지 않습니다.
    우리가 지정한 포인트컷에 대해, 정의한 어드바이스가 실행되지 않는다는 말입니다. 동작을 기대하는 로직이 동작하지 않는 것은 잠재적 버그이자 레거시가 될 수 있습니다.
    우리가 흔히 사용하고 있는 @Transactional, @Cacheable과 같은 어노테이션도 마찬가지 동작원리를 따르므로, 의도한 동작이 실행되는지 아닌지를 따져봐야 합니다.

  2. 같은 요청에 대해 같은 응답과 내부 동작을 기대할 수 있는 영역에 캐시를 적용하는 것이 좋습니다.
    메소드동작이 단순히 쿼리하는 것만이 아닌, 비지니스로직이 들어가 있다면 캐싱여부를 고민해봐야 합니다.
    로직이 때에 따라 다른 처리를 하도록 분기돼 있다면, 기대하는 응답이 달라질 수 있거나 또는 부가적인 데이터 처리를 위해 반드시 메소드를 호출해야 하는 경우가 있을 수 있기 때문입니다.
    캐시하고 싶은건 데이터지, 전체 세부 로직이 아니라는 점을 염두해야 합니다.
    이러한 이유로 저의 코드 스타일에도 변화가 생겼습니다. 데이터접근에 관한 메소드에 여러 로직을 넣지 않고, 최소한의 기대하는 기능만으로 잘게 쪼개는 코드 스타일을 선호하게 된 것입니다.

하고 싶은 말

well-made 프레임워크와 라이브러리가 참 많습니다.
스프링도 그 중 하나이며, 엄청나게 많은 훌륭한 개발자pool과 커뮤니티를 통해 ‘믿고 쓸 수 있도록’ 발전해 가는 시스템임에 (아직까지는..) 이견이 없습니다.
그러나 그 간편함과 편의성이, 내부 동작을 이해하지 않고 ‘믿고 쓸 수 있다’는 의미는 아닐겁니다.
계속해서 디벨롭된다는 건, 지금 사용하는 버전에 개선 포인트가 있다는 반증일테니까요.

그러니 실무적으로 빠르게 개발하고 실험하되, 이슈를 검증하고 대안을 모색하는 남모를 노력이 필요합니다.
그러기 위해 내부 동작을 이해하려는 수고와 습관은 디폴트겠죠.

다른 개발자분들과 나누고 싶은 이야기임과 동시에 제가 되새겨야 할 말인 것 같습니다.

감사합니다.