[Spring Boot] Spring Data R2DBC + H2 Database 를 이용한 CRUD 구현
Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
Spring Data R2DBC 를 이용하기 위해서는 r2dbc-spi, r2dbc-pool, spring-data-r2dbc 의 의존성이 필요한데 spring-boot-starter-data-r2dbc 의존성 하나를 추가하면 이 모든 의존성을 한번에 추가해준다.
그리고 H2 Database 를 사용하기 위해서 관련 의존성을 추가해준다.
Configuration
@Configuration
@EnableR2dbcRepositories
public class R2dbcConfig extends AbstractR2dbcConfiguration {
@Override
public ConnectionFactory connectionFactory() { return null; }
}
Spring Data R2DBC 를 사용하는 방법에는 Spring Data 에서 지원하는 Repository 를 이용하는 방법과 R2dbcEntityTemplate 를 이용하는 방법이 있다.
이번 예제에서는 Repository 를 이용할 것이기 때문에 @EnableR2dbcRepositories 어노테이션을 추가한다.
ConnectionFactory 는 application.yml 에서 설정값으로 생성하기 때문에 생략한다.
spring:
r2dbc:
url: r2dbc:h2:mem:///test
username: sa
password:
logging:
level:
org:
springframework:
r2dbc: DEBUG
debug: true
Domain
@Table
@Getter
@Setter
@AllArgsConstructor
@ToString
public class Schedule {
@Id
Long id;
Integer year;
Integer month;
Integer day;
String title;
LocalTime startTime;
LocalTime endTime;
String writer;
Integer alarm;
String comment;
public static Schedule of(ScheduleDto scheduleDto) {
return new Schedule(scheduleDto.getId(),
scheduleDto.getYear(),
scheduleDto.getMonth(),
scheduleDto.getDay(),
scheduleDto.getTitle(),
scheduleDto.getStartTime(),
scheduleDto.getEndTime(),
scheduleDto.getWriter(),
scheduleDto.getAlarm(),
scheduleDto.getComment());
}
}
생성자를 통해서 도메인에서 어떤 필드를 인식할지 결정하기 때문에 @AllArgsConstructor 를 반드시 추가해줘야 한다.
테스트만 진행할 것이기 때문에 schema.sql 파일을 생성해 샘플 테이블과 샘플 데이터를 추가한다.
CREATE TABLE schedule (
id INT(20) AUTO_INCREMENT PRIMARY KEY,
year NUMBER(4) not null,
month NUMBER(2) not null,
day NUMBER(2) not null,
title VARCHAR(256) not null,
start_time time not null,
end_time time not null,
writer VARCHAR(16) not null,
alarm INT(1),
comment VARCHAR(2048)
);
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2021, 10, 5, '프로젝트 회의', '20:00:00', '22:00:00', 'ch4njun', 1, 'SAMPLE1');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2020, 12, 5, '스터디 회의', '20:00:00', '22:00:00', 'ch4njun', 0, 'SAMPLE2');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2021, 10, 5, '프론트 회의', '23:00:00', '00:00:00', 'ch4njun', 1, 'SAMPLE3');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2021, 10, 5, '프로젝트 회의', '13:00:00', '15:00:00', 'ch4njun', 1, 'SAMPLE4');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2021, 11, 11, '스터디 회의', '10:00:00', '12:00:00', 'ch4njun', 0, 'SAMPLE5');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2021, 10, 6, '회의장 개발 회의', '23:00:00', '00:00:00', 'ch4njun', 1, 'SAMPLE6');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2021, 11, 5, '회식', '18:00:00', '22:00:00', 'ch4njun', 1, 'SAMPLE7');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2020, 9, 5, '스터디 회의', '20:00:00', '22:00:00', 'ch4njun', 0, 'SAMPLE8');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2020, 7, 10, '프론트 회의', '23:00:00', '01:00:00', 'ch4njun', 1, 'SAMPLE9');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2020, 7, 3, '프로젝트 회의', '15:00:00', '17:00:00', 'ch4njun', 1, 'SAMPLE10');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2021, 10, 20, '스터디 회의', '20:00:00', '22:00:00', 'ch4njun', 0, 'SAMPLE11');
INSERT INTO schedule(year, month, day, title, start_time, end_time, writer, alarm, comment)
VALUES (2021, 10, 30, '회의장 개발 회의', '22:00:00', '00:00:00', 'ch4njun', 1, 'SAMPLE12');
Repository
package com.pangtudy.conferenceapi.repository;
import com.pangtudy.conferenceapi.entity.Schedule;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface ScheduleRepository extends ReactiveCrudRepository<Schedule, Long> {
Flux<Schedule> findByYearOrderByStartTime(int year);
}
Spring Data JPA 와 동일한 방식으로 Repository 코드를 작성하면 된다. 당연하다... Repository 라는 개념이 Spring Data Commons 에서 나온 것이기 때문에 모든 모듈이 동일하게 사용할 수 있다.
사용 가능한 반환 타입이나 세부적인 것에는 차이가 있으니 공식문서를 참조해 사용하는게 좋을듯 하다.
Controller
CRUD 에 대한 API 를 제공하는 Controller 를 생성해보자.
package com.pangtudy.conferenceapi.controller;
import com.pangtudy.conferenceapi.dto.ScheduleDto;
import com.pangtudy.conferenceapi.service.CalendarService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Slf4j
@RestController
@RequestMapping("/calendar")
@RequiredArgsConstructor
public class CalendarController {
private final CalendarService calendarService;
@GetMapping("/{year}/schedules")
public Flux<ScheduleDto> getSchedulesByYear(@PathVariable int year) {
return calendarService.retrieveSchedules(year);
}
@PostMapping("/schedule")
public Mono<ScheduleDto> setSchedule(
@RequestBody ScheduleDto scheduleDto
) {
return calendarService.saveSchedule(scheduleDto);
}
@PutMapping("/schedules/{idx}")
public Mono<ScheduleDto> updateSchedule(
@PathVariable long idx,
@RequestBody ScheduleDto scheduleDto
) {
return calendarService.updateSchedule(idx, scheduleDto);
}
@DeleteMapping("/schedules/{idx}")
public Mono<Void> deleteSchedule(
@PathVariable long idx
) {
return calendarService.deleteSchedule(idx);
}
}
참고로 그냥 이렇게 생성하고 Front 쪽에서 호출하면 CORS 로 인한 문제가 발생하기 때문에 반드시 WebConfig 쪽에서 이에 대한 설정을 해줘야 한다.
Service
package com.pangtudy.conferenceapi.service;
import com.pangtudy.conferenceapi.dto.ScheduleDto;
import com.pangtudy.conferenceapi.entity.Schedule;
import com.pangtudy.conferenceapi.repository.ScheduleRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Slf4j
@Service
@RequiredArgsConstructor
public class CalendarService {
private final ScheduleRepository scheduleRepository;
public Flux<ScheduleDto> retrieveSchedules(int year) {
return scheduleRepository.findByYearOrderByStartTime(year)
.map(ScheduleDto::of);
}
@Transactional
public Mono<ScheduleDto> saveSchedule(ScheduleDto scheduleDto) {
return scheduleRepository.save(Schedule.of(scheduleDto))
.map(ScheduleDto::of);
}
@Transactional
public Mono<ScheduleDto> updateSchedule(long idx, ScheduleDto scheduleDto) {
return scheduleRepository.findById(idx)
.map(schedule -> {
schedule.setTitle(scheduleDto.getTitle());
schedule.setStartTime(scheduleDto.getStartTime());
schedule.setEndTime(scheduleDto.getEndTime());
schedule.setComment(scheduleDto.getComment());
schedule.setAlarm(scheduleDto.getAlarm());
return schedule;
})
.flatMap(schedule -> scheduleRepository.save(schedule)
.map(ScheduleDto::of));
}
@Transactional
public Mono<Void> deleteSchedule(long idx) {
return scheduleRepository.deleteById(idx);
}
}
Spring Data R2DBC 는 기존 JPA 와 다르게 Mono & Flux 로 데이터를 반환할 수 있다. 이렇게 반환받은 데이터를 그대로 반환하면 되고, 중간에 처리할 것이 있다면 Reactor 가 제공하는 연산자를 이용하면 된다.
Update 쪽에서 코드를 보면 Reactor 연산중에는 영속성을 가지지 않기 때문에 별도로 Repository.save() 메서드를 통해서 다시 저장을 해줘야 되는 것을 확인할 수 있다.
이외에도 Spring Data R2DBC 는 연관관계 매핑을 지원하지 않는다는 단점이 있다.
마지막으로 R2dbcEntityTemplate 을 이용한 코드를 첨부하며 포스팅을 마친다.
@Component
@RequiredArgsConstructor
@Slf4j
public class PostRepository {
private final R2dbcEntityTemplate template;
public Flux<Post> findByTitleContains(String name) {
return this.template.select(Post.class)
.matching(Query.query(where("title").like("%" + name + "%")).limit(10).offset(0))
.all();
}
public Flux<Post> findAll() {
return this.template.select(Post.class).all();
}
public Mono<Post> findById(UUID id) {
return this.template.selectOne(Query.query(where("id").is(id)), Post.class);
}
public Mono<UUID> save(Post p) {
return this.template.insert(Post.class)
.using(p)
.map(post -> post.getId());
}
public Mono<Integer> update(Post p) {
/*
return this.template.update(Post.class)
.matching(Query.query(where("id").is(p.getId())))
.apply(Update.update("title", p.getTitle())
.set("content", p.getContent())
.set("status", p.getStatus())
.set("metadata", p.getMetadata()));
*/
return this.template.update(
Query.query(where("id").is(p.getId())),
Update.update("title", p.getTitle())
.set("content", p.getContent())
Post.class
);
}
public Mono<Integer> deleteById(UUID id) {
return this.template.delete(Query.query(where("id").is(id)), Post.class);
}
}