Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[1단계 방탈출 결제 / 배포] 몰리(김지민) 미션 제출합니다. #46

Merged
merged 26 commits into from
Jun 2, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
37f366d
chore(/src): 기존 코드 마이그레이션
jminkkk May 29, 2024
60459e1
docs(data.sql): 요구사항 분석 작성
jminkkk May 29, 2024
02cfe1d
chore(/resources): 프론트 코드 추가 및 수정
jminkkk May 29, 2024
cf2aaeb
feat(PaymentProperties): 외부 API 결제 키 추가
jminkkk May 29, 2024
f14d05e
feat(ClientConfiguration): 외부 결제 Client 설정 추가
jminkkk May 29, 2024
6526ae9
feat(PaymentClient): 외부 API에 결제 요청을 보내는 PaymentClient 생성
jminkkk May 29, 2024
89e452c
feat(ReservationService, PaymentService): 예약 로직에서 결제 승인하는 기능 추가
jminkkk May 29, 2024
3750a51
feat(PaymentClient): 결제 API 호출 시 예외처리
jminkkk May 29, 2024
40b81f8
refactor(PaymentClient): 결제 관련 외부 Client 객체 추상화
jminkkk May 29, 2024
70cb8a0
refactor(ReservationServiceTest): 예약 시 FakePaymentClient 호출하도록 수정
jminkkk May 29, 2024
4664d31
refactor(ReservationIntegrationTest): 안쓰는 import 제거
jminkkk May 29, 2024
c5f4a34
refactor(GlobalExceptionHandler): 500 대 HttpServerErrorException 에러 처리
jminkkk May 30, 2024
8d4e573
refactor(ClientConfiguration): Authorization 헤더 상수로 변경
jminkkk May 30, 2024
0cb75e4
refactor(ClientConfiguration): 인증 헤더의 시크릿 키에서 상수 추출
jminkkk May 30, 2024
43d3457
refactor(ReservationService): admin, 일반 유저 예약 생성 중복 로직 제거
jminkkk May 30, 2024
cf8f2e9
refactor(RestClientConfiguration): ClientConfiguration를 더 명확하게 이름 변경
jminkkk May 31, 2024
fc576c4
refactor(GlobalExceptionHandler): HttpServerErrorException를 핸들링하는 메서드…
jminkkk May 31, 2024
846f338
refactor(TossClientErrorResponse): Toss에서 제공하는 에러 객체 이름 변경
jminkkk May 31, 2024
f7c1236
refactor(PaymentProperties): 결제 요청 시 필요한 비밀번호 값을 properties로 위치 이동
jminkkk May 31, 2024
e0f50e2
refactor(/payment, /toss): 결제 client 관련 파일들의 위치 수정
jminkkk May 31, 2024
06bbf14
refactor(TossPaymentErrorHandler): Toss 결제 처리 중 발생한 에러를 핸들링하는 객체 구현 및 등록
jminkkk Jun 1, 2024
e8ca752
refactor(TossErrorCodeNotForUser): 유저에게 보여주지 않을 토스 에러 코드 이넘으로 관리
jminkkk Jun 1, 2024
72a0361
test(TossRestClientTest): 토스 결제 승인 실패 시, 토스 에러 코드가 사용자에게 알리지 않을 사유라면 …
jminkkk Jun 1, 2024
09a27a8
test(FakePaymentClient): 결제 승인 요청 시 무조건 예외를 발생하는 keyword 추가 및 결제 오류 시…
jminkkk Jun 1, 2024
a8095d6
test(PaymentRestClientConfiguration): 타임아웃 설정
jminkkk Jun 1, 2024
aab08d3
test(GlobalExceptionHandler): SocketTimeoutException에 대한 핸들링 추가
jminkkk Jun 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 7 additions & 24 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 요구사항 분석
# 정책

## 예약 대기 정책

Expand All @@ -13,26 +13,9 @@
- 예약 취소 시, 해당 예약에 대해 우선순위가 높은 대기가 자동으로 예약된다.
- 관리자는 예약 대기를 거절할 수 있다.

## 3단계
- 예약 대기 요청 API 구현
- [x] 예약 대기 스키마 설계
- [x] 예약 대기 엔티티 구현
- [x] 예약 대기 요청 로직 구현
- [x] 예약 대기 취소 API 구현
- [x] 예약 조회 API 변경
- 예약 조회 시, 대기 목록을 포함한다.

## 4단계
- [x] 에약 대기 승인 기능 구현
- 예약 취소 API에서 대기 번호 1번인 대기가 예약이 되도록 변경
- [x] 예약 대기 거절 API 구현
- [x] 어드민 예약 대기 목록 조회 API 구현

---
## 1단계
- [x] gradle 의존성 추가
- [x] 엔티티 매핑
- [x] 연관관계 매핑

