REST 방식의 서비스

강상은·2023년 12월 4일

REST방식

1. 개요

  • REST방식은 클라이언트 프로그램인 브라우저나 앱이 서버와 데이터를 어떻게 주고받는 것이 좋을지에 대한 가이드라고 할 수 있다
  • Ajax를 이용하면 브라우저의 주소가 이동할 필요 없이 서버와 데이터를 교환할 수 있기 때문에 URL은 ‘행위나 작업’이 아닌 ‘원하는 대상’ 그 자체를 의미하고, GET/POST 방식과 PUT/DELETE 등의 추가적인 전송방식을 활용해서 ‘행위나 작업’을 의미하게 되었다

2. Swagger UI 추가

  • 정적 파일 경로 문제
    • Swagger UI 설정이 완료되면 기존에 동작하는 /board/list 화면에 부트스트랩이 적용 안되는 모습을 볼 수 있음
    • Swagger UI가 적용되면서 정적 파일의 경로가 달라졌기 때문인데 이를 CustomServletConfig로 WebMvcConfigurer 인터페이스를 구현하도록 하고 AddResourceHandlers를 재정의해서 수정
  • CustomServletConfig
  • ResourceHandlerRegistry
    • Spring MVC에서 정적 자원을 처리하는 핸들러를 등록하는 데 사용되는 클래스입니다. 주로 웹 애플리케이션에서 정적인 리소스(이미지, CSS, JavaScript 파일 등)를 제공하는 데 활용됩니다.
@Configuration
@EnableWebMvc
public class CustomServletConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {

        registry.addResourceHandler("/js/**")
                .addResourceLocations("classpath:/static/js/");
        registry.addResourceHandler("/fonts/**")
                .addResourceLocations("classpath:/static/fonts/");
        registry.addResourceHandler("/css/**")
                .addResourceLocations("classpath:/static/css/");
        registry.addResourceHandler("/assets/**").
                addResourceLocations("classpath:/static/assets/");

    }

}

REST 방식의 댓글 처리 준비

  1. 단계
    1. URL의 설계와 데이터 포맷 결정
    2. 컨트롤러의 JSON/XML 처리
    3. 동작 확인
    4. 자바스크립트를 통한 화면 처리
  2. URL 설계와 DTO 설계
  • REST 방식은 주로 XML이나 JSON 형태의 문자열을 전송하고 이를 컨트롤러에서 처리하는 방식
  • JSON을 이용해서 DTO에 맞는 데이터를 전송하고 스프링을 이용해서 이를 DTO로 처리하도록 구성 할 예정
  • 댓글의 URL 설계는 다음과 같은 형태로 구성
URIMethod설명반환 데이터
/repliesPOST특정 게시물의 댓글 추가{ 'rno' : 11 } - 생성된 댓글의 번호
/replies/list/:bnoGET특정 게시물(bno)의 댓글 목록 '?' 뒤에 페이지 번호를 추가해서 댓글 페이징 처리PageResponseDTO를 JSON으로 처리
/replies/:rnoPUT특정 번호의 댓글 수정{ 'rno' : 11 } - 수정된 댓글의 번호
/replies/:rnoDELETE특정한 번호의 댓글 삭제{ 'rno' : 11 } - 삭제된 댓글의 번호
/replies/:rnoGET특정한 번호의 댓글 조회댓글 객체를 JSON으로 반환한 문자열

ReplyDTO 구성

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ReplyDTO {

    private Long rno;

    @NotNull
    private Long bno;

    @NotEmpty
    private String replyText;

    @NotEmpty
    private String replyer;

    @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")
    private LocalDateTime regDate;

    @JsonIgnore
    private LocalDateTime modDate;

}

ReplyController 구성

  • @RestController를 활용해서 메소드의 모든 리턴 값은 JSP나 Thymeleaf로 전송되는게 아니라 바로 JSON이나 XML 등으로 처리
  • 약간의 테스트를 진행하기위해 POST방식으로 처리하는 메소드 추가
    • @ApiOperation : Swagger(OpenAPI) 문서를 자동으로 생성하기 위해 사용되는 SpringFox 라이브러리의 애노테이션 중 하나
    • 보통 컨트롤러 클래스의 메서드 위에 사용되며, 다양한 정보를 설정
      • value: API의 간단한 설명을 나타냅니다.
      • notes: API에 대한 자세한 설명을 나타냅니다.
      • tags: API를 그룹화하기 위한 태그를 지정합니다.
      • response: 여러 응답을 정의할 때 사용됩니다.
    • @RequestBody는 JSON 문자열을 ReplyDTO로 변환하기 위해서 표시
    • @PostMapping의 consumes 속성 : 해당 메소드를 받아서 소비(comsume)하는 데이터가 어떤 종류인지 명시. 앞의 경우 JSON타입의 데이터를 처리하는 메소드임을 명시중
