[NestJS] - 21. 결제 프로세스와 Transaction Isolation

2023. 3. 5. 07:35
반응형

 

서비스하고있는 많은 플랫폼들은 결제 프로세스를 사용한다. 이번에는 클라이언트측에서 결제 요청 후 백엔드 서버에서의 처리에 대해서 포스팅해보려고 한다.

 


 

https://sjh9708.tistory.com/50

 

[Web] 결제 프로세스 적용을 위한 IamPort 사용해보기

우리는 흔히 웹, 키오스크, 모바일 등에서 결제 시스템을 이용하게 된다. 결제 프로세스에 적용을 위한 개념과 간단한 세팅 하는 것을 다루어보려고 한다. 결제 과정 1. 구매자가 구매 상품과 금

sjh9708.tistory.com

해당 포스팅에서 IamPort를 통한 결제 적용과 간단한 프론트엔드 코드를 작성했었다.

프론트엔드에서 결제 완료 후 결제 정보와 함께 벡엔드 서버로 요청을 하게 되는데, 결제 완료에 대한 기록을 남기고, 사용자에게 포인트를 적립시켜보는 로직을 완성시켜보자.

 

 

 


결제 모듈과 모델 생성

 

다음과 같이 결제 모듈와 모델을 생성하였다.

 

▶payment.entity.ts

// 보통 Insert Only Table, 상태가 바뀌면 변경을 하는것이 아니라 추가를 하여 결제 히스토리를 추적한다.
@Entity()
export class Payment {
  @PrimaryGeneratedColumn('increment')
  id: string;

  @Column()
  impUid: string;

  @Column()
  amount: number;

  @Column({ type: 'enum', enum: PAYMENT_TRANSACTION_STATUS_ENUM })
  status: string;

  @ManyToOne(() => User)
  user: User;

  @CreateDateColumn()
  createdAt: Date;
}
  • 결제 결과를 저장하기 위해서 Payment 엔티티를 생성하였다.
  • impUid에는 IamPort의 결제 내역 UID를 기록할 것이다.
  • status에는 결제 상태를 나타낼 것인데 TypeORM에서 enum 타입을 사용하기 위해 다음과 같이 작성하였다.
  • User와 N(Payment) : 1(User) 관계를 설정해주었다.
  • 보통 결제 내역을 기록하는 테이블은 Insert Only Table로 사용한다. 업데이트하거나 삭제하면 히스토리 추적을 하기 어렵기 때문이다.

 

▶ paymentStatus.enum.ts

export enum PAYMENT_TRANSACTION_STATUS_ENUM {
  PAYMENT = 'PAYMENT',
  CANCEL = 'CANCEL',
}
  • enum 타입을 정의해주었다. 결제 완료와 취소상태를 나타낸다.

 

 


결제 API 작성

 

▶ paymentInput.dto.ts

export class PaymentInput {
  @IsString()
  impUid: string;

  @IsInt()
  amount: number;
}

 

▶ payment.controller.ts

@Controller('payment')
export class PaymentController {
  constructor(private readonly paymentService: PaymentService) {}

  @UseGuards(AuthGuard('access'))
  @Post()
  createPointTransaction(
    @Body() input: PaymentInput,
    @Req() req: Request & IOAuthUser,
  ) {
    const currentUser = req.user;
    const { impUid, amount } = input;
    return this.paymentService.create({ impUid, amount, currentUser });
  }
}
  • 컨트롤러에서는 결제 이후 API를 작성해주었다.
  • 클라이언트쪽에서 IamPort 결제 성공 이후 받은 impUid와 금액을 요청에서 함께 받는다.
  • Access 인가까지 성공하였을 때, 사용자에게 포인트를 적립할 것이다.

 

 

▶ payment.service.ts

@Injectable()
export class PaymentService {
  constructor(
    @InjectRepository(Payment)
    private readonly pointTransactionRepository: Repository<Payment>,
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly connection: Connection,
  ) {}
  
