새소식

반응형
프로젝트

[프로젝트] 데이터를 불러오는 데 너무 오래걸리는 문제 해결 및 성능 개선(feat. 데이터베이스 join)

2024.06.20
  • -
반응형

1. 개요

먼저 문제를 살펴보겠습니다.

 

 

위 영상을 보면 알 수 있듯이 한 요소를 클릭하면 다이얼로그(Alert Dialog)가 열리는 기능이었는데, 이 기능이 프론트엔드 단에서 구현되는 방식은 "해당 요소의 userId 값을 다이얼로그 컴포넌트로 넘겨 해당 userId로 다시 해당 유저에 대한 상세 정보를 얻기 위한 별도의 API 호출이 진행"되는 방식입니다.

 

개발을 할 때는 영상을 보는 것처럼 그렇게 느리다라는 생각을 하지 못했는데, 다만 원래는 로딩 컴포넌트가 뜨지 않을 정도로 빠르게 데이터가 나왔었는데 어느 순간 로딩 컴포넌트가 눈에 보이는 수준이 되었고 조금 찝찝하긴 했어도 그냥 넘어가게 되었습니다.

 

그러나 문제는 프로덕션 서버에서 발생했는데요.

 

개발 단계에서는 위 이미지처럼 1~2초 남짓? 걸리던 로딩이 실제 배포 서버에서 똑같은 동작을 했을 때는 12~13초가 걸리는 문제가 발생하는 것이었습니다.

 

이는 아무리 데이터가 많다는 것을 감안하더라도 UX적 측면에서 유저로 하여금 상당한 불편함을 야기할 수 있는 부분이기 때문에 해결책을 되도록 빠르게 찾아야 했습니다.

 

2. 해결 과정

2-1. 문제 인식

가장 먼저 해당 API의 성능을 확인해보기 위해 postman으로 해당 부분의 url로 요청을 보내봤습니다.

1. 문제가 되는 API

 

2. 일반적인 API들

 

위에 보이는 1번 사진이 현재 문제가 되는 API의 응답으로, 일반적인 API들 중에서도 데이터를 많이 가져오는 API와 비교했을 때조차도 대략 10배 가량 차이가 나는 속도 차이가 보여지고 있습니다.

 

심지어 빠르게 동작하는 API와 비교했을 때는 약 58배 가량 차이가 나는 모습을 볼 수 있죠.

 

자 그럼 문제가 되는 해당 다이얼로그 컴포넌트를 자세히 들여다보겠습니다.

 

크게 '유저 신상 정보'와 '유저가 수강 중인 강의', 크게 두 부분의 데이터로 구분지어 볼 수 있습니다.

 

해당 컴포넌트의 성능이 안 좋아지기 시작한 시기를 미루어봤을 때 두 가지의 수정 사항이 있었습니다.

  1. <수강 중인 강의>에 대한 부분이 유저 데이터를 응답하는 API 응답 데이터에 포함시킴
  2. 유저의 강의 수강 기록에 대한 데이터가 DB에 많이 쌓임(데이터 마이그레이션)

1번 사항은 기존에는 수강 중인 강의에 대한 데이터는 가져오지 않도록 하다가 클라이언트측의 요청으로 해당 데이터도 받아와야 해서 수정된 사항입니다. 아무래도 userId를 가지고 유저 신상 정보를 가져오면서 동시에 수강 중인 강의에 대한 정보도 가져와야 하기 때문에 만약 별도의 API를 하나 만들게 되면 해당 컴포넌트가 렌더링 될 때마다 1개의 API를 호출해도 될 것을 2개의 API를 호출해야 하기 때문에 이것으로부터 비용이 발생할 수 있다고 생각이 들어 하나의 API만으로 해당 화면을 구성하는 모든 데이터를 전부 가져오도록 수정한 것입니다.

 

사실 이 내용은 제가 이전부터 궁금해왔던 내용으로 많은 글을 찾아보기도 했지만 큰 인사이트를 얻지 못했었습니다.

 

그러다 '커리어리'라는 개발자를 위한 질문 플랫폼에 이와 관련된 질문을 게시하여 어느 분으로부터 양질의 답변을 얻을 수 있었고

 

 

수강 중인 강의를 가져오기 위해선 다른 테이블과 조인 관계를 하나 더 추가해야 하는 상황이었는데 해당 테이블의 레코드 수가 데이터 마이그레이션으로 인해 13000여개 가량이 생기게 되었고 이에 대한 데이터를 전부 가져오려다보니 그렇게 엄청난 성능저하가 발생했다고 볼 수 있는 것입니다.

 

2-2. 첫번째 해결방안 - 데이터 쿼리 분리

