개발/JAVA

[자바웹개발워크북] 6. AJAX와 JSON

독코더 2023. 2. 3. 17:44
반응형

/*

 *

 * 자바웹개발워크북의 내용을 정리하기 위한 포스팅입니다.

 * AJAX, JSON, REST, swagger UI,

 */

 

1. 용어정리 / Swagger UI 준비 / 댓글엔티티 처리

AJAX(Asynchronous JavaScript And XML)방식은 브라우저에서 서버를 호출하지만 모든 작업이 브라우저 내부에서 이루어지기 때문에 현재 브라우저의 브라우저 화면의 변화없이 서버와 통신할 수 있습니다. 대표적인 예로 '자동완성'이나 '지도서비스'등이 있습니다.

Ajax가 적용되면서 자바그크립트 기술의 수요가 늘었고, 최근에는 프론트엔드중심의 개발방식이 자리 잡게됐습니다.

 

Ajax의 약자처럼 자바스크립트를 이용해서 XML을 주고받는 방식을 이용했습니다만, 최근에는 JSON(JavaScript Object Notation)을

이용하는 방식을 더 선호하고 있습니다.

 

JSON문자열 : 서버에서 순수한 데이터를 보내고 클라이언트가 적극적으로 이를 처리한다 라는 개발방식의 핵심은 '문자열'입니다.

문자열은 데이터를 주고받는 것에 신경써야하는건 없지만, 복잡한 구조의 데이터를 표현하기 어렵습니다. 이로인해 고려된것이 XML과 JSON이라는 형태입니다. JSON은 자바스크립트에서 객체리터럴과 유사합니다.(문자열을 {키:값}의 형태로 표시)

 

REST방식은 클라이언트 프로그램인 브라우저나 앱이 서버와 데이터를 어떻게주고받는 것이 좋을지에 대한 가이드라고 할 수 있습니다.

예를들어 게시물을 수정한다고 하면 과거에는 다음과 같이 표현했습니다.

이전표현 REST 방식 표현
▶ /board/modify -> 게시물의 수정(행위/목적)
▶ <form>  -> 데이터의 묶음
▶ /board/123 -> 게시물 자원 자체
▶ PUT방식 -> 행위나 목적

개발자의 입장에서 REST방식이라는 것은 GET방식은 조회, POST는 등록..과 같이 전송방식을 통해서 작업을 결정하고,

URL은 특정한 자원을 의미하게 됩니다.

최근 웹페이지주소의 마지막부분이 번호로 끝나는 경우가 많은것을 볼 수 있습니다. 과거에 '?'의 쿼리스트링으로 정보를 전달하는 방식과

달리 최근에는 직접주소의 일부로 사용하는 방식도 REST방식의 표현법입니다.

REST방식은 '하나의 자원을 하나의 주소로 표현'이 가능하고 유일무이해야합니다. 즉, REST방식에서 URL하나는 하나의 자원을

식별할 수 있는 고유한 값이고, GET/POST등은 이에 대한 '작업'을 의미합니다.

URI(자원) + GET/POST/PUT/DELETE(행위)

 

Swagger UI 프로젝트의 준비

프로젝트의 build.gradle에 'swagger ui maven'으로 검색한 라이브러리를 추가합니다.

dependencies {
    ... 생략 ...
    
    // Swagger UI
    implementation 'io.springfox:springfox-boot-starter:3.0.0'
    implementation 'io.springfox:springfox-swagger-ui:3.0.0'
}

프로젝트의 config패키지에 SwaggerConfig클래스를 추가합니다.

@Configuration
public class SwaggerConfig {

    @Bean
    public Docket api() {
        return new Docket(DocumentationType.OAS_30)
                .useDefaultResponseMessages(false)
                .select()
                .apis(RequestHandlerSelectors.basePackage("org.zerock.board.controller"))
                .paths(PathSelectors.any())
                .build()
                .apiInfo(apiInfo());
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Boot Board Project Swagger")
                .build();
    }
}

3버전은 SwaggerConfig를 추가한 후에 프로젝트를 실행하면 에러가 발생합니다.

해결하려면 Spring Web관련 설정을 추가해주어야만 합니다. config폴더에 CustomServletConfig클래스를 추가합니다.

@Configuration
@EnableWebMvc
public class CustomServletConfig {
}

앞의설정을 추가하고 'http://loacalhost:8080/swagger-ui/index.html'을 호출하면 swagger페이지를 볼 수 있습니다.

화면의 메소드를 선택하고 [try it out]버튼과 [Excute]버튼을 통해서 메소드를 실행할 수 있고, 결과를 바로 확인할 수 있습니다.

 

정적 파일 경로 문제

SwaggerUI가 적용되면서 정적 파일의 경로가 달라져서 기존 화면에 문제가 발생합니다.

