Programming/Spring Boot

Spring Boot + PayPal 결제 구현해 보기 (sandbox 테스트 환경)

Jan92 2023. 6. 6. 23:12
반응형

Spring Boot + PayPal 결제 구현

spring boot + paypal 결제

해당 포스팅은 PayPal 결제 테스트 환경인 PayPal sandbox를 사용하여 Spring Boot 프로젝트에서 PayPal Checkout API를 통한 결제 테스트를 구현해 보며 정리한 내용입니다.

 

전체 코드가 있는 github 주소는 포스팅 맨 하단에 링크되어 있으니 부족한 내용은 해당 링크를 참고해 주시면 좋을 것 같습니다.

(아래 구현된 부분은 전체 api 중 아주 일부분에 해당됩니다.)

 

 


결제 테스트에 필요한 부분

API Credentials

 

Sanbox test accounts

테스트에 앞서 필요한 것은 테스트를 위한 'PayPal 계정'이 필요하며, 해당 계정에서 생성한 'REST API apps', 'Sandbox text accounts'가 필요합니다.

 

***

test account의 View/Edit account 부분을 통해 해당 테스트 계정의 아이디와 비밀번호를 확인할 수 있으며, 해당 데이터를 가지고 코드 구현 이후 결제 테스트를 진행해 볼 수 있습니다.

(필요한 경우 test account를 추가로 생성할 수 있으며, 해당 테스트에서는 default 계정을 사용하였습니다.)

 

https://developer.paypal.com/home

(PayPal Developer 사이트)

 

 


Checkout api 결제 로직

checkout api workflow

Authentication (POST - /v1/oauth2/token)

Create order (POST - /v2/checkout/orders)

Show order details (GET - /v2/checkout/orders/{id})

 

아래 예시에서는 PayPal REST api 중 'Authentication', 'Create order' 두 가지 api가 사용되었으며, 포스팅 맨 하단 링크된 github의 전체 코드에서는 'Show order details' api를 사용한 예시도 확인할 수 있습니다.

 

 

Checkout api를 사용하는 전체 결제 로직의 경우 아래와 같습니다.

 

1. 구매자는 앱 서버에서 결제를 시작합니다.

2. 앱 서버에서는 clientId, secret을 포함하여 Authentication api를 호출합니다.

(이때 사용되는 clientId, secret은 REST API apps의 clientId, secret입니다.)

3. Authentication api 호출 결과로 access token을 받아옵니다.

4. 앱 서버에서는 access token 및 주문 정보를 포함하여 Create order api 호출을 통해 주문을 생성합니다.

5. 생성된 주문 결과를 앱 서버에서 받습니다.

6. 결제 승인을 위해 주문 결과 데이터 중 결제 승인 페이지를 구매자에게 리다이렉트 시켜줍니다.

7. 구매자는 계정 로그인 후 결제를 승인합니다.

8. 앱 서버에서는 결제에 대한 결과를 받습니다.

 

 


Spring Boot + PayPal 결제 코드 구현

 

- build.gradle

//build.gradle 중 dependencies 부분
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	// https://mvnrepository.com/artifact/com.mysql/mysql-connector-j
	implementation group: 'com.mysql', name: 'mysql-connector-j', version: '8.0.33'
	// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-jpa
	implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '2.7.10'
	// https://mvnrepository.com/artifact/org.json/json
	implementation group: 'org.json', name: 'json', version: '20230227'
}

아래 예시에서는 paypal과 관련된 의존성을 따로 사용하지 않고 직접 구현한 'PaypalHttpClient'를 사용하여 통신합니다.

 

 

- application.yml, PaypalProperties

paypal:
  baseUrl: https://api-m.sandbox.paypal.com
  clientId: {REST API apps clientId}
  secret: {REST API apps secret}

(application.yml)

 

@Component
@Getter
@Setter
@ConfigurationProperties(prefix = "paypal")
public class PaypalProperties {

    private String baseUrl;
    private String clientId;
    private String secret;
}

(PaypalProperties class)

 

@ConfigurationProperties 어노테이션을 통해 application.yml 파일에 있는 property 값을 바인딩시키고 @Component 어노테이션을 통해 해당 클래스를 bean으로 등록합니다.

 

 

- Order Entity

@Data
@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "paypal_order_id")
    private String paypalOrderId;
    
    @Column(name = "paypal_order_status")
    private String paypalOrderStatus;
}

(주문 정보를 저장하기 위한 Order Entity)

 

 

- PaypalEndpoints enum

public enum PaypalEndpoints {
    GET_ACCESS_TOKEN("/v1/oauth2/token"),
    GET_CLIENT_TOKEN("/v1/identity/generate-token"),
    ORDER_CHECKOUT("/v2/checkout/orders");

    private final String path;

    PaypalEndpoints(String path) {
        this.path = path;
    }

    public static String createUrl(String baseUrl, PaypalEndpoints endpoint) {
        return baseUrl + endpoint.path;
    }

    public static String createUrl(String baseUrl, PaypalEndpoints endpoint, String... params) {
        return baseUrl + endpoint.path + String.format("/%s", params);
    }
}

enum class를 통해 각 요청에 대한 endpoint를 정의합니다.(+ createUrl method)

 

 

- PaypalHttpClient

import static com.example.paypal.enums.PaypalEndpoints.*;

@Slf4j
@Component
public class PaypalHttpClient {

    private final HttpClient httpClient;
    private final PaypalProperties paypalProperties;
    private final ObjectMapper objectMapper;

    private static final String BEARER_TYPE = "Bearer ";

    @Autowired
    public PaypalHttpClient(PaypalProperties paypalProperties, ObjectMapper objectMapper) {
        this.paypalProperties = paypalProperties;
        this.objectMapper = objectMapper;
        httpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
    }

