본문 바로가기

Spring

Spring Webflux (3) - Select

728x90

이전 글에서 Insert하는 과정에 대해서 작성했다.

이번엔 CRUD중 Read인 SELECT 과정에 대해서 기록해보려고 한다. 사실 SELECT기능을 구현한지 꽤 되어서 기억이 듬성듬성이다.

 

이전과 마찬가지로 Board와 BoardFile 1:N 관계에서 목표는

Board를 여러개 받는 Flux<Board>를 리턴하는 api( getBoardList() )와

Board 1개와 해당 Board의 BoardFile을 여러개 리턴하는 api( getBoardOne() )를 구현하는 것이다.

 

 

1. getBoardList api

에 들어가기 전에 이슈가 하나 생겼다.

 

나는 MariaDB를 사용하고있고, 기존에 r2dbc 설정에서 mysql 드라이버를 사용하고 있었는데

이 드라이버에서 DatabaseClient나 R2dbcEntityTemplate를 활용할때 Entity와 테이블 컬럼 매핑이 잘못되는 이슈를 발견했다. (DatabaseClient나 R2dbcEntityTemplate를 활용하려는 이유는 동적인 SELECT를 위해서)

 

구글링 결과 같은 이슈를 겪는 사람들이 종종 보여서 지금은 해결되었는지 모르지만 (그대로일듯) mariadb 드라이버로 설정을 수정했다.

아래는 해당 이슈 관련 글

참고 : https://github.com/spring-projects/spring-framework/issues/26124

 

Spring r2dbc ColumnMapRowMapper wrong mapping when using MySQL driver · Issue #26124 · spring-projects/spring-framework

Affects: 5.3.1 var collectList = r2dbcEntityTemplate.databaseClient.sql(""" select * from pending_message """.trimIndent()) // .map(EntityRowMapper(PendingMessage::cla...

github.com

 

1. R2DBC 재설정

 

R2dbcConfig

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.r2dbc.connection.R2dbcTransactionManager;
import org.springframework.transaction.ReactiveTransactionManager;

import io.r2dbc.spi.ConnectionFactories;
import io.r2dbc.spi.ConnectionFactory;
import io.r2dbc.spi.ConnectionFactoryOptions;

@Configuration
@PropertySource({"classpath:db.properties"})
public class R2dbcConfig extends AbstractR2dbcConfiguration{

	@Value("${poozimdb.host}")
	private String host;
	
	@Value("${poozimdb.port}")
	private int port;
	
	@Value("${poozimdb.username}")
	private String username;
	
	@Value("${poozimdb.password}")
	private String password;
	
	@Bean
	@Override
	public ConnectionFactory connectionFactory() {
		return ConnectionFactories.get(ConnectionFactoryOptions.builder()
				.option(ConnectionFactoryOptions.DRIVER, "mariadb")
				.option(ConnectionFactoryOptions.HOST, host)
				.option(ConnectionFactoryOptions.PORT, port)
				.option(ConnectionFactoryOptions.DATABASE, "test")
				.option(ConnectionFactoryOptions.USER, username)
				.option(ConnectionFactoryOptions.PASSWORD, password)
				.build());
	}
	
	@Bean
	public ReactiveTransactionManager transactionManager(ConnectionFactory connectionFactory) {
		return new R2dbcTransactionManager(connectionFactory);
	}
	
	@Bean
	public R2dbcEntityTemplate r2dbcEntityTemplate(ConnectionFactory connectionFactory) {
		return new R2dbcEntityTemplate(connectionFactory);
	}
	
}

 

gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	implementation 'org.springframework.session:spring-session-core'
	providedRuntime 'org.springframework.boot:spring-boot-starter-netty'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.projectreactor:reactor-test'
	
	//lombok
	implementation 'org.projectlombok:lombok'
	
	//r2dbc
	implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
	implementation 'org.mariadb:r2dbc-mariadb:1.0.3'
}

 

2. getBoardList() : Flux<Board>를 리턴하는 api

 

repository 방법

@Autowired
private TestRepository boardRepo;

public Mono<ServerResponse> getBoardList(ServerRequest request) {
    Flux<Board> board_list = boardRepo.findAll();
    return ServerResponse.ok().body(board_list, Board.class);

}

