🎯 문제
쪽지함 기능 개발 후 쪽지 목록을 조회 할 때 데이터가 적을 땐 문제를 느끼지 못 했는데, 10만건 이상 데이터 삽입 후 테스트 했을 때 조회 속도가 느려졌다. 조회 속도를 개선해보자!! 😊
전체 조회 개선, 검색 개선 -> 페이징 개선 으로 진행함.
급하면 결론만 보기!!
📌 화면 (보낸 쪽지 목록)
✔ 페이지 진입 시 최신순으로 조회.
✔ 검색(필터)조건에 따라 조회.
✔ 페이지 이동 시 한 페이지 개수 설정만큼 가져옴.
📌 속도 측정 방법
✅ 애플리케이션 개발자 도구 네트워크 응답을 측정
조회 개수, 검색 조건을 설정할 수 있는데, 조회 개수 10개 고정하고 전체 조회와 수신자(받은 사람) 검색 시간을 측정했다.
- 내가 보낸 쪽지 10만건 + 다른 쪽지 x
- 내가 보낸 쪽지 20만건 + 다른 쪽지 10만건
✔ 참고
- 다른 쪽지: 다른 유저가 보낸 쪽지로 조회화면에 나오지 않는 데이터 (발신자 ID로 조회)
- 위 조건에서 개수는 정확히 10만건 x, 100개 미만의 데이터가 더 있었지만 무시했다!! (테스트를 혼자만 할 수 있는 환경이 아니라서..)
- 속도는 2가지 경우에서 측정하지 못 하거나 캡쳐를 못 한 것도 있다. 😜
✅ app에 반영 전 DBeaver 툴에서 쿼리 속도 비교
SET profiling = 1;
SHOW PROFILES;
애플리케이션에서 페이지 로드, 검색 시
조건에 맞는 데이터 개수 + 실제 데이터 가져오는 쿼리 2개가 실행되는데
디피 툴에서 쿼리 2개 각각 실행시간 측정해서 더한 것 = 개발자 도구 응답 시간 얼추 일치했다.
- 측정 예시 (데이터: 보낸 쪽지 20만건 + 다른 쪽지 10만건)
✅ 1. Exists 사용
✅ 1-1. 수신자 검색 개수 - 3.3s
SELECT COUNT(DISTINCT n.note_id)
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
WHERE n.regt_id = 'user1'
AND EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE '%용%'
)
✅ 1.2 수신자 검색 - 7.13s
SELECT
n.note_id, n.title, n.content, n.regt_id, n.regt_dt
, ntm.trans_id, ntm.trans_type, ntm.read_yn, COUNT(nam.attach_id) as attach_cnt
, GROUP_CONCAT(DISTINCT u2.user_nm_kr) AS recv_nms
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_attach_mgmt nam ON n.note_id = nam.attach_id
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2 ON ntm2.user_id = u2.user_id
WHERE n.regt_id = 'user1'
AND EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE '%용%'
)
GROUP BY n.note_id
ORDER BY n.note_id desc
✅ 2. Exists 미사용
✅ 2-1. 수신자 검색 - 5.1s
SELECT COUNT(DISTINCT n.note_id)
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2 ON ntm2.user_id = u2.user_id
WHERE n.regt_id = 'user1'
AND u2.user_nm_kr LIKE '%용%'
✅ 2.2 수신자 검색 - 9.2s
SELECT
n.note_id, n.title, n.content, n.regt_id, n.regt_dt
, ntm.trans_id, ntm.trans_type, ntm.read_yn, COUNT(nam.attach_id) as attach_cnt
, GROUP_CONCAT(DISTINCT u2.user_nm_kr) AS recv_nms
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_attach_mgmt nam ON n.note_id = nam.attach_id
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2 ON ntm2.user_id = u2.user_id
WHERE n.regt_id = 'user1'
AND u2.user_nm_kr LIKE '%용%'
GROUP BY n.note_id
ORDER BY n.note_id desc
📌 조회 속도 개선 과정
✅1. 초기 쿼리 (GROUP_CONCAT, LEFT JOIN, 서브쿼리 사용)
🔹 조회 속도
- (보낸 쪽지 10만건 + 다른 쪽지 x) 전체: 17.71s
- (보낸 쪽지 20만건 + 다른 쪽지 10만건) 전체**: 38.83s**
🔹 문제점
GROUP_CONCAT, LEFT JOIN과 서브쿼리 다중 사용으로 인해 데이터가 많아질수록 선형적으로 성능 저하 발생
🔹 초기 쿼리
<!-- 보낸 쪽지 개수 -->
<select id="selectNoteByRegtIdTotalCnt">
SELECT COUNT(DISTINCT n.note_id)
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id
LEFT JOIN (
SELECT
n2.note_id,
GROUP_CONCAT(ntm2.user_id) AS recv_ids,
GROUP_CONCAT(u2.user_nm_kr) AS recv_nms
FROM
notes n2
LEFT JOIN
note_trans_mgmt ntm2 ON ntm2.note_id = n2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2
ON ntm2.user_id = u2.user_id
GROUP BY
n2.note_id
) AS recvs ON n.note_id = recvs.note_id
WHERE n.regt_id = #{regtId} AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
<if test="keyword != null and keyword != ''">
<choose>
<when test="searchOption == 'recv'">
AND LOWER(recvs.recv_nms) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<when test="searchOption == 'titl'">
AND LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<when test="searchOption == 'cont'">
AND LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<otherwise>
AND (
LOWER(recvs.recv_nms) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
)
</otherwise>
</choose>
</if>
</select>
<!-- 보낸 쪽지 -->
<select id="selectNoteListByRegtId">
SELECT
n.note_id, n.title, n.content, n.regt_id, n.regt_dt
, tu.user_nm_kr as userNm, ntm.trans_id, ntm.trans_type, ntm.read_yn
, COUNT(nam.attach_id) as attach_cnt
, recvs.recv_ids, recvs.recv_nms
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id
LEFT JOIN note_attach_mgmt nam ON n.note_id = nam.attach_id
LEFT JOIN users tu ON n.regt_id = tu.user_id
LEFT JOIN (
SELECT
n2.note_id,
GROUP_CONCAT(ntm2.user_id) AS recv_ids,
GROUP_CONCAT(u2.user_nm_kr) AS recv_nms
FROM
notes n2
LEFT JOIN
note_trans_mgmt ntm2 ON ntm2.note_id = n2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2
ON ntm2.user_id = u2.user_id
WHERE n2.regt_id = #{regtId}
GROUP BY
n2.note_id
) AS recvs ON n.note_id = recvs.note_id
WHERE n.regt_id = #{regtId} AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
<if test="keyword != null and keyword != ''">
<choose>
<when test="searchOption == 'recv'">
AND LOWER(recvs.recv_nms) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<when test="searchOption == 'titl'">
AND LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<when test="searchOption == 'cont'">
AND LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<otherwise>
AND (
LOWER(recvs.recv_nms) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
)
</otherwise>
</choose>
</if>
GROUP BY n.note_id
ORDER BY n.note_id DESC
LIMIT #{pageSize} OFFSET #{startIndex}
</select>
✅2. 서브쿼리 제거
🔹 개선
- 서브쿼리 (recvs) 를 제거하고, tbn_noterecvmgmt 을 메인 쿼리에 직접 JOIN 하는 방식으로 변경. -> 불필요한 임시 테이블 생성 제거
- 데이터 필터링이 먼저 실행됨 (WHERE n.regt_id = ?)
- JOIN 후 바로 GROUP_CONCAT 적용 → 불필요한 중복 연산 감소
🔹 수정된 쿼리
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2 ON ntm2.user_id = u2.user_id
//…WHERE
AND LOWER(u2.user_nm_kr) LIKE CONCAT('%', LOWER(#{keyword}), '%')
🔹 조회 속도
- (보낸 쪽지 10만건 + 다른 쪽지 x) 전체: 3.06s, 수신자 검색: 15.93s
- (보낸 쪽지 20만건 + 다른 쪽지 10만건) 전체: 9.76s, 수신자 검색: 25.93s
위부터 전체 조회, 전체 검색, 제목 검색, 내용 검색, 받는 사람 검색
🔹 문제점
- 서브쿼리를 제거하면서 조회 성능이 크게 향상됨
- 하지만 여전히 받는 사람(recv) 검색은 LIKE로 인해 Full Table Scan 발생 가능, 데이터 늘어나면 조회 속도 배로 증가
⭐ 전체 검색이 받는 사람 검색보다 빠르다?
➡️ OR 조건 때문!!
- 제목(title)이나 내용(content)에서 검색어를 찾으면, 뒤에 실행하지 않고 TRUE가 되어 결과에 포함됨
- 옵티마이저가 자동으로 순서 최적화 해준다고 하는데, 혹시 몰라서 오래걸리는 u2.user_nm_kr조건을 뒤쪽에 배치했다.
✅ 3. EXISTS 사용 (u2 조인제거)
🔹개선
- 기존 JOIN에서 LIKE를 사용하면 JOIN 이후 전체 데이터에서 문자열 검색을 수행해야 하므로 비효율적
- EXISTS를 사용하면 필요한 레코드가 존재하는지만 확인하여 불필요한 데이터 조회 방지
- JOIN을 사용하면 전체 u2.user_nm_kr 테이블을 스캔한 후 LIKE를 적용해야 하지만, EXISTS는 매칭되는 데이터가 있으면 바로 종료되므로 불필요한 데이터 스캔을 줄일 수 있음.
🔹 수정된 쿼리
AND EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE CONCAT('%', #{keyword}, '%')
)
🔹 조회 속도
(보낸 쪽지 20만건 + 다른 쪽지 10만건) 전체: 3.03s, 수신자 검색: 13.47s
✅4. 최종: Where조건 위치 수정, lower함수 제거, 불필요 컬럼 제거(userNm)
🔹 개선
- WHERE 조건을 Join-on으로 옮김 → 필터링을 먼저 수행하고, 이후 연산을 진행
- LOWER() 함수 제거 → 불필요 연산 제거(한국어만 들어가는 컬럼이다!!)
- 불필요한 컬럼(userNm - 보낸사람이름) 제거 → 쿼리 처리량 감소
🔹수정된 쿼리
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
🔹 조회 속도
(20만건+다른 유저10만건 기준) 전체 조회: 3.17s, 보낸 사람 검색: 11.09s
⭐ 참고: 미적용된 방법
✅ 인덱스 추가
인덱스는 user_nm_kr에 추가하고 %text% -> text%로 바꿔 보았지만 사용되지x, force index 사용시 오히려 성능 감소. → 인덱스를 강제할 경우 범위 검색(range scan)이 발생하면서 불필요한 검색이 증가할 수 있음
like 검색 편의성을 위해 제외함.
기존
1 SIMPLE u2 ref PRIMARY,idx_user_name PRIMARY 162 skku.ntm2.user_id 1 Using where
LEFT JOIN users u2 FORCE INDEX (idx_user_name)ON ntm2.user_id = u2.user_id 적용 시
1 SIMPLE u2 range idx_user_name idx_user_name 323 1463 Using where; Using index; Using join buffer (flat, BNL join)
✅ VARCHAR → CHAR
한국 사람 이름 글자 수 제한 정책 없어서 반영하지 않음.
특징 CHAR(n) VARCHAR(n)
길이 | 고정 길이 (n 바이트) | 가변 길이 (데이터 길이 + 1~2바이트) |
공백 처리 | 짧으면 공백( )으로 패딩됨 | 저장 시 공백 제거됨 |
속도 | 고정 길이라 정렬 및 검색이 빠를 수 있음 | 길이 확인 과정이 있어 약간의 오버헤드 발생 가능 |
공간 효율성 | 길이가 짧을수록 공간 낭비 발생 가능 | 필요한 만큼만 저장하여 공간 효율적 |
적합한 경우 | 길이가 일정한 데이터 (예: 국가 코드, 성별 등) | 길이가 다양한 문자열 저장 시 유리 |
✅ FULLTEXT,GRAM TABLE
FULLTEXT,GRAM TABLE 등은 한 글자, %text% 검색이 필요해서 반영x.
✅ @Transactional(readOnly = true)
속도 측정에서 차이x.
// 보낸 쪽지 목록 전체 카운트
@Transactional(readOnly = true)
public int getSendNotesTotalCnt(Criteria cri) {
return noteMapper.selectNoteByRegtIdTotalCnt(cri);
}
// 보낸 쪽지 목록 리스트 (페이징)
@Transactional(readOnly = true)
public List<NoteResponseDTO> getSendNotes(Criteria cri) {
return noteMapper.selectNoteListByRegtId(cri);
}
✅ 비동기 처리
실시간 최신성이 중요한 서비스 특성을 고려해 제외.
🚀 결론
🔹최종 쿼리
✔ 서브쿼리 제거 → JOIN을 이용
✔ EXISTS 사용 → 무조건 더 빠르지 x, 쿼리 속도를 비교해보고 적용할 것!!
✔ 불필요 함수, 컬럼 제거
<!-- 보낸 쪽지 개수 -->
<select id="selectNoteByRegtIdTotalCnt">
SELECT COUNT(DISTINCT n.note_id)
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
WHERE n.regt_id = #{regtId}
<if test="keyword != null and keyword != ''">
<choose>
<when test="searchOption == 'recv'">
AND EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE CONCAT('%', #{keyword}, '%')
)
</when>
<when test="searchOption == 'titl'">
AND LOWER(n.title) LIKE CONCAT('%', #{keyword}, '%')
</when>
<when test="searchOption == 'cont'">
AND LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<otherwise>
AND (
LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE CONCAT('%', #{keyword}, '%')
)
)
</otherwise>
</choose>
</if>
</select>
<!-- 보낸 쪽지 -->
<select id="selectNoteListByRegtId">
SELECT
n.note_id, n.title, n.content, n.regt_id, n.regt_dt
, ntm.trans_id, ntm.trans_type, ntm.read_yn, COUNT(nam.attach_id) as attach_cnt
, GROUP_CONCAT(DISTINCT u2.user_nm_kr) AS recv_nms
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_attach_mgmt nam ON n.note_id = nam.attach_id
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2 ON ntm2.user_id = u2.user_id
WHERE n.regt_id = #{regtId}
<if test="keyword != null and keyword != ''">
<choose>
<when test="searchOption == 'recv'">
AND EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE CONCAT('%', #{keyword}, '%')
)
</when>
<when test="searchOption == 'titl'">
AND LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<when test="searchOption == 'cont'">
AND LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<otherwise>
AND (
LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE CONCAT('%', #{keyword}, '%')
)
)
</otherwise>
</choose>
</if>
GROUP BY n.note_id
ORDER BY n.note_id DESC
LIMIT #{pageSize} OFFSET #{startIndex}
</select>
📌 페이징 속도 개선
🔹 문제점
첫 페이지 조회 속도는 문제가 없었지만, 뒷 페이지로 이동 시 오래 걸림
✅ LIMIT과 OFFSET 작동 방식
LIMIT #{pageSize} OFFSET #{startIndex}
✔ LIMIT : 가져올 행(row)의 개수를 지정
✔ OFFSET : 건너뛸 행(row)의 개수를 지정
🔥 왜 느려질까?
1️⃣ DB는 OFFSET만큼의 데이터를 먼저 읽고 버려야 함
2️⃣ OFFSET이 클수록 버리는 데이터가 많아지고, 디스크 I/O 증가
즉, OFFSET은 앞의 데이터를 다 읽고 건너뛴 후 결과를 반환하므로 쿼리 성능이 저하된다. 😢
🔹 개선
- OFFSET을 제거
✅ ID 기반 페이징
SELECT * FROM 테이블
WHERE note_id < #{마지막_note_id}
ORDER BY note_id DESC
LIMIT 10;
✔ 이전 페이지의 마지막 ID(note_id)를 기준으로 다음 데이터를 가져오기 때문에 속도가 빠름!
✔ INDEX를 활용하므로 OFFSET보다 훨씬 효율적!
⭐ 참고: 미적용 방법
✅ 서브쿼리로 OFFSET 최적화
SELECT * FROM 테이블
WHERE note_id IN (
SELECT note_id FROM 테이블 ORDER BY note_id DESC LIMIT 10 OFFSET 1000
);
✔ OFFSET이 큰 경우, 먼저 ID만 찾고 최적화할 수 있음
✅ 페이징을 캐싱하기
✔ 같은 쿼리가 반복되면 Redis 같은 캐시 활용
🚀 결론
✔ OFFSET대신 마지막 조회 데이터 ID를 기준으로 조회
✔ 프론트엔드에서 요소에 데이터 ID를 가지고 있다가 보내주는 처리 필요함!!
🔹 조회 속도
첫 페이지 조회 속도, 마지막 페이지 조회 속도
🔹최종 쿼리
<select id="selectNoteListByRegtId">
SELECT
n.note_id, n.title, n.content, n.regt_id, n.regt_dt
, ntm.trans_id, ntm.trans_type, ntm.read_yn, COUNT(nam.attach_id) as attach_cnt
, GROUP_CONCAT(DISTINCT u2.user_nm_kr) AS recv_nms
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_attach_mgmt nam ON n.note_id = nam.attach_id
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2 ON ntm2.user_id = u2.user_id
WHERE n.regt_id = #{regtId}
<if test="lastSno != 0">
AND n.note_id <![CDATA[<]]> #{lastSno}
</if>
<if test="keyword != null and keyword != ''">
<choose>
<when test="searchOption == 'recv'">
AND EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE CONCAT('%', #{keyword}, '%')
)
</when>
<when test="searchOption == 'titl'">
AND LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<when test="searchOption == 'cont'">
AND LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
</when>
<otherwise>
AND (
LOWER(n.title) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR LOWER(n.content) LIKE CONCAT('%', LOWER(#{keyword}), '%')
OR EXISTS (
SELECT 1 FROM users u_sub
WHERE u_sub.user_id = ntm2.user_id
AND u_sub.user_nm_kr LIKE CONCAT('%', #{keyword}, '%')
)
)
</otherwise>
</choose>
</if>
GROUP BY n.note_id
ORDER BY n.note_id DESC
LIMIT #{pageSize}
</select>
- 속도 비교
# offset 이용 - 5.694s
SELECT
n.note_id, n.title, n.content, n.regt_id, n.regt_dt
, ntm.trans_id, ntm.trans_type, ntm.read_yn, COUNT(nam.attach_id) as attach_cnt
, GROUP_CONCAT(DISTINCT u2.user_nm_kr) AS recv_nms
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_attach_mgmt nam ON n.note_id = nam.attach_id
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2 ON ntm2.user_id = u2.user_id
WHERE n.regt_id = 'user1'
GROUP BY n.note_id
ORDER BY n.note_id desc
limit 10 offset 100000
# ID기반 조회 - 0.479s
SELECT
n.note_id, n.title, n.content, n.regt_id, n.regt_dt
, ntm.trans_id, ntm.trans_type, ntm.read_yn, COUNT(nam.attach_id) as attach_cnt
, GROUP_CONCAT(DISTINCT u2.user_nm_kr) AS recv_nms
FROM notes n
LEFT JOIN note_trans_mgmt ntm ON n.note_id = ntm.note_id AND ntm.trans_type = "1" AND ntm.use_yn = "Y"
LEFT JOIN note_attach_mgmt nam ON n.note_id = nam.attach_id
LEFT JOIN note_trans_mgmt ntm2 ON n.note_id = ntm2.note_id AND ntm2.trans_type = '2'
LEFT JOIN users u2 ON ntm2.user_id = u2.user_id
WHERE n.regt_id = 'user1' and n.note_id < '291276'
GROUP BY n.note_id
ORDER BY n.note_id desc
limit 10