티스토리 뷰

반응형

흔히 요즘 DAU가 많은 서비스들을 흔하게 볼 수 있습니다. 카카오의 실시간 댓글 서비스는 DAU가 60만에 도달하기도 합니다. 그럼 DAU가 뭘까요? 

Daily Active User의 앞 글자만 따온 용어로 하루 활성화된 유저 수를 뜻합니다. 이러한 서비스는 TPS가 절대적으로 중요할 수 밖에 없는데 예를 들어 60만의 사용자중 1/60 인 1만명이 댓글 새로고침을 눌렀을 때 만약 분산 서버환경 sacle 서버 5대가 있어도 하나의 WAS당 2000개의 요청을 받아야합니다. 만약 여기서 병목이 생긴다면 굉장히 치명적일 것입니다.

 

문제 상황

다음 문제는 이렇습니다. 게시판 서비스이고 현재 상태 단일 서버 Tomcat 기본 thread pool size인 200개로 open했습니다. 그리고 현재 MySQL에는 100만개 게시판 데이터가 존재하고 100명의 사용자가 10번 반복하여 특정 게시물 조회 요청을 쏩니다. 

@Transactional(readOnly = true)
public BoardResponse findById(final Long boardId) {
    return boardRepository.getBoardDetail(boardId).orElseThrow(() ->
            new BoardNotFoundException(ErrorCode.BOARD_NOT_FOUND));
}

현재는 단순 DB 조회만으로 로직이 구성되어있습니다. 

 

그렇다면 이제 요청을 만들어 보겠습니다. 도구는 Jmeter를 사용했습니다. 

100명이 10번의 루프를 돌며 요청을 보내고 있고 초당 요청으로 설정해두었습니다. 

 

그럼 테스트를 돌려보겠습니다. 

여기서 주목해야할 지표는 Throughput입니다. 초당 처리량을 나타내는 지표인데요. 초당 240개 가량의 처리속도를 보이고 있습니다. 즉 1000개의 요청을 초당 240개씩 약 4초에 걸쳐 처리하고 있음을 나타내고 있습니다. 만약 위 같은 환경이라면 더 안좋은 성능을 낼 수 있음을 의미합니다. 

 

그렇다면 어떻게 해결하면 좋을까요?

해결 방법 소개 part.1 (scale-out)

첫 번째는 scale out 을 하여 요청을 분산 시키는 것입니다. 위에도 나와있다 싶이 DAU 60만의 대규모 서비스는 단일 서버로 운영되기 어렵습니다. 분산서버는 사진으로 보면 다음과 같은 아키텍처 양상을 띕니다.

1000개의 동시 요청이 온다면 서버 마다 각 500개 정도만 처리할 수 있습니다. 

 

실제로 분산된 서버에서는 다음과 같은 성능을 나타냅니다. 

각 서버 마다 500개 요청을 처리한다고 가정했을 때 거의 477개를 초당 처리하고 있습니다. 거의 초당 한개의 요청을 처리하고 있습니다. 충분히 개선된 서비스의 모습입니다. 그렇다면 다음 방법을 살펴보죠.

해결 방법 소개 part.2 (Cache)

두 번째 방법은 Cache를 사용하고 있습니다. Cache는 여러가지 방법이 있습니다. Memcached나 Local Cache를 이용할 수도 있고 Redis도 있습니다. 저는 다양한 컬렉션을 사용할 수 있고 Lettuce 클라이언트 기반으로 10만 TPS를 처리할 수 있는 Redis를 사용하였습니다. 또한 Redis는 single Thread이기 때문에 원자성을 보장해주어 cache뿐만 아니라 다양하게 사용이 가능합니다. 

 

Local Cache가 아닌 Global Cache를 사용한 이유

Local Cache는 매우 빠른 성능을 보여줍니다. Spring의 경우 3.1 이 후 ConcurrentHashMap 기반의 CacheManager를 기본으로 제공해주어 별도의 외부 네트워크 없이 사용이 가능합니다. 

하지만 그럼에도 사용하지 않은 이유는 Local Cache의 치명적인 단점인 확장에 좋지 않다는 점 때문입니다. cache가 성능이 좋더라도 결국 서버 요청량이 늘어남에 따라 scale out을 고려할 수 밖에 없습니다. 또한 처리량이 높다고 하더라도 그 처리량을 위해 cpu가 과도하게 사용될 수 있습니다. 

