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

문의하기 구글 스프레드시트 API 요청 책임을 클라이언트에서 서버로 변경 #923

Merged
merged 42 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3c88d2b
chore: fix typo
zeus6768 Oct 23, 2024
6a6a38f
chore: fix typo
zeus6768 Oct 23, 2024
4c36b14
Merge remote-tracking branch 'upstream/dev/be' into dev/be
zeus6768 Oct 24, 2024
9dcf0f0
Merge remote-tracking branch 'upstream/dev/be' into dev/be
zeus6768 Oct 28, 2024
c43f3b9
Merge remote-tracking branch 'upstream/dev/be' into dev/be
zeus6768 Nov 10, 2024
431e92f
Merge remote-tracking branch 'upstream/dev/be' into dev/be
zeus6768 Nov 14, 2024
3e12905
feat(voc): 뼈대 코드 작성
zeus6768 Nov 11, 2024
a6872f3
test(dto): voc dto 문서 및 테스트 작성
zeus6768 Nov 12, 2024
703dab8
test(dto): 테스트 순서 변경
zeus6768 Nov 12, 2024
f61eda7
test(dto): 테스트 가독성 개선
zeus6768 Nov 12, 2024
3ff8b59
docs(voc): 문의하기 API 명세
zeus6768 Nov 14, 2024
0f15c67
feat(voc): http client 설정
zeus6768 Nov 14, 2024
b543c9a
docs(voc): 문의하기 api 명세 수정
zeus6768 Nov 15, 2024
4eeca8a
feat(voc): 문의하기 happy case 구현
zeus6768 Nov 15, 2024
1ccfc72
feat(voc): 문의하기 api base url 추가
zeus6768 Nov 16, 2024
b56f614
fix(voc): 응답 상태 코드 변경 ok -> created
zeus6768 Nov 16, 2024
060d855
docs(voc): 문의 내용 예시 20글자 이상으로 수정
zeus6768 Nov 16, 2024
54a6c8f
test(voc): 테스트 가독성 개선
zeus6768 Nov 16, 2024
01d0337
feat(voc): add error handler and interceptor for voc rest client
zeus6768 Nov 17, 2024
1110c8a
style(voc): static import 컨벤션
zeus6768 Nov 17, 2024
b0c247e
refactor(service): 에러 처리 로직 분리
zeus6768 Nov 17, 2024
4d7ebd0
test(controller): 컨트롤러 테스트
zeus6768 Nov 17, 2024
978eda2
test(service): voc 서비스 계층 테스트
zeus6768 Nov 17, 2024
d8cbf75
test(voc): request dto 테스트 추가 및 가독성 개선
zeus6768 Nov 17, 2024
da384a8
test(voc): 불필요한 의존성 제거
zeus6768 Nov 17, 2024
dbaa316
test(voc): 테스트 설명 수정
zeus6768 Nov 18, 2024
9136fc8
refactor(config): 불필요한 의존성 제거
zeus6768 Nov 18, 2024
8344d20
refactor(dto): 생성자 추가
zeus6768 Nov 18, 2024
3654b93
refactor(config): voc error handler 로그 형식 변경
zeus6768 Nov 23, 2024
38734d1
fix(service): 응답을 정상적으로 수신하도록 수정
zeus6768 Nov 23, 2024
448eb21
test(voc): 테스트코드 가독성 개선
zeus6768 Nov 28, 2024
86343e9
refactor(config): 메서드명 개선
zeus6768 Nov 28, 2024
e824906
refactor(voc): 메서드명 개선
zeus6768 Nov 28, 2024
412bc3b
refactor(voc): 메서드명 개선
zeus6768 Nov 28, 2024
2ab6769
test(service): 테스트 추가
zeus6768 Dec 2, 2024
514995e
refactor(controller): `@ResponseStatus` -> ResponseEntity 사용으로 변경
zeus6768 Dec 6, 2024
08634b1
docs: VOC request email nullable 명시
zeus6768 Dec 6, 2024
95100a5
chore: 불필요한 주석 삭제 및 voc yml 설정 추가
zeus6768 Dec 6, 2024
948c8be
test(voc): MockMvcTest를 사용하도록 변경
zeus6768 Dec 7, 2024
016bf9f
test(voc): print문 제거
zeus6768 Dec 7, 2024
e01242b
test(voc): 주석 제거
zeus6768 Dec 7, 2024
86001cf
test(voc): displayname message 컨벤션
zeus6768 Dec 7, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package codezap.voc.config;

