미리 집계 하기

설문 조사 기능이 있다. 각 설문조사마다 좋아요를 할 수 있고 설문 조사 목록을 보여줄 때 답변 수와 좋아요수를 표시한다. 이를 위해 survey, anwer, liked 테이블이 존재한다.

목록을 표시할 때 설문에 답한 회원 수와 좋아요를 한 회원 수를 표시한다는 요건을 위한 쿼리.

SELECT s.id, s.subject,
	(SELECT COUNT(*) FROM answer a WHERE a.surveyId = s.id) as answerCnt,
	(SELECT COUNT(*) FROM liked a WHERE l.surveyId = s.id) as likeCnt
FROM survey s
ORDER BY id DESC
LIMIT 30;

논리적으로 정확하지만 성능에 이슈가 존재할 수 있다. 30개의 설문이 있을 때 설문마다 평균 답변자 수가 10만명이고 좋아요를 한 회원 수가 1만이라 가정해보자. 위 쿼리 실행시 다음과 같이 실행된다.

  • 목록 조회
  • 답변자 수 세는 쿼리 30번: 각 쿼리는 10만개 센다
  • 좋아요 수 세는 쿼리 30번: 각 쿼리 1만개 센다. 합치면 논리적으로 61번 쿼리가 실행된다.
  • 쿼리 시간: 0.01 + 0.1 * 30 + 0.05 * 30 = 4.51 4.51 초는 온라인 서비스에서 매우 긴 시간. 게다가 트래픽이 몰리면 더욱 조회 시간이 느려질것이다.

이를 개선하려면 집계 쿼리를 안쓰고 미리 계산해서 별도 컬럼에 저장해 두면된다. 이 예에서는 servey 테이블에 답변자 수와 좋아요 수를 저장할 컬럼 추가하고, 이 컬럼에 값을 계산해 넣는 방식으로 구현한다. 다음과 같은 쿼리를 쓰는거지

-- answer table에 답변 추가
INSERT INTO answer VALUES (...)
 
-- servey table의 answerCnt 값을 1 증가시킨다.
UPDATE survey SET answerCnt = answerCnt + 1 
WHERE surveyId = 아이디;
 
-- 좋아요도 비슷. 취소했다면 likedCnt - 1 하면됨
DELETE FROM liked WHERE answerId = 설문ID AND memberId = 회원ID;
UPDATE servey SET likedCnt = likedCnt - 1 WHERE surveyId = 설문ID

정규화가 꼭 좋은건가?

페이지 기준 목록 조회 대신 ID 기준 목록 조회 방식 사용하기

게시글 데이터가 10만개 있다 가정. 화면에 10개씩 보여주면 전체 페이지수는 1만개가 된다. 첫 페이지 읽기 위한 쿼리는 다음과 같다.

SELECT id, subject, writer, regdt
FROM article
ORDER BY id DESC
LIMIT 10 OFFSET 0;

다음은 마지막 1만 번째 페이지를 읽어오는 쿼리

SELECT id, subject, writer, regdt
FROM article
ORDER BY id DESC
LIMIT 10 OFFSET 99990;

이 쿼리를 실행시 99,991번째 id부터 바로 조회하면 좋겠지만 DB는 어떤 id가 99.991 번째인지 모른다. 그래서 역순으로 id를 99,990개 세고 나서 10개 데이터를 조회한다. 데이터를 세는 시간만큼 실행시간이 증가하는 것이다.

인덱스에 포함되어 있지 않은 칼럼은 더 느리다. 다음 쿼리는 삭제되지 않은 게시글 목록을 조회한다.

SELECT id, subject, writer, regdt
FROM article
WHERE deleted = false
ORDER BY id DESC
LIMIT 10 OFFSET 10000;

deleted 칼럼이 false인지 비교할 목적으로 데이터를 읽어오는 시간을 줄이기 위해, deleted 칼럼을 인덱스로 사용할 수 도 있다. 하지만 여전히 지정한 오프셋만큼 데이터를 세는시간이 남는다.

지정한 오프셋으로 이동하기 위해 데이터를 세는 시간을 줄이는 방법은 특정 ID를 기준으로 조회하는 것!

예를 들어 마지막으로 다음 쿼리로 조회한 데이터의 id가 9985라 하면

SELECT * FROM article
WHERE deleted = false;
ORDER BY id DESC
LIMIT 10;

다음 10개를 읽을때는 앞서 읽어온 마지막 id를 사용해서 조회하면된다.

SELECT * FROM article
WHERE id < 9985 AND deleted = false;
ORDER BY id DESC
LIMIT 10;

id는 인덱스이므로 바로 9984 찾는다. 오프셋을 썻을때의 데이터 세는 시간이 생략되는 것!

프론트 개발자가 다음에 읽어올 데이터 존재하는지 알려주는 속성을 응답결과에 포함시켜달라하면 1개만더 읽어 판단하면 된다. 예를 들어 10개를 주면될때는 11개로 읽고 11개면 다음에 읽을게 존재하는것이다. 이건 비즈니스로직으로 판단하면되겠다.

SELECT * FROM article
WHERE id < 1001 AND deleted = false
ORDER BY id DESC
LIMIT 11;

조회 범위를 시간 기준으로 제한하기

조회 성능 개선 방법중 하나는 조회 범위를 시간 기준으로 제한하는 것.

최신 데이터 위조로 조회하게 기능을 변경하면 DB성능 또한 향상된다. DB는 성능향상을 위해 메모리 캐시를 쓴다. 조회가 발생하면 이를 메모리에 캐시해 다음 동일 요청시 더 빨리 응답한다. 일반적으로 최신 데이터가 더 많이 조회되기에 캐시에 최신 데이터가 적재될 확률이 높다.

전체 개수 세지 않기

목록을 표시하는 기능은 전체 개수를 함께 표시하는 경우가 많다. 조건에 해당하는 데이터 개수를 구하기 위해서는 count 함수를 써야된다. 예를 들어, 이름에 특정 단어가 포함된 회원 목록 조회하려면 다음 두 쿼리 써야된다.

SELECT id, ...
FROM member
WHERE name LIKE '지은%'
ORDER BY id DESC
LIMIT 20;
 
SELECT COUNT(*)
FROM member
WHERE name LIKE '지은%';

데이터 적을땐 노상관이지만 많아지면 조건에 해당하는 모든 데이터 탐색해야된서 느려진다. 커버링 인덱스를 써도 전체 인덱스를 스캔해야되며, 커버링 인덱스가 아닌경우에는 실제 데이터를 전부 읽어야된다.

해결방법은? 바로 소통!

오래된 데이터 삭제 및 분리 보관하기

과거 데이터를 삭제할 수 있는 예로 로그인 시도 내역을 들 수 있다. 새로운 지역에서 로그인 시도 발생 탐지같은걸 개발하려면 로그인 시도 내역을 일정 기간 보관해야한다.

로그인 시도 내역같은건 장기간 보관이 필요없다. 이상 징후 탐지를 위한거라 최근 몇달치만 있어도 충분.

별도 캐시 서버 구성하기

캐시 도입시 코드를 수정해야하지만 코드 수정에 드는 비용대비 캐시로 증가시킬 수 있는 처리량이 크다면, 코드를 수정하는것이 더 합리적이다.