Dandy Now!
  • [Sequelize] `ConnectionAcquireTimeoutError` 해결: 트랜잭션과 연결 풀 관리 팁
    2025년 06월 02일 23시 49분 11초에 업로드 된 글입니다.
    작성자: DandyNow
    728x90
    반응형

    Sequelize ConnectionAcquireTimeoutError 해결: 트랜잭션과 연결 풀 관리 팁

    이 블로그 게시물은 Sequelize를 사용할 때 마주칠 수 있는 ConnectionAcquireTimeoutError 문제와 이를 해결하는 효과적인 방법에 대해 설명한다.

    1. ConnectionAcquireTimeoutError는 무엇인가?

    ConnectionAcquireTimeoutError는 Sequelize가 데이터베이스 연결 풀에서 새로운 연결을 확보하는 데 정해진 시간(타임아웃) 내에 실패했을 때 발생하는 오류이다. 이는 일반적으로 다음과 같은 상황에서 발생한다.

    • 연결 풀 고갈: 데이터베이스에 동시에 너무 많은 쿼리가 실행되어 모든 연결이 사용 중이거나, 사용된 연결이 제때 반환되지 않는 경우.
    • 느린 데이터베이스 응답: 데이터베이스 서버 자체가 과부하 상태이거나, 실행 중인 쿼리가 너무 오래 걸리는 경우.
    • 트랜잭션 누수: 트랜잭션이 시작되었지만, 오류 등으로 인해 commit() 또는 rollback()이 호출되지 않아 연결이 풀로 반환되지 않는 경우.

    이 오류는 애플리케이션의 성능 저하 및 안정성 문제를 야기할 수 있다.

    2. 문제 상황 예시: 비효율적인 upsert() 사용

    다음은 ConnectionAcquireTimeoutError를 유발할 수 있는 비효율적인 코드 예시이다.

    // models 객체와 sequelize 인스턴스가 미리 정의되어 있다고 가정한다.
    const processData = async (items) => {
      try {
        // 모든 항목에 대해 개별적으로 트랜잭션을 시작하고 upsert를 수행한다.
        const results = await Promise.all(
          items.map(async (item) => {
            const transaction = await models.sequelize.transaction(); // 각 item마다 새 트랜잭션
            try {
              // 기존 레코드가 있으면 count를 증가시키고, 없으면 생성한다.
              // 이 upsert 호출이 여러 번 동시에 실행되면 연결 풀에 부담을 준다.
              await models.MyData.upsert(
                {
                  id: item.id,
                  value: item.value,
                  count: item.count,
                },
                {
                  // count를 증가시키려는 의도였지만, upsert의 두 번째 인자는 업데이트될 값이어야 한다.
                  // Sequelize.literal을 직접 사용하는 것은 upsert에 적합하지 않다.
                  count: models.sequelize.literal(`count + ${item.count}`),
                },
                { transaction }
              );
              await transaction.commit();
              return { id: item.id, success: true };
            } catch (error) {
              await transaction.rollback();
              console.error(`Error processing item ${item.id}:`, error);
              return { id: item.id, success: false, error: error.message };
            }
          })
        );
        return results;
      } catch (error) {
        console.error("Overall processing failed:", error);
        throw error;
      }
    };

    위 코드는 Promise.all 내에서 각 item에 대해 개별 트랜잭션을 시작하고 upsert를 호출한다. 만약 items 배열의 크기가 크다면, 동시에 수많은 트랜잭션이 열리고 upsert 쿼리가 실행되어 데이터베이스 연결 풀이 빠르게 고갈될 수 있다. 또한 upsert에서 Sequelize.literal을 사용하여 count를 증가시키려는 시도는 upsert의 본래 목적(새 레코드를 삽입하거나, 기존 레코드를 주어진 값으로 대체)과 맞지 않아 예상치 못한 결과를 초래할 수 있다.

    3. 해결책: findOrCreateincrement 패턴 활용

    ConnectionAcquireTimeoutError를 해결하고 데이터 업데이트를 더 안전하게 처리하기 위한 가장 좋은 방법은 findOrCreateincrement 패턴을 활용하고, 필요한 경우 트랜잭션을 효율적으로 관리하는 것이다.

    // models 객체와 sequelize 인스턴스가 미리 정의되어 있다고 가정한다.
    const processDataOptimized = async (items) => {
      // 모든 개별 작업을 하나의 트랜잭션으로 묶는다.
      const transaction = await models.sequelize.transaction();
      let successCount = 0;
      const failedItems = [];
    
      try {
        await Promise.all(
          items.map(async (item) => {
            try {
              // 1. 해당 레코드가 있는지 찾거나, 없으면 새로 생성한다.
              const [dataEntry, created] = await models.MyData.findOrCreate({
                where: { id: item.id }, // id를 기준으로 찾는다.
                defaults: {
                  value: item.value,
                  count: item.count, // 새로 생성될 때의 초기 count 값
                },
                transaction, // 단일 트랜잭션 사용
              });
    
              // 2. 만약 레코드가 이미 존재했다면 count를 증가시킨다.
              if (!created) {
                await dataEntry.increment('count', { by: item.count, transaction });
                // 필요에 따라 다른 필드도 업데이트할 수 있다.
                await dataEntry.update({ value: item.value }, { transaction });
              }
              successCount++;
            } catch (error) {
              console.error(`Error processing item ${item.id}:`, error);
              failedItems.push({ id: item.id, error: error.message });
            }
          })
        );
        await transaction.commit(); // 모든 개별 작업이 성공적으로 처리되면 커밋
        return {
          status: "success",
          total: items.length,
          succeeded: successCount,
          failed: failedItems.length,
          failedDetails: failedItems,
        };
      } catch (error) {
        await transaction.rollback(); // 트랜잭션 중 예상치 못한 오류 발생 시 롤백
        console.error("Transaction failed during data processing:", error);
        throw error;
      }
    };

    주요 개선점

    • 단일 트랜잭션: 모든 item 처리를 하나의 트랜잭션으로 묶었다. 이는 데이터베이스 연결을 한 번만 할당하고, 모든 작업을 원자적으로 처리하여 데이터 일관성을 유지하는 데 유리하다. 또한, 개별 트랜잭션을 여러 개 생성하는 오버헤드를 줄여 연결 풀의 부담을 완화한다.
    • findOrCreate & increment 조합:
      • findOrCreate: 레코드의 존재 여부를 확인하고, 없으면 생성한다. 이는 upsert보다 의도가 명확하다.
      • increment: 이미 존재하는 레코드의 count 값을 안전하게 증가시킨다. 이 메서드는 데이터베이스 수준에서 원자적으로 작동하므로, 동시성 문제에 강하다.
    • 오류 처리 개선: Promise.all 내부에서 개별 항목의 오류를 처리하고 기록하여, 모든 작업이 실패하더라도 다른 성공적인 작업은 유지되도록 한다. 외부 try-catch는 트랜잭션 시작이나 Promise.all 자체의 치명적인 오류를 잡는 데 사용한다.

    4. 추가적인 해결 팁: Sequelize 연결 풀 설정

    Sequelize 초기화 시 연결 풀(pool) 설정을 최적화하는 것도 중요하다.

    const sequelize = new Sequelize('database', 'username', 'password', {
      host: 'localhost',
      dialect: 'mysql', // 또는 사용하는 DB
      pool: {
        max: 10,    // 동시에 유지할 수 있는 최대 연결 수 (기본값 5)
        min: 0,     // 최소 연결 수
        acquire: 30000, // 연결을 얻기 위한 최대 시간 (밀리초), 기본값 60000 (1분)
        idle: 10000 // 연결이 사용되지 않을 때 유지되는 최대 시간 (밀리초)
      },
      // 로깅 활성화 (개발 단계에서 유용)
      logging: console.log,
    });
    • max: 애플리케이션의 동시 요청 수와 데이터베이스 서버의 성능을 고려하여 적절한 값을 설정한다. 너무 높으면 데이터베이스에 과부하를 줄 수 있다.
    • acquire: 연결을 기다리는 최대 시간이다. 이 값을 늘리는 것은 임시방편일 수 있으므로, 근본적인 문제(연결 풀 고갈, 느린 쿼리)를 해결하는 것이 우선이다.
    728x90
    반응형
    댓글