refactor: TossPaymentClient 코틀린 마이그레이션 및 테스트

This commit is contained in:
이상진 2025-07-16 12:09:17 +09:00
parent 6fdc9767c1
commit 313d9850d1
3 changed files with 237 additions and 197 deletions

View File

@ -1,95 +1,113 @@
package roomescape.payment.infrastructure.client;
package roomescape.payment.infrastructure.client
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import roomescape.common.exception.ErrorType;
import roomescape.common.exception.RoomescapeException;
import roomescape.payment.web.PaymentApprove;
import roomescape.payment.web.PaymentCancel;
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpRequest
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode
import org.springframework.http.MediaType
import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.payment.web.PaymentApprove
import roomescape.payment.web.PaymentCancel
import java.io.IOException
import java.util.Map
@Component
public class TossPaymentClient {
private static final Logger log = LoggerFactory.getLogger(TossPaymentClient.class);
private final RestClient tossPaymentClient;
public TossPaymentClient(RestClient.Builder tossPaymentClientBuilder) {
this.tossPaymentClient = tossPaymentClientBuilder.build();
class TossPaymentClient(
private val log: KLogger = KotlinLogging.logger {},
tossPaymentClientBuilder: RestClient.Builder
) {
companion object {
private const val CONFIRM_URL: String = "/v1/payments/confirm"
private const val CANCEL_URL: String = "/v1/payments/{paymentKey}/cancel"
}
public PaymentApprove.Response confirmPayment(PaymentApprove.Request paymentRequest) {
logPaymentInfo(paymentRequest);
private val tossPaymentClient: RestClient = tossPaymentClientBuilder.build()
fun confirmPayment(paymentRequest: PaymentApprove.Request): PaymentApprove.Response {
logPaymentInfo(paymentRequest)
return tossPaymentClient.post()
.uri("/v1/payments/confirm")
.uri(CONFIRM_URL)
.contentType(MediaType.APPLICATION_JSON)
.body(paymentRequest)
.retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
(req, res) -> handlePaymentError(res))
.body(PaymentApprove.Response.class);
.onStatus(
{ status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError },
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) }
)
.body(PaymentApprove.Response::class.java)
?: throw RoomescapeException(ErrorType.PAYMENT_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR)
}
public PaymentCancel.Response cancelPayment(PaymentCancel.Request cancelRequest) {
logPaymentCancelInfo(cancelRequest);
Map<String, String> param = Map.of("cancelReason", cancelRequest.cancelReason);
fun cancelPayment(cancelRequest: PaymentCancel.Request): PaymentCancel.Response {
logPaymentCancelInfo(cancelRequest)
val param = Map.of<String, String>("cancelReason", cancelRequest.cancelReason)
return tossPaymentClient.post()
.uri("/v1/payments/{paymentKey}/cancel", cancelRequest.paymentKey)
.uri(CANCEL_URL, cancelRequest.paymentKey)
.contentType(MediaType.APPLICATION_JSON)
.body(param)
.retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
(req, res) -> handlePaymentError(res))
.body(PaymentCancel.Response.class);
.onStatus(
{ status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError },
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) }
)
.body(PaymentCancel.Response::class.java)
?: throw RoomescapeException(ErrorType.PAYMENT_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR)
}
private void logPaymentInfo(PaymentApprove.Request paymentRequest) {
log.info("결제 승인 요청: paymentKey={}, orderId={}, amount={}, paymentType={}",
paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount,
paymentRequest.paymentType);
private fun logPaymentInfo(paymentRequest: PaymentApprove.Request) {
log.info {
"결제 승인 요청: paymentKey=${paymentRequest.paymentKey}, orderId=${paymentRequest.orderId}, " +
"amount=${paymentRequest.amount}, paymentType=${paymentRequest.paymentType}"
}
}
private void logPaymentCancelInfo(PaymentCancel.Request cancelRequest) {
log.info("결제 취소 요청: paymentKey={}, amount={}, cancelReason={}",
cancelRequest.paymentKey, cancelRequest.amount, cancelRequest.cancelReason);
private fun logPaymentCancelInfo(cancelRequest: PaymentCancel.Request) {
log.info {
"결제 취소 요청: paymentKey=${cancelRequest.paymentKey}, amount=${cancelRequest.amount}, " +
"cancelReason=${cancelRequest.cancelReason}"
}
}
private void handlePaymentError(ClientHttpResponse res)
throws IOException {
HttpStatusCode statusCode = res.getStatusCode();
ErrorType errorType = getErrorTypeByStatusCode(statusCode);
TossPaymentErrorResponse errorResponse = getErrorResponse(res);
@Throws(IOException::class)
private fun handlePaymentError(
res: ClientHttpResponse
): Nothing {
val statusCode = res.statusCode
val errorType = getErrorTypeByStatusCode(statusCode)
val errorResponse = getErrorResponse(res)
throw new RoomescapeException(errorType,
String.format("[ErrorCode = %s, ErrorMessage = %s]", errorResponse.code, errorResponse.message),
statusCode);
throw RoomescapeException(
errorType,
"[ErrorCode = ${errorResponse.code}, ErrorMessage = ${errorResponse.message}]",
statusCode
)
}
private TossPaymentErrorResponse getErrorResponse(ClientHttpResponse res) throws IOException {
InputStream body = res.getBody();
ObjectMapper objectMapper = new ObjectMapper();
TossPaymentErrorResponse errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse.class);
body.close();
return errorResponse;
@Throws(IOException::class)
private fun getErrorResponse(
res: ClientHttpResponse
): TossPaymentErrorResponse {
val body = res.body
val objectMapper = ObjectMapper()
val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java)
body.close()
return errorResponse
}
private ErrorType getErrorTypeByStatusCode(HttpStatusCode statusCode) {
if (statusCode.is4xxClientError()) {
return ErrorType.PAYMENT_ERROR;
private fun getErrorTypeByStatusCode(
statusCode: HttpStatusCode
): ErrorType {
if (statusCode.is4xxClientError) {
return ErrorType.PAYMENT_ERROR
}
return ErrorType.PAYMENT_SERVER_ERROR;
return ErrorType.PAYMENT_SERVER_ERROR
}
}

View File

@ -1,117 +0,0 @@
package roomescape.payment.infrastructure.client;
import static org.assertj.core.api.Assertions.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.*;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import roomescape.common.exception.ErrorType;
import roomescape.common.exception.RoomescapeException;
import roomescape.payment.SampleTossPaymentConst;
import roomescape.payment.web.PaymentApprove;
import roomescape.payment.web.PaymentCancel;
@RestClientTest(TossPaymentClient.class)
class TossPaymentClientTest {
@Autowired
private TossPaymentClient tossPaymentClient;
@Autowired
private MockRestServiceServer mockServer;
@Test
@DisplayName("결제를 승인한다.")
void confirmPayment() {
// given
mockServer.expect(requestTo("/v1/payments/confirm"))
.andExpect(method(HttpMethod.POST))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().json(SampleTossPaymentConst.paymentRequestJson))
.andRespond(withStatus(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.confirmJson));
// when
PaymentApprove.Request paymentRequest = SampleTossPaymentConst.paymentRequest;
PaymentApprove.Response paymentResponse = tossPaymentClient.confirmPayment(paymentRequest);
// then
assertThat(paymentResponse.paymentKey).isEqualTo(paymentRequest.paymentKey);
assertThat(paymentResponse.orderId).isEqualTo(paymentRequest.orderId);
}
@Test
@DisplayName("결제를 취소한다.")
void cancelPayment() {
// given
mockServer.expect(requestTo("/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/cancel"))
.andExpect(method(HttpMethod.POST))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().json(SampleTossPaymentConst.cancelRequestJson))
.andRespond(withStatus(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.cancelJson));
// when
PaymentCancel.Request cancelRequest = SampleTossPaymentConst.cancelRequest;
PaymentCancel.Response paymentCancelResponse = tossPaymentClient.cancelPayment(cancelRequest);
// then
assertThat(paymentCancelResponse.cancelStatus).isEqualTo("DONE");
assertThat(paymentCancelResponse.cancelReason).isEqualTo(cancelRequest.cancelReason);
}
@Test
@DisplayName("결제 승인 중 400 에러가 발생한다.")
void confirmPaymentWithError() {
// given
mockServer.expect(requestTo("/v1/payments/confirm"))
.andExpect(method(HttpMethod.POST))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().json(SampleTossPaymentConst.paymentRequestJson))
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.tossPaymentErrorJson));
// when & then
assertThatThrownBy(() -> tossPaymentClient.confirmPayment(SampleTossPaymentConst.paymentRequest))
.isInstanceOf(RoomescapeException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.PAYMENT_ERROR)
.hasFieldOrPropertyWithValue("invalidValue",
"[ErrorCode = ERROR_CODE, ErrorMessage = Error message]")
.hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("결제 취소 중 500 에러가 발생한다.")
void cancelPaymentWithError() {
// given
mockServer.expect(requestTo("/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/cancel"))
.andExpect(method(HttpMethod.POST))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().json(SampleTossPaymentConst.cancelRequestJson))
.andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.tossPaymentErrorJson));
// when & then
assertThatThrownBy(() -> tossPaymentClient.cancelPayment(SampleTossPaymentConst.cancelRequest))
.isInstanceOf(RoomescapeException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.PAYMENT_SERVER_ERROR)
.hasFieldOrPropertyWithValue("invalidValue",
"[ErrorCode = ERROR_CODE, ErrorMessage = Error message]")
.hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR);
}
}

