토스페이먼츠 결제 구현

Jnns·2024년 8월 31일
post-thumbnail

왜 토스페이먼츠인가?

결제 요청과 승인을 분리되어 있어 가주문과 실주문 데이터를 비교해 데이터의 정합성을 체크하고 중간에 사용자가 새로고침하는 등의 예외처리를 손쉽게 할 수 있다.

& 토스 공식문서가 잘 정리되어 있어 보다 접근성이 좋았다.

결제 플로우

  1. 클라: 가주문 생성 요청
  2. 서버: 가주문 생성
  3. 클라: 결제 요청
  4. 토스: 결과 반환
  5. 서버로 데이터 넘기기(결제 승인을 위해)
  6. 서버에서 결제 승인 처리(이때 가주문과 실제 주문을 비교대조함)
  7. db에 각 데이터 저장(결제 내역, 환불 내역, 결제 수단: 정기 결제를 위함, 사용자의 구독 상태, 결제 방법과 이력 종합 유저정보)

DB 테이블

1. Plan

  • 목적: 다양한 유형의 구독 계획을 저장한다. 예를 들어, 월간 구독이나 연간 구독 등의 멤버십 옵션이 여기에 해당한다.
  • 필드:
    • id: 각 구독 계획의 고유 식별자
    • title: 구독 계획의 이름 (예: "월간 이용권").
    • price: 구독 계획의 가격
    • duration: 구독 기간을 월 단위로 나타냄

2. TempOrder

  • 목적: 가주문 데이터 정보를 저장한다.
  • 필드:
    • tempOrderId : 가주문 ID
    • orderName : 주문번호
    • totalAmount : 결제 총 금액

3. Payment (userId로 결제 내역 조회 가능하게)

  • 목적: 사용자의 결제 이력을 저장한다. 각 결제 시도에 대한 세부 정보를 포함한다.
  • 필드:
    • id: 결제의 고유 식별자
    • userId: 결제를 진행한 사용자의 ID
    • planId: 결제에 사용된 구독 계획의 ID
    • amount: 결제 금액
    • status: 결제 상태 (예: 진행 중, 성공, 실패).
      enum PaymentStatus {
        INITIATED
        SUCCESS
        FAILED
        PENDING
      }

4. Subscription

  • 목적: 사용자의 현재 구독 상태를 나타낸다. 어떤 구독 계획에 가입했는지와 그 활성화 상태 등을 관리한다.
  • 필드:
    • id: 구독의 고유 식별자
    • userId: 구독을 소유한 사용자의 ID
    • planId: 구독 중인 계획의 ID
    • isActive: 구독의 활성화 여부
enum PlanType {
  BASIC
  PREMIUM
  EVENT
}

model TempOrder {
  tempOrderId  String   @id @default(uuid()) @db.VarChar(25)
  orderName    String   @db.VarChar(50)
  totalAmount  Int
}

model Plan {
  id          Int      @id @default(autoincrement())
  type        PlanType
  price       Int

  subscriptions Subscription[]
  payments Payment[]
}

model Payment {
  orderId         String      @id @db.VarChar(25)
  userId          Int
  planId          Int
  amount          Int
  status          String
  paymentKey      String
  createdAt       DateTime     @default(now())

  user            User         @relation(fields: [userId], references: [id])
  plan            Plan         @relation(fields: [planId], references: [id])
  refunds         Refund[]
}

model Subscription {
  id          Int      @id @default(autoincrement())
  userId      Int
  planId      Int
  startDate   DateTime @default(now())
  endDate     DateTime
  isActive    Boolean  @default(false)

  user    User    @relation(fields: [userId], references: [id])
  plan    Plan    @relation(fields: [planId], references: [id])
}

model Refund {
  id          Int      @id @default(autoincrement())
  orderId     String
  amount      Int
  status      String
  createdAt   DateTime @default(now())

  payment    Payment   @relation(fields: [orderId], references: [orderId])
}

model EventAmount {
  id          Int      @id @default(autoincrement())
  amount      Int
  createdAt   DateTime @default(now())
}

가주문 구현

