본문 바로가기

개발새발 개발자/기타

느슨해진 권한 관리 씬에 긴장감을 주다

대부분의 IT 서비스에는 관리자가 접속할 수 있는 어드민 페이지가 있다. 우리 회사 또한 어드민 계정이라면 유저, 매출 등 다양한 정보를 어드민 페이지에서 관리할 수 있다.
 
문제는 회사가 빠르게 성장하면서 구성원도 늘어났는데 이 많은 사람들이 본인의 업무가 무엇이든 간에 어드민 권한 하나만 있으면 모든 정보에 접근할 수 있게 되었다는 것이다. 자칫하면 내부의 누군가가 민감한 정보를 유출하거나 조작할 가능성이 있었다.

1. AS-IS

기존 인증 플로우

메인 서버

Node.js 환경의 레거시 서버다. 너무 덩치가 커져버려서 MSA로 계속 분리하고 있으나 아직 대부분의 로직이 메인 서버에 존재한다. 로그인 및 어드민 권한 관리 또한 메인 서버에서 담당한다.

Firebase SDK

구글에서 제공하는 툴이다. 로그인이나 권한이 필요한 페이지에 접속할 때마다 Firebase SDK로 JWT 토큰을 검증한다.
옛날 옛적 선조 개발자들이 서비스를 구축할 때, 빨리 만들다 보니 인증 등 많은 부분을 서드 파티에 위임했다. 서비스가 커진 지 몇 년이 됐지만 워낙 복잡한 레거시와 밀려오는 추가 요구 사항 때문에 걷어내지 못했다.

JWT?

JWT는 서비스끼리 통신할 때 Authorization을 위해 사용하는 토큰이다. header, payload, signature로 구성되어 있으며 셋 사이에 점을 찍어 이어 붙이면 JWT가 완성된다.

  • header: JWT를 어떻게 검증할지에 대한 정보. 서명 시 사용하는 알고리즘과 공개 키, 비밀 키의 식별 값을 가지고 있다.
  • payload: JWT의 내용. 유저 정보, 어드민 권한 값 등이 들어가있다.
  • signature: header와 payload를 합쳐 서명한 값. header에 정의된 알고리즘과 비밀 키로 생성한다. JWT의 서명을 검증하려면 공개 키가 필요하다.

MSA 서버

메인 서버에서 분리해 낸 도메인을 담당한다. 대부분이 Java/Spring Boot로 구현되어 있다. MSA에서 인증과 관련된 로직을 처리하려면 무조건 메인 서버에 의존해야 한다.

2. TO-BE

바뀌는 인증 플로우

접근 제어 라이브러리를 만들어 접속 요청을 메인 서버에서 받든, 각 MSA에서 받든 라이브러리에 위임한다. 메인 서버는 스파게티처럼 엮여있던 권한 체크 로직을 별도의 모듈로 분리해 유지 보수가 쉬워졌다. MSA는 권한 체크를 위해 다시 메인 서버에 API 콜을 할 필요가 없다.
 
접근 제어 라이브러리가 해당 페이지에 접근하기 위한 적합한 권한을 가지고 있는지 확인하기 위해 접근 제어 서버를 신규로 구축한다. 접근 제어 서버는 전체적인 유저의 권한값을 관리하는 서버다. 라이브러리가 JWT 및 기타 유저 유효성 검증을 한 뒤 이 유저의 권한이 제대로 된 권한이 맞는지 접근 제어 서버에 확인받는 것이다.
 
정리하면 새로 만드는 서비스는 2개다.

  • 접근 제어 서버
  • 접근 제어 라이브러리

이 프로젝트에서 나는 접근 제어 라이브러리 개발을 담당했다. 클라이언트와 접근 제어 서버 간의 다리 역할을 하는 것이다.

3. 라이브러리 요구사항

  • 서버 사이드에서 유효한 JWT 토큰과 접근 권한이 있는 사람만 기능을 이용할 수 있게 한다.
  • Node.js와 JVM 두 환경에서 모두 사용할 수 있다.
  • 응답 속도가 10ms 내외여야 한다.
10ms....요....?

라이브러리는 처리 속도를 최대한 빠르게 만드는 것이 관건이었다. 페이지에 접속하려고 시도하는 부분에서조차 시간이 길어지면 나머지 비즈니스 로직을 처리하는 동안 유저는 답답해하다가 결국 이탈할 것이다.

4. 1차 개발

로직이 복잡하진 않았기 때문에, 일단 주요 기능을 대충 만들어 놓고 성능이 얼마나 나오는지 확인하기로 했다. 라이브러리 내부는 2개의 모듈로 나누어져 있다. 서버에서 직접 요청을 받고 응답하는 모듈과 실제 로직이 담긴 코어 모듈이다.

  • 요청 서버에서 JWT와 유저가 가진 권한을 라이브러리에 보낸다.
  • 외부 API 모듈에 있는 인터페이스가 요청을 받는다.
  • 내부 core 모듈에 있는 구현체가 접근 제어 서버를 통해 JWT 유효성을 검증하고, 유저가 가진 권한을 확인한다.

