MSA 인스턴스 - 회원, 인증

MSA 설계를 하는 시점에서, 내가 구현하는 앱이 채팅인 만큼 채팅채팅이 아닌 것을 기준으로 인스턴스를 나눠 설계해야겠다는 생각이 들었다. 채팅은 기본적으로 웹소켓 프로토콜에서 이뤄지고 그외의 것은 HTTP 프로토콜에서 이뤄지는 것이 보편적이었기 때문이었다.

모놀리스에서 MSA로 넘어가는 취지 중 하나였던, 단일 기능이 전체 프로세스에 영향을 끼치는 것을 방지하자는 의의에 맞춰서 특정 프로토콜이 다른 프로토콜에 영향을 끼치는 것을 막기 위한 것도 있었다. 사실 앱 수준이 복잡하지 않아서 채팅 기능에 포함되지 않을 법한 것이라 해봤자, 회원이랑 인증 정도였다.

참고로 해당 포스팅 시점에서 이미 기능 확장 완료 후, 1차 배포를 준비 중이다. 그래서 포스팅 성격이 회고의 느낌이 강할 예정

Issue 1) 인증 정보의 공유

인스턴스가 전부 분리되면서 가장 먼저 든 생각은, 아래와 같았다.

Q. 해당 사용자가 인증됐음을 어떻게 다른 인스턴스에서도 인지시키는가?

이에 대하여 여러 해결책을 비교, 생각해보면서 적합한 해결책을 생각해보았다.

A. 회원 인스턴스에서의 일회성 인증 후, 다른 인스턴스는 통과

가장 간단한 방법이라고 생각했다. 클라이언트의 접근 관리는 프론트엔드가 별도로 구축되어있기 때문에 로그인 화면을 넘어선 시점에서 인증이 됐다고 간주할 수 있으므로 생각할 수 있는 해결책이었다. 하지만, 이렇게 되면 다른 인스턴스에 대한 API 호출을 비인증 사용자가 수행할 수 있다는 것이므로 인증의 기능을 포기하는 것과 다를 바 없으므로 이 해결책은 기각됐다.

A. 각각의 인스턴스 별로 인증 로직을 구축

첫 번째 해결책의 맹점을 파악하였고, 그렇다면 모든 인스턴스에 대해 인증 여부를 검증하는 로직이 갖춰져야 한다고 생각했다. 기존 모놀리식 아키텍처에서 JWT 기반 인증을 채택하였으므로, JWT 엑세스 토큰을 파싱하는 로직을 각 인스턴스의 필터 단계에서 구축하면 되지 않을까라는 생각을 했다. 가장 확실한 방법이지만, API 호출에 대해 중복되는 작업을 수행하는 셈이 되기 때문에 트래픽이 몰리면 악영향을 끼칠 것으로 생각했다.

A. 인증된 정보를 캐시로써 활용해서 다른 인스턴스가 참조(1차 결정)

1차적으로 최종 결정된 해결책은 캐시를 도입함으로써 다른 인스턴스에서 이를 참조하는 것이었다. 입출력 작업이 매우 빠른 Redis를 활용해서 인증 정보(username, authority)를 조회해서 각각의 인스턴스에서 인증 객체를 생성케 하는 로직을 구상했다. 이를 조금 더 상세히 분리하는 것이 후술할 두 번째 이슈의 해결책을 마련하는 과정에 포함되어 있었다.

Issue 2) 인증 책임의 관심사 분리

이 이슈를 이해하기 위해서는 인증과 관련된 의존성인 스프링 시큐리티의 역할을 이해하고 넘어가야 했다. 기본적으로 스프링 시큐리티는 Authentication 인터페이스 기반으로 관리되는 인증 객체를 통해 디스패처 서블릿 통과 여부를 검증한다. 이 말인 즉슨, 인증 객체의 생성 로직에 대해 통제하고 있으면 된다.

disc. 인증 객체의 생성 로직

스프링 시큐리티에서의 인증 객체 생성은 보통 다음과 같이 이뤄진다.

// ...

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(createAuthentication(username));
        SecurityContextHolder.setContext(context);
    }

    // Authentication 객체 생성 (UPAT 생성)
    private Authentication createAuthentication(String username) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }

