CAP for Java

CAP Java 목록이 느린 진짜 원인은? 왕복 횟수 #shorts #SAP #CAPforJava

이 글이 답하는 질문

  • 목록 화면이 건수가 늘수록 느려지는데, 쿼리는 한 번뿐인 것 같다면 어디를 봐야 하나?
  • 필요한 필드만 고르는 것이 실제로 얼마나 차이를 만드는가?
  • 연관 데이터를 가져올 때 확장과 개별 조회 중 무엇을 골라야 하나?
  • 대량 저장에서 건별 처리와 묶음 처리는 어디서 갈리는가?
  • 느린 원인이 쿼리인지 변환인지 어떻게 가르는가?

느린 목록 화면의 진짜 원인

부품 출고 요청을 조회하는 화면이 있다고 하겠습니다. 처음에는 빨랐는데 데이터가 쌓이면서 응답이 3초를 넘기기 시작합니다. 개발자는 보통 데이터베이스를 먼저 의심합니다. 인덱스를 추가하고 실행 계획을 봅니다. 그런데 계획을 보면 별 문제가 없습니다.

이런 경우 원인은 쿼리 자체가 아니라 쿼리를 부르는 방식이거나 결과를 객체로 바꾸는 비용인 경우가 많습니다. 이 글에서는 실제로 자주 만나는 세 가지 지점을 코드로 짚습니다. 필요한 만큼만 고르기, 연관 데이터를 한 번에 가져오기, 그리고 대량 처리에서 왕복을 줄이기입니다.

사전 가정

  • CAP for Java에서 서비스와 핸들러를 작성해 본 경험이 있다
  • CQN 기반 조회를 한 번이라도 써 봤다
  • 엔티티와 연관 관계를 CDS로 정의할 수 있다

1. 전체 컬럼을 가져오는 습관

가장 흔한 형태입니다. 화면에는 다섯 개 필드만 쓰는데 조회는 전부 가져옵니다.

// 필요 없는 것까지 전부 읽는다
CqnSelect all = Select.from(PART_ISSUE_REQUESTS);
List<PartIssueRequests> rows =
    db.run(all).listOf(PartIssueRequests.class);

출고 요청 테이블에 비고나 첨부 설명처럼 긴 문자열 필드가 있으면 이 차이가 커집니다. 행 수가 같아도 옮기는 바이트가 몇 배 늘고, 그 바이트를 다시 자바 객체로 만드는 비용까지 붙습니다.

// 화면이 쓰는 것만 고른다
CqnSelect slim = Select.from(PART_ISSUE_REQUESTS)
    .columns(r -> r.requestNo(),
             r -> r.plantId(),
             r -> r.partNo(),
             r -> r.quantity(),
             r -> r.status())
    .where(r -> r.status().eq("OPEN"))
    .orderBy(r -> r.requestNo().desc())
    .limit(50);

여기서 함께 볼 것이 정렬과 개수 제한입니다. 목록 화면은 대개 상위 몇 십 건만 보여줍니다. 그런데 제한 없이 전부 읽어 자바에서 잘라내는 코드가 의외로 많습니다. 그러면 데이터베이스는 전체를 준비하고, 네트워크는 전체를 나르고, 애플리케이션은 전체를 객체로 만든 다음 대부분을 버립니다. 세 계층 모두에서 낭비가 납니다.

2. 연관 데이터를 건별로 다시 읽는 구조

목록을 먼저 읽고, 각 행마다 담당자 정보를 다시 읽는 코드입니다.

// 목록 1회 + 행마다 1회 = 왕복이 행 수만큼 늘어난다
for (PartIssueRequests req : rows) {
    CqnSelect one = Select.from(EMPLOYEES)
        .where(e -> e.id().eq(req.getRequesterId()));
    req.put("requesterName",
        db.run(one).single(Employees.class).getName());
}

쿼리 하나하나는 빠릅니다. 그래서 실행 계획을 봐도 문제가 안 보입니다. 문제는 횟수입니다. 50건이면 51번, 500건이면 501번 왕복합니다. 왕복 한 번에 2밀리초만 걸려도 500건이면 1초가 그냥 사라집니다.