단순히 r2dbc repository만 사용한다면 findAll을 사용하면 된다. 

혹은 @Query를 사용해 파라미터를 동적으로 넣어줄수 있다.

 

@Query 예시

public interface BoardFileRepository extends ReactiveCrudRepository<BoardFile, Integer>{

	@Query(value = "SELECT * FROM BoardFile WHERE board_seq = :board_seq")
	public Flux<BoardFile> getBoardFileList(int board_seq);
}

@Query의 경우 JPQL과 비슷한 느낌이다. 다른점은 SELECT에 *이 사용 가능하다는 점

 

R2dbcEntityTemplate 방법

이 방법은 좀 더 동적으로 조회할 수 있다.

@Autowired
private TestRepository boardRepo;

@Autowired
private R2dbcEntityTemplate r2dbcEntityTemplate; //당연히 빈 등록 해야함

public Mono<ServerResponse> getBoardList(ServerRequest request) {
    Flux<Board> board_list = r2dbcEntityTemplate
    	.select(Board.class)
        .matching(Query.query(Criteria.where("seq").greaterThan(10)).limit(10).offset(3))
        .all();
    return ServerResponse.ok().body(board_list, Board.class);

}

SQL문으로 치면 

SELECT *
FROM Board
WHERE seq > 10
LIMIT 10 OFFSET 3

를 조회하는 api다.

 

matching(), Query와 Criteria를 활용해 어느정도 동적으로 조건을 넣을 수 있는데, 이 정도만 해도 활용할 수 있지만 개인적으로 뭔가 낯설어서 사용하진 않았다.

 

 

DatabaseClient 방법

sql()을 활용해 String 형태의 쿼리문을 직접 사용하는 방법이다. 최종적으로는 이 방법이 더욱 동적으로 활용이 가능할 것같아서 선택했다.

@Autowired
private DatabaseClient databaseClient;

public Mono<ServerResponse> getBoardList(ServerRequest request) {
	Flux<Map<String, Object>> board_list = databaseClient.sql("SELECT b.* FROM Board b WHERE seq > 10 LIMIT 10 OFFSET 3")
				.bind(0, 10)
				.fetch()
				.all();
}

R2dbcEntityTemplate를 사용할 경우에는 조인이라던가 서브쿼리 같은 경우에 사용할 수가 없기 때문에, DatabaseClient의 sql로 직접 스트링 형태의 쿼리문을 활용하는 것이 더 편해보였다. 실제로 SQL을 직접 쓰는 mybatis에 익숙한 사람은 @Query로 하나하나 쓰는것도 괜찮지만 String의 query를 만들수 있는 유틸을 간단히 구현해서 작성한다면 더 편하지 않을까 싶어서 DatabaseClient를 선택하게 되었다.

 

활용 예시

 

쓰는것과 안쓰는것이 크게 차이는 없는 query 생성용 클래스를 하나 만들었다. 아직은 SELECT 전용이다.

DynamicQuery

public class DynamicQuery {

	private StringBuilder sql;
	
	public DynamicQuery() {
		this.sql = new StringBuilder();
	}
		
	public DynamicQuery select(String select) {
		sql.append("SELECT ").append(select).append(" ");
		return this;
	}
	
	public DynamicQuery from(String from) {
		sql.append(" FROM ").append(from).append(" ");
		return this;
	}
	
	public DynamicQuery where(String where) {
		sql.append(" WHERE ").append(where).append(" ");
		return this;
	}
	
	public DynamicQuery and(String and) {
		sql.append(" AND ").append(and).append(" ");
		return this;
	}
	
	public DynamicQuery or(String or) {
		sql.append(" OR ").append(or).append(" ");
		return this;
	}
	
	public DynamicQuery groupBy(String groupby) {
		sql.append(" GROUP BY ").append(groupby).append(" ");
		return this;
	}
	
	public DynamicQuery orderBy(String orderby) {
		sql.append(" ORDER BY ").append(orderby).append(" ");
		return this;
	}
	
	public DynamicQuery having(String having) {
		sql.append(" HAVING ").append(having).append(" ");
		return this;
	}
	
