이미 백엔드 작업을 얼추 마쳤지만, 하면서 기억에 남았던 내용이나 앞으로 발생할 문제에 대해 기록해보려 한다.
처음 작성할 내용은 트랜잭션에 관한 내용이다.
현재 진행하고 있는 프로젝트에서 BULK로 INSERT작업을 해야하는 경우가 여러 곳에 있다. 반복문을 돌면서 여러번 호출하는 것은 여러모로 비효율적이라고 생각하기 때문에 해당 방법을 채택했는데..
만약 8개를 INSERT하다가 3개만 성공하고 4번째에서 오류가 난다면 어떻게 되는거지?
이럴 때 필요한 것이 트랜잭션 이다!
트랜잭션이란?
여러 개의 데이터베이스 작업을 하나로 묶어서 처리하는 단위이다. 트랜잭션은 원자성, 일관성, 격리성, 영속성 이라는 네 가지 성질을 보장해야한다.
네 가지 속성 중 '원자성'이 포인트다. 트랜잭션 안의 모든 작업이 성공하거나, 아니면 하나라도 실패하면 전부 롤백해서 시작 전 상태로 되돌리는 성질이다.
즉, 트랜잭션을 이용하면 INSERT 도중 에러가 발생할 경우 아예 아무것도 INSERT하지 않은 시점으로 롤백 된다는 것이다!
다음은 프로젝트의 service 파일 코드 일부이다.
const createDetails = async (details) => {
const connection = await pool.getConnection();
const sql = SQL_INSERT_DETAILS;
const values = [];
details.map((item) => values.push(Object.values(item)));
try {
const [results] = await connection.query(sql, [values]); // BULK INSERT
return results;
} catch (error) {
throw error;
} finally {
connection.release();
}
};
이걸 트랜잭션을 이용한 코드로 수정해보려 한다.
const createDetails = async (details) => {
const connection = await pool.getConnection();
const sql = SQL_INSERT_DETAILS;
const values = [];
details.map((item) => values.push(Object.values(item)));
try {
await connection.beginTransaction(); // 트랜잭션으로 작업을 시작
const [results] = await connection.query(sql, [values]); // BULK INSERT
// 오류가 나지 않더라도, 삽입된 row의 수가 배열의 길이와 다르면 안됨
if(results.affectedRows !== details.length) {
throw new Error("일부 삽입 실패");
}
await connection.commit(); // 작업사항 반영
return results;
} catch (error) {
await connection.rollback(); // 에러발생 시 rollback
throw error;
} finally {
connection.release();
}
};
중간에 에러가 발생하면 throw절로 넘어가면서 rollback을 실행한다. 👍
아까 트랜잭션의 설명을 다시 보면 여러 개의 작업을 하나로 묶어서 라고 되어 있다.
물론 BULK INSERT도 여러개의 INSERT를 하나로 묶은 것이지만, 저렇게 보면 꼭 하나의 작업을 수행하는 것 같지 않은가?
두 개의 테이블에 한번에 INSERT해야 하는 경우에 대해 코드를 작성해보겠다. 위에서 작성한 코드를 일부 가져왔다. 기존 배열로 INSERT했던 것을 한 건의 INSERT라고 가정하겠다.
const createDetails = async (details) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
let sql = SQL_INSERT_A;
let values = [];
await connection.query(sql, values);
sql = SQL_INSERT_B;
values = [];
await connection.query(sql, values);
await connection.commit();
return results;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
이런식으로 작성하면 물론 제대로 동작한다.
그러나 조금 마음에 걸렸던 부분이 있다면, API 분리가 안된다는 것이다..
나는 users와 maps API에 대해 router, controller, service를 모두 분리했는데 userService에서 map에 대한 쿼리를 실행하는 등 서로 다른 API의 영역을 침범하고 싶지 않았다.
service는 지켜주고, controller에서 다른 service단을 호출하려 했더니, 이번에는 connection이 문제였다. pool.getConnection -> 작업 수행 -> connection.release() 이 세 단계를 거쳐야 하는데, 동일한 connection이 아니면 트랜잭션은 성립하지 않는다.
트랜잭션 작업을 처리하는 별도의 함수를 만들어서 해결하기로 결정했다.
const runInTransaction = async (callback) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await callback(connection); // callback으로 넘어오는 작업을 수행
await connection.commit(); // 문제가 없다면 commit
} catch (error) {
await connection.rollback(); // 에러 발생 시 rollback
throw error;
} finally {
connection.release();
}
}
// controller에서 사용
await runInTransaction(async (connection) => {
await scheduleService.deleteDetails(mapId, connection);
await scheduleService.createDetails(details, connection);
});
이렇게 하면 runInTransaction이 실행되면서 connection을 받아 service단으로 넘겨주면서 같은 connection을 공유하게 된다. 작업 수행 중 에러가 발생하면 runInTransaction의 catch문으로 넘어갔다가 throw을 타고 단계별로 넘어오게 되는 것이다.
정보처리기사를 공부하면서 트랜잭션에 대해서는 알고 있었지만 실제로 써본 것은 처음이다!
지금까지는 너무 생각없이 service단을 여러개 호출했는데, 앞으로는 데이터의 일관성을 조금 더 지킬 수 있게 되어 뿌듯하다. 👍