@Injectable()
export class TempOrdersService {
  constructor(private prismaService: PrismaService) {}

  // 주문 ID 생성
  generateRandomOrderId(length: number): string {
    const charset =
      'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=';
    let randomString = '';
    for (let i = 0; i < length; i++) {
      const randomIndex = Math.floor(Math.random() * charset.length);
      randomString += charset.charAt(randomIndex);
    }
    return randomString;
  }

  // 가주문 생성
  async createTempOrder(orderData: { orderName: string; totalAmount: number }) {
    const tempOrderId = this.generateRandomOrderId(25); // 주문 ID
    try {
      const order = await this.prismaService.tempOrder.create({
        data: {
          tempOrderId,
          ...orderData,
        },
      });
      return order;
    } catch (error) {
      throw new HttpException(
        'Failed to create temporary order',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 가주문 조회(실주문과 대조용도)
  async getTempOrdersData(orderId: string) {
    try {
      const order = await this.prismaService.tempOrder.findUnique({
        where: { tempOrderId: orderId },
      });
      if (!order) {
        throw new HttpException('Order not found', HttpStatus.NOT_FOUND);
      }
      return order;
    } catch (error) {
      throw new HttpException(
        'Failed to retrieve order data',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 가주문 삭제
  async deleteTempOrder(orderId: string) {
    try {
      const order = await this.prismaService.tempOrder.delete({
        where: { tempOrderId: orderId },
      });
      return order;
    } catch (error) {
      throw new HttpException(
        'Failed to delete temporary order',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }
}

결제 구현


@Injectable()
export class PaymentsService {
  private readonly tossUrl = process.env.TOSS_PAYMENTS_URL;
  private readonly secretKey = process.env.TOSS_SECRET_KEY;

  constructor(
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
    private readonly configService: ConfigService,
    private readonly tempOrdersService: TempOrdersService,
    private readonly eventService: EventService,
    private readonly prismaService: PrismaService,
  ) {}

  /**
   * 결제 승인 후 처리 과정을 수행
   * 1. toss 서버에 결제 승인 요청을 보냄
   * 2. toss 서버에서 결제 승인 응답을 받음
   * 3. 결제 완료 응답 데이터를 가주문(tempOrders) 테이블과 비교
   * 4. 결제 완료 응답 데이터를 구독(subscriptions) 테이블에 저장
   * 5. 결제 완료 응답 데이터를 결제내역(payments) 테이블에 저장
   * 6. 가주문(tempOrders) 테이블에서 해당 주문 삭제
   *
   * @param {ConfirmPaymentDto} confirmPaymentDto - 결제 승인 요청 데이터
   * @returns {Promise<PaymentResponseDto>} 결제 처리 결과를 반환
   */
  async confirmPayment(confirmPaymentDto: ConfirmPaymentDto) {
    const { userId, planId, orderId, amount, paymentKey } = confirmPaymentDto;
    const idempotency = uuidv4(); // 멱등키

    try {
      const response = await axios.post(
        `${this.tossUrl}/confirm`,
        {
          orderId,
          amount,
          paymentKey,
        },
        {
          headers: {
            Authorization: `Basic ${btoa(`${this.secretKey}:`)}`,
            'Content-Type': 'application/json',
            'Idempotency-Key': `${idempotency}`,
          },
        },
      );

      // 가주문과 비교
      const tempOrdersData =
        await this.tempOrdersService.getTempOrdersData(orderId);
      const { tempOrderId, totalAmount } = tempOrdersData;

      if (response.data.orderId !== tempOrderId) {
        throw new HttpException(
          '주문 ID가 예상 값과 일치하지 않습니다.',
          HttpStatus.BAD_REQUEST,
        );
      }
      if (response.data.totalAmount !== totalAmount) {
        throw new HttpException(
          '결제 총액이 예상 금액과 일치하지 않습니다.',
          HttpStatus.BAD_REQUEST,
        );
      }
      this.logger.info('가주문 비교 완료');

      // 결제 수단
      const method = response.data.method;
      let paymentMethod: string;
      switch (method) {
        case '카드':
          const issuerCode: CardIssuerCode = response.data.card.issuerCode;
          paymentMethod = CardIssuerName[issuerCode];
          break;
        case '간편결제':
          paymentMethod = response.data.easyPay.provider;
          break;
        case '휴대폰':
          paymentMethod = response.data.method;
          break;
        default:
          paymentMethod = '알 수 없음';
          break;
      }

      // 구독 생성 또는 업데이트
      if (planId === 3) {
        // 이벤트 결제라면 누적 금액 업데이트
        await this.eventService.updateEventAmount(amount);
      } else {
        const subscription = await this.getSubscriptionsByUserId(userId);
        if (subscription) {
          // 기존 구독이 있을 경우 업데이트
          await this.updateSubscription(userId, planId);
        } else {
          // 기존 구독이 없을 경우 생성
          await this.createSubscription(
            userId,
            planId,
            new Date(response.data.approvedAt),
            true,
          );
        }
        this.logger.info('구독 생성 또는 업데이트 완료');
      }

      // 결제 데이터 저장
      const payment = await this.createPayment(
        orderId,
        userId,
        planId,
        response.data.totalAmount,
        response.data.status,
        paymentKey,
        new Date(response.data.approvedAt),
      );
      this.logger.info('결제내역 생성 완료');

      // 가주문 삭제
      await this.tempOrdersService.deleteTempOrder(orderId);
      this.logger.info('가주문 삭제 완료');

      this.logger.info('결제 성공');
      return {
        title: '결제 성공',
        paymentMethod,
        payment,
      };
    } catch (err) {
      this.logger.error(`결제 실패: ${err.response.data.message}`);
      const customErrorResponse = {
        code: err.response.data.code,
        message: err.response.data.message,
      };
      throw new HttpException(
        customErrorResponse,
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  /**
   * 서비스에서 사용할 결제 관련 기능 구현
   * 1. 구독 조회
   * 2. 구독 생성
   * 3. 구독 업데이트
   * 4. 결제내역 조회
   * 5. 결제내역 생성
   * 6. 환불내역 생성
   * 7. 매일 자정 만료 구독 비활성화
   */

  // 구독 조회
  async getSubscriptionsByUserId(userId: number) {
    if (!userId) {
      throw new HttpException('userId는 필수 입니다.', HttpStatus.NOT_FOUND);
    }

    try {
      const subscriptions = await this.prismaService.subscription.findFirst({
        where: { userId },
        include: {
          plan: true,
        },
      });
      this.logger.info('구독 조회 완료');
      return subscriptions;
    } catch (error) {
      this.logger.error('구독 조회 실패:', error);
      throw new HttpException(
        'Failed to retrieve subscriptions',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 구독 생성
  async createSubscription(
    userId: number,
    planId: number,
    startDate: Date,
    isActive: boolean,
  ) {
    if (planId === 3) return;

    try {
      const endDate = new Date(startDate); // startDate를 복사하여 새로운 Date 객체 생성
      endDate.setMonth(startDate.getMonth() + 1); // endDate를 한 달 뒤로 설정

      const response = await this.prismaService.subscription.create({
        data: {
          userId,
          planId,
          startDate: startDate,
          endDate: endDate,
          isActive: isActive,
        },
      });
      this.logger.info('구독 생성 완료');
      return response;
    } catch (error) {
      this.logger.error('구독 생성 실패:', error);
      throw new HttpException(
        'Subscription creation failed',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 구독 업데이트
  async updateSubscription(userId: number, planId: number) {
    if (planId === 3) return;

    try {
      const startDate = new Date();
      const subscription = await this.prismaService.subscription.findFirst({
        where: { userId },
      });

      if (!subscription) {
        throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
      }

      const response = await this.prismaService.subscription.update({
        where: { id: subscription.id },
        data: { planId, startDate, isActive: true },
      });

      this.logger.info('구독 업데이트 완료');
      return response;
    } catch (error) {
      this.logger.error('구독 업데이트 실패:', error);
      throw new HttpException(
        'Failed to update subscription',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 결제내역 조회(userID) - 나의 모든 결제 내역 조회
  async getAllPaymentsByUserId(userId: number) {
    if (!userId) {
      throw new HttpException('userId는 필수 입니다.', HttpStatus.NOT_FOUND);
    }

    try {
      const payments = await this.prismaService.payment.findMany({
        where: { userId },
        include: {
          plan: true,
        },
      });

      this.logger.info('결제내역 조회 완료');
      return payments;
    } catch (error) {
      this.logger.error('결제내역 조회 실패:', error);
      throw new HttpException(
        'Failed to retrieve payments',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 결제내역 조회(orderId) - 주문번호로 특정 결제 내역 조회
  async getPaymentByOrderId(orderId: string) {
    if (!orderId) {
      throw new HttpException('주문번호는 필수 입니다.', HttpStatus.NOT_FOUND);
    }

    try {
      const payment = await this.prismaService.payment.findUnique({
        where: { orderId: orderId },
        include: {
          plan: true,
        },
      });

      this.logger.info('결제내역 조회 완료');
      return payment;
    } catch (error) {
      this.logger.error('결제내역 조회 실패:', error);
      throw new HttpException(
        'Failed to retrieve payment',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // planId로 결제내역 조회
  async getPaymentsByPlanId(planId: string) {
    if (!planId) {
      throw new HttpException('플랜 ID는 필수 입니다.', HttpStatus.NOT_FOUND);
    }

    const planIdNumber = parseInt(planId);

    try {
      const payment = await this.prismaService.payment.findMany({
        where: { planId: planIdNumber },
      });

      this.logger.info('결제내역 조회 완료');
      return payment;
    } catch (error) {
      this.logger.error('결제내역 조회 실패:', error);
      throw new HttpException(
        'Failed to retrieve payment',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 결제내역 생성
  async createPayment(
    orderId: string,
    userId: number,
    planId: number,
    amount: number,
    status: string,
    paymentKey: string,
    createdAt: Date,
  ) {
    try {
      const response = await this.prismaService.payment.create({
        data: {
          orderId,
          userId,
          planId,
          amount,
          status,
          paymentKey,
          createdAt: new Date(createdAt),
        },
      });

      this.logger.info('결제내역 생성 완료');
      return response;
    } catch (error) {
      this.logger.error('결제내역 생성 실패:', error);
      throw new HttpException(
        'Payment creation failed',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 환불내역 생성
  async cancelPayment(orderId: string, cancelReason: string) {
    const idempotency = uuidv4(); // 멱등키

    const payment = await this.prismaService.payment.findUnique({
      where: { orderId: orderId },
    });

    if (!payment) {
      this.logger.error('결제 내역을 찾을 수 없습니다.');
      throw new HttpException(
        '결제 내역을 찾을 수 없습니다.',
        HttpStatus.NOT_FOUND,
      );
    }

    if (payment.createdAt.getTime() + 3 * 24 * 60 * 60 * 1000 < Date.now()) {
      this.logger.error('결제 후 3일 이내에만 취소할 수 있습니다.');
      throw new HttpException(
        '결제 후 3일 이내에만 취소할 수 있습니다.',
        HttpStatus.BAD_REQUEST,
      );
    }

    const paymentKey = payment.paymentKey;

    if (!paymentKey) {
      this.logger.error('결제 키가 없습니다.');
      throw new HttpException(
        '결제 키가 없습니다.',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }

    this.logger.info('환불 요청 시작');

    try {
      await axios.post(
        `${this.tossUrl}/${paymentKey}/cancel`,
        { cancelReason },
        {
          headers: {
            Authorization: `Basic ${btoa(`${this.secretKey}:`)}`,
            'Content-Type': 'application/json',
            'Idempotency-Key': `${idempotency}`,
          },
        },
      );

      // 결제 상태 업데이트
      await this.prismaService.payment.update({
        where: { orderId: orderId },
        data: { status: 'REFUNDED' },
      });
      this.logger.info('결제 상태 업데이트 완료');

      // 구독 비활성화 또는 이벤트 누적 금액 업데이트
      if (payment.planId === 3) {
        await this.eventService.updateEventAmount(-payment.amount);
        this.logger.info('이벤트 누적 금액 업데이트 완료');
      } else {
        const subscription = await this.updateSubscription(
          payment.userId,
          payment.planId,
        );
        await this.prismaService.subscription.update({
          where: { id: subscription.id },
          data: { isActive: false },
        });
        this.logger.info('구독 비활성화 완료');
      }

      // 환불 기록 생성
      const refund = await this.prismaService.refund.create({
        data: {
          orderId,
          amount: payment.amount,
          status: 'REFUNDED',
          createdAt: new Date(),
        },
      });
      this.logger.info('환불내역 생성 완료');

      this.logger.info('환불 완료');
      return {
        message: '환불 완료',
        refund,
      };
    } catch (error) {
      this.logger.error(`환불 실패: ${error.response.data.message}`);
      throw new HttpException(
        `${error.response.data.message}`,
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  // 매일 자정 만료 구독 비활성화
  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
  async deactivateExpiredSubscriptions() {
    try {
      const currentDate = new Date();
      const subscriptions = await this.prismaService.subscription.findMany({
        where: {
          endDate: {
            lt: currentDate, // endDate가 현재 날짜보다 이전인 경우
          },
          isActive: true, // 현재 활성 상태인 구독만 대상으로 함
        },
      });

      subscriptions.forEach(async (subscription) => {
        await this.prismaService.subscription.update({
          where: { id: subscription.id },
          data: { isActive: false },
        });
      });

      console.log(`Deactivated ${subscriptions.length} expired subscriptions.`);
    } catch (error) {
      console.error('Failed to deactivate subscriptions:', error);
      throw new HttpException(
        'Failed to deactivate subscriptions',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }
}

프론트 구현

결제 요청

const widgetClientKey = import.meta.env.VITE_APP_TOSS_CLIENT_KEY;

/**
 * 토스 페이먼츠 결제 요청
 *
 * 공식 문서
 * https://docs.tosspayments.com/guides/learn/payment-flow
 * https://docs.tosspayments.com/guides/payment-widget/integration
 */
export default function Checkout() {
  const [paymentWidget, setPaymentWidget] = useState<PaymentWidgetInstance | null>(null);
  const paymentMethodsWidgetRef = useRef<PaymentMethodsWidget | null>(null);
  const { userInfo } = useUserStore();
  const { tempOrderId } = useTempOrderStore();
  const { planId, amount, planType } = usePlanStore();
  const navigate = useNavigate();

  // 결제 위젯 불러오기
  useEffect(() => {
    if (!userInfo.email) {
      console.error(ERROR_MESSAGES.PAYMENT.NO_USER_INFO);
      navigate(`/order-fail?code=404&message=데이터가 유실되었습니다. 다시 시도해주세요.`);
      return;
    }

    const customerKey = userInfo.email;
    const fetchPaymentWidget = async () => {
      try {
        const loadedWidget = await loadPaymentWidget(widgetClientKey, customerKey);
        setPaymentWidget(loadedWidget);
      } catch (error) {
        console.error('결제 위젯 불러오기 실패:', error);
      }
    };

    fetchPaymentWidget();
  }, [userInfo.email]);

  // 결제 수단 선택 UI, 이용약관 UI 렌더링
  useEffect(() => {
    if (paymentWidget == null) {
      return;
    }

    // 결제 수단 선택 UI 렌더링
    const paymentMethodsWidget = paymentWidget.renderPaymentMethods(
      '#payment-widget',
      { value: amount },
      { variantKey: 'DEFAULT' },
    );

    // 이용약관 UI 렌더링
    paymentWidget.renderAgreement('#agreement', { variantKey: 'AGREEMENT' });

    paymentMethodsWidgetRef.current = paymentMethodsWidget;
  }, [paymentWidget, amount]);

  // 결제 금액 업데이트
  useEffect(() => {
    const paymentMethodsWidget = paymentMethodsWidgetRef.current;

    if (paymentMethodsWidget == null) {
      return;
    }

    paymentMethodsWidget.updateAmount(amount);
  }, [amount]);

  // 결제 요청
  const handlePaymentRequest = async () => {
    const paymentRequestData = {
      orderId: tempOrderId,
      orderName: planType,
      customerEmail: userInfo.email,
      customerName: userInfo.nickname,
    };

    try {
      const response = await paymentWidget?.requestPayment(paymentRequestData);

      const userId = userInfo.id;
      const paymentKey = response?.paymentKey ?? '';
      const amount = response?.amount;
      const orderId = response?.orderId ?? '';
      const paymentType = response?.paymentType ?? '';

      navigate(
        `/order-approval?userId=${userId}&planId=${planId}&paymentKey=${encodeURIComponent(paymentKey)}&amount=${amount}&orderId=${encodeURIComponent(orderId)}&paymentType=${encodeURIComponent(paymentType)}`,
      );
    } catch (error) {
      console.error('Error requesting payment:', error);
      navigate(`/order-fail?code=500&message=결제 요청 중 오류가 발생했습니다. 다시 시도해주세요.`);
    }
  };

  return (
    <Area>
      {/* 결제 UI, 이용약관 UI 영역 */}
      <div id='payment-widget' />
      <div id='agreement' />
      {/* 결제하기 버튼 */}
      <ButtonBox>
        <Button onClick={handlePaymentRequest}>결제</Button>
      </ButtonBox>
    </Area>
  );
}

결제 진행

/**
 * 결제 요청 승인 후 실제 결제 진행
 */
export function OrderApproval() {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const { setUserInfo } = useUserStore();
  usePreventGoBack();
  usePreventRefresh();

  // 결제 정보
  const paymentData = {
    userId: Number(searchParams.get('userId')),
    planId: Number(searchParams.get('planId')),
    orderId: searchParams.get('orderId') ?? '',
    amount: Number(searchParams.get('amount')),
    paymentKey: searchParams.get('paymentKey') ?? '',
  };

  const paymentType = searchParams.get('paymentType');

  const { data: userData, isLoading } = useQueryGet<UserInfo | null>('getUserData', `${USER_URL.USER}/me`);
  const { mutate } = useMutationPost<ConfirmResponse, ConfirmRequest>(`${USER_URL.PAYMENTS}/confirm`, {
    onSuccess: () => {
      navigate(
        `/order-success?orderId=${paymentData.orderId}&amount=${paymentData.amount}&paymentKey=${paymentData.paymentKey}&paymentType=${paymentType}`,
      );
    },
    onError: (err: unknown) => {
      if (axios.isAxiosError(err)) {
        const message = err.response?.data?.message || 'Unknown error occurred';
        navigate(`/order-fail?code=${err.code}&message=${encodeURIComponent(message)}`);
        return;
      }

      console.error(err);
    },
  });

  // 유저 정보 및 토큰 업데이트 후 결제 진행
  useEffect(() => {
    if (isLoading) return;

    if (userData) {
      setUserInfo(userData);
    }

    mutate(paymentData);
  }, [userData]);

  useEffect(() => {
    const preventClose = (e: BeforeUnloadEvent) => {
      e.preventDefault();
      e.returnValue = '';
      return;
    };

    (() => {
      window.addEventListener('beforeunload', preventClose);
    })();

    return () => {
      window.removeEventListener('beforeunload', preventClose);
    };
  }, []);

  return (
    <Area className='result wrapper'>
      <Container className='box_section'>
        <h2>결제 중 입니다.</h2>
        <Spinner delay='0s' />
      </Container>
    </Area>
  );
}

결제 완료

export function OrderSuccess() {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  usePreventGoBack();

  return (
    <Area className='result wrapper'>
      <Container className='box_section'>
        <img src='https://static.toss.im/illusts/check-blue-spot-ending-frame.png' width='120' height='120' />
        <h2>결제 완료</h2>
        <Box>
          <p>{`주문번호: ${searchParams.get('orderId')}`}</p>
          <p>{`결제 금액: ${Number(searchParams.get('amount')).toLocaleString()}`}</p>
        </Box>
        <Button onClick={() => navigate('/server')}>처음으로 돌아가기</Button>
      </Container>
    </Area>
  );
}

0개의 댓글