이를 CustomServletConfig로 WebMvcConfigurer인터페이스를 구현하고 addResouceHandlers를 재정의해서 수정합니다.

@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방식의 댓글처리 준비

▶ URL의 설계와 데이터포맷결정

▶ 컨트롤러의 JSON/XML처리

▶ 동작확인

▶ 자바스크립트를 통한 화면처리

URL(method) 설명 반환데이터
/replies (POST) 특정한 게시물의 댓글 추가 {'rno':11} - 생성된 댓글의 번호
/replies/list/:bno (GET) 특정 게시물(bno)의 댓글 목록
'?'뒤에 페이지번호를 추가해서 댓글
페이징처리
PageResponseDTO를 JSON으로 처리
/replies/:rno (PUT) 특정한 번호의 댓글 수정 {'rno':11} - 수정된 댓글의 번호
/replies/:rno (DELETE) 특정한 번호의 댓글 삭제 {'rno':11} - 삭제된 댓글의 번호
/replies/:rno 특정한 번호의 댓글 조회 댓글 객체를 JSON으로 변환한 문자열

프로젝트의 dto패키지에 ReplyDTO클래스를 추가합니다.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReplyDTO {
    
    private long rno;
    
    private long bno;
    
    private String replyText;
    
    private String replyer;
    
    private LocalDateTime regDate, modDate;
}

ReplyController은 기존과달리 @RestController를 쓰는데 메소드의 모든 리턴값은 JSP나 Thymeleaf로 전송되는게 아니라,

바로 JSON이나 XML등으로 처리되게 해줍니다.

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

    @ApiOperation(value = "Replies POST", notes = "POST 방식으로 댓글 등록")
    @PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map<String,Long>> register(@RequestBody ReplyDTO replyDTO) {
        
        log.info(replyDTO);
        
        Map<String, Long> resultMap = Map.of("rno", 111L);
        
        return ResponseEntity.ok(resultMap);
    }
    
}

파라미터의 @RequestBody는 JSON문자열을 ReplyDTO로 변환하기 위해서 표시합니다.

@PostMapping에 consumes는 해당 메소드를 받아서 소비하는 데이터가 어떤 종류인지 명시할 수 있습니다.

(JSON타입데이터를 처리하는 메소드임을 명시)

리턴값도 특이한데, @RestController는 리턴값 자체가 JSON으로 처리되는데 ResponseEntity타입을 이용하면 상태코드를 전송할 수 있습니다. 여기선 ResponseEntity.ok()를 이용하는데 HTTP상태코드200(OK)이 전송됩니다.

 

@Valid와  @RestControllerAdvice

REST방식의 컨트롤러는 대부분 Ajax와 같이 눈에 보이지 않는 방식으로 서버를 호출하고 결과를 전송하므로, 에러가 발생하면 어디서-

어떤게 발생했는지 알기 어렵기 때문에 @Valid과정에서 문제가 발생하면 처리할 수 있도록 @RestControllerAdvice를 설계합니다.

controller패키지에 advice패키지를 구성하고 CustomRestAdvice클래스를 추가합니다.

@RestControllerAdvice
@Log4j2
public class CustomRestAdvice {

    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.EXPECTATION_FAILED)
    public ResponseEntity<Map<String, String>> handlerBindException(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);
    }
}

@RestControllerAdvice를 이용하면 컨트롤러에서 발생하는 예외에 대해 JSON과 같은 순수한 응답메시지를 생성해서 보낼수있습니다.

handleBindException()은 컨트롤러에서 BindException이 던져지는 경우 이를 이용해서 JSON메시지와 400에러를 전송하게됩니다.

 

연관관계를 결정하는 방법

객체지향을 이용하는 JPA는 방향성을 결정하는것이 어렵습니다.

예를들어 '회원'이 여러개의 '아이템'을 갖고있다면 '회원'객체가 '아이템'을 참조할것인지, 반대가 될것인지 판단해야합니다.

JPA의 연관관계 판단기준을 결정할때는 다음과 같은 기준을 적용하는것이 좋습니다.

연관관계의 기준은 항상 변화가 많은 쪽을 기준으로 결정

: '회원'과 '게시물'에서 회원들이 여러개의 게시물을 작성하므로 핵심은 '게시물'로 판단하는것이 편리합니다.

 회원을 기준으로 시작하면 나중에는 엔티티클래스설계를 감당할 수 없을만큼 많은 연관관계가 필요하게 됩니다.

'회원이 게시물을 작성하고 게시물에 여러개의 파일과 댓글이 있고, 각 게시글에 회원의 좋아요가 있고...'

ERD의 FK를 기준으로 결정

 

객체참조의 단방향과 양방향

단방향: 구현이 단순하고 에러발생의 여지를 많이 줄일수있지만 데이터베이스상에서 조인처리와 같이 다른 엔티티 객체의 내용을 사용하는데 더 어렵다는 단점이 있습니다.