처음에 생각한 방식은 어쨌든 유저 신상 정보의 경우엔 아무 join 관계 없이 단일 쿼리만으로도 원하는 데이터를 빠르게 가져올 수 있기 때문에 해당 데이터를 가져오는 쿼리와 수강 중인 강의에 대한 쿼리를 분리하여 수강 중인 강의 데이터를 가져오는 시간이 느리더라도 유저 정보라도 우선적으로 보여주고 아래 컴포넌트를 가져오는 동안엔 로딩 컴포넌트를 보여주게 하면 그나마 UX적으로 개선되었다고 볼 수 있다고 생각하였습니다.

 

하지만 유저정보가 아닌 수강 중인 강의를 보기 위해 온 사용자는 결국 또 그 시간만큼을 기다려야 볼 수 있는 것이기 때문에 문제를 완전히 해결했다고 볼 수는 없었습니다.

 

또한 user의 데이터는 유저 리스트가 나열되어 있을 때도 존재하기 때문에 그 데이터를 그대로 가져오면 추가적인 API를 요청하지 않아도 됩니다. 그래서 다이얼로그 컴포넌트가 렌더링될 때 '수강 중인 강의'에 대한 API 하나만 요청되도록 하였습니다.

 

2-2. 두번째 해결방안 - join 제거

그렇다면 분리를 했더라도 결국엔 수강 중인 강의에 대한 데이터를 빠르게 가져올 수 있도록 해야했습니다.

 

해당 데이터를 가져올 때 시간이 오래 걸리도록 영향을 미치는 것들에 대해서 생각해보았고 다음과 같은 것들이 고려되었습니다.

  1. 수강신청한 모든 강의에 대한 정보를 가져옴
  2. 각 강의에는 많게는 10개 이상의 영상들을 포함함
  3. 각 영상들마다 몇 분까지 들었는지와 그에 달린 퀴즈, 퀴즈 제출 정보 등의 데이터를 포함함

그러다 보니 사용자가 수강신청한 강의가 하나만 늘어나더라도 테이블의 레코드 수가 많을 수밖에 없는 "영상을 몇 분까지 봤는지 기록하는 테이블(LectureTimeRecord)", "제출한 퀴즈 테이블(QuizSubmit)"과의 join으로 인해 자연스럽게 성능이 하락할 수 밖에 없던 것입니다.

 

더군다가 개인용 컴퓨터에 비해 클라우드 서버의 사양은 그렇게 좋지 못하기 때문에 데이터베이스 쿼리의 시간은 더욱 느려져 10초 이상의 응답 속도를 보여준 것이었습니다.

 

그래서 제가 생각해낸 해결책은 다음과 같습니다.

 

1. 수강 중인 강의에 대한 데이터를 전부 가져오는 것 -> 각 강의를 클릭하면 그 때 해당 강의에 대한 내용을 가져오도록

사용자가 수강 신청한 강의에 대한 정보만 가져오는 것은 어떠한 join 관계를 사용하지 않아도 되기 떄문에 상당히 빠른 속도로 데이터를 가져올 수 있습니다.

 

그렇기 때문에 해당 강의들을 리스트 형태로 보여주는 데까지는 거의 로딩 컴포넌트가 보이지 않을 정도로 가져올 수 있습니다.

 

그러고 나서는 강의 리스트에서 한 강의를 클릭하면 아래로 아코디언 컴포넌트가 열리면서 해당 강의에 달린 여러 영상들에 대해 사용자가 어느 정도로 수강 중인지와 퀴즈에 대한 상세 정보가 나오게 되는데 이렇게 되면 사용자가 보고 싶은 강의만 그때그때 열기 때문에 불필요한 데이터를 가져옴으로써 발생하는 성능 하락을 막을 수 있게 됩니다.

 

2. 문제가 되는 LectureTimeRecord와의 join 제거

가장 문제가 됐던 부분은 LectureTimeRecord와의 join으로 인한 성능 하락이 가장 심했었다. 이 테이블과의 join 관계만 제거하더라도 엄청난 성능의 상승을 볼 수 있었는데 이 테이블과 join을 해야 하는 이유는 단지 각 영상 별로 수강을 완료했는지의 여부와 총 몇 개의 영상 중 몇 개의 영상을 수강 완료했는지의 데이터를 얻기 위해서였다.

 

그러나 이는 사용자의 수강신청 정보를 담는 Enrollment 테이블에서도 가져올 수 있었는데 해당 테이블의 completedNumber라는 컬럼을 잘 활용하지 못해 어쩔 수 없이 LectureTimeRecord와 join을 했어야 하는 이유가 있었다.

 

그래서 Enrollment의 copmletedNumber를 최대한 활용해보도록 코드를 수정하였다.

 

