GitHub

await 지옥에서 빠르게 벗어나기 feat. prisma transaction

hojun lee · 10/29/2024
커버이미지

초기 렌더링 최적화 - 데이터 로딩 성능 개선하기

1s의 로딩을 200ms로 최적화 하는 방법

실제 회사에서 겪은 렌더링 최적화를 각색해서 작성한다. (product)

웹 애플리케이션의 초기 렌더링 화면에서 데이터베이스(DB)에서 다수의 데이터를 불러오는 과정이 동기적으로 이루어지면서 로딩 속도가 느려지는 문제가 발생했다. 초기 데이터를 몽창 불러오는 SSR에 따른 문제로도 볼수 있는데, UX 측면에서 사용자는 페이지 로딩이 지연되면서 불편함을 느끼고, 이는 사용자 경험에 부정적인 영향을 미칠 수 있다.

현재 코드는 다음과 같이 여러 개의 await를 사용하여 DB에서 데이터를 동기적으로 가져오는 구조이다.

tech stack

javascript Framework : svelteKit(SSR) DB : supabase ORM : prisma

(문제의 예시 코드)

// 내부 DB Call
// 0. 이전 고객 방문 확인 / 월간 방문자 수
const monthlyVisitorCount = await getMonthlyVisitorCount()

// 1. 로그인 불일치 목록
const loginConflicts = await getLoginConflictsCount()

// 2. 진행 중, 업로드 중, 처리 중 건수 가져오기
const { inProgressCount, uploadPendingCount, processingCount } = await getSupportStatus()
const upcomingEventList = await upcomingEvents()

const inProgressToCompletedCount = await completedSupportRequests()

// 3. 고객 문의 건
const customerInquiryCount = await getCustomerInquiries()

// 4. 날짜 기준 이전 고객 방문 기록 가져오기
const prevDayVisitLog = await getPrevDayVisitLog()

// 5. 이번 달 기준 고객 방문 기록 가져오기
const thisMonthVisitLog = await getThisMonthVisitLog()

// 외부 API
// 6. SMS 잔여 횟수
const smsCount = await getSMSCont() 

이쁜 코드인데, 무엇이 문제인가?

문제는 느리다는 것이다. async / await가 가장 최신 멋진 트렌디한 깔끔하고 힙한 비동기처리 방식이 아닌가? 왜 느릴까?

잠시 비동기의 역사를 짚어보자.

| 비동기란 무엇인가 https://hololog.dev/post/35

동시성과 병렬성도 알아야한다. 제대로! (동기 vs 비동기 vs 동시 vs 병렬에서 가져온 이미지)

(interleaved Execution) 동시성은 하위 작업을 번갈아 가며 수많은 작업을 처리하는 것(일명 인터리빙)이고, 병렬성은 여러 작업을 동시에 수행하는 것과 같습니다. 예를 들어, 휴대전화를 보다가 국물을 한 숟가락 떠먹기 위해 내려놓았다가 숟가락을 내려놓은 후 다시 휴대전화를 보는 경우, 이는 동시 작업입니다. (parallel Execution) 반대로 한 손으로 밥을 먹으면서 다른 한 손으로 문자를 보낸다면 병렬 작업을 하는 것입니다. 두 경우 모두 멀티태스킹이지만, 멀티태스킹을 처리하는 방식에는 미묘한 차이가 있습니다.

우리는 동시성과 병렬성을 동시에 사용해 초기 렌더링을 최적화해보자


병렬로 다 api call을 해버리면?

promissAll로 모든 함수를 병렬로 때리면 어떨까

   const [
      monthlyVisitorCount,
      loginConflicts,
      { inProgressCount, uploadPendingCount, processingCount },
      upcomingEventList,
      inProgressToCompletedCount,
      customerInquiryCount,
      prevDayVisitLog,
      thisMonthVisitLog
    ] = await Promise.all([
      getMonthlyVisitorCount(),
      getLoginConflictsCount(),
      getSupportStatus(),
      upcomingEvents(),
      completedSupportRequests(),
      getCustomerInquiries(),
      getPrevDayVisitLog(),
      getThisMonthVisitLog()
    ]);

이런식으로 처리하면 짱이다. 똑똑한 것 같다.