양방향: 양쪽 객체 모두 서로 참조를 유지하기 때문에 모든 관리를 양쪽 객체에 동일하게 적용해야만 하는 불편함이 있지만 JPA에서 필요한 데이터를 탐색하는 작업에서는 편리함을 제공합니다.

 

JPA에서 엔티티 간의 관계를 한쪽에서만 참조하는 단방향방식으로 구현하는 경우 장점은 관리가 편하다는점입니다.

양방향의 경우 양쪽 객체 모두를 변경해 주어야 하기 때문에 구현할때도 주의해야하지만 트랜잭션을 신경써야만 합니다.

Board객체는 Reply에 대해서 전혀 모르는 상태이므로 Reply의 CRUD에 대해서 무관해도 되므로 단순하게 구현할 수 있습니다.

 

다대일 연관 관계의 구현

다대일 연관 관계는 필요한 엔티티 클래스에 @ManyToOne을 이용해서 연관관계를 작성합니다.

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class Reply  extends BaseEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long rno;
    
    @ManyToOne(fetch = FetchType.LAZY)
    private Board board;
    
    private String replyText;
    
    private String replyer;
    
}

Reply클래스에는 Board타입의 객체참조를 board라는 변수를 이용해서 참조하는데 @ManyToOne으로 다대일관계로 구성합니다.

@ToString을 할때 참조하는 객체를 사용하지 않도록 반드시 exclude속성값을 지정합니다.

=> reply테이블에서 쿼리를 실행하면 Board객체를 같이 출력하므로 board테이블에 쿼리를 추가로 실행해야하는데,

       한번만 쿼리를 실행하면 에러가 발생하기때문에 exclude설정을 해줘야합니다.(강제로 실행하고싶으면 @Transactional 추가)

@ManyToOne과 같이 연관관계를 나타낼때는 반드시 fetch속성은 Lazy로 지정합니다.

=>  Lazy속성값은 '지연로딩'이라고 표현하는데 기본적으로 필요한 순간까지 데이터베이스와 연결하지 않는 방식으로 동작합니다.

       이에 반대는 EAGER입니다. '즉시로딩'으로 해당 엔티티를 로딩할때 같이 로딩하는 방식입니다.

 

특정 게시물의 댓글조회와 인덱스

쿼리 조건으로 자주 사용되는 칼럼에는 인덱스를 생성해 두는 것이 좋은데 @Table어노테이션에 추가적인 설정을 이용해서 인덱스를 지정할 수 있습니다. Reply클래스에는 @Table어노테이션을 추가해서 다음과 같이 구성합니다.

@Entity
@Table(name = "Reply", indexes = {@Index(name = "idx_reply_board_bno", columnList = "board_bno")})
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class Reply  extends BaseEntity {

   ...

}

특정한 게시글의 댓글들은 페이징처리를 할 수 있도록 Pageable기능을 ReplyRepository에 @Query를 이용해서 작성합니다.

public interface ReplyRepository extends JpaRepository<Reply, Long> {
    
    @Query("select r from Reply r where r.board.bno = :bno")
    Page<Reply> listOfBoard(long bno, Pageable pageable);
}

 

게시물 목록과 Projection

게시글과 댓글의 관계처럼 엔티티가 확장되면 목록화면이 문제됩니다. 목록화면에서 특정한 게시물에 속한 댓글의 숫자를 같이 출력하려면,

기존의 코드를 사용할 수 없고 다시 추가 개발이 필요해집니다. 이상황에서 우선 화면에 필요한 DTO를 먼저 구성합니다.

dto패키지에 BoardListReplyCountDTO클래스를 추가합니다.

@Data
public class BoardListReplyCountDTO {
    
    private long bno;
    private String title;
    private String writer;
    private LocalDateTime regDate;
    
    private long replyCount;
}

search패키지에 BoardSearch와 BoardSearchImpl에 추가메소드를 아래와 같이 구현합니다.

Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable);
@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);
    
    return null;
}

여기서 단방향 참조가 가지는 단점이 필요한 정보가 하나의 엔티티를 통해서 접근할 수 없다는 점입니다.

이문제를 해결하는 방법으로 JPQL로 조인(JOIN)을 이용하는것입니다.

특정 게시물은 댓글이 없는 경우도 있으므로 LEFT(OUTER)JOIN을 통해서 처리해야 합니다.

JPQLQuery의 leftjoin()을 이용할때는 on()으로 조인조건을 지정합니다. 이후 게시물당 처리가 필요하므로 groupby()를 적용합니다.

 

JPA에선 Projection이라고해서 JPQL의 결과를 바로 DTO로 처리하는 기능을 제공합니다. Querydsl도 이런 기능을 제공합니다.

목록화면에서 필요한 쿼리의 결과를 Projection.bean()이라는것을 이용해서 한번에 DTO로 처리할 수 있는데, 이를 이용하려면

