[express.js] Transaction을 통해서 데이터 안정성 확보하기

김지엽·2023년 11월 22일
0
post-thumbnail
post-custom-banner

1. 개요

프로젝트에서 좋아요 기능을 추가하게 되었다. 데이터 설계는 다음과 같다.

하지만 위의 데이터 설계에서 Comment와 Post의 likes 컬럼에서 의문이 들었다. 다음과 같은 상황을 가정해보자.

  1. 사용자가 댓글에 좋아요를 눌러 서버로 요청을 보낸다.
  2. 서버에서는 사용자와 댓글의 존재 여부를 확인한다.
  3. 확인이 완료되면 해당 사용자와 댓글의 id로 CommentLike를 새로 생성한다.
  4. CommentLike를 생성한 후 해당 댓글(Comment)의 likes를 1 증가해 업데이트 한다.

하지만 만약에 3번과 4번 사이에서 예상치 못한 에러가 발생하여 4번을 수행하지 못한 경우 CommentLike는 생성되었지만 정작 Comment의 좋아요 수는 증가하지 않는 안타까운 상황이 발생한다.

2. 해결 방법 생각해보기

위에서와 같이 CommentLike 테이블에서는 데이터가 저장되었지만, Comment 테이블에서는 데이터가 수정되지 않은 상황을 방지하기 위해서 방법을 생각해보았다.

- likes 컬럼을 없애기

문제의 기초적 원인은 좋아요가 CommentLike 테이블로서도 존재하고, Comment의 likes로도 존재하기 때문에 발생한 문제이다.

따라서 CommentLike 테이블로만 좋아요 데이터를 저장해서 만약 댓글의 총 좋아요 개수가 필요한 경우 쿼리를 통해 개수만 카운트해서 불러오는 등으로 문제를 해결할 수 있다.

장점
하나의 데이터에 대해 여러 위치에서 처리하는 것을 방지한다.

단점
CommentLike의 숫자가 엄청 많아질 경우에 성능적으로 불리해진다.

- 트랜잭션(transaction) 사용하기

트랜잭션은 데이터 처리에 있어 안정성을 확보하기 위한 방법이다.

아까의 예시에서 3번과 4번 사이에 에러가 발생해서 4번을 수행하지 못하게 되는 경우 트랜잭션을 적용하면 모든 작업이 취소(롤백)된다. 따라서 에러가 발생한다고 해도 데이터의 잘못된 처리는 발생하지 않는다는 것이다.

장점
데이터 처리중에 오류가 발생해도 과정 자체를 롤백하기에 잘못된 데이터가 저장되지 않는다.

단점
트랜잭션의 격리수준에 따라 dirty read, non-repeatable read 등 여러가지 문제가 발생할 수 있다.

- 트랜잭션으로 결정

팀원들과 상의 후에 좋아요의 특성상 개수가 아주 많아질수 있기에 likes의 컬럼을 유지하기로 했고 그에 따라 트랜잭션을 도입하기로 하였다.

3. sequelize-transaction 사용하기

Mysql 엔진을 InnoDB로 해야만 transaction을 사용할 수 있다.

- sequelize를 통해 transaction 코드 작성

sequelize에서 transaction을 사용할때 두가지 방법이 있다.

Unmanaged transactions: 개발자가 직접 커밋과 롤백을 작성
Managed transactions: 에러가 발생하면 자동으로 롤백, 발생하지 않으면 커밋

나는 로직에 커밋과 롤백이 명확하게 나타나길 원해서 Unmanaged transactions를 택하였다.

likeRouter.post("/comments/:commentId/like", async (req, res, next) => {
    const userId = 1;
    const { commentId } = req.params;

  	// 트랜잭션 생성
    const t = await db.sequelize.transaction();

    try {
        // 유저 존재 확인
        const user = await User.findOne({ 
            where: { id: userId },
            transaction: t, // 트랜잭션 처리
        });

        if (!user) {
            throw new Error("Unauthorized");
        }

        // 댓글 존재 확인
        const comment = await Comment.findOne({
            where: { id: commentId },
            transaction: t, // 트랜잭션 처리
        });

        if (!comment) {
            throw new Error("Not Found");
        }

        let { likes } = comment;

        // 댓글 좋아요 여부 확인 및 업데이트
        const commentLike = await CommentLike.findOne({
            where: { userId, commentId },
            transaction: t, // 트랜잭션 처리
        });

        if (!commentLike) {
            await CommentLike.create({
                userId,
                commentId
            }, {
                transaction: t // 트랜잭션 처리
            });

            likes += 1;
        } else {
            await CommentLike.destroy({
                where: { commentId }
            }, {
                transaction: t // 트랜잭션 처리
            });

            likes -= 1;
        }


        // 댓글 좋아요 카운트
        await Comment.update({
            likes,
        }, {
            where: { id: commentId },
            transaction: t
        });

        // 트랜잭션 커밋
        await t.commit();

    } catch (e) {
        console.log(e);
        // 트랜잭션 롤백
        await t.rollback();
        
        if (e.message === "Unauthorized") {
            return res.status(401).json({
                ok: false,
                message: "로그인이 필요한 기능입니다."
            });
        } else if (e.message === "Not Found") {
            return res.status(404).json({
                ok: false,
                message: "존재하지 않는 댓글입니다."
            });
        } else {
            next(e);
            return;
        }
    }

    return res.status(200).json({
        ok: true,
        message: "댓글 좋아요 수정 완료"
    });
});

- 결과

성공

에러가 발생했을 경우

참고

트랜잭션 개념
트랜잭션 문법

profile
욕심 많은 개발자
post-custom-banner

0개의 댓글