Backend boot camp/Session4
[Spring WebFlux] Spring WebFlux
by orioncsy
2022. 12. 9.
Spring WebFlux
Spring WebFlux
개념
- Spring 5 부터 추가된 기술 스택은 Reactive 스택
- WebFlux는 Reactor의 Flux가 Web에 사용된 기술
- Reactive Streams를 구현한 구현체라면 Reactor가 아니어도 사용 가능
Spring WebFlux 애플리케이션 VS Spring MVC 애플리케이션
기술 스택 비교
- WebFlux(Reactive Stack)
- Non-Blocking 통신 지원
- Reactive Adapter를 사용해 RxJava 등 다른 리액티브 라이브러리 사용 가능
- Spring Security를 사용한 보안, WebFilter를 사용해 리액티브 특성에 맞는 인증, 권한 적용
- 웹 계층에 Spring WebFlux 사용
- 리액티브 스택을 데이터 액세스 계층까지 확장하여 Non-Blocking 통신 지원
- R2DBC(Reactive Relation DataBase Connectivity)는 관계형 데이터베이스에 Non-Blocking 통신을 적용하기 위한 표준 사양으로 DB 벤더에서 사양에 맞는 드라이버 구현 및 공급
- Spring MVC(Servlet Stack)
- Blocking 통신 지원
- Servlet API의 스펙에 의존
- Spring Security를 사용한 보안
- 웹 계층에 Spring MVC 사용
- Spring Data Repository로 JDBC, JPA, NoSQL 지원
공통점
- @Controller 같은 애너테이션을 Spring WebFlux에도 사용 가능
- RestTemplate을 대체할 수 있는 Webclient를 양 쪽 모두 사용 가능
- Tomcat, Jetty 등의 서블릿 컨테이너는 서블릿 방식, 리액티브 방식 모두 지원
코드 비교
- Spring MVC Blocking 처리 방식
- Spring MVC 기반 메인 애플리케이션 코드
- Controller 클래스에서 @RestController를 붙여 생성
- 우선 CoffeeResponseDto라는 클래스를 생성하여 커피ID, 한글 이름, 영어 이름, 가격을 멤버로 선언한다.
- RestTemplate 객체를 선언해서 생성자로 RestTemplateBuilder를 객체로 받아 build() 메서드를 이용해 할당한다.
- @GetMapping으로 get 요청만 하나 작성해서 coffee-id를 받아 로그를 현재 시간으로(LocalDateTime.now())를 사용하여 출력한다.
- 로그를 출력하고 ResponseEntity를 선언하여 restTemplate 객체에 getForEntity() 메서드를 사용하여 uri, CoffeeResponseDto.class를 넘겨주어 객체를 받고 RespopnseEntity.ok(response.getBody())를 리턴한다.
- uri의 경우 http://localhost:7070/v11/coffes/1로 다른 포트를 사용하는 외부 애플리케이션 포트(7070포트사용)을 할당하여 다른 애플리케이션에 요청을 전송하도록 작성
- 외부 애플리케이션 controller
- application.yml 파일에 포트 번호 지정
server:
port: 7070
- 외부 애플리케이션에도 get 핸들러를 작성하고 CoffeeResponseDto 객체를 선언하여 반환한다.
- 이때 Thread.sleep()을 통해 스레드 동작을 잠시 멈춘다. 외부 애플리케이션 요청 처리 시간을 5초 정도가지는 것을 구현하는 것이다.
- 메인 애플리케이션 클라이언트 코드
- @SpringBootApplacation이 붙은 클래스에서
- @Bean으로 등록하여 CommandLineRunner를 반환하는 run() 메서드를 생성
- (String… args)→{} 형태로 반환
- 그 안에 요청 시작 시각을 LocalTime.now()를 통해 로그로 출력
- for문을 돌아 5번의 요청을 반복하고 로그로 시간을 찍는다.
- 5번의 요청은 private을 통해 CoffeeResponseDto를 반환하는 메서드를 생성해서 호출
- RestTemplate 객체를 생성해서 메인 애플리케이션 get 핸들러를 uri로 해서 ResponseEntity 객체에 restTemplate.getForEntity() 메서드를 통해 받아와서 .getBody()로 Dto 객체를 반환한다.
- 실행할 때는 외부 애플리케이션이 먼저 실행되어야 메인 애플리케이션에서 외부 애플리케이션에 요청이 가능해진다.
- 요청 결과는 각 요청마다 5초 정도의 시간이 걸려 총 25초가 걸린다. 이것은 Blocking 방식으로 외부 서버와 통신한다는 것을 확인 가능
- Spring WebFlux의 Non-Blocking 처리 방식
- 메인 애플리케이션 controller
- 이전 MVC 코드와 유사하지만 외부 애플리케이션의 통신을 WebClient라는 Rest Client를 사용한다.
- getCoffee 핸들러 메서드에서 반환 타입이 Mono<CoffeeResponseDto>를 사용한다.
- 반환으로 Webclient.creat()을통해 생성
- .uri()로 통신할 uri 작성
- .retrieve().bodyToMono(CoffeeResponseDto.class)를 통해 요청을 받아서 Mono 타입으로 만든 후 반환
- 외부 애플리케이션 controller
- 이전 코드와 유사하지만 @ResponseStatus(HttpStatus.OK)를 통해 핸들러 상태를 전달
- 반환 타입을 Mono<CoffeeResponseDto>로 변경하고 return Mono.just(responseDto)를 통해 Webflux 활용
- 메인 애플리케이션 클라이언트 코드
- 이전 코드와 유사하지만 getCoffee() 메서드를 반환형을 Mono<CoffeeResponseDto>로 변경하고 위에서 작성했던 것과 마찬가지로 WebClient를 생성해서 반환한다.
- run() 메서드에서는 for문에서 getCoffee() 메서드를 호출해서 .subscribe() 메서드를 사용해 log를 출력하는 람다식을 구현한다.
- 실행해보면 총 10초 정도의 시간이 소요된다.
- 즉 Spring webFlux의 경우, Blocking 되지 않고 비동기적으로 요청을 전송하고 처리한다. 이것이 Non-Blocking 방식이다.
Reactive application 구현
프로젝트 설정
build.gradle 설정
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
runtimeOnly 'io.r2dbc:r2dbc-h2'
- 기존의 spring-boot-starter-web에서 webflux로 변경
- JPA 대신 R2DBC 사용
- H2 인메모리 대신 io.r2dbc:r2dbc-h2 사용
- 리액티브 스택은 H2 웹 콘솔을 정상 지원하지 않는다.
application.yml 설정
- spring.sql.init.shema-locations에서 classpath*:db/h2/schema.sql를 선언한다.
- Spring Data JPA의 Auto DDL 기능을 제공하지 않아 직접 설정을 추가해야 한다.
- spring.sql.init.data-locations에서 classpath*:db/h2/data.sql을 실행
- logging.level.org.springframework.r2dbc 를 DEBUG로 설정
DB Schema 설정
- src/main/resources/db/h2에 scheme.sql 파일의 테이블 생성 스크립트 작성
애플리케이션 공통 설정
- main() 메서드가 포함된 애플리케이션 클래스에 @EnableR2dbcRepositories를 붙여서 R2DBC repository 사용
- @EnableR2dbcAuditing을 추가하여 데이터베이스에 엔티티가 저장, 수정될 때 생성, 수정 날짜 자동 저장을 한다.
Controller 구현
- 기존에 구현된 controller에서 변경
- postMember()메서드
- 매개변수를 Mono<MemberDto.Post>로 타입을 변경한다.
- Mono<MemberDto.Response> 타입의 객체를 선언한다.
- 입력으로 받은 requestBody를 .flatMap(post -> memberService.createMember(mapper.memberPostToMember(post))) 을 통해 객체를 추가
- .map(member→mapper.memberToMemberResponse(member))를 통해 ResponseDto 형태로 변환한다.
- return을 통해 new ResponseEntity<>(위에서 구한 객체, HttpStatus.CREATED)) 반환
- patchMember() 메서드
- getMember() 메서드
- getmembers() 메서드
- Mono<List<MemberDto.Response>> response를 선언한다.
- memberService.findMembers()하여 PageRequest.of(page-1, size, Sort.by(”memberId”).descending())을 전달한다.
- .map()을 통해 pageMember→ mapper.membersToMemberResponses(pageMember.getContent())을 전달한다.
- 결과를 ResponseEntity에 적용하여 반환
- deleteMember() 메서드
- Mono<Void>를 선언한 객체에 memberService.deleteMember를 사용하여 적용
Entity 클래스 정의
- 기존 엔티티 클래스 변형
- @Entity 애너테이션 제거
- @Id를 식별자에 추가
- @CreatedDate, @LastModifiedDate 등을 추가하여 LocalDateTime 객체를 선언한다.
서비스 클래스 구현
- verifyExistEmail() 메서드
- 반환형을 Mono<Void>로 변경
- memberRepository.findByEmail()을 통해 객체를 찾는다.
- .flatMap()에서 값이 null이 아닌 경우에 return Mono.error()로 에러를 발생시킨다.
- 값이 null인 경우 return Mono.empty()를 반환
- findVerifiedMEmber() 메서드
- memberRepository에서 findById() 로 객체를 찾고 .switchIfEmpty(Mono.err())를 통해 객체가 없다면 에러 발생시킨다.
- createMember() 메서드
- 반환 타입을 Mono<Member>로 변경
- verifyExistEmail()을 통해 회원이 존재하는지 확인
- .then(memberRepository.save(member))를 통해 없다면 저장
- .map(resultMember→{});
- template.insert(new Stamp(resultMember.getMemberId())).subscribe()
- Stamp 정보를 테이블에 저장하고 subscribe()를 통해 동작을 수행한다.
- return resultMember를 통해 결과를 반환
- updateMember() 메서드
- 위와 마찬가지로 Mono<Member>를 반환 타입으로 설정
- findVerifiedMember()를 통해 기존 회원 탐색하고
- .map()에서 beanUtils 객체를 활용하여 member에 null 값을 제외하고 모든 멤버 값을 결과 객체에 넣어준다.
- BeanUtils.copyProperties(src, to, null, null)을 사용하면 null 값을 제외하고 src의 멤버를 to의 멤버에 복사한다.
- flatMap()에서 memberRepository.save()를 통해 저장한다.
- findMember() 메서드
- 반환 타입을 Mono<Member>로 변경
- findVerifiedMember()를 사용하여 반환
- findMembers() 메서드
- 반환 타입은 Mono<Page<Member>>로 선언
- 매개 변수로 PageRequest 객체를 받는다.
- memberRepository.findAllBy(pageRequest)를 통해 회원 목록을 가져온다.
- .collectList()를 통해 List 객체로 가져오고
- .zipWith(memberRepository.count())를 통해 전체 데이터의 건 수를 받는다.
- .map(tuple -> new PageImpl<>(tuple.getT1(), pageRequest, tuple.getT2()));
- tuple.getT1()은 list 객체이고, tuple.getT2()는 전체 건수이다.
- deleteMember() 메서드
- 반환 타입은 Mono<void>로 선언
- findVerifiedMember를 통해 회원을 찾는다.
- flatMap()에서 member -> template.delete(query(where("MEMBER_ID").is(memberId)), Stamp.class) 를 통해서 Stamp 테이블에서 해당 memberID를 갖는 값을 삭제한다.
- then(memberRepository.deleteById(memberId))를 통해서 제거한다.
Repository
- extends R2dbcRepository<Member,Long>을 통해 R2dbcRepository의 인터페이스를 확장
- Mono<Member> findByEmail(String email)을 통해 email 값에 해당하는 값을 찾아 Mono 객체로 반환
- Flux<Member> findAllBy(Pageable pageable)을 통해 Pageable 객체로 받아서 모든 회원 목록을 Flux 객체로 반환