[과릿] Redis 과의존 개선기

이정진·2024년 9월 30일
1

개발

목록 보기
21/21
post-thumbnail

과릿을 통해 경험한 내용 및 달성한 성과를 포트폴리오에 정리하면서 지인들에게 피드백을 요청했었다. 지인들과 과릿에서의 Redis 사용에 대해서 많은 얘기를 나누었는데, Redis를 사용하면서 놓치고 있던 부분(Ex: 데이터 싱크, 장애 대응 등)이 많았다는 것을 깨달았다. 제대로 고려하지 않고 도입한 Redis로 인해 발생할 수 있는 (또는 발행한 적이 있었던) 문제 상황 및 해결 과정을 정리해보고자 한다.

문제상황

과릿을 운영하며 실제로 Redis 장애(참조: Redis 스냅샷 오류)를 경험한 적이 있었다. 당시, 로그인 장애가 발생하여 약 15분간 신규 사용자 유입이 불가능했었다.

이에 더해 발생한 적은 없지만, Redis의 다운으로 인해 데이터 손실이 발생했을 때를 대비하여 MySQL이 그 역할을 이어받을 수 있어야 했다.
정확히 말하자면, Redis가 유일하게 데이터를 저장하는 공간이 되지 않아야 하는 기본 원칙을 지키지 않았기에, 이를 FailOver 형식으로 개선하기로 결정하고 도입했던 과정을 정리한다.

과릿과 Redis

개선 과정을 정리하게 앞서, Redis는 무엇이며 과릿에서 왜 Redis를 도입해서 활용하고 있는지를 언급하고 가겠다.

Redis란?

Redis를 정의하자면, key-value 저장소로서, 인메모리 NoSQL 데이터베이스이다. 캐싱, 세션 관리, PubSub, Sorted Set을 활용한 랭킹 시스템 등의 다양한 용도로 활용할 수 있다.

Redis 도입 목적 및 관리하는 데이터

도입 목적

  • Timeout이 존재하는 데이터를 적재 및 삭제 용이
  • 데이터 캐싱을 통해 DB I/O 최소화 및 사용자 경험 개선

관리하는 데이터 및 Flow

인증번호
과릿은 회원가입 및 비밀번호 초기화 시 전화번호 인증을 통한 사용자 확인을 진행하고 있다.
비밀번호 초기화 Flow 위 이미지의 중간 과정을 보면, 랜덤 인증번호를 발급 받은 이후, 전화번호와 인증번호를 Redis에 저장하고 있는 것을 알 수 있다. 인증번호는 5분이라는 인증 유효기간 이후 사라지며, 재요청 시 덮어쓰기가 되어야 하기에, Redis에 데이터를 적재하는 것을 선택했었다.

JWT 토큰
과릿은 로그인 시, Access Token과 Refresh Token을 발급하여 반환한다. 그 중 Refresh Token을 Redis에 저장하여, Access Token이 만료되었을 때 재발급 시 일치 여부를 확인하기 위하여 사용한다. 로그인 Flow 또한 위 이미지 로직과 더불어, 로그아웃 API를 통해 사용하지 않는 Access Token을 블랙리스트로 등록해 Expire전까지 해당 Access Token으로 API 요청이 올 경우, 권한 없음으로 처리하도록 개발했다.

Access Token과 Refresh Token 구조를 채택한 이유
Access Token만 있을 경우, Access Token이 만료되면 지속적으로 로그인을 진행해야 한다. 모바일 앱에서는 대체적으로 로그인 상태 유지 옵션을 활성화하는 것이 일반적이고, 이것이 사용자 경험의 불편함을 해소하는 옵션이라고 생각하여 Access Token과 Refresh Token을 함께 사용하는 구조를 채택했다.

Redis 장애 시 대응 방법

Redis의 장애 시 대응 방법은 단순하게 서버 재시작뿐이었다. 과릿의 Redis는 RDB 스냅샷 방식의 백업을 사용하고 있기에, 해당 스냅샷으로 Redis를 재시작한다면 데이터의 손실은 최소화한 상태로 운영은 가능했다.
다만, 이 방식은 결국 수동이라는 점이다. 과릿의 백엔드/인프라를 혼자 담당하고 있기에 확인 및 대응이 불가능한 시점에 장애가 발생한다면 서비스에 치명적인 혼란을 초래할 수 있어 개선이 꼭 필요한 상황이다.

