
@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/");
}
}
| URI | Method | 설명 | 반환 데이터 |
|---|---|---|---|
| /replies | POST | 특정 게시물의 댓글 추가 | { 'rno' : 11 } - 생성된 댓글의 번호 |
| /replies/list/:bno | GET | 특정 게시물(bno)의 댓글 목록 '?' 뒤에 페이지 번호를 추가해서 댓글 페이징 처리 | PageResponseDTO를 JSON으로 처리 |
| /replies/:rno | PUT | 특정 번호의 댓글 수정 | { 'rno' : 11 } - 수정된 댓글의 번호 |
| /replies/:rno | DELETE | 특정한 번호의 댓글 삭제 | { 'rno' : 11 } - 삭제된 댓글의 번호 |
| /replies/:rno | GET | 특정한 번호의 댓글 조회 | 댓글 객체를 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 구성
@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);
}
}
결과

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 메서드에서는 해당 예외를 처리하고, 클라이언트에게 적절한 응답을 생성하여 반환합니다.
JPA의 연관 관계의 판단 기준

⇒ 여기서는 기본적으로 단방향으로 참조 관계를 유지하면서 다른 엔티티를 사용해야 할 때는 JPQL을 통한 조인 처리를 이용
3-1. 연관 관계 구성 시 주의 사항
이해를 돕기 위한 예시
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 속성을 사용하여 무한 루프를 방지하고 필요한 정보만을 출력할 수 있도록 하는 것이 좋음
3-2. @ManyToOne
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) : 즉시로딩-해당 엔티티를 로딩할 때 같이 로딩하는 방식(성능에 영향을 줄 수 있으므로 필요에 따라서 고려할 것)
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);
}
}
결과

목록 화면에는 특정한 게시물에 속한 댓글의 숫자를 같이 출력해 주어야 하기 때문에 기존의 코드를 사용할 수 없음
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;
}
⇒ 목록화면에서 필요한 쿼리의 결과를 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 메서드
@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>