Skip to content

채팅방 불러오기 API DB 쿼리 수정을 통한 응답 시간 개선

namewhat99 edited this page Jan 9, 2024 · 2 revisions
  • 부스트캠프 프로젝트 도중 한 사람의 채팅방 목록을 가져오는 API 를 구현했다. 이 API 는 우리가 흔히 사용하는 카카오톡을 들어가보면 나오는 채팅방 목록에서 보여지는 채팅 상대 이름, 채팅 상대의 프로필 사진, 마지막 채팅 , 안 읽은 채팅이 있는지에 대한 알림 등을 가져오는 API 이다.

  • 성능 테스트를 위해 한 사람에 대해 채팅방 30개를 만들고 각 방에 대략 15000개의 채팅을 넣었다. 총 45만개의 채팅 데이터를 DB 에 INSERT 했다.

  • 성능 테스트를 해보기 전 까지는 그냥 괜찮은 응답속도라 생각했었다. 50ms 내외로 계속 응답이 왔었고 이정도면 나쁘지는 않다라고 판단했었는데 이후에 채팅 데이터를 넣고 다시 테스트를 해보니까 처참한 결과가 나왔다.

(개선 전) 채팅방 목록을 불러오는 API

async findRoomList(userId: string){
  
  let now = new Set();
      const rooms = await this.chatRoomRepository
        .createQueryBuilder('chat_room')
        .select([
          'chat_room.user',
          'chat_room.writer',
          'chat_room.id',
          'chat_room.post_id',
          'chat.message',
          'chat.create_date',
        ])
        .where('chat_room.user = :userId', {
          userId: userId,
        })
        .orWhere('chat_room.writer = :userId', {
          userId: userId,
        })
        .leftJoin('chat', 'chat', 'chat_room.id = chat.chat_room')
        .orderBy('chat.id', 'DESC')
        .addSelect(['user.w.user_hash', 'user.w.profile_img', 'user.w.nickname'])
        .leftJoin('user', 'user.w', 'user.w.user_hash = chat_room.writer')
        .addSelect(['user.u.user_hash', 'user.u.profile_img', 'user.u.nickname'])
        .leftJoin('user', 'user.u', 'user.u.user_hash = chat_room.user')
        .addSelect(['post.thumbnail', 'post.title'])
        .leftJoin('post', 'post', 'post.id = chat_room.post_id')
        .getRawMany();

      const result = rooms
        .reduce((acc, cur) => {
          acc.push({
            room_id: cur.chat_room_id,
            post_id: cur.chat_room_post_id,
            post_title: cur.post_title,
            post_thumbnail: cur.post_thumbnail,
            user: cur['user.w_user_hash'],
            user_profile_img: cur['user.w_profile_img'],
            user_nickname: cur['user.w_nickname'],
            writer: cur['user.u_user_hash'],
            writer_profile_img: cur['user.u_profile_img'],
            writer_nickname: cur['user.u_nickname'],
            last_chat: cur.chat_message,
            last_chat_date: cur.chat_create_date,
          });
          return acc;
        }, [])
        .sort((a, b) => {
          return b.last_chat_date - a.last_chat_date;
        })
        .reduce((acc, cur) => {
          if (!now.has(cur.room_id)) {
            acc.push(cur);
            now.add(cur.room_id);
          }
          return acc;
        }, []);

      return result;
  
}

  • 현재 10회 반복한 결과인데 더 여러번 해볼 가치조차 느끼지 못했고 바로 API 에 대한 개선이 필요하다고 생각했다. 데이터 대략 45만개에 API 응답 시간이 10초라니,,, 이건 과장 좀 보태서 내가 처리하는게 더 빠르겠다는 생각이 들었다.

  • 그래서 현재 내 API 에 어떤 문제점이 있는지, 성능 저하를 하는 요소는 무엇이 있을지 찾아보았고 여러가지 개선 해야할 점을 작성할 수 있었다.

    개선할 수 있는 지점

1. chat_room 과 chat 을 join 하는 과정에서 모든 채팅 데이터를 서버로 가져오는 작업