Redis 대신 다른 방법은 사용할 수 없었는지

Redis 대신 서버 내 자료 구조를 활용하는 등의 방법은 없었는지 궁금할 수 있다.
이는 과릿의 첫 번째 인프라 아키텍처를 통해 Redis를 도입하게 된 이유를 알 수 있다.
과릿의 첫 번째 인프라는 다음과 같이 구성되어 있었다.
Auto Scaling 설정을 통해, CPU 점유율이 일정 수치 이상이 되면 새로운 EC2 서버를 띄워 트래픽을 분산하도록 ElasticBeanstalk 기반의 인프라를 구성했었다. 이 구성에서는 서버 내 자료 구조를 활용하게 될 경우, 새롭게 띄워진 EC2 서버에서는 데이터를 확인할 수 있는 방법이 없다. Redis를 활용해 Auto Scaling 상황에서도 데이터를 공유하면서 관리하는 것이 효과적이라 판단했다.

Redis를 AWS ElastiCache를 사용하지 않은 이유는 비용 때문이다.

과릿의 인프라

과릿은 현재는 위의 인프라를 유지하고 있지 않다. 간략하게, 변경된 인프라를 정리한다.

3번의 마이그레이션

위의 첫 번째 인프라 아키텍처는 SW마에스트로에서 서버 비용을 지원해주었기에 구성할 수 있었던 꽤나 비싼 인프라였다. SW마에스트로 고도화 과정이 종료된 이후, 팀원과 논의하여 계속 과릿을 운영하기로 결정했기에 인프라 이전이 필요했다.

GCP는 3개월간 최대 30만원의 비용을 사용할 수 있는 지원이 존재한다. 두 번째 인프라는 GCP를 활용해서 구성했다.

GCP의 3개월 요금 지원이 종료된 이후, AWS의 프리티어 계정을 활용하여 마이그레이션을 진행했다.

위 아키텍처들은 IGW, NAT Gateway 등은 생략된 인프라 아키텍처이다.

Redis를 계속 사용하는 이유

첫 번째 인프라와 달리 두 번째, 세 번째 인프라는 Auto Scaling 옵션을 적용하고 있지 않다. 그럼에도 불구하고, 왜 Redis를 사용하고 있는지 궁금할 수 있다. 이에 대한 답변은 다음과 같다.
마이그레이션 과정에서 Redis 백업본을 그대로 가져와서 활용했다. Redis를 사용하지 않고, 다른 방식으로 전환하게 된다면 모든 사용자의 로그인이 반드시 풀려야 하는 상황이 발생한다. 이는, 사용자 입장에서 로그인을 다시 진행하거나 오래되어 비밀번호를 까먹어 재발급 및 수정을 진행하는 등의 경험을 하게 된다. 사용자 경험상 발생시키지 않아야 하는 일이라고 판단되어, Redis를 유지하기로 결정했다.

Redis의 백업본을 효과적으로 서버 내부 자료 구조 등으로 마이그레이션할 수 있는 방법에 대해서 알고 있지 않아 선택한 방식이기도 하다.

과릿의 바뀐 Redis 사용법

과릿은 현재 FailOver 방식으로 Redis를 사용하고 있다. FailOver란 무엇인지, 과릿에서는 어떻게 적용했는지 그 상세한 내용을 다루어본다.

FailOver 방식

토스의 캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁이라는 글에서 캐시 문제에 관한 다양한 내용을 다루고 있다. 해당 글의 3. 캐시 시스템 장애 파트를 보면 과릿이 겪을 수 있는 문제점에 대한 내용이 정리되어 있다.

문제 상황으로, 조회 요청이 폭발하여 Redis 서버에 장애가 발생했다고 가정하고 있다. 이를 과릿의 상황으로 대입해보자면, 악성 사용자가 인증번호를 무수히 많이 요청한 상황이거나 단일 EC2 t2.micro 서버의 한계로 인해 메모리가 부족한 상황이 될 수 있겠다.