연관이 모델에 정의돼 있다면 확장으로 한 번에 가져옵니다.

CqnSelect withEmp = Select.from(PART_ISSUE_REQUESTS)
    .columns(r -> r.requestNo(),
             r -> r.quantity(),
             r -> r.requester().expand(
                    e -> e.id(),
                    e -> e.name()))
    .limit(50);

확장이 항상 정답은 아닙니다. 한 요청에 하위 항목이 수백 건씩 달리는 구조라면 확장은 결과 행을 곱으로 늘립니다. 이럴 때는 부모를 먼저 읽고 자식을 한 번에 묶어 읽는 두 번의 조회가 더 낫습니다.

// 부모 키를 모아서 자식은 한 번에
List<String> ids = rows.stream()
    .map(PartIssueRequests::getId).toList();

CqnSelect items = Select.from(PART_ISSUE_ITEMS)
    .where(i -> i.parent_Id().in(ids));

Map<String, List<PartIssueItems>> byParent =
    db.run(items).listOf(PartIssueItems.class).stream()
      .collect(groupingBy(PartIssueItems::getParentId));

판단 기준은 단순합니다. 부모 대비 자식이 몇 배인가. 자식이 한두 건이면 확장, 수십 건 이상이면 묶음 조회가 유리합니다.

3. 대량 저장을 건별로 도는 구조

조회와 같은 원리가 저장에도 적용됩니다.

// 건수만큼 왕복한다
for (PartIssueRequests req : incoming) {
    db.run(Insert.into(PART_ISSUE_REQUESTS).entry(req));
}
// 한 번에 넘긴다
db.run(Insert.into(PART_ISSUE_REQUESTS).entries(incoming));

천 건을 넣을 때 이 차이는 보통 수 초에서 수십 초입니다. 다만 무한정 크게 묶으면 메모리와 트랜잭션 로그가 문제가 되므로, 실무에서는 500건에서 1000건 단위로 끊어 처리하고 각 묶음의 성공 여부를 따로 기록합니다.

int size = 500;
for (int i = 0; i < incoming.size(); i += size) {
    List<PartIssueRequests> chunk =
        incoming.subList(i, Math.min(i + size, incoming.size()));
    db.run(Insert.into(PART_ISSUE_REQUESTS).entries(chunk));
    log.info("적재 {}/{}", i + chunk.size(), incoming.size());
}

4. 읽기 전용인데 쓰기 트랜잭션으로 도는 경우

조회만 하는 요청이 쓰기 트랜잭션 안에서 돌면 잠금과 로그 비용이 함께 붙습니다. 목록 화면처럼 읽기만 하는 경로는 읽기 전용으로 분리하는 편이 안전합니다.

@Transactional(readOnly = true)
public List<PartIssueRequests> findOpen(String plantId) {
    CqnSelect q = Select.from(PART_ISSUE_REQUESTS)
        .columns(r -> r.requestNo(), r -> r.partNo(), r -> r.quantity())
        .where(r -> r.plantId().eq(plantId)
                     .and(r.status().eq("OPEN")))
        .limit(50);
    return db.run(q).listOf(PartIssueRequests.class);
}

효과가 극적이지는 않지만, 동시 사용자가 많은 화면에서는 잠금 대기가 줄어드는 것만으로 응답 편차가 눈에 띄게 안정됩니다. 무엇보다 읽기 경로에서 실수로 쓰기가 일어나는 것을 구조적으로 막아 줍니다.

5. 페이지를 넘길 때 같은 행이 다시 나오는 이유

목록에 개수 제한만 걸고 정렬을 안 주면, 데이터베이스는 같은 순서를 보장하지 않습니다. 그래서 2페이지에 1페이지에서 본 행이 다시 나오거나 어떤 행은 아예 건너뛰어집니다.

// 정렬 기준이 유일하지 않으면 페이지가 흔들린다
CqnSelect unstable = Select.from(PART_ISSUE_REQUESTS)
    .orderBy(r -> r.status().asc())   // 같은 값이 수백 건
    .limit(50, offset);