JPQLQuery객체의 select()를 이용합니다. BoardSearchImpl의 searchWithReplyCount()를 다음과 같이 구현합니다.

@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));
    
    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);
    
    List<BoardListReplyCountDTO> dtoList = dtoQuery.fetch();
    
    long count = dtoQuery.fetchCount();

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

 

게시물 목록화면 처리

우선 데이터를 가져오는 타입이 BoardDTO가 아닌 BoardListReplyCountDTO가 되었으므로 BoardService, BoardServiceImpl에는

listWithReplyCount()메소드를 추가하고 구현합니다.

// 댓글의 숫자까지 처리
PageResponseDTO<BoardListReplyCountDTO> listWithReplyCount(PageRequestDTO pageRequestDTO);
@Override
public PageResponseDTO<BoardListReplyCountDTO> listWithReplyCount(PageRequestDTO pageRequestDTO) {
    
    String[] types = pageRequestDTO.getTypes();
    String keyword = pageRequestDTO.getKeyword();
    Pageable pageable = pageRequestDTO.getPageable();
    
    Page<BoardListReplyCountDTO> result = boardRepository.searchWithReplyCount(types, keyword, pageable);
    
    return PageResponseDTO.<BoardListReplyCountDTO>withAll()
            .pageRequestDTO(pageRequestDTO)
            .dtoList(result.getContent())
            .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);

    model.addAttribute("responseDTO", responseDTO);
}

마지막으로 화면을 처리하는 list.html에는 replyCount라는 속성을 출력하도록 수정합니다.

 


2. 댓글의 서비스, 컨트롤러 계층의 구현

댓글의 엔티티 처리가 끝났다면 서비스 계층을 구현하도록 합니다.

service패키지에 ReplyService, ReplyServiceImpl을 추가합니다.(댓글 등록/조회/수정/삭제/목록 기능을 추가)

댓글을 수정하는 경우에는 replyText만을 수정할 수 있으므로 Reply객체를 조금 수정합니다.

public class Reply  extends BaseEntity {

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

}
public interface ReplyService {

    long register(ReplyDTO replyDTO);
    
    ReplyDTO read(long rno);
    
    void modify(ReplyDTO replyDTO);
    
    void remove(long rno);
}
@Service
@RequiredArgsConstructor
@Log4j2
public class ReplyServiceImpl implements ReplyService{

    private final ReplyRepository replyRepository;
    private final ModelMapper modelMapper;

    @Override
    public long register(ReplyDTO replyDTO) {
        Reply reply = modelMapper.map(replyDTO, Reply.class);
        long rno = replyRepository.save(reply).getRno();
        return rno;
    }

    @Override
    public ReplyDTO read(long rno) {
        Optional<Reply> replyOptional = replyRepository.findById(rno);
        Reply reply = replyOptional.orElseThrow();
        return modelMapper.map(reply, ReplyDTO.class);
    }

    @Override
    public void modify(ReplyDTO replyDTO) {
        Optional<Reply> replyOptional = replyRepository.findById(replyDTO.getRno());
        Reply reply = replyOptional.orElseThrow();
        reply.changeText(replyDTO.getReplyText()); // 댓글의 내용만 수정가능
        replyRepository.save(reply);

    }

    @Override
    public void remove(long rno) {
        replyRepository.deleteById(rno);

    }
}

 

특정 게시물의 댓글 목록 처리

댓글서비스의 가장 중요한 기능은 특정 게시물의 댓글목록을 페이징처리하는 것입니다.

ReplyService에 getListOfBoard()를 추가합니다.

PageResponseDTO<ReplyDTO> getListOfBoard(long bno, PageRequestDTO pageRequestDTO);
@Override
public PageResponseDTO<ReplyDTO> getListOfBoard(long bno, PageRequestDTO pageRequestDTO) {
    Pageable pageable = PageRequest.of(pageRequestDTO.getPage() <=0? 0:pageRequestDTO.getPage() -1, pageRequestDTO.getSize(), Sort.by("rno").ascending());
    Page<Reply> result = replyRepository.listOfBoard(bno, pageable);
    List<ReplyDTO> dtoList = result.getContent().stream().map(reply -> modelMapper.map(reply, ReplyDTO.class)).collect(Collectors.toList());

    return PageResponseDTO.<ReplyDTO>withAll()
            .pageRequestDTO(pageRequestDTO)
            .dtoList(dtoList)
            .total((int)result.getTotalElements())
            .build();
}

 

컨트롤러 계층 구현

컨트롤러 영역에서는 SwaggerUI를 이용해서 테스트와 함께 필요한 기능들을 개발합니다.

@RestController
@RequestMapping("/replies")
@Log4j2
@RequiredArgsConstructor // 의존성 주입을 위한
public class ReplyController {

    private final ReplyService replyService;
    
    ...
}