1차 성능 테스트

  • 테스트 환경: production
  • 최대 동시 접속 유저 수: 100명 (admin 페이지 사용 대상)

사내에서 사용하고 있는 성능 측정 툴로 확인한 결과, p95 기준 764.75ms가 나온다. 목표는 10ms였는데 차이가 나도 너무 난다!
 

분석 툴을 통해 어떤 부분이 실행 시간을 많이 잡아먹는지 확인했다.

1) 서버에서 라이브러리에 요청

권한을 체크하기 위해 라이브러리 API를 요청하는 데 걸리는 시간이다.

2) Firebase 서버와 통신

JWT 유효성을 검사할 때 Firebase 서버와 통신하면서 걸리는 시간이다.

3) GraphQL 필드 변환

앞서 유저의 권한이 올바른지 확인하기 위해 접근 제어 서버를 거친다고 했다. 이 서버는 GraphQL로 요청을 받고 있는데, 네트워크로 넘어온 데이터를 역직렬화하는 과정에서 발생하는 시간이다. 

4) 유저, 권한 데이터 조회

접근 제어 서버에서 권한을 확인하기 위해 데이터를 조회하는 데 걸리는 시간이다.
 
1, 3번은 로우한 부분이기 때문에 빠른 런칭을 위해 2, 4번에 집중하기로 했다.

5. 성능 최적화

Firebase SDK 뜯어보기

Firebase 서버와 통신하지 않고 자체적으로 JWT를 검사하면 시간을 줄일 수 있지 않을까?

그래서 SDK가 JWT를 검증하는 로직을 분석해 보기로 했다.
 

공개 키 만료 여부를 확인하는 로직

SDK 로직의 대부분은 위에서 설명한 JWT 검증 방식대로 짜여 있었다. 문제는 signature를 검사하는 부분이었다. 메모리 상에 public key를 저장해두고 있다가 일정 시간이 지나면 refresh를 하기로 되어있다.
 

firebase 서버에 HTTP 요청을 하는 로직
응답의 max-age로 만료 시간을 계산하는 로직

public key를 만료하는 시간은 firebase 서버에 HTTP 요청을 보낸 뒤 받은 응답의 max-age값으로 계산하고 있었다. 즉, 유효한 공개 키를 사용하기 위해서는 꼭 서버를 거쳐야 하는 것이다. 여기서 실행 시간을 줄일 수 있을 거란 희망은 사라졌다...
 

하...SDK 까보느라 얼마나 고생했는데ㅠ

 

공개 키가 만료되지 않았을 때 실행 시간

하지만 달리 생각해 보면, 공개 키가 만료되지 않았다면 서버와 통신을 하지 않고 넘어간다는 뜻이기도 하다. 그래서 공개 키가 캐싱되어 있는 상태에서의 성능 측정을 다시 했더니 211.89ms가 나왔다.
 
일단 firebase 서버와의 통신은 어쩔 수 없이 유지해야 하는 것으로 판단하고 다른 최적화 방안을 찾기 시작했다.

쿼리 개선하기

접근 제어 서버는 MongoDB를 사용하고 있다. 공식 문서에서 쿼리 최적화 방법을 확인해 보자.
https://www.mongodb.com/docs/manual/tutorial/optimize-query-performance-with-indexes-and-projections/

Optimize Query Performance — MongoDB Manual

Docs Home → MongoDB Manual For commonly issued queries, create indexes. If a query searches multiple fields, create a compound index. Scanning an index is much faster than scanning a collection. The indexes structures are smaller than the documents refer

www.mongodb.com

인덱스 적용

쿼리 개선에 제일 만만한 건 인덱스 적용이다. 하지만 확인해 보니 이미 필요한 부분은 다 적용되어 있었다.

projection 적용

원하는 필드만 딱 찝어서 쿼리 하는 방식이다. 소수의 필요한 필드만 받도록 한다면 더 좋은 성능을 낼 수 있다고 한다. 마침 우리는 수많은 유저 데이터 중 권한만 알아내면 된다!
 

interface UserRepository : MongoRepository<User, String> {

    @Query(value = "{ 'id': ?0 }", fields = "{ 'roleName': 1 }")
    fun findUserRoleNamesById(id: String): Optional<User>

}

 
Spring은 MongoDB를 사용할 경우 Spring Data JPA처럼 간편하게 쿼리 할 수 있는 Spring Data MongoDB를 제공한다.

  • MongoRepository를 상속하는 UserRepository 인터페이스를 생성한다.
  • @Query 애너테이션으로 쿼리 조건과 projection 할 필드를 적어준다.

Spring Data JPA를 사용해 봤다면 유사한 방식으로 쿼리 할 수 있다.

aggregation 적용

