티스토리 뷰
이번 글에서는 R2DBC, Spring data r2dbc를 사용해 코드를 작성해보자.
- 학습 기록용. 피드백은 언제나 환영입니다.
- 사용한 모든 예제코드는 github을 참고해주세요.
이전 글
예제 환경은 다음과 같다.
- Java: JDK 17
- Build tool: Gradle
- Database: MySQL
Dependency
사용하기 위해서는 의존성을 먼저 추가해주어야 한다. 데이터베이스에 맞는 R2DBC driver와 그 구현체를 의존성을 추가해줘야 하는데, 좀 충격적?이게도 MySQL에서 공식적으로 지원하는 R2DBC Driver 구현체는 없다 (R2DBC 드라이버 구현체 참조).
비공식적으로 제공되는게 2개(jasync-sql, r2dbc-mysql)있는데, jasync-sql
을 사용하는 것이 나아보인다. r2dbc-mysql은 관리도 되지 않고, 메인테이너도 포기한 것 같다.
그러므로 이번 글에서는 r2dbc mysql driver 구현체로 jasync-sql
을 사용하겠다.
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation "org.springframework.boot:spring-boot-starter-data-r2dbc"
implementation "com.github.jasync-sql:jasync-r2dbc-mysql:2.0.8"
Configurations
R2DBC에서 중요한 인터페이스가 2가지가 있다.
- ConnectionFactory - io.r2dbc.spi package
- 말 그래도 데이터베이스 드라이버와 Connection을 생성하는 인터페이스이다.
- 드라이버 구현체에서 이를 구현해서 사용하게 된다. Jasync-sql을 사용하면
JasyncConnectionFactory
클래스가 구현체로 사용된다.
- DatabaseClient - org.springframework.r2dbc.core package
ConnectionFactory
를 사용하는 non-blocking, reactive client이다.- 아래와 같이 사용할 수 있다.
-
DatabaseClient client = DatabaseClient.create(factory); Mono<Actor> actor = client.sql("select first_name, last_name from t_actor") .map(row -> new Actor(row.get("first_name", String.class), row.get("last_name", String.class))) .first();
이 2가지 인터페이스 등 r2dbc 사용을 위해 제공되는 인터페이스들 많다. 다행히도 Spring의 특징인 auto configuration을 통해 대부분의 구현체들을 bean으로 등록해줘서 편하게 사용할 수 있다.
- 예를 들어, R2dbcAutoConfiguration가 있다. 대부분의 R2dbc의 autoconfiguration을 맡는다.
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ConnectionFactory.class)
@ConditionalOnResource(resources = "classpath:META-INF/services/io.r2dbc.spi.ConnectionFactoryProvider")
@AutoConfigureBefore({ DataSourceAutoConfiguration.class, SqlInitializationAutoConfiguration.class })
@EnableConfigurationProperties(R2dbcProperties.class)
@Import({ ConnectionFactoryConfigurations.PoolConfiguration.class,
ConnectionFactoryConfigurations.GenericConfiguration.class, ConnectionFactoryDependentConfiguration.class })
public class R2dbcAutoConfiguration {
}
- Import를 자세히보면 중요한 클래스들은 다 bean으로 등록해준다. 예를 들어,
ConnectionFactoryDependentConfiguration
. - 부가적으로 아래의 Configuration 클래스도 참고하면 좋다.
- ConnectionFactoryDependentConfiguration
- R2dbcRepositoriesAutoConfiguration
결국, Multiple database 설정 등 같은 복잡한 구성을 하는 것이 아니라면 database 관련 property만 잘 작성해주면 알아서 다 설정해준다.
위 R2dbcAutoConfiguration
코드에서 볼 수 있듯 R2dbcProperties
를 Enable 시켜주고 있어서, 해당 Property만 잘 입력해주면 된다.
- Multiple database 같은 설정을 하다보면 Bean을 직접 추가해주는데, 이렇게 되면 Conditional annotation을 통해 auto configuration 되는 것들이 bean으로 추가되지 않을 수 있다. 그러므로 주의해서 bean 등록을 해주어야 한다.
- 관련해서 다음에 다뤄볼 예정이지만, 아래 글들을 참고하면 좋다.
나는 간단한게 docker로 mysql을 띄웠고, 이에 대한 접근 정보를 application.yaml
에 적어주었다.
spring:
r2dbc:
url: r2dbc:pool:mysql://localhost:3306/webflux # schema 이름을 webflux로 지었음
username: user
password: user
# 추가 설정 - R2DBC에서 Database로 나가는 쿼리를 보고 싶으면 아래처럼 로깅레벨을 설정해야 함
logging:
level:
org.springframework.r2dbc.core: debug
- Logging level을 설정한 것은 r2dbc에서 나가는 쿼리를 보기 위함이다. jpa 구현체인 hibernate에서 제공하는
show-sql
property 같이 제공되지 않기에 수동으로 Logging level을 설정해주었다.
앞서 말했듯 따로 Configuration class를 만들 필요는 없지만, R2dbc Audit 기능을 사용하고 싶다면 Enable 시켜야 한다. 이를 위한 Configuration class를 생성하자.
@Configuration
@EnableR2dbcAuditing
public class R2dbcConfig {
}
- R2dbc audit은 JPA audit과 유사하다.
@CreatedBy
,@CreatedDate
,@LastModifiedBy
,@LastModifiedDate
annotation을 활성화시켜준다. - https://docs.spring.io/spring-data/r2dbc/docs/current/reference/html/#auditing.annotations
Example database
예제로 진행할 데이터베이스는 다음과 같다.
Entity class
Post 테이블에 대한 Entity class를 생성해 매핑해보자.
@Table("post")
public class Post {
@Id
private Long id;
private String title;
private String content;
private Long authorId;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
@Table
annotation을 통해 매핑할 데이터베이스 table을 설정한다.@Id
annotation을 통해 Primary key를 설정한다.@Column
annotation을 통해 Table column을 설정한다. 위 예제에서 사용되지 않았듯, 테이블 필드 이름과 동일하다면 사용하지 않아도 된다.@CreatedDate
,@LastModifiedDate
는 위에서 설명했듯 Audit annotation으로 R2dbc에서 audit을 활성화하면 자동으로 값을 넣어준다.- annotation에 대한 더 자세한 내용은 여기, 지원하는 default field type에 대한 내용은 여기를 참조하자.
여기서 좀 주의 깊게 봐야할 점이 2가지가 있다.
@Id
annotation 설정 필드는 값을 할당하느냐 안하느냐로 SQL query가 달라진다.
@Id
필드가 null인 상태로 save 메서드를 실행하면,Insert
statement가 실행된다.@Id
필드가 값이 설정된 상태로 save 메서드를 실행하면,Update
statement가 실행된다.
- Spring data r2dbc는 Relational Mapping을 지원하지 않는다.
- JPA에 익숙한 개발자라면
Post
entity class에서authorId
는 외래키(Foreign key)이므로@OneToOne
,@OneToMany
@ManyToOne
같은 annotation을 설정해주어야 하지 않나 하는 생각이 들 수 있다. 하지만, R2dbc에서는 Annotation을 통한(JPA style의) Relational mapping을 지원하지 않는다(관련 Issue). - 그러므로 Lazy loading, Method name을 통한 Join 등이 불가능하다. 아래는 Spring data r2dbc의 공식 페이지의 내용이다.
Spring Data R2DBC aims at being conceptually easy. In order to achieve this, it does NOT offer caching, lazy loading, write-behind, or many other features of ORM frameworks. This makes Spring Data R2DBC a simple, limited, opinionated object mapper.
- 그러므로 Join이 필요한 상황이라면 직접 Query를 작성해야만 한다.
Repository
spring data r2dbc도 spring data jpa와 마찬가지로 쉽게 CRUD를 사용할 수 있도록 Repository interface를 제공하고 있다. R2dbcRepository
interface를 상속하면 되고, 사용법은 spring data jpa에서 제공하는 것과 거의 유사하다.
public interface PostRepository extends ReactiveCrudRepository<Post, Long> {
}
Service
이를 이용해 Service logic을 구현하면 다음과 같다. 코드 상으로만 본다면 JPA를 사용할 때와 거의 유사한 흐름대로 작성되며, Input/Ouput만 Reactive type으로 이루어진다 (당연히 내부에서는 Reactive 하게 동작하고 JPA와 많이 다르다).
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public Flux<PostResponse> getAll() {
return postRepository.findAll()
.map(PostResponse::from);
}
public Mono<PostResponse> getOne(Long postId) {
return postRepository.findById(postId)
.map(PostResponse::from);
}
public Mono<Void> save(SavePostRequest request) {
return postRepository.save(request.toEntity())
.then();
}
}
Controller
Controller에서는 해당 Service를 사용하여 알맞은 DTO 형태로 반환해주면 된다.
- WebFlux를 사용하기에 Functional endpoint 형태로도 사용할 수 있지만, MVC일 때와 유사하게 로직을 살펴볼 수 있도록 Annotated Controller 모델로 작성했다. Webflux에서는 RequestBody로 Reactive type을 받을 수 있다.
- Webflux programming 모델에 대한 더 자세한 내용은 공식문서를 참조하자.
@RestController
@RequestMapping("/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@GetMapping
public Mono<ResponseEntity<List<PostResponse>>> getAllPosts() {
return postService.getAll().collectList()
.map(ResponseEntity::ok);
}
@GetMapping("/{postId}")
public Mono<ResponseEntity<PostResponse>> getAllPosts(@PathVariable Long postId) {
return postService.getOne(postId)
.map(ResponseEntity::ok);
}
@PostMapping
public Mono<ResponseEntity<Void>> savePost(@RequestBody Mono<SavePostRequest> request) {
return request.flatMap(postService::save)
.thenReturn(ResponseEntity.created(URI.create("/")).build());
}
}
직접 Query를 사용하고 싶은 경우
단순한 CRUD를 제외하고, 직접 Query를 사용하는 방법은 2가지가 있다.
Query
annotation을 사용하는 방법
spring data jpa에서 제공되는 것과 유사하게 spring data r2dbc에서도 Query annotation을 제공한다.
public interface PostRepository extends R2dbcRepository<Post, Long> {
@Query("""
SELECT *
FROM post p
WHERE p.title LIKE CONCAT('%', :keyword, '%') OR
p.content LIKE CONCAT('%', :keyword, '%')
""")
Flux<Post> searchByKeyword(String keyword);
}
DatabaseClient
를 이용하는 방법
위에서 이야기했듯 DatabaseClient
는 R2dbc에서 사용하는 non-blocking, reactive client이다. 이를 직접 이용하여 Query를 전송할 수 있다. Query의 결과를 Map<String, Object>
으로 결과를 가져와 원하는 클래스로 매핑할 수 있다.
@Repository
@RequiredArgsConstructor
public class PostCustomRepository {
private final DatabaseClient databaseClient;
public Flux<Post> searchByKeyword(String keyword) {
var containKeywordSQL = """
SELECT *
FROM post p
WHERE p.title LIKE CONCAT('%', :keyword, '%') OR
p.content LIKE CONCAT('%', :keyword, '%')
""";
return databaseClient.sql(containKeywordSQL)
.bind("keyword", keyword)
.fetch().all()
.map(row -> Post.builder()
.id((Long) row.get("id"))
.title((String) row.get("title"))
.content((String) row.get("content"))
.createdAt((LocalDateTime) row.get("created_at"))
.updatedAt((LocalDateTime) row.get("updated_at"))
.build());
}
}
- 여기서는 직접 CustomRepository를 class로 구현하였고, 위 메서드를 사용하고 싶은 Controller 내지 Service에서 bean으로 주입받아 사용하면 된다.
- 이렇게 되면 총 2개의 Repository(일반적인 CRUD를 위한 R2dbcRepository와 Custom query를 위한 PostCustomRepository)를 주입받아야만 하는데, 이것이 조금 불편하게 느껴진다면 Custom repository를 위한 interface를 두고, 이를 R2dbcRepository를 상속받은 interface에서 Custom repository 또한 상속받고, 그에 대한 구현체만 만들어두면 된다.
- 말이 조금 어렵게 설명되는데 QueryDSL repository를 만들듯이 사용하면 하나의 Repository로도 구현이 가능하다. (그림 참조)
앞서 이야기했듯 Spring data r2dbc에서는 Relational Mapping을 지원하지 않는다. 그럼 어떻게 Join을 처리할까?
다음 글에서는 Join을 어떻게 처리할지에 대해 이야기해보고자 한다.
References
'Development > Java, Kotlin, Frameworks' 카테고리의 다른 글
DAO, DTO, PO, SO, BO, VO (2) | 2022.11.11 |
---|---|
R2DBC를 사용해보자 (3) - Join (Many-To-One, One-To-One, One-To-Many) (0) | 2022.08.15 |
R2DBC를 사용해보자 (1) - 왜 사용할까? (0) | 2022.08.15 |
ThreadLocal 사용 시, remove를 잊지 맙시다 (0) | 2022.07.06 |
Stream.toList로 Stream.collect(toList())를 대체해도 되는 걸까? (1) | 2022.05.07 |
- Total
- Today
- Yesterday
- 하루
- java
- jasync
- c++
- Spring boot
- Istio
- hexagonal architecture
- 로그
- boj
- docker
- python
- container
- 일상
- Log
- Spring
- 알고리즘
- 쿠버네티스
- WebFlux
- k8s
- 비동기
- gradle
- Algorithm
- MySQL
- HTTP
- Kubernetes
- Intellij
- 클린 아키텍처
- tag
- 백준
- Clean Architecture
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |