본문 바로가기
개발/Spring

[Spring] Springboot GraphQL 게시판 CRUD 만들기2

by onethejay 2022. 5. 20.
728x90

이전의 포스팅을 통해 Spring GraphQL 프로젝트를 구성했습니다.
이제 게시판 CRUD를 위한 Controller와 서비스를 구현하고 테스트를 진행해보겠습니다.

Service, JPA Repository 구현

먼저 작업을 진행할 BoardRepository를 entity 패키지에 생성합니다.

import org.springframework.data.jpa.repository.JpaRepository;

public interface BoardRepository extends JpaRepository<BoardEntity, String> {
}

service 패키지안에 BoardService 파일을 생성합니다.

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@RequiredArgsConstructor
@Service
public class BoardService {
    private final BoardRepository boardRepository;
}

BoardService에서 CRUD에 해당하는 메서드를 구현합니다.

import com.example.springbootgraphql.dto.BoardDto;
import com.example.springbootgraphql.entity.BoardEntity;
import com.example.springbootgraphql.entity.BoardRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@RequiredArgsConstructor
@Service
public class BoardService {
    private final BoardRepository boardRepository;

    //게시글 1개 가져오기
    public BoardDto getBoard(String id) {
        BoardEntity boardEntity = boardRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다."));

        return new ModelMapper().map(boardEntity, BoardDto.class);
    }

    //게시글 목록 가져오기
    public List<BoardDto> getBoardList() {
        List<BoardDto> dtos = new ArrayList<>();

        boardRepository.findAll().forEach(entity -> {
            dtos.add(new ModelMapper().map(entity, BoardDto.class));
        });

        return dtos;
    }

    //게시글 등록
    public BoardDto create(BoardDto boardDto) {
        BoardEntity entity = BoardEntity.builder()
                .title(boardDto.getTitle())
                .content(boardDto.getContent())
                .build();

        BoardEntity savedEntity = boardRepository.save(entity);

        return new ModelMapper().map(savedEntity, BoardDto.class);
    }

    //게시글 수정
    public BoardDto update(BoardDto boardDto) {
        BoardEntity entity = boardRepository.findById(boardDto.getId())
                .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다."));
        entity.setTitle(boardDto.getTitle());
        entity.setContent(boardDto.getContent());

        BoardEntity savedEntity = boardRepository.save(entity);

        return new ModelMapper().map(savedEntity, BoardDto.class);
    }

    //게시글 삭제
    public void delete(String id) {
        BoardEntity entity = boardRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다."));
        boardRepository.delete(entity);
    }
}

이어서 Controller 메서드에 서비스를 구현합니다.

import com.example.springbootgraphql.dto.BoardDto;
import com.example.springbootgraphql.service.BoardService;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RequiredArgsConstructor
@RestController
public class BoardController {

    private final BoardService boardService;

    @QueryMapping
    public BoardDto board(@Argument Long id) {
        System.out.println("호출되었습니다.");
        return boardService.getBoard(id);
    }

    @QueryMapping
    public List<BoardDto> boardList() {
        return boardService.getBoardList();
    }

    @MutationMapping
    public BoardDto create(@Argument BoardDto boardInput) {
        System.out.println("BoardInput :: " + boardInput);
        return boardService.create(boardInput);
    }

    @MutationMapping
    public BoardDto update(@Argument BoardDto boardInput) {
        System.out.println("BoardInput :: " + boardInput);
        return boardService.update(boardInput);
    }

    @MutationMapping
    public Boolean delete(@Argument Long id) {
        boardService.delete(id);
        return true;
    }
}

생성한 메서드가 잘 작동하는지 테스트 코드를 통해 확인합니다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.graphql.test.tester.GraphQlTester;

@SpringBootTest
@AutoConfigureGraphQlTester
class BoardControllerTests {

    @Autowired
    private GraphQlTester graphQlTester;


    @DisplayName("1. 게시글 1개 가져오기")
    @Test
    void test_1(){
        this.graphQlTester.documentName("board")
                .variable("id", "1")
                .execute()
                .path("board.title")
                .entity(String.class)
                .isEqualTo("제목1");
    }

    @DisplayName("2. 게시글 목록 가져오기")
    @Test
    void test_2(){
        this.graphQlTester.documentName("boardList")
                .execute()
                .path("boardList[*].title")
                .entityList(String.class)
                .contains("제목1", "제목2", "제목3");
    }
}