2. 이 채팅 데이터를 모두 서버로 가져와서 다시 reduce 작업 , 정렬 작업을 수행한다.

3. 이 코드에는 나와있지 않지만, user 나 writer 의 프로필 이미지가 null 이면 , 해당 값을 기본 프로필 이미지의 url 로 바꿔주는 작업.

개선점에 대한 고찰

  • 일단 2번 작업에서 채팅방들을 최신순으로 업데이트 하는 작업은 서버나 DB 둘 중 하나에서는 무조건 처리해야 한다.

  • 가장 큰 문제는 1번이었다. 현재 DB table 은 chat 과 chat_room 이 존재했고 chat_room 의 id 와 chat 의 chat_room 칼럼이 외래키로 연결되어 있었다. 이 chat_room 을 가져오는 과정에서 해당 chat_room 에 대한 chat 을 모두 Join 해서 가져오는 문제가 있었다. 즉 채팅방에 채팅이 10개가 있으면 해당 chat을 모두 Join 해서 가져온다.

  • 3번도 큰 영향은 아니지만 DB에서 처리해서 가져올 수 있지 않을까? 라고 생각이 들었다. profile_img 의 값이 null 인 경우 기본 이미지 url 로 가져오는 쿼리가 있을 것 같았다.

개선 과정

  • 먼저 채팅목록을 모두 가져오는 방법 말고 각 채팅방의 마지막 채팅만 가져오는 방식을 찾아보았다. 그래서 이에 대해 찾아보다가 MySQL 에 GROUP BY 쿼리를 알게 되었다. GROUP BY 는 데이터를 정해진 Column 으로 묶어서 해당 그룹에 대한 데이터를 추출하는데 사용된다.

  • 이 GROUP BY 를 알았을 때, "아 그러면 GROUP BY 를 해서 각 그룹마다 가장 최근에 작성된 채팅 데이터를 가져오면 되겠구나!" 라고 생각하고 바로 쿼리를 작성했다. 이때 작성한 SQL 이 다음과 같았다.

SELECT MAX(id) , message, sender, chat_room FROM chat
	GROUP BY chat_room
  • 그런데 이 쿼리는 내가 원하는 데이터를 가져오지 않았다. 현재 우리의 DB TABLE 에서는 id 가 클수록 최근에 작성된 채팅이기 때문에 MAX(id) 로 가져오면 당연히 해당 채팅 데이터가 다 불러올거라 생각했다. 생각과 달리 이 쿼리는 id 는 Max 인 값을 가져오는데 나머지 값이 id 가 max 인 row 의 Column 들을 가져오는 것이 아닌 다른 값들을 가져왔다.

  • 그래서 그러면 이 id 가 MAX(id) 인 row 를 가져오면 되겠다라고 판단되었다. 여기서 서브쿼리를 사용하면 된다고 해서 처음으로 서브쿼리를 사용해보았다. 여기까지 각 채팅방에서 가장 최신 채팅 데이터를 가져오는 부분이다.

SELECT * FROM chat
	WHERE id IN
    	(SELECT MAX(id) FROM chat
        	GROUP BY chat_room)
  • 아까 개선점에서 1번에 대한 개선이 크게 이뤄졌다. 모든 채팅목록을 가져와서 최대값을 서버에서 계산하는것이 아닌 이미 DB 에서 가져올 때 부터 채팅방과 각 채팅방에 대한 마지막 채팅정보를 가져오게 되었다.
IFNULL(profile_img, url) 
  • 그리고 나머지 table 을 JOIN 한 이후, IFNULL 구문 을 이용하여 프로필 이미지가 null 값인 것들을 서버에서 처리하는 것이 아니라 애초에 DB에서부터 가져올 때, url 값으로 가져올 수 있게 되었다. 이에 더해서 아예 서버에서 해당 쿼리에 대한 추가적인 처리를 없애기 위해 ORDER BY 까지 SQL 쿼리로 처리했다.

현재까지 개선된 코드

  • DB 에서 클라이언트에 필요한 모든 처리를 마치고 서버에서 추가적인 작업을 없앴다.