  async create({ impUid, amount, currentUser }) {
    // T. 트랜잭션 사용을 위해서 queryRunner 연결
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();

    // T. 트랜잭션 시작
    await queryRunner.startTransaction('SERIALIZABLE');

    try {    
    
      //1. pointTransaction 테이블에 거래기록 생성

      //2. 유저의 돈 찾아오기
  
      //3. 유저의 돈 업데이트(충전)

      //4. 최종 결과 응답
      
      
      // T. 트랜잭션 커밋
      await queryRunner.commitTransaction();
      return pointTransaction;
    } catch (err) {
      console.log(err);
      // T. 트랜잭션 롤백
      await queryRunner.rollbackTransaction();
    } finally {
      // T. queryRunner 연결 종료
      await queryRunner.release();
    }
  }
}
  • 컨트롤러의 비즈니스 로직을 처리하는 서비스를 작성한다.
  • 해당 서비스에서는 사용자의 포인트를 적립하면서, 결제 내역을 남길 것이다.
  • 결제는 모든 과정이 성공적으로 이루어지지 못하면 실패해야 한다. 따라서 트랜잭션을 사용할 것이다.
  • 트랜잭션의 Isolation Level은 Serializable로 둔다. 왜냐하면 해당 과정이 이루어지는 동안 다른 곳에서의 유저 포인트에 대한 읽기 및 쓰기 작업을 막아야 하기 때문이다.
    • 유저의 포인트를 데이터베이스에서 업데이트 할 때의 계산식은 (기존 유저의 잔액) + (추가 포인트) = (유저의 잔액)이 되어야 한다. 그러나 해당 로직이 동시에 실행되게 된다면 아래의 예시와 같은 상황이 발생할 수 있다.
    • 따라서 트랜잭션 격리 수준을 높여서 트랜잭션 실행 중에는 포인트에 대해서 다른 곳(트랜잭션)에서 읽기/쓰기를 불가능하도록 해야 한다.
Transaction A Transaction B C의 잔액
A가 C에게 1000원 포인트 적립 요청   0
  B가 C에게 2000원 포인트 적립 요청 0
C의 잔액 계산 : C의 잔액(0) + 1000   0
... 처리중 C의 잔액 계산 : C의 잔액(0) + 2000 0
송금 완료, C의 잔액 = C의 잔액(0) + 1000 ... 처리중 1000
  송금 완료, C의 잔액 = C의 잔액(0) + 2000 2000

 

  • 트랜잭션 처리와 Isolation Level에 대해서는 아래의 포스팅을 참고

 

https://sjh9708.tistory.com/41

 

 

[NestJS] - 15. TypeORM 트랜잭션(Transaction)

이번엔 TypeORM에서의 트랜잭션 적용을 해보겠다. 트랜잭션 (Transaction) 하나의 로직을 처리하는 SQL 질의들을 집합으로 묶어, 도중에 예외가 발생할 경우 Rollback 처리를, 모두 성공할 경우 Commit 처리

sjh9708.tistory.com

 

https://sjh9708.tistory.com/40

 

[Database] 트랜잭션(Transaction), ACID 및 Isolation Level

트랜잭션 (Transaction) 하나의 로직을 처리하는 SQL 질의들을 집합으로 묶어, 도중에 예외가 발생할 경우 Rollback 처리를, 모두 성공할 경우 Commit 처리하는 실행 단위이다. 쇼핑몰에서 주문할 때의 과

sjh9708.tistory.com

 


비즈니스 로직 작성

 

 

1. 거래기록 저장

 

▶ payment.service.ts

@Injectable()
export class PaymentService {
  constructor(
    //...
  ) {}
  
  async create({ impUid, amount, currentUser }) {
    // T. 트랜잭션 사용을 위해서 queryRunner 연결
    //...
    // T. 트랜잭션 시작
    //...

    try {    
    
      //1. pointTransaction 테이블에 거래기록 생성
      const pointTransaction = this.pointTransactionRepository.create({
        impUid: impUid,
        amount: amount,
        user: currentUser,
        status: PAYMENT_TRANSACTION_STATUS_ENUM.PAYMENT,
      });

      await queryRunner.manager.save(pointTransaction);

      //2. 유저의 돈 찾아오기
  
      //3. 유저의 돈 업데이트(충전)

      //4. 최종 결과 응답
      
      
      // T. 트랜잭션 커밋
      await queryRunner.commitTransaction();
      return pointTransaction;
    } catch (err) {
      console.log(err);
      // T. 트랜잭션 롤백
      await queryRunner.rollbackTransaction();
    } finally {
      // T. queryRunner 연결 종료
      await queryRunner.release();
    }
  }
}
  • 결제 로직 과정의 첫 단계로 거래기록을 생성해주었다.
  • Transaction 처리를 해야하므로 QueryRunner를 통하여 save해주었다.

 

 