이 때, 토스가 제시한 해결책이 FailOver이다.
FailOver는 한국어로는 대체작동이라고 정의할 수 있다.

FailOver의 핵심 내용은 다음과 같다.

  • 반드시 동작해야 하는 핵심 기능은 유지
  • 편의를 위한 부가 기능은 동작 정지

즉, 핵심 기능은 데이터베이스를 활용해 트래픽을 처리하도록 하면서 부가 기능은 사용자에게 별도의 공지를 통해 사용 불가함을 알려 해결하는 것이다. 이 방식을 적용하기 위해서는 서비스의 핵심 기능과 부가 기능을 분리할 수 있어야 하며, 핵심 기능을 데이터베이스를 활용해 트래픽을 처리할 수 있도록 구성해야 한다.

과릿에서의 FailOver

  • Redis 서버 다운 시 대처 방안
    - 토스 기술 블로그의 캐시 글에서 나온 FailOver 방식 도입
    • 과릿 서비스에 어떻게 적용한 것인지 설명
    • Redis가 죽었을 때, 서비스가 에러를 뱉지 않고 MySQL을 통해 중요 서비스를 유지할 수 있도록 리팩토링

과릿에서 FailOver를 도입하기 위해서 핵심 기능과 부가 기능을 정리해야 한다.
과릿에서 Redis를 활용하고 있는 정보는 다음과 같다.

  • 인증번호
  • 블랙리스트
  • Refresh Token

서비스 전체에 장애를 줄 수 있는 핵심 기능은 인증번호, 블랙리스트 등 총 두 가지의 정보이다. 인증번호는 회원가입, 비밀번호 초기화와 같은 인증 로직에서 핵심적인 역할을 하는 데이터이기 때문이며, 블랙리스트는 토큰을 통한 부적절한 접근을 막기 위한 보안적인 기능이기 때문이다.
Refresh Token은 장애 발생 시, 재로그인을 통해서 사용은 가능하므로 부가 기능으로 판단했다.

위와 같이 핵심 기능과 부가 기능을 분리했지만, FailOver를 처음 도입해보기에 해당 세 가지 정보를 모두 FailOver 동작이 가능하도록 구성해볼 것이다.

Redis 장애와 처리 Flow

Redis 장애 여부와 데이터 존재 여부에 따른 처리 Flow를 분리해서 정리한 내용이다.
Redis 정상 & 데이터 존재
Redis가 정상이면서, 데이터가 존재한다면 데이터베이스로 트래픽을 돌릴 필요 없이 바로 Redis의 데이터를 활용하면 된다.

Redis 정상 & 데이터 미존재
Redis가 정상이지만, 데이터가 미존재하는 경우가 있을 수 있다.
과거 Redis가 장애가 발생했을 당시, 데이터베이스에만 데이터가 저장된 경우가 그 예시이다. 이럴 때에는 Redis가 정상임에도 불구하고 데이터베이스로 트래픽을 돌려서 데이터를 조회하여 비즈니스 로직을 진행하면 된다.

Redis 장애
Redis 장애가 발생했을 경우이다. 모든 트래픽이 데이터베이스로 몰리게 된다.

데이터 설계

  • 인증번호
  • 블랙리스트
  • Refresh Token

위 데이터들을 데이터베이스에 어떻게 저장할 것인지 설계해야 한다.

처음에는 Redis의 key-value 형태를 동일하게 차용하여 pk를 key, value라는 text 자료형의 column, Long 타입의 expired_at으로 설계했었다. RDB 특성을 살리지 않는 방식이면서, 적재된 데이터들에 대해서 유지 및 관리의 어려움이 있다고 판단되어 데이터별로 다음과 같이 테이블을 분리했다.
ERD

Redis 장애 여부 확인

시퀀스 다이어그램에서도 확인할 수 있듯이 데이터 조회 과정에서는 Redis 장애 여부를 확인하는 것이 MySQL을 대체로 사용할 것인지에 판단하는 핵심 조건이다.

RedisDto

@Getter
@Builder
public class RedisDto {

    private String key; // Redis Key
    private String value; // Redis Value
    private boolean isSuccess; // Redis 작업 성공 여부
}

RedisClient