하지만 동일한 데이터베이스에 다수의 요청이 동시에 일어나면 서버 과부하가 발생할 수 있다고 함

마트에 100명의 고객이 1개 물건을 살려고 몰려든 행색.

Promise.all을 사용하여 여러 비동기 작업을 병렬로 처리할 때, 동일한 데이터베이스에 대한 다수의 요청이 동시에 발생하면 데이터베이스 서버에 과부하를 일으킬 수 있습니다. 이는 특히 데이터베이스 서버의 처리 능력이 제한된 경우에 문제가 될 수 있습니다.

이건 안됨!

쿼리를 최소화해서 요청하는게 필요해

마트에 물건을 100개 사러가는데 100번 가는 것처럼 불필요한 행동이 또 있을까. 물건 살 것을 총정리해서 한 방에 끝내야 효율적이다.

프로그래밍에서 마찬가지로 DB data를 요청할 때 여러번이 아닌 1번에 끝낼 방법이 있다면 최선이겠지

Promise.all도 아무때나 쓰는게 아님

const { inProgressCount, uploadPendingCount, processingCount } = await getSupportStatus()

상태를 불러오는 함수가 있다. 3가지 상태이기 때문에 3개의 쿼리를 사용했다.

const getStatus = async () => {
    const [ inProgressCount, uploadPendingCount, processingCount ] = await Promise.all([
        prisma.hit.count({ where: { status: 'REVIEWING' } }),
        prisma.hit.count({ where: { status: 'UPLOADING' } }),
        prisma.hit.count({ where: { status: 'PROCESSING' } })
    ])

    return { inProgressCount, uploadPendingCount, processingCount }
}

이 함수는 간단한 쿼리임에도 불구하고 100ms이상의 시간이 소요되었다.

prisma의 gruopBy를 사용하면 어떨까

const getStatusQuery = () => {
    return prisma.hit.groupBy({
        by: ['status'],
        _count: {
            status: true
        },
        where: {
            status: {
                in: [ inProgressCount, uploadPendingCount, processingCount ]
            }
        }
    })
}

후처리가 필요하지만, 함수 실행시간이 30ms로 감소하였다.

적용 나이스

쿼리만 뽑아 $transaction 때려버리면?

일단 await 걸려있는 함수들은 하나 실행하고 전처리 - 쿼리 - 후처리 되어있다. 정확성을 보장하긴 하지만, 속도는 그만큼 느려질 것이다. 하나의 아이디어로는 query들만 따로 뽑아내고 전처리 함수 - prisma.$transaction(queryList) - 후처리 하여 마무리하는 것이다.

prisma.transaction이란

프리즈마(Prisma)에서의 트랜잭션은 여러 데이터베이스 작업을 하나의 묶음으로 처리하는 방법입니다. 이를 통해 데이터의 일관성을 유지하고, 모든 작업이 성공해야만 데이터베이스에 반영되도록 할 수 있습니다. 트랜잭션을 사용하면 다음과 같은 이점이 있습니다:

원자성(Atomicity): 모든 작업이 성공적으로 완료되거나, 하나라도 실패하면 모든 작업이 취소됩니다. 즉, 데이터베이스는 항상 일관된 상태를 유지합니다.

일관성(Consistency): 트랜잭션이 완료된 후, 데이터베이스는 정의된 규칙을 준수해야 합니다. 즉, 데이터의 무결성이 보장됩니다.

격리성(Isolation): 동시에 실행되는 트랜잭션은 서로 간섭하지 않습니다. 각각의 트랜잭션은 독립적으로 실행됩니다.

지속성(Durability): 트랜잭션이 성공적으로 완료되면, 그 결과는 영구적으로 데이터베이스에 저장됩니다.

그럼 함수를 분석하고 쿼리를 뽑아보자

// 쿼리만 있는 함수
const getCustomerInquiriesQuery = () => {
  // 쿼리 로직
};

const getTotalLoanSumQuery = () => {
  // 쿼리 로직
};

// 전처리가 필요한 함수
const checkDateForMonthlyCost = () => {
  // 15일 이후인지 확인하는 로직
  // 결과를 queryList에 담기
};

// 후처리가 필요한 함수
const getApplicationStatusQuery = () => {
  // 쿼리 로직
};

