Skip to content
This repository was archived by the owner on Aug 13, 2022. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ dependencies {
implementation 'org.springframework.security:spring-security-crypto:5.5.2'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'

testImplementation 'io.github.javaunit:autoparams:0.2.12'
testImplementation 'com.tngtech.archunit:archunit-junit5:0.20.1'
testImplementation 'org.mockito:mockito-inline:3.9.0'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.yam.app.account.infrastructure;

import java.util.concurrent.Executor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Slf4j
@EnableAsync
@Configuration
public class AsyncConfiguration implements AsyncConfigurer {

@Override
public Executor getAsyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
int processors = Runtime.getRuntime().availableProcessors();
log.info("processors count {}", processors);
executor.setCorePoolSize(processors);
executor.setMaxPoolSize(processors * 2);
executor.setQueueCapacity(50);
Copy link
Member

Choose a reason for hiding this comment

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

이 값을 50으로 설정한 의도가 궁금합니다~ 이벤트리스너를 비동기로 돌리기 위한 설정인 것 같은데 만약 이벤트리스너 안에서 오래걸리는 작업이 있다면 어떻게 될지도 궁금하네요

Copy link
Collaborator Author

@Rebwon Rebwon Sep 3, 2021

Choose a reason for hiding this comment

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

ThreadPoolTaskExecutor는 내부적으로 LinkedBlockingQueue를 사용하여 코어 수보다 더 많은 작업 요청이 들어올 경우, Integer.MAX_VALUE 크기의 큐를 만들어서 작업을 대기시켜 놓습니다. 대기 큐의 사이즈를 정해주지 않으면, 작업이 오래 걸리는 경우 큐에 너무 많은 작업이 쌓이게 되어 메모리를 계속적으로 점유하게 되고 OOM이 발생할 가능성이 높습니다.

따라서 성능 테스트를 통한 임의의 값을 적절하게 설정해주어야 하는데, 아직은 실험적인 단계라 많은 블로그들에서 초기 값을 50으로 설정하는 것을 보고 결정하였습니다.

이벤트 리스너 안에서 오래 걸리는 작업이 있다면 비동기로 처리하는 것이 성능이 오히려 나빠질 수 있습니다.

하지만 해당 케이스는 application.yml에 mail send timeout을 5초로 설정하여 그 이상 커넥션을 유지하지 않도록 설정하여서, 사용자로 하여금 회원가입 시나리오에서 메일을 동기적으로 보내는 것 때문에 발생하는 응답시간을 줄일 수 있다고 판단하였습니다.

Copy link
Member

Choose a reason for hiding this comment

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

단기간에 갑자기 요청이 몰리는 경우가 있을 것 같은데 큐의 사이즈가 작으면 이 작업들을 받지 못해서 천천히라도 처리하지 못할 것 같습니다. 큐 사이즈는 메모리 계산 잘 해서 넉넉하게 잡아주는게 좋아보입니다~

executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("AsyncExecutor-");
executor.initialize();
return executor;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import com.yam.app.account.domain.RegisterAccountEvent;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

@Component
final class MailManager {
class MailManager {

private final MailDispatcher mailDispatcher;
private final TemplateEngine templateEngine;
Expand All @@ -21,6 +22,7 @@ public MailManager(@Value("${app.mail.host}") String host,
this.host = host;
}

@Async
@EventListener
public void handle(RegisterAccountEvent event) {
var newAccount = event.getAccount();
Expand Down
2 changes: 0 additions & 2 deletions src/test/java/com/yam/app/CircularDependencyTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.Disabled;

@AnalyzeClasses(packagesOf = YouAndMeApplication.class)
@Disabled
final class CircularDependencyTests {

@ArchTest
Expand Down
27 changes: 27 additions & 0 deletions src/test/java/com/yam/app/YouAndMeApplicationTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.yam.app;

import static org.mockito.Mockito.mockStatic;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.boot.SpringApplication;

final class YouAndMeApplicationTests {

@Test
@DisplayName("SpringApplication.run() 실행을 Mocking합니다.")
void mainShouldStartMyApplication() {
try (MockedStatic<SpringApplication> mocked = mockStatic(SpringApplication.class)) {

mocked.when(
() -> SpringApplication.run(YouAndMeApplication.class, "foo"))
.thenReturn(null);

YouAndMeApplication.main(new String[]{"foo"});

mocked.verify(
() -> SpringApplication.run(YouAndMeApplication.class, "foo"));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
package com.yam.app.account.integration;

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

import com.fasterxml.jackson.databind.ObjectMapper;
import com.yam.app.account.presentation.RegisterAccountRequest;
import org.junit.jupiter.api.BeforeEach;
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.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.client.MockMvcWebTestClient;

@SpringBootTest
@AutoConfigureMockMvc
Expand All @@ -28,6 +26,15 @@ final class AccountIntegrationTests {
@Autowired
private ObjectMapper objectMapper;

private WebTestClient webTestClient;

@BeforeEach
void setUp() {
this.webTestClient = MockMvcWebTestClient
.bindTo(mockMvc)
.build();
}

@Test
@DisplayName("새로운 계정을 등록하는 회원가입 시나리오")
void register_success() throws Exception {
Expand All @@ -38,18 +45,20 @@ void register_success() throws Exception {
request.setPassword("password!");

// Act
final var actions = mockMvc.perform(post("/api/accounts")
var spec = webTestClient
.post()
.uri("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
.bodyValue(objectMapper.writeValueAsString(request))
.exchange();

// Assert
actions
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.email").isString())
.andExpect(jsonPath("$.nickname").isString());
spec
.expectStatus().isOk()
.expectBody()
.jsonPath("$.id").isNumber()
.jsonPath("$.email").isNotEmpty()
.jsonPath("$.nickname").isNotEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
package com.yam.app.account.presentation;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.yam.app.account.application.AccountFacade;
import org.javaunit.autoparams.AutoSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.client.MockMvcWebTestClient;

@DisplayName("회원가입 등록 HTTP API")
@WebMvcTest(RegisterAccountApi.class)
Expand All @@ -30,6 +30,15 @@ class RegisterAccountApiTests {
@MockBean
private AccountFacade accountFacade;

private WebTestClient webTestClient;

@BeforeEach
void setUp() {
this.webTestClient = MockMvcWebTestClient
.bindTo(mockMvc)
.build();
}

@Test
@DisplayName("회원가입에 적절한 파라미터가 입력되고 회원가입이 성공한다.")
void register_success() throws Exception {
Expand All @@ -43,19 +52,21 @@ void register_success() throws Exception {
when(accountFacade.register(request)).thenReturn(
new AccountResponse(1L, "[email protected]", "rebwon"));

final var actions = mockMvc.perform(post("/api/accounts")
var spec = webTestClient
.post()
.uri("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
.bodyValue(objectMapper.writeValueAsString(request))
.exchange();

// Assert
actions
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.email").isString())
.andExpect(jsonPath("$.nickname").isString());
spec
.expectStatus().isOk()
.expectBody()
.jsonPath("$.id").isNumber()
.jsonPath("$.email").isNotEmpty()
.jsonPath("$.nickname").isNotEmpty();
}

@Test
Expand All @@ -68,13 +79,15 @@ void register_account_api_not_use_accept_header_and_content_type() throws Except
request.setPassword("password!");

// Act
final var actions = mockMvc.perform(post("/api/accounts")
.content(objectMapper.writeValueAsString(request))
);
var spec = webTestClient
.post()
.uri("/api/accounts")
.bodyValue(objectMapper.writeValueAsString(request))
.exchange();

// Assert
actions
.andExpect(status().isUnsupportedMediaType());
spec
.expectStatus().isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
}

@ParameterizedTest
Expand All @@ -88,16 +101,17 @@ void register_http_parameter_is_null_and_empty(String arg) throws Exception {
request.setPassword(arg);

// Act
final var actions = mockMvc.perform(post("/api/accounts")
var spec = webTestClient
.post()
.uri("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
.bodyValue(objectMapper.writeValueAsString(request))
.exchange();

// Assert
actions
.andDo(print())
.andExpect(status().isBadRequest());
spec
.expectStatus().isBadRequest();
}

@ParameterizedTest
Expand All @@ -111,15 +125,16 @@ void register_http_parameter_is_invalid_email_and_password(String arg) throws Ex
request.setPassword(arg);

// Act
final var actions = mockMvc.perform(post("/api/accounts")
var spec = webTestClient
.post()
.uri("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
.bodyValue(objectMapper.writeValueAsString(request))
.exchange();

// Assert
actions
.andDo(print())
.andExpect(status().isBadRequest());
spec
.expectStatus().isBadRequest();
}
}