[Spring Boot] REST API 게시판 서버 만들기 #2(게시판 기본 CRUD, JPA, H2 Database)

프로젝트 구조 설명

 

- controller, model, service, repository로 패키지 구조화

 

- model 패키지의 경우 DTO, VO 같은 데이터 클래스가 정의되어있는 패키지 인데, 이것을

 * 컨트롤러에서 파라미터로 받는 Param,

 * DB에서 select문의 결과로 반환될 Entity,

 * ResponseEntity Json형태로 반환될 Result 객체로 구분

 

- service 클래스는 인터페이스를 클래스가 구현하는 형태가 아닌 직접 클래스가 역할을 하는 쪽으로 함. -> 인터페이스를 이용하여 다형성을 구현하는 형태가 아니라 일종의 루틴처럼 사용되는 경향이 있어보여서 과감히 탈피.

 

- 이전 챕터에서 진행했던 home 클래스는 삭제해도 됨.

 

- API 테스트를 진행할 BoardControllerTest 클래스 파일 생성.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

파일 설명

POM.xml

<?xml version="1.0" encoding="UTF-8"?>
<project 
	xmlns="http://maven.apache.org/POM/4.0.0" 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
    					https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.2.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>co.worker</groupId>
    <artifactId>board</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>board</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.jacoco/org.jacoco.agent -->
        <dependency>
            <groupId>org.jacoco</groupId>
            <artifactId>org.jacoco.agent</artifactId>
            <version>0.8.5</version>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <!-- jacoco 코드 커버리지 플러그인 -->
            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.5</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>prepare-agent</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>report</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>report</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

- <dependency> 에 JPA, H2 를 추가해줍니다.

 

 

application.properties

spring.http.encoding.charset=UTF-8
spring.http.encoding.enabled=true
spring.http.encoding.force=true

# query print
spring.jpa.show-sql=true

- 한글의 값을 http 파라미터로 넘겼을 경우 발생하는 인코딩문제를 해결하기 위해 http encoding을 UTF-8로 설정해둡니다.

- 또한 로그에 sql 쿼리형식으로 로그가 찍히도록 설정해줍니다.

 

 

BoardController.java

@RestController
@RequestMapping("/api/boards")
public class BoardController {

    @Autowired
    private BoardService boardService;