ReplyController에 이미 개발된 등록기능을 JSON처리를 위해서 다음과 같이 수정합니다.

@ApiOperation(value = "Replies POST", notes = "POST 방식으로 댓글 등록")
@PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String,Long> register(@Valid @RequestBody ReplyDTO replyDTO, BindingResult bindingResult) throws BindException {

    log.info(replyDTO);

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

    Map<String, Long> resultMap = new HashMap<>();

    long rno = replyService.register(replyDTO);

    resultMap.put("rno", rno);

    return resultMap;
}

Swagger UI를 통해서 테스트를 진행합니다. 잘못되는 상황에 대한 처리를 확인해 보겠습니다.

@Valid는 이미 처리를 했지만 연관관계를 가진 엔티티를 처리할때마다 항상 문제가 되는것은 연관된 객체의 안전성을 확보하는것입니다.

bno값을 없는값으로 넣고 실행하면 다음과 같은 에러가 발생합니다.

서버에 기록된 로그를 보면 SQL Exception이긴 하지만,

org.springframework.dao.DataIntegrityViolationException예외가 발생한것을 볼수있습니다.(SQL만 보면 없는 PK값을 사용해서)

서버의 상태코드는 500으로 '서버내부의 오류'로 처리됩니다. 외부에서 Ajax로 댓글등록기능을 호출했을때 500에러가 발생한다면

호출한 측에서는 현재서버의 문제라고 생각할 것이고 전송하는 데이터에 문제가 있다고 생각하지는 않을것입니다.

클라이언트에 서버의 문제가 아니라 데이터의 문제가 있다고 전송하기 위해서는 @RestController를 이용하는 CustomRestAdvice에

DataIntegrityViolationException를 만들어서 사용자에세 예외 메시지를 전송하도록 구성합니다.

@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleFKException(Exception e) {

    log.error(e);

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

    errorMap.put("time", "" + System.currentTimeMillis() );
    errorMap.put("msg", "constraint falis");
    return ResponseEntity.badRequest().body(errorMap);
}

DataIntegrityViolationException이 발생하면 'constraint fails'메시지를 클라이언트로 전송합니다.

프로젝트를 재실행한 후 잘못된 게시물번호가 전달되면 이전과 달리 400상태코드와 메시지가 전송됩니다.

 

특정 게시물의 댓글등록

스프링에서는 @PathVariable을 이용해서 호출하는 경로의 값을 직접 파라미터의 변수로 처리할 수 있습니다.

ReplyController에 다음과 같은 형태로 메소드를 추가합니다.

@ApiOperation(value = "Replies of Board", notes = "Get 방식으로 특정 게시물의 댓글 목록")
@GetMapping(value = "/list/{bno}")
public PageResponseDTO<ReplyDTO> getList(@PathVariable("bno") long bno, PageRequestDTO pageRequestDTO) {
    
    PageResponseDTO<ReplyDTO> responseDTO = replyService.getListOfBoard(bno, pageRequestDTO);
    
    return responseDTO;
}

 

특정 댓글 조회

@ApiOperation(value = "Read Reply", notes = "Get 방식으로 특정 댓글 조회")
@GetMapping(value = "/{rno}")
public ReplyDTO getReplyDTO(@PathVariable("rno") long rno) {
    
    ReplyDTO replyDTO = replyService.read(rno);
    
    return replyDTO;
}

특정한 번호를 이용해서 조회할때 문제가 되는 부분은 해당 데이터가 존재하지 않는 경우입니다.

이런경우에는 500에러가 발생합니다. 서비스계층에서 조회시에 Optional<T>를 이용했고 orElseThrow()를 이용했기 때문에

컨트롤러에세 예외가 전달되고 다음과 같은 에러가 발생합니다.

 

이를 해결하기 위해서 CustomRestAdvice를 이용해서 예외처리를 추가해 주도록 합니다.