서비스를 통해 DB의 데이터를 가져오는 Query Resolver를 생성하였습니다.

Mutation Resolver 구현

데이터를 조회하는 Query Resolver에 이어서 데이터 조작을 위한 Mutation Resolver도 구현합니다.

데이터를 저장할 create 메서드를 BoardController에 추가합니다.

/* BoardController */
...

@MutationMapping
public BoardDto create(@Argument BoardDto boardInput) {
    System.out.println("BoardInput :: " + boardInput);
    return boardService.create(boardInput);
}

schema.graphqls에 create Mutation을 추가하고 create를 호출할 때 사용할 BoardInput도 만들어줍니다.

type Query {
    boardList: [Board]
    board(id: ID): Board
}

type Board {
    id: ID
    title: String
    content: String
}

type Mutation {
    create(boardInput: BoardInput): Board
}

input BoardInput {
    id: ID
    title: String
    content: String
}

create Mutation에 대응하는 테스트 create.graphql 파일을 생성합니다.

mutation ($input: BoardInput){
    create(boardInput: $input) {
        id
        title
        content
    }
}

이어서 Controller 테스트 코드를 만들어줍니다.

/* BoardControllerTests.java */
...
@DisplayName("3. 게시글 create")
@Test
void test_3(){
    Map<String, Object> dto = new HashMap<>();
    dto.put("title", "제목5");
    dto.put("content", "내용5");

    this.graphQlTester.documentName("create")
            .variable("input", dto)
            .execute()
            .path("create.title")
            .entity(String.class)
            .isEqualTo("제목5");
}

create 테스트를 실행해봅니다.

이어서 update와 delete도 진행해보도록 하겠습니다.

schema.graphqls, 테스트 코드, 테스트 graphql을 생성합니다.

...

type Mutation {
    create(boardInput: BoardInput): Board
    update(boardInput: BoardInput): Board
    delete(id: ID): Boolean
}

...
mutation ($input: BoardInput){
    update(boardInput: $input) {
        id
        title
        content
    }
}
mutation ($id: ID){
    delete(id: $id)
}
/* BoardControllerTests.java */
@DisplayName("4. 게시글 update")
@Test
void test_4(){
    Map<String, Object> dto = new HashMap<>();
    dto.put("id", 4);
    dto.put("title", "제목4가 6으로");
    dto.put("content", "내용5");

    this.graphQlTester.documentName("update")
            .variable("input", dto)
            .execute()
            .path("update.title")
            .entity(String.class)
            .isEqualTo("제목4가 6으로");
}

@DisplayName("5. 게시글 delete")
@Test
void test_5(){

    //게시글 삭제
    this.graphQlTester.documentName("delete")
            .variable("id", 4)
            .executeAndVerify();

    //삭제한 게시글의 id로 조회 시도
    this.graphQlTester.documentName("board")
            .variable("id", 4)
            .execute()
            .errors();
}

update와 delete 테스트 코드를 수행합니다.

두 가지 테스트 코드 모두 통과하는 것을 확인할 수 있습니다.

GraphQL 코드 작성시 유의사항

1. Controller와 schema.graphqls의 이름은 동일해야 합니다.

schama.graphqls의 Query에는 board라고 되어있습니다.

type Query {
    board(id: ID): Board
}

Controller의 메서드명을 boardOne으로 변경합니다.

/* BoardController */
@QueryMapping
public BoardDto boardOne(@Argument Long id) {
    System.out.println("호출되었습니다.");
    return boardService.getBoard(id);
}

테스트코드를 호출합니다.

@DisplayName("1. 게시글 1개 가져오기")
@Test
void test_1(){
    this.graphQlTester.documentName("board")
            .variable("id", "1")
            .execute()
            .path("board.title")
            .entity(String.class)
            .isEqualTo("제목1");
}

아래 사진과 같은 에러가 발생하면서 board 메서드를 찾을 수 없게 됩니다.
img.png

2. Controller의 Argument 변수 이름과 schema.graphqls의 파라미터 이름은 동일해야합니다.

schama.graphqls의 Query board는 id라는 변수가 파라미터로 되어있습니다.

type Query {
    board(id: ID): Board
}

Controller board 메서드의 변수명을 idx로 변경합니다.