@Service
@RequiredArgsConstructor
public class RedisClient {

    private final RedisTemplate<String, String> redisTemplate;

    /**
     * Redis에 key-value 저장
     * @param key 저장할 key
     * @param value 저장할 value
     * @param timeout 데이터 유효시간 (expire time)
     */
    public RedisDto setValue(String key, String value, Long timeout) {
        // Redis 서버가 정상적으로 동작하지 않을 경우
        if(!isRedisAvailable()) {
            return RedisDto.builder()
                    .key(key)
                    .value(value)
                    .isSuccess(false)
                    .build();
        }

        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key, value, Duration.ofMinutes(timeout));

        return RedisDto.builder()
                .key(key)
                .value(value)
                .isSuccess(true)
                .build();
    }

    /**
     * Redis에서 key로 value 조회
     * @param key 조회할 key
     * @return key에 해당하는 value
     */
    public RedisDto getValue(String key) {
        if(!isRedisAvailable()) {
            return RedisDto.builder()
                    .key(key)
                    .isSuccess(false)
                    .build();
        }

        ValueOperations<String, String> values = redisTemplate.opsForValue();

        return RedisDto.builder()
                .key(key)
                .value(values.get(key))
                .isSuccess(true)
                .build();
    }

    /**
     * Redis에서 key로 value 삭제
     * @param key 삭제할 key
     */
    public void deleteValue(String key) {
        // Redis 정상 동작 시에만 삭제
        if(isRedisAvailable()) {
            redisTemplate.delete(key);
        }
    }

    /**
     * Redis 서버가 정상적으로 동작하는지 확인
     * @return Redis 서버 동작 여부
     */
    private boolean isRedisAvailable() {
        try {
            return Optional.ofNullable(redisTemplate.getConnectionFactory())
                    .map(connectionFactory -> (connectionFactory.getConnection().ping() != null))
                    .orElse(Boolean.TRUE);
        } catch (Exception e) {
            return false;
        }
    }
}

처음에는 위 코드에서 isRedisAvailalbe() 메소드를 외부 접근이 가능하도록 설정하여, 각 비즈니스 로직에서 장애를 확인하도록 작성했었다. 그러나, 해당 방식이 변경 포인트가 너무 많아진다고 판단되어 RedisDto를 만들어 장애 발생 여부를 표현하는 isSuccess를 활용하는 방식을 채택했다.

인증번호 개선

인증번호 발송

@Transactional
public void sendAuthorizationCode(PostAuthPhoneReq postAuthPhoneReq) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException, URISyntaxException {
    // Business Logic - 테스트계정은 문자 발송이 되지 않도록 수정
    if(!postAuthPhoneReq.getPhone().equals("01011111111")) {
        String code = smsClient.sendAuthorizationCode(postAuthPhoneReq);

        // Redis 저장 (데이터 쓰기 작업은 Redis 성공/실패 여부와 상관없이 동작해야 하므로)
        redisClient.setValue(postAuthPhoneReq.getPhone(), code, 300L);

        // MySQL 저장 (계정당 가장 최신의 요청 하나만 가지고 있어야 하므로 DELETE 후 INSERT)
        authorizationCodeRepository.deleteAllByPhone(postAuthPhoneReq.getPhone());
        AuthorizationCode authorizationCode = AuthorizationCode.builder()
                .phone(postAuthPhoneReq.getPhone())
                .authorizationCode(code)
                .build();
        authorizationCodeRepository.save(authorizationCode);
    }

    // Response
}

인증번호 확인

private void checkAuthenticationCode(String phone, String code) {
    // Redis - 인증코드 조회 및 확인
    RedisDto redisDto = redisClient.getValue(phone);
    if(!redisDto.isSuccess() || redisDto.getValue() == null || !redisDto.getValue().equals(code)) {
        // MySQL - 인증코드 조회 및 확인 (Redis 장애 또는 Redis 데이터가 없는 경우)
        AuthorizationCode authorizationCode = authorizationCodeRepository.
                findByPhone(phone)
                .orElse(null);
        // 데이터가 존재하지 않을 경우
        if (authorizationCode == null) {
            throw new MemberException(ErrorCode.WRONG_AUTHENTICATION_CODE);
        }
        // 인증코드 유효기간이 끝난 경우
        if(LocalDateTime.now().isAfter(authorizationCode.getExpiredAt())) {
            throw new MemberException(ErrorCode.WRONG_AUTHENTICATION_CODE);
        }
        // 인증코드가 일치하지 않는 경우
        if(!authorizationCode.getAuthorizationCode().equals(code)) {
            throw new MemberException(ErrorCode.WRONG_AUTHENTICATION_CODE);
        }
    }
}

