나의 개발일지

[SpringBoot] WebClient 비동기 흐름 내 Transaction 비활성화 트러블슈팅 - 1 ( 토스페이먼츠 WebClient 로 구현 ) 본문

Language/SpringBoot

[SpringBoot] WebClient 비동기 흐름 내 Transaction 비활성화 트러블슈팅 - 1 ( 토스페이먼츠 WebClient 로 구현 )

사각분무기 2024. 2. 22. 15:33

HotShare 1차 프로젝트 기간을 보내는데 있어 가장 공을 많이 들인 부분은 아무래도 토스페이먼츠인 것 같습니다

 

타 api 호출을 통한 시스템 구현을 오롯이 혼자 하는 건 처음이었으니요.

 

이미 수업 시간 중에 토스페이먼츠를 배운 전적이 있지만 큰 도움은 되지 않았던 것 같습니다.

 

그 이유는 온전히 저에게 있었습니다.

  1. rest api 에 대한 이해 부족
  2. 선수적인 리액트 학습 없이 next.js 환경에서 구현

이것 때문에 속 많이 썩었네요.

 

가장 힘든 부분은 next.js 였지만 사실 가장 쉬운 부분 또한 next.js였습니다.

 

제가 선수 지식이 부족해서였을 뿐 그렇게 어려운 내용은 아니었거든요.

 

백엔드를 위한 자바 코드도 할만했습니다.

 

토스페이먼츠는 개발자 문서가 잘 작성되어있고 샘플 코드도 언어별로 볼 수 있다는 점이 매력적이네요.

 

샘플 코드로 작성된 자바 코드입니다.

@Controller
public class WidgetController {
  
  private final Logger logger = LoggerFactory.getLogger(this.getClass());

  @RequestMapping(value = "/confirm")
  public ResponseEntity<JSONObject> confirmPayment(@RequestBody String jsonBody) throws Exception {
    
    JSONParser parser = new JSONParser();
    String orderId;
    String amount;
    String paymentKey;
    try {
      // 클라이언트에서 받은 JSON 요청 바디입니다.
      JSONObject requestData = (JSONObject) parser.parse(jsonBody);
      paymentKey = (String) requestData.get("paymentKey");
      orderId = (String) requestData.get("orderId");
      amount = (String) requestData.get("amount");
    } catch (ParseException e) {
      throw new RuntimeException(e);
    };
    JSONObject obj = new JSONObject();
    obj.put("orderId", orderId);
    obj.put("amount", amount);
    obj.put("paymentKey", paymentKey);
    
    // 토스페이먼츠 API는 시크릿 키를 사용자 ID로 사용하고, 비밀번호는 사용하지 않습니다.
    // 비밀번호가 없다는 것을 알리기 위해 시크릿 키 뒤에 콜론을 추가합니다.
    String widgetSecretKey = "test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6";
    Base64.Encoder encoder = Base64.getEncoder();
    byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes("UTF-8"));
    String authorizations = "Basic " + new String(encodedBytes, 0, encodedBytes.length);
    
    // 결제를 승인하면 결제수단에서 금액이 차감돼요.
    URL url = new URL("https://api.tosspayments.com/v1/payments/confirm");
    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    connection.setRequestProperty("Authorization", authorizations);
    connection.setRequestProperty("Content-Type", "application/json");
    connection.setRequestMethod("POST");
    connection.setDoOutput(true);
    
    OutputStream outputStream = connection.getOutputStream();
    outputStream.write(obj.toString().getBytes("UTF-8"));
    
    int code = connection.getResponseCode();
    boolean isSuccess = code == 200 ? true : false;
    
    InputStream responseStream = isSuccess ? connection.getInputStream() : connection.getErrorStream();
    
    // 결제 성공 및 실패 비즈니스 로직을 구현하세요.
    Reader reader = new InputStreamReader(responseStream, StandardCharsets.UTF_8);
    JSONObject jsonObject = (JSONObject) parser.parse(reader);
    responseStream.close();
    
    return ResponseEntity.status(code).body(jsonObject);
  }
  
}

 

해당 내용은 아래 페이지에서 가져왔습니다.

https://docs.tosspayments.com/guides/payment-widget/integration?backend=java

 

연동하기 | 토스페이먼츠 개발자센터

토스페이먼츠의 간편한 결제 연동 과정을 한눈에 볼 수 있습니다. 각 단계별 설명과 함께 달라지는 UI와 코드를 확인해보세요.

docs.tosspayments.com

 

이 상태에서 필요한 부분, 가령 키와 엔드포인트 주소와 같은 기본적인 설정만 바꿔주고 사용해도 문제는 없습니다.

 

작동에 문제는 없는거죠. 하지만 완성된 모습을 가지고 팀장님께 가니 저에게 힌트를 하나 주셨습니다.

 

WebClient가 있다고 말이죠.

 

그리고 이를 통해 저희 프로젝트에서 사용하게 된 코드는 아래와 같습니다.

 

Config

@Configuration
public class WebClientConfig {
    @Bean
    public ReactorResourceFactory resourceFactory() {
        ReactorResourceFactory factory = new ReactorResourceFactory();
        factory.setUseGlobalResources(false);
        return factory;
    }

    @Bean
    public WebClient webClient() {

        Function<HttpClient, HttpClient> mapper = client -> {

            return HttpClient.create()
                    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
                    .doOnConnected(connection -> {
                        connection.addHandlerLast(new ReadTimeoutHandler(10))
                                .addHandlerLast(new WriteTimeoutHandler(10));
                    })
                    .responseTimeout(Duration.ofSeconds(2));
        };

        ClientHttpConnector connector =
                new ReactorClientHttpConnector(resourceFactory(), mapper);

        return WebClient.builder().clientConnector(connector).build();
    }
}

 

Service

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TossService {
    private final WebClient webClient;

    private static String authorization;

    @Value("${custom.tossPayments.widget.secretKey}")
    private void setTossPaymentsWidgetSecretKey(String tossPaymentsWidgetSecretKey) {
        String encodedKey = Base64.getEncoder().encodeToString((tossPaymentsWidgetSecretKey + ":").getBytes(UTF_8));
        authorization = "Basic " + encodedKey;
    }

    @Transactional
    public Mono<TossPaymentRequest> confirmTossPayment(final TossConfirmRequest tossConfirmRequest) {

        return webClient.post()
                .uri("https://api.tosspayments.com/v1/payments/confirm")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", authorization)
                .bodyValue(tossConfirmRequest)
                .retrieve()
                .onStatus(
                        HttpStatusCode::isError,
                        clientResponse -> Mono.error(new PaymentException(PAYMENT_API_CALL_FAILED)))
                .bodyToMono(TossPaymentRequest.class);
    }
}

 

컨트롤러에서 모든 처리를 도맡던 기존의 샘플코드의 내용이 다른 컨트롤러에서도 사용될 수 있기 때문에 별개의 Service 클래스로 정의해줬습니다.

 

처음 접해보는 비동기 기반 코드라 그런지 정말 어렵네요.

 

대신 결과물을 보면 확실히 깔끔합니다.

 

오래된 방식인 HttpURLConnection 대신 최신의 WebClient를 사용하니 장점이 많네요.

 

  1. 가독성이 좋고 직관적인 빌더 패턴
  2. 객체를 통째로 넣어도 내부에서 자체적으로 인코딩 진행
  3. 비동기 환경

등등 좋은 점만 가득하다고 생각하고 있었습니다

 

트랜잭션이 안 먹힌다는 사실을 알기 전까지는 말이죠