    public AccessTokenResponseDto getAccessToken() throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(createUrl(paypalProperties.getBaseUrl(), GET_ACCESS_TOKEN)))
                .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .header(HttpHeaders.AUTHORIZATION, encodeBasicCredentials())
                .header(HttpHeaders.ACCEPT_LANGUAGE, "en_US")
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
                .POST(HttpRequest.BodyPublishers.ofString("grant_type=client_credentials"))
                .build();

        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        String content = response.body();

        return objectMapper.readValue(content, AccessTokenResponseDto.class);
    }

    public CreateOrderResponseDto createOrder(OrderDto orderDto) throws Exception {
        AccessTokenResponseDto accessTokenDto = getAccessToken();
        String payload = objectMapper.writeValueAsString(orderDto);

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(createUrl(paypalProperties.getBaseUrl(), ORDER_CHECKOUT)))
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .header(HttpHeaders.AUTHORIZATION, BEARER_TYPE + accessTokenDto.getAccessToken())
                .POST(HttpRequest.BodyPublishers.ofString(payload))
                .build();

        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        String content = response.body();

        return objectMapper.readValue(content, CreateOrderResponseDto.class);
    }

    private String encodeBasicCredentials() {
        String input = paypalProperties.getClientId() + ":" + paypalProperties.getSecret();
        return "Basic " + Base64.getEncoder().encodeToString(input.getBytes(StandardCharsets.UTF_8));
    }
}

직접 구현한 PaypalHttpClient class에서는 내부적으로 'java.net.http.HttpClient'를 사용하여 통신하는데요.

 

getAccessToken() 메서드를 통해 Authentication api(/v1/oauth2/token)를 호출하여 access token을 가져오고, createOrder() 메서드에서는 access token 및 주문 데이터를 가지고 Create order api(/v2/checkout/orders)를 호출합니다.

 

 

- CheckoutController

@Slf4j
@RequestMapping("/checkout")
@RequiredArgsConstructor
@Controller
public class CheckoutController {

    private final PaypalHttpClient paypalHttpClient;
    private final OrderRepository orderRepository;

    @PostMapping
    public ResponseEntity<CreateOrderResponseDto> checkout(@RequestBody OrderDto orderDto) throws Exception {
        PaypalAppContextDto appContext = new PaypalAppContextDto();
        appContext.setReturnUrl("http://localhost:8083/checkout/success");
        appContext.setBrandName("jan92");
        appContext.setLandingPage(PaymentLandingPage.BILLING);
        orderDto.setApplicationContext(appContext);
        CreateOrderResponseDto orderResponse = paypalHttpClient.createOrder(orderDto);

        Order order = new Order();
        order.setPaypalOrderId(orderResponse.getId());
        order.setPaypalOrderStatus(orderResponse.getStatus().toString());
        orderRepository.save(order);

        return ResponseEntity.ok(orderResponse);
    }

    @GetMapping("/success")
    public ResponseEntity<?> paymentSuccess(HttpServletRequest request) {
        String orderId = request.getParameter("token");
        Order targetOrder = orderRepository.findByPaypalOrderId(orderId);
        targetOrder.setPaypalOrderStatus(OrderStatus.APPROVED.toString());
        orderRepository.save(targetOrder);

        return ResponseEntity.ok().body("Payment success");
    }
}

(CheckoutController)

 

 


결제 테스트

PayPal 결제 테스트

***

아래 공식 문서의 요청 Payload를 살펴보면 purchase_units에는 amount 외에도 여러 데이터가 들어가는 것을 확인할 수 있는데요.

해당 예시에서는 결제 동작을 목표로 최대한 간단하게 구현되었다는 점을 참고 부탁드립니다.

 

https://developer.paypal.com/docs/api/orders/v2/#orders_create

(Create order api docs)

 

 

{
    "id": "XXXXXXXXXXXXXXXXX",
    "status": "CREATED",
    "links": [
        {
            "href": "https://api.sandbox.paypal.com/v2/checkout/orders/XXXXXXXXXXXXXXXXX",
            "rel": "self",
            "method": "GET"
        },
        {
            "href": "https://www.sandbox.paypal.com/checkoutnow?token=XXXXXXXXXXXXXXXXX",
            "rel": "approve",
            "method": "GET"
        },
        {
            "href": "https://api.sandbox.paypal.com/v2/checkout/orders/XXXXXXXXXXXXXXXXX",
            "rel": "update",
            "method": "PATCH"
        },
        {
            "href": "https://api.sandbox.paypal.com/v2/checkout/orders/XXXXXXXXXXXXXXXXX/capture",
            "rel": "capture",
            "method": "POST"
        }
    ]
}

위 결제 테스트의 요청 결과로 다음과 같은 데이터를 얻을 수 있는데요. ("status": "CREATED")

"rel": "approve"에 해당하는 링크로 사용자를 이동시키면 아래와 같이 사용자가 결제할 수 있는 페이지가 나오게 됩니다.

 

 

PayPal 사용자 결제 페이지

사용자는 해당 페이지에서 로그인 후 결제를 완료할 수 있습니다.

(실제로 결제까지 요청해보고 싶은 경우 위에서 생성한 test account를 통해 로그인하여 결제를 진행할 수 있습니다.)

 

 

 

< 참고 자료 >

https://bitshifted.co/blog/spring-boot-paypal-integration/

https://developer.paypal.com/api/rest/

https://blog.devgenius.io/paypal-integration-with-spring-boot-f1e297d76336

 

 

< 전체 코드 github 주소 >

https://github.com/JianChoi-Kor/paypal

반응형