개요
지난 포스팅에선 Webflux의 기본개념과 Reactive Streams 의 이론에 대해 알아보았습니다.
이번엔 Weblfux를 사용해 비동기 API를 만들어보고 추후 시큐리티로 API KEY 방식의 인증을 구현해보겠습니다.
요샌 보통 JWT를 쓰지만, JWT는 예제도 많고 API KEY 방식도 아직 쓰이는곳이 많아서 선정했습니다.
먼저 예제 프로젝트를 생성해보겠습니다.
- JAVA11
- SpringBoot 2.7.4
- Gradle
- R2DBC
- Mysql
큰 항목은 이렇게 설정했으며, Gradle dependencies 에 r2dbc-mysql connection은 따로 입력해주세요.
기존 start.spring.io 에 있는 mysql connection은 연결이 안되므로 따로 디펜던시를 설정합니다.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
// 새로 추가 mysql connection
implementation 'dev.miku:r2dbc-mysql:0.8.2.RELEASE'
}
프로퍼티설정은 yaml 방식으로 변경하겠습니다 resources 디렉토리의 application.property 를 -> yml 로 변경
application.yml
spring:
r2dbc:
url: r2dbc:mysql://localhost:3306/test
username: test
password: 1234
pool:
max-size: 400
validation-query: SELECT 1
enabled: true
initial-size: 100
max-idle-time: 30m
data:
r2dbc:
repositories:
enabled: true
logging:
level:
org:
springframework:
r2dbc: DEBUG
왜 Mysql ? Reactive Streams 는 비동기인데?
당연히 Webflux 를 쓰려면 비동기식 DB를 쓰는게 바람직합니다. 그점에서 NoSql을 주로 쓰게됩니다.. (예: mongoDB)
하지만 기존의 데이터를 마이그레이션하기 힘든환경이거나, 현업에서 mysql 등 이미 쓰고있는 db를 활용해야하며,, 등등
다양한 이유로 nosql 을 쓰지못하거나 익숙하지 않다면 R2DBC의 도움으로 Mysql 등의 트랜잭션 SQL 을 비동기로 적용할 수 있습니다.
다만 아직 JPA같은 ORM은 못쓰며,, hibernate reactive 가 있지만 적용하기 어려운점등이 있어서
결국 R2DBC를 이용한 쿼리를 사용해 빠른 개발을 할 수 있습니다. 그래서 저도 익숙한 mysql + R2DBC 로 적용했습니다.
그래도 사이드, 학습용으로 db가 상관없다면 nosql 쓰세요..
이렇게 기본 프로젝트의 모듈설정을 java 11 로 맞추시고 위의 설정까지 했다면 기본 설정은 다 되었습니다.
저는 간단하게 예제로 유저정보를 가져오는 api를 만들어 보는것으로 하겠습니다.
프로젝트의 구조입니다.
Router Functional 구조로 구성되어 있습니다.
UserEntity
@Data
@Table(name = "user")
@Builder
public class UserEntity {
@Id
@Column("id")
private String id;
@Column("password")
private String password;
@Column("nickname")
private String nickname;
}
UserDTO
@Getter
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_EMPTY) // empty, null 빼고 데이터 주고받음
public class UserDTO {
private String id;
private String password;
private String nickName;
@Builder
public UserDTO (UserEntity user) {
this.id = user.getId();
this.password = user.getPassword();
this.nickName = user.getNickname();
}
@Builder
public UserDTO(String nick) {
this.nickName = nick;
}
}
UserRepository
@Repository
public interface UserRepository extends ReactiveCrudRepository<User,String> {
}
Mysql과 R2DBC를 사용하여 ReactiveCrudRepository 를 상속받았습니다.
UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public Mono<UserDTO> getUserNickName(String id) {
return userRepository.findById(id).map(m -> new UserDTO(m.getNickname()));
}
public Mono<UserDTO> getUserDetail(String id) {
return userRepository.findById(id).map(m -> new UserDTO(m));
}
}
RouterConfig & UserHandler
@Configuration
@EnableWebFlux
@RequiredArgsConstructor
public class RouterConfig {
private final UserHandler userHandler;
@Bean
public RouterFunction<ServerResponse> routes() {
return RouterFunctions
.route()
.path("/user", builder -> builder
.nest(accept(MediaType.APPLICATION_JSON), builder2 -> builder2
.GET("/detail", userHandler::getUserDetail))
.GET("/nickname/{id}", userHandler::getUserNickName)
).build();
}
}
@Component
@RequiredArgsConstructor
public class UserHandler {
private final UserService userService;
private final Logger logger = LoggerFactory.getLogger(UserHandler.class);
public Mono<ServerResponse> getUserNickName (ServerRequest request) {
logger.info("Check Params : " + request);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(userService.getUserNickName(request.pathVariable("id")), UserEntity.class);
}
public Mono<ServerResponse> getUserDetail (ServerRequest request) {
logger.info("Check Params : " + request.bodyToMono(UserDTO.class));
// MultiValueMap<String, String> params = request.queryParams(); ver1 : map
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(userService.getUserDetail(request.queryParam("id").get()), UserEntity.class);
}
}
MVC 패턴에서 사용하던 Controller 의 기능을 router 와 handler 로 나눴다고 볼 수 있습니다.
Controller 어노테이션 대신 Configuration & Component 어노테이션이 사용되었습니다.
router는 request 요청을 처리하고 데이터는 handler에서 바인딩하고 가공합니다. 물론 router에서도 데이터를 바이딩하고 처리할 수 있지만, 명확한 역할분담을 위해 나눴습니다.
데이터를 정확히 가져오는지 테스트 코드를 작성 해보겠습니다.
@SpringBootTest
@ExtendWith(MockitoExtension.class)
class UserHandlerTest {
private WebTestClient client;
@Mock
private UserService userService;
@Autowired
private UserHandler handler;
@BeforeEach
void setup() {
RouterFunction<ServerResponse> router = RouterFunctions
.route(RequestPredicates.GET("/user/nickname/{id}"), handler::getUserNickName)
.andRoute(RequestPredicates.GET("/user/detail"), handler::getUserDetail);
this.client = WebTestClient.bindToRouterFunction(router).build();
}
@Test
void getUserNickName() {
client.get()
.uri("/user/nickname/{id}", "hello1")
.headers(header -> {
header.setContentType(MediaType.APPLICATION_JSON);
// token... etc...
})
.exchange()
.expectStatus()
.isOk()
.expectBody()
.consumeWith(System.out::println);
}
@Test
void getUserDetail() {
client.get()
.uri("/user/detail?id=hello1")
.headers(header -> {
header.setContentType(MediaType.APPLICATION_JSON);
})
.exchange()
.expectStatus()
.isOk()
.expectBody()
.consumeWith(System.out::println);
}
}
Controller 방식으로 테스트코드를 작성할 땐 잘 되던 전역테스트가 Router 방식으로 리팩토링하여 진행하려니 의존성 주입이 잘 안돼 애먹었습니다..
제가 한 방법은 router를 사용하기 위해 WebTestClient 에 router에 대입하여 의존성을 추가하고 BeforeEach로 초기화합니다.
아직 Webflux 의 Unit Test 에 익숙하지 않기도 하고, 의존성 주입에 대해 모호한 지식이 많아 고생했습니다..
레이어단위 유닛 테스트도 다음에 정리해보겠습니다.