username 혹은 기타 식별 정보를 바탕으로 UserDetails 객체를 조회한 후, 사용자의 usernameauthority를 바탕으로 UsernamePasswordAuthenticationToken 객체를 생성한다. 즉, 캐시에 존재해야 할 데이터에 usernameauthority를 포함시켜야 한다.

이 정보를 갖고 올 수 있는 방법으로 세션 인증, JWT 인증 등 다양한 인증 수단을 강구할 수 있는 것이다. 결론은, '인증'과 '인증 수단'은 분리해서 구현하는 것이며, 분리의 단위를 MSA 인스턴스로 삼아 별개의 인증 수단 인스턴스 구축으로 삼았다.

A. 인증 정보 비동기적 수신 처리(결정)

N.t. Kafka는 비동기 스트리밍에 최적화된 도구

API Gateway는 보통 Netty 엔진을 기반으로 구축된다. 그 이유는 성능과 확장성을 극대화하고, 비동기 요청 처리에 필요한 요구 사항을 충족시키기 위해서다. 인스턴스 간의 통신 수단을 Kafka(다음 포스팅에서 후술)로 구축한 시점이기 때문에, 자연스럽게 Kafka를 통한 인증 인스턴스와 API Gateway 간의 인증 정보 송수신을 생각했다.

// Auth Filter in API Gateway

