Notice
Recent Posts
Recent Comments
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

Día de Ruru

[트랜잭션]회원가입 도중 에러 발생 시 재시도 불가능 에러 본문

항해99/FinalProject

[트랜잭션]회원가입 도중 에러 발생 시 재시도 불가능 에러

공대루루 2023. 5. 13. 22:32

문제점

우리 프로젝트는 회사를 대상으로 한 b2b 프로젝트이다보니 최초에 회사가 가입하는 부분이 필요하다. 회사가 가입할 때 입력하는 정보를 각각 companies , teams, users 테이블에 정보를 나눠서 입력해주기 위해서 하나의 API에 create 명령어가 세개 들어가게 되었다. 그런데 해당 명령어들이 순차적으로 진행되다보니 3개중에서 하나의 명령어에서 에러가 생겨도 그 전에 실행되었던 명령어는 성공이 된 상태이기 때문에 다시 회원가입을 하려고 할 때, companies에 성공적으로 입력된 값 때문에 이미 가입된 회사라는 에러메세지를 반환하게 된다.


트랜잭션 도입

순차적으로 동작하는 3개의 명령어를 트랜잭션 도입으로 단일 작업처럼 실행되게 하면 문제를 해결할 수 있을 것 같다.

트랜잭션(Transaction)이란?
트랜잭션은 작업의 완전성을 보장해주기 위해 사용되는 개념이다. 여러개의 작업을 전부 처리하거나 전부 실패하게 만들어서 데이터의 일관성을 보장해준다. 여러개의 작업을 하나의 작업 단위로 그룹화 하여 처리하는 것이다.

✔ 고려해야 했던 부분

트랜잭션을 도입하려고 했을 때 가장 먼저 고민되었던 부분은 격리 수준(Isolation Level) 이었다. 트랜잭션의 격리 수준에는 총 4가지가 있다. 아래 4가지 중에서 고려했던 것은 READ COMMITED  REPEATABLE READ 였다.

1. READ UNCOMMITED : 가장 낮은 수준의 격리수준이며, 락을 걸지 않아 동시성이 높지만 일관성이 쉽게 깨질 수 있다.
2. READ COMMITED : 다른 트랜잭션이 데이터를 수정하고 있는 중에는 데이터를 읽을 수 없어 커밋되지 않은 읽기현상이 발생하지 않는다.
3. REPEATABLE READ : 공유락이 걸린 상태에서 데이터를 수정하는 것은 불가능하지만, 데이터를 삽입하는 것이 가능해진다.
4. SERIALIZABLE : 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 읽거나 삽입할 수 없고, 새로운 데이터를 추가하는 것 또한 불가능하다. -> 동시성이 떨어짐

READ COMMITED는 Non Repeatable Read문제와 Phantom Read 문제가 발생할 수 있으며 REPEATABLE READ 는 UNDO 영역에 백업된 레코드가 많아지면 성능이 떨어질 수 있다는 문제가 있었다. 

- Dirty Read 
커밋되지 않은 수정 중인 데이터를 다른 트랜잭션에서 읽을 수 있도록 허용할 때 발생
- Non-Repeatable Read 
한 트랜잭션 내에서 같은 쿼리를 두 번 수행할 때 그 사이에 다른 트랜잭션이 값을 수정 또는 삭제함으로써 두 쿼리의 결과가 상이하게 나타나는 비 일관성 발생
- Phantom Read
한 트랜잭션 안에서 일정 범위의 레코드를 두 번 이상 읽을 때, 첫 번째 쿼리에서 없던 레코드가 두 번째 쿼리에서 나타나는 현상. 이는 트랜잭션 도중 새로운 레코드가 삽입되는 것을 허용하기 때문에 나타남.

내가 구현중인 기능에서는 하나의 트랜잭션 내에서 각각 다른 테이블에 데이터를 삽입해주는 동작을 하기 때문에 Non Repeatable Read 문제나 Phantom Read 문제가 발생할 위험이 없다고 판단되었다. 때문에 굳이 격리 수준을 높게 잡을 필요가 없다고 생각되어 격리 수준은 READ COMMITED으로 설정해주기로 결정했다.

✔ 시도해본 과정

