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