@ExceptionHandler(NoSuchElementException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleNoSuchElement(Exception e) {
    
    log.error(e);
    
    Map<String, String> errorMap = new HashMap<>();
    
    errorMap.put("time", ""+System.currentTimeMillis());
    errorMap.put("msg", "No Such Element Exception");
    
    return ResponseEntity.badRequest().body(errorMap);
}

 

특정댓글 삭제

일반적으로 REST방식에서 삭제 작업은 GET/POST가 아닌 DELETE방식을 이용해서 처리합니다.

@ApiOperation(value = "Remove Reply", notes = "DELETE 방식으로 특정 댓글 삭제")
@DeleteMapping(value = "/{rno}")
public Map<String, Long> remove(@PathVariable("rno") long rno) {
    
    replyService.remove(rno);
    
    Map<String, Long> resultMap = new HashMap<>();
    
    resultMap.put("rno", rno);
    
    return resultMap;
}

존재하지 않는 번호의 댓글을 삭제할 경우 발생하는 예외처리를 handleNoSuchElement메소드에 추가합니다.

@ExceptionHandler({NoSuchElementException.class, EmptyResultDataAccessException.class}) // 추가
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleNoSuchElement(Exception e) {

    log.error(e);

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

    errorMap.put("time", ""+System.currentTimeMillis());
    errorMap.put("msg", "No Such Element Exception");

    return ResponseEntity.badRequest().body(errorMap);
}

 

특정 댓글 수정

댓글 수정은 PUT방식으로 처리하도록 합니다.

@ApiOperation(value = "Modify Reply", notes = "PUT 방식으로 특정 댓글 수정")
@PutMapping(value = "/{rno}", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Long> modify(@PathVariable("rno") long rno, @RequestBody ReplyDTO replyDTO) {
    
    replyDTO.setRno(rno); // 번호를 일치시킴
    
    replyService.modify(replyDTO);
    
    Map<String, Long> resultMap = new HashMap<>();
    
    resultMap.put("rno", rno);
    
    return resultMap;
}

 


3. 댓글의 자바스크립트 처리

REST방식의 서비스는 브라우저에서 Ajax를 이용해서 처리됩니다. GET/POST/PUT/DELETE방식으로 ReplyController를 호출하고

JSON문자열을 처리하도록 하겠습니다.

 

비동기처리와 Axios

비동기방식의 핵심은 '통보'에 있습니다. 비동기는 여러작업을 처리하기 때문에 나중에 결과가 나오면 이를 '통보'해주는 방식입니다.

이러한 방식을 전문용어로는 '콜백'이라고 합니다. 비동기방식에서 콜백을 이용하는것이 해결책이 되긴 하지만 동기화된 코드에 익숙한

개발자들에게는 조금만 단계가 많아져도 복잡한 코드를 만들어야하는 불편함이 있습니다. 자바스크립트에서는 Promise라는 개념을

도입해서 '비동기 호출을 동기화된 방식'으로 작성할 수 있는 문법적인 장치를 만들어 주었는데 Axios는 이를 활용하는 라이브러리입니다.

Axios를 이용하면 Ajax를 호출하는 코드를 작성할때 마치 동기화된 방식처럼 작성할 수 있어서 자바스크립트 기반으로 하는 프레임워크나

(Angular) 라이브러리들(React, Vue)에서 많이 사용되고 있습니다.

 

Axios를 위한 준비

Axios를 활용해 Ajax를 이용하기위해서는 댓글처리가 필요한 화면에 Axios라이브러리를 추가해 주어야 합니다.

자바스크립트코드의 경우 read.html에서는 주로 이벤트관련 부분을 처리하도록 하고, 별도의 JS파일을 작성해서

Axios를 이용하는 통신을 처리하도록 구성해 봅니다.

static > js폴더에 reply.js파일을 추가합니다. 별도의 내용이 없이 파일만 추가합니다.

read.html의 <div layout:fragment="content">가 끝나기전에 Axios라이브러리를 다음과 같이 추가하고 reply.js파일도 추가합니다.

    </div><!--end row-->

    <div class="row mt-3">
        <div class="col-md-12">
            <div class="my-4">
                <button class="btn btn-info addReplyBtn">댓글달기</button>
            </div>
            <ul class="list-group replyList">
            </ul>
        </div>
    </div>
    <div class="row mt-3">
        <div class="col">
            <ul class="pagination replyPaging">
            </ul>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/js/reply.js"></script>
</div><!--end content-->

<script layout:fragment="script" th:inline="javascript">

</script>

 

Axios 호출하기

Axios를 이용할때 async/await를 같이 이용하면 비동기처리를 동기화된 코드처럼 작성할 수 있습니다.

async는 함수선언시에 사용하는데 해당함수가 비동기처리를 위한 함수라는것을 명시하기위해 사용하고,

await는 함수내에서 비동기 호출하는 부분에 사용합니다.

 

비동기 처리할 때는 어떻게 사용해서 일관성 있게 처리할 것인지를 결정해야합니다.

▶ 비동기함수에서는 순순하게 비동기 통신만 처리하고 호출한쪽에 then()이나 catch()등을 이용해서 처리하는 방식

▶ 비동기함수를 호출할때 나중에 처리해야하는 내용을 같이 별도의 함수로 구성해서 파라미터로 전송하는 방식

 

이번에는 비동기통신은 reply.js가 담당하고 화면은 read.html에서 처리하도록 구성해보겠습니다.

reply.js는 Axios를 이용해서 Ajax 통신하므로 코드는 짧지만 통신하는 영역과 이벤트/화면처리영역을 분리하기 위해서 사용합니다.

이러한 방식의 개발은 Vue나 React에서도 많이 사용되는 방식입니다.

 

댓글 목록 처리

reply.js에 getList(), 파라미터는 bno(게시물번호), page(페이지번호), size(페이지당사이즈), goLast(마지막페이지 호출여부)

댓글의 경우 한 페이지에서 모든 동작이 이루어지므로 새로운 댓글이 등록되어도 화면에는 아무런 변화가 없습니다.

또한 페이징처리가 되면 새로 등록된 댓글이 마지막 페이지에 있기 때문에 댓글결과를 볼 수 없다는 문제가 생깁니다.

이때 goLast변수를 이용해 강제적으로 마지막 댓글 페이지를 호출하도록 합니다.

reply.js

async function getList({bno, page, size, goLast}) {
    
    const result = await axios.get(`/replies/list/${bno}`, {params: {page, size}})

    return result.data
}

read.html

<script layout:fragment="script" th:inline="javascript">

    const bno = [[${dto.bno}]]
    
    const replyList = document.querySelector('.replyList') // 댓글 목록 DOM
    const replyPaging = document.querySelector('.replyPaging') // 페이지 목록 DOM
    
    function printList(dtoList) {   // 댓글 목록 출력
        let str = '';
        if (dtoList && dtoList.length > 0) {
            for (const dto of dtoList) {
                str += `<li class="list-group-item d-flex replyItem">
                        <span class="col-2">${dto.rno}</span>
                        <span class="col-6" data-rno="${dto.rno}">${dto.replyText}</span>
                        <span class="col-2">${dto.replyer}</span>
                        <span class="col-2">${dto.regDate}</span>
                        </li>`
            }
        }
        replyList.innerHTML = str
    }
    
    function printPages(data) {  // 페이지 목록 출력
        
        // pagination
        let pageStr = '';
        
        if (data.prev) pageStr += `<li class="page-item"><a class="page-link" data-page="${data.start -1}">이전</a></li>`
        
        for (let i = data.start; i <= data.end; i++) {
            pageStr += `<li class="page-item ${i == data.page?"active":""}"><a class="page-link" data-page="${i}">${i}</a></li>`
        }
        
        if (data.next) {
            pageStr += `<li class="page-item"><a class="page-link" data-page="${data.end +1}">다음</a></li>`
        }
        replyPaging.innerHTML = pageStr
    }

    function printReplies(page, size, goLast) {
        getList({bno, page, size, goLast}).then(
            data => {
                printList(data.dtoList) // 목록처리
                printPages(data)    // 페이지처리
            }
        ).catch(e => {
            console.error(e)
        })
    }

    printReplies(1, 10)

</script>

등록시간이 배열로 처리되어서 보기 좋지않습니다. @JsonFormat을 이용해서 포맷팅을 해줍니다.

또한 댓글수정시간의 경우 출력할 일이 없으므로 @JsonIgnore로 제외해줍니다.

...
public class ReplyDTO {

    ...

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

 

마지막 페이지로 이동처리

댓글페이징은 새로 댓글이 추가되는 상황이 발생하면 마지막으로 등록되기 때문에 확인이 어렵다는 문제가 있습니다.

따라서 이를 처리하려면 댓글목록 데이터의 total을 이용해서 다시 마지막페이지를 호출해야할 필요가 있습니다.

(실제 서비스에서는 댓글의 개수를 50, 100개씩 처리해서 다시 호출해야하는 경우의 수를 줄입니다.)

마지막 페이지의 호출은 total과 size를 이용해서 마지막 페이지를 계산하고 다시 Axios로 호출하는 방식입니다.

이를위해서 reply.js의 getList()를 다음과 같이 수정합니다.

async function getList({bno, page, size, goLast}) {

    const result = await axios.get(`/replies/list/${bno}`, {params: {page, size}})
    
    if (goLast) {
        const total = result.data.total
        const lastPage = parseInt(Math.ceil(total/size))
        return getList({bno:bno, page:lastPage, size:size})
    }

    return result.data
}

read.html에서 처음부터 댓글의 마지막페이지를 보고싶다면 printReplies()를 호출할때 true값을 하나 더 추가하면 됩니다.

printReplies(1, 10, true)

 

댓글등록

댓글추가는 모달창을 이용해서 처리하겠습니다. 우선은 reply.js에 댓글등록 기능을 추가합니다.

async function addReply(replyObj) {
    const response = await axios.post(`/replies/`, replyObj)
    return response.data
}

read.html에 모달창을 추가합니다.

<div class="modal registerModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">댓글 등록</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                </div>
                <div class="modal-body">
                    <div class="input-group mb-3">
                        <span class="input-group-text">댓글 내용</span>
                        <input type="text" class="form-control replyText">
                    </div>
                    <div class="input-group mb-3">
                        <span class="input-group-text">작성자</span>
                        <input type="text" class="form-control replyer">
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-primary registerBtn">등록</button>
                        <button type="button" class="btn btn-outline-dark closeRegisterBtn">닫기</button>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/js/reply.js"></script>
</div><!--end content-->
// 댓글 등록 모달
const registerModal = new bootstrap.Modal(document.querySelector(".registerModal"))

// registerModel
const replyText = document.querySelector(".replyText")
const replyer = document.querySelector(".replyer")
const registerBtn = document.querySelector(".registerBtn")
const closeRegisterBtn = document.querySelector(".closeRegisterBtn")

document.querySelector(".addReplyBtn").addEventListener("click", function (e){
    registerModal.show()
}, false)

document.querySelector(".closeRegisterBtn").addEventListener("click", function (e) {
    registerModal.hide()
}, false)

registerBtn.addEventListener("click", function (e){
    const replyObj = {
        bno:bno,
        replyText: replyText.value,
        replyer: replyer.value
    }
    addReply(replyObj).then(result => {
        alert(result.rno)
        registerModal.hide()
        replyText.value = ''
        replyer.value = ''
        printReplies(1, 10, true) // 댓글 목록 갱신
    }).catch(e => {
        alert("Exception... " + e)
    })
}, false)

 

댓글 페이지번호 클릭

페이지번호는 매번 새로이 번호를 구성하므로 이벤트를 처리할때는 <ul>을 대상으로 이벤트리스너를 이용하도록 합니다.

read.html에 다음과 코드를 추가합니다.

// 댓글 페이자번호
let page = 1
let size = 10

replyPaging.addEventListener("click", function (e){
    e.preventDefault()
    e.stopPropagation()
    
    const target = e.target
    
    if (!target || target.tagName != 'A') return
    
    const pageNum = target.getAttribute("data-page")
    page = pageNum
    printReplies(page, size)
})

 

댓글조회와 수정

async function getReply(rno) {
    const response = await axios.get(`/replies/${rno}`)
    return response.data
}

async function modifyReply(replyObj) {
    const response = await axios.put(`/replies/${replyObj.rno}`, replyObj)
    return respnse.data
}
<div class="modal modifyModal" tabindex="-1">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title replyHeader"></h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            <div class="modal-body">
                <div class="input-group mb-3">
                    <span class="input-group-text">댓글 내용</span>
                    <input type="text" class="form-control modifyText">
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-info modifyBtn">수정</button>
                    <button type="button" class="btn btn-danger removeBtn">삭제</button>
                    <button type="button" class="btn btn-outline-dark closeModifyBtn">닫기</button>
                </div>
            </div>
        </div>
    </div>
</div><!--end modifyModal-->
// 댓글 수정 모델
const modifyModal = new bootstrap.Modal(document.querySelector(".modifyModal"))
const replyHeader = document.querySelector(".replyHeader")
const modifyText = document.querySelector(".modifyText")
const modifyBtn = document.querySelector(".modifyBtn")
const removeBtn = document.querySelector(".removeBtn")
const closeModifyBtn = document.querySelector(".closeModifyBtn")
...
// 댓글 수정 이벤트
    replyList.addEventListener("click", function (e) {
        e.preventDefault()
        e.stopPropagation()

        const target = e.target

        if (!target || target.tagNam != 'SPAN') return

        const rno = target.getAttribute("data-rno")

        if (!rno) return

        getReply(rno).then(reply => {

            console.log(reply)
            replyHeader.innerHTML = reply.rno
            modifyText.value = reply.replyText
            modifyModal.show()
        }).catch(e => alert('error'))
    }, false)

댓글 수정과 화면갱신

댓글의 수정 후 처리부분이 조금 신경쓰입니다. 수정된 댓글은 결국 목록에서 확인하게 되기 때문에 만일 사용자가 댓글을 수정하는 사이에 많은 댓글이 추가되면 확인할 방법이 없습니다. 현실적으로 타협하자면 최대한 자신이 보고있었던 페이지를 유지하는 수준으로 구현합니다.

modifyBtn.addEventListener("click", function (e) {
    const replyObj = {
        bno:bno,
        rno:replyHeader.innerHTML,
        replyText:modifyText.value
    }
    modifyReply(replyObj).then(result => {
        alert(result.rno + ' 댓글이 수정되었습니다.')
        replyText.value = ''
        modifyModal.hide()
        printReplies(page, size)
    }).catch(e => {
        console.log(e)
    })
}, false)

closeModifyBtn.addEventListener("click", function(e){
    modifyModal.hide()
}, false)

댓글삭제

async function removeReply(rno) {
    const response = await axios.delete(`/replies/${rno}`)
    return response.data
}
removeBtn.addEventListener("click", function (e){
    removeReply(replyHeader.innerHTML).then(result => {
        alert(result.rno + ` 댓글이 삭제되었습니다.`)
        replyText.value = ''
        modifyModal.hide()
        
        page = 1    // 이부분이 없다면 원래 페이지로
        
        printReplies(page, size)
    }).catch(e => {
        console.log(e)
    })
}, false)

 

반응형