async findRoomList(userId: string) {
    const subquery = this.chatRepository
      .createQueryBuilder('chat')
      .select('chat.id', 'id')
      .addSelect('chat.chat_room', 'chat_room')
      .addSelect('chat.message', 'message')
      .addSelect('chat.create_date', 'create_date')
      .addSelect('chat.is_read', 'is_read')
      .addSelect('chat.sender', 'sender')
      .where(
        'chat.id IN (SELECT MAX(chat.id) FROM chat GROUP BY chat.chat_room)',
      );

    const rooms = await this.chatRoomRepository
      .createQueryBuilder('chat_room')
      .innerJoin(
        '(' + subquery.getQuery() + ')',
        'chat_info',
        'chat_room.id = chat_info.chat_room',
      )
      .innerJoin(
        'chat_room.writerUser',
        'writer',
        'chat_room.writerUser = writer.user_hash',
      )
      .innerJoin(
        'chat_room.userUser',
        'user',
        'chat_room.userUser = user.user_hash',
      )
      .leftJoin('chat_room.post', 'post', 'chat_room.post = post.id')
      .select([
        'chat_room.id as room_id',
        'chat_room.writer as writer',
        'writer.nickname as writer_nickname',
        'IFNULL(writer.profile_img , "https://kr.object.ncloudstorage.com/village/732e917b-28ee-4096-b4dc-4a9229d6d928") as writer_profile_img',
        'chat_room.user as user',
        'user.nickname as user_nickname',
        'IFNULL(user.profile_img , "https://kr.object.ncloudstorage.com/village/732e917b-28ee-4096-b4dc-4a9229d6d928") as user_profile_img',
        'chat_room.post_id as post_id',
        'post.title as post_title',
        'post.thumbnail as post_thumbnail',
        'chat_info.create_date as last_chat_date',
        'chat_info.message as last_chat',
        'chat_info.is_read as all_read',
        'chat_info.sender as sender',
      ])
      .where('chat_room.writer = :userId', { userId: userId })
      .orWhere('chat_room.user = :userId', { userId: userId })
      .orderBy('chat_info.create_date', 'DESC')
      .getRawMany();

    return rooms;
  }

개선하긴했다....!!

  • 985ms 는 분명 아직 느리긴 하지만 그래도 10배 가까이 API 의 응답 속도가 빨라졌다.

  • 일단 할 수 있는 처리는 모두 한 듯 하다. ORDER BY , GROUP BY 에 index 를 걸어놓는 작업까지 진행했고 쿼리는 많이 개선했다.

아쉬운 점 , 찾아봤던 것 들

  • 여기서 더 개선하려면 서브쿼리를 캐싱하여 각 채팅방에 대한 MAX(id) 를 매번 구하는 것이 아니라 캐시한 데이터를 가져오면 더 빠르게 응답할 수 있지 않을까? 라는 생각으로 이에 대해 찾아봤었는데 MySQL 이 5.7.x 부터 쿼리 캐싱을 지원하지 않는다는 것을 알았다.

  • 이 방법 말고 더 빠른 방법이 있을 듯 한데 아직 SQL 에 대해 많이 알지 못해서 그런가 해당 방법을 적용하기가 어렵다... 적용시키려고 하면 어딘가에서 구문 오류가 나거나 invalid 하다.

  • 분명히 채팅목록 중 최대인 것들을 서브쿼리 처리 없이 chat 과 chat_room 테이블 join 으로 처리할 수 있을 듯 한데... 더 공부하면서 개선해봐야겠다.

  • 아니면 마지막 채팅 내역을 chat_room table 에 같이 저장하면서, 채팅을 저장할 때, 채팅 저장과 chat_room table 의 업데이트를 한 트랜잭션 내에서 처리하면 효율적으로 할 수 있지 않을까 생각이 든다. 이해 대해 실험해봐야겠다.

최종 개선!

image
  • 위에 작성한 마지막 채팅 내역의 id 를 chat_room table 에 같이 저장하여 서브쿼리를 사용하지 않고 그냥 바로 chat 과 join 하여 최종적으로 52ms 까지 쿼리를 개선했다!
Clone this wiki locally