기존에는 한 영상을 주어진 시간 동안 시청을 완료하면 LectureTimeRecord에서 해당 영상에 대한 status를 1로 바꾸어 완료 표시를 했었다. 그래서 LectureTimeRecord로부터 status 값을 가져오고 status가 1인 갯수를 가져오면 사용자가 해당 강의에서 수강 완료한 강의 갯수를 얻을 수 있었던 것이다.

 

수정한 방식은 영상 시청을 주어진 시간만큼 완료하게 되면 LectureTimeRecord의 status값을 바꿈과 동시에 Enrollment의 completedNumber를 기존 값의 +1을 해주는 식으로 수정하였다. 

 

이런 방식으로 진행을 해도 문제가 없는 것이 사용자는 이전 강의를 완료하지 않으면 다음 강의에 접근하지 못하도록 UI적으로 처리를 해두었기 때문에 completedNumber와 실제 사용자가 수강 완료한 강의의 갯수와의 일관성 문제는 없을 것이기 떄문이다.

 

또한 이렇게 함으로써 기존 LectureTimeRecord로 부터 status를 가져와 1이면 체크 표시에 색칠이 되도록 하던 방식을 completedNumber의 값만큼 처음 영상부터 체크 표시를 칠하도록 하게 하면 LectureTimeRecord의 데이터로부터 완전히 독립시킬 수 있게 된다.

 

가령 completedNumber 값이 5라면 영상 리스트를 map() 함수를 통해 나열시킨 상황에서 index 값을 가져와 1강부터 5강에 대한 부분만 색칠이 되도록 하면 되는 것이다.

 

2-3. 정리

최종적으로 다이얼로그 컴포넌트가 렌더링될 때 발생되는 상황을 성능 개선 전후로 비교한 결과를 살펴보겠습니다.

성능 개선 이전

  • 다이얼로그 컴포넌트 렌더링
    • 모든 데이터를 다 가져옴
      • /users/:userId 요청 -> (로컬) 583ms / (배포) 12s

로컬 환경에서 요청한 API 속도

 

배포 환경에서도 해당 API를 테스트 해보고 싶지만 환경을 다시 되돌리기가 어려워 재구현 모습은 없습니다..ㅠㅠ

 

 

성능 개선 이후

  • 다이얼로그 컴포넌트 렌더링
    • /users/:userId/enrollment 요청 -> (로컬) 26ms / (배포) 20ms
    • 한 강의 클릭
      • /users/:userId/enrollment/:courseId 요청 -> (로컬) 32ms / (배포) 108ms

enrollment 데이터 (왼쪽 배포, 오른쪽 로컬)
lectureDetail (왼쪽 배포, 오른쪽 로컬)

 

 

정리하면 다이얼로그 컴포넌트가 발생하고 한 강의에 대한 정보를 열람하기까지 드는 시간을 비교해봤을 때 성능 개선 전에 583ms 정도 걸리던 것이 58ms 정도까지 성능 개선이 되었고 비율로 따졌을 때 약 90.05% 가량의 성능 개선을 이루어낼 수 있었습니다.

 

배포 서버 측면에서 봤을 때는 12초에서 128ms까지 줄였기 때문에 약 98.93% 가량의 성능 개선을 이루어낼 수 있었으며 이와 더불어 상당히 의미있는 것은 처음 다이얼로그 컴포넌트가 렌더링되고 최초 데이터가 화면에 보이는 시간을 봤을 때 12초에서 20ms가 된 것이기 때문에 UX 적인 측면에서도 상당히 의미있는 성능 개선 작업이었다고 볼 수 있습니다.

 

물론 강의 요소를 하나씩 열 때마다 하나의 API 요청이 가기 때문에 강의 하나당 로컬에서는 32ms, 배포 서버에서는 108ms가 소모되는 것이지만 데이터가 캐싱될 것을 고려했을 때 전혀 문제없는 지표로 볼 수 있다고 생각합니다.

 

 

위에서 볼 수 있듯이 다이얼로그가 열릴 때는 전혀 로딩이 보이지 않지만 강의 요소를 하나 클릭할 때 로딩이 보이는 것을 볼 수 있습니다.

 

위 영상은 gif 변환으로 인해 로딩이 거의 없는 것처럼 보이지만 실제로 보면 로딩이 조금 더 길게 유지됩니다.

 

3. 더 개선시켜야 할 부분

위 작업에서 LectureTimeRecord와의 분리를 시켰지만 QuizSubmit 또한 사용자들이 제출한 퀴즈를 모으는 테이블이기 때문에 나중에 가면 더욱 데이터가 많이 쌓일 수록 쿼리의 성능이 낮아질 수 있습니다.

 

