본문 바로가기

Spring

Spring Webflux (2) - Insert

728x90

이전 글에서 간단하게 Webflux 사용을 위한 R2DBC, Router, Handler에 대해 설정해봤다.

이번엔 CRUD중 Create인 Insert 기능을 먼저 구현하려고 한다. 

Insert의 목표는 게시글인 Board 추가와 첨부파일을 의미하는 BoardFile도 Insert하는 기능을 구현할 예정이다.

Board와 BoardFile은 릴레이션(1:N)으로 테이블을 구성했다. 

 

테스트 - Postman

 

Board Class

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;

import lombok.Data;

@Data
@Table("Board")
public class Board {
	@Id
	@Column(value = "seq")
	private int seq;
	
	@Column(value = "title")
	private String title;
	
	@Column(value = "content")
	private String content;
	
	@Column(value = "regdate")
	private String regdate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

}

regdate에 기본 현재 날짜와 시간을 담도록 값을 정의했다.

@Column을 통해 테이블 컬럼명을 지정해주었는데 사실 동일하기 때문에 안해도 된다.

 

BoardFile Class

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

import lombok.Data;

@Data
@Table("BoardFile")
public class BoardFile{
	@Id
	private int seq;
	private int board_seq;
	private String name;

}

 

Router

이전 글에도 있지만 혹시 참고가 필요할까봐 적어둔다.

@Configuration
public class TestRouter {
	
	@Bean
	public RouterFunction<ServerResponse> testRoute(TestHandler handler){
		return RouterFunctions.route()
				.GET("/test", request -> handler.test(request))
				.GET("/board/{seq}", handler::getBoardOne)
				.GET("/board", handler::getBoardList)
				.POST("/board"/*, RequestPredicates.accept(MediaType.MULTIPART_FORM_DATA)*/, handler::insertBoard)
				.PUT("/board/{seq}", handler::updateBoard)
				.DELETE("/board/{seq}", handler::deleteBoard)
				.POST("/file", handler::writeFile)
				.build();
	}
}

 

 

Insert 들어가기에 앞서, 요청 데이터 MIME타입(Content Type) 케이스를 2가지로 생각했다.

 

1. application/json인 경우 즉, 요청 데이터가 JSON형식으로 오는 경우

insert method는 코드는 상당히 간단하다.

 

Handler의 Insert Method

public Mono<ServerResponse> insertBoard(ServerRequest request) {
	return ServerResponse.ok().body(request.bodyToMono(Board.class)
					.flatMap(board -> boardRepo.save(board)), Board.class);
}

json형태로 요청 데이터를 보내는경우 ServerRequest 클래스의 bodyToMono를 사용하면 정말 쉽다.

물론 여러개 insert라면 당연히 bodyToFlux를 쓰면 된다.

 

테스트

 

 

2. multipart/form-data인 경우. 요청 데이터가 JSON이 아닌 경우

코드는 살짝 복잡해진다.

 

구현 목표

Board 저장 - 저장된 Board의 seq값이 setting된 List<BoardFile>와 파일 실제 저장

이었기 때문에 리액터를 처음 접하는 나는 사실 좀 어려웠다.

 

사실 어떻게 보면 비효율적인 로직이라고 생각 한다. 클라이언트단에서 Board를 Insert하는 메서드 호출하고 콜백으로 BoardFile을 새로 Insert하는 메서드를 호출하면 해결되는 간단한 요구사항이기 때문이다. 필요하다면 첨부파일을 저장하는 method도 따로 호출하면 될일이다.

 

하지만 개인적으로 클라이언트단에서 뭔가 하는걸 좀 불안하게 생각하는 편이다. 그리고 저정도 구현은 할줄 알아야 간단한 프로젝트를 개발할 수 있지 않을까 싶었다.

 

주저리 주저리였지만 아무튼 개인 욕심이다.

 

전체 코드가 아닌 코드를 짜는 과정을 적어보려 한다.

 

ServerRequest로 form data들을 가져오는 메서드를 2가지 확인했다.

 

multipart가 아닌경우 formData()를 사용하면 되고 

multipart인 경우 multipartData()를 사용하면 되는데, 둘의 차이는 리턴타입이다.

formData()는 Mono<MultiValueMap<String, String>>

