회원가입 동시성 제어

백수만·2023년 3월 12일

프로젝트

목록 보기
2/6
post-thumbnail

회원가입을 로직을 구현하면서 동시성에 관해 고민을 하게 되었다.

기존 코드 (동시성 문제 발생 가능)

  • Users.service.ts
    async signUp(createUserDto: CreateUserDto): Promise<void> {
        const { username, password } = createUserDto;
    
        await this.checkUser(username); // 아이디가 존재하면 에러 처리
        const encryptedPassword = await this.encryptPassword(password); // 비밀번호 암호화
    
        await this.usersRepository.createUser(
          { ...createUserDto, password: encryptedPassword },
          PROVIDER.LOCAL,
        );
      }
    
      async checkUser(username: User['username']): Promise<void> {
        const user = await this.usersRepository.getUserByUsername(username);
        if (user) {
          throw new BadRequestException('이미 존재하는 아이디가 있습니다.');
        }
      }
  • Users.respository.ts
    export class UsersRepository {
      constructor(private mysqlService: MysqlPrismaService) {}
    
      async getUserByUsername(username: User['username']): Promise<User> {
        return await this.mysqlService.user.findFirst({ where: { username } });
      }
    
      async createUser(createUserDto: CreateUserDto, provider: PROVIDER): Promise<void> {
        await this.mysqlService.user.create({ data: { ...createUserDto, provider } });
      }
    }

기존 코드의 로직은 다음과 같다.

  1. 아이디가 존재하면 에러 처리 (select)
  2. 비밀번호 암호화
  3. 계정 생성 (create)

여기서 드는 궁금 중.. 만약 다음과 같이 동시에 접근을 한다면?

  • 상황: User1과 User2가 동시에 같은 아이디를 생성하려고 함
  • User1: [1]아이디가 존재하면 에러 처리 실행 → 에러 없음 → [2]비밀번호 암호화 실행 중
  • User2: [1]아이디가 존재하면 에러 처리 실행 → 에러 없음
  • User1: [3]계정 생성 실행 → DB에 다음과 같이 계정 생성
    username=”test1234”
    nickname=AAA
  • User2: [3]계정 생성 실행 → DB에 다음과 같이 계정 생성
    username=”test1234”
    nickname="BBB"

계정이 두 개 생기게 됨

이 문제를 어떻게 해결하면 좋을까

생각나는 해결방안은 다음과 같다.

  1. 트랜잭션 격리조건을 활용해 동시 읽기 막기
  2. create시 이미 계정이 있으면 에러 처리

1. 트랜잭션 격리조건을 활용해 동시 읽기 막기

트랜잭션 격리 조건에 대해 어떤 것을 써야할 지 고민을 했다.

코드는 다음과 같아진다.

await this.mysqlService.$transaction(
  async (tx) => {
    const user = await tx.user.findFirst({ where: { username } });
    if (user) throw new BadRequestException('이미 존재하는 아이디가 있습니다.');

    await this.mysqlService.user.create({ data: { ...createUserDto,provider} });
  },
  {
    isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
  },
);

2. create시 이미 계정이 있으면 에러 처리

기존코드에서 [1]아이디가 존재하면 에러 처리 부분을 제거하고 다음과 같이 쿼리를 두개 보내겠다. (똑같은 아이디가 2개 존재하는 상황 만들기 위해)

  • 첫번째 쿼리
    {
        "username": "test@naver.com",
        "password": "password@311",
        "nickname": "AAA"
    }
    결과
  • 두번째 쿼리
    {
        "username": "test@naver.com",
        "password": "password@311",
        "nickname": "BBB"
    }
    결과

이렇게 같은 아이디가 2개 생성되면 추후에 아이디를 통해 찾으려고 하면 문제가 발생할 수 있다.

해결책

  • 테이블에 (username, provider)를 UNIQUE로 만들자!

  • 과연 결과는..?
    • 에러처리가 잘 되었다. Unique constraint failed on the constraint: User_username_provider_key

결론

  • 격리 수준을 높이면 성능이 떨어진다. 성능 저하가 없는 unique를 사용하려고 한다!
  • 이래서 DB 설계를 잘 해야하는구나를 느낌!

수정 후 코드

  • 중복 시, create 에서 에러가 발생하므로 따로 아이디를 확인하는 함수인 checkUser()를 지웠다.

UsersRepository

export class UsersRepository {
  constructor(private mysqlService: MysqlPrismaService) {}

  async createUser(createUserDto: CreateUserDto, provider: PROVIDER): Promise<void> {
    await this.mysqlService.user.create({ data: { ...createUserDto, provider } });
  }
}

UsersService

export class UsersService {
  constructor(private usersRepository: UsersRepository) {}

  async signUp(createUserDto: CreateUserDto): Promise<void> {
    const encryptedPassword = await this.encryptPassword(createUserDto.password); // 비밀번호 암호화

    await this.usersRepository.createUser(
      { ...createUserDto, password: encryptedPassword },
      PROVIDER.LOCAL,
    );
  }
}

0개의 댓글