목차
- 개요
- 핵심 차이점
- DAO 계층 변경
- Service 계층 변경
- 여러 쿼리 동시 실행하기
- Mono / Flux 처리 시 알아두면 좋은 것들
- 핵심 패턴 요약
- 참고 자료
1. 개요
Spring WebFlux프로젝트를 진행하며, DAO와 Service 계층의 코드 내 Mybatis를 비동기로 실햏하는 방법과 Spring MVC 프로젝트와의 차이점에 대해 설명합니다.
2. 핵심 차이점
| 구분 | Spring MVC | Spring WebFlux |
|---|
| 프로그래밍 모델 | Blocking (동기) | Non-blocking (비동기) |
| 반환 타입 | User, List<User> | Mono<User>, Flux<User> |
| DB 접근 | JDBC (Blocking) | R2DBC (Non-blocking) 또는 JDBC + CompletableFuture |
| HTTP 클라이언트 | RestTemplate (Blocking) | WebClient (Non-blocking) |
| 스레드 모델 | Thread per request | Event Loop (적은 스레드로 많은 요청 처리) |
3. DAO 계층 변경
기존 MyBatis나 JDBC는 블로킹 방식이므로, CompletableFuture로 감싸서 비동기로 실행합니다.
3.1 Spring MVC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @Repository
@RequiredArgsConstructor
public class MemberDAO {
private final SqlSessionTemplate sqlSessionTemplate;
// 동기 방식 - 메서드가 완료될 때까지 스레드 블로킹
public MemberVM getMemberInfo(SigninReq param) {
return sqlSessionTemplate.selectOne("memberSQL.getMemberInfo", param);
}
public boolean insertMember(MemberVM param) {
return sqlSessionTemplate.insert("memberSQL.insertMember", param) > 0;
}
public int existsByAddress(String address) {
return sqlSessionTemplate.selectOne("memberSQL.existsByAddress", address);
}
public List<MemberVM> getAllMembers() {
return sqlSessionTemplate.selectList("memberSQL.getAllMembers");
}
}
|
3.2 Spring WebFlux
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| @Repository
@RequiredArgsConstructor
public class MemberDAO {
// 실제 테스트한 프로젝트의 경우, 2개의 JDBC 연결 테스트도 진행하여 "@Qualifier" 사용
@Qualifier("sqlSessionTemplate")
private final SqlSessionTemplate sqlSessionTemplate;
/**
* CompletableFuture로 감싸서 비동기 실행
* Service 계층에서 Mono.fromFuture()로 변환하여 사용
*/
public CompletableFuture<MemberVM> getMemberInfo(SigninReq param) {
return CompletableFuture.supplyAsync(() -> {
return sqlSessionTemplate.selectOne("memberSQL.getMemberInfo", param);
});
}
public CompletableFuture<Boolean> insertMember(MemberVM param) {
return CompletableFuture.supplyAsync(() -> {
return sqlSessionTemplate.insert("memberSQL.insertMember", param) > 0;
});
}
/**
* 간단한 조회는 동기로 유지 (선택적)
* - 인덱스 기반 단일 행 조회
* - 빠른 응답이 보장되는 경우
*/
public int existsByAddress(String address) {
return sqlSessionTemplate.selectOne("memberSQL.existsByAddress", address);
}
/**
* 리스트 조회는 CompletableFuture<List<T>> 반환
* Service에서 Flux로 변환 가능
*/
public CompletableFuture<List<MemberVM>> getAllMembers() {
return CompletableFuture.supplyAsync(() -> {
return sqlSessionTemplate.selectList("memberSQL.getAllMembers");
});
}
}
|
주요 변경 사항:
- 반환 타입:
T → CompletableFuture<T> CompletableFuture.supplyAsync(() -> { ... }) 로 블로킹 코드 감싸기- 간단한 조회는 동기로 유지 가능 (선택적)
4. Service 계층 변경
4.1 Spring MVC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| @Service
@RequiredArgsConstructor
public class MemberService {
private final MemberDAO memberDAO;
private final TokenProvider tokenProvider;
// 동기 방식 - 메서드가 완료될 때까지 블로킹
public SigninRes login(SigninReq reqDto) {
// 유저 정보 암호화
String password = SecurityUtil.encryptHash(reqDto.getPassword());
reqDto.setPassword(password);
// DB 조회 (블로킹)
MemberVM member = memberDAO.getMemberInfo(reqDto);
if (member == null) {
throw new GatewayException(ErrorCode.USER_NOT_FOUND);
}
// JWT 토큰 생성
String token = tokenProvider.createToken(member);
SigninRes res = new SigninRes();
res.setName(member.getName());
res.setRole(member.getRole());
res.setToken(token);
return res;
}
}
|
4.2 Spring WebFlux
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| @Service
@RequiredArgsConstructor
public class MemberService {
private final MemberDAO memberDAO;
private final TokenProvider tokenProvider;
/**
* 반환 타입: SigninRes → Mono<SigninRes>
* 비동기 체이닝으로 non-blocking 처리
*/
public Mono<SigninRes> login(SigninReq reqDto) {
// 1. 비밀번호 암호화 (동기 작업 - 빠름)
String password = SecurityUtil.encryptHash(reqDto.getPassword());
reqDto.setPassword(password);
// 2. CompletableFuture를 Mono로 변환
Mono<MemberVM> queryResult = Mono.fromFuture(memberDAO.getMemberInfo(reqDto));
// 3. Reactive 체이닝
return queryResult.flatMap(member -> {
// JWT 토큰 생성 (동기 작업)
String token = tokenProvider.createToken(member);
// 응답 생성
SigninRes res = new SigninRes();
res.setName(member.getName());
res.setRole(member.getRole());
res.setToken(token);
return Mono.just(res);
})
// member가 null이면 에러
.switchIfEmpty(Mono.error(new GatewayException(ErrorCode.USER_NOT_FOUND)));
}
}
|
주요 변경 사항:
- 반환 타입:
SigninRes → Mono<SigninRes> - DAO 호출 결과를
Mono.fromFuture()로 변환 flatMap()으로 비동기 변환 처리- 예외는
throw 대신 Mono.error() 사용 - null 체크는
switchIfEmpty() 사용
5. 여러 쿼리 동시 실행하기
5.1 Spring MVC
1
2
3
4
5
6
7
8
9
10
11
12
13
| public DashboardVM getDashboard(Long userId) {
// 순차적으로 실행 (각각 블로킹)
MemberVM member = memberDAO.getMemberInfo(userId);
List<Order> orders = orderDAO.getOrdersByUserId(userId);
Stats stats = statsDAO.getStats(userId);
DashboardVM dashboard = new DashboardVM();
dashboard.setMember(member);
dashboard.setOrders(orders);
dashboard.setStats(stats);
return dashboard;
}
|
5.2 Spring WebFlux
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public Mono<DashboardVM> getDashboard(Long userId) {
// 3개 쿼리를 병렬로 실행
Mono<MemberVM> memberMono = Mono.fromFuture(memberDAO.getMemberInfo(userId));
Mono<List<Order>> ordersMono = Mono.fromFuture(orderDAO.getOrdersByUserId(userId));
Mono<Stats> statsMono = Mono.fromFuture(statsDAO.getStats(userId));
// zip()으로 3개 결과를 결합
return Mono.zip(memberMono, ordersMono, statsMono)
.map(tuple -> {
DashboardVM dashboard = new DashboardVM();
dashboard.setMember(tuple.getT1());
dashboard.setOrders(tuple.getT2());
dashboard.setStats(tuple.getT3());
return dashboard;
});
}
|
성능 개선:
- MVC: 3개 쿼리가 순차 실행 (총 300ms = 100ms × 3)
- WebFlux: 3개 쿼리가 병렬 실행 (총 100ms = max(100ms, 100ms, 100ms))
6. Mono / Flux 처리 시 알아두면 좋은 것들
6.1 flatMap() - 비동기 변환
1
2
3
4
5
6
7
8
9
10
11
| // Mono<User>를 Mono<UserDTO>로 변환 (비동기 작업 포함)
public Mono<UserDTO> getUserDTO(Long userId) {
return Mono.fromFuture(userDAO.getUser(userId))
.flatMap(user -> {
// 사용자의 권한 정보를 비동기로 조회
return Mono.fromFuture(roleDAO.getRoleByUserId(user.getId()))
.map(role -> {
return new UserDTO(user, role);
});
});
}
|
6.2 switchIfEmpty() - 빈 결과 처리
1
2
3
4
| public Mono<User> getUser(Long userId) {
return Mono.fromFuture(userDAO.getUser(userId))
.switchIfEmpty(Mono.error(new NotFoundException("User not found")));
}
|
6.3 zip() - 여러 Mono 병렬 실행
1
2
3
4
5
6
7
| public Mono<Dashboard> getDashboard(Long userId) {
Mono<User> userMono = Mono.fromFuture(userDAO.getUser(userId));
Mono<List<Order>> ordersMono = Mono.fromFuture(orderDAO.getOrders(userId));
return Mono.zip(userMono, ordersMono)
.map(tuple -> new Dashboard(tuple.getT1(), tuple.getT2()));
}
|
7. 핵심 패턴 요약
| 패턴 | 사용 시점 | 예제 |
|---|
Mono.fromFuture() | CompletableFuture → Mono 변환 | Mono.fromFuture(dao.getUser()) |
Mono.fromCallable() | 블로킹 코드 래핑 | JDBC, 파일 I/O |
flatMap() | 비동기 변환 (중첩 Mono) | 쿼리 결과로 다른 쿼리 실행 |
map() | 동기 변환 | DTO 변환, 계산 |
zip() | 병렬 실행 | 여러 쿼리 동시 실행 |
switchIfEmpty() | 빈 결과 대체 | null 체크, 기본값 |
onErrorResume() | 에러 복구 | Fallback 로직 |
Mono.just() | 동기 값을 Mono로 | 성공 결과 반환 |
Mono.error() | 에러 전파 | 예외 처리 |
8. 참고 자료