데이터베이스 트랜잭션 누수
I’m happy to translate the article for you, but I’ll need the full text of the post (the content you’d like translated). Could you please paste the article’s body here? Once I have the text, I’ll provide a Korean translation while preserving the source link, markdown formatting, and any code blocks or URLs unchanged.
소개
우리는 메모리 누수에 대해 자주 이야기하지만, 백엔드 개발에서 또 다른 조용한 성능 저해 요인이 있습니다: 데이터베이스 트랜잭션 누수.
최근에 레거시 코드베이스를 디버깅하면서, 특정 모듈의 테스트는 고립된 상태에서는 완벽히 통과했지만 전체 스위트의 일부로 실행될 때 일관되게 실패한다는 것을 발견했습니다. 범인은? 처음에는 일시적인 현상이라고 무시했던 **“Database connection timeout”**였습니다. 여기서는 우리 코드가 데이터베이스 연결을 “누수”하고 있었음을 어떻게 발견했는지, 그리고 이를 어떻게 해결했는지 설명합니다.
증상: “외톨이” 테스트
단독으로 실행했을 때는 User 모듈 테스트가 모두 통과했습니다. 하지만 다른 50개의 테스트와 함께 실행하면 갑자기 타임아웃이 발생했습니다.
데이터베이스가 실제로 느린 것이 아니라, 자원이 고갈된 것이었습니다. 이전 테스트들이 트랜잭션을 열어두고 닫지 않아 풀에서 연결을 계속 점유했으며, 그 결과 후속 테스트를 위한 연결이 남지 않았습니다.
범인 #1: 조기 반환 함정
우리 레거시 컨트롤러에서는 많은 함수가 수동 트랜잭션 관리에 의존했습니다. 여러 경우에 조기 반환이 발생했으며, 개발자가 함수 종료 전에 트랜잭션을 닫는 것을 잊었습니다.
버그 코드
const t = await startTransaction();
try {
if (someCondition) {
// Early return! The transaction 't' stays open forever
// until the database or server kills the process.
return { status: 400, message: "Invalid Request" };
}
await t.commit();
} catch (e) {
await t.rollback();
}
범인 #2: 공유 트랜잭션 소유권
두 번째 문제는 더 미묘했습니다: 중첩 트랜잭션 자살. 부모 함수가 트랜잭션을 생성하고 이를 자식 함수에 전달했으며, 자식 함수가 트랜잭션을 직접 커밋하거나 롤백했습니다. 제어가 부모에게 돌아왔을 때, 부모는 이미 종료된 트랜잭션을 다시 커밋하려고 시도했습니다.
버그 코드
async function childFunction(t) {
try {
const data = await db.create({}, { transaction: t });
await t.commit(); // Child closes the transaction
return data;
} catch (e) {
await t.rollback();
throw e;
}
}
async function parentScope() {
const t = await startTransaction();
try {
const data = await childFunction(t);
await t.commit(); // Error! The transaction is already finished.
return data;
} catch (e) {
await t.rollback();
}
}
왜 이것이 프로덕션을 중단시키지 않았을까?
당신은 이렇게 궁금할 수도 있습니다: 연결이 새고 있었다면, 왜 프로덕션 서버가 매시간마다 크래시되지 않았을까?
답은 PM2였습니다. 우리 프로덕션 환경은 Node.js 프로세스를 관리하기 위해 PM2를 사용하고 있었습니다. 연결 풀이 고갈되고 애플리케이션이 멈추거나 크래시되기 시작하면, PM2가 자동으로 인스턴스를 재시작했습니다. 이 과정에서 “새진” 연결들이 정리되어 일시적인(그리고 위험한) 임시 방편이 되었습니다. 사용자 입장에서는 단지 “앱이 가끔 느려진다”는 느낌만 받았을 뿐입니다.
해결책: 적절한 트랜잭션 관리
1. 명시적 라이프사이클 관리
모든 가능한 코드 경로(특히 조기 반환)가 트랜잭션을 처리하도록 항상 보장하십시오.
const t = await startTransaction();
try {
if (someCondition) {
await t.rollback(); // Always clean up before returning!
return { status: 400 };
}
await t.commit();
} catch (e) {
await t.rollback();
}
2. “단일 소유자” 원칙
좋은 경험법칙: 트랜잭션을 생성하는 함수가 이를 종료해야 합니다. 트랜잭션을 자식 함수에 전달하면, 자식은 이를 사용하지만 커밋이나 롤백을 직접 수행해서는 안 됩니다.
async function childFunction(t) {
// Use the transaction 't', but don't commit/rollback here
return await db.create({}, { transaction: t });
}
async function parentScope() {
const t = await startTransaction();
try {
await childFunction(t);
await t.commit(); // Only the creator manages the lifecycle
} catch (e) {
await t.rollback();
}
}
결론
이러한 트랜잭션 누수를 수정함으로써, 우리의 테스트 스위트는 불안정하고 느리던 상태에서 안정적이고 빠르게 변했습니다. 개별적으로는 테스트가 통과하지만 그룹으로 실행하면 실패한다면, “Connection Timeout” 오류를 무시하지 마세요—데이터베이스 로직에 누수가 있을 수 있습니다.