@RestController
@RequestMapping("/replies")
@Log4j2
public class ReplyController {

    @ApiOperation(value = "Replies POST", notes = "POST 방식으로 댓글 등록")
//    Swagger(OpenAPI) 문서를 자동으로 생성하기 위해 사용되는 SpringFox 라이브러리의 애노테이션 중 하나
    @PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
//    @RequestBody는 JSON 문자열을 ReplyDTO로 변환하기 위해 표시
    public ResponseEntity<Map<String, Long>> register(@RequestBody ReplyDTO replyDTO) {

        log.info("댓글 DTO: " + replyDTO);

        Map<String, Long> resultMap = Map.of("rno",99L);

        return ResponseEntity.ok(resultMap);
    }
}

결과

@Vaild와 @RestcontrollerAdvice

  • REST 방식의 컨트롤러는 대부분 Ajax와 같이 눈에 보이지 않는 방식으로 서버를 호출하고 결과를 전송하므로 에러가 발생하면 어디에서 어떤 에러가 발생했는지 알아보기 힘들 때가 많음
  • ⇒ 이러한 이유로 @Vaild 과정에서 문제가 발생하면 처리할 수 있도록 @RestControllerAdvice를 설계
  • controller.advice 디렉토리를 추가하여 CustomerRestAdvice 구성
package org.zerock.b01.controller.advice;

@RestControllerAdvice //컨트롤러에서 발생하는 예외에 대해 JSON과 같은 순수한 응답 메세지를 생성해서 보낼 수 있음
@Log4j2
public class CustomerRestAdvice {
    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.EXPECTATION_FAILED) // 예외가 발생했을때 응답 코드를 지정 EXPECTATION_FAILDE(417)
    public ResponseEntity<Map<String, String>> handleBindException(BindException e) { // 클라이언트에게 전달되는 오류 정보를 나타내는 메서드

        log.error(e);

        Map<String, String> errorMap = new HashMap<>();

        if(e.hasErrors()){

            BindingResult bindingResult = e.getBindingResult();

            bindingResult.getFieldErrors().forEach(fieldError -> {
                errorMap.put(fieldError.getField(), fieldError.getCode());
            });
        }

        return ResponseEntity.badRequest().body(errorMap);
        //최종적으로 클라이언트에게 Bad Request(400) 상태 코드와 함께 errorMap을 통해 JSON 메시지를 반환
    }

}

ReplyController 구성

package org.zerock.b01.controller;

@RestController
@RequestMapping("/replies")
@Log4j2
public class ReplyController {

    @ApiOperation(value = "Replies POST", notes = "POST 방식으로 댓글 등록")
//    Swagger(OpenAPI) 문서를 자동으로 생성하기 위해 사용되는 SpringFox 라이브러리의 애노테이션 중 하나
    @PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
//    @RequestBody는 JSON 문자열을 ReplyDTO로 변환하기 위해 표시
    public ResponseEntity<Map<String, Long>> register(@RequestBody ReplyDTO replyDTO,
                                                      BindingResult bindingResult) throws BindException {

        log.info("댓글 DTO: " + replyDTO);

        if (bindingResult.hasErrors()) {
            throw new BindException(bindingResult);
        }

        Map<String, Long> resultMap = Map.of("rno", 104L);

        return ResponseEntity.ok(resultMap);

        //수정된 register()
        //ReplyDTO를 수집할 때 @Valid를 적용
        //BindingResult를 파라미터로 추가하고 문제가 있을 때는 BindException을 throw하도록 수정
        //메소드 선언부에 BindException을 throws하도록 수정
        //메소드 리턴값에 문제가 있다면 @RestControllerAdvice가 처리할 것이므로 정상적인 결과만 리턴
    }
}

동작 과정

  • 클라이언트가 ReplyController/replies/ 엔드포인트로 POST 요청을 보내면, register 메서드가 호출됩니다.
  • 만약 데이터 바인딩 중에 오류가 발생하면, register 메서드에서 BindException이 발생하게 됩니다.
  • 이때, BindException은 Spring Framework에서 자동으로 CustomerRestAdvice 클래스의 handleBindException 메서드로 전달됩니다.
  • handleBindException 메서드에서는 해당 예외를 처리하고, 클라이언트에게 적절한 응답을 생성하여 반환합니다.

