Spring/채팅앱

[Spring 채팅앱 성능 개선기 3] Cache 설계 전략

wans10 2023. 7. 6. 15:54

이전글 보러가기

2023.07.04 - [Spring/채팅앱] - [Spring 채팅앱 성능개선기 2] Cache를 이용한 속도향상

 

[Spring 채팅앱 성능개선기 2] Cache를 이용한 속도향상

성능개선기 1편을 이어나가 2편 시작합니다. 2023.07.03 - [Spring/채팅앱] - [Spring 채팅앱 성능개선기 1] JPA 쓰기지연을 활용한 채팅내역 저장 [Spring 채팅앱 성능개선기 1] JPA 쓰기지연을 활용한 채팅내

wans1027.tistory.com

이전 포스트에서는 채팅을 보낼 때만 캐시가 생성되고 저장되었습니다.

오늘은 캐시 불러오는 로직을 Cache 설계 전략에 맞춰 개발해보겠습니다.

 


Cache 전략

캐시를 이용하게 되면 반드시 닥쳐오는 문제점이 있는데 바로 데이터 정합성 문제입니다.

 

데이터 정합성이란, 어느 한 데이터가 캐시(Cache Store)와 데이터베이스(Data Store) 이 두 곳에서 같은 데이터임에도 불구하고 데이터 정보값이 서로 다른 현상을 말합니다.

 

따라서 적절한 캐시 읽기 전략(Read Cache Strategy) 캐시 쓰기 전략(Write Cache Strategy)을 통해 해결해보겠습니다.

 

다양한 전략 패턴이 있지만 여기선 제가 사용할 패턴만 설명하겠습니다.

 

 

캐시 읽기 전략

Look Aside 패턴

  • 데이터를 찾을때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략.
    만일 캐시에 데이터가 없으면 DB에서 조회
  • 반복적인 읽기가 많은 호출에 적합
  • 캐싱을 이용할때 일번적으로 사용되는 기본적인 캐시 전략

순서

  1. Cache Store에 검색하는 데이터가 있는지 확인 (Cache Hit)
  2. Cache Store에 없을 경우 DB에서 데이터 조회 (Cache Miss)
  3. DB에서 조회해온 데이터를 Cache Store에 업데이트(Cache Warming)

 

캐시 쓰기 전략

Write Back 패턴

  • 데이터를 저장할때 DB에 바로 쿼리하지않고, 캐시에 모아서 일정 주기 배치 작업을 통해 DB에 반영
  • 캐시에 모아놨다가 DB에 쓰기 때문에 쓰기 쿼리 회수 비용과 부하를 줄일 수 있음
  • Write가 빈번하면서 Read를 하는데 많은 양의 Resource가 소모되는 서비스에 적합
  • 데이터 정합성 확보

순서

  1. 들어온 데이터를 Cache Store에 저장
  2. 일정 시간이나 일정 용량이 되면 DB에 저장

 


구현

캐시 쓰기 전략 WriteBack 패턴을 보면 놀랍게도 [성능 개선기 1, 2] 를 통해 이미 자연스럽게  구현해왔습니다.

읽기 전략만 추가하면 됩니다.


궁금점 발생 ( ORDER BY  vs Collections.reverse() 속도 비교)

캐시의 큐는 FirstInFirstOut으로 메세지를 꺼낸다면 메세지 인덱스는 오름차순으로 정렬되어 있습니다.

 

문제는 DB Query에서 발생합니다.

만약 roomId가 1번에 속하는 최신 메세지 10개를 DB에서 가져오고 싶습니다.

테이블에 MESSAGE_ID: 1~100 까지 있다면, 결과는 캐시의 결과와 같게 오름차순으로 정렬된 91~100이 되야 합니다.

그렇다면 쿼리는 "order by message_id desc limit 10"을 하고 그 결과를 다시 "order by message_id asc" 를 통해 정렬하는 서브쿼리 형태가 됩니다.

 

여기서 궁금증

위의 쿼리속도가 빠를까?, 아니면 "order by message_id desc limit 10" 여기까지의 결과를 얻어 WAS에서

연산을 통해 뒤집는게 빠를까? 입니다.

 

직접해보겠습니다.

참고로 JPQL을 사용할때 FROM절안에 서브쿼리 사용이 불가능합니다.

@Query("select n from (select m from Message m where m.chattingRoomId = :roomId order by m.id desc limit :num) n order by n.id") 
//JPQL from 절 subQuery 사용 불가

이렇게하면 오류납니다. 이것 때문에 몇시간 날려먹음..

해결하기 위해서는 NativeQuery로 작성해야합니다.

 

Query로만 결과 출력 함수 (nativeQuery작성)

@Query(value = "select * from (select * from message where chatting_room_Id = :roomId order by message_id desc limit :num) n order by n.message_id", nativeQuery = true)
List<Message> findNumberOfMessageInChattingRoomReverse(@Param("roomId") Long roomId, @Param("num") int num);

리스트 뒤집기 연산을 할 함수

@Query("select m from Message m where m.chattingRoomId = :roomId order by m.id desc limit :num")
List<Message> findNumberOfMessageInChattingRoom(@Param("roomId") Long roomId, @Param("num") int num);

 

이 두 처리 속도를 비교해보겠습니다. (추가로 order by 속도도 알고싶어 두 쿼리의 속도도 비교해보겠습니다)