const getCompletedHelocOverviewQuery = () => {
  // 쿼리 로직
};

const getPreviousDayLoanChangeQuery = (date) => {
  // 쿼리 로직
};

const getCurrentMonthLoanChangeQuery = (date) => {
  // 쿼리 로직
};

date가 중복으로 사용되는 전처리가 있어 이를 먼저 계산해서 뽑아내고 const queryList = [ 쿼리들 ] 담아서 트랜잭션을 준비한다.

//load 함수 실행
    const transactionResult = await 
        prisma.$transaction(queryList)


    const [
      visitorCount,
      { _sum: totalVisits },
      loginConflictCountResult,
      visitStatusResult,
      completedVisitSummaryResult,
      previousDayVisitLogResult,
      currentMonthVisitLogResult,
      isMonthlyCostCount = false
    ] = transactionResult;

일단 중점적으로 염두한 사항은 기본 코드 동작에 대해선 크게 바뀌지 않아야 한다는 것이다. 그래서 transactionResult의 값을 디스트럭쳐링해서 받아놓고 후처리를 진행했다.

이후 후처리를 하면 기존코드를 변함없이 사용 할 수 있다.

// api call 이후 후 처리

// 방문자 통계에서 count 값 추출
const visitorConflicts = Number(loginConflictCountResult[0]?.count) || 0; // 0은 기본값
// totalVisits가 없을 경우 기본값 설정
const todayVisitSum = totalVisits || { visit: 0, maxVisits: 0 };

// 방문 상태 건수 가져오기
const { reviewingCount, uploadingCount, processingCount } = getVisitStatus(visitStatusResult);

const reviewToProcessingCount = completedVisitSummary(completedVisitSummaryResult);

// 날짜 기준 방문 변화 가져오기
const previousDayVisitChange = getPreviousDayVisitChange(previousDayVisitLogResult);

// 이번 달 기준 방문 변화 가져오기
const currentMonthVisitChange = getCurrentMonthVisitChange(currentMonthVisitLogResult);

어느정도 최적화가 완료되었다. 속도는 눈에 띄게 빨라졌다.

SMS는 다른 API인데요.

그럼 이제 사용해보자 PromissAll

Promise.all은 여러 개의 프로미스를 동시에 실행하고, 모든 프로미스가 이행되기를 기다린 후 결과를 배열로 반환하는 메서드입니다. 만약 하나라도 거부되는 프로미스가 있다면, 전체가 거부됩니다.

서로 다른 API를 콜할 때의 이점

병렬 처리: 여러 API를 동시에 호출하여 전체 요청 시간을 단축할 수 있습니다. 각 호출이 독립적으로 실행되므로, 하나의 API 호출이 다른 호출을 기다릴 필요가 없습니다.

코드 간결성: 여러 개의 API 호출을 한꺼번에 관리할 수 있어 코드가 더 깔끔해집니다. 모든 호출을 배열로 묶어 처리할 수 있습니다.

결과 집합: 모든 API 호출이 성공적으로 완료되면, 결과를 배열로 받아 필요한 데이터만 쉽게 추출할 수 있습니다.

에러 처리 용이: 모든 API 호출이 실패할 경우, 첫 번째로 실패한 API의 에러만 처리하면 되므로 에러 관리가 단순해집니다.

우리 DB와 외부 API를 호출할 땐 병렬

    // queryList과 smsRemain 병렬 실행 (다른 API 호출)
    const [transactionResult, smsRemain] = await Promise.all([
        prisma.$transaction(queryList),
        remain()
    ])

쉽게 말하면 우리 DB call은 왼손으로 처리하고 SMS API call은 오른손으로 처리하므로서 효율을 극대화 시킨다.

기존 1s 까지 올라갔던 초기 로딩은 200ms언저리로 최적화 될 수 있었다.

결론

하면 된다. 일단 남의 코드를 읽는다는 것 자체가 버거웠고, 다양한 함수와 쿼리가 뒤엉켜있었다. 결국 프로그래머는 코드를 잘 굴러가게하는 유지보수 능력도 중요하다.

이게 되네 싶었는데 이게 됐다. 당신 코드도 이처럼 적용해보라 충분한 고민 후에.