다대일(Many To One) 연관관계 실습

  1. 연관관계를 결정하는 방법
  • 데이터베이스 : PK와 FK를 이용해 PK를 가진 테이블 먼저 설계 → 이를 FK로 사용하는 테이블을 설계 ⇒ 우선순위가 결정되고, 처리방식등도 일정한 규칙이 있음
  • JPA : 객체지향을 이용하는 JPA는 방향성을 결정하기 어렵다 ⇒ 예를 들어, 회원이 여러개의 아이템을 가지고 있다면 회원 객체가 아이템을 참조할 것인지 아이템이 회원을 참조할 것인지 판단해야함

JPA의 연관 관계의 판단 기준

  • 연관 관계의 기준은 항상 변화가 많은 쪽을 기준으로 결정
  • ERD(엔티티 관계 다이어그램)의 FK를 기준으로 결정( 논리적 모델과 물리적 모델 간의 일관성을 유지하기 위함과 관례적으로 그러함)

  1. 단방향과 양방향
  • 단방향(unidirectional) : 구현이 단순하고 에러 발생의 여지를 많이 줄일 수 있지만, 데이터베이스 상에서 조인 처리와 같이 다른 엔티티 객체의 내용을 사용하는 데 더 어렵다는 단점이 있다.
  • 양방향(bidirectional) : 양쪽 객체 모두 서로 참조를 유지하기 때문에 모든 관리를 양쪽 객체에 동일하게 적용해야만 하는 불편함이 있지만 JPA에서 필요한 데이터를 탐색하는 작업에서는 편리함을 제공

⇒ 여기서는 기본적으로 단방향으로 참조 관계를 유지하면서 다른 엔티티를 사용해야 할 때는 JPQL을 통한 조인 처리를 이용

  1. 다대일 연관 관계의 구현

3-1. 연관 관계 구성 시 주의 사항

  • @ToString을 할 때는 참조하는 객체를 사용하지 않도록 반드시 exclude 속성값을 지정 (exclude : 들어오지 못하게 하다)
    • 이해를 돕기 위한 예시

      import lombok.ToString;
      
      @ToString
      public class Person {
      
          private String name;
          private Address address;
      
          // Getters and setters...
      }
      
      @ToString
      public class Address {
      
          private String city;
          private Person resident;
      
          // Getters and setters...
      }
      
      이와 같은 상황에서 Address와 Person 객체가 서로를 참조하고 있을 때,
      @ToString 없이 toString() 메서드 호출 시 무한 루프가 발생하거나 스택 오버플로우가 발생할 수 있다.
      따라서 exclude 속성을 사용하여 무한 루프를 방지하고 필요한 정보만을 출력할 수 있도록 하는 것이 좋음
      
  • @ManyToOne과 같이 연관 관계를 나타낼 때는 반드시 fetch 속성은 LAZY로 지정

3-2. @ManyToOne

  • Java Persistence API (JPA)에서 사용되는 어노테이션 중 하나로, 다대일(N:1) 관계를 나타냄. 이 어노테이션은 엔터티 간의 관계를 매핑할 때 사용되며, 특히 다대일 관계에서 '다' 쪽 엔터티가 '일' 쪽 엔터티를 참조할 때 사용

Reply 구성

package org.zerock.b01.domain;

@Entity
@Table(name ="Reply",indexes = {
        @Index(name = "idx_reply_board_bno", columnList = "board_bno")
})// @Table 어노테이션은 JPA(Java Persistence API)에서 엔티티 클래스와 데이터베이스 테이블 간의 매핑을 지정하는데 사용됩니다.
// 이 어노테이션을 사용하면 엔티티 클래스가 어떤 테이블과 매핑되는지를 명시적으로 설정할 수 있습니다.
// 엔티티 이름과 매핑될 테이블 이름이 다른 경우, name 속성을 사용하여 매핑, 동일하면 생략 가능
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString(exclude = "board") // 동시 조회 시 상호 참조에 대한 문제가 발생할 수 있으므로(exclude = "board") 지정
public class Reply {

    //엔터티 클래스의 주요 키(primary key)를 설정하는 데 사용되는 어노테이션
    //어노테이션은 주요 키의 값을 자동으로 생성하는 전략을 지정
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long rno;

    //Java Persistence API (JPA)에서 사용되는 어노테이션 중 하나로, 다대일(N:1) 관계를 나타냅니다. 이 어노테이션은 엔터티 간의 관계를 매핑할 때 사용되며,
    // 특히 다대일 관계에서 '다' 쪽 엔터티가 '일' 쪽 엔터티를 참조할 때 사용
    @ManyToOne(fetch = FetchType.LAZY) //FetchType.EAGER는 연관 엔티티를 동시에 조회, LAZY는 연관 엔티티를 실제 사용할 때 조회
//    @JoinColumn(name = "board") 컬럼명이 다를 경우
    private Board board;    //@ToString하지 않았으면 Reply를 출력할 때 Board도 추가적인 쿼리를 실행하게 됨
                            //강제로 실행하고 싶다면 테스트 코드에서 @Transactional을 추가