@Test
void 시간비교() {
    for (int i = 0; i < 10000; i++) {
        messageRepository.save(new Message(TALK, 1L,1L,"Message"));
    }
    stopWatch.start("서버에서 List 뒤집기");
    List<Message> messageList = messageRepository.findNumberOfMessageInChattingRoom(1L, 10);
    Collections.reverse(messageList);
    stopWatch.stop();

    stopWatch.start("Query 에서 order by로 재정렬");
    List<Message> numberOfMessageInChattingRoomNotReverse = messageRepository.findNumberOfMessageInChattingRoomReverse(1L, 10);
    stopWatch.stop();

    stopWatch.start("List 뒤집기 X");
    List<Message> messageList2 = messageRepository.findNumberOfMessageInChattingRoom(1L, 10);
    stopWatch.stop();

    System.out.println(stopWatch.prettyPrint());
}

처리속도 결과

출력되는 결과는 같지만 무려 약 30배 차이로 Query만을 사용하는게 훨씬 빠릅니다.

(사용하는 DB종류와 데이터의 양에 따라서 결과는 크게 변동 될 수 있습니다.)

 

그렇다고 무조건 쿼리로 처리하는게 좋을까?

그건 잘 모르겠습니다.

나중에 페이징 처리를 쉽게 하기 위해선 균형을 이루는 Trade Off 가 필요해 보입니다.

 

궁금증도 해결했으니 하던거나 마저 해 봅시다.


캐시 읽기 요구사항

  • 캐시가 있다면 캐시를 반환
  • 없다면 DB에서 찾아 캐시에 적재
  • 만약 DB에도 없다면 빈 리스트 반환

Service 추가

@Service
@Transactional
@RequiredArgsConstructor
public class MessageService implements DisposableBean {
    private final MessageRepository messageRepository;
    private static final Map<Long, Queue<Message>> messageMap = new HashMap<>();
    private final EntityManager em;
    private static final int transactionMessageSize = 20;//트랜잭션에 묶일 메세지 양
    private static final int messagePageableSize = 30; //roomId에 종속된 큐에 보관할 메세지의 양


    public void saveMessage(Message.MessageType type, Long roomId, String detailMessage, Long senderId) {

        Message message = new Message(type, roomId, senderId, detailMessage);

        if(!messageMap.containsKey(roomId)){
            //채팅방에 처음쓰는 글이라면 캐시가 없으므로 캐시를 생성
            Queue<Message> q = new LinkedList<>();
            q.add(message);
            messageMap.put(roomId, q);
        }
        else {
            Queue<Message> mQueue = messageMap.get(roomId);
            mQueue.add(message);
            //캐시 쓰기 전략 (Write Back 패턴)
            if (mQueue.size() > transactionMessageSize + messagePageableSize) {
                Queue<Message> q = new LinkedList<>();
                for (int i = 0; i < transactionMessageSize; i++) {
                    q.add(mQueue.poll());
                }
                commitMessageQueue(q);//commit
            }
            messageMap.put(roomId, mQueue);
        }
    }
    public List<Message> getMessages(Long roomId){
    //캐시 읽기 전략 (LookAside 패턴)
        List<Message> messageList = new ArrayList<>();
        if(!messageMap.containsKey(roomId)){
            //Cache Miss
            List<Message> messagesInDB = getMessagesInDB(roomId);
            //DB 에도 없다면 새로 만든 방이므로 빈 리스트를 반환
            if(messagesInDB.isEmpty()) return messageList;
            //DB 에서 가져온 데이터를 Cache 에 적재
            Queue<Message> q = new LinkedList<>(messagesInDB);
            messageMap.put(roomId, q);
            messageList = messagesInDB;
        }
        else {
            //Cache Hit
            messageList = getMessagesInCache(roomId);
        }
        return messageList;
    }

    public List<Message> getMessagesInDB(Long roomId) {
        return messageRepository.findNumberOfMessageInChattingRoomReverse(roomId,messagePageableSize);
    }

    public List<Message> getMessagesInCache(Long roomId){
        return messageMap.get(roomId).stream().toList();
    }

}

 

캐시 읽기 전략을 추가했습니다.

 

여기서 HiddenCase는 DB에도 데이터가 없을 때 입니다.

그럴때는 언제인가?

채팅방을 만들고 아무 채팅도 쓰지 않을경우 DB에 메세지가 없게 됩니다.

 

이 경우에는 아래와 같이 처리하게 합니다.

  1. 채팅방을 만들고 접속하는 경우 빈리스트를 반환
  2. 처음 채팅을 쓰게되면 그때 캐시 생성

 


문제점

해결해야할 사항을 생각해 봅시다.

 

용량부족

RAM의 용량은 작습니다.

현재 HashMap<Key, Value> 에서 Value는 큐 사이즈의 최대치를 정해 놓아서 괜찮지만

채팅방이 늘어남에 따라 Key는 무한정으로 늘어날 수 있습니다. 이를 제한해야 합니다.

 

Static 문제

현재 서버내의 static 정적 변수로 local Cache를 사용하고 있습니다. 

Static은 메모리에 한번 할당되어 DATA영역에 저장되며 프로그램이 종료될 때 해제됩니다.

문제는 DATA영역은 GarbageCollector의 관리 영역 밖에 있으므로 삽입, 삭제를 남발하다보면 성능에 악영향을 끼칠 수 있습니다.

 

 

다음에는 위의 문제를 해결하는 방법을 고안해 보도록 하겠습니다.

 

 


Reference

https://velog.io/@yonii/JAVA-Static%EC%9D%B4%EB%9E%80

https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EC%BA%90%EC%8B%9CCache-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%EC%A7%80%EC%B9%A8-%EC%B4%9D%EC%A0%95%EB%A6%AC