계기: Spring Tips: Reactive Summit Keynote: Here and There → Spring R2DBC + MySQL
회사에서 WebFlux 를 사용하여 프로젝트가 되어 있었다. Redis는 ReactiveRedisTemplate 을 사용하여 작성이 되어 있었으나, RDBMS는 jooq 를 이용하고 있었는데 ExecutorService 를 이용해서 CompletionStage 로 바꾸고 Mono.fromCompletionStage 로 래핑을 사용하는 형태로 구성이 되어 있었다.
R2DBC를 이용하면 ReactiveRedisTemplate 를 사용하는 것처럼 Mono 나 Flux로 바로 받을 수 있으니 좀 더 편하게 사용할 수 있을까 생각했는데 Spring Data R2DBC 드라이버에 아래와 같이 목록이 있음을 알게 되었다.
회사에서는 Amazon의 Aurora DB를 사용하는데 드라이버는 대부분 MariaDB Driver를 사용하고 있었다.
의존성을 추가한 다음 build.gradle 을 보니 아래와 같았다.
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
runtimeOnly 'org.mariadb:r2dbc-mariadb'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
의존성에는 spring-r2dbc-5.3.12.jar 및 spring-data-r2dbc-1.3.6.jar
그리고 mariadb-java-client-2.7.4.jar 와 r2dbc-mariadb-1.0.2.jar 가 딸려왔다.
간단히 조회 및 저장 및 삭제, 업데이트 하는 것을 만들어보았다.
공통
조회는 크게 어렵지 않았다. DB는 로컬에 설치되어 있는 MySQL을 사용했고 테이블은 아래와 같이 ID와 NAME이 있는 간단한 형태였다.\
심지어 ID는 자동 증가열도 아니였다. 나중에 저장할 때 문제가 되는데 저장 설명시에 다시 이야기하겠다.
프로젝트 파일은 간단하게 하기 위해 달랑 3개의 java 파일로만 구성하였다.
Member - 도메인의 엔티티와 JPA에서 엔티티가 다르긴 하지만 그냥 같이 사용했다. 모델간 변환을 줄이려는 목적이 있었기 때문이다. 보면 JPA 처럼 @Entity 라는 것이 없는데 JPA랑은 조금 다르다. @Entity 에 테이블 이름을 매핑할 수 있지만 여기서는 @Table 이라는 스프링의 annotation을 사용했다. 마찬가지로 @Id 나 @Column 등도 스프링의 것이다.
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Getter
@Setter
@Table("MEMBER")
public class Member {
@Id
@Column("ID")
private Long id;
@Column("NAME")
private String name;
}
MemberRepository - 리포지토리는 R2dbcRepository 인터페이스를 상속받았다. 이 인터페이스를 상속받으면 구현체로 SimpleR2dbcRepository 가 사용된다.
코드는 딱히 특별할 것은 없다. Spring JPA를 해보았다면 크게 새로운 것이 없을 것이다.
import org.springframework.data.r2dbc.repository.R2dbcRepository;
public interface MemberRepository extends R2dbcRepository<Member, Long> {
}
MemberEndpoint - 죄회, 저장, 삭제, 업데이트에 대한 operation 은 모두 여기에 구현된다. Service layer를 둘 수도 있지만 단순하게 하기 위해 MemberRepository 를 바로 사용한다.
@RestController
public class MemberEndpoint {
private final MemberRepository memberRepository;
public MemberEndpoint(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
조회
조회는 @GetMapping 으로 구현했다. 특별한 것은 없고 id를 입력받아 Mono<Member> 를 반환하게 했다.
@RestController
public class MemberEndpoint {
// ..
@GetMapping("/member/{id}")
public Mono<Member> findMember(@PathVariable long id) {
return memberRepository.findById(id);
}
curl 로 테스트 해보면 이미 저장된 id 3의 값이 돌아옴을 알 수 있다.
curl -X 'GET' \
'http://localhost:8080/member/3' \
-H 'accept: */*'
저장
위에 언급했지만 저장하면서 테이블의 구조를 바꾸었어야 했다.
처음에는 아래와 같이 구현을 했었다.
@RestController
public class MemberEndpoint {
// ..
@PostMapping("/member")
public Mono<Member> createMember(@RequestBody Member member) {
return Mono.just(member)
.flatMap(memberRepository::save);
}
그런데 문제는 SimpleR2dbcRepository 가 저장을 수행하는 방식 때문에 발생했다.
package org.springframework.data.r2dbc.repository.support;
@Transactional(readOnly = true)
public class SimpleR2dbcRepository<T, ID> implements R2dbcRepository<T, ID> {
private final RelationalEntityInformation<T, ID> entity;
private final R2dbcEntityOperations entityOperations;
@Override
@Transactional
public <S extends T> Mono<S> save(S objectToSave) {
if (this.entity.isNew(objectToSave)) {
return this.entityOperations.insert(objectToSave);
}
return this.entityOperations.update(objectToSave);
}
위의 코드를 보면 알 수 있듯이 save 가 수행되면 새로운 객체인지 확인을 해서 새로운 객체이면 insert를 수행하고 아니면 update를 수행함을 알 수 있다. RelationalEntityInformation 인터페이스의 isNew를 판단하는 로직은 아래처럼 PersistentEntity 에게 위임한다.
package org.springframework.data.repository.core.support;
public class PersistentEntityInformation<T, ID> implements EntityInformation<T, ID> {
private final PersistentEntity<T, ? extends PersistentProperty<?>> persistentEntity;
@Override
public boolean isNew(T entity) {
return persistentEntity.isNew(entity);
}
따라가면 isNew 를 판단하는 로직은 PersistentEntityIsNewStrategy에 있었다.
package org.springframework.data.mapping.model;
class PersistentEntityIsNewStrategy implements IsNewStrategy {
private final Function<Object, Object> valueLookup;
@Override
public boolean isNew(Object entity) {
Object value = valueLookup.apply(entity);
if (value == null) {
return true;
}
if (valueType != null && !valueType.isPrimitive()) {
return false;
}
if (value instanceof Number) {
return ((Number) value).longValue() == 0;
}
throw new IllegalArgumentException(
String.format("Could not determine whether %s is new! Unsupported identifier or version property!", entity));
}
}
Object value 가 되는 것은 valueLookup에 따라 정해지는데 로직은 PersistentEntityIsNewStrategy 생성자에 있다.
class PersistentEntityIsNewStrategy implements IsNewStrategy {
private PersistentEntityIsNewStrategy(PersistentEntity<?, ?> entity, boolean idOnly) {
this.valueLookup = entity.hasVersionProperty() && !idOnly //
? source -> entity.getPropertyAccessor(source).getProperty(entity.getRequiredVersionProperty())
: source -> entity.getIdentifierAccessor(source).getIdentifier();
버전 및 ID 프로퍼티에 따라 결정이 되는데 나의 경우는 @Id 만 사용했기에 결국 Id 컬럼에 따라서 결정이 되는 것이다.
저장시에 ID를 받도록 되어 있는데 id값이 0이 아니면 update하는 경우로 빠지는데 문제는 데이터가 존재하지 않으니 에러가 발생하게 된다. 따라서 ID는 자동 증가 형태가 되어야 새로운 값이 입력이 가능하다.
그래서 MEMBER 테이블을 아래와 같이 alter table을 했다.
alter table MEMBER modify ID bigint auto_increment;
그리고 새로운 멤버를 추가하기 위한 요청용 클래스를 추가했다.
@RestController
public class MemberEndpoint {
// ..
@PostMapping("/member")
public Mono<Member> createMember(@RequestBody NewMember newMember) {
return Mono.just(newMember)
.map(it -> Member.newMember(it.getName()))
.flatMap(memberRepository::save);
}
}
@Getter
@Setter
class NewMember {
private String name;
}
Member 객체를 만들기 위해 newMember 정적 팩토리 메서드를 추가했다.
public class Member {
// ..
private Member(String name) {
this.id = null;
this.name = name;
}
public static Member newMember(String name) {
return new Member(name);
}
}
저장용 curl 명령
curl -X 'POST' \
'http://localhost:8080/member' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
-d '{
"name": "new member"
}'
삭제
삭제는 delete 로 구성했다. ID로 삭제를 하도록 하는 게 일반적이겠지만 일단은 이름을 대상으로 제거하는 것으로 구현했다.
이름으로 ID 조회를 위해 MemberRepository 에 findFirstByName 를 추가했다. name 은 unique 제약 조건이 걸려있어서 중복을 막고 있지만 만약 제약 조건이 없다면 이름이 같은 사람이 현실상에서는 존재할 것이기 때문에 제일 먼저 검색되는 것이 지워지게 된다.
public interface MemberRepository extends R2dbcRepository<Member, Long> {
Mono<Long> findFirstByName(String name);
}
삭제하는 operation
@RestController
public class MemberEndpoint {
// ..
@DeleteMapping("/member/name/{name}")
public Mono<Void> removeMember(@PathVariable String name) {
return Mono.just(name)
.flatMap(memberRepository::findFirstByName)
.flatMap(memberRepository::deleteById);
}
}
삭제 curl
curl -X 'DELETE' \
'http://localhost:8080/member/name/namo' \
-H 'accept: */*'
데이터가 없으면 404를 내는 것이 좋겠지만 편의상 200으로 응답이 된다. 뭐 delete는 멱등이니 어떨 때는 200이 되고 어떨 때는 404 가 되는 것도 좋지 않을 수도 있겠다.
업데이트
사실 save가 insert or update라서 첫 번째 예제가 update로 동작을 할 수 있으나 업데이트는 기존의 이름을 다른 이름으로 바꾸는 operation 으로 구현했다.
MemberEndpoint
@RestController
public class MemberEndpoint {
// ..
@PutMapping("/member/name/{name}")
public Mono<Member> updateMemberName(@PathVariable String name, @RequestParam String newName) {
return Mono.just(name)
.flatMap(memberRepository::findFirstByName)
.map(id -> Member.updateMember(id, newName))
.flatMap(memberRepository::save);
}
}
삭제에서 만든 findFirstByName을 재활용했다.
Member 객체를 만들기 위해 updateMember 정적 팩토리 메서드를 만들었다.
public class Member {
// ..
private Member(Long id, String name) {
this.id = id;
this.name = name;
}
// ..
public static Member updateMember(Long id, String name) {
return new Member(id, name);
}
}
curl
curl -X 'PUT' \
'http://localhost:8080/member/name/namo2?newName=namo' \
-H 'accept: */*'
소스코드
전체 소스코드는 아래 커밋을 참고하세요.
https://github.com/namhokim/studySpring/commit/0570aa6d16e7f95c81c792f676a8471f90b478d5
'Programing > OpenSource' 카테고리의 다른 글
[JobRunr] 지연된 작업 기능으로 문자 메시지 상태 조회하기 (0) | 2024.11.03 |
---|---|
[JobRunr] 시작하기 (0) | 2024.05.05 |
Electron ≥ 12.x : 컨텍스트 분리(Context Isolation) (0) | 2021.11.14 |
Mockito use case (0) | 2021.04.08 |
[eclipse] Runnable JAR File 구현은? (0) | 2020.11.28 |