    @GetMapping(
            value = "/all",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity get(){
        return ResponseEntity.ok(boardService.getBoard());
    }

    @GetMapping(
            value = "/{seq}",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity get(@PathVariable("seq") Long seq){
        return ResponseEntity.ok(boardService.getBoard(seq));
    }

    @PostMapping(value = "/add", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity add(@RequestBody @Valid BoardParam param){
        boardService.add(param);
        return ResponseEntity.ok(null);
    }

    @PutMapping(value = "/{seq}", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity edit(@RequestBody @Valid BoardParam param,
                               @PathVariable("seq") Long seq){
        param.setSeq(seq);
        boardService.edit(param);
        return ResponseEntity.ok(null);
    }

    @DeleteMapping(value = "/{seq}", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity delete(@PathVariable("seq") Long seq){
        boardService.delete(seq);
        return ResponseEntity.ok(null);
    }
}

- @RequestBody : request객체의 Body에 JSON 형태로 넘어온 데이터를 BoardParam 객체에 매핑하는 것. Get 방식은 request Body가 없기 때문에 안됨.

- @Valid : 유효성체크해주는 어노테이션, BoardParam에 존재하는 @NotEmpty, @NotNull 따위의 어노테이션을 체크하기 위해서 사용.

- @PathVariable : 예를 들어, delete 메소드에서 /api/boards/3 이라는 URI값으로 DELETE 방식으로 데이터가 넘어온다면 3이라는 값이 {seq}에 매핑되고 Long seq에 대입된다.

 

 

BoardService.java

@Service
public class BoardService {

    @Autowired
    BoardRepository boardRepository;

    @Transactional
    public List<BoardResult> getBoard(){
        List<BoardEntity> entityList = boardRepository.findAll();
        List<BoardResult> results = entityList.stream().map(boardEntity -> {
            BoardResult boardResult = new BoardResult();
            boardResult.setContent(boardEntity.getContent());
            boardResult.setUsername(boardEntity.getUsername());
            boardResult.setTitle(boardEntity.getTitle());
            boardResult.setSeq(boardEntity.getSeq());
            return boardResult;
        }).collect(Collectors.toList());

        return results;
    }

    @Transactional
    public Object getBoard(Long seq){
        return boardRepository.findById(seq).map(boardEntity -> {
            BoardResult boardResult = new BoardResult();
            boardResult.setContent(boardEntity.getContent());
            boardResult.setUsername(boardEntity.getUsername());
            boardResult.setTitle(boardEntity.getTitle());
            boardResult.setSeq(boardEntity.getSeq());
            return boardResult;
        }).get();
    }

    @Transactional
    public void edit(BoardParam param) {
        Optional<BoardEntity> getEntity = boardRepository.findById(param.getSeq());
        getEntity.ifPresent(entity -> {
            entity.setTitle(param.getTitle());
            entity.setContent(param.getContent());
            entity.setUsername(param.getUsername());
            boardRepository.save(entity);
        });
    }

    @Transactional
    public void add(BoardParam param) {
        BoardEntity entity = new BoardEntity();
        entity.setUsername(param.getUsername());
        entity.setContent(param.getContent());
        entity.setTitle(param.getTitle());
        boardRepository.save(entity);
    }
	
    @Transactional
    public void delete(Long seq) {
        boardRepository.deleteById(seq);
    }
}

- 주로 간단한 객체 매핑작업이 수작업으로 이루어지는 비즈니스 로직입니다. 반복되는 코드는 추후 라이브러리를 이용해 숨기도록 해보겠습니다.

- 간단한 CRUD 로직을 구현하는 Service 로직입니다. 

 

BoardRepository.java

@Repository
public interface BoardRepository extends JpaRepository<BoardEntity, Long> {
}

- JpaRepository<반환테이블객체, @ID 타입> 을 상속받는 인터페이스는 JPA 레퍼지토리로써 데이터 영속층에 해당합니다. 

- 자동적으로 PK를 이용한 객체 Select나 전체 Select, 삭제, 수정을 지원하는 메소드가 생깁니다.

 

BoardParam.java

@Getter
@Setter
@ToString
@Builder
public class BoardParam {
    @Min(0)
    Long seq;
    @NotEmpty
    String content;
    @NotEmpty
    String username;
    @NotEmpty
    String title;
}

- Controller에서 파라미터로 받는 객체

- @Min(0) : 최소값을 0으로 설정한다는 것.

- @NotEmpty : null과 "" 빈문자열 체크를 하고 있습니다.

 

BoardEntity.java

@Entity
@Getter
@Setter
@ToString
@Table(name = "BoardEntity")
public class BoardEntity {
    @Id
    @GeneratedValue
    Long seq;
    String content;
    String username;
    String title;
}

- @Table : 객체를 DB 테이블처럼 인식시키는 JPA 어노테이션

- @Id, @GeneratedValue : DB에서 PK라고 생각하면 된다.

 

BoardResult.java

@Getter
@Setter
@ToString
public class BoardResult {
    Long seq;
    String content;
    String username;
    String title;
}

- 반환타입 클래스. ResponseEntity.ok(여기); 에 들어갈 반환타입.

- 만일 정교한 매핑이 필요하다면 모두 정제하여 반환될 값만 딱 들어있을 객체

 

 

 

테스트

BoardControllerTest.java

@SpringBootTest
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
public class BoardControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    BoardRepository boardRepository;

    @Autowired
    ObjectMapper objectMapper;

    @Before
    public void insertBoard(){
        for(int i =0; i<10; i++){
            BoardEntity board = new BoardEntity();
            board.setContent("내용"+i);
            board.setTitle("제목"+i);
            board.setUsername("코딩하는흑구");
            boardRepository.save(board);
        }
    }

    @Test
    public void addBoard() throws Exception {
        BoardParam param = BoardParam.builder()
                .content("추가내용")
                .title("추가제목")
                .username("추가유저")
                .build();

        mockMvc.perform(post("/api/boards/add")
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .accept(MediaType.APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(param)))
                .andDo(print())
                .andExpect(status().isOk())
                .andReturn();
        this.getBoard();
    }

    @Test
    public void editBoard() throws Exception{
        BoardParam param = BoardParam.builder()
                .content("수정내용")
                .title("수정제목")
                .username("수정유저")
                .build();

        mockMvc.perform(put("/api/boards/3")
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .accept(MediaType.APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(param)))
                .andDo(print())
                .andExpect(status().isOk());
        this.getBoard();
    }

    @Test
    public void getBoard() throws Exception {
        mockMvc.perform(get("/api/boards/all")
            .contentType(MediaType.APPLICATION_JSON_VALUE))
            .andDo(print())
            .andExpect(status().isOk());
    }

    @Test
    public void getBoardOne() throws Exception {
        mockMvc.perform(get("/api/boards/1")
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .accept(MediaType.APPLICATION_JSON_VALUE))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    public void deleteBoardOne() throws Exception{
        mockMvc.perform(delete("/api/boards/3")
                .contentType(MediaType.APPLICATION_JSON_VALUE))
                .andDo(print())
                .andExpect(status().isOk());
        this.getBoard();
    }
}

- @Before : @Test 어노테이션이 붙은 메소드가 실행되기 전에 미리 실행하여 몇몇개의 데이터를 Insert 해놓고 테스트하기 위해서 작성.

- ObjectMapper 클래스를 이용해서 build된 param 객체를 JsonString 타입으로 변형시킵니다.

  * 예를 들어, {"seq":null,"content":"추가내용","username":"추가유저","title":"추가제목"} 이런식으로 값을 넘깁니다.

 

- 메소드 옵션(get,post,put,delete)과 uri를 이용해서 api를 호출해봅니다.

 

[add 메소드 실행]

[add board]

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /api/boards/add
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Accept:"application/json", Content-Length:"86"]
             Body = {"seq":null,"content":"추가내용","username":"추가유저","title":"추가제목"}
    Session Attrs = {}

Handler:
             Type = co.worker.board.board.controller.BoardController
           Method = co.worker.board.board.controller.BoardController#add(BoardParam)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = []
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
Hibernate: select boardentit0_.seq as seq1_0_, boardentit0_.content as content2_0_, boardentit0_.title as title3_0_, boardentit0_.username as username4_0_ from board_entity boardentit0_

[all board] -> 확인용

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /api/boards/all
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8"]
             Body = null
    Session Attrs = {}

Handler:
             Type = co.worker.board.board.controller.BoardController
           Method = co.worker.board.board.controller.BoardController#get()

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json;charset=UTF-8"]
     Content type = application/json
             Body = [{"seq":1,"content":"내용0","username":"코딩하는흑구","title":"제목0"},{"seq":2,"content":"내용1","username":"코딩하는흑구","title":"제목1"},{"seq":3,"content":"내용2","username":"코딩하는흑구","title":"제목2"},{"seq":4,"content":"내용3","username":"코딩하는흑구","title":"제목3"},{"seq":5,"content":"내용4","username":"코딩하는흑구","title":"제목4"},{"seq":6,"content":"내용5","username":"코딩하는흑구","title":"제목5"},{"seq":7,"content":"내용6","username":"코딩하는흑구","title":"제목6"},{"seq":8,"content":"내용7","username":"코딩하는흑구","title":"제목7"},{"seq":9,"content":"내용8","username":"코딩하는흑구","title":"제목8"},{"seq":10,"content":"내용9","username":"코딩하는흑구","title":"제목9"},{"seq":11,"content":"추가내용","username":"추가유저","title":"추가제목"}]
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

Jacoco 코드 커버리지 확인

그리고 프로젝트 루트 파일로가서 빼먹지 않고 명령어를 입력해줍니다.

(윈도우 : cmd / 인텔리제이 유저 : terminal 탭 / mac 유저 : terminal)

 

" mvn clean verify "

 

모든 자바파일들이 수정되었으니 이를 메이븐으로 재빌드하고 code coverage를 확인해줍니다.

 

그리고 target > site > jacoco 폴더의 index.html 파일을 열어서 확인해줍니다.

 

- 대략 70%로 코드 커버리지를 확인할 수 있습니다.

- 아직은 복잡한 비즈니스 로직이 없기 때문에 (예를들어, switch 문이나 if 문) 비교적 높은 퍼센트를 갖게됨.

- 이를 통해 사용하지 않는 메소드나 새로 개발한 api를 고립되어 테스트할 수 있음(Test Driven Development 정석)

 

 

댓글

Designed by JB FACTORY