    private String replyText;

    private String replyer;

    public void changeText(String text){
        this.replyText = text;
    }
}

@ManyToOne(fetch = FetchType.LAZY) : 지연로딩-기본적으로 필요한 순간까지 데이터베이스와 연결하지 않는 방식으로 동작

@ManyToOne(fetch = FetchType.EAGER) : 즉시로딩-해당 엔티티를 로딩할 때 같이 로딩하는 방식(성능에 영향을 줄 수 있으므로 필요에 따라서 고려할 것)

  1. ReplyRepository 생성과 테스트

Reply는 Board와 별도로 CRUD가 일어 날 수 있기 때문에 별도의 Repository를 작성해서 관리하도록 구성

ReplyRepository 구성

public interface ReplyRepository extends JpaRepository<Reply,Long> {

}

ReplyRepositoryTests

@SpringBootTest
@Log4j2
public class ReplyRepositoryTests {

    @Autowired
    private ReplyRepository replyRepository;

    @Test
    public void testInsert() {

        //실제 DB에 있는 bno
        Long bno  = 90L;

        Board board = Board.builder().bno(bno).build();

        Reply reply = Reply.builder()
                .board(board)
                .replyText("댓글.....")
                .replyer("replyer1")
                .build();

        replyRepository.save(reply);

    }

}

결과

댓글 조회/수정/삭제

목록 화면에는 특정한 게시물에 속한 댓글의 숫자를 같이 출력해 주어야 하기 때문에 기존의 코드를 사용할 수 없음

  1. 화면에 필요한 DTO를 먼저 구성

BoardSearch 구성

Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable);
searchWithReplyCount 메서드 구현에는 단방향 참조가 가지는 단점이 보이는데 그것은 바로 
필요한 정보가 하나의 엔티티를 통해 접근할 수 없다는 점
따라서 JPQL을 이용해 left(outer)join이나 inner join을 이용하는 것

ex) 게시물과 댓글 중 한쪽에만 데이터가 존재 할 경우 - 특정 게시물은 댓글이 없는 경우가 있음

결과적으로 OUTER JOIN을 사용

BoardSearchImpl 구성

LEFT OUTER JOIN : 조인문의 왼쪽에 있는 테이블의 모든 결과를 가져 온 후 오른쪽 테이블의 데이터를 매칭하고, 매칭되는 데이터가 없는 경우 NULL로 표시한다.   
ex) SELECT 검색할 컬럼        
FROM 테이블명 LEFT OUTER JOIN 테이블명2        
ON 테이블.컬럼명 = 테이블2.컬럼명;
@Override
    public Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable) {

        QBoard board = QBoard.board;
        QReply reply = QReply.reply;

        JPQLQuery<Board> query = from(board);
        query.leftJoin(reply).on(reply.board.eq(board)); //eq메서드 : Querydsl의 메서드. 두 개의 엔티티 필드가 서로 같은지 비교할 때 사용.
				query.groupBy(board); //조인 처리 후 게시물당 처리가 필요하므로 groupBy()를 적용

        return null;
        }
  1. Projections.bean()
  • JPA에서는 Projection(프로젝션)이라고해서 JPQL의 결과를 바로 DTO로 처리하는 기능을 제공
  • Querydsl도 마찬가지로 이러한 기능을 제공

⇒ 목록화면에서 필요한 쿼리의 결과를 Projections.bean()이라는 것을 이용해서 한번에 DTO로 처리할 수 있음. 이를 이용하려면 JPQLQuery 객체의 select()를 이용

searchWithReplyCount 내부에 추가

JPQLQuery<BoardListReplyCountDTO> dtoQuery = query.select(Projections.bean(BoardListReplyCountDTO.class,
            board.bno,
            board.title,
            board.writer,
            board.regDate,
            reply.count().as("replyCount")
    ));

최종적으로 검색조건까지 추가한 BoardSearchImpl 클래스의 searchWithReplyCount 메서드

  • Spring Data JPA에서 Querydsl을 사용하여 게시물(Board)과 그 게시물에 대한 댓글(Reply)의 개수를 함께 조회하는 메서드