aggregation은 여러 document를 한 번에 조회할 수 있게 해 준다. RDB의 join과 비슷하다고 생각하면 된다. DB 요청을 날리는 것도 네트워크 비용이기 때문에 아끼면 좋다.
 

[{
 $match: {
  _id: 'user_id'
 }
}, {
 $project: {
  'roles.roleName': 1
 }
}, {
 $lookup: {
  from: 'roles',
  localField: 'roles.roleName',
  foreignField: 'name',
  as: 'results'
 }
}, {
 $addFields: {
  permissionNames: '$results.permissions.name'
 }
}, {
 $project: {
  permissionNames: {
   $reduce: {
    input: '$permissionNames',
    initialValue: [],
    'in': {
     $concatArrays: [
      '$$value',
      '$$this'
     ]
    }
   }
  },
  _id: 0
 }
}, {}]

원래는 유저를 한 번 조회하고, 그 유저의 권한이 현재 필요한 권한이 맞는지 조회하는 2번의 네트워크를 탔는데, aggregation을 이용해 유저와 권한 조회를 하나로 합쳐보았다.

쿼리 개선 결과

실행 시간을 계산해 보니 평균 49ms가 나온다. 기존 126ms 대비 60% 빨라졌다.

6. 최종 성능 테스트

1차 때와 같은 조건으로 성능 테스트를 실행하니 166ms가 나왔다. JWT 공개 키를 계속 캐싱하고 있다는 가정 하에 211.89ms에서 약 20%가 줄었다. 목표였던 10ms까지 개선은 실패했지만 유의미한 개선이다. 일단 런칭이 우선이니 런칭 후 모니터링 하면서 개선점을 더 찾아보기로 하고 성능 개선은 마무리 지었다.

7. 커스텀 애너테이션 만들기

성능 테스트를 해보기 위해 대충 갈겨놨던 코드를 리팩토링 할 시간이 왔다. 권한을 확인하는 로직마다 일일이 checkPermissions() 메서드를 호출하는 건 번거로운 중복 작업이다.
 

@RestController
@RequiredArgsConstructor
public class Controller {

    @Authorize("admin")
    @PostMapping
    public CommonResponse test() {
        return CommonResponse.ok("ok");
    }
}

 
이렇게 권한 확인이 필요한 컨트롤러에 간단히 애너테이션만 달면, 기입한 권한을 가지고 있는지 체크하도록 만들어보자.
 

@Aspect
@RequiredArgsConstructor
public class AuthorizeProcessor {

    private static final String AUTHORIZATION = "authorization";

    @Around(value = "@annotation(authorize)")
    public Object before(ProceedingJoinPoint joinPoint, Authorize authorize) throws Throwable {
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        assert requestAttributes != null;

        String authorization = requestAttributes.getRequest().getHeader(AUTHORIZATION);
        
        // 라이브러리 core 구현체로 권한 확인을 요청하는 로직
        ...
        
        return joinPoint.proceed();
    }
}

AOP를 활용해 authorize 애너테이션이 있으면 서블릿에서 authorization 헤더를 가져오고 권한을 체크한다.
 

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorize {
    @AliasFor("permissionNames")
    String[] value() default {};

    @AliasFor("value")
    String[] permissionNames() default {};
}

커스텀 애너테이션을 만드는 방법은 구글링 하면 상세히 나와있으니 고대로 따라 하면 된다. 여러 개의 권한을 체크하는 것이 요구 사항이라 배열 타입으로 정의했다.
 

@Aspect
@RequiredArgsConstructor
public class AuthorizeProcessor {

    @Around(value = "@annotation(authorize)")
    public Object before(ProceedingJoinPoint joinPoint, Authorize authorize) throws Throwable {
        ...
        
        String authorization = requestAttributes.getRequest().getHeader(AUTHORIZATION);
        // autorize 애너테이션에서 넘겨준 값을 get하는 로직
        List<String> permissionNames = Arrays.asList(authorize.permissionNames());

        ...

        return joinPoint.proceed();
    }
}

@AliasFor("value") 덕분에 authorize 애너테이션에 넣어준 값들을 permissionNames()로 가져올 수 있다.

회고

firebase SDK를 뜯어보면서 어렴풋이 알고만 있었던 JWT 검증 방식이나 OOP적인 설계들을 확인해 볼 수 있었다. 성능 개선은 글로 쓰니 참 간단하고 쉽게 한 것 같지만, 사실 개선할 수 있는 포인트를 찾고 방향을 고민하는 것이 무척 힘들었다. 다양한 지식을 총집합시켜야 하다 보니 CS 지식 쪽으로 아직 많이 부족하다는 걸 깨닫기도 했다.
 
이런 일련의 과정을 거치면서 다양한 개발자들과 고민하고 토론할 기회가 많았는데, 그때 나눈 그들의 인사이트가 많은 도움이 되었다. 개발 토론의 장, 너무 좋다!