import java.io.IOException;
import java.net.SocketTimeoutException;

import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;

import codezap.global.exception.CodeZapException;
import codezap.global.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class VocClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {

@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) {
try {
return execution.execute(request, body);
} catch (SocketTimeoutException e) {
throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청 시간이 초과되었습니다: " + e.getMessage());
} catch (IOException e) {
throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다: " + e.getMessage());
}
}
}
39 changes: 39 additions & 0 deletions backend/src/main/java/codezap/voc/config/VocConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package codezap.voc.config;

import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableConfigurationProperties(VocProperties.class)
@RequiredArgsConstructor
public class VocConfiguration {

private final VocProperties properties;

@Bean
public RestClient.Builder vocRestClientBuilder() {
return RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.defaultStatusHandler(new VocResponseErrorHandler())
.requestInterceptor(new VocClientHttpRequestInterceptor())
.requestFactory(requestFactory());
}
Comment on lines +24 to +31
Copy link
Contributor

Choose a reason for hiding this comment

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

아래와 같이 return 타입을 RestClient가 아닌 RestClient.Builder로 한 이유가 있나요??

Suggested change
public RestClient.Builder vocRestClientBuilder() {
return RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.defaultStatusHandler(new VocResponseErrorHandler())
.requestInterceptor(new VocClientHttpRequestInterceptor())
.requestFactory(requestFactory());
}
public RestClient vocRestClientBuilder() {
return RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.defaultStatusHandler(new VocResponseErrorHandler())
.requestInterceptor(new VocClientHttpRequestInterceptor())
.requestFactory(requestFactory())
.build();
}

Copy link
Contributor Author

@zeus6768 zeus6768 Dec 7, 2024

Choose a reason for hiding this comment

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

확장과 테스트를 위해서 열어 놓았어요.

  • RestClient는 불변이고, Builder는 도메인에 따라 달라지는 설정을 커스텀해서 사용할 수 있어요.
    • 지금은 사용되는 곳이 한 곳 뿐이지만, 이정도 유연성을 가져가는 건 괜찮다고 생각해서 이렇게 작성했어요.
  • 테스트코드에서처럼 MockMvcRestServiceServer를 사용하기 위해서는 RestClient Builder 객체가 필요해요.

혹시 제가 모르는 게 있다면 알려주세요~!


private ClientHttpRequestFactory requestFactory() {
var httpRequestFactory = new SimpleClientHttpRequestFactory();
Copy link
Contributor

Choose a reason for hiding this comment

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

제우스 이 PR과 조금 다른 이야기일수도 있긴 한데요! var 변수에 대해 다같이 컨벤션을 논의해보면 좋을 것 같아요.

어떤 변수는 타입 추론이고 어떤 변수는 객체 지정이면 같은 프로젝트 내에서 조금 일관성이 깨질 수도 있을 것 같아요 🥲
추가로 이에 대한 다른 분들 의견도 궁금해요!

httpRequestFactory.setConnectTimeout(properties.getConnectTimeout());
httpRequestFactory.setReadTimeout(properties.getReadTimeout());
jminkkk marked this conversation as resolved.
Show resolved Hide resolved
return new BufferingClientHttpRequestFactory(httpRequestFactory);
}
}
17 changes: 17 additions & 0 deletions backend/src/main/java/codezap/voc/config/VocProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package codezap.voc.config;

