Spring
Spring WebFlux - Spring WebFlux 2
RangA
2023. 6. 15. 17:51
Spring WebFlux
리액티브한 샘플 애플리케이션 구현
프로젝트 설정
- Spring WebFlux를 프로젝트에서 사용하기 위한 설정은 Spring MVC 학습에서 설정했던 방식과 크게 다르지 않지만 약간의 차이점이 존재함
build.gradle 설정
...
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux' // (1)
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc' // (2)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
implementation 'org.mapstruct:mapstruct:1.5.1.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final'
runtimeOnly 'io.r2dbc:r2dbc-h2' // (3)
}
...
- Spring WebFlux를 프로젝트에서 사용하기 위해서는 build.gradle에 의존성을 추가하면 됨
- (1)에서는 우리가 기존에 사용하던 ‘spring-boot-starter-web’에서 ‘spring-boot-starter-webflux’로 변경함
- 리액티브 스택에서는 JPA 대신에 R2DBC를 사용하며, 따라서 (2)에서 기존의 'spring-boot-starter-data-jpa’에서 ‘spring-boot-starter-data-r2dbc'로 변경됨
- 인메모리 DB인 H2 역시 Non-Blocking을 지원하는 드라이버를 사용할 수 있도록 (3)과 같이 ‘com.h2database:h2’에서 ‘io.r2dbc:r2dbc-h2’로 변경됨
application.yml 설정
spring:
sql:
init:
schema-locations: classpath*:db/h2/schema.sql // (1)
data-locations: classpath*:db/h2/data.sql // (2)
logging:
level:
org:
springframework:
r2dbc: DEBUG // (3)
- Spring Data 패밀리 프로젝트에서는 (1), (2)와 같이 직접 테이블 스키마를 정의하고, 샘플 데이터를 정의해서 애플리케이션 실행 시, SQL 스크립트를 실행할 수 있음
- Spring Data JDBC와 마찬가지로 Spring Data R2DBC 역시 Spring Data JPA의 Auto DDL 같은 기능을 제공하지 않기 때문에 (1)과 같이 직접 SQL 스크립트 설정을 추가해야 함
- 회원 목록 조회를 위해 애플리케이션 실행 시, 20 row의 샘플 데이터가 MEMBER 테이블에 저장되도록 data.sql에 스크립트가 추가되어 있음
- Spring Data R2DBC 기술을 이용해 데이터베이스와 상호작용하는 동작을 로그로 출력하고자 r2dbc 로그 레벨을 ‘DEBUG’로 설정함
DB Schema 설정
CREATE TABLE IF NOT EXISTS MEMBER (
MEMBER_ID bigint NOT NULL AUTO_INCREMENT,
EMAIL varchar(100) NOT NULL UNIQUE,
NAME varchar(100) NOT NULL,
PHONE varchar(100) NOT NULL,
MEMBER_STATUS varchar(20) NOT NULL,
CREATED_AT datetime NOT NULL,
LAST_MODIFIED_AT datetime NOT NULL,
PRIMARY KEY (MEMBER_ID)
);
CREATE TABLE IF NOT EXISTS STAMP (
STAMP_ID bigint NOT NULL AUTO_INCREMENT,
STAMP_COUNT bigint NOT NULL,
MEMBER_ID bigint NOT NULL,
CREATED_AT datetime NOT NULL,
LAST_MODIFIED_AT datetime NOT NULL,
PRIMARY KEY (STAMP_ID),
FOREIGN KEY (MEMBER_ID) REFERENCES MEMBER(MEMBER_ID)
);
...
- ‘src/main/resources/db/h2’에 있는 scheme.sql 파일의 테이블 생성 스크립트
- schema.sql 파일은 테이블 schema 설정에 사용됨
애플리케이션 공통 설정
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.r2dbc.config.EnableR2dbcAuditing;
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
@EnableR2dbcRepositories // (1)
@EnableR2dbcAuditing // (2)
@SpringBootApplication
public class WebFluxCoffeeOrderSampleApplication {
public static void main(String[] args) {
SpringApplication.run(WebFluxCoffeeOrderSampleApplication.class, args);
}
}
- R2DBC의 Reposiroty를 사용하기 위해서는 main() 메서드가 포함된 애플리케이션 클래스(WebFluxCoffeeOrderSampleApplication)에 (1)과 같이 @EnableR2dbcRepositories 애너테이션을 추가해 주어야 함
- 데이터베이스에 엔티티가 저장 및 수정될 때, 생성 날짜와 수정 날짜를 자동으로 저장할 수 있도록 Auditing 기능을 사용하기 위해 (2)와 같이 @EnableR2dbcAuditing 애너테이션을 추가함
Controller 구현
Spring WebFlux가 적용된 MemberController
import com.member.dto.MemberDto;
import com.member.mapper.MemberMapper;
import com.member.service.MemberService;
import com.utils.UriCreator;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import javax.validation.Valid;
import javax.validation.constraints.Positive;
import java.net.URI;
import java.util.List;
@Validated
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
private final MemberMapper mapper;
public MemberController(MemberService memberService, MemberMapper mapper) {
this.memberService = memberService;
this.mapper = mapper;
}
@PostMapping
public Mono<ResponseEntity> postMember(@Valid @RequestBody Mono<MemberDto.Post> requestBody) { // (1)
return requestBody
.flatMap(post -> memberService.createMember(mapper.memberPostToMember(post))) // (2)
.map(createdMember -> {
URI location = UriCreator.createUri(MEMBER_DEFAULT_URL, createdMember.getMemberId());
return ResponseEntity.created(location).build();
});
}
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(@PathVariable("member-id") @Positive long memberId,
@Valid @RequestBody Mono<MemberDto.Patch> requestBody) { // (3)
Mono<MemberDto.Response> response =
requestBody
.flatMap(patch -> { // (4)
patch.setMemberId(memberId);
return memberService.updateMember(mapper.memberPatchToMember(patch));
})
.map(member -> mapper.memberToMemberResponse(member));
return new ResponseEntity<>(response, HttpStatus.OK);
}
@GetMapping("/{member-id}")
public ResponseEntity getMember(@PathVariable("member-id") @Positive long memberId) {
Mono<MemberDto.Response> response =
memberService.findMember(memberId) // (5)
.map(member -> mapper.memberToMemberResponse(member));
return new ResponseEntity(response, HttpStatus.OK);
}
@GetMapping
public ResponseEntity getMembers(@RequestParam("page") @Positive int page,
@RequestParam("size") @Positive int size) {
Mono<List<MemberDto.Response>> response =
memberService.findMembers(PageRequest.of(page - 1, size, Sort.by("memberId").descending())) // (6)
.map(pageMember -> mapper.membersToMemberResponses(pageMember.getContent()));
return new ResponseEntity<>(response, HttpStatus.OK);
}
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(@PathVariable("member-id") long memberId) {
Mono<Void> result = memberService.deleteMember(memberId); // (7)
return new ResponseEntity(result, HttpStatus.NO_CONTENT);
}
}
- Mono라는 Reactor의 타입이 코드 곳곳에 추가되었음
- Mono라는 Reactor의 타입이 추가되는 순간, Reactor의 동작 원리와 Reactor에서 지원하는 다양한 Operator의 사용법을 알아야 함
- (1)을 보면 postMember() 핸들러 메서드의 파라미터인 MemberDto.Post 객체가 Mono로 래핑 되어 있음
- Spring WebFlux에서는 request body로 단순히 MemberDto.Post 객체를 전달받을 수도 있지만 (1)과 같이 Mono<MemberDto.Post>와 같이 전달받을 수도 있음
- 전달받은 객체에 Blocking 요소가 포함되지 않도록 request body를 전달받는 순간부터 Non-Blocking으로 동작하도록 Operator 체인을 바로 연결해서 다음 처리를 시작할 수 있음
- (1)에서 전달받은 request body가 Mono<MemberDto.Post>이기 때문에 (2)와 같이 바로 다음 처리를 Non-Blocking으로 처리할 수 있도록 Operator 체인을 연결할 수 있음
- (2)에서는 MemberService 클래스의 createMember() 메서드를 호출해서 회원 정보를 저장하는 처리를 바로 이어서 수행하고 있음
- 모든 처리가 Mono Sequence 내에서 처리되기 때문에 Non-Blocking으로 처리됨
- (3)에서도 역시 Mono<MemberDto.Patch>로 request body를 전달 받음
- (4)에서 Mono Sequence 내부에서 MemberService 클래스의 updateMember() 메서드를 호출해서 회원 정보를 저장하는 처리를 바로 이어서 수행함
- (5)에서는 MemberService 클래스의 findeMember() 메서드를 호출해서 회원 정보를 조회함
- 한 가지 기억해야 될 내용은 MemebrService 클래스의 메서드를 호출해서 Mono Sequecne를 추가적으로 연결할 수 있다는 의미는 MemberService 클래스의 메서드 역시 리턴 타입이 Mono라는 것
- 기존에 알고 있던 MemberService 클래스의 메서드는 대부분 Member 객체를 리턴했지만 Spring WebFlux 기반의 MemberService 클래스는 Mono와 같이 Mono로 래핑 한 값을 리턴함
- (6)에서는 페이지네이션을 위해 PageRequest 객체를 MemberController 쪽에서 직접 만들어서 MemberService 쪽으로 전달하고 있는 것이 Spring MVC 방식과 다름
- (7)에서는 회원 정보를 삭제하기 위해 MemberService의 deleteMember() 메서드를 호출함
- 리턴되는 데이터가 없는 경우, Spring MVC 방식에서는 메서드이 리턴 타입이 void이지만 Spring WebFlux에서는 Mono<Void>가 됨
Entity 클래스 정의
Member 엔티티
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@NoArgsConstructor
@Getter
@Setter
@Table // (1)
public class Member {
@Id // (2)
private Long memberId;
private String email;
private String name;
private String phone;
private MemberStatus memberStatus = MemberStatus.MEMBER_ACTIVE;
@CreatedDate // (3)
private LocalDateTime createdAt;
@LastModifiedDate // (4)
@Column("last_modified_at")
private LocalDateTime modifiedAt;
public Member(String email) {
this.email = email;
}
public Member(String email, String name, String phone) {
this.email = email;
this.name = name;
this.phone = phone;
}
public enum MemberStatus {
MEMBER_ACTIVE("활동중"),
MEMBER_SLEEP("휴면 상태"),
MEMBER_QUIT("탈퇴 상태");
@Getter
private String status;
MemberStatus(String status) {
this.status = status;
}
}
}
- R2DBC는 Spring Data JDBC나 Spring Data JPA처럼 애너테이션이나 컬렉션 등을 이용한 연관 관계 매핑은 지원하지 않으므로, 엔티티 클래스의 코드는 Spring Data 패밀리 프로젝트에서 사용되는 최소한의 설정만 포함됨
- (1)에서 @Table 애너테이션을 명시적으로 추가했지만 생략해도 무방함
- (2)에서 memberId 필드에 @Id 애너테이션을 추가함
- Spring Data 패밀리 프로젝트의 기술들은 식별자에 해당되는 필드에 @Id 애너테이션을 필수로 추가해야 함
- (3)과 (4)에서는 @CreatedDate, @LastModifiedDate 애너테이션을 추가해서 데이터가 저장 또는 업데이트될 때 별도의 날짜/시간 정보를 추가하지 않아도 Spring Data 패밀리에서 지원하는 Auditing 기능을 통해 자동으로 날짜/시간 정보가 테이블에 저장되도록 함
Stamp
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@NoArgsConstructor
@Getter
@Setter
@Table
public class Stamp {
@Id
private long stampId;
private int stampCount;
private long memberId; // (1)
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
@Column("last_modified_at")
private LocalDateTime modifiedAt;
public Stamp(long memberId) {
this.memberId = memberId;
}
}
- R2DBC가 별도의 연관 관계 매핑 기능을 지원하지 않기 때문에 테이블 간의 관계로 따지자면 Stamp 클래스가 Member와 1대1 관계이므로 (1)과 같이 MEMBER 테이블의 식별자에 해당하는 memberId 필드가 외래키 역할을 하도록 추가함
- Spring R2DBC에서 엔티티 간의 연관 관계 매핑을 지원하지 않는 이유는 연관 관계 매핑이 적용되는 순간 내부적으로 Blocking 요소가 포함될 가능성이 있기 때문
- Spring WebFlux의 기술을 효과적으로 잘 활용하기 위해서는 구현 코드 또는 사용하는 써드 파티 라이브러리 등에 Blocking 요소가 포함이 되는지 여부를 잘 판단하는 것이 중요함
서비스 클래스 구현
- Spring WebFlux에서는 모든 데이터의 이동이 Mono 또는 Flux 안에서 이루어짐
import com.exception.BusinessLogicException;
import com.exception.ExceptionCode;
import com.member.entity.Member;
import com.member.repository.MemberRepository;
import com.stamp.Stamp;
import com.utils.CustomBeanUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import static org.springframework.data.relational.core.query.Criteria.where;
import static org.springframework.data.relational.core.query.Query.query;
@Transactional
@Service
public class MemberService {
private final MemberRepository memberRepository; // (1)
private final CustomBeanUtils<Member> beanUtils;
private final R2dbcEntityTemplate template; // (2)
public MemberService(MemberRepository memberRepository, CustomBeanUtils<Member> beanUtils, R2dbcEntityTemplate template) {
this.memberRepository = memberRepository;
this.beanUtils = beanUtils;
this.template = template;
}
public Mono<Member> createMember(Member member) {
return verifyExistEmail(member.getEmail()) // (3)
.then(memberRepository.save(member)) // (4)
.map(resultMember -> {
// Stamp 저장
template.insert(new Stamp(resultMember.getMemberId())).subscribe(); // (5)
return resultMember;
});
}
public Mono<Member> updateMember(Member member) {
return findVerifiedMember(member.getMemberId()) // (6)
.map(findMember -> beanUtils.copyNonNullProperties(member, findMember)) // (7)
.flatMap(updatingMember -> memberRepository.save(updatingMember)); // (8)
}
@Transactional(readOnly = true)
public Mono<Member> findMember(long memberId) {
return findVerifiedMember(memberId);
}
@Transactional(readOnly = true)
public Mono<Page<Member>> findMembers(PageRequest pageRequest) {
return memberRepository.findAllBy(pageRequest) // (9)
.collectList() // (10)
.zipWith(memberRepository.count()) // (11)
.map(tuple -> new PageImpl<>(tuple.getT1(), pageRequest, tuple.getT2())); // (12)
}
public Mono<Void> deleteMember(long memberId) {
return findVerifiedMember(memberId)
.flatMap(member -> template.delete(query(where("MEMBER_ID").is(memberId)), Stamp.class)) // (13)
.then(memberRepository.deleteById(memberId)); // (14)
}
private Mono<Void> verifyExistEmail(String email) {
return memberRepository.findByEmail(email)
.flatMap(findMember -> {
if (findMember != null) {
return Mono.error(new BusinessLogicException(ExceptionCode.MEMBER_EXISTS)); // (15)
}
return Mono.empty(); // (16)
});
}
private Mono<Member> findVerifiedMember(long memberId) {
return memberRepository
.findById(memberId)
.switchIfEmpty(Mono.error(new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND))); // (17)
}
}
- Spring Data R2DBC에서 데이터베이스에 접근하는 방법은 두 가지
- (1)은 우리가 사용해봤던 익숙한 방식인 Repository
- (2)는 Spring Data R2DBC에서 지원하는 가독성 좋은 SQL 쿼리 빌드 메서드를 이용하는 방식
- (3)에서 회원 정보를 등록하기 전에 이미 존재하는 이메일인지의 여부를 검증함
- (15)를 보면 email을 조건으로 조회한 후, 존재하는 이메일이면 Exception을 throw하고 있음
- Spring MVC에서는 throw 키워드를 사용해서 Exception을 throw하지만 Spring WebFlux에서는 error() Operator를 사용해서 Exception을 throw할 수 있음
- (16)은 존재하는 이메일이 아닌 경우의 처리
- Spring MVC의 경우 별도의 코드가 필요 없지만 Spring WebFlux의 경우 Mono 안에서 모든 처리가 이루어져야 하므로, Mono.empty()를 리턴해 주어야 다음 동작을 진행할 수 있음
- (15)를 보면 email을 조건으로 조회한 후, 존재하는 이메일이면 Exception을 throw하고 있음
- (3)에서 존재하는 이메일이 아닐 경우 다음 동작을 진행함
- (4)의 then() Operator는 이 전에 동작하고 있던 Sequence를 종료하고 새로운 Sequence를 시작하게 해주는 Operator
- (4)에서는 회원 정보를 저장 한 뒤에 다음 동작을 수행하도록 리턴 값으로 Mono<Member>를 리턴하며, 여기서의 Mono<Member>는 데이터베이스에 저장된 데이터
- Spring MVC 기반 코드에서는 JPA를 CASCADE 기능을 이용해서 회원 정보를 저장하면 스탬프 정보까지 자동으로 테이블에 저장을 해주지만 Spring Data R2DBC의 경우, 직접 테이블에 저장하는 코드가 필요함
- (5)에서 R2dbcEntityTemplate의 insert() 메서드를 이용해서 스탬프 정보를 테이블에 저장함
- 중요한 포인트는 insert() 메서드를 호출하고, subscribe()를 호출해야 된다는 것
- map() Operator에서 리턴하는 값은 Controller 쪽으로 전달하는 회원 정보
- 스탬프 정보는 회원 정보를 저장하는 Operator 체인 내부에 별도로 존재하는 Inner Sequence이기 때문에 subscribe()를 호출해야지만 테이블에 데이터를 저장하는 동작을 수행함
- 리액티브 프로그래밍의 특징 중 하나가 바로 subscribe() 메서드를 호출하지 않으면 아무 동작을 수행하지 않는다는 것
- (6)에서는 회원 정보 수정 전에 존재하는 회원인지 여부를 확인함
- (17)에서 switchIfEmpty() Operator를 사용하여 회원이 존재하지 않는 다면 Exception을 throw함
- switchIfEmpty() Operator는 emit되는 데이터가 없다면 switchIfEmpty() Operator의 파라미터로 전달되는 Publisher가 대체 동작을 수행할 수 있게 해주는 Operator
- (7)은 회원 정보 중에서 request body에 포함된 정보만 테이블에 업데이트되도록 해주는 유틸리티 클래스
- beanUtils.copyNonNullProperties(member, findMember))에서 첫 번째 파라미터는 request body에 포함된 데이터이며, 두 번째 파라미터는 테이블에서 조회한 회원의 기존 데이터
- 첫 번째 파라미터(member)에서 null이 아닌 필드의 값만 두 번째 파라미터(findMember)의 동일한 필드에 덮어 씌우기 때문에 실제 테이블에 저장 전, 간편하게 회원 정보 필드를 업데이트할 수 있음
- (7)에서 업데이트된 Member 객체를 (8)에서 테이블에 저장함
- (9)에서는 회원 정보 목록에 페이지네이션 처리함
- Spring MVC 기반 코드에서는 PageRequest 객체를 MemberService 클래스에서 생성했지만 여기서는 PageRequest 객체가 Sequence 내부에서 재사용되어야하기 때문에 Controller 쪽에서 미리 생성한 PageRequest 객체를 findMembers() 메서드의 파라미터로 전달함
- 파라미터로 전달받은 PageRequest 객체와 (10)에서 collectList() Operator를 통해 변환되는 List 객체, (11)의 테이블에서 조회되는 전체 데이터의 건 수는 모두 (12)에서 PageImpl 객체를 생성하기 위해 필요한 정보
- zipWith() Operator는 zipWith()를 호출하는 Mono와 zipWith()의 파라미터로 주어지는 Mono에서 emit하는 두 개의 데이터를 Tuple2 객체로 결합한 후, Downstream에 전달하는 Operator
- collectList() Operator는 List를 emit하고, memberRepository.count()는 Long 타입의 전체 건 수를 emit함
- map() Operator가 전달받는 Tuple2 객체인 tuple.getT1()의 값은 List이고, tuple.getT2()의 값은 전체 건 수(Long)
- (13)에서는 R2dbcEntityTemplate의 delete() 메서드와 SQL 쿼리 빌드 메서드 체인을 통해 스탬프 정보를 삭제함
- MEMBER 테이블과 STAMP 테이블은 외래키로 관계를 맺고 있기 때문에 MEMBER 테이블의 식별자를 외래키로 가지는 STAMP 테이블의 스탬프 정보를 먼저 삭제해 주어야 함
- (13)에서 스탬프 정보를 삭제했으니 (14)에서는 회원 정보를 삭제함
- 여기서는 MemberRepository의 deleteById() 메서드를 이용해서 회원 정보를 삭제함
Repository 구현
import com.member.entity.Member;
import org.springframework.data.domain.Pageable;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface MemberRepository extends R2dbcRepository<Member, Long> { // (1)
Mono<Member> findByEmail(String email); // (2)
Flux<Member> findAllBy(Pageable pageable); // (3)
}
- (1)의 R2dbcRepository 인터페이스는 Spring Data R2DBC에서 사용하는 Repository
- R2dbcRepository는 기본적인 CRUD 기능과 페이지네이션, 정렬 기능을 모두 포함하고 있음
- (2)와 (3)을 보면 Spring Data R2DBC에서 조회되는 데이터는 모두 Mono 또는 Flux임을 알 수 있음
- 이를 통해 Controller부터 Repository까지 완전한 Non-Blocking 동작을 수행할 수 있게 됨