// 유일 키를 마지막 정렬 기준으로 붙인다
CqnSelect stable = Select.from(PART_ISSUE_REQUESTS)
    .orderBy(r -> r.status().asc(),
             r -> r.requestNo().desc())  // 중복 없는 키
    .limit(50, offset);

정렬 기준의 마지막에 중복이 없는 키를 하나 붙이는 것만으로 이 문제는 사라집니다. 건너뛴 데이터 때문에 발생한 집계 오류를 며칠 뒤에 발견하는 것보다, 지금 정렬 한 줄을 더 쓰는 편이 낫습니다.

6. 집계를 어디서 할 것인가

공장별 출고 수량 합계처럼 요약 값이 필요한 경우, 데이터를 전부 가져와 자바에서 더하는 코드를 자주 봅니다. 행이 적을 때는 문제가 없지만 늘어나면 그대로 비용이 됩니다.

// 데이터베이스에서 집계해 결과만 받는다
CqnSelect agg = Select.from(PART_ISSUE_REQUESTS)
    .columns(r -> r.plantId(),
             r -> CQL.sum(r.quantity()).as("totalQty"))
    .where(r -> r.status().eq("DONE"))
    .groupBy(r -> r.plantId());

반대로 집계 대상이 이미 메모리에 있고 건수가 적다면, 다시 조회하는 것보다 그대로 계산하는 편이 빠릅니다. 기준은 옮겨야 하는 행 수입니다. 결과가 열 줄인데 십만 행을 옮기고 있다면 위치가 잘못된 것입니다.

직접 해보기

1. 느린 구간을 먼저 가른다

long t0 = System.nanoTime();
Result r = db.run(query);
long t1 = System.nanoTime();
List<PartIssueRequests> list =
    r.listOf(PartIssueRequests.class);
long t2 = System.nanoTime();

log.info("조회 {}ms · 변환 {}ms · 행 {}",
    (t1 - t0) / 1_000_000,
    (t2 - t1) / 1_000_000,
    list.size());

조회가 오래 걸리면 쿼리와 인덱스를 보고, 변환이 오래 걸리면 컬럼 수와 행 수를 줄이는 쪽을 봅니다. 이 한 줄을 안 찍으면 엉뚱한 곳을 붙잡고 며칠을 씁니다.

2. 왕복 횟수를 센다

@Before(event = CqnService.EVENT_READ)
public void countReads(EventContext ctx) {
    readCounter.increment();
}

화면 한 번 여는 데 조회가 몇 번 나가는지 세어 보면, 건별 조회 구조가 바로 드러납니다.

3. 기준 수치를 남긴다

개선 전후를 같은 데이터로 비교해 기록해 둡니다. "빨라졌다"는 체감이 아니라 "1200밀리초에서 180밀리초"라는 수치가 남아야 다음 사람이 다시 느려졌을 때 판단할 수 있습니다.

자주 만나는 함정

  • 개발 데이터는 수백 건인데 운영은 수십만 건 — 건별 조회 구조는 개발기에서 절대 안 드러난다
  • 확장을 겹겹이 쌓아 결과 행이 곱으로 늘어난 경우 — 건수부터 세어 볼 것
  • 정렬 기준이 없는 페이지 처리 — 페이지를 넘길 때 같은 행이 다시 나온다
  • 필요 없는 계산 필드를 목록에서도 계산 — 상세 화면에서만 필요한 값이 아닌지 확인
  • 캐시로 덮어 원인을 가리는 것 — 캐시는 느린 구조를 감출 뿐 없애지 않는다
  • 측정 없이 최적화 — 바꾼 뒤 수치가 없으면 되돌릴 근거도 없다

핵심 한 줄

느린 목록의 원인은 대개 쿼리 한 번의 속도가 아니라 가져오는 양과 왕복 횟수이며, 그 둘은 컬럼 선택·확장과 묶음 조회·일괄 저장으로 줄인다.

더 파볼 주제

  • 페이지 처리에서 안정 정렬을 보장하는 키 설계
  • 집계를 데이터베이스에 맡길 때와 애플리케이션에서 할 때의 기준
  • 읽기 전용 트랜잭션이 성능에 주는 차이
  • 대량 처리에서 실패 건만 재처리하는 구조

댓글 0

아직 댓글이 없습니다.