/* BoardController */
@QueryMapping
public BoardDto board(@Argument Long idx) {
    System.out.println("호출되었습니다.");
    return boardService.getBoard(idx);
}

위에서 수정한 board 메서드의 변수명에 맞게 variable을 수정하고 테스트 코드를 호출합니다.

@DisplayName("1. 게시글 1개 가져오기")
@Test
void test_1(){
    this.graphQlTester.documentName("board")
            .variable("idx", "1")
            .execute()
            .path("board.title")
            .entity(String.class)
            .isEqualTo("제목1");
}

create 메서드가 호출 되었으나 id가 null이기 때문에 데이터를 찾을 수 없어 에러가 발생했습니다.
img.png

3. 테스트 코드 작성시 Input 타입의 schama variable에 데이터를 입력할때는 Map 객체를 사용해야합니다.

Controller create 메서드는 BoardDto 타입 객체를 파라미터로 받습니다.

/* BoardController */
@MutationMapping
public BoardDto create(@Argument BoardDto boardInput) {
    System.out.println("BoardInput :: " + boardInput);
    return boardService.create(boardInput);
}

create 메서드에 맞게 BoardDto 객체를 만들어서 테스트 코드를 호출합니다.

@DisplayName("3. 게시글 create")
@Test
void test_3(){
//        Map<String, Object> dto = new HashMap<>();
//        dto.put("title", "제목5");
//        dto.put("content", "내용5");

    BoardDto dto = BoardDto.builder()
        .title("제목5")
        .content("내용5")
        .build();

    this.graphQlTester.documentName("create")
            .variable("input", dto)
            .execute()
            .path("create.title")
            .entity(String.class)
            .isEqualTo("제목5");
}

에러 메세지를 확인하면 Map 타입을 기대했지만 BoardDto 가 입력되었다고 합니다.
Variables에 해당하는 객체는 항상 Map 타입의 객체여야합니다.
img.png

Dynamic Field Resolver

type에는 Field가 선언되어있으나 데이터가 없는 경우 Dynamic Field Resolver를 통해 데이터를 출력할 수 있습니다.
테스트를 위해 schema.graphqls의 type Board에 author를 추가하고 Non Nullable Field(Scalar 뒤에 !를 추가)로 지정합니다.

type Board {
    id: ID
    title: String
    content: String
    author: String!
}

이전에 작성한 게시글 1개 가져오기 테스트 코드를 실행합니다.
img.png
Non Nullable Field로 지정된 author에 null 데이터가 존재하면서 에러가 발생하였습니다.

Response has 1 unexpected error(s) of 1 total. If expected, please filter them out: [NonNullableFieldWasNullError{message='The field at path '/board/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value.  The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'String' within parent type 'Board'', path=[board, author]}]
Request: document='query ($id: ID){
    board(id: $id) {
        id
        title
        content
        author
    }
}', variables={id=1}

에러를 해결하기 위해 Board의 author를 Nullable Field(Scalar의 !를 없앤다.)로 수정하거나, 항상 데이터를 담아서 보내면 에러가 발생하지 않을 것입니다.
이번 테스트에서는 항상 데이터를 담아 보내는 방법으로 진행합니다.

Controller 메서드의 이름과 schema의 이름이 항상 동일해야 하는 것처럼 Field의 이름과 동일한 메서드를 추가합니다.
단, 어노테이션은 SchemaMapping을 사용하고 해당 메서드는 Board Type을 위한 것이므로 typeName을 Board로 지정합니다.

@SchemaMapping(typeName = "Board")
public String author() {
    return "author return";
}

테스트 코드를 통해 author를 호출합니다.

@DisplayName("6. Dynamic Field Resolver 테스트")
@Test
void test_6(){
    this.graphQlTester.documentName("board")
            .variable("id", "1")
            .execute()
            .path("board.author")
            .entity(String.class)
            .isEqualTo("author return");
}

테스트에 통과하였습니다. 위의 방법으로 동적 필드에 대응할 수 있습니다.
img.png

정리

Springboot GraphQL로 게시판 CRUD 기본 서비스를 만들어보았습니다.
Query, Mutation, Dynamic Field Resolver를 구현해보고 테스트까지 진행한 소스는 깃허브에 올려두었으니 필요하신 분들은 참고하셔도 좋습니다.

감사합니다.

728x90

댓글