inblog logo
|
LifeLog, DevLog
    TIL

    TIL 20260201~0219

    제한적인 프론트엔드 템플릿을 통한 결제, 구독 백엔드 구현기
    KYJTHEYJ's avatar
    KYJTHEYJ
    Feb 19, 2026
    TIL 20260201~0219
    Contents
    프로젝트 개요결제 진행기KEEP1. Internal, External Service 분리Reason2. 리프레쉬 토큰, 로그아웃 블랙리스트리프레쉬 토큰 발급리프레쉬 토큰 재발급로그아웃 (블랙리스트 등록)Reason3. Lock OrderingReasonProblem1. 비관적 락 남용 우려Problem Reason2. 포인트 만료 처리 부재Problem ReasonTRY1. Test CodeTRY Reason2. CI/CDTRY Reason

    프로젝트 개요

    상품에 재고가 있고 회원에 포인트가 있는 환경에서
    결제와 구독을 구현하여 일반 결제의 흐름과 구독의 흐름을 파악해보는 것이 목적

    결제는 일반 카드 결제를 원화로 결제한다는 환경 하에 일반결제를 구현,
    구독은 빌링키를 만들고, 정기 구독되어 빌링 결제 내역까지 확인하도록 구현한다

    그 중에 내가 맡은 도메인은 결제로, 추가로 환불과 웹훅 작동에 대한
    흐름 검증, 리팩토링도 수행 하였다

    결제 진행기

    먼저 결제는 상품을 구매하기 위해 주문을 만들고
    결제를 진행하여 주문을 완료하고 일정 시간이 지나면 확정이 되도록 구현했다

    여기서 웹훅의 개념을 알게 되었는데,
    웹훅은 상대 서버의API 를 호출하는 것과 달리 실시간성을
    갖기 위해 어떤 이벤트가 발생하면 URL 을 등록해두어
    우리 서버의 API를 호출하도록 하는 것

    결제가 완료되었을 때 즉시 포인트나 상품에 대한 검증이 필요했는데
    이런 웹훅을 사용하여 현 결제를 즉시 검증하여 그 즉시 취소 처리도
    진행할 수 있게 되었다

    또 이러한 웹훅을 사용 했을 때 장점은 실시간성이라서
    재고와 회원 등에 동시성 제어 (이 경우 비관적 락을 사용했다)를 해두고
    웹훅에 검증을 달아두었다면 한정된 재고에 주문이
    마구 생성되고 결제가 우후죽순으로 처리되어도 선착순으로 제어할 수 있다는 점이다

    다만 여기서 하나더 조심해야 할 것은 이런 경우에 상품 재고 차감이 결제 완료 시점에 진행되어야 할텐데, 동시성 제어를 위해 비관적 Lock 을 사용한 시점에선 데드락을
    주의 해야하기 위해 항상 같은 재고 처리 순서로 재고가 차감 되어야한다

    1 → 2 → 3 … 순으로 재고 처리가 갑자기
    2 → 1 → 3 … 순으로 재고 처리가 들어와 버리면

    1번과 2번 상품이 동시에 대기에 빠져
    서로가 풀리길 기다리는 데드락 상황이 걸리는 것을 알았다
    결론은 동일 처리에 대하여 트랜잭션이 항상 같은 방향으로만
    락을 잡아야 한다는 것을 알게 되었다

    위 코드는 웹훅의 외부처리 프로세스 담당부인데
    카드 결제를 진행하여 뜬 결제창에 대해 결제가 완료되면 (첫 번째 사진)
    실제 결제 금액을 결제 담당 (이번 프로젝트에선 포트원 결제 대행 SDK를 이용했다)
    회사의 서버에서 받아와 일치하는지 프로세스를
    구현해서 검증과 후속 처리 프로세스를 작동하게 했다

    여기서 왜 외부처리라는 용어를 서술했는지
    궁금증이 든다면 원래 이런 내부 데이터 처리를 하기 위해 트랜잭션을 수행했을 때,
    외부 API 가 포함된다면 외부 API가 처리 될 때 까지 기다려야 한다는 점
    (이를 점유라고 한다)을 알게되어
    트랜잭션 내부에 외부 API 연동부가 없도록 분리를 수행하였다
    (InternalService, ExternalService 로 분리)
    이런 분리말고도 TranscationTemplate 라는 객체를 수행하는 법이 있고
    구독부에서는 활용했는데 따로 포스팅을 진행하여 탐구 해보려한다

    이런 기술적인 포인트 말고도 결제가 진행되었을 때
    주문의 상태를 완료로 놓고 스케줄러를 통하던 사용자가
    호출하는 확정 API를 통하여만 포인트를 지급한다던지,
    등급을 변경한다던지 하는 비즈니스적 로직도 알게 되었다


    KEEP

    1. Internal, External Service 분리

    • 외부 API 호출부가 트랜잭션 내부에 있을 경우 해당 API가 완료 될 때 까지 트랜잭션이 유지되어 버려 문제 발생의 소지가 있음

      • 보통 이럴 경우 분리하여 트랜잭션 내에 외부 API 호출이 없도록 하는 것이 좋음 (분리도 좋지만 TransactionTemplate 에 대해서도 알아두어야 할 듯)

      • getPaymentAmount 메서드는 외부 API를 호출

      • 그 이후 트랜잭션 프로세스 (webhookInternalProcess) 를 구현하게 하여 트랜잭션에 외부 API 개입을 분리하였음

    Reason

    해당 API가 완료될 때 까지 트랜잭션이 유지가 되므로 문제 발생 소지가 있음 타임 아웃이 떨어지거나 할 여지도 있어, 분리의 경우가 좋음

    2. 리프레쉬 토큰, 로그아웃 블랙리스트

    • JWT 를 프로젝트에서 사용할 때 액세스 토큰은 만료시간을 적게 잡아야 비교적 보안이 안전한데 이럴 경우 액세스 토큰의 만료가 잦으니 재발급을 매번 요구하면 (재로그인 등) UX가 망가진다

    • 그러므로 리프레쉬 토큰이라는 별도의 토큰을 Redis 나 DB에 보관 한 후 쿠키로 발급하여 활용해야한다

      • 액세스 토큰에 비해 긴 만료시간을 가져야한다

    • 보통 쿠키에 담아 사용하고 만료되었을 경우 재발급 API를 프론트엔드에서 호출하여 리프레쉬 토큰과 액세스 토큰을 재발급하여 순환 구조를 가져야 안전하다

    • 로그아웃의 경우엔 리프레쉬 토큰 저장 데이터를 삭제하고 블랙리스트에 기존 액세스 토큰을 등록한다

      • 인증 단계에서 조회하여 블랙리스트에 등록되어 있다면 쳐내는 방식으로 사용한다

    리프레쉬 토큰 발급

    @PostMapping("/login")
    public ResponseEntity<BaseResponse<LoginResponse>> login(
            @RequestBody LoginRequest request
            , HttpServletResponse response
    ) {
        String email = request.email();
    
        try {
            // AuthenticationManager를 통한 인증
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(request.email(), request.password())
            );
    
            // JWT 토큰 생성
            TokenInfo tokens = authService.login(request);
    
            // 리프레쉬 토큰은 쿠키로 반환
            Cookie refreshTokenCookie = new Cookie("refreshToken", tokens.refreshToken());
            refreshTokenCookie.setPath("/");
            refreshTokenCookie.setHttpOnly(true); // 쿠키 변조 방지
            refreshTokenCookie.setMaxAge(3600 * 24 * 7); // 비교적 긴 만료시간
            //refreshTokenCookie.setHttpOnly(true); // 현재는 환경상 주석처리
            response.addCookie(refreshTokenCookie);
    
            // 액세스 토큰은 헤더로 반환
            return ResponseEntity.ok()
                .header("Authorization", "Bearer " + tokens.accessToken())
                .body(BaseResponse.success(HttpStatus.OK.name(), null, new LoginResponse(true, email)));
    
        } catch (AuthenticationException e) {
            log.error("Login Error : {}", e.getMessage());
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(BaseResponse.fail(HttpStatus.UNAUTHORIZED.name(), MSG_NOT_MATCH_LOGIN, new LoginResponse(false, MSG_NOT_MATCH_LOGIN)));
        }
    }
    

    리프레쉬 토큰 재발급

    // Refresh Token 을 통한 재발급
    @PostMapping("/refresh")
    public ResponseEntity<BaseResponse<RefreshResponse>> refresh(@CookieValue(name = "refreshToken") String refreshToken) {
        RefreshResponse response = authService.refreshToken(refreshToken);
        return ResponseEntity.status(HttpStatus.OK)
                .header("Authorization", "Bearer " + response.accessToken())
                .body(BaseResponse.success(HttpStatus.OK.name(), null, response));
    }
    
    @Transactional
    public RefreshResponse refreshToken(String refreshToken) {
        if(jwtTokenProvider.validateToken(refreshToken)) {
            RefreshToken existRefreshToken = refreshTokenRepository.findByRefreshToken(refreshToken).orElseThrow(() -> new ServiceErrorException(ErrorEnum.ERR_TOKEN_EMPTY));
    
            // 리프레쉬 토큰이 만료 되었는지 확인
            if(existRefreshToken.getExpirationAt().isBefore(LocalDateTime.now())) {
                refreshTokenRepository.delete(existRefreshToken);
                throw new ServiceErrorException(ERR_TOKEN_EXPIRE);
            }
    
            // 유저 정보 가져오기
            Member member = existRefreshToken.getMember();
            if(!memberRepository.existsByEmailAndDeletedFalse(member.getEmail())) {
                refreshTokenRepository.delete(existRefreshToken);
                throw new ServiceErrorException(ERR_NOT_FOUND_MEMBER);
            }
    
            // 기존 토큰 삭제
            refreshTokenRepository.delete(existRefreshToken);
    
            // 새 토큰 발급
            String newAccessToken = jwtTokenProvider.createAccessToken(member.getEmail(), member.getMemberUid(), member.getRole());
            String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getEmail(), member.getMemberUid(), member.getRole());
    
            // 새 리프레쉬 토큰 저장
            refreshTokenRepository.save(RefreshToken.register(member, newRefreshToken, jwtTokenProvider.getExpireTime(newRefreshToken)));
    
            return new RefreshResponse(newAccessToken, newRefreshToken);
        } else {
            log.error("Refresh Tokens Error : {}", "리프레쉬 토큰 올바르지 않음");
            throw new ServiceErrorException(ErrorEnum.ERR_TOKEN_INVALID);
        }
    }
    

    로그아웃 (블랙리스트 등록)

    // 로그아웃
    // TODO 프론트에서 동작하게 해야함
    @PostMapping("/logout")
    public ResponseEntity<BaseResponse<Void>> logout(
            @AuthenticationPrincipal UserDetails loginMemberInfo
            , @RequestHeader(HttpHeaders.AUTHORIZATION) String accessTokenWithBearer
    ) {
        String email = loginMemberInfo.getUsername();
        String accessToken = accessTokenWithBearer.substring("Bearer ".length());
    
        authService.logout(accessToken, email);
    
        return ResponseEntity.status(HttpStatus.OK).body(BaseResponse.success(HttpStatus.OK.name(), MSG_LOGOUT, null));
    }
    
    @Transactional
    public void logout(String accessToken, String email) {
        RefreshToken refreshToken = refreshTokenRepository.findByEmail(email).orElseThrow(() -> new ServiceErrorException(ErrorEnum.ERR_TOKEN_EMPTY));
        refreshTokenRepository.delete(refreshToken);
    
        blackAccessTokenRepository.save(BlackAccessToken.register(accessToken, jwtTokenProvider.getExpireTime(accessToken)));
    }
    

    Reason

    리프레쉬 토큰이 없으면 액세스 토큰의 만료를 길게 두어야하고 이는 보안에 좋지 않기 때문에 이러한 활용법을 알아두는 것은 필수로 보인다

    3. Lock Ordering

    재고 차감이 동시다발로 이루어진다는 환경이라면 동시성 제어를 위하여 그리고 실패하지 않아야 하는 건이니 비관적 락을 사용하게 될 것이다

    그런데 이 재고 차감이 항상 같은 순서로 이루어져야지 랜덤으로 이루어진다면 서로가 서로의 락이 풀리기를 기다리는 데드락 상황이 터질 수 있다

    그러므로 중간에 주문 상품 (ProductOrder)을 정렬하여 항상 같은 순으로 접근할 수 있도록 하였다

    // 결제 완료
    @Transactional
    public PaymentResponse completePayment(String paymentId) {
        Payment payment = paymentRepository.findByPortOneIdWithLock(paymentId)
                .orElseThrow(() -> new ServiceErrorException(ERR_NOT_FOUND_PAYMENT));
    
        List<ProductOrder> productOrderList = productOrderRepository.findByOrderAndDeletedFalse(payment.getOrder());
    
        // 멱등성 처리
        if (payment.getStatus() != PaymentStatus.PENDING) {
            Order order = payment.getOrder();
            return PaymentResponse.register(
                    order.getStatus() == OrderStatus.COMPLETE
                    , String.valueOf(order.getOrderId())
                    , order.getStatus().name()
                    , order.getStatus() == OrderStatus.COMPLETE ? "결제 완료" : "결제 실패"
            );
        }
    
        Order order = payment.getOrder();
        Member member = memberRepository.findByIdWithLock(order.getMember().getMemberId()).orElseThrow(() -> new ServiceErrorException(ERR_NOT_FOUND_MEMBER));
    
        try {
            // 재고 및 포인트 재검증
            paymentValidator.validateForConfirm(member, order, productOrderList);
    
            // 결제 및 주문 상태 변경
            payment.updateStatus(PaymentStatus.COMPLETE);
            order.updateStatus(OrderStatus.COMPLETE);
    
            // 동시에 접근 시 여러 상품의 락을 획득할 때는 항상 일정한 순서로 진입해야 데드락을 막을 수 있음 (Lock Ordering)
            productOrderList.sort(Comparator.comparing(productOrder -> productOrder.getProduct().getId()));
    
            // 재고 차감
            for (ProductOrder productOrder : productOrderList) {
                Product product = productRepository.findByIdWithLock(productOrder.getProduct().getId())
                        .orElseThrow(() -> new ServiceErrorException(ERR_NOT_FOUND_PRODUCT));
                product.updateStock(productOrder.getQuantity());
            }
    
            // 포인트 차감만 처리
            pointService.usePoint(member, order);
        } catch (Exception e) {
            log.error("결제 완료 진행 중 오류 : {}", e.getMessage());
            payment.updateStatus(PaymentStatus.FAIL);
    
            // PaymentId 가 PortOne에 기록된 채로 실패한 경우, 재시도가 불가함 (paymentId 재활용 처리라 처리 불가 발생)
            order.updateStatus(OrderStatus.FAIL);
    
            return PaymentResponse.register(false, String.valueOf(payment.getOrder().getOrderId()), OrderStatus.FAIL.name(), "결제 실패");
        }
    
        return PaymentResponse.register(
                true
                , order.getOrderId().toString()
                , OrderStatus.COMPLETE.name()
                , "결제 완료"
        );
    }
    

    Reason

    데드락을 방지한다
    = 모든 트랜잭션은 항상 같은 방향으로 데이터를 처리해야 한다
    (비순환이 되어야한다)

    Problem

    1. 비관적 락 남용 우려

    Problem Reason

    현재 회원, 제품, 결제 테이블에 비관적 락을 활용 중인데 이럴 경우 결제의 동시성은 해결되지만 결제 흐름에 모든 테이블에 거의 락을 사용하는 것이라 성능적 문제가 발생할 여지가 있다 추후 개선 방향을 새로 모색해봐야 할 듯하다

    2. 포인트 만료 처리 부재

    Problem Reason

    단순하게 구현을 하지 못했는데, 이 부분에도 주문 확정, 구독 정산 처럼 스케줄러를 활용한다면 서버가 늘어난 경우에 특유의 스케줄러 문제 ( 전 서버에 동시 적용되는 ) 도 곁들여 생각해 볼 여지가 있을 것 같다

    TRY

    1. Test Code

    TRY Reason

    항상 구현할 때마다
    작성하는 버릇을 들이려 해도 일정에 치이니 놓치게 되고 못해버렸다..
    테스트 코드 작성이 없으니 결제 완료 후 웹훅 등의 흐름을 고려했을 때
    재귀적인 질문에 자꾸 빠져 곤란한 적이 있었다
    다음엔 작성한 코드에 대해 여러 방면으로 먼저 테스트 코드를
    즉시 진행하여 흐름에 대해 단위적인 의문이 들지 않도록 해야겠다

    2. CI/CD

    TRY Reason

    해보고 싶었지만 메인 기능 구현과 리팩토링,
    버그 수정 등의 일정에 치여 구현을 하지 못했다
    개인적으로 포크하여 구현해볼 예정이다

    Share article
    Contents
    프로젝트 개요결제 진행기KEEP1. Internal, External Service 분리Reason2. 리프레쉬 토큰, 로그아웃 블랙리스트리프레쉬 토큰 발급리프레쉬 토큰 재발급로그아웃 (블랙리스트 등록)Reason3. Lock OrderingReasonProblem1. 비관적 락 남용 우려Problem Reason2. 포인트 만료 처리 부재Problem ReasonTRY1. Test CodeTRY Reason2. CI/CDTRY Reason

    LifeLog, DevLog - https://github.com/KYJTHEYJ

    RSS·Powered by Inblog