multipartData()는 Mono<MultiValueMap<String, Part>>이기 때문에 클래스에 값을 할당하는 방법이 달라진다.

 

formData()의 파라미터 값 할당

return request.formData().flatMap(data -> {
    // 방법 1
    Map<String, String> data_map = data.toSingleValueMap();
    ObjectMapper mapper = new ObjectMapper();
    Board board = mapper.convertValue(data_map, Board.class);

    //방법 2
    Board board2 = new Board();
    board2.setTitle(data.get("title").get(0));
    board2.setContent(data.getFirst("content"));

파라미터 값을 할당하는 부분만 쓴 코드다.

 

MultiValueMap은 키에 대한 값이 List로 들어있기 때문에 toSingleValueMap()으로 첫번째 값들만 추출해 Map으로 만들 수 있다. 때문에 이를 활용한 방법1을 선호하는 편이다.

 

파라미터 마다 값이 따로 필요하다면 toSingleValueMap()로 나온 Map에서 값을 얻어도 되고,

방법2 처럼 get(키)로 List를 얻어 추출하거나 getFirst(키)를 사용하면 해당 컬럼의 첫번째 값을 바로 얻을 수 있다.

 

multipartData()의 파라미터 할당

이 경우에는 FormFieldPart를 사용해야한다. Part에서 String 값을 추출하는 방법을 찾기 힘들었는데 FormFieldPart를 사용하는게 가장 편해보였다. 실제로 Spring docs에서도 이 방법을 추천하고 있었다. 

 

return request.multipartData().flatMap(data -> {
    FormFieldPart field;

    field = (FormFieldPart) data.toSingleValueMap().get("title");
    board.setTitle(field.value());

    field = (FormFieldPart) data.getFirst("content");
    board.setContent(field.value());

FormFieldPart로 캐스팅 후 value()를 사용하면 편하게 값을 추출할 수 있다.

 

하지만 multipartData()는 첨부 파일을 추가로 처리해야한다.

return request.multipartData().flatMap(data -> {
	Board board = new Board();
    
    //set Parameters
	FormFieldPart field;

    field = (FormFieldPart) data.toSingleValueMap().get("title");
    board.setTitle(field.value());

    field = (FormFieldPart) data.getFirst("content");
    board.setContent(field.value());
    
    //set Files
    if(data.containsKey("files")) {
        data.get("files").forEach(filePart -> {
            FilePart file = (FilePart)filePart;
			
            //file save
            file.transferTo(new File("/www/" + file.filename()));
            
            BoardFile boradFile = new BoardFile();
            boradFile.setBoard_seq(board.getSeq());
            boradFile.setName(file.filename());
            
        });

    }

set Files 주석으로 표시한 로직이 첨부 파일 처리 로직이다.

먼저 FilePart로 캐스팅을 하면 첨부 파일의 파일명을 얻을 수 있다. -> file.filename()

실제 첨부파일을 저장하는 메서드는 transferTo()를 사용하면 된다. -> file.transferTo(new File("경로" + 파일명))

 

이후 내가 처음 짠 코드는 아래와 같다.

바보 사례 1

public Mono<ServerResponse> insertBoard(ServerRequest request) {
	if(request.headers().contentType().get().compareTo(MediaType.APPLICATION_JSON) > 0) {	// json이 아니면
		return request.multipartData().flatMap(data -> {
            Board board = new Board();

            //set Parameters
            FormFieldPart field;

            field = (FormFieldPart) data.toSingleValueMap().get("title");
            board.setTitle(field.value());

            field = (FormFieldPart) data.getFirst("content");
            board.setContent(field.value());

            //set Files
            List<BoardFile> board_files = new ArrayList<BoardFile>();
            
            if(data.containsKey("files")) {
            data.get("files").forEach(filePart -> {
                FilePart file = (FilePart)filePart;

                //file save
                file.transferTo(new File("/www/" + file.filename()));

                BoardFile boradFile = new BoardFile();
                boradFile.setBoard_seq(board.getSeq());
                boradFile.setName(file.filename());
				board_files.add(boradFile);
            	});
            }
            
            //save BoardFiles
            boardFileRepo.saveAll(board_files);
            
            //save Board
            Mono<Board> res = boardRepo.save(board);
            
            return ServerResponse.ok().body(res, Board.class);
		});
	} else {	// json이면
        return ServerResponse.ok().body(request.bodyToMono(Board.class)
                .flatMap(board -> boardRepo.save(board)), Board.class);
	}
}

 

과거라서 정확히 같은 코드는 아니지만 대충 이런식이었다.

 

첨부된 파일을 돌면서 파일 저장 -> List에 담은 후

List를 한번에 saveAll하면서 BoardFile 데이터를 insert하고 마지막으로 Board를 Insert하는 로직이다.

 

MVC박에 모르고 리액터에 무지한 나는 딱 바보같이 짜고 말았다. 결과는 당연히 Board만 수행된다. 혹시 나같은 바보사례가 나올 사람을 위해 이유를 써주고 싶지만 나도 아직 확실하게 모르니 추측한대로 써본다.

 

WHY?

리액터는 크게 Publisher와 Subscriber로 나뉘어서 구독의 개념으로 구독한 subscriber에게 reactive stream으로 데이터를 전달하는건 다들 알 것이다. Publisher가 Subscriber한테 데이터를 전달하는 순간은 구독할때 라고 한다.

이 구독을 나는 subscribe()라는 메서드return하는 순간이라고 생각했다.

 

그래서 위의 경우 return하는 Mono에 담긴 로직이 Board를 save하는 로직이기 때문에 Board save만 수행된 것이다.

 

그럼 BoardFile을 저장할때 subscribe()를 사용하면 어떻게 될까?

위에 바보사례에서 BoardFile을 저장하는 saveall 부분을 수정해 돌려봤다.

//save BoardFiles
boardFileRepo.saveAll(board_files).subscribe();

결과는 당연히 BoardFile도 저장되고, Board도 저장된다. 하지만 저장된 BoardFile의 board_seq값은 0이다.

boradFile.setBoard_seq(board.getSeq());

이 부분이 돌아갈땐 구독 전이기 때문에 save된 Board가 없기 때문이다.

 

그래서 살짝 순서를 수정한 후 다시 시도해봤다.

바보 사례 2

return request.multipartData().flatMap(data -> {
    Board board = new Board();

    //set Parameters
    FormFieldPart field;

    field = (FormFieldPart) data.toSingleValueMap().get("title");
    board.setTitle(field.value());

    field = (FormFieldPart) data.getFirst("content");
    board.setContent(field.value());

    //save Board
    boardRepo.save(board).log().subscribe();

    //set Files
    List<BoardFile> board_files = new ArrayList<BoardFile>();

    if(data.containsKey("files")) {
    data.get("files").forEach(filePart -> {
        System.out.println("create Board File Class");
        FilePart file = (FilePart)filePart;

        //file save
        file.transferTo(new File("/www/" + file.filename()));

        BoardFile boradFile = new BoardFile();
        boradFile.setBoard_seq(board.getSeq());
        boradFile.setName(file.filename());
        board_files.add(boradFile);
        });
    }

    //save BoardFiles
    boardFileRepo.saveAll(board_files).log().subscribe();


    return ServerResponse.ok().body(Mono.just(""), Board.class);
});

참고로 log()를 사용해 콘솔로 편하게 확인할 수 있다.

 

결과는

2022-04-09 17:03:31.045  INFO 3672 --- [ctor-http-nio-2] reactor.Mono.UsingWhen.1                 : onSubscribe(MonoUsingWhen.MonoUsingWhenSubscriber)
2022-04-09 17:03:31.046  INFO 3672 --- [ctor-http-nio-2] reactor.Mono.UsingWhen.1                 : request(unbounded)
create Board File Class
2022-04-09 17:03:31.205  INFO 3672 --- [ctor-http-nio-2] reactor.Flux.UsingWhen.2                 : onSubscribe(FluxUsingWhen.UsingWhenSubscriber)
2022-04-09 17:03:31.205  INFO 3672 --- [ctor-http-nio-2] reactor.Flux.UsingWhen.2                 : request(unbounded)
2022-04-09 17:03:31.349 DEBUG 3672 --- [actor-tcp-nio-3] o.s.r2dbc.core.DefaultDatabaseClient     : Executing SQL statement [INSERT INTO BoardFile (board_seq, name) VALUES (?, ?)]
2022-04-09 17:03:31.349 DEBUG 3672 --- [actor-tcp-nio-2] o.s.r2dbc.core.DefaultDatabaseClient     : Executing SQL statement [INSERT INTO Board (title, content, regdate) VALUES (?, ?, ?)]
2022-04-09 17:03:31.383  INFO 3672 --- [actor-tcp-nio-3] reactor.Flux.UsingWhen.2                 : onNext(BoardFile(seq=38, board_seq=0, name=default_thumb.png))
2022-04-09 17:03:31.383  INFO 3672 --- [actor-tcp-nio-2] reactor.Mono.UsingWhen.1                 : onNext(Board(seq=70, title=testtttittititi, content=testtttt, regdate=2022-04-09 17:03:30))
2022-04-09 17:03:31.383  INFO 3672 --- [actor-tcp-nio-2] reactor.Mono.UsingWhen.1                 : onComplete()
2022-04-09 17:03:31.383  INFO 3672 --- [actor-tcp-nio-3] reactor.Flux.UsingWhen.2                 : onComplete()

SQL이 실행되기 전에 BoardFile 클래스를 만드는 로직으로 들어가기 때문에 결과는 그대로였다.

그래서 내 생각에 구독은 절차대로 실행될지라도 데이터를 처리하는 onNext는 콜백식으로 비동기로 돌아가지 않을까 생각했다.

 

그래서 결론은 Board를 저장하는 로직, BoardFile을 저장하는 로직, 첨부 파일을 저장하는 로직의 세가지 로직이

체이닝식으로 하나의 Mono로 결합되야 순서대로 동작하는구나 생각해서 여러 시도를 해봤다.

 

일단 Board를 저장하는 Mono<Board>와 BoardFile을 저장하는 Mono<BoardFile>은 타입이 달라서 하나의 Mono로 합치기 힘들었다. 때문에 먼저 든 생각은 둘을 하나의 타입으로 합치는 것이었다. 그래서 새로 BoardInfo 클래스를 만들었다.

 

BoardInfo Class

@Data
public class BoardInfo {
	private Board board;
	private List<BoardFile> board_file_list;
	
	public BoardInfo(Board board, List<BoardFile> board_file_list) {
		this.board = board;
		
		if(board.getSeq() > 0) {
			System.out.println("set board seq");
			board_file_list.forEach((board_file) -> board_file.setBoard_seq(board.getSeq()));
		}
		
		this.board_file_list = board_file_list;
	}
}

기존의 문제였던 board_seq를 생성자에서 setting이 되는지 넣어봤다.

 

그리고 Mono의 zip()을 활용해 Board를 저장하는 Mono와 BoardFile을 저장하는 Mono를 합쳐주었다.

 

바보 사례 3

return request.multipartData().flatMap(data -> {
    //set Parameters
    Map<String, String> board_map = new HashMap<String, String>();
    Map<String, Part> data_map = data.toSingleValueMap();
    data_map.remove("files");
    data_map.forEach((key, value) -> {
        FormFieldPart field = (FormFieldPart) value;
        board_map.put(key, field.value());
    });

    ObjectMapper mapper = new ObjectMapper();
    Board board = mapper.convertValue(board_map, Board.class);

    //save Board
    Mono<Board> mono_board = boardRepo.save(board).log();

    //set Files
    List<BoardFile> board_files = new ArrayList<BoardFile>();

    if(data.containsKey("files")) {
    data.get("files").forEach(filePart -> {
        System.out.println("create Board File Class");
        FilePart file = (FilePart)filePart;

        //file save
        file.transferTo(new File("/www/" + file.filename()));

        BoardFile boradFile = new BoardFile();
        boradFile.setBoard_seq(board.getSeq());
        boradFile.setName(file.filename());
        board_files.add(boradFile);
        });
    }

    //save BoardFiles
    Mono<List<BoardFile>> mono_board_file = boardFileRepo.saveAll(board_files).log().collectList();


    return ServerResponse.ok().body(
            Mono.zip(mono_board, mono_board_file, (boards, board_file_list) -> 
                new BoardInfo(boards, board_file_list)).log(), 
            BoardInfo.class);
});

return을 보면 Mono.zip()을 활용해 Mono<Board>와 Mono<BoardFile>을 Mono<BoardInfo>로 합칠 수 있다.

(참고로 파라미터 값 세팅부분도 살짝 바꿨음)

 

포스트맨으로 테스트한 결과

 

원하던 형식의 값을 리턴받아서 성공했다고 생각했다.

하지만 실제 DB에 저장된 값을보면

값이 0이었다. 사실 내가 초보라서 몰랐을뿐 당연한 결과다.

Board 저장 - BoardFile 저장 - 둘이 합쳐서 set board_seq후 리턴

순서이기 때문에 당연히 리턴 결과는 잘나오지만 저장은 안되었던 것이다. 그래서 바보 사례 3라고 지었다.

그리고 어차피 파일 저장도 당연히 안되고 있었다. 하지만 zip()이라는 방법을 얻었다.

 

그래서 여러 검색과 시도끝에 구현한 최종 코드는 zipWhen()을 사용했다.

 

public Mono<ServerResponse> insertBoard(ServerRequest request) {
    if(request.headers().contentType().get().compareTo(MediaType.APPLICATION_JSON) > 0) {	// json이 아니면
        return request.multipartData().flatMap(data -> {
            // Board Setting
            Map<String, String> board_map = new HashMap<String, String>();
            Map<String, Part> data_map = data.toSingleValueMap();
            data_map.remove("files");
            data_map.forEach((key, value) -> {
                FormFieldPart field = (FormFieldPart) value;
                board_map.put(key, field.value());
            });

            ObjectMapper mapper = new ObjectMapper();
            Board board = mapper.convertValue(board_map, Board.class);


            //success insert BoardFile with board_seq
            Mono<Tuple2<Board, List<BoardFile>>> res = boardRepo.save(board).log().zipWhen(saved_board -> {
                List<BoardFile> board_filess = new ArrayList<BoardFile>();

                if(data.containsKey("files")) {
                    data.get("files").forEach(filePart -> {
                        FilePart file = (FilePart)filePart;

                        System.out.println("create board file");
                        BoardFile boradFile = new BoardFile();
                        boradFile.setBoard_seq(board.getSeq());
                        boradFile.setName(file.filename());
                        board_filess.add(boradFile);
                    });

                } 

                return boardFileRepo.saveAll(board_filess).log().collectList();
            });

            if(data.containsKey("files")) {
                return ServerResponse.ok().body(res.zipWhen(temp -> {
                    return Flux.fromIterable(data.get("files")).flatMap(file_one -> {
                        FilePart file_part_one = (FilePart)file_one;
                        return file_part_one.transferTo(new File("/www/" + file_part_one.filename()));
                    }).collectList();
                }), Tuple2.class);
            } else {
                return ServerResponse.ok().body(res, Tuple2.class);
            }
        });
    } else {	// json이면
        return ServerResponse.ok().body(request.bodyToMono(Board.class)
                .flatMap(board -> boardRepo.save(board)), Board.class);
    }
}

Board를 save한 후 zipWhen()으로 List<BoardFile> save를 하나의 Mono<Tuple2<Board, List<BoardFile>>>로 합치는 것을 볼 수 있다. 그리고 파일 저장부분은 return 부분에 file이 있을경우 먼저 저장한 후 마찬가지로 zipWhen으로 앞에 합친 Mono<Tuple2<Board, List<BoardFile>>>과 한번 더 합쳐서 리턴하고 있다.

여기서 Tuple2<A, B>는 null값이 아닌 A와 B를 보유하는 클래스인데 BoardInfo 대신 Object 개념으로 사용했다.

 

 

결론

비동기 논블럭으로 코드를 짜려면 복잡하다는 말을 많이 봤는데 그게 이런걸 의미하는게 아닐까 하는 생각이 든다.

개인적으로 가장 깔끔한건 역시 클래스별로 CRUD 메서드를 나누고 JSON 형태의 데이터로 요청을 해서 따로따로 연산을 하는게 비동기적이면서 좋다는 생각은 하지만, 역시 개인추측이기 때문에 현업에서는 어떤 형태로 개발을 하는지 너무 궁금하다. 누군가는 나처럼 완고한 고집으로 하나로 개발하는데 막혔을때 이 글이 도움이 되었으면 좋겠다.

728x90