import java.time.Duration;

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

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@ConfigurationProperties("voc.http.client")
@RequiredArgsConstructor
@Getter
public class VocProperties {
private final String baseUrl;
private final Duration connectTimeout;
private final Duration readTimeout;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package codezap.voc.config;

import java.io.IOException;

import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.ResponseErrorHandler;

import codezap.global.exception.CodeZapException;
import codezap.global.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class VocResponseErrorHandler implements ResponseErrorHandler {
kyum-q marked this conversation as resolved.
Show resolved Hide resolved

@Override
public boolean hasError(ClientHttpResponse response) {
try {
HttpStatusCode statusCode = response.getStatusCode();
return statusCode.isError();
} catch (IOException e) {
log.error(e.getMessage(), e);
Copy link
Contributor

Choose a reason for hiding this comment

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

아무리 생각해도 지금 코드에서 이거 없앨 수 있는 방법이 생각이 안나네요...😭
나중에 에러 처리도 손봐야 할 것 같긴 합니다..

throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다.");
}
}

@Override
public void handleError(ClientHttpResponse response) {
try {
HttpStatusCode statusCode = response.getStatusCode();
log.error("{}", statusCode);
} catch (IOException e) {
log.error(e.getMessage(), e);
}
throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package codezap.voc.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import codezap.global.swagger.error.ApiErrorResponse;
import codezap.global.swagger.error.ErrorCase;
import codezap.voc.dto.VocRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "문의하기")
public interface SpringDocVocController {

@Operation(summary = "문의하기")
@ApiResponse(responseCode = "201", description = "VOC 스프레드시트 데이터 추가 성공")
@ApiErrorResponse(
status = HttpStatus.BAD_REQUEST,
instance = "/contact",
errorCases = {
@ErrorCase(description = "문의 내용을 입력하지 않은 경우.", exampleMessage = "문의 내용을 입력해주세요."),
@ErrorCase(description = "문의 내용이 20자 미만인 경우.", exampleMessage = "문의 내용을 20자 이상 입력해주세요."),
@ErrorCase(description = "문의 내용이 10,000 글자를 초과한 경우.", exampleMessage = "문의 내용은 최대 10,000 글자까지 입력할 수 있습니다.")
}
)
ResponseEntity<Void> create(VocRequest request);
}
25 changes: 25 additions & 0 deletions backend/src/main/java/codezap/voc/controller/VocController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package codezap.voc.controller;

import jakarta.validation.Valid;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import codezap.voc.dto.VocRequest;
import codezap.voc.service.VocService;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class VocController implements SpringDocVocController {

private final VocService vocService;

@PostMapping("/contact")
public ResponseEntity<Void> create(@Valid @RequestBody VocRequest request) {
vocService.create(request);
return ResponseEntity.ok().build();
}
}
40 changes: 40 additions & 0 deletions backend/src/main/java/codezap/voc/dto/VocRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package codezap.voc.dto;

import jakarta.annotation.Nullable;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

import io.swagger.v3.oas.annotations.media.Schema;

public record VocRequest(

@Schema(description = "문의 내용", example = "코드잽 정말 잘 사용하고 있어요. 우테코 6기 코드잽 화이팅!")
@NotEmpty(message = "문의 내용은 비어있을 수 없습니다.")
@Size(min = 20, max = 10_000, message = "문의 내용은 최소 20자, 최대 10,000 자 입력할 수 있습니다.")
String message,

@Nullable
@Schema(description = "이메일(선택)", example = "[email protected]")
@Email(regexp = "[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$", message = "올바른 형식의 이메일 주소여야 합니다.") // Regex from RFC 5322
jminkkk marked this conversation as resolved.
Show resolved Hide resolved
jminkkk marked this conversation as resolved.
Show resolved Hide resolved
String email,

@Nullable
@Schema(description = "사용자 db id(선택)", example = "1")
@Min(value = 1, message = "1 이상이어야 합니다.")
Long memberId,

@Nullable
@Schema(description = "사용자 아이디(선택)", example = "만두")
@Size(max = 255, message = "아이디는 255자 이하로 입력해주세요.")
String name
) {
public VocRequest(String message) {
this(message, null);
}

public VocRequest(String message, String email) {
this(message, email, null, null);
}
}
23 changes: 23 additions & 0 deletions backend/src/main/java/codezap/voc/service/VocService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package codezap.voc.service;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import codezap.voc.dto.VocRequest;

@Service
public class VocService {

private final RestClient restClient;

public VocService(RestClient.Builder builder) {
restClient = builder.build();
}

public void create(VocRequest request) {
restClient.post()
.body(request)
.retrieve()
.toBodilessEntity();
}
}
6 changes: 6 additions & 0 deletions backend/src/main/resources/application-voc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
voc:
http:
client:
base-url: ${VOC_SPREADSHEET_API_BASE_URL}
connect-timeout: 5s
read-timeout: 5s
Comment on lines +5 to +6
Copy link
Contributor

Choose a reason for hiding this comment

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

각각 5, 5초로 설정한 이유가 궁금해요!
google sheet api 사용량 한도 공식 문서

sheet api 를 사용한 것이 맞다면, 다음 부분을 참고해보면 좋을 것 같아요 :)

하나의 API 요청 처리 시 최대 시간 제한이 있습니다. Sheets에서 180초가 넘는 시간을 처리하는 경우 요청에서 시간 초과를 반환합니다. 오류가 발생했습니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

이게 좀 헷갈리는 부분인데, 우리는 Google Sheets API를 사용하지 않아요!

대신 Google Apps Script를 사용해요.

Google Apps Script는 Sheets뿐 아니라 Docs와 Slides에서도 개발자가 미리 배포한 자바스크립트를 실행할 수 있게 해줘요.

Google Apps Script문서에는 timeout 권장 시간을 명시한 문서는 없고 Google 서비스 할당량에 따른 6분의 실행 제한만 있어요.

Screenshot 2024-12-06 at 6 03 00 PM

그러나 우리의 Apps Script는 매우 간단하고, 제한을 초과할 일이 거의 없어요.

그래서 제 임의로 너무 길지도 너무 짧지도 않게 5초로 설정했어요.

몰리는 어떻게 설정하는 게 좋다고 생각하나요?

좋은 아이디어가 있다면 얼마든지 제안해주세요~!

Copy link
Contributor

Choose a reason for hiding this comment

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

음 Read Timeout이 5초면 너무 짧지 않을까 싶었어요.
요청이 간단해도 구글 측에 일시적인 부하가 있다면 응답이 느려질 수도 있을 것 같은데, 적절한 값이 나와있지 않아 직접 평균 응답 시간 테스트를 해보지 않는 이상 어렵긴 하네요....

Copy link
Contributor Author

Choose a reason for hiding this comment

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

제가 직접 수차례 테스트했을 땐, 1초 이상의 속도가 걸렸전 적은 없어요.

몰리 말대로 일시적 부하가 있다면 응답에 문제가 있을 수 있지만, 구체적으로 어떤 값으로 정할지 모르겠어서 일단 넘어갈게요!

개인적으로는 관리자 기능을 개발하며 스프레드시트 대신 DB를 사용하도록 바뀌어야 한다고 생각해요 😁

6 changes: 3 additions & 3 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
spring:
profiles:
group:
default: local
dev: db, actuator, swagger, cors
prod: db, actuator, swagger, cors
default: local, voc
dev: db, actuator, swagger, cors, voc
prod: db, actuator, swagger, cors, voc
4 changes: 4 additions & 0 deletions backend/src/test/java/codezap/global/MockMvcTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import codezap.member.service.MemberService;
import codezap.template.service.facade.TemplateApplicationService;
import codezap.tag.service.TagService;
import codezap.voc.service.VocService;

@WebMvcTest(SpringExtension.class)
@EnableConfigurationProperties(CorsProperties.class)
Expand Down Expand Up @@ -51,6 +52,9 @@ public abstract class MockMvcTest {
@MockBean
protected LikesService likesService;

@MockBean
private VocService vocService;

@MockBean
protected TemplateApplicationService templateApplicationService;

Expand Down
28 changes: 28 additions & 0 deletions backend/src/test/java/codezap/voc/config/VocPropertiesTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package codezap.voc.config;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

import java.time.Duration;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(classes = VocConfiguration.class)
class VocPropertiesTest {

@Autowired
private VocProperties sut;

@Test
@DisplayName("yml 파일로부터 http client 관련 설정 값을 가져오는지 확인")
void getAllowedOrigins() {
assertAll(
() -> assertThat(sut.getBaseUrl()).isEqualTo("http://localhost:8080"),
() -> assertThat(sut.getConnectTimeout()).isEqualTo(Duration.ofSeconds(5L)),
() -> assertThat(sut.getReadTimeout()).isEqualTo(Duration.ofSeconds(5L))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package codezap.voc.controller;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;

import codezap.global.MockMvcTest;
import codezap.voc.dto.VocRequest;

@Import(VocController.class)
class VocControllerTest extends MockMvcTest {

@Test
@DisplayName("문의하기 요청 성공")
void create() throws Exception {
Copy link
Contributor

Choose a reason for hiding this comment

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

DisplayName 써주세요!

var request = new VocRequest("lorem ipsum dolor sit amet consectetur adipiscing elit", null);

mvc.perform(post("/contact")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
}

@Test
@DisplayName("문의하기 요청 실패 : 요청 본문 없음")
void create_error() throws Exception {
mvc.perform(post("/contact"))
.andExpect(status().isBadRequest());
}
}
Loading
Loading