	public DynamicQuery limit(int limit) {
		sql.append(" LIMIT ").append(limit).append(" ");
		return this;
	}
	
	public DynamicQuery offset(int offset) {
		sql.append(" OFFSET ").append(offset).append(" ");
		return this;
	}
	
	public DynamicQuery leftJoin(String leftJoin) {
		sql.append(" LEFT JOIN ").append(leftJoin).append(" ");
		return this;
	}
	
	public DynamicQuery rightJoin(String rightJoin) {
		sql.append(" RIGHT JOIN ").append(rightJoin).append(" ");
		return this;
	}
	
	public DynamicQuery on(String on) {
		sql.append(" ON ").append(on).append(" ");
		return this;
	}
	
	public String getQuery() {
		String query = sql.toString();
		sql = sql.delete(0, sql.length());
		return query;
	}
}

빌더를 사용하는 형태로 쓰고싶어 return this를 해주고있는 모습이다.

 

limit와 offset값을 파라미터로 보낸다는 가정하에 getBoardList() api를 아래와 같이 구현했다.

public Mono<ServerResponse> getBoardList(ServerRequest request) {
    int limit = Integer.parseInt(Optional.ofNullable(request.queryParams().getFirst("limit")).orElse("10"));
    int offset = Integer.parseInt(Optional.ofNullable(request.queryParams().getFirst("offset")).orElse("0"));

    DynamicQuery dq = new DynamicQuery();
    String board_select = dq.select("*").from("Board").limit(limit).offset(offset).getQuery();
    Flux<Map<String, Object>> board_list = databaseClient.sql(board_select).fetch().all().log();

    return ServerResponse.ok().body(board_list, Board.class);
}

ServerRequestqueryParams()를 활용해 쿼리 스트링 파라미터 값을 얻을 수 있다.

 

 

3. getBoardOne() : Board 하나와 해당 BoardFile 여러개를 리턴하는 api

이전에 설정한 router를 먼저 보자

@Bean
public RouterFunction<ServerResponse> testRoute(TestHandler handler){
    return RouterFunctions.route()
            .GET("/board/{seq}", handler::getBoardOne)
            ...
            .build();
}

{seq}로 path에 변수를 줄 수 있다.

 

public Mono<ServerResponse> getBoardOne(ServerRequest request) {
    int seq = Integer.parseInt(request.pathVariable("seq"));
    Mono<Board> board = boardRepo.findById(seq).log();

    Mono<Tuple2<Board, List<BoardFile>>> res = board.zipWhen(board_one -> {
        return boardFileRepo.getBoardFileList(board_one.getSeq()).collectList().log();
    }).log();

    return ServerResponse.ok().body(res, Board.class);
}

ServerRequest의 pathVariable을 활용해 path에 넣은 변수 seq 데이터를 얻을 수 있다.

사실 repository의 findById만 사용하면 충분하다. 하지만 나는 릴레이션 1:N 관계의 BoardFile 테이블이 있다고 가정했고, 해당 데이터도 함께 조회할 수 있는 정도로 구현 할 줄 알아야 한다고 생각해서 완고하게 구현해봤다.

사실 api를 따로 호출하면 이럴일이 없다 :)

 

Insert 기능때처럼 마찬가지로 zipWhen()을 사용했는데, 이유를 알 수 없는 이슈를 한가지 겪었다.

return boardFileRepo.getBoardFileList(board_one.getSeq()).collectList().log();

이부분에서 boardFileRepo.getBoardFileList의 return 타입을 위에서 확인할 수 있는데 Flux<~~~>로 설정되어있다.

사실 이전에 Mono<List<~~~>>로 설정을 했는데 이유는 모르지만 어떠한 exception도 없이 zipWhen된 결과물이 값이 계속 비어있는걸 확인했다. 이후에 return 타입을 Flux로 수정하자 정상적으로 작동했는데 이유는 아직 모르겠다. (누가 알려줬으면 좋겠다)

 

참고

@Query(value = "SELECT * FROM BoardFile WHERE board_seq = :board_seq")
public Flux<BoardFile> getBoardFileList(int board_seq);

 

 

728x90