* 현재 ORM 도입 없이 PostgreSQL 드라이버인 pg를 사용하여 데이터베이스와 연결하고 있습니다.
기존 코드
기존 CommentService의 코드입니다. commentId를 받아와서 댓글 하나를 삭제합니다.
이 로직 한 번에 총 4번의 DB 쿼리가 날아가게 됩니다.
1. commentId로 comment를 찾아옵니다.
2. 해당 comment의 부모 댓글을 찾아 recomment_count를 1 감소시킵니다.
3. 해당 comment가 달린 article을 찾아 comment_count를 1 감소시킵니다.
4. 만일 대댓글이 달린 댓글이라면 해당 댓글에 deleted를 마킹하고, 그렇지 않다면 해당 댓글을 테이블에서 바로 삭제합니다.
현재 트랜잭션 처리가 되어있지 않아 2~4번 과정에서 에러가 발생하면 DB 일관성이 와장창 깨지게 됩니다.
이제 트랜잭션 처리를 도입해봅시다.
트랜잭션 처리 도입
Service단 (Ex. CommentService)
static async deleteComment(commentId) {
return await transaction(async (client) => {
const comment = await CommentRepository.findByCommentId(
commentId,
client,
);
if (!comment) {
throw new CustomError(CommentErrorMessage.COMMENT_NOT_FOUND);
}
const { parentId, articleId, recommentCount } = comment;
if (parentId) {
await CommentRepository.updateRecommentCount(
{
commentId: comment.parentId,
amount: -1,
},
client,
);
}
await ArticleRepository.updateCommentCount(
{ articleId, amount: -1 },
client,
);
const result =
recommentCount > 0
? await CommentRepository.updateDeleted(commentId, client)
: await CommentRepository.delete(commentId, client);
return { data: result };
});
}
위 코드가 트랜잭션 처리가 도입된 코드입니다. 달라진 점은 아래와 같습니다.
- `transaction` 메서드를 호출한다.
- 모든 로직처리가 `transaction` 메서드의 콜백함수 안에서 이루어진다.
- 모든 Repository 메서드에 같은 `cilent`가 인자로 들어간다.
- ArticleService가 아닌 ArticleRepository를 바로 호출한다.
아래는 transaction 함수의 내용입니다.
transaction 담당 함수
import pool from '../config/db';
async function transaction(callback) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
console.error(error);
} finally {
client.release();
}
}
export { transaction };
- pool.connect()를 통해 미리 만들어 놓은 커넥션 중 available 한 것을 하나 받아옵니다.
- 받아온 client를 콜백 함수에 넘겨주며 callback 함수를 실행시킵니다.
- client.query('BEGIN') , client.query('COMMIT'), client.query('ROLLBACK') 구문을 통해 트랜잭션을 처리합니다.
Repository단 (Ex. CommentRepository)
그렇다면 Repository의 내용은 어떻게 변했을까요?
먼저 기존 코드입니다.
class CommentRepository {
static async create(
{ articleId, content, parentId, mentionMemberId, memberId}
) {
const result = await pool.query(
'INSERT INTO comment (article_id, member_id, content, parent_id, mention_member_id) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[articleId, memberId, content, parentId, mentionMemberId],
);
return result.rows[0];
}
...
}
pool.query()를 통해 db에 쿼리문을 날립니다.
pool.query()는 pool로부터 available한 커넥션을 가져온 뒤, 쿼리를 전달하여 데이터를 받아오고, 이후 알아서 커넥션을 release하는 역할까지 함께 합니다. 따라서 트랜잭션 처리가 필요없는 단일 쿼리 상황에서 사용하는 것이 옳으며, 지금처럼 트랜잭션을 도입하는 경우엔 적절하지 않습니다.
아래는 수정된 코드입니다.
class CommentRepository {
static async create(
{ articleId, content, parentId, mentionMemberId, memberId },
client = null,
) {
const queryRunner = client || pool;
const result = await queryRunner.query(
'INSERT INTO comment (article_id, member_id, content, parent_id, mention_member_id) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[articleId, memberId, content, parentId, mentionMemberId],
);
return result.rows[0];
}
...
}
1. client를 매개변수로, 기본값은 null으로 설정합니다.
2. 받아온 client가 존재할 경우 client를 이용하여 쿼리를 수행합니다.
따라서 client가 전달된 경우에는 client를 통해 쿼리를 수행하고, 전달되지 않은 경우에는 pool.query()를 통해 단일 쿼리를 수행하도록 변경 되었습니다.
이제 Service단에서 트랜잭션 처리가 필요한 함수들에서는 `transaction` 메서드를 호출하고, 콜백함수를 작성하여 콜백함수 안에서 Repository 메서드를 호출하여 쿼리를 수행합니다.
따라서 더 이상 Service 레이어에서는 또 다른 Service 레이어를 호출할 수 없다는 원칙이 생깁니다. Service 단에서의 함수 실행은 하나의 트랜잭션의 시작을 의미합니다. 복잡한 비즈니스 로직이 필요한 경우에는 Service 함수에서 직접 여러 Repository 메서드를 호출하여 작업을 수행해야 하며, 이를 통해 각 Service 함수는 독립적으로 하나의 트랜잭션을 처리하게 됩니다.
만일 Service 함수에서 또 다른 Service 함수를 호출하게 되면, 서로 다른 트랜잭션이 생성되어 동시성 문제가 발생할 수 있고 하여튼 원하는 방향대로 동작 되지 않을 가능성이 생겨납니다 ^__^ ;;
기존에 CommentService 메서드에서 바로 ArticleRepository를 호출하지 않고 ArticleService를 통해 데이터를 얻도록 작성했던 이유는, ArticleRepository에 접근하는 것은 ArticleService여야만 한다는 무언의 강박이 있었기 때문입니다. (이유가 뭘까요 ..? 🧐) 무언가 책임이 여기저기로 분산된다는 '느낌'이 들었기 때문인 것 같습니다.
이를테면 다음과 같은 상황입니다.
MemberSevice에서 member의 프로필 이미지를 변경하는 로직입니다.
static async updateMemberProfileImage({ memberId, file }) {
const member = await this.getMember(memberId);
// 기존 프로필 이미지가 있다면 삭제
this.deleteMemberProfileImage(member);
const data = {
nickname: member.nickname,
memberId: member.member_id,
bio: member.bio,
email: member.email,
profileImageUrl: null,
};
if (file) {
const newImage = await ImageService.saveProfileImage(file);
const updatedMember = await MemberRepository.updateProfileImage({
memberId,
imageId: newImage.image_id,
});
data.profileImageUrl = updatedMember.profile_image_url;
}
return data;
}
static async deleteMemberProfileImage(member) {
if (member.profile_image_id) {
try {
await ImageService.deleteImage(member.profile_image_id);
} catch (e) {
console.log(e);
}
await MemberRepository.deleteProfileImage(member.id);
}
}
static async getMember(memberId) {
const member = await MemberRepository.findByMemberId(memberId);
if (!member) {
console.error('member 찾기 실패');
console.log(`${memberId} is not Found`);
throw new CustomError(MemberErrorMessage.MEMBER_NOT_FOUND);
}
return member;
}
MemeberService 내에서 ImageService를 호출하고 있습니다.
다음은 ImageService의 내용입니다.
import ImageRepository from '../repositories/imageRepository.js';
import { deleteS3Image } from '../utils/s3Util.js';
class ImageService {
static async saveProfileImage(file) {
const result = await ImageRepository.create({
storedName: file.key,
storedUrl: file.location,
});
return result;
}
static async getImageUrl(imageId) {
const result = await ImageRepository.findByImageId(imageId);
return result.stored_url;
}
static async deleteImage(imageId) {
const deletedImage = await ImageRepository.delete(imageId);
// s3에서 해당 이미지 삭제 처리
await deleteS3Image({
Key: deletedImage.stored_name,
});
}
}
export default ImageService;
이미지 테이블로의 데이터 삭제 뿐만 아니라 s3Util 에서 함수를 호출하여 해당 버킷을 s3에서 삭제합니다. s3에 저장된 이미지 데이터 정보를 가져와서 이미지 테이블에 저장하는 로직 또한 들어있습니다.
그런데 이제 트랜잭션 처리를 적용하고 나서 Service단에서 Service를 호출하지 못 한다는 원칙을 도입한다면, 위 코드가 전부 MemberService 메서드 내로 옮겨가게 됩니다.
MemberService가 멤버의 프로필 이미지를 관리하는 로직을 포함하는 것은 맞지만, 이렇게 s3와 교류하여 이미지를 삭제하고 저장하는 로직까지 포함하는 것이 과연 옳을까요?... 그렇다면 어떻게 레이어를 구분하는 것이 좋을까요?
다음은 위 질문에 대한 포스팅을 작성해보겠숩니당
'Project' 카테고리의 다른 글
SpringBoot + QueryDSL 적용 살펴보기 (2) | 2024.11.25 |
---|---|
[BAW] 중간점검 .. 프로젝트 모듈 구조화 (0) | 2024.11.07 |
[Express + React] OAuth2 로그인 구현 : Kakao 로그인에 닉네임이 반드시 필요하다면? (2) | 2024.10.21 |