// 인증 요청 Kafka 전송
return kafkaProducerTemplate.send(topic, id.toString(), tokenDTO)
        .then(Mono.defer(() -> {
            // 인증 응답 대기
            return kafkaReceiver
                    .receive()
                    .filter(record -> record.key().equals(id.toString()))
                    .next()
                    .map(ConsumerRecord::value)
                    .flatMap(userInfoDTO -> {
                        // ...

다만 이것의 문제는 Kafka의 비동기성 및 이벤트 스트리밍 취지를 위배하는 것이었다. 실제로 요청 - 응답 모델의 형식으로 구축되는 시점에서 Locust로 테스트를 수행했을 때, 인증을 전제로 하는 로그아웃 요청이 전부 실패하는 테스트 결과를 받았다.

Kafka 요청 - 응답 모델 테스트

위의 Locust 테스트 결과를 보면 로그아웃(/api/users/logout) 요청이 전부 실패했으며 그 원인은 서버 내부의 에러(500)임을 알 수 있는데, 애시당초 비동기에 특화된 Kafka를 요청 - 응답이라는 블로킹 방식으로 활용하려다가 문제가 발생했던 것이다.

N.t. Redis의 최대 장점은 고속 성능

해당 이슈는 Kafka 포스팅에서 자세히 후술하도록 하고, 여튼 이런 문제로 Kafka의 응답 부분을 떼어낸 후, 인증 인스턴스의 결과를 Redis에 바로 저장해서 캐시로 활용함과 동시에 API Gateway에서 직접 캐시를 조회하는 방식으로 수정, 구현했다.

// Auth Filter in API Gateway

// 인증 요청 Kafka 전송
return kafkaProducerTemplate.send(topic, tokenDTO)
        .flatMap(result -> {
            // Kafka에 메시지 전송 후 Redis에서 결과를 비동기적으로 조회
            return checkRedisForUserInfo(id)
                    .flatMap(userInfoDTO -> {
                    // ...

여기서 고려할 점으로 API Gateway의 Redis 조회가 인증 작업보다 빠른 것에 대한 대비책인데, 엑세스 토큰의 파싱 속도보다 API Gateway의 조회 속도가 더 빠를 경우 아래와 같은 로그를 출력할 수도 있다.

API Gateway의 조회와 엑세스 토큰 파싱의 비정합성

그렇기 때문에 아래와 같은 수신 대기 메소드를 추가로 작성해서 활용하였다. 0.05초 간격으로 재시도 로직을 수행해서 인증 정보를 받아올 수 있도록 처리함과 동시에, 최대 타임아웃으로 10초를 설정하여 인증 로직 혹은 Redis에 문제가 발생해 캐시가 조회되지 않으면 예외를 내뱉도록 처리했다.

// redis 캐시 조회 메소드
private Mono<UserInfoDTO> checkRedisForUserInfo(String id) {
    return Mono.defer(() -> {
            UserInfoDTO userInfo = userInfoTemplate.opsForValue().get(REDIS_ACCESS_KEY + id);

                if (userInfo != null) {
                    log.info("Redis에서 사용자 정보 조회 성공 - UserInfo: {}", userInfo);  // 성공 시 로그 추가

                    if (!id.equals(userInfo.getId())) {
                        userInfoTemplate.delete(REDIS_ACCESS_KEY + id);
                        userInfoTemplate.opsForValue().set(REDIS_ACCESS_KEY + userInfo.getId(), userInfo, 120 * 30, TimeUnit.SECONDS);
                    }

                    return Mono.just(userInfo);
                } else {
                    log.info("Redis에서 사용자 정보가 없음 - ID: {}\n 조회하는 시간: {}", id, System.nanoTime());  // 데이터가 없는 경우 로그 추가
                    return Mono.empty();
                }
            })
            .repeatWhenEmpty(flux -> flux
                    .delayElements(Duration.ofMillis(50)) // 0.05초 간격으로 재시도
                    .take(200)) // 재시도
            .timeout(Duration.ofSeconds(10)) // 타임아웃 설정
            .onErrorResume(e ->
                    Mono.error(new ResponseStatusException(
                            HttpStatus.INTERNAL_SERVER_ERROR, "레디스 유저 임시정보 조회 에러", e)));
    }

Redis에 캐시가 있을 때는 굳이 인증 인스턴스를 거치지 않고 바로 요청을 라우팅하고, 캐시가 존재하지 않으면 그제서야 인증 인스턴스로 Kafka를 통해 송신해서 인증 정보를 캐시 조회로 받아오는 방식이기 때문에 아래와 같은 시나리오를 볼 수 있다. 이를 통해 인프라적 개선을 이끌어낼 수 있었다.

Redis에 캐시가 존재할 경우

Redis에 캐시가 존재하지 않을 경우

Kafka 전파 - Redis 캐시 조회 모델 테스트

Issue 3) 엑세스 토큰의 조회 키 활용

기본적으로 JWT 인증에서 엑세스 토큰은 클라이언트에 노출됨을 전제로 한다. 그렇기 때문에 엑세스 토큰에 들어가는 내용은 사용자의 이메일 등, 공개가 전제된 정보들을 바탕으로 생성한다. 그렇다고 한들 엑세스 토큰이 제3자에게 노출되는 것은 공격의 빌미를 제공하고 보안 취약점을 유발하는 것이기 때문에 로직 내에서의 활용에 있어 노출을 최대한 지양하는 방법에 대해 고민했다.

disc. Redis 조회 키 설정

Redis에서 데이터를 조회하기 위한 키를 고민했다. 클라이언트에 노출되면서 각 사용자별로 식별값을 가질 수 있는 엑세스 토큰(혹은 기반 키)을 Redis 조회 키로 삼는다. 이를 API Gateway에서는 캐시를 조회하는 Redis 키로 활용하는 방식을 설계했다.

하지만 위에서 언급했듯이 엑세스 토큰이 로직 활용에 있어 대놓고 노출되는 느낌이 너무 강해서 이것을 최대한 방지하고 싶어서 Redis 조회 키 설정 기준에 대해 고민하였다.

1. 각 사용자별로 고유한 값

2. 키를 통해서는 해당 사용자가 누군지 추측할 수 없어야 함

상당히 모순적(...)인 기준인데, 이 기준과 관련해서 디바이스 핑거프린트라는 개념을 접하고 키의 암호화에 적용하는 메소드를 구축했다.

A. 디바이스 핑거프린트 메소드 작성(결정)

// 디바이스 핑거프린트 생성 메소드
private String createFingerPrint(String token) {
    String data = key + token;

    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));

        // 바이트 배열 16진수 문자열로 변환
        StringBuilder hexString = new StringBuilder();
        for (byte b : hash) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) hexString.append('0');
            hexString.append(hex);
        }

        return hexString.toString();
    } catch (NoSuchAlgorithmException e) {
        throw new RuntimeException("해시 알고리즘 탐색 불가", e);
    }
}

엑세스 토큰과 암호화 키워드를 조합한 후, SHA-256 해시 알고리즘을 기반으로 결합된 문자열을 바이트 배열로 변환하여 해시를 생성한다. 이떄, 해시 생성은 MessageDigest 클래스를 활용한다. 생성된 해시 바이트 배열을 16진수 문자열로 변환해서 각 바이트를 16진수로 변환하고, 만약 한 자리 수인 경우에는 앞에 '0'을 추가하여 두 자리로 만들어 일관된 형식을 유지한다.

profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글