목표
메인 화면 조회 API를 개선하는 과정을 소개합니다.
개요
처음 프로젝트를 시작하면서 동아리 활동 기간인 2달에 맞춰 개발을 진행했습니다.
기간을 맞추는 것을 우선순위로 진행하면서, 기술 부채가 쌓이고 있었습니다. 그 중 하나는 메인 화면을 조회하는 로직입니다.
메인 화면은 애플리케이션에 진입하면 가장 먼저 볼 수 있는 화면입니다.
본인을 포함한 친구, 챌린지 멤버들의 운동 기록과 더불어 기록한 영역 수 등 많은 정보를 한 번에 확인할 수 있는 핵심 기능입니다.
역할을 분배하면서, 해당 기능은 팀원이 담당했었습니다. 이제 백엔드 개발을 혼자 진행하고 있어, 코드 분석부터 문제 해결까지 진행했습니다.
요약
구체적인 내용을 기술하기 전에 간단하게 요약하면 다음과 같습니다.
성능 개선을 위해 두 가지 측면에서 리팩토링을 진행했습니다
- 불필요한 데이터 제거
- 구조 개선(로직 수정)
화면에 데이터를 보여주기 위해 필요한 것만 골라내고, 비효율적인 로직을 개선했습니다. 결과는 다음과 같습니다.
기준 | 전 | 후 | 개선치 |
SpanDelta 유무 | 552.31ms | 298.28ms | |
구조 개선 전/후 | 2887.6ms | 522.31ms |
메인화면 소개
메인화면은 다음과 같습니다.
화면에 보이는 네모난 영역들은 일주일간 기록한 운동 기록입니다. 초록색은 본인, 다른 색은 친구나 챌린지 멤버의 운동 기록을 나타낸 것입니다. 상단(12시 방향)에는 이번 주에 기록한 전체 영역의 개수입니다. 우측에는 진행하고 있는 챌린지 개수가 표시됩니다. 화면에는 3개의 챌린지를 진행하고 있다고 나오네요. 또한, 마커들은 각각의 회원이 운동을 기록한 마지막 위치입니다.
운동 기록 데이터는 친구와 진행하는 챌린지와 비례해서 늘어나는 데이터입니다.
문제 인식과 원인 분석
문제 인식
로그를 기록하는 것을 리팩토링하면서, AOP를 활용해 메소드 수행 시간을 기록했습니다.
해당 로그를 확인해보면서, 데이터가 많아질수록 메소드의 수행시간이 눈에 띄게 증가하는 것을 확인할 수 있었습니다.
운동 기록은 월요일~일요일 동안 운동 기록이 점차 쌓이게 되는 구조이므로, 일요일에 가까워질수록 수행시간이 눈에 띄게 증가합니다.
임의로 데이터를 넣어서 비교해본 결과, 다음과 같은 결과를 확인할 수 있습니다.
runningTime이 약 5배가량 증가한 것을 확인할 수 있습니다. 1초에 가까운 시간은 실질적으로 사용하기 버거운 속도입니다.
원인 분석
직접적인 원인을 찾기 위해 코드를 확인했습니다. 우선 요청에 따라 어떤 결과값을 내야하는지 확인할 필요가 있습니다.
https://www.nemodu.site/user/home?nickname=user1
위와 같이 회원의 닉네임을 전송하면, 다음과 같은 반환 값을 받아야 합니다.
{
"challengesNumber": 회원이 참여하는 진행 중 상태의 챌린지 개수
"challengeMatrices": [
{
"challengeColor": 챌린지 색깔,
"challengeNumber": 나와 함께하는 챌린지 개수,
"latitude": 멤버의 마지막 위치(위도),
"longitude": 멤버의 마지막 위치(경도),
"matrices": [멤버의 운동 기록 좌표들],
"nickname": 멤버의 닉네임,
"picturePath": 멤버의 프로필 사진 URL
}
...
],
"friendMatrices": [
{
"latitude": 친구의 마지막 위치(위도)
"longitude": 친구의 마지막 위치(경도),
"matrices": [친구의 운동 기록 좌표들],
"nickname": 친구 닉네임,
"picturePath": 친구의 프로필 사진 URL
}
...
],
"isPublicRecord": 필터 정보,
"isShowFriend": 필터 정보,
"isShowMine": 필터 정보,
"userMatrices": {
"latitude": 회원의 마지막 위치(위도),
"longitude": 회원의 마지막 위치(경도),
"matrices": [회원의 운동 기록 좌표들],
"matricesNumber": 회원의 이번 주 운동 기록 개수,
"nickname": 회원의 닉네임,
"picturePath": 회원의 프로필 사진 URL
}
}
거의 회원과 관련된 모든 정보를 내려줘야 하는 상황입니다.
영역이 많아지면 JSON은 방대해지고, 친구와 챌린지 수가 많아지면 곱절로 불어나는 것을 알 수 있습니다.
하지만, 우리는 화면에 보이는 운동 기록만 필요합니다. 화면 밖에서 기록된 운동 기록은 필요없지만, JSON에 포함되어 있습니다.
이는 데이터의 수를 줄일 필요가 있음을 의미합니다.
다음은 API의 일부 코드를 확인해보겠습니다.
/*챌린지 멤버 정보*/
List<UserResponseDto.ChallengeMatrix> challengeMatrices = new ArrayList<>();
for (User friend : friendsWithChallenge) {
Integer challengeNumber = challengeRepository.findCountChallenge(user, friend); // 함께하는 챌린지 수
//색깔 처리
Challenge challengeWithFriend = challengeRepository.findChallengesWithFriend(user, friend).get(0);//함께하는 첫번째 챌린지 조회
ChallengeColor challengeColor = userChallengeRepository.findChallengeColor(user, challengeWithFriend);//회원 기준 해당 챌린지 색깔
List<ExerciseRecord> challengeRecordOfThisWeek = exerciseRecordRepository.findRecordOfThisWeek(friend.getId()); // 이번주 운동기록 조회
List<MatrixDto> challengeMatrixSetDto = matrixRepository.findMatrixSetByRecords(challengeRecordOfThisWeek); // 운동 기록의 영역 조회
challengeMatrices.add(
new UserResponseDto.ChallengeMatrix(
friend.getNickname(), challengeNumber, challengeColor,
friend.getLatitude(), friend.getLongitude(), challengeMatrixSetDto,
friend.getPicturePath())
);
}
챌린지 멤버의 데이터를 조회하는 부분입니다.
자세한 내용은 생략하고 구조를 확인하면, 반복문 안에서 챌린지 회원을 하나씩 가져와서 필요한 데이터를 조회하고 있습니다.
즉, 반복문 1회 실행할 때, DB에 쿼리를 5번이나 실행하고 있습니다. DB에서 처리하는 시간 자체는 짧지만, 쿼리를 쏘기 위해 필요한 전, 후 작업과 DB에 다녀오는 RTT(Round Trip Time)이 추가됩니다. 필요한 데이터를 한 번에 조회한다면 데이터를 조회하는 시간을 줄일 수 있습니다.
개선 과정
불필요한 데이터 제거(Feat. MySQL Spatial, Span Delta)
문제 분석을 통해서 화면 안에 있는 운동 기록만 조회해 성능을 개선할 수 있음을 알았습니다.
개선 방법에는 두가지 선택지가 주어집니다.
- 전체 영역을 조회해서 일정 범위 내에 있는 데이터만 클라이언트에게 반환하기
- DB에서 일정 범위 내에 있는 데이터만 조회하기
서버의 부담을 줄이기 위해, DB에서 최소한의 데이터만 가져오는 두 번째 방법을 선택했습니다.
DB에서 데이터를 조회할 때 연산이 추가되면서 부하가 증가할 수 있지만, 인덱스를 활용한다면 부하를 최소화할 수 있다고 판단했습니다.
MySQL에서 지원하는 Geometry Class와 Spatial Data Format을 활용해서 범위 내에 데이터만 조회할 수 있습니다.
MySQL의 공간 데이터 배경 지식
MySQL은 다양한 Geometry Class를 지원합니다.
공간 데이터 타입은 크게 Point(점), LineString(선), Polygon(면)으로 구성되어 있습니다.
Polygon은 면을 구성하는 시작점과 끝점이 동일해야 한다는 특징이 있습니다.
MySQL은 관계형 데이터베이스 답게, 공간 데이터도 인덱스로 관리합니다. B- Tree로 관리되는 일반 데이터와 다르게, R-Tree로 관리합니다.
위도, 경도를 복합키(Composite)으로 관리하면 왼쪽 데이터를 먼저 조회하므로 원하는 공간을 찾을 때 성능이 저하됩니다.
반면, R-Tree는 공간 데이터를 조회할 때 B-Tree보다 우수한 성능을 보입니다. 뒤에서 설명할 MBR을 활용해 공간 데이터를 관리합니다. MBR이 겹쳐지거나 포함되게끔 계층 구조로 관리해 빠르게 공간 데이터를 조회할 수 있습니다.
MBR(Minimum Bounding Rectangle, 최소 경계 사각형)
MBR은 영역을 감싸는 제일 작은 사각형입니다. 운동 영역이 다음과 같이 저장되어 있다고 가정해보겠습니다.
저장된 4개의 운동 영역의 MBR은 다음과 같습니다.
운동 영역을 감싸는 빨간색 사각형이 MBR입니다.
위치 데이터를 Point 타입으로 관리하게 되면, MBR을 활용해 효율적으로 데이터를 조회할 수 있습니다.
결국, 일정 범위 내의 운동 기록만 조회하기 위해 Point와LineString을 활용했습니다.
운동 영역을 조회하기 위해 Polygon이 적합하다고 생각했지만, 시작점과 끝점이 동일해야 한다는 것은 복잡도를 늘린다고 판단했습니다.
MySQL에서 지원하는 Spaital 함수를 활용하기 위해 위도, 경도 각각 관리되던 운동 영역을 Point 타입으로 저장해야 합니다.
Point는 MySQL에서 공간 데이터를 관리하기 위한 자료형으로, Point(경도, 위도) 와 같이 사용됩니다.
Spring에서 엔티티의 값을 Point로 저장하기 위해 다음과 같은 의존성을 추가해야 합니다.
JPA와 함께 사용하려면, Hibernate와 버전이 맞아야 합니다.
//build.gralde
implementation group: 'org.hibernate', name: 'hibernate-spatial', version: '5.6.9.Final'
//Point 클래스 위치
import org.locationtech.jts.geom.Point;
다른 Point 클래스와 섞일 수 있으므로, geom 패키지에 포함된 Point 클래스를 사용해야 합니다.
여러 개의 운동 영역을 저장하고 나면, 일정 범위에 있는 영역만 조회해야 합니다.
조회하고자 하는 영역을 MBR로 간주해서 영역을 조회하는 로직으로 기능을 개선해나갔습니다.
MySQL의 MBRContains(g1, g2) 함수를 활용해서, MBR 내에 있는 영역을 조회하려고 합니다.
g1이 MBR이고, g2가 대상입니다. g2가 g1에 포함되는지 여부를 판단해 포함하면 1, 아니면 0을 반환합니다.
이를 Where 절에서 활용해 영역이 MBR안에 포함되는지 확인할 수 있습니다.
g1은 LineString, Polygon 등의 영역을 특정짓는 타입이 들어갈 수 있습니다.
LineString은 해당 선을 대각선으로 하는 직사각형을 만들어, 직사각형에 포함되는 Point만 반환하게 됩니다.
다음은 MBRContains() 함수를 활용한 SQL 예시입니다.
SELECT
m.point,
e.user_id
FROM
matrix AS m
INNER JOIN exercise_record AS e
ON m.exercise_record_id = e.exercise_record_id
WHERE
MBRContains(
ST_LINESTRINGFROMTEXT(
'LINESTRING(37.353280 126.835272, 37.315125 126.787285)'
),
m.point
)
해당 쿼리는 주어진 범위 내에 포함되는 운동 영역만 조회합니다.
조회해야 하는 범위 찾기(MBR)
일정 범위에 있는 운동 기록을 조회하는 방법은 알았으니, 사용자가 어느 지역을 보고 있는지 알아야 합니다.
사용성과 오차를 고려해 화면보다 살짝 큰 MBR을 구성해야 합니다. 대부분의 핸드폰은 세로로 긴 직사각형이므로, 긴 부분을 기준으로 LineString을 찾아야 합니다.
모바일 애플리케이션 특성상, 줌인-줌아웃을 통해 화면의 비율을 바꿀 수 있습니다. 따라서, 위치와 함께 비율을 알아야 합니다.
iOS는 지도의 축척을 계산해서 화면의 비율을 알 수 있습니다. 이를 Span Delta라고 합니다.
화면의 중심점과 Span Delta를 활용해서 적절한 MBR을 찾을 수 있습니다.
위 공식을 활용해서, 더 긴 부분을 기준으로 LineString을 생성합니다.
화면 내 영역만 조회하게 되므로, 데이터의 크기가 크게 줄어들었습니다.
구조 개선(로직 수정)
데이터의 수가 줄어들어도, 비효율적인 구조로 인해 쿼리를 계속 사용하면 큰 의미가 없습니다.
여기서 포인트는, 데이터를 조회해야하는 인원은 정해져있다는 점입니다. 쿼리를 다 쏴보지 않아도 데이터가 필요한 회원은 정해져 있습니다.
반복문을 돌아가면서 한 명씩 데이터를 조회하는 것이 아닌, 한 번에 모든 회원의 데이터를 조회해서 관리한다면, 최소한의 쿼리로 원하는 데이터를 얻을 수 있습니다. 물론, 데이터를 많이 들고 있으면 메모리를 더 사용하는 것도 사실입니다. 하지만, 각 회원의 데이터를 조회하고, 새로운 객체를 만드는 것이 더 많은 메모리를 소비합니다. GC가 돌기 전까지 의미없는 객체가 남아있을테니까요.
개선 전 후의 쿼리를 정리하면 다음과 같습니다.
# | 전 | 후 |
1 | 회원 조회 | 회원 조회 |
2 | 회원의 이번 주 운동 기록 조회 | 회원의 운동 영역 조회 |
3 | 회원의 운동 영역 조회 (2번 결과를 기반으로 N번) | 회원의 이번 주 영역 개수 조회(화면 밖 영역 포함한 개수) |
4 | 챌린지 목록 조회 | 진행 중 챌린지 멤버 조회 |
5 | 친구 목록 조회 | 친구 목록 조회 |
6 | 챌린지 참여 인원 조회 (4번 결과를 기반으로 최대 3번) | 챌린지 멤버와 친구 영역 목록 조회 |
7 | 친구의 운동 기록 조회 (5번 결과를 기반으로 N번) | 함께하는 챌린지 개수 조회 |
8 | 친구의 운동 영역 조회 (5번 결과를 기반으로 N번) | 챌린지 정보 조회 |
9 | 친구와 함께하는 챌린지 수 조회 (5번 결과를 기반으로 N번) | 챌린지 색깔 조회 |
10 | 친구와 함께하는 첫번째 챌린지 조회 (5번 결과를 기반으로 N번) | |
11 | 챌린지 색깔 조회 (9번 결과를 기반으로 N번) | |
12 | 챌린지 멤버 운동 기록 조회 (6번 결과를 기반으로 최대 3번) | |
13 | 챌린지 멤버 운동 영역 조회 (12번 결과만큼) | |
14 | 진행 중인 챌린지 개수 조회 |
데이터를 조회할 명분만 5개가 차이나는데, 그 안에서 N번 호출씩 호출되는 쿼리를 포함하면 엄청난 차이를 보입니다.
각 회원에 대한 데이터를 조회하는 것이 아닌, 챌린지 or 친구를 기준으로 데이터를 조회하면서 쿼리의 수를 확연히 줄일 수 있습니다.
이 과정에서, 조회 성능을 끌어올리기 위해 인덱스를 적극 활용했습니다. 인덱스를 활용해 데이터 수를 확연히 줄이고 데이터를 조회함으로써 성능을 개선할 수 있습니다. 쿼리에서 사용되는 모든 컬럼을 인덱스로 갖고 있으면, 데이터 블록에 접근하지 않고 데이터를 조회할 수 있습니다. 이러한 개념을 커버링 인덱스라고 합니다.
테스트
테스트 케이스는 다음과 같습니다.
- 회원 50명 (USER1 ~ USER50)
- USER1을 기준으로 실행 속도를 측정한다.
- USER1은 USER2 ~ USER15과 친구 관계이다.
- USER1이 참여하는 이번 주 진행되는 챌린지는 3개이다. (USER2, USER3, USER4과 함께 진행)
- 완료된 챌린지는 총 10개이며, 모든 챌린지는 USER1이 참여 중이다. (일부는 주최자)
- 전체 운동 기록 수: 280,663개
- 전체 운동 영역 수: 1,054,636개
각 회원의 운동 기록은 다음과 같다.
회원 | 위치 | 기간 |
USER1, USER6 ~ USER10 | 경기도 안산시 선부 광장 (37.334240, 126.809936) 조회하는 기준 MBR |
2달 |
USER2 | 경기도 안산시 화랑유원지 (37.325424, 126.998663) MBR에서 동남쪽 1KM 거리 |
이번 주 |
USER3 | 경기도 안산시 원곡동 부근 (37.330288, 126.798716) MBR에서 북서쪽 1KM |
이번 주 |
USER4 | 경기도 용인시 부근 (37.203520, 127.207124) MBR에서 동쪽 30KM |
이번 주 |
USER5 | 서울특별시 용산공원 (37.536400, 127.992448) MBR에서 북동쪽 30KM |
이번 주 |
USER11 ~ USER35 | 부산광역시 부근 (35.167695, 128.998663) MBR에서 동남쪽 300KM |
2달 |
개선 전
평균 실행 속도 | 회원 영역 개수 | 타인의 영역 개수 합 |
2887.6ms | 570 | 7980 |
클라이언트(앱)을 구동했을 때, 기본적으로 약 300m X 250m 정도의 지도를 확인합니다.
그럼에도 불구하고, 불필요한 부산에 있는 영역까지 조회해 8500개가 넘는 영역을 조회하고 있습니다.
불필요한 로직과 객체 생성으로 인해 테스트 중간에 Heap이 터지는 경우도 있었습니다.
개선 후
SpanDelta | 평균 실행 속도 | 회원 영역 개수 | 타인의 영역 개수 합 | 특징 |
0.003 | 289.28ms | 72 | 360 | - 약 300m - Default MBR - 선부 광장을 제외한 영역 조회 안됨 |
0.009 | 355.32ms | 456 | 2280 | - 약 900m - Default MBR을 조금 넘어서는 영역 - USER1, USER6 ~ 10 추가 영역 조회 - 다른 영역은 조회 안됨 |
0.03 | 393.37ms | 570 | 3990 | - 약 3km - 안산시 영역까지 조회 - USER4, 5 및 11~35 조회 안됨 |
1 | 432.49ms | 570 | 5130 | - 약 100km - 용인, 용산공원까지 조회됨 |
10 | 522.31ms | 570 | 7980 | - 약 1000km - 부산까지 모두 조회 됨 - 모든 친구, 챌린지 멤버 영역 조회 |
SpanDelta에 따라 조회되는 영역이 크게 줄었음을 확인할 수 있습니다. 특히, 기본 MBR에서 타인의 영역이 크게 줄었습니다.
전체 결과
최종적으로 불필요한 데이터를 줄이고, 로직을 개선한 각각의 결과는 다음과 같습니다.
기준 | 전 | 후 | 개선치 |
SpanDelta 유무 | 552.31ms | 298.28ms | |
구조 개선 전/후 | 2887.6ms | 522.31ms |
정말 개선을 통해 이득만 얻었을까?
눈에 띄는 성능 개선을 이뤄냈지만, 분명 손해보는 것도 있습니다. 개선 과정에서 발생하는 사이드 이펙트와 이에 따른 개발 비용이 발생하기 때문입니다.
일정 영역의 운동 기록만 조회하기 위해 운동 기록의 데이터 타입을 수정했습니다.
운동 기록이 메인인 프로젝트에서 운동 기록의 데이터 타입이 끼치는 사이드 이펙트는 상당했습니다.
위도와 경도를 직접 가져오는 것이 아닌, Point 타입에서 한번 더 꺼내는 과정을 거쳐야 했습니다. DTO 레이어에서 값을 꺼내거나, MySQL에서 ST_X(), ST_Y()와 같은 함수를 사용해야 합니다. 운동 기록과 관련한 전반적인 코드의 수정이 불가피했습니다. 이는 개발 과정과 테스트까지 고려했을 때 들어가는 시간과 노력은 만만치 않습니다. 실무였다면 이에 대한 인건비는 무시할 수 없었을 것입니다.
또한, 클라이언트에서 넘어오는 축척과 필수 값이 올바르게 넘어왔는지 확인해야하는 과정이 추가되어야 합니다. 당장은 크게 어려운 부분이 아니지만, 프로젝트 규모가 크면 감당하기 힘든 수준이 될 수 있습니다.
구조 개선의 측면에서도 분명 쿼리의 수는 줄었지만, 각 쿼리의 길이는 훨씬 길어졌습니다. 한 테이블에서 조회하던 데이터는 여러 테이블을 조인해서 데이터를 조회하기 시작했습니다. 조인으로 발생하는 비용은 그렇게 크지 않습니다.
문제는, 여러 테이블을 조회할 때 락이 걸릴 수도 있다는 점입니다. 챌린지의 경우 매일 상태가 바뀌기 때문에, 배치가 돌면서 배타 락을 걸수도 있습니다. 사용자는 특정 시간에 저하된 성능을 느낄 수밖에 없다는 것입니다.
정리
기존 코드는 데이터가 50만건만 되어도 테스트 도중에 Heap 메모리가 터져버리곤 했습니다.
개선 후 이러한 부분들이 개선되었고, 가독성 측면에서도 많이 좋아졌다고 생각합니다.
프로젝트 전역의 코드를 수정하면서 디자인 패턴과 같은 정형화된 구조의 필요성을 크게 느낄 수 있었습니다.
'프로젝트 > NEMODU' 카테고리의 다른 글
[MySQL] UUID의 개념과 성능 개선 결과 (1) | 2023.02.22 |
---|