티스토리 뷰
안녕하세요.
Seller & SD Engineering팀의 김민우입니다.
이번 글은 ESM(지마켓 판매자 사이트) 개편 중 '문의하기' 개발을 맡으며 겪었던 어려움과 이를 해결해 나가는 몇 가지 방법들을 소개하고자 합니다. 제가 개발 맡은 부분은 판매자에게 구매자의 문의내용을 담당하는 '게시판 문의', 그리고 CS(고객센터)에서 주는 '긴급 메시지'를 통합하여 지마켓과 옥션 두 사이트에 대해 각각 보여주는 통합 페이지를 개편시키는 일이었습니다.
각설하고 조회 성능을 올린 결과물부터 영상으로 보시죠.
영상에서 보셨듯이, 대형 셀러의 경우 한 페이지를 로드하는 데 기본 몇십 초가 걸립니다. 페이지를 넘길 때조차 마찬가지입니다. 상품이 많아질수록 문의도 증가하기 마련인데, 모든 문의에 일일이 답변하기는 어렵기 때문에 미응답으로 남는 경우가 많습니다. 이러한 어드민 페이지를 개편할 때 보통 프론트엔드 중심으로 성과를 드러내는 경우가 많았고, 저 역시 해당 프로젝트를 처음 배정받았을 때는 성능 개선에 대한 기획은 없었습니다. 백엔드 로직은 그대로 두되 UI/UX를 고치는 것이 본래 목적이었습니다. 하지만 백엔드 개발이 메인인 저는 충격적인 성능을 목격하고서, 이번 프로젝트에서 백엔드에서도 성과를 낼 수 있음을 보여주기 위해, 두려움과 동시에 최선을 다해야겠다고 마음먹었습니다.
저에게 주어진 과제의 걸림돌이었던 점을 나열해보겠습니다.
1. 답변까지 복수로 조회
구버전에서는 문의를 클릭해야만 해당 문의에 대한 답변을 볼 수 있었습니다. 많은 판매자 분들이 답변을 일괄적으로 처리하지 못하고 하나하나 처리해야 한다는 어려움을 호소해 주셨습니다. 그래서 새로운 버전에서는 답변 칸을 문의 옆에 짝지어 나열하는 UI/UX로 변경하게 되었습니다. 그러나 개발자로서 이미 몇십 초가 걸리는 조회 로직에 답변까지 함께 짝지어서 조회하려면 얼마나 더 오래 걸릴지 막막하기만 했습니다.
2. 네 가지의 도메인을 데이터 구조는 그대로 두고 통합
앞서 서두에서 언급했듯이, 지마켓/옥션 각각의 '게시판 문의'와 '긴급 메시지'라는 비슷하지만 엄연히 다른 도메인을 한 가지 표에 통합해야 했습니다. 흔히 1번에 이어서 ESM 전용으로 조회하기 좋은 단순한 데이터베이스를 구상해 본다면 다음과 같습니다.
서로 다른 정책과 프로세스를 가진 제각각 2000년대 중반에 아우르는 유서 깊은 레거시들을 전부 다 조사하는 것도 일이었지만, 데이터 자체를 그대로 둔 채로 통합하면서 속도 성능을 내야 하는 건 생각보다 큰 과제였습니다.
데이터를 분석하면서 충격적이었던 점은 옥션 긴급 메시지를 제외하고는 '질문'과 '답변'이 같은 테이블에 섞여 들어가 있다는 점이었습니다. 한 질문에 대한 답변을 찾으려면 셀프 조인을 해야 했습니다. 그렇다고 파티션이 되어 있는 것도 아니었습니다. 질문과 답변이 1:1 관계도 아니었고, 한 개의 질문에 대해 여러 번 답변이 가능한 구조였습니다. 가뜩이나 답변 하나 가져오는 데도 몇십 초가 걸리는 상황에서, 같은 테이블에서 셀프 조인하고 여러 답변 중 가장 최신의 답변을 가져와야 했습니다.
통상적으로 이런 통합된 페이지의 성능을 높이려면 데이터 구조부터 바꾸거나 기존 테이블들을 그대로 두되, 새로 구상한 테이블에 기존 테이블의 데이터를 배치나 스트림을 통해 미리 최신의 답변을 집계하고 데이터를 통합해서 주기적으로 업데이트하는 것이 최적의 방법입니다. 그러나 개발 공수 기간이 짧아 이 방법을 적용할 수 없었습니다.
3. 대용량의 데이터베이스
해당 테이블들의 정확한 데이터양 수치를 서술하기에는 보안상의 이슈가 있을 것 같아 대략적으로 말씀드리자면, 상품 및 주문 도메인 관련 테이블들과 비슷하게 엄청난 볼륨을 가지고 있었습니다. 데이터베이스들의 생성 시기와 가장 오래된 데이터의 날짜를 비교해 보면, 그나마 한 번쯤은 데이터를 정리한 것 같습니다만, 근 10년간의 게시판 문의와 긴급 메시지들이 쌓여 있다는 점에서도 두려웠습니다. 대용량 데이터를 주로 다뤘던 경험상, 데이터베이스의 크기 자체는 대용량까지는 아니지만, 행(Row)의 수는 중견급 마켓의 상품 수와 맞먹을 정도였습니다.
이 때문에 인덱스를 새로 추가하는 것도 이미 큰 데이터베이스에 무리가 가기에 리스크가 있었고, 오래된 데이터를 비우는 일도 마이그레이션과 더불어 파티셔닝에 신경 써야 했기 때문에 개발 공수를 맞추기에는 부족했습니다. 따라서 추후 옵션으로 고려할 수는 있으나, 기존 데이터베이스 테이블들을 건드는 일 자체가 당장은 어렵다는 것이 확실했습니다.
이런 비슷한 상황에서 조회 속도를 어떻게 올릴 것인가를 고민하는 다른 여러 개발자들을 위해 제가 해결해 나간 과정을 소개합니다.
우선 방법 소개에 앞서, 잠깐 언급할 개념이 있습니다. 개발을 대학 전공으로 배울 때 가장 먼저 입문하게 되는 것이 '자료구조'와 '알고리즘' 수업입니다. '자료구조'는 주로 공간(메모리)을 어떻게 효율적으로 활용할 것인지를 다루고, '알고리즘'은 소요 시간을 얼마나 줄일 수 있는지를 공부합니다. 이 둘의 관계는 시간과 공간을 대표하는 과목들로, 컴퓨팅에 대한 중요한 법칙을 내포하고 있다고 생각합니다. 일반적으로 이 시간과 공간은 반비례 관계에 있다고 보시면 됩니다 (꼭 그런 것은 아닙니다만 대체로 그렇습니다).
아래 그림처럼 A → B로 가는 경우의 수를 구하는 문제를 보면, 직접 선을 하나씩 그려가며 A에서 시작해서 B로 가는 선을 10개 그려서 경우의 수를 구할 수도 있습니다. 하지만 이렇게 풀면 시간이 오래 걸리고 결국 OMR 마킹도 못하고 내는 경우도 있을 겁니다.
하지만 우리는 아래 그림과 같은 방법으로도 풀 수 있다는 것을 초등학교와 고등학교 수학 시간에 배웠습니다. 이 방법으로는 A와 B를 한 번씩만 보고 넘기며, 각 칸에 정보를 저장해 가며 한 번에 해결할 수 있습니다.
공교롭게도, 이 방법을 다시 자료구조나 알고리즘 전공 수업 때 파스칼의 삼각형을 배우며 익히게 됩니다. 이러한 프로그래밍 기법을 DP(동적 계획법, dynamic programming)이라고 배웁니다. 백트래킹을 하지 않고도 메모리를 조금 더 쓰면 경우의 수를 좀 더 빨리 구할 수 있다는 것입니다.
그러나 메모리와 같은 공간적인 개념은 저장공간의 효율적인 대중화로 인해 개발자들이 크게 신경 쓰지 않게 되었으며, 알고리즘 또한 다양한 라이브러리들이 많아서 코드에서는 잘 드러나지 않아 둘 다 간과되는 경우가 많습니다. 비즈니스에 직결되는 개발의 구현 여부만 신경 쓰다 보니, 구 ESM 문의하기도 처음에는 신경 써서 설계했겠지만, 10여 년이 지난 지금은 여러 요구사항들이 덧붙여져 땜질된 상태였습니다. 결과적으로 일부 구현에만 신경 쓴 여러 코드들이 전체적인 시간복잡도나 공간복잡도를 간과한 것들이 쌓이고 쌓여, 결국 어느새 엄청 느려진 조회 속도를 방치할 수밖에 없었습니다. 저 또한 이번에 개편한 코드가 몇십 년 후에는 땜질되어 느려질 수 있기 때문에, 이 프로젝트에 참여한 개발자들을 골라서 탓하는 것은 옳지 않다고 생각합니다.
제가 이토록 시간과 공간의 반비례 관계를 강조해도 잊어버릴 수 있는 분들을 위해, 포켓몬스터 게임의 예를 들어 보겠습니다. 게임에는 '시간'을 관장하는 디아루가와 '공간'을 관장하는 펄기아라는 전설의 포켓몬이 항상 싸웁니다. (이 게임의 버전 이름이 공교롭게도 포켓몬스터 "DP"입니다.) 이 둘의 관계를 다시 떠올려 보면, 처리 시간을 줄이기 위해 어떤 방법을 선택해야 할지 판단이 더 빨리 설 것입니다.
다음은 제가 이와 연관해서 실마리를 얻고 구 버전에서 신 버전으로 로직을 옮기며 성능을 해결해 나가는 부분에 대한 서술입니다.
1. 인덱스를 활용하지 못한 경우
인덱스는 데이터베이스를 빠르게 조회할 수 있다는 장점이 있지만, 데이터베이스의 각 행(row)을 기준으로 많은 공간을 사용하게 된다는 단점이 있습니다. 이는 공간을 내어주고 시간을 절약한다는 제 본론과 매우 부합하는 개념 중 하나입니다.
문의 리스트를 가져오는 부분은 신 버전과 구 버전이 대체로 비슷합니다. 두 버전은 동일한 데이터베이스 테이블을 바라보고 있으며, 주로 원하는 검색 필터를 기준으로 WHERE 절에 적용하고, 테이블에 없는 추가적인 데이터는 따로 JOIN을 통해 가져옵니다. 그러나 두 버전의 차이를 만든 원인 중 하나는 SellerID를 통합해서 조회하는 방법입니다. 저희 회사 정책상 ESM을 보고 있는 한 셀러는 MasterID가 있고, 그 아래에는 여러 하위 SellerID들이 있습니다. '문의하기' 페이지는 이러한 MasterID에 속한 모든 SellerID를 가지고 해당 SellerID에게 주어진 문의들을 전부 조회해야 합니다.
가장 크리티컬 했던 요소 중 하나를 꼽자면 다음과 같은 코드입니다. (아래 코드는 예시입니다.)
foreach (string sellerId in request.sellerList.Split(','))
{
GetList(request); // SQL 실행
}
//Stored Procedure (SQL)
SELECT ~~~
FROM TABLE
//기타 JOIN 생략
WHERE SELLER_ID = @SELLER_ID
AND ~~~~
반면 제 것은 다음과 같습니다.
SELECT ~~~~~
FROM TABLE
JOIN STRING_SPLIT(@SELLER_ID_LIST, ',') AS SPLIT ON TABLE.SELLER_ID = SPLIT.value
//기타 JOIN 생략
WHERE ~~~~~~~
단순히 둘의 시간복잡도를 구상했을 때는 s를 셀러의 개수, q를 뽑아낸 문의 개수로 치환한다면 다음과 같습니다.
- 기존 접근법:
- 판매자 ID 분리: O(s)
- 각 판매자별 데이터 가져오기: O(s * q log q)
- 가져온 데이터들 병합: O(s*q)
- 개선된 접근법:
- 판매자 ID 합치기: O(s)
- SQL 쿼리 실행 및 JOIN: O(s*q) or s가 인덱스가 되어있으면 O(q log s)
다행히 해당 테이블들은 SellerID에 인덱스가 걸려 있으므로 최종적으로 시간복잡도는 O(s * q log q) vs O(q log s)가 됩니다. 아래 그래프에서도 알 수 있듯이, Big O의 경우 n^2이 걸리는 순간 엄청난 소요 시간 차이가 발생합니다. 개발자 취준생들에게 이중 for loop를 최대한 피하라고 하는 이유도 여기 있습니다. 코딩 테스트에서 수행 시간 테스트가 점수에 포함되는 것도 그 때문입니다. MasterID에 딸린 셀러 개수가 많을수록, 테이블 자체에 있는 총 문의 개수가 많아질수록 조회 속도가 느려질 수밖에 없는 상황이었던 것입니다.
제가 제시한 방법의 단점은, 그래도 JOIN을 위해 해당 SELLER_ID_LIST를 내부적으로 임시 테이블로 만들고 JOIN 하기 때문에 공간을 포기하는 셈이라는 점입니다.
2. 임시 테이블과 JOIN 활용하기
또 다른 사례는 답변과의 임시테이블을 만들고 나서 JOIN 하는 방식입니다. 구버전에서 몇 SQL 들은 비록 답변을 하기 위해 질문을 클릭해야만 답변 내용을 표시해 주어서 답변 관련 JOIN은 필요 없을 줄 알았지만, 놀랍게도 답변 시각과 제목은 list로 같이 짝지어 보여주고 있었기 때문에 답변 관련 데이터들은 이미 답변과 짝도 짓고 있었던 셈입니다.
기존 전략은 단순히 답변 테이블과 JOIN 한 후에 WHERE 절로 검색 필터에 들어온 값들을 걸러내는 방식이었습니다.
SELECT ~~~~
FROM QUESTION
LEFT JOIN ANSWER
WHERE ~~~~~~
반면, 저는 인덱스가 되는 검색 필터 값들을 먼저 걸러낸 임시 테이블을 만든 후에 JOIN을 했습니다. 이렇게 하면 한꺼번에 JOIN 하는 것보다 후보지가 줄어들고, 나머지 인덱스가 걸리지 않은 것들은 WHERE 절에서 거르더라도 시간이 덜 걸립니다. 이 역시 임시 테이블을 위해 공간을 더 쓰는 방식이지만, 한편으로는 더욱 빠른 속도를 수행하게 됩니다.
WITH FilteredQuestions AS (
SELECT ~~~~
FROM QUESTION
LEFT JOIN ANSWER ON ~~~~
WHERE ~~~~(여기는 인덱스값) AND ~~~~ AND ~~~~
)
SELECT ~~~~
FROM QUESTION
JOIN FilteredQuestions ON ~~~~
WHERE ~~~~ AND ~~~~
게다가 이번 개편을 통해 한 가지 호재가 더 있었습니다. 답변의 타이틀을 없앤다는 정책이 새로 만들어졌고, 답변이 없는 '미처리' 상태로 조회하는 것이 디폴트가 되었습니다. 따라서 판매자가 '미처리'된 문의 건들을 조회할 때는 굳이 답변을 JOIN 할 필요가 없게 되었습니다.
그래서 저는 사용자가 검색 필터로 넣는 '처리 상태' 값에 따라 쿼리를 분기 처리하는 아이디어를 냈습니다. 만약 '미처리' 상태로 조회할 경우, 답변과 JOIN 하지 않는 쿼리를 사용하고, '처리 완료'나 '전체'라는 값이 들어올 때만 위의 쿼리를 사용하는 것입니다
SELECT ~~~~
FROM QUESTION
WHERE ~~~~~~
2. Hash Table (Map, Dictionary 등)을 적극적으로 활용하기
1) Enum에서 Map 활용하기
사실 이 또한 코딩테스트를 보게 되면 지겹도록 익히게 되는 법칙이지만, Hash Table을 사용하면 시간 복잡도가 O(1)로 월등히 줄어듭니다. 생각보다 많은 개발자들이 놓치는 것 중 하나는 Hash Table이 매칭/매핑할 때 무엇보다 큰 성능을 발휘한다는 점입니다. 가장 큰 예시로는 Enum을 사용할 때입니다. Enum은 일종의 나열된 집합체로, 가독성과 해시 성질 덕분에 많은 코드에서도 여전히 많이 발견됩니다.
아래의 예시는 실제 코드는 아니고 제가 임시로 만든 코드입니다.
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum PhoneAreaEnum {
SEOUL("서울", "02"),
BUSAN("부산", "051"),
DAEGU("대구", "053"),
INCHEON("인천", "032"),
GWANGJU("광주", "062"),
DAEJEON("대전", "042");
private final String koreanName;
private final String code;
public static String findByCode(String code) {
for (PhoneAreaEnum area : PhoneAreaEnum.values()) {
if (area.getCode().equals(code)) {
return area.getKoreanName();
}
}
return null;
}
}
여기서 만약 조회한 문의 건들 안에 code만 있어서 이를 front에서 표시하기 위한 koreanName을 가져와야 한다면 다음과 같이 PhoneAreaEnum.findByCode(entity.code)를 썼을 텐데요. 보다시피 해당 코드는 for를 돌아 O(문의 결과 리스트 * Enum 개수)을 돌게 됩니다.
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
@Getter
@RequiredArgsConstructor
public enum PhoneAreaEnum {
SEOUL("서울", "02"),
BUSAN("부산", "051"),
DAEGU("대구", "053"),
INCHEON("인천", "032"),
GWANGJU("광주", "062"),
DAEJEON("대전", "042");
private final String koreanName;
private final String code;
private static final Map<String, PhoneAreaEnum> codeToEnumMap = Arrays.stream(PhoneAreaEnum.values())
.collect(Collectors.toMap(PhoneAreaEnum::getCode, area -> area));
public static String findByCode(String code) {
PhoneAreaEnum area = codeToEnumMap.get(code);
return (area != null) ? area.getKoreanName() : null;
}
}
하지만 이런 식으로 바꾸면 처음에 Map을 세팅할 때에만 for loop가 돌고, 조회된 건수를 일일이 매핑할 때는 미리 만들어둔 Map에서 O(1)의 시간 복잡도로 빠르게 매핑해 나갈 수 있습니다. 특히, 많은 Enum 값을 다룰 때 이 방법이 유리합니다.
2) 서비스 분기처리를 Map으로 하기
이 외에도 사소한 시간 영향을 주는 if/else에 대한 얘기를 Map과 연관시켜 살펴보겠습니다. 저는 통합 조회 API를 만들었고, 서로 다른 사이트인 '지마켓'과 '옥션', 그리고 각각의 '게시판 문의'와 '긴급 메시지'의 비즈니스 로직을 다르게 처리하고자 했습니다. 절차지향적인 코드라면 한 컨트롤러에 한 서비스를 사용하고, if/else나 switch 문을 통해 분기 처리를 했을 겁니다. 예를 들어, '옥션 게시판 문의'가 요청으로 들어왔다면 if(옥션) 안에 if(게시판 문의)로 중첩해서 로직을 썼거나, else if (옥션 게시판 문의) 식으로 썼을 수도 있습니다. 다만, 이 방법은 미세하지만 if, else if, else if 혹은 if 안에 if를 거치기 때문에 불필요한 시간이 소요됩니다.
그래서 저는 handler들을 자체적으로 만들고, Spring에 내장된 handlerMap을 사용하는 것을 권장합니다.
예시를 들자면 다음과 같은 코드입니다.
먼저 저의 케이스를 코드로 대강 풀이하자면 다음과 같습니다. 먼저 다음과 같이 handler interface를 만들어둡니다. 다음 코드들은 변수명도 다른 임의의 코드입니다.
public interface QnaQueryListHandler {
List<MappedEntity> processList(HandlerRequest request);
}
다음, 옥션과 지마켓 각각의 인터페이스 혹은 추상 클래스로 각각 사이트별로 처리하는 방식을 먼저 정합니다. 저는 FeignClient를 의존성 주입하기 위해 추상 클래스를 사용했고, 'AbstractAuctionQnaQueryListHandler'와 'AbstractGmarketQnaQueryListHandler'를 만들어 각각 사이트별로 다른 방법을 오버라이딩하여 분기합니다.
@Service
public abstract class AbstractAuctionQnaQueryListHandler implements QnaQueryListHandler {
private String extractIds(HandlerRequest request) {
// 해당 방법은 지마켓 옥션이 다르기 때문에 분기될만함.
return "";
}
@Override
public List<MappedEntity> processList(HandlerRequest request) {
String ids = extractIds(request);
return handleIds(ids, request);
}
protected abstract List<? extends MappedEntity> handleIds(String ids, HandlerRequest request);
}
그런 다음, 옥션에 해당되는 게시판 문의에 대한 비즈니스 로직을 작성합니다.
@Service("AUCTION_BOARD_INQUIRY_QUERY_LIST_HANDLER")
public class AuctionBoardQueryListHandler extends AbstractAuctionQnaQueryListHandler {
@Override
protected List<? extends MappedEntity> handleIds(String ids, HandlerRequest request) {
// 생략
return new ArrayList<>();
}
}
이와 같은 방식으로 지마켓 긴급 메시지, 지마켓 게시판 문의, 옥션 긴급 메시지도 동일하게 작성합니다. 이렇게 하면 QnaQueryListHandler에 묶인 서비스들이 하나의 Map으로 Spring에 내장된 기능에 의해 생성됩니다. 이를 서비스 단에 의존성 주입하고 사용하면 됩니다.
@RequiredArgsConstructor
public class QnaQueryServiceImpl implements QnaQueryService {
private final Map<String, QnaQueryListHandler> qnaQueryListHandlerMap;
// 생략
public QueryEntity<QueryListQueryEntity> getQueryList(QnaQueryListRequest qnaQueryListRequest) {
String handlerKey = String.format("%s_%s_QUERY_LIST_HANDLER",
qnaQueryListRequest.getSite().name(), qnaQueryListRequest.getMessageType().name());
QnaQueryListHandler handler = qnaQueryListHandlerMap.get(handlerKey);
List<QueryListJpaEntity> jpaEntityList = handler.processList(qnaQueryListRequest);
// 생략
}
}
해당 방법을 사용하면 서비스별로 if/else로 분기 처리하는 것을 보다는 가독성을 가져오면서도, 사이트별 혹은 문의 유형별 공통 기능들도 쉽게 유지보수하며 성능을 약간 더 향상할 수 있는 여지가 생깁니다.
3. Multithread 활용하기
Java가 Node 계열의 JavaScript나 데이터 구조화에 유리한 Python에 밀리는 추세라고는 하지만, Interpreter 언어들과 달리 Java가 근본적으로 가지고 있는 강점 중 하나는 Multithread의 활용성입니다. 범용성이 높다는 Python도 아직 JVM의 멀티스레딩과 저용량 성능을 따라오지 못하고 있습니다. 그러므로 Java 개발자라면 Multithread를 적재적소에 활용해야 한다고 봅니다. 특히 성능을 고려해야 하는 경우에는 더욱 그렇습니다.
저의 경우, 웬만하면 같은 DB 서버에 묶여 있고 인덱스가 있으면 최대한 JOIN을 선호합니다. 관계형 데이터베이스는 이를 위해 만들어진 것이기 때문입니다. 하지만 DB 서버가 같이 묶여 있지 않거나 인덱스가 없다면 JOIN은 어렵습니다. 따라서 다른 SQL이나 API 호출을 통해 데이터를 얻은 후, 기존에 조회된 문의 결과와 매칭할 수밖에 없습니다. 이럴 때, 문의 결과 리스트 n개와 다른 데이터 리스트 m개를 이중 for문으로 처리해서는 안 되고, 아래 예제와 같이 Map을 먼저 만들어줘야 성능이 나옵니다.
Map<String, ExtraBasicInfoResponse> extraInfoMap = extraUrlConfig.getExtraBasicInfoMap(extraNumbersList, siteId);
그 후 해당 Map을 가지고 매칭을 하면 되는데, 이때 단순 매칭을 위해서는 순서가 중요하지 않기 때문에 다음과 같이 Multithread를 사용하여 동시성 있게 처리하면 조회 성능을 끌어올리는 데 도움이 됩니다.
return entityList.parallelStream()
.map(entity -> {
TempEntity tempEntity = asEntity(entity);
ExtraBasicInfoResponse extraInfo = extraInfoMap.get(entity.getNo());
// 생략
});
다만, 단점은 메모리를 잡아먹기 때문에 해당 프로젝트에 같이 돌고 있는 다른 도메인들에게도 영향이 갈 수 있다는 점입니다. 제가 사용한 parallelStream은 단순하지만, 가독성을 위해 Stream 객체를 따로 만들어야 한다는 점과 Common ForkJoinPool을 사용하기 때문에 다른 곳에서도 해당 스레드풀을 사용하면 성능 향상이 예상보다 낮을 수 있습니다. 그렇다고 무작정 ForkJoinPool이나 ExecutorService와 같은 객체를 따로 만든다면 같은 parallelStream을 사용하는 도메인에는 영향을 주지 않지만 추가된 메모리 공간을 사용해야 합니다. 만약 여러 도메인들이 조회 성능만을 생각해 이런 식으로 풀을 추가했다가 총메모리를 초과해 GC 에러를 시켜 서버가 중단된다면 큰일입니다. 그러므로 더욱 신중하게 고려해야 합니다. 따라서 아무리 조회 성능을 향상한다 해도 메모리 공간을 지나치게 포기하면 안 됩니다. 다행히 저 코드 프로젝트는 parellelstream을 아직 쓰는 곳이 거의 없을뿐더러 '문의하기'처럼 규모가 있는 트래픽을 담당할만한 도메인이 아직까지는 없는 것으로 파악했습니다.
4. GROUP BY를 안 쓰고도 카운트 집계 성능 내는 방법
가장 문제가 되는 구버전의 조회에는 집계 기능도 포함되어 있었습니다. 집계를 할 때 거의 필수적으로 쓰이는 SQL문은 GROUP BY입니다. GROUP BY는 사용자에게 다양한 형태의 데이터를 제공하는 유용한 SQL문입니다. 다만, GROUP BY를 사용하면 유형별 임시 공간이 필요하고, 그룹화를 위해 정렬을 해야 하므로 일반적으로 O(n log n)의 시간복잡도를 가집니다.
예시로, 구버전이 사용하는 집계 함수는 다음과 같은 코드 형태입니다.
SELECT TYPE_CODE,
COUNT(*) AS COUNT
FROM EXAMPLE
WHERE CREATION_DATE BETWEEN @START_DATE AND @END_DATE
GROUP BY TYPE_CODE;
신버전에서는 다음과 같이 집계를 수행할 수 있습니다. 단, 이 방법은 고정된 TYPE_CODE가 정해져 있어야 합니다. 예를 들어, 성별과 같이 단순한 기준으로 row 개수를 구할 때는 유용하지만, 직업별 row 개수처럼 특정되지 않은 채로 다양한 유형별 개수를 파악할 때는 사용하기 어렵습니다.
SELECT ISNULL(SUM(CASE WHEN TYPE_CODE = '1' THEN 1 ELSE 0 END), 0) AS FATHER,
ISNULL(SUM(CASE WHEN TYPE_CODE = '2' THEN 1 ELSE 0 END), 0) AS MOTHER,
ISNULL(SUM(CASE WHEN TYPE_CODE = '3' THEN 1 ELSE 0 END), 0) AS SON,
ISNULL(SUM(CASE WHEN TYPE_CODE = '4' THEN 1 ELSE 0 END), 0) AS DAUGHTER
FROM EXAMPLE
WHERE CREATION_DATE BETWEEN @START_DATE AND @END_DATE;
이렇게 변경하면 GROUP BY처럼 추가적인 임시 공간을 거의 사용하지 않으며, 집계 결과를 저장할 몇 개의 변수 공간만 필요하고, 한 번만 순회하면 끝나기 때문에 시간복잡도 역시 O(n)에 그칩니다. 이처럼 무작정 시간과 공간이 반비례하는 것은 아닙니다. 포켓몬에서도 간혹 디아루가와 펄기아가 힘을 합칠 때가 있듯이 말입니다.
개편된 ESM을 오픈한 지 며칠 안 된 지금, 아직까지는 큰 문제가 없어 보입니다. 더욱 빨라진 조회 속도로 인해 조회 빈도 자체가 예전에 비해 급속도로 높아진 점에서 고무적이라고 생각합니다. 기획과 UI/UX 팀에서 조회 성능을 위해 적절한 대안을 찾고, 정책적인 변화를 통해 중복되는 칼럼들을 단건씩 보이게 하여 조회 성능에 집중할 수 있게 해준 점에 대해서도 감사합니다. 이러한 변화는 구매자의 문의를 보고 싶어도 답답한 속도 때문에 못 보고 지나쳤던 판매자들에게 더 큰 기회를 줄 수 있을 것입니다. 또한, 물건을 사기 전에 답변이 오지 않아 망설였던 고객들의 발걸음을 되돌릴 수 있을 것이라 기대합니다.
'Backend' 카테고리의 다른 글
오픈마켓 여행 플랫폼의 실전 API 연동 노하우 (31) | 2024.07.23 |
---|---|
신규 서비스 "꿀템"을 만들기 위한 여정(네? 다음달까지요?) -1편 (32) | 2024.06.30 |
Gmarket Mobile Web Vip 악성 봇 대침투 사건 (3) | 2024.05.11 |
설계란 고민의 연속이다 2편 (1) | 2024.04.04 |
설계란 고민의 연속이다 1편 (1) | 2024.03.14 |