하지만 Local cache는 확장된 서버에 따로 cache데이터가 남아있지 않기 때문에 cache를 제대로 사용할 수 없습니다. (cache hit 율 매우 떨어짐) 반면에 Redis는 clustering으로 인해 저장소 자체 확장성도 보장되는 동시에 서버가 확장되더라도 하나의 Redis를 바라보기 때문에 확장에 용이합니다. 다만, 네트워크 성능이 외부에 존재하는 인프라기 때문에 로컬 캐시에 비해 비쌉니다.

결론적으로, 네트워크 성능과 비용을 손해보더라도 확장에 유리한 설계를 위해 트레이드 오프하여 글로벌 캐시를 사용하게 됐습니다.

 

그러면 Redis를 먼저 docker로 띄우고 세팅해줍니다. 

@Bean
public RedisCacheManager redisCacheManager() {
    RedisCacheConfiguration configuration = RedisCacheConfiguration
            .defaultCacheConfig()
            .disableCachingNullValues()
            .entryTtl(Duration.ofMinutes(30))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
                    new StringRedisSerializer()));

    Map<String, RedisCacheConfiguration> config = new HashMap<>();

    config.put(MEMBER_KEY, RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(MEMBER_TTL)));
    config.put(BOARD_KEY, RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(BOARD_TTL)));
    config.put(AUTH_KEY, RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(AUTH_TTL)));

    return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory())
            .cacheDefaults(configuration)
            .withInitialCacheConfigurations(config)
            .build();
}
public class RedisPolicy {
    public static final String MEMBER_KEY = "M_KEY";
    public static final String BOARD_KEY = "B_KEY";
    public static final String AUTH_KEY = "A_KEY";

    public static final int MEMBER_TTL = 30;
    public static final int BOARD_TTL = 30;
    public static final int AUTH_TTL = 180;
}

Cache로 사용하기 위해 각각에 설정과 TTL을 설정해줍니다. TTL은 LRU 캐시 방식에서 아이디어를 따왔습니다. Redis는 In-memory DB로 저장 비용 자체가 비싸기 때문에 자주 사용하지 않는 Cache들은 비울 필요가 있습니다. 그렇기 때문에 자주 사용하지 않는 cache를 날려주기 위해 TTL을 걸어주었습니다. 

 

그럼 비즈니스 로직을 살펴봅시다.

@Transactional(readOnly = true)
@Cacheable(key = "#boardId", value = RedisPolicy.BOARD_KEY)
public BoardResponse findById(final Long boardId) {

    return boardRepository.getBoardDetail(boardId).orElseThrow(() ->
            new BoardNotFoundException(ErrorCode.BOARD_NOT_FOUND));
}

Cacheable은 AOP에서 처리됩니다. Redis를 cache로 쓰기 때문에 Cacheable이 붙은 메서드는 1회 호출 시 Redis에 데이터가 저장됩니다. 

 

 

그렇다면 성능을 살펴봅시다. 

성능이 굉장히 빨라진걸 볼 수 있습니다. TPS가 기존 240개에서 4배 이상이 상승한걸 확인할 수 있습니다.

 

추가적으로 게시판은 실시간성 데이터는 아니지만 변경이 일어나면 새로고침이나 새로운 사람이 접근했을 때 변경된 후의 글을 봐야합니다. 하지만 Redis Cache에 변경이 반영 되어있지 않아 불편함을 줄 수 있기 때문에 CachePut을 이용하여 변경이 일어날 때 마다 Redis에도 같이 적용할 수 있게 하였습니다. 

@Transactional
@CachePut(key = "#boardId", value = RedisPolicy.BOARD_KEY)
public void update(final Board updateBoard,
                   final Long boardId, final Long memberId) {
    Board board = getBoardEntity(boardId);

    if (!Objects.equals(board.getMemberId(), memberId)) {
        throw new BoardMissMatchException(ErrorCode.BOARD_MISS_MATCH);
    }

    if (board.getTitle().equals(updateBoard.getTitle())) {
        existTitle(updateBoard.getTitle());
    }

    board.updateBoard(updateBoard);
}

 

게시판 수정 시 레디스에 존재하는 데이터도 수정하여 수정 요청이 와도 redis 데이터를 최신으로 유지할 수 있습니다. 

마무리

동시 접속자 수가 많은 환경에서 TPS를 높여 서비스를 개선해보았습니다. 처음엔 성능 테스트를 할 때 단순 요청에 대한 처리 시간이 얼마나 걸리는지만 확인 했었는데 많은 사람들에게 인사이트를 받아 TPS 기준으로 성능 개선을 시도 해보았는데 훨씬 명확한 지표를 알 수 있었습니다.

 

마침.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함