## 2단계
- [x] 내 예약 조회 API 구현
# 요구사항 분석

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

- 예약 API 변경
- [x] 예약 요청 시 결제 기능 추가
- [x] 결제 실패 시 예외 처리
- [x] 사용자에게 결재 실패 사유 전달
- [x] 외부 API 연동
36 changes: 36 additions & 0 deletions src/main/java/roomescape/common/ClientConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package roomescape.common;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestClient;
import roomescape.payment.client.PaymentProperties;

@Configuration
public class ClientConfiguration {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

소소한 피드백ㅎㅎ
여기도 RestClient를 명시하는건 어때요? RestClientConfiguration

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 더 명확해질 것 같아요!
반영하겠습니다 🙌


private static final String BASIC_PREFIX = "Basic ";
private static final String NO_PASSWORD_SUFFIX = ":";

private final PaymentProperties paymentProperties;

public ClientConfiguration(final PaymentProperties paymentProperties) {
this.paymentProperties = paymentProperties;
}

@Bean
public RestClient restClient() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RestClient를 선택해주신 이유가 궁금해요~

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 외부 client를 고려할 때, 빠른 미션 적용을 위해 학습 테스트에서 제시된 RestTemplate, RestClient 둘 중에 하나를 고민을 했었는데요 !
RestClient가 체이닝으로 더 가독성이 좋고 깔끔하게 코드를 작성할 수 있는 것 같아서 선택했습니다 !

byte[] encodedBytes = Base64.getEncoder()
.encode((paymentProperties.getSecretKey() + NO_PASSWORD_SUFFIX)
.getBytes(StandardCharsets.UTF_8));

String authorizations = BASIC_PREFIX + new String(encodedBytes);

return RestClient.builder()
.baseUrl("https://api.tosspayments.com/v1/payments")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

time-out도 한번 설정해볼까요?
외부 api 호출 시 타임아웃은 왜 중요할까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

타임아웃 설정을 놓쳤네요 😅

제가 알기로는, 외부 Client 와 연결이 되지 않는다거나(connection timeout), 연결은 됐지만 응답이 오는 시간이 너무 오래 걸려(read timeout) 사용자가 무한정 기다리게 되는 상황을 막고자 timeout 설정이 필요한 것으로 알고 있어요 !

.defaultHeader(HttpHeaders.AUTHORIZATION, authorizations)
.build();
}
}
20 changes: 20 additions & 0 deletions src/main/java/roomescape/common/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import roomescape.common.exception.ForbiddenException;
import roomescape.common.exception.UnAuthorizationException;

Expand Down Expand Up @@ -49,6 +51,24 @@ public ResponseEntity<ProblemDetail> catchHttpMessageNotReadableException(HttpMe
.body(ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, exceptionMessage));
}

@ExceptionHandler
public ResponseEntity<ProblemDetail> catchHttpClientErrorException(HttpClientErrorException ex) {
logger.warning(EXCEPTION_PREFIX + ex.getMessage());

PaymentClientErrorResponse response = ex.getResponseBodyAs(PaymentClientErrorResponse.class);
return ResponseEntity.status(ex.getStatusCode())
.body(ProblemDetail.forStatusAndDetail(ex.getStatusCode(), response.message()));
}