View File

@ -0,0 +1,139 @@
package roomescape.payment.infrastructure.client
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.ResponseActions
import org.springframework.test.web.client.match.MockRestRequestMatchers.*
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.payment.SampleTossPaymentConst
import roomescape.payment.web.PaymentApprove
import roomescape.payment.web.PaymentCancel
@RestClientTest(TossPaymentClient::class)
class TossPaymentClientTest(
@Autowired val client: TossPaymentClient,
@Autowired val mockServer: MockRestServiceServer
) : FunSpec() {
init {
context("결제 승인 요청") {
fun commonAction(): ResponseActions = mockServer.expect {
requestTo("/v1/payments/confirm")
}.andExpect {
method(HttpMethod.POST)
}.andExpect {
content().contentType(MediaType.APPLICATION_JSON)
}.andExpect {
content().json(SampleTossPaymentConst.paymentRequestJson)
}
test("성공 응답") {
commonAction().andRespond {
withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.confirmJson)
.createResponse(it)
}
// when
val paymentRequest = SampleTossPaymentConst.paymentRequest
val paymentResponse: PaymentApprove.Response = client.confirmPayment(paymentRequest)
assertSoftly(paymentResponse) {
this.paymentKey shouldBe paymentRequest.paymentKey
this.orderId shouldBe paymentRequest.orderId
this.totalAmount shouldBe paymentRequest.amount
}
}
test("400 에러 발생") {
commonAction().andRespond {
withStatus(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.tossPaymentErrorJson)
.createResponse(it)
}
// when
val paymentRequest = SampleTossPaymentConst.paymentRequest
// then
val exception = shouldThrow<RoomescapeException> {
client.confirmPayment(paymentRequest)
}
assertSoftly(exception) {
this.errorType shouldBe ErrorType.PAYMENT_ERROR
this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]"
this.httpStatus shouldBe HttpStatus.BAD_REQUEST
}
}
}
context("결제 취소 요청") {
fun commonAction(): ResponseActions = mockServer.expect {
requestTo("/v1/payments/${SampleTossPaymentConst.paymentKey}/cancel")
}.andExpect {
method(HttpMethod.POST)
}.andExpect {
content().contentType(MediaType.APPLICATION_JSON)
}.andExpect {
content().json(SampleTossPaymentConst.cancelRequestJson)
}
test("성공 응답") {
commonAction().andRespond {
withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.cancelJson)
.createResponse(it)
}
// when
val cancelRequest: PaymentCancel.Request = SampleTossPaymentConst.cancelRequest
val cancelResponse: PaymentCancel.Response = client.cancelPayment(cancelRequest)
assertSoftly(cancelResponse) {
this.cancelStatus shouldBe "DONE"
this.cancelReason shouldBe cancelRequest.cancelReason
this.cancelAmount shouldBe cancelRequest.amount
}
}
test("500 에러 발생") {
commonAction().andRespond {
withStatus(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.tossPaymentErrorJson)
.createResponse(it)
}
// when
val cancelRequest: PaymentCancel.Request = SampleTossPaymentConst.cancelRequest
// then
val exception = shouldThrow<RoomescapeException> {
client.cancelPayment(cancelRequest)
}
assertSoftly(exception) {
this.errorType shouldBe ErrorType.PAYMENT_SERVER_ERROR
this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]"
this.httpStatus shouldBe HttpStatus.INTERNAL_SERVER_ERROR
}
}
}
}
}