@Override
    public Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable) {

        QBoard board = QBoard.board;
        QReply reply = QReply.reply;

        JPQLQuery<Board> query = from(board);
        query.leftJoin(reply).on(reply.board.eq(board));

        query.groupBy(board);

        if( (types != null && types.length > 0) && keyword != null ){

            BooleanBuilder booleanBuilder = new BooleanBuilder(); // (

            for(String type: types){

                switch (type){
                    case "t":
                        booleanBuilder.or(board.title.contains(keyword));
                        break;
                    case "c":
                        booleanBuilder.or(board.content.contains(keyword));
                        break;
                    case "w":
                        booleanBuilder.or(board.writer.contains(keyword));
                        break;
                }
            }//end for
            query.where(booleanBuilder);
        }

        //bno > 0
        query.where(board.bno.gt(0L));

				//조회할 필드 및 댓글 개수를 포함한 BoardListReplyCountDTO로 select
        JPQLQuery<BoardListReplyCountDTO> dtoQuery = query.select(Projections.bean(BoardListReplyCountDTO.class,
                board.bno,
                board.title,
                board.writer,
                board.regDate,
                reply.count().as("replyCount")
        ));

        this.getQuerydsl().applyPagination(pageable,dtoQuery); //applyPagination를 통해 페이징 처리를 적용

        List<BoardListReplyCountDTO> dtoList = dtoQuery.fetch(); //dtoQuery.fetch()를 통해 결과를 조회하고 개수를 계산

        long count = dtoQuery.fetchCount();

        return new PageImpl<>(dtoList, pageable, count);
    }

BoardRepositoryTests 구성

@Test
    public void testSearchReplyCount() {

        String[] types = {"t","c","w"};

        String keyword = "1";

        Pageable pageable = PageRequest.of(0,10, Sort.by("bno").descending());

        Page<BoardListReplyCountDTO> result = boardRepository.searchWithReplyCount(types, keyword, pageable );

        //total pages
        log.info(result.getTotalPages());
        //pag size
        log.info(result.getSize());
        //pageNumber
        log.info(result.getNumber());
        //prev next
        log.info(result.hasPrevious() +": " + result.hasNext());

        result.getContent().forEach(board -> log.info(board));
    }

게시물 목록 화면 처리하기

BoardService와 BoardServiceImpl에 listWithReplyCount()메서드 추가

//게시판(Board) 목록을 조회하면서 각 게시물에 대한 댓글 수를 포함한 BoardListReplyCountDTO를 사용하여 
//페이지네이션된 결과를 반환하는 메서드

@Override
public PageResponseDTO<BoardListReplyCountDTO> listWithReplyCount(PageRequestDTO pageRequestDTO) {

    // 페이지 요청에서 필요한 정보 추출
    String[] types = pageRequestDTO.getTypes(); // 검색 유형
    String keyword = pageRequestDTO.getKeyword(); // 검색 키워드
    Pageable pageable = pageRequestDTO.getPageable("bno"); // 페이지네이션 정보

    // 게시물과 댓글 수를 함께 조회하는 메서드 호출
    Page<BoardListReplyCountDTO> result = boardRepository.searchWithReplyCount(types, keyword, pageable);

    // 조회 결과를 PageResponseDTO로 변환하여 반환
    return PageResponseDTO.<BoardListReplyCountDTO>withAll()
            .pageRequestDTO(pageRequestDTO)
            .dtoList(result.getContent()) // 조회 결과의 DTO 리스트
            .total((int)result.getTotalElements()) // 전체 게시물 수
            .build();
}

BoardController에서 호출하는 메서드 변경

@GetMapping("/list")
    public void list(PageRequestDTO pageRequestDTO, Model model){

//        PageResponseDTO<BoardDTO> responseDTO = boardService.list(pageRequestDTO);

        PageResponseDTO<BoardListReplyCountDTO> responseDTO =
                boardService.listWithReplyCount(pageRequestDTO);
        
        log.info("댓글 리스트 갯수 : " +responseDTO);

        model.addAttribute("responseDTO", responseDTO);

    }

list.html에 댓글 갯수 보이는 기능 추가

<tbody th:with="link = ${pageRequestDTO.getLink()}">
                        <tr th:each="dto:${responseDTO.dtoList}"  >
                            <th scope="row">[[${dto.bno}]]</th>
                            <td>
                                <a th:href="|@{/board/read(bno =${dto.bno})}&${link}|" class="text-decoration-none"> [[${dto.title}]] </a>
                                <span class="badge progress-bar-success" style="background-color: #0a53be">[[${dto.replyCount}]]</span>
                            </td>
                            <td>[[${dto.writer}]]</td>
                            <td>[[${#temporals.format(dto.regDate, 'yyyy-MM-dd')}]]</td>
                        </tr>
                        </tbody>

0개의 댓글