이를 해결하려면 LectureTimeRecord와의 join을 사용하지 않았던 것처럼 QuizSubmit 역시 사용하지 않는 방법을 고려해보아야 하는데 이 테이블의 데이터는 반드시 필요하기 때문에 아래 두 가지와 같은 방법을 고려해볼 수 있을 것 같습니다.

  1. 별도의 쿼리로 퀴즈 요소를 하나 클릭할 때 해당 퀴즈 내용을 가져오는 식으로 변경(강의와 똑같은 방법)
  2. join을 하되 QuizSubmit 테이블에 인덱스 추가

 

 

4. 배운점

이번 작업을 통해 몇가지 인사이트를 얻을 수 있는 부분들이 있었습니다.

 

1. 쿼리를 분리해서 병렬로 요청을 날려 먼저 도착한 데이터부터 보여주도록 하자

다이얼로그 컴포넌트가 렌더링될 때 모든 데이터를 한꺼번에 가져오면서 응답 시간이 길어지게 되는 문제를 겪었고 이를 해결하기 위해 데이터를 분리하여 병렬로 요청하고, 먼저 도착한 데이터부터 화면에 보여주도록 했습니다. 이로써 사용자 경험(UX)을 향상시켰고 응답 시간을 단축할 수 있었습니다.

 

2. join을 최소화하자 (데이터베이스 관리, )

JOIN 연산이 많은 데이터를 다루면서 성능 저하를 유발했습니다.

JOIN을 최소화하는 방향으로 쿼리를 수정하려 했습니다. 필요하지 않은 JOIN을 제거함으로써 쿼리 성능을 크게 개선할 수 있었습니다.

 

3. 한 화면을 구성하는 데이터를 요청하는 하나의 쿼리 vs 한 화면에 컴포넌트를 분리하여 각각의 쿼리 요청

한 화면을 구성하는 데이터를 한 번에 가져오려다 보니 응답 시간이 길어졌습니다.

한 화면을 구성하는 데이터를 각각의 컴포넌트로 분리하고, 각 컴포넌트마다 개별적으로 쿼리를 요청하는 방식으로 변경했습니다. 이를 통해 한 데이터만 가져와 화면에 보여줄 수 있었고, 데이터 로딩 시간을 줄일 수 있었습니다.

 

 

4. 가져올 데이터가 너무 많으면 하나씩 가져오는 것을 고려해보자

많은 데이터를 한 번에 가져오는 방식으로 인해 성능 저하가 발생했습니다.

데이터를 한꺼번에 가져오는 대신, 필요한 데이터를 클릭할 때마다 한 세트씩 가져오는 방식을 도입했습니다. 이를 통해 초기 로딩 시간을 크게 줄였고, 사용자가 필요한 데이터를 요청할 때마다 가져오도록 하여 효율적인 데이터 관리를 할 수 있었습니다.

 

 

정리하자면 제가 이번 성능 개선 작업에서 진행한 부분은 크게 세 가지로 볼 수 있습니다.

 

첫째, 쿼리 분리와 병렬 요청입니다. 기존에는 다이얼로그 컴포넌트가 렌더링될 때 모든 데이터를 한꺼번에 가져오는 방식이었는데, 이를 각각의 데이터 요청으로 분리하여 병렬로 처리했습니다. 이렇게 함으로써 먼저 도착한 데이터부터 화면에 보여줄 수 있었고, 전체적인 응답 시간을 단축하여 사용자 경험을 크게 향상시켰습니다.

둘째, JOIN 연산의 최소화입니다. 많은 데이터를 처리할 때 JOIN 연산이 성능 저하를 유발하는 주요 원인이었습니다. 이를 해결하기 위해, 필요한 데이터를 별도의 쿼리로 분리하고, JOIN을 최소화하는 방향으로 쿼리를 재구성했습니다. 특히, 주요 성능 저하를 유발했던 LectureTimeRecord와의 JOIN을 제거하여 쿼리 성능을 크게 개선할 수 있었습니다.

셋째, 데이터 접근 방식의 변화입니다. 기존에는 한 번에 많은 데이터를 가져오는 방식이었으나, 이를 각 컴포넌트별로 필요한 데이터를 개별적으로 요청하는 방식으로 변경했습니다. 예를 들어, 사용자가 수강 중인 강의 목록만 먼저 가져오고, 강의 세부 정보를 클릭할 때 해당 데이터를 가져오는 방식으로 최적화했습니다. 이를 통해 초기 로딩 시간을 줄이고, 필요한 데이터를 그때그때 가져올 수 있도록 하여 성능을 더욱 개선할 수 있었습니다.

이 세 가지 접근 방식을 통해 전체적인 성능을 약 90% 이상 개선할 수 있었고, 사용자 경험 또한 크게 향상시켰습니다.

 

 

 

 

 

 

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.