[Sequelize] `ConnectionAcquireTimeoutError` 해결: 트랜잭션과 연결 풀 관리 팁
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. 해결책: findOrCreate
와 increment
패턴 활용
ConnectionAcquireTimeoutError
를 해결하고 데이터 업데이트를 더 안전하게 처리하기 위한 가장 좋은 방법은 findOrCreate
와 increment
패턴을 활용하고, 필요한 경우 트랜잭션을 효율적으로 관리하는 것이다.
// 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
: 연결을 기다리는 최대 시간이다. 이 값을 늘리는 것은 임시방편일 수 있으므로, 근본적인 문제(연결 풀 고갈, 느린 쿼리)를 해결하는 것이 우선이다.