로그인/로그아웃 개선

로그인

@Transactional
public PostLoginRes login(PostLoginReq postLoginReq) {
    // Validation: 계정 존재 여부 및 회원탈퇴 여부 확인
    Member member = memberRepository.findActiveByPhoneAndType(postLoginReq.getPhone(), MemberType.valueOf(postLoginReq.getType())).orElse(null);
    if(member == null) {
        throw new MemberException(ErrorCode.NOT_FOUND_EXCEPTION);
    }
    if(!member.getPassword().equals(SHA256.encrypt(postLoginReq.getPassword()))) {
        throw new MemberException(ErrorCode.WRONG_PASSWORD);
    }

    // Business Logic: 토큰 발급 및 Redis 저장
    TokenDto tokenDto = tokenProvider.generateToken(member);
    String key = member.getType() + member.getPhone(); // unique 확인은 phone + type이므로 이를 string으로 저장, 앞 7자리는 type으로 고정
    // Redis 토큰 저장 (데이터 쓰기 작업은 Redis 성공/실패 여부와 상관없이 동작해야 하므로)
    redisClient.setValue(key, tokenDto.getRefreshToken(), 30 * 24 * 60 * 60 * 1000L);
    refreshTokenRepository.deleteAllByPhoneAndMemberType(member.getPhone(), member.getType());
    RefreshToken refreshToken = new RefreshToken(
            member.getPhone(),
            member.getType(),
            tokenDto.getRefreshToken(),
            tokenProvider.getTokenExpirationAsLocalDateTime(tokenDto.getRefreshToken())
    );
    refreshTokenRepository.save(refreshToken);

    // Response
    return new PostLoginRes().toDto(tokenDto, member);
}

로그아웃

@Transactional
public void logout(String atk, Member member) {
    // Business Logic
    String key = member.getType() + member.getPhone();

    // Redis 블랙리스트 토큰 저장 (데이터 쓰기 작업은 Redis 성공/실패 여부와 상관없이 동작해야 하므로)
    redisClient.deleteValue(key);
    redisClient.setValue(atk, "logout", tokenProvider.getExpiration(atk));

    // MySQL 블랙리스트 등록
    refreshTokenRepository.deleteAllByPhoneAndMemberType(member.getPhone(), member.getType());
    Blacklist blacklist = new Blacklist(
            atk,
            tokenProvider.getTokenExpirationAsLocalDateTime(atk)
    );
    blacklistRepository.save(blacklist);

    // FCM 토큰 정보 삭제
    member.deleteToken();
    memberRepository.save(member);

    // Response
}

토큰 재발급

