[NestJS] - 21. 결제 프로세스와 Transaction Isolation
2023. 3. 5. 07:35
반응형
서비스하고있는 많은 플랫폼들은 결제 프로세스를 사용한다. 이번에는 클라이언트측에서 결제 요청 후 백엔드 서버에서의 처리에 대해서 포스팅해보려고 한다.
https://sjh9708.tistory.com/50
해당 포스팅에서 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
https://sjh9708.tistory.com/40
비즈니스 로직 작성
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시키고 프론트엔드에 결과를 응답해준다.
결과
반응형
'Backend > Node.js (NestJS)' 카테고리의 다른 글
[NestJS] - 23. NestJS 미들웨어(Middleware) (0) | 2023.03.05 |
---|---|
[NestJS] - 22. NestJS Logging - Winston 연결하기 (0) | 2023.03.05 |
[NestJS] - 20. 인증과 인가 - OAuth2 Google 소셜 로그인 (0) | 2023.03.05 |
[NestJS] - 19. JWT 토큰 인가 - PassportStrategy/Guard 사용과 토큰 재발급 (0) | 2023.03.04 |
[NestJS] - 18. JWT 토큰 인증 - 회원가입/로그인 구현 (1) | 2023.03.04 |