2. 유저의 잔액 조회 및 충전

 

▶ payment.service.ts

@Injectable()
export class PaymentService {
  constructor(
    //...
  ) {}
  
  async create({ impUid, amount, currentUser }) {
    // T. 트랜잭션 사용을 위해서 queryRunner 연결
    //...
    // T. 트랜잭션 시작
    //...

    try {    
    
      //1. pointTransaction 테이블에 거래기록 생성
      //...

      //2. 유저의 돈 찾아오기
      //T : 베타락을 걸어서 동시 Read를 방지함!! 트랜잭션이 완료될 때 까지 다른 곳에서는 조회가 불가능함
      const user = await queryRunner.manager.findOne(
        User,
        { id: currentUser.id },
        { lock: { mode: 'pessimistic_write' } }, //SERIALIZABLE 수준일때 베타락 적용가능
      );

      //3. 유저의 돈 업데이트(충전)
      const updatedUser = await this.userRepository.create({
        ...user,
        point: user.point + amount,
      });

      await queryRunner.manager.save(updatedUser);

      //4. 최종 결과 응답
      
      
      // T. 트랜잭션 커밋
      await queryRunner.commitTransaction();
      return pointTransaction;
    } catch (err) {
      console.log(err);
      // T. 트랜잭션 롤백
      await queryRunner.rollbackTransaction();
    } finally {
      // T. queryRunner 연결 종료
      await queryRunner.release();
    }
  }
}
  • 이제 유저 테이블에서 해당하는 유저에게 포인트를 충전하는 로직을 만들었다.
  • 위에서 언급했듯, 유저의 포인트에 대해서는 트랜잭션 간에 동시 접근이 되어서는 안된다. Read/Write를 막는 베타락을 적용하기 위해 'pessimistic_write'를 적용하였다.
  • 유저의 포인트를 조회 후 포인트를 업데이트시키는 로직을 트랜잭션 과정에 추가한다.

 

 

3. 트랜잭션 완료

@Injectable()
export class PaymentService {
  constructor(
    @InjectRepository(Payment)
    private readonly pointTransactionRepository: Repository<Payment>,

    @InjectRepository(User)
    private readonly userRepository: Repository<User>,

    private readonly connection: Connection, //Typeorm 지원
  ) {}
  async create({ impUid, amount, currentUser }) {
    // T. 트랜잭션 사용을 위해서 queryRunner 연결
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();

    // T. 트랜잭션 시작
    await queryRunner.startTransaction('SERIALIZABLE');

    try {
      //1. pointTransaction 테이블에 거래기록 생성
      const pointTransaction = this.pointTransactionRepository.create({
        impUid: impUid,
        amount: amount,
        user: currentUser,
        status: PAYMENT_TRANSACTION_STATUS_ENUM.PAYMENT,
      });

      await queryRunner.manager.save(pointTransaction);

      //2. 유저의 돈 찾아오기
      //T : 베타락을 걸어서 동시 Read를 방지함!! 트랜잭션이 완료될 때 까지 다른 곳에서는 조회가 불가능함
      const user = await queryRunner.manager.findOne(
        User,
        { id: currentUser.id },
        { lock: { mode: 'pessimistic_write' } }, //SERIALIZABLE 수준일때 베타락 적용가능
      );

      //3. 유저의 돈 업데이트(충전)
      const updatedUser = await this.userRepository.create({
        ...user,
        point: user.point + amount,
      });

      await queryRunner.manager.save(updatedUser);

      // T. 트랜잭션 커밋
      await queryRunner.commitTransaction();

      //4. 최종 결과 프론트엔드에 응답하기
      return pointTransaction;
    } catch (err) {
      console.log(err);
      // T. 트랜잭션 롤백
      await queryRunner.rollbackTransaction();
    } finally {
      // T. queryRunner 연결 종료
      await queryRunner.release();
    }
  }
}
  • 최종적으로 트랜잭션을 Commit시키고 프론트엔드에 결과를 응답해준다.

 

 


결과

 

결제를 진행하였다.

 

 

포인트가 적립되었음을 확인할 수 있다.

 

결제 내역 기록에 성공하였다.

 

결제 승인 내역을 IamPort에서도 확인 가능하다.

 

반응형

BELATED ARTICLES

more