서두
회사에 처음 입사해서 각 서비스의 버전마다 코드를 확인했는데, 숨이 막혔다.
1.0, 2.0 각각 다른 담당자였는데, 1.0에서는 sql문 그대로 박아서 인젝션 대비도 안되어있고, 그러면서도 sequelize의 모델은 분리되어있었다. 이게 정녕 sequelize를 쓰는게 맞는 것인가? 모델에 각각 컬럼을 세팅해놓고 .. 분리가 된게 맞는지 모르겠다.
2.0은 오히려 퇴화했다. 모델을 분리하지 않고 서비스로직에서 모든 것을 해결하고 있었다.
아니 모든 것을 해결할 것도 없는게, 모델을 분리하지 않는게 그 코드들의 최고 효율로 보이긴 했다. 왜냐하면 전반적인 코드에 에러처리가 없었고, 에러처리가 필요한건 throw가 아니라 리턴 값으로 컨트롤러로직으로 보내고 있었다.
무엇이 문제일까? 일단 가독성이 현저히 떨어진다. 코드를 공개할 수는 없지만, 변수명과 주석이 명확하지 않다면 이게 뭔지 알기 위해 sql을 봐야 하고, 복잡한 로직일 경우 이 sql을 이해하기 위해서 시간이 꽤 소요됐다. sql가 익숙하다고 한들 과연 이게 협업에 있어 효율적일까 싶었다.
중요한 것은 db와 접촉하는 세팅에 관한 부분은 전부 모델에서 세팅하고, db와 관련된 에러처리를 서비스로직에서 진행하려 한다. 이 포스팅에 하지는 않겠지만, 클라이언트에 관한 에러처리는 컨트롤러에서 처리하면 되겠다.
본론
오늘 포스팅하는 내용은 서비스 - 모델 로직을 분리하는 과정이다.
너무 간단한 로직으로는 이해가 떨어질 수 있어 적당한 예시를 들어보겠다.
회원 탈퇴 라고 생각해보자.
User 서비스
export const userWithdrawal = async (user_id, reason) => {
try {
const withDrawalQuery = `
INSERT INTO WithDrawal(reason)
VALUES (:reason)
`;
await sequelize.query(withDrawalQuery, {
replacements: { reason },
});
const query = `
DELETE FROM User
WHERE id = :user_id
`;
await sequelize.query(query, {
replacements: { user_id },
});
사실 이미 완성된 과정이다. 처음에는 직접 매개변수로 values, where에 직접 변수가 들어가 있었는데, 그렇게 되면 sql인젝션 공격을 당할 수 있으니 꼭 replacements를 통해 대체해주자.
아무튼, 내가 하려 하는 것은 여기서 모델로직을 분리하는 것이다. 지금 이 코드는 한 눈에 잘 띄긴 하지만, 여기서 만약 어떠한 조건들이 생기고 스크롤압박이 생긴다면 조금이라도 덜어주는게 좋지 않을까?
모델로직에는 db에 들어갈 각 데이터들의 세팅을 한다고 생각하면 되겠다.
아래를 살펴보자.
User 모델
import { DataTypes } from "sequelize";
import sequelize from "../../configs/sequelize.config.js";
export const User = sequelize.define(
"User",
{
uuid: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: sequelize.literal("uuid_generate_v4()"),
},
nickname: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
field: "created_at",
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
field: "updated_at",
},
},
{
modelName: "User",
tableName: "User",
}
);
설명하고자 하는 컬럼으로 추렸다.
서비스에서 사용할 수 있도록 export함과 동시에 이름은 첫번째 매개변수에 테이블 이름을 그대로 설정했다.
주의할 점
그리고 가장 아래 객체를 살펴보면 모델네임과 테이블네임을 적어주었는데, sequelize의 경우 테이블네임을 세팅해주지 않으면 설정한 이름(첫번째 매개변수)에 s가 자동으로 붙기 때문에 에러가 날 수 있다.
또한 sequelize는 createdAt, updatedAt을 자동으로 설정해준다. 그래서 굳이 설정하지 않아도 되는데, db에 들어가는 createdAt, updatedAt을 스네이크케이스로 사용하기 위해서 이름을 지정해준 것이다.
그 외에 타입, null여부 등은 바로 이해할 수 있을 것이라 생각한다.
그럼 이제 서비스 로직에서 모델을 어떻게 사용할 것인가?
하기전에 제목에서 알 수 있듯 단순히 분리하는 과정 외에도 리팩토링한 과정을 간단히 설명하려 한다.
분리하는 과정만 알기 위해서는 아래로 내리면 되겠다.
변경된 User 서비스
export const userWithdrawal = async (user_id, reason) => {
const transaction = await sequelize.transaction();
try {
const existingUser = await User.findOne({
where: {
id: user_id,
},
transaction: transaction,
});
if (!existingUser) {
throw new UncommonError(
errorCode.UNAUTHORIZED,
errorMessage.UNAUTHORIZED,
"User not found"
);
}
await WithDrawal.create({ reason }, { transaction: transaction });
await User.destroy({
where: {
id: user_id,
},
transaction: transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
Logger.error(error);
throw error;
}
};
아까전과 달라진 부분이 몇 가지 있다.
먼저 눈에 띄는 것은 트랜잭션이다. 트랜잭션을 하는 이유는, 여러 동작이 있을 때 중간에 에러가 나게 되면 에러가 나기 직전까지 실행되어버린 내용들은 다시 돌려놓아야 한다. 흔히들 드는 예시로는 결제가 있겠다. 결제 중간에 에러가 나면 결제가 취소되어야지, 결제되고 에러가 나서 다시 결제할 수는 없는 것이다.
세팅 자체는 간단하다. 먼저 선언해서 불러오고, 각 로직 안에서 객체로 transaction을 사용하고, 로직이 실행되는 마지막에 커밋을 해준다. 커밋은 마무리하는 과정이라고 보면 되겠다. 그러나 에러가 발생한다면 transaction이 걸린 로직들은 취소된다. 취소되는 로직이 롤백이다.
두 번째로 달라진 것은 에러처리다. 서두에 푸념했듯.. 에러처리를 하지 않으면 클라이언트의 문제든 서버의 문제든, 작동이 어디까지 되고 멈추든 잘 되든 200을 반환하게 된다. 그리고 에러처리를 하지 않으면 굳이 실행하지 않아도 되는 동작들이 실행된다.
회원탈퇴를 하는데 만약 클라이언트나 서버의 이슈로 이미 탈퇴가 되었는데 화면은 회원탈퇴하기 전 화면이라고 가정해보자. 이미 탈퇴되어 있는데 탈퇴를 시도해봤자 아무 일도 일어나지 않을 것이다. 그렇다면 탈퇴가 가능한 상태가 맞는지 확인하고, 아니면 바로 에러를 던져서 그 이후의 로직은 작동하지 않도록 막아서 필요없는 리소스낭비를 줄일 수 있다.
위 예시 코드의 경우 탈퇴를 할 때 기록을 WithDrawal에 남기고 있는데, 이미 탈퇴되었는데도 계속해서 로그가 생길 수 있다는 것이다. 그 전에 멈출 수 있도록 하는 것이다.
자 이제 서비스 - 모델 로직 분리다. 글로 설명해도 금방 끝난다.
export한 모델을 서비스에서 가져가서 쓰고, 바로 sequelize의 메서드를 사용하면 된다. 대부분의 orm이 비슷한 네이밍의 메서드를 사용하고 있고, 그때그때 공식문서나 검색을 통해 자세한 로직을 확인하면 되겠다. 간단하게는 create, findOne,destroy 이렇게 세 메서드를 사용했다.
//변경전
const withDrawalQuery = `
INSERT INTO WithDrawal(reason)
VALUES (:reason)
`;
await sequelize.query(withDrawalQuery, {
replacements: { reason },
});
//변경후
await WithDrawal.create({ reason }, { transaction: transaction });
바로 체감할 수 있을 것이다. 사실상 transaction도 옵션임을 생각하면 더 간단해진다.
마치며
물론 ORM이 정답은 아니다. SQL문을 직접 사용해서 의도한 결과를 정확히 나타낼 수 있다면 그게 더 효율은 좋을 것이다. 그래서 지금 리팩토링하면서도 간단한 조회같은 로직은 SQL로 하고, 다른 사람이 보았을 때 한 눈에 확인할 수 있도록 ORM을 사용하여 정리하고 있다.
위 코드처럼 5줄 6줄 되는 코드가 한 줄로 변한다.
그러나 확실히 체감하지 못하고 당연하듯 ORM을 사용하기보다 정확한 의도와 가독성을 위해 정리하는 과정으로 사용하면 그게 가장 좋은 의도인 것 같다.
'개발 > DB' 카테고리의 다른 글
[mysql] view의 장단점, 성능에 대한 고찰 (0) | 2023.10.17 |
---|---|
[SQL] 입사하자마자 발견한 여러 문제들.. 특히 SQL 인젝션 문제 (0) | 2023.10.14 |
[sql] 다른 schema 간 DB table 복사방법 (0) | 2023.08.26 |
[TypeORM] Nest.JS 없이 Node.JS + TypeORM 기본 세팅 (0) | 2023.08.02 |
[mySQL] root 비밀번호 오류 초기화 간단설명, 초기화해도 안되는 경우 (0) | 2023.07.05 |