@ExceptionHandler
public ResponseEntity<ProblemDetail> catchHttpClientErrorException(HttpServerErrorException ex) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public ResponseEntity<ProblemDetail> catchHttpClientErrorException(HttpServerErrorException ex) {
public ResponseEntity<ProblemDetail> catchHttpServerErrorException(HttpServerErrorException ex) {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 미쳐 확인하지 못했네요 😅
꼼꼼히 봐주셔서 감사합니다 🙌 🙇🏻‍♀️

logger.warning(EXCEPTION_PREFIX + ex.getMessage());

PaymentClientErrorResponse response = ex.getResponseBodyAs(PaymentClientErrorResponse.class);
return ResponseEntity.status(ex.getStatusCode())
.body(ProblemDetail.forStatusAndDetail(ex.getStatusCode(), response.message()));
}

@ExceptionHandler
public ResponseEntity<ProblemDetail> catchBadRequestException(IllegalArgumentException ex) {
logger.warning(EXCEPTION_PREFIX + ex.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package roomescape.common;

public record PaymentClientErrorResponse(String code,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

webclient에서 던지는 HttpClientErrorException을 PaymentClientErrorResponse화 하는 것에 대해서,
나중엔 payment 외에 다른 client를 연동하게 될 수도 있지 않을까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동의합니다 !
여러 Client를 사용하게 된다면 HttpClientErrorExceptionTossPayment에만 발생한다는 보장이 없네요!
따라서 해당 에러 객체의 규격이 PaymentClientErrorResponse과 동일하다고 할 수도 없구요!

모든 HttpClientErrorExceptionPaymentClientErrorResponse화 하지 않고 TossClient의 예외 상황에 한해 감싸서 터트리는 것이 맞을 것 같아요 수정하겠습니다 🙇

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 다시 읽어봤을 때 좀 모호하게 질문드렸던 것 같은데 찰떡같이 알아들으셨네요ㅎㅎ 💯

String message) {
}
9 changes: 9 additions & 0 deletions src/main/java/roomescape/payment/client/PaymentClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package roomescape.payment.client;

import roomescape.payment.dto.request.ConfirmPaymentRequest;
import roomescape.payment.model.Payment;

public interface PaymentClient {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 인터페이스화 한거 너무 좋네요!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

꼭 toss 말고 다른 결제 client를 사용하게 될 수도 있어서 추상화가 필요한 부분이라고 생각했어요 ㅎㅎ


Payment confirm(ConfirmPaymentRequest confirmPaymentRequest);
}
17 changes: 17 additions & 0 deletions src/main/java/roomescape/payment/client/PaymentProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package roomescape.payment.client;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(value = "payment")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

해당 어노테이션은 어떤 역할을 해줘요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring boot가 자동으로 외부 설정 파일(properties 또는 yml)의 속성값을 인식하여 클래스의 필드값으로 바인딩해주는 어노테이션입니다 !

기존에 사용했던 @value보다 쉽게 값들을 주입받을 수 있고 클래스로 더 편하게 관리할 수 있다고 생각하여 사용했습니다 😁

public class PaymentProperties {

private final String secretKey;

public PaymentProperties(final String secretKey) {
this.secretKey = secretKey;
}

public String getSecretKey() {
return secretKey;
}
}
26 changes: 26 additions & 0 deletions src/main/java/roomescape/payment/client/TossPaymentClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package roomescape.payment.client;

import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import roomescape.payment.dto.request.ConfirmPaymentRequest;
import roomescape.payment.model.Payment;

@Component
public class TossPaymentClient implements PaymentClient {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


private final RestClient restClient;

public TossPaymentClient(final RestClient restClient) {
this.restClient = restClient;
}

@Override
public Payment confirm(ConfirmPaymentRequest confirmPaymentRequest) {
return restClient.post()
.uri("/confirm")
.body(confirmPaymentRequest)
.retrieve()
.toEntity(Payment.class)
.getBody();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package roomescape.payment.dto.request;

import roomescape.reservation.dto.request.CreateMyReservationRequest;

public record ConfirmPaymentRequest(String paymentKey,
String orderId,
Long amount) {
public static ConfirmPaymentRequest from(final CreateMyReservationRequest createMyReservationRequest) {
return new ConfirmPaymentRequest(
createMyReservationRequest.paymentKey(),
createMyReservationRequest.orderId(),
createMyReservationRequest.amount());
}
}
25 changes: 25 additions & 0 deletions src/main/java/roomescape/payment/model/Payment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package roomescape.payment.model;

public class Payment {
private String paymentKey;
private String orderId;
private Long amount;

public Payment(final String paymentKey, final String orderId, final Long amount) {
this.paymentKey = paymentKey;
this.orderId = orderId;
this.amount = amount;
}

public String getPaymentKey() {
return paymentKey;
}

public String getOrderId() {
return orderId;
}

public Long getAmount() {
return amount;
}
}
20 changes: 20 additions & 0 deletions src/main/java/roomescape/payment/service/PaymentService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package roomescape.payment.service;

import org.springframework.stereotype.Service;
import roomescape.payment.client.PaymentClient;
import roomescape.payment.dto.request.ConfirmPaymentRequest;
import roomescape.payment.model.Payment;

@Service
public class PaymentService {

private final PaymentClient paymentClient;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 서비스에서 Client를 직접 호출하는 게 아니라 Service로 한번 더 감싸주셨군요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 "결제 승인"이라는 기능에 대해 단순 외부 client 연동만 하면 된다는 미션 요구사항이 제공되었지만,
외부 client에 요청을 한다는 것 또한 결제라는 기능을 위한 비즈니스 요구사항이라고 생각하여 Service 계층을 만들어 해당 역할을 하는 객체를 호출했습니다 !
또 결제에 대한 추가적인 요구사항이 변경될 경우에도 해당 계층 덕분에 더 유연할 것이라고도 생각했습니다 😁


public PaymentService(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}

public Payment createPayment(ConfirmPaymentRequest paymentRequest) {
return paymentClient.confirm(paymentRequest);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,14 @@ public record CreateMyReservationRequest(

@Positive(message = "예약 테마 식별자는 양수만 가능합니다.")
@NotNull(message = "예약 등록 시 테마는 필수입니다.")
Long themeId) {
Long themeId,

@NotNull(message = "결제 키를 입력해주세요.")
String paymentKey,

@NotNull(message = "주문을 입력해주세요.")
String orderId,

@NotNull(message = "결제 금액를 입력해주세요.")
Long amount) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import roomescape.common.exception.ForbiddenException;
import roomescape.member.domain.Member;
import roomescape.member.repository.MemberRepository;
import roomescape.payment.dto.request.ConfirmPaymentRequest;
import roomescape.payment.service.PaymentService;
import roomescape.reservation.dto.request.CreateMyReservationRequest;
import roomescape.reservation.dto.request.CreateReservationByAdminRequest;
import roomescape.reservation.dto.request.CreateReservationRequest;
Expand All @@ -29,19 +31,22 @@
public class ReservationService {

private final WaitingService waitingService;
private final PaymentService paymentService;

private final ReservationRepository reservationRepository;
private final ReservationTimeRepository reservationTimeRepository;
private final ThemeRepository themeRepository;
private final MemberRepository memberRepository;
private final WaitingRepository waitingRepository;

public ReservationService(final WaitingService waitingService,
final ReservationRepository reservationRepository,
final PaymentService paymentService, final ReservationRepository reservationRepository,
final ReservationTimeRepository reservationTimeRepository,
final ThemeRepository themeRepository,
final MemberRepository memberRepository,
final WaitingRepository waitingRepository) {
this.waitingService = waitingService;
this.paymentService = paymentService;
this.reservationRepository = reservationRepository;
this.reservationTimeRepository = reservationTimeRepository;
this.themeRepository = themeRepository;
Expand All @@ -53,33 +58,33 @@ public CreateReservationResponse createMyReservation(final AuthInfo authInfo,
final CreateMyReservationRequest createMyReservationRequest) {
CreateReservationRequest createReservationRequest = CreateReservationRequest.of(authInfo.getMemberId(),
createMyReservationRequest);
return CreateReservationResponse.from(createReservation(createReservationRequest));
Reservation reservation = convertToReservation(createReservationRequest);
paymentService.createPayment(ConfirmPaymentRequest.from(createMyReservationRequest));
return CreateReservationResponse.from(reservationRepository.save(reservation));
}

public CreateReservationResponse createReservationByAdmin(
final CreateReservationByAdminRequest createReservationByAdminRequest) {
public CreateReservationResponse createReservationByAdmin(final CreateReservationByAdminRequest createReservationByAdminRequest) {
CreateReservationRequest createReservationRequest = CreateReservationRequest.of(createReservationByAdminRequest);
return CreateReservationResponse.from(createReservation(createReservationRequest));
Reservation reservation = convertToReservation(createReservationRequest);
return CreateReservationResponse.from(reservationRepository.save(reservation));
}

public Reservation createReservation(final CreateReservationRequest createReservationRequest) {
public Reservation convertToReservation(final CreateReservationRequest createReservationRequest) {
ReservationTime reservationTime = reservationTimeRepository.getById(createReservationRequest.timeId());
Theme theme = themeRepository.getById(createReservationRequest.themeId());
Member member = memberRepository.getById(createReservationRequest.memberId());

checkAlreadyExistReservation(createReservationRequest, createReservationRequest.date(), theme.getName(),
reservationTime.getStartAt());
Reservation reservation = createReservationRequest.toReservation(member, reservationTime, theme);
return reservationRepository.save(reservation);
checkAlreadyExistReservation(createReservationRequest, theme.getName(), reservationTime.getStartAt());
return createReservationRequest.toReservation(member, reservationTime, theme);
}

private void checkAlreadyExistReservation(final CreateReservationRequest createReservationRequest,
final LocalDate date, final String themeName, final LocalTime time) {
final String themeName, final LocalTime time) {
if (reservationRepository.existsByDateAndReservationTimeIdAndThemeId(
createReservationRequest.date(),
createReservationRequest.timeId(),
createReservationRequest.themeId())) {
throw new IllegalArgumentException("이미 " + date + "의 " + themeName + " 테마에는 " + time
throw new IllegalArgumentException("이미 " + createReservationRequest.date() + "의 " + themeName + " 테마에는 " + time
+ " 시의 예약이 존재하여 예약을 생성할 수 없습니다.");
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ spring.h2.console.enabled=true
token.secretKey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.ih1aovtQShabQ7l0cINw4k1fagApg3qLWiB8Kt59Lno
token.validityInMilliseconds=3600000

payment.secretKey=test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 테스트용 secretKey를 사용하지만, 추후 실제 환경을 운영하게 되어 발급을 받아 사용하게 된다면, 민감한 정보라고 생각되어 properties로 관리하고자 했습니다..!

spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.defer-datasource-initialization=true
Loading