1. 트랜잭션을 service 계층에서 아래와 같이 적용해주었을 때, commit에서는 문제가 없는데 rollback이 제대로 작동하지 않았다. rollback 쿼리는 발생하지만 실제로 rollback이 수행은 되지 않아 테이블에 데이터가 삽입되는 문제가 발생했다.

const t = await sequelize.transaction({
    isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED, // 트랜잭션 격리 수준을 설정합니다.
});
try {
    const { 받아올 내용 } = req.body;
    await Companys.create({
	// 생성할 내용
    },{ transaction: t })

    await Users.create({
	// 생성할 내용
    },{ transaction: t })

    await t.commit();
    return res.status(200).json({ message: "회원가입에 성공하였습니다." })
} catch (err) {
    await t.rollback();
    next(err)
}

3-Layered Architecture 에서 코드의 실행 흐름은 아래와 같다. 아래의 흐름대로 코드를 살펴보면 service 계층에서 트랜잭션이 걸려있더라도 repository 계층에서 이미 실행이 되어버려서 발생하는 문제였다. 

router → controller →  service → repository → service → controller → route

2. 위의 문제점을 발견하고 repository 계층에서 트랜잭션을 적용해주기 위해서 아래와 같이 코드를 작성하게 되었다.

왼쪽 : service 계층 코드      오른쪽 : repository 계층 코드

repository 계층에서 트랜잭션을 적용하는 과정에서 repository계층 각각의 메서드에 트랜잭션을 따로 선언해주었다. 이렇게 적용해주었더니 트랜잭션이 각 메서드에 새로 적용되어 코드가 진행되면서 각각의 새로운 트랜잭션이 수행되면서 교착상태가 발생하였다.

✔ 최종 해결 방법

트랜잭션을 각각의 메서드에 적용할 것이 아니라 하나의 트랜잭션으로 만들어주어야 하기 때문에 repository 계층에서 3개로 나뉘어 있던 메서드를 1개의 메서드로 만들어 주고 하나의 트랜잭션을 적용해주었다. 

//service 계층 코드
companySignup = async ({
		//입력할 데이터
    }) => {
        //사업자비밀번호 암호화
        bcrypt.hash(password, 10, async (err, encryptedPW) => {
            if (err) {
                throw new CustomError("회원가입이 실패했습니다.", 412);
            } else {
                await this.SignupRepository.companySignup({
					//입력할 데이터
                });
            }
        });
    };

기존의 코드와 다르게 service 계층에서 회원가입을 위해서 하나의 메서드를 호출하며 repository에는 하나의 메서드안에서 모든 회원 가입 관련 코드가 진행된다. 

//repository 계층 코드
companySignup = async ({
		//입력해줄 데이터
    }) => {
        const t = await sequelize.transaction({
            isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED, 
        });

        try {
            //회사생성
            await Companys.create(
                {
					//입력해줄 데이터
                },
                { transaction: t }
            );

            //팀생성
            const team = await Teams.create(
                {
                    //입력해줄 데이터
                },
                { transaction: t }
            );
            //유저생성
            const day = new Date()
            await Users.create(
                {
                    //입력해줄 데이터
                },
                { transaction: t }
            );
            await t.commit();
        } catch (transactionError) {
            // rollback()을 호출하여 트랜잭션 전체를 롤백
            await t.rollback();
            throw new CustomError(
                "회원가입 중 예상치 못한 에러가 발생했습니다.",
                400
            );
        }
    };

✔ 고려해볼 사항

위의 코드로 최종적으로 트랜잭션을 적용했지만 코드의 재사용성이 너무 떨어지는 구조로 되어 있다. 어차피 회원가입에 사용되는 메서드는 다시 재사용할 일이 없어서 크게 문제가 생기지는 않았지만 재사용해야하는 메서드가 많을 경우에는 service 계층에서 트랜잭션을 선언해주고 repository의 각 메서드들에 인자로 함께 보내주는 방식으로 코드를 작성해야 할 것 같다.

'항해99 > FinalProject' 카테고리의 다른 글

에러 로깅 시스템 구축  (0) 2023.05.15
fileName, fileLocation 조회 코드 개선  (0) 2023.05.15
Comments