R2DBC의 한계와 그 사용법
Webflux 기반으로 React 한 코드 — 비동기적, 넌 블럭킹-를 애플리케이션 전체에 적용하기 위해서 DB를 다루는 영역 또한 React 하게 처리되어야 한다.
하지만 DB영역에서 논 블럭킹 방식을 지원하는 유일한 라이브러리인 R2DBC는 현재까지는 우리에게 익숙한 JPA에 비해서 그 제약사항과 한계점을 많이 가지고 있고, 아직까지는 불완전한 상태의 라이브러리라 제공 가능한 스펙을 정확히 파악해서 제대로 사용해야 할 필요가 있다.
이 글에서 R2DBC의 제약사항에 대해서 완전하지는 않지만, 그 제약사항을 피해서 적용할 수 있는 다른 방법 몇 가지를 정리할 목적을 작성하였다.
R2DBC의 공식 사이트인데, 여기서 현재 지원하는 드라이버를 확인할 수 있다.
mysql 드라이버는 jasync-sql, r2dbc-mysql 이 두 가지 드라이버가 많이 사용된다. 여기서는 jasync 라이브러리를 사용했다.
- GitHub — jasync-sql/jasync-sql: Java & Kotlin Async DataBase Driver for MySQL and PostgreSQL written in Kotlin
- GitHub — asyncer-io/r2dbc-mysql: Reactive Relational Database Connectivity for MySQL. The official successor to mirromutth/r2dbc
- 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.
it does NOT offer caching, lazy loading, write behind or many other features of ORM frameworks.
Spring에서는 R2DBC의 성격과 목적에 대해 공식페이지에 상기와 같이 정의하고 있다.
요약하면, 캐싱, 지연로딩, 쓰기 지연등 ORM의 주요 기능을 제공하지 않는다는 의미이다.
즉, 우리가 JPA를 사용하면서 당연하게 사용했던 영속성 컨텍스트 기반의 유용한 기능들을 사용할 수 없다는 의미이다.
그럼 우리는 R2DBC를 어떤 기준으로 써야 하는가?
분명한 건 기존의 JPA에서 당연하게 지원했던 영속성 컨텍스트 — persistent Context — 에서의 주요 기능들은 지원하지 않는다는 전제로
사용해야 되는 건 확실하다. 여기서 JPA의 영속성 컨텍스트의 주요 기능에 대해서 잠깐 알아보자.
JPA 영속성 컨텍스트의 주요기능
1차 캐시
영속성 데이터가 보관되는 캐시영역, 당연히 조회가 가능하며 1차 캐시에 없으면 DB에서 조회하여 1차 캐시에 저장하게 된다.
영속성 컨텍스트는 기본적으로 내부에 Map 형태로 된 1차 캐시를 가지고 있고 아래의 구조로 영속성 데이터를 보관하게 된다.
- key: @Id로 선언한 필드, 데이터베이스의 기본키와 매핑됨
- value: 엔티티 인스턴스
lazy loading (지연로딩)
JPA의 데이타 패치전략 중 하나로 엔티티가 실제 사용(참조)될 때까지 데이터베이스 조회를 지연(Lazy loading)시키는 방법이다.
주제와는 상관없지만 JPA 지연로딩에 관련된 이슈에 대한 글을 참고하면 이해하는데 좀 더 도움을 될 것 같다.
https://yonguri.tistory.com/73
write behind (쓰기 지연)
- 쓰기 지연은 영속성 컨텍스트에 변경이 발생했을 때, 바로 데이터베이스로 쿼리를 보내지 않고 SQL 쿼리를 버퍼에 모아놨다가 영속성 컨텍스트가 flush 하는 시점에 모아둔 SQL 쿼리를 데이터베이스로 보내는 기능이다.
- 이 기능은 DB와의 불필요한 커넥션을 최소화하기 위한 목적으로 flush 되는 시점은 해당 트랜잭션이 commit시점으로 보면 된다. 즉 쓰기지연은 정확히 transcational write behind이다.
변경 감지(Dirty Checking)
- 변경감지기능은 영속성 컨텍스트상의 Entity에 대한 변경 사항을 추적하고 필요한 경우에만 해당 데이터베이스 레코드를 업데이트합니다. 이 기능 또한, 불필요한 데이터베이스 업데이트를 방지하고 수정된 필드만 업데이트하여 성능을 최적화하는 것이 주 목적이다.
- 변경감지 메커니즘의 작동방식은 아래와 같다.
- 엔터티 검색: JPA를 사용하여 데이터베이스에서 엔터티를 검색하면 해당 엔터티의 상태가 지속성 컨텍스트에 로드된다.
- 변경 감지: 엔터티의 속성을 변경하면 JPA는 지속성 컨텍스트 내에서 이러한 변경 사항을 추적한다.
- 트랜잭션 커밋: 트랜잭션을 커밋할 때(또는 지속성 컨텍스트를 명시적으로 플러시할 때) JPA는 지속성 컨텍스트 내 엔터티의 변경 사항을 확인한다.
- 데이터베이스 업데이트: 변경 사항이 감지되면(즉, 엔터티의 일부 속성이 수정됨) JPA는 필요한 SQL 문을 생성하고 실행하여 해당 데이터베이스 레코드에서 수정된 필드만 업데이트한다.
이 외에 JPA의 다른 특징들도 많지만 ORM Framework에서 얘기하는 중요한 기능들은 위의 4가지 기능이 대표적인 기능이라 할 수 있다.
문제는 지금의 R2DBC 드라이버는 상기의 4가지 기능 대부분(?)을 지원하지 않는다는 것이다. 대부분이라고 한 이유는 ‘변경 감지’는 단일키(pk)로 구성된 Entity에서는 지원하기 때문이다.
그 외의 언급한 나머지 유용한 JPA관련 기능은 사용할 수 없다고 보면 된다.
R2DBC 제약사항
- JPA 사용불가
- 복합키 미지원 — @EmbeddedId, @IdClass
- 연관관계 표현 미지원 (Join 표현 불가, 1:N, N:1, 1:1)
- JSON 컬럼 지원 불가 (현재는 PostgreSQL 만 지원)
이런 제약사항이 있음에도 불구하고, WebFlux기반의 Database 연결을 Non-blocking으로 사용하려면 아직까지는 R2DBC 드라이버를 사용하는 방법밖에는 없다.
분명 한계가 명확하고 아직 많이 부족한 R2DBC이지만 다른 대안이 없는 상황에서 자신의 애플리케이션 환경(Webflux 기반)에 맞게 최대한 활용가능한 방법들을 정리해 보았다.
Best Practice가 아닐 수 있겠지만 R2DBC 활용에 약간의 가이드 역할을 할 수 있을 거라 생각한다.
⠀
복합키 처리
R2DBC의 복합키 지원은 기대하지 않는 게 좋을 것 같다. 깃헙이슈로 등록된 지 거의 5년이 지났는데도 아직 이슈는 Open상태이다.(2019년 등록)
https://github.com/spring-projects/spring-data-relational/issues/574
R2DBC가 복합키를 지원하지 않는다고 반드시 복합키로 설계해야 할 테이블을 변경할 수는 없다. 물론 단일키로 설계가 가능하면 좋겠지만 그렇지 않는 경우, 해당 테이블은 복합키로 설계할 수밖에 없고 그렇게 해야 한다.
따라서 복합키로 생성된 테이블의 Entity는 키 정의를 할 수 없다. 즉 @Id 어노테이션을 사용할 수 없다는 의미이다.
당연히 @EmbeddedId , @IdClass 와 같은 복합키에 사용하는 어노테이션도 사용불가하다.
결국 Spring Data에서 제공하는 기본 Repository API(ex. findById등)는 사용하지 못하고, Custom Query Method나 Native Query로 처리해야 한다.
@Table(value = "DEVICE_MASTER")
public class DeviceMaster implements Persistable<Tuple2<String, String>> {
@Column("user_id")
private String userId;
@Column("device_id")
private String deviceId;
@Column("parent_device_id")
private String parentDeviceId;
@Column("device_type")
private DeviceType deviceType;
@Column("device_nickname")
private String deviceNickname;
...
@Transient
@Builder.Default
private boolean newProduct = false;
@Override
public Tuple2<String, String> getId() {
return Tuples.of(this.userId, this.deviceId);
}
@Override
public boolean isNew() {
return this.newProduct;
}
}
@Repository
public interface DeviceMasterRepository extends R2dbcRepository<DeviceMaster, Tuple2<String, String>> {
Mono<DeviceMaster> findByUserIdAndDeviceId(String userId, String deviceId);
Flux<DeviceMaster> findByUserId(String userId);
Flux<DeviceMasterDto> findByOwnerId(String ownerId);
<T> Flux<T> findByUserId(String userId, Class<T> type);
...
}
Entity의 유일성을 보장할 Key를 정의할 수 없기 때문에 Spring Data에서 제공하는 Pesistable 인터페이스를 구현하도록 해서 새로운 Entity여부를 코드레벨에서 판단할 수 있도록 처리해야 한다.
이 말은 위에서도 얘기했다시피 JPA의 특징인 Dirty Checking을 활용한 업데이트용으로의 sava API는 사용은 불가하다는 의미이다.
Persistable 인터페이스의 isNew()를 통해 코드에서 새로운 Entity임을 판단하고, insert의 목적으로만 save API를 사용할 수 있다.
참고로 의미는 없지만 형식적으로나마 Tuple2 (Reactor 라이브러리)로 해당 Entity가 복합키임을 나타낼 수는 있다.⠀
...
return deviceMasterRepository.findByUserIdAndDeviceId(reqDeviceMetaDto.getUserId(), reqDeviceMetaDto.getDeviceId())
.defaultIfEmpty(newDeviceMaster)
.flatMap(deviceMaster -> {
if (!deviceMaster.isNew()) {
...
}
...
}).then(deviceAttributesRepository.save(newDeviceAttr))
...
update의 경우는 무조건 직접 쿼리를 실행하는 방법( @Query 또는 DatabaseClient 활용)으로만 가능하다.
JSON 컬럼 처리
MySQL은 5.7.8 버전부터 JSON 컬럼유형을 지원하기 시작했다.
하지만 현재 R2DBC Driver는 Postgre SQL에서만 JSON컬럼 타입을 지원한다.
MySQL에서 JSON컬럼을 사용해야 하는 경우, 별도의 Custom Converter를 활용하여 Json컬럼 각각을 개별적으로 변환처리하도록 해야 한다.
커스텀 컨버터는 R2dbcCustomConversions 빈을 등록할 때 지정해 주면 된다.
@Configuration
@EnableR2dbcAuditing
@RequiredArgsConstructor
public class R2dbcConfig extends AbstractR2dbcConfiguration {
private final ObjectMapper objectMapper;
...
@Override
public R2dbcCustomConversions r2dbcCustomConversions() {
List<Converter<?, ?>> converters = new ArrayList<>();
converters.add(new MapToJsonConverter(objectMapper));
converters.add(new JsonToMapConverter(objectMapper));
...
return new R2dbcCustomConversions(getStoreConversions(), converters);
}
}
일반적으로 JSON 형태의 속성은 별도의 DTO(PoJo)로 정의해서 사용하나 그럴 경우 모든 DTO에 대해서 Converter를 만들어야 하는 문제가 있다. 좋은 방법은 아니나 JSON 타입은 기본적으로 Map으로 받을 수 있기 때문에 등록 또는 수정 시에는 MapToJsonConverter 하나로 모든 JSON칼럼에 대해서 처리하도록 하는 방법으로 사용할 수 있다.
@Slf4j
@WritingConverter
@AllArgsConstructor
public class MapToJsonConverter implements Converter<Map<String, Object>, String> {
private final ObjectMapper objectMapper;
@Override
public String convert(@NotNull Map<String, Object> source) {
try {
return objectMapper.writeValueAsString(source);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
@Slf4j
@ReadingConverter
@AllArgsConstructor
public class JsonToMapConverter implements Converter<String, Map<String, ?>> {
private final ObjectMapper objectMapper;
@Override
public Map<String, ?> convert(@NotNull String jsonStr) {
try {
return objectMapper.readValue(jsonStr, new TypeReference<>() {});
} catch (IOException e) {
log.error("Problem while parsing JSON: {}", jsonStr, e);
}
return new HashMap<>();
}
}
컨버터에 특별할 로직은 없다. ObjectMapper를 사용하여 Map to String(JSON), String(JSON) to Map으로 변환하는 로직이 전부이다. Spring Data 라이브러리에서 제공하는 @ReadingConverter 어노테이션을 붙이면 데이터베이스로부터 데이터를 읽어올 때만 컨버터가 적용되고, @WritingConverter 를 붙이면 데이터베이스에 데이터를 입력할 때 컨버터가 적용된다.
즉, MapToJsonConverter는 등록 또는 수정 시, JsonToConverter는 조회 시에 적용되게 된다. 물론 converter는 필요에 따라 더 추가할 수 있다.
Query 수행방안
단순한 조회 및 삭제등의 처리는 기존과 동일하게 Spring Data기반의 Query Method (R2dbcRepository 인테이스 기반)를 사용하면 된다. 하지만 단순한 조회라는 건, 조인이 일어나지 않는 단일 테이블을 참조하는 경우를 의미하는데, 일반적인 서비스에서는 이렇게 단순한 조회업무만 존재하지 않는다. 결국 Query자체가 코드에 포함되어야 하는 경우는 필수적으로 따라올 수밖에 없다.
QueryDSL과 같은 라이브러리를 같이 쓸 수 있다고는 하지만, 실제로 사용하기에는 불안한 문제들이 있다.
즉, ORM이 가지는 장점 중에 Query에 대한 Code Safe를 보장할 수 없는 코드가 생산될 수밖에 없다는 의미이다.
Query를 직접 사용하는 방법은 크게 두 가지이다.
- @Query 어노테이션 사용
- R2dbc 의 DatabaseClient 사용 ( JdbcTemplate과 유사)
@Query 어노테이션 사용
@Repository
public interface DeviceCustomRepository extends R2dbcRepository<DeviceMaster, Tuple2<String, String>> {
@Query(
value = "
select m.device_id as deviceId,
m.parent_device_id as parentDeviceId,
JSON_UNQUOTE(a.connection_info->'$.connectType') as connectType,
JSON_UNQUOTE(a.connection_info->'$.target') as target
from DEVICE_MASTER m INNER JOIN DEVICE_ATTRIBUTES a
on m.parent_device_id = a.device_id
where m.user_id = :userId
and m.device_id = :deviceId
", nativeQuery = true)
Mono<DeviceConnectionMetaDto> findDeviceInfoForCommand(String userId, String deviceId);
...
}
R2dbc 의 DatabaseClient 사용
@Slf4j
@Repository
@RequiredArgsConstructor
public class DeviceCustomRepositoryImpl implements DeviceCustomRepository {
private final DatabaseClient databaseClient;
@Override
public Mono<DeviceConnectionMetaDto> findDeviceInfoForCommand(String userId, String deviceId) {
String sql = """
select m.device_id as deviceId,
m.parent_device_id as parentDeviceId,
JSON_UNQUOTE(a.connection_info->'$.connectType') as connectType,
JSON_UNQUOTE(a.connection_info->'$.target') as target
from DEVICE_MASTER m INNER JOIN DEVICE_ATTRIBUTES a
on m.parent_device_id = a.device_id
where m.user_id = ?
and m.device_id = ?
""";
return databaseClient.sql(sql)
.bind(0, userId)
.bind(1, deviceId)
.map((row, rowMetadata) -> {
return DeviceConnectionMetaDto.builder()
.deviceId(row.get("deviceId", String.class))
.userId(userId)
.parentDeviceId(row.get("parentDeviceId", String.class))
.connectType(row.get("connectType", String.class))
.target(row.get("target", String.class))
.build();
}).one();
}
...
}
참고로 DatabaseClient를 사용하게 되면 위에서 설명한 컨버터 설정은 적용되지 않는다. 컨버터를 설정하려면 각각의 컬럼에 수동으로 컨버터를 적용해야 한다.
...
return databaseClient.sql(sql)
.bind(0, userId)
.bind(1, deviceId)
.map((row, rowMetadata) -> {
...
.tags(new JsonToListConverter(objectMapper).convert((Objects.requireNonNull(row.get("tags", String.class)))))
...
.properties(new JsonToDevicePropertyMapConverter(objectMapper).convert((Objects.requireNonNull(row.get("properties", String.class)))))
.additionalInfo(new JsonToMapConverter(objectMapper).convert((Objects.requireNonNull(row.get("additional_info", String.class)))))
.connectionInfo(new JsonToMapConverter(objectMapper).convert((Objects.requireNonNull(row.get("connection_info", String.class)))))
...
...
}).one();
}
...
...
복합키 관련해서 한 번 더 짚고 넘어가자면, R2DBC의 엔티티 상태에 대한 감지전략은 아래와 같다.⠀
- 엔티티의 식별자(identifier)를 @Id 어노테이션이 붙은 속성으로 판단한다.
- save API사용 시, 식별자 속성값이 null이거나 기본 유형의 경우 0이면 해당 엔티티를 새로운 엔티티로 간주하고 insert가 수행되고 그렇지 않으면 존재하는 엔티티로 간주하고 save API사용시 update가 수행된다.
⠀⠀
결론
- 단순조회 — Spring Data의 Query Method 활용
- 복잡한 조회, 조인연산 — Spring R2dbc 의 DatabaseClient 사용, @Query 어노테이션 활용
- 복합기 미지원으로 인해 save 사용 시 Persistable 인터페이스 구현, isNew활용
- Dirty Checking을 활용한 업데이트용으로의 save API사용은 불가
정리할수록 이렇게까지 R2DBC를 써야 하는 생각이 들다가도 아직까지는 Webflux를 적용하면서 Datasource영역까지 논블로킹을 적용하기 위해서 딱히 다른 대안이 없기 때문에, 제약사항을 잘 확인하고 사용할 필요가 있겠다.