@Transactional
public GetRefreshRes reissue(HttpServletRequest httpServletRequest) {
    // Validation: RTK 조회
    String rtk = httpServletRequest.getHeader("Authorization");
    tokenProvider.validateToken(rtk); // RTK 유효성 검증
    String key = tokenProvider.getType(rtk) + tokenProvider.getPhone(rtk);
    RedisDto redisDto = redisClient.getValue(key);
    // Redis 장애 또는 Cache Miss 시, MySQL Data 대체
    if(!redisDto.isSuccess() || redisDto.getValue() == null || !redisDto.getValue().equals(rtk)) {
        RefreshToken refreshToken = refreshTokenRepository
                .findByPhoneAndMemberType(tokenProvider.getPhone(rtk), MemberType.valueOf(tokenProvider.getType(rtk)))
                .orElse(null);
        if(rtk.isBlank()
                || refreshToken == null
                || (tokenProvider.getTokenExpirationAsLocalDateTime(rtk).isBefore(LocalDateTime.now()))
                || (tokenProvider.getTokenExpirationAsLocalDateTime(refreshToken.getToken()).isAfter(LocalDateTime.now())
                    && !refreshToken.getToken().equals(rtk))) {
            throw new ApplicationException(ErrorCode.WRONG_TOKEN);
        }
    }

    Member member = memberRepository
            .findActiveByPhoneAndType(tokenProvider.getPhone(rtk), MemberType.valueOf(tokenProvider.getType(rtk)))
            .orElse(null);
    if(member == null) {
        throw new ApplicationException(ErrorCode.WRONG_TOKEN);
    }

    // Business Logic
    TokenDto tokenDto = tokenProvider.regenerateToken(member, rtk);
    String newRefreshToken = tokenDto.getRefreshToken();
    if(!newRefreshToken.equals(rtk)) {
        redisClient.setValue(key, newRefreshToken, tokenProvider.getExpiration(newRefreshToken));

        refreshTokenRepository.deleteAllByPhoneAndMemberType(member.getPhone(), member.getType());
        RefreshToken refreshToken = RefreshToken.builder()
                .token(newRefreshToken)
                .phone(member.getPhone())
                .memberType(member.getType())
                .build();
        refreshTokenRepository.save(refreshToken);
    }

    // Response
    return new GetRefreshRes().toDto(tokenDto.getAccessToken(), tokenDto.getRefreshToken());
}

ArgumentResolver

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    String authorization = webRequest.getHeader("Authorization");

    // 토큰 정보 유무 확인
    if (authorization == null) {
        throw new ApplicationException(ErrorCode.WRONG_TOKEN);
    }

    // 토큰 유효 여부 확인
    RedisDto redisDto = redisClient.getValue(authorization);
    // Redis 장애 또는 Cache Miss 시, MySQL Data 대체
    if(!redisDto.isSuccess() || redisDto.getValue() == null || !redisDto.getValue().equals("logout")) {
        Blacklist blacklist = blacklistRepository.findBlacklistByToken(authorization).orElse(null);
        // MySQL Data 존재 시, 로그아웃 Value 확인 및 처리 - ExpiredAt이 현재 시간보다 크면 블랙리스트로 처리
        if(blacklist != null && blacklist.getExpiredAt().isAfter(LocalDateTime.now())) {
            throw new ApplicationException(ErrorCode.LOGOUT_TOKEN);
        }
    }
    // Redis Data 존재 시, 로그아웃 Value 확인 및 처리
    else {
        throw new ApplicationException(ErrorCode.LOGOUT_TOKEN);
    }

    // 토큰 유효성 검사
    tokenProvider.validateToken(authorization);

    // 토큰에서 사용자 정보 추출
    String phone = tokenProvider.getPhone(authorization);
    String type = tokenProvider.getType(authorization);

    // 사용자 정보 획득
    Member member = memberRepository.findActiveByPhoneAndType(phone, MemberType.valueOf(type)).orElse(null);
    if(member == null) {
        throw new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION);
    }

    // 사용자 정보 반환
    return  member;
}

작성자가 봐도 코드 퀄리티가 좋지 않다.
리팩토링 중이므로, 위 코드에서는 FailOver 적용에 초점을 맞추어 코드를 보면 된다.

장애 상황 연출

Production 환경에서 장애 상황을 연출할 수 없으므로, 개발 환경에서 장애 상황을 연출하고 테스트를 진행했다. 현재 서버 비용 등의 이유로 별도의 개발 서버를 운영하고 있지 않다. 그렇기에, 로컬에서 도커 컨테이너를 이용해 정상 상황 및 장애 상황을 연출해서 확인했다.

Redis 정상

Hibernate: 
    select
        member0_.member_id as member_i1_10_,
        member0_.created_at as created_2_10_,
        member0_.deleted_at as deleted_3_10_,
        member0_.modified_at as modified4_10_,
        member0_.grade as grade5_10_,
        member0_.is_advertisement as is_adver6_10_,
        member0_.is_privacy as is_priva7_10_,
        member0_.name as name8_10_,
        member0_.need_notification as need_not9_10_,
        member0_.password as passwor10_10_,
        member0_.phone as phone11_10_,
        member0_.school as school12_10_,
        member0_.state as state13_10_,
        member0_.token as token14_10_,
        member0_.type as type15_10_ 
    from
        member member0_ 
    where
        member0_.phone=? 
        and member0_.type=? 
        and member0_.state=? 
        and (
            member0_.deleted_at is null
        ) limit ?
2024-09-30 13:43:30.830  INFO 2749 --- [nio-8080-exec-5] c.s.gwalit.global.aop.LogAspect          : [GetRefreshRes com.selfrunner.gwalit.domain.member.service.AuthService.reissue(HttpServletRequest)]  execution time: 50ms

Redis가 살아있을 때에는 RefreshToken을 확인하기 위해 데이터베이스로 트래픽이 발생하지 않음을 알 수 있다.

Redis 장애
Redis 장애 상황 연출은 API 서버를 실행시킨 뒤, Redis 컨테이너를 종료시키고 요청을 보냈다.

Hibernate: 
    select
        refreshtok0_.refresh_token_id as refresh_1_14_,
        refreshtok0_.created_at as created_2_14_,
        refreshtok0_.expired_at as expired_3_14_,
        refreshtok0_.member_type as member_t4_14_,
        refreshtok0_.phone as phone5_14_,
        refreshtok0_.token as token6_14_ 
    from
        refresh_token refreshtok0_ 
    where
        refreshtok0_.phone=? 
        and refreshtok0_.member_type=?
Hibernate: 
    select
        member0_.member_id as member_i1_10_,
        member0_.created_at as created_2_10_,
        member0_.deleted_at as deleted_3_10_,
        member0_.modified_at as modified4_10_,
        member0_.grade as grade5_10_,
        member0_.is_advertisement as is_adver6_10_,
        member0_.is_privacy as is_priva7_10_,
        member0_.name as name8_10_,
        member0_.need_notification as need_not9_10_,
        member0_.password as passwor10_10_,
        member0_.phone as phone11_10_,
        member0_.school as school12_10_,
        member0_.state as state13_10_,
        member0_.token as token14_10_,
        member0_.type as type15_10_ 
    from
        member member0_ 
    where
        member0_.phone=? 
        and member0_.type=? 
        and member0_.state=? 
        and (
            member0_.deleted_at is null
        ) limit ?
2024-09-30 13:41:36.119  INFO 2749 --- [nio-8080-exec-1] c.s.gwalit.global.aop.LogAspect          : [GetRefreshRes com.selfrunner.gwalit.domain.member.service.AuthService.reissue(HttpServletRequest)]  execution time: 364ms

Redis 장애로 인해, 데이터베이스 쿼리가 발생된 것을 확인할 수 있다.

정리

Redis에 대하여 다음과 같은 두 개의 관점으로만 섣불리 도입을 결정했었다.
1. DB I/O 최소화
2. Timeout을 통한 데이터 삭제 가능
위 관점으로만 Redis를 도입하면서 사용자 경험의 향상을 목표로 했지만, 정작 Redis 장애로 인해 서비스 전체의 중단은 고려하지 않았었다.
서비스 개발 시에는 서비스의 안정성이 최우선순위이며, 이후의 사용자 경험이 따라와야 한다는 관점을 놓치지 않아야 한다.

Redis는 인메모리 DB이며, 스냅샷이 존재하지만 데이터 손실 가능성을 늘 유의해야 한다. MySQL로 대체 작동할 수 있도록 FailOver 방식을 적용하는 것이 필수적이다.
과릿은 이번 FailOver 방식을 통해서, Redis 장애 시에도 서비스 전체에 문제가 생기는 상황을 방지할 수 있게 되었다.

위와 같이 FailOver를 도입했음에도 불구하고 아직 일부 문제들이 남아있다.
MySQL에는 데이터가 존재하지 않음에도 불구하고 Redis에 데이터가 남아 있어 해당 데이터를 반환하는 상황이 그 예이다.
위와 같은 상황은 현재의 인프라에서는 Redis의 데이터가 만료되어 삭제되기를 기다려야 한다. Redis 장애로 인해 복구되는 과정에서 Redis와 MySQL의 데이터 싱크는 어떻게 맞추어갈 수 있을지를 고민하고 적용하는게 다음 도전이 될 것이다.


레퍼런스

0개의 댓글