1.  컨트롤러 계층 구현

컨트롤러 영역에서는 Swagger UI를 이용해서 테스트와 함께 필요한 기능을 개발
ReplyController는 ReplyService를 주입 받도록 설계


  
@RestController
@RequestMapping("/api/replies")
@Log4j2
@RequiredArgsConstructor
public class ReplyController {
private final ReplyService replyService;

 


1)  등록 기능 확인

  👩🏻‍🚀  ReplyController의 등록 기능은 이미 개발된 코드에 JSON 처리를 위해서 추가 코드가 필요


  
@Operation(summary = "Replies Post", description = "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> map = new HashMap<>();
Long rno = replyService.register(replyDTO);
map.put("rno", rno);
return map;
}

 

  ✓  프로젝트를 실행하고 Swagger UI를 통해서 테스트를 진행. 등록 작업에서 주의할 점은 bno가 존재하는 게시물 번호여야 함

  ✓  정상적으로 동작하면 {"rno": 6}과 같은 결과가 전송되는 것을 확인

 

에러에 대한 처리

 

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

        ➡️  예를 들어 앞선 테스트에서 bno 값을 사용할 수 없는 번호로 작성하면  다음과 같은 문제가 발생

[(conn=1337) Cannot add or update a child row: a foreign key constraint fails (`boot_ex_app_01_2405`.`reply`, CONSTRAINT `FKr1bmblqir7dalmh47ngwo7mcs` FOREIGN KEY (`board_bno`) REFERENCES `board` (`bno`))]

 

  ⚡️  서버에 기록된 로그를 보면 SQL Exception이긴 하지만, org.springframework.dao.DataIntegrityViolationException 예외가 발생. 예외가 발생한다는 것은 분명 정상적인 결과이지만 서버의 상태 코드는 500으로 '서버 내부의 오류'로 처리

  ⚡️  외부에서 Ajax로 댓글 등록 기능을 호출했을 때 500 에러가 발생한다면 호출한 측에서는 현재 서버의 문제라고 생각할 것

         ➡️  클라이언트에 서버의 문제가 아니라 데이터의 문제가 있다고 전송하기 위해서는 @RestControllerAdvice를 이용하는 CustomRestAdvice에 DataIntegrityViolationException를 만들어서 사용자에게 예외 메시지를 전송하도록 구성

 


  
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleFKException(Exception ex) {
log.error(ex);
Map<String, String> errorsMap = new HashMap<>();
errorsMap.put("time", "" + System.currentTimeMillis());
errorsMap.put("msg", "constraint fails");
return ResponseEntity.badRequest().body(errorsMap);
}

 

  ✓  추가한 handlerFKException()는 DataIntegrityViolationException이 발생하면 "constraint fails" 메시지를 클라이언트로 전송

 


2)  특정 게시물의 댓글 목록

  👩🏻‍🚀  특정한 게시물의 댓글 목록 처리는 '/api/replies/list/{bno}' 경로를 이용하도록 구성. 이때 bno는 게시물의 번호를 의미.
  👩🏻‍🚀  스프링에서는 @PathVariable이라는 어노테이션을 이용해서 호출하는 경로의 값을 직접 파라미터의 변수로 처리할 수 있는 방법을 제공

ReplyController에 메서드 추가

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

 

   ✓  getList()에서 bno 값은 경로에 있는 값을 취해서 사용할 것이므로 @PathVariable을 이용하고, 페이지와 관련된 정보는 일반 쿼리 스트링을 이용
  ✓  Swagger UI로 ReplyController를 호출해 보면 PageResponseDTO가 JSON으로 처리된 결과를 볼 수 있음

 


3)  특정 댓글 조회

  👩🏻‍🚀  특정한 댓글을 조회할 때는 Reply의 rno를 경로로 이용해서 GET 방식으로 처리

ReplyController에 getReplyDTO() 메서드를 추가

  
@Operation(summary = "Read Reply", description = "GET 방식으로 특정 댓글 조회")
@GetMapping(value = "/{rno}")
public ReplyDTO getReplyDTO(@PathVariable("rno") Long rno) {
ReplyDTO replyDTO = replyService.read(rno);
return replyDTO;
}

 

  ✓ 정상적인 rno 값이 전달되면 ReplyDTO가 JSON으로 처리

 

데이터가 존재하지 않는 경우의 처리

 

  📍  getReplyDTO()와 같이 특정한 번호를 이용해서 조회할 때 문제가 되는 부분은 해당 데이터가 존재하지 않는 경우
  📍  'NoSuchElementException' 예외 전송을 위해 CustomRestAdvice에 기능을 추가


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


4)  특정 댓글 삭제

  👩🏻‍🚀  일반적으로 REST 방식에서 삭제 작업은 GET / POST 가 아닌 DELETE 방식을 이용해서 처리

ReplyController에 remove() 메서드를 추가

  
@Operation(summary = "Delete Reply", description = "DELETE 방식으로 특정 댓글 삭제")
@DeleteMapping(value = "/{rno}")
public Map<String, Long> remove(@PathVariable("rno") Long rno) {
replyService.remove(rno);
Map<String, Long> map = new HashMap<>();
map.put("rno", rno);
return map;
}

 

존재하지 않는 번호의 삭제 예외

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

 


5)  특정 댓글 수정

  👩🏻‍🚀  댓글 수정은 PUT방식으로 처리

ReplyController에 modify() 메서드를 추가

 

  📍  주의할 점은 수정할 때도 등록과 마찬가지로 JSON 문자열이 전송되므로 이를 처리하도록 @RequestBody를 적용한다는 점


  
@Operation(summary = "Modify Reply", description = "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> map = new HashMap<>();
map.put("rno", rno);
return map;
}

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  댓글 서비스 계층의 구현

댓글의 엔티티 처리가 끝났다면 서비스 계층을 구현
Service 패키지에 ReplyService 인터페이스와 ReplyServiceImpl 클래스를 추가

 

1)  댓글 등록 처리

  👩🏻‍💻  댓글 등록은 게시물과 비슷하게 구현. ReplyService 인터페이스에 메서드를 정의


  
public interface ReplyService {
Long register(ReplyDTO replyDTO);
}

 

  👩🏻‍💻  ReplyServiceImpl은 ReplyRepositoy와 ModelMapper를 주입받아서 구현


  
@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;
}
}

 

 

댓글 등록 테스트


  📍  test 폴더의 service 패키지에 ReplyServiceTests 클래스를 추가해서 ReplyService 기능들의 동작에 문제가 없는지 확인


  
@SpringBootTest
@Log4j2
class ReplyServiceImplTest {
@Autowired
private ReplyService replyService;
@Test
public void registerReply() {
log.info(replyService.getClass().getName());
ReplyDTO replyDTO = ReplyDTO.builder()
.bno(90L)
.replyText("test...")
.replyWriter("test...")
.build();
replyService.register(replyDTO);
}
}

 

  ✓  실행 결과에 정상적으로 SQL이 동작하고 새로운 댓글이 insert되는지 확인. 실행 로그에는 새로운 댓글의 rno 값이 출력

 

 


2)  댓글 조회 / 수정 / 삭제 / 목록

  👩🏻‍💻  댓글을 수정하는 경우에는 Reply 객체에서 replyText만을 수정할 수 있으므로 Reply를 수정


  
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 replyWriter;
public void changeText(String text) {
this.replyText = text;
}
}

 

  👩🏻‍💻  ReplyService 인터페이스에 CRUD 기능들을 선언


  
public interface ReplyService {
Long register(ReplyDTO replyDTO);
ReplyDTO read(Long rno);
void modify(ReplyDTO replyDTO);
void remove(Long rno);
}

 

  👩🏻‍💻  ReplyServiceImpl에서 register()를 제외한 기능들은 다음과 같이 구현


  
@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);
}

 


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

  👩🏻‍💻  댓글 서비스의 가장 중요한 기능은 특정한 게시물의 댓글 목록을 페이징 처리한 것

ReplyService에 getListOfBoard()를 추가

  
PageResponseDTO<ReplyDTO> getListOfBoard(Long bno, PageRequestDTO pageRequestDTO);

 

  👩🏻‍💻  ReplyServiceImpl에서는 PageRequestDTO를 이용해서 페이지 관련 정보를 처리하고 ReplyRepository를 통해서 특정 게시물에 속하는 Page<Reply>를 구함. 실제 반환되어야 하는 타입은 Reply가 아니라 ReplyDTO 타입이므로 ReplyServiceImpl에서는 이를 변환하는 작업이 필요


  
@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> replies = replyRepository.listOfBoard(bno, pageable);
List<ReplyDTO> replyDTOList = replies.getContent().stream().map(reply ->
modelMapper.map(reply, ReplyDTO.class)).collect(Collectors.toList());
return PageResponseDTO.<ReplyDTO>withAll()
.pageRequestDTO(pageRequestDTO)
.dtoList(replyDTOList)
.total((int)replies.getTotalElements())
.build();
}

 

 

 

 

[ 내용 참고 : IT 학원 강의 ] 


1.  연관관계를 결정하는 방법

데이터베이스에서는 PK와 FK를 이용해서 엔티티 간의 관계를 표현.
  ⚡️  데이터베이스의 테이블을 설계하는 경우 PK를 가진 테이블을 먼저 설계하고, 이를 FK로 사용하는 테이블을 설계하는 방식이 일반적

반면에 객체지향을 이용하는 JPA에서는 key가 아니라 객체를 참조
  ⚡️  예를 들어 '회원'이 여러 개의 '아이템'을 가지고 있다고 가정하면 '회원 객체가 아이템들을 참조할 것인지, 아이템이 회원을 참조하게 될 것인지' 판단을 해야 함.

  ⚡️  만일 reply 테이블에서 board 테이블을 참조하는 bno키를 엔티티 객체에서 구현하는 경우 Long bno 라고 설정을 하면 JPA에서는 board의 프라이머리키를 참조하는 FK라고 인지 못하고, 일반 컬럼 bno 라고 판단

 

JPA의 연관 관계의 판단 기준을 결정할 때 기준
  • 연관 관계의 기준은 항상 변화가 많은 쪽을 기준으로 결정
  • ERD의 FK를 기준으로 결정

 



1)  변화가 많은 쪽을 기준

  🚀  판단 기준은 '조금 더 자주 변화가 있는 쪽'을 선택하는 것이 편리
          ➡️ 예를 들어 '회원'과 '게시물'의 관계를 보면 회원들의 활동을 통해서 여러 개의 게시물이 만들어지므로 연관 관계의 핵심은 '게시물'로 판단하는 것이 편리

ERD 상에서 FK를 기준


  ✓  간혹 연관 관계의 주어를 결정하기 어려울 때는 엔티티 관계 다이어그램 ERD를 그려서 확인하는 것이 좋음
  ✓  ERD에서 JPA의 연관 관계를 읽을 때는 FK를 판단해서 읽는 것이 하나의 해결책이 될 수 있음



2)  단방향과 양방향

  🚀  객체지향이 관계형 데이터베이스와 다른 점 중의 하나는 객체가 다른 객체를 참조하는 방식의 차이가 있음
  🚀  데이터베이스에는 특정한 PK를 다른 테이블에서 FK로 참조해서 사용할 수 있지만, 객체지향에서는 'A가 B의 참조를 가질 수 있고, B가 A의 참조를 가질수'있다는 점이 다름
  🚀  JPA는 참조를 결정할 때 다양한 방식이 존재할 수 있음
       ➡️  A가 B를 참조하거나 B가 A를 참조라는 방식으로 한쪽만 참조를 유지하는 방식을 단방향 unidirection

       ➡️  양쪽 모두를 참조하는 방식을 양방향 bidirection

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

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

 


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

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

 

domain 패키지에 댓글의 엔티티 클래스를 추가

  
@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 replyWriter;
}

 

  ✓  Reply 클래스에는 Board 타입의 객체 참조를 하는 변수를 이용해서 참조하는데 이때 @ManyToOne을 이용해서 '다대일' 관계로 구성됨을 설명

 

  📍  연관 관계를 구성할 때에는 다음과 같은 점들을 주의해서 작성

  • @ToString을 할 때 참조하는 객체를 사용하지 않도록 반드시 exclude 속성값을 지정
  • @ManyToOne과 같이 연관 관계를 나타낼 때는 반드시 fetch 속성을 LAZY로 지정


  ✓  프로젝트를 실행하면 Reply 관련 테이블이 자동으로 생성되는 것을 확인
  ✓  생성된 테이블에서는 게시물 Board과 관련된 속성으로 'board_bno'라는 컬럼이 생성되고, 자동으로 FK가 생성


1)  ReplyRepository 생성과 테스트

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

repository 패키지에 ReplyRepository를 선언

  
public interface ReplyRepository extends JpaRepository<Reply, Long> {
}

 

 

A.  테스트를 통한 insert 확인


  👾  test 폴더에는 ReplyRepositoryTest 클래스를 작성해서 작성된 ReplyRepository의 테스트 코드를 작성

  👾  insertTest() 에서 주의할 부분은 Board 객체를 생성하는 부분. JPA에서는 @Id를 이용해서 엔티티 객체들을 구분하므로 실제 존재하는 @Id 값을 가지도록 구성하는 것이 중요


  
@SpringBootTest
@Log4j2
class ReplyRepositoryTest {
@Autowired
private ReplyRepository replyRepository;
@Test
public void testInsert() {
// 실제 DB에 있는 bno
Long bno = 50L;
Board board = Board.builder().bno(bno).build();
Reply reply = Reply.builder()
.board(board)
.replyText("댓글")
.replyWriter("작성자 1")
.build();
replyRepository.save(reply);
}

 

  ✓  InsertTest()를 실행하면 SQL 문이 실행되는 것과 데이터베이스의 reply 테이블도 확인

 


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


  👾  댓글이 사용되는 방식은 주로 게시물 번호를 통해서 사용되는 경우가 많음
         ➡️  게시물의 댓글의 수나 해당 게시물의 댓글 목록 등
  👾  쿼리 조건으로 자주 사용되는 컬럼에는 인덱스를 생성해 두는 것이 좋은데 @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> listByBoard(@Param("bno") Long bno, Pageable pageable);
}

 


C.  테스트 코드와 fetch 속성


  👾  테스트 코드를 통해서 listOfBoard()의 동작을 확인


  
@Test
public void listOfBoardTest() {
// 실제 DB에 있는 bno
Long bno = 50L;
Pageable pageable = PageRequest.of(0, 10, Sort.by("rno").descending());
Page<Reply> replies = replyRepository.listByBoard(bno, pageable);
replies.getContent().forEach(log::info);
}

 

 

  👾  Reply 객체를 출력할 때 조심해야 하는 부분은 @ToString(). JPA는 기본적으로 필요한 엔티티를 최소한의 자원으로 쓰는 방식을 선택.

    ➡️  예를 들어 Reply를 출력했할 때 Board를 같이 출력하도록 한다면 reply 테이블에서 쿼리를 실행하고 board테이블에서 추가적인 쿼리를 실행하게 됨
  👾  Reply 클래스의 @ToString()에서 exclude를 제거해 보면 이런 동작을 확인할 수 있음


  
@ToString
public class Reply extends BaseEntity {

 

  ✓  이 상태에서 BoardRepliesTest() 실행하면 에러가 발생

 

💫  에러 메시지가 발생하는 상황을 생각해보면 reply 테이블에서 쿼리를 실행했지만, Board 객체를 같이 출력해야 하므로 다시 board 테이블에 쿼리를 추가로 실행해야만 하는 상황. 그래서 다시 한번 데이터베이스를 연결해야만 하는데 현재 테스트 코드는 한 번만 쿼리를 실행할 수 있기 때문에 발생하는 에러. no Session이라는 의미가 데이터베이스와 추가적인 연결이 필요해서 발생하는 문제


  👾  강제로 이를 실행하고 싶으면 테스트 코드에 @Transactional을 추가하면 가능

  👾  @Transactional을 추가한 후 테스트 코드를 실행하면 다음과 같이 reply 테이블에 쿼리가 실행되고 board 테이블에 추가 쿼리가 실행된 것을 볼 수 있음

 

 

  👾  @ManyToOne에 fetch 속성 값이 FetchType.LAZY로 지정되어 있음. LAZY 속성값은 '지연 로딩' 이라고 표현하는데 지연 로딩은 기본적으로 필요한 순간까지 데이터베이스와 연결하지 않는 방식으로 동작
  👾  FetchType.EAGER. EAGER는 '즉시 로딩'이라는 단어로 표현하는데 해당 엔티티를 로딩할 때 같이 로딩하는 방식. EAGER은 성능에 영향을 줄 수 있으므로 우선은 LAZY 값을 기본으로 사용하고 필요에 따라 EAGER를 고려하는 것이 좋음

 


D.  댓글 조회 / 수정 / 삭제

 

  👾  JPA에서 엔티티 간의 관계를 한쪽에서만 참조하는 '단방향'방식으로 구현하는 경우 장점은 관리하기 편함
          ➡️ 양방향의 경우 양쪽 객체 모두를 변경해 주어야 하기 때문에 구현할 때도 주의해야 하지만 트렌잭션을 신경 써야만 함
  👾  현재 구현하는 코드처럼 단방향으로 구현되는 경우 Board 객체는 Reply에 대해서 모르는 상태. 즉, Reply의 CRUD와 무관하기 때문에 단순하게 구현할 수 있음

 


2)  게시물 목록과 Projection

  🚀  게시물과 댓글의 관계처럼 엔티티가 조금씩 확장되면 가장 문제가 되는 부분은 목록 화면.

        ✓  기존의 목록 화면에서는 Board 객체를 BoardDTO로 변환시켜서 내용을 출력하면 충분했지만 댓글이 추가되면 상황이 달라짐
        ✓  목록 화면에서는 특정한 게시물에 속한 댓글의 숫자를 같이 출력해 주어야 하기 때문에 기존의 코드를 사용할 수가 없고 다시 추가 개발이 필요

 

dto 패키지에 BoardListReplyCountDTO 클래스를 작성

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

 

목록 처리는 Querydsl을 이용하는 구조이므로 search의 BoardSearch에 추가

  
public interface BoardSearch {
Page<Board> search1(Pageable pageable);
Page<Board> searchAll(String[] types, String keyword, Pageable pageable);
Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable);
}

 


A.  LEFT (OUTER) JOIN 처리


  👾  BoardSearchImpl에서 searchWithReplyCount() 구현에는 단방향 참조가 가지는 단점이 보이는데, 바로 필요한 정보가 하나의 엔티티를 통해서 접근할 수 없다는 점
  👾  이 문제를 해결하기 위해서 가장 쉽게 사용할 수 있는 방법은 JPQL을 이용해서 'left (outer) join'이나 'inner join'과 같은 조인 join 을 이용하는 것
  👾  게시물과 댓글의 경우 한쪽에서만 데이터가 존재하는 상황이 발생할 수 있음. 예를 들어 특정 게시물은 댓글이 없는 경우가 발생하므로 outer join 을 통해서 처리

 

코드의 일부분으로 조인 처리만을 구성

  
@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;
}

 

  ✓  JPQLQuery의 leftJoin()을 이용할 때는 on()을 이용해서 조인 조건을 지정
  ✓  조인 처리 후에 게시물당 처리가 필요하므로 groupBy()를 적용

 


B.  Projections.bean()


  👾  JPA에서는 Projection(프로젝션)이라고 해서 JPQL의 결과를 바로 DTO로 처리하는 기능을 제공. Querydsl도 마찬가지로 이러한 기능을 제공
  👾  목록 화면에서 필요한 쿼리의 결과를 Projections.bean()이라는 것을 이용해서 한번에 DTO로 처리할 수 있는데, 이를 이용하려면 JPQLQuery 객체의 select()를 이용

 

BoardSearchImpl 클래스의 searchWithReplyCount() 내부는 다음과 같이 구현

 

  ·  최종적으로 검색 조건과 applyPagination() 까지 적용


  
@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);
}// end if
// bno > 0
query.where(board.bno.gt(0L));
JPQLQuery<BoardListReplyCountDTO> dtojpqlQuery = query.select(Projections.bean(BoardListReplyCountDTO.class,
board.bno, board.title, board.writer, board.regDate, reply.count().as("replyCount")));
this.getQuerydsl().applyPagination(pageable, dtojpqlQuery);
List<BoardListReplyCountDTO> dtoList = dtojpqlQuery.fetch();
long count = dtojpqlQuery.fetchCount();
return new PageImpl<>(dtoList, pageable, count);
}

 

테스트 코드는 BoardRepositoryTests를 이용

  
@Test
public void searchReplyCountTest() {
Pageable pageable = PageRequest.of(1, 10, Sort.by("bno").descending());
Page<BoardListReplyCountDTO> boardPage = boardRepository.searchWithReplyCount(new String[]{"t", "c", "w"}, "1", pageable);
// total pages
log.info(boardPage.getTotalPages());
// page size
log.info(boardPage.getSize());
// pageNumber
log.info(boardPage.getNumber());
// prev next
log.info(boardPage.hasPrevious() + ": " + boardPage.hasNext());
boardPage.getContent().forEach(board -> log.info(board));
}

 

 


3)  게시물 목록 화면 처리

  🚀  BoardRepository와 BoardSearch(Impl)을 이용해서 BoardListReplyCountDTO 처리가 완료되었다면 이를 반영해서 댓글의 수가 반영되는 화면을 구성

  🚀  우선 데이터를 가져오는 타입이 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("bno");
Page<BoardListReplyCountDTO> result = boardRepository.searchWithReplyCount(types, keyword, pageable);
return PageResponseDTO.<BoardListReplyCountDTO>withAll()
.pageRequestDTO(pageRequestDTO)
.dtoList(result.getContent())
.total((int)result.getTotalElements())
.build();
}

 

  🚀  BoardController에서는 호출하는 메서드를 변경. 기존에 BoardService의 list()를 호출하는 대신에 listWithReplyCount()를 호출하도록 수정


  
@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에는 replyCount라는 속성을 출력하도록 수정


  
<tr th:each="dto, status:${responseDTO.dtoList}">
<th scope="col">[[${no - status.index}]]</th>
<td><a th:href="|@{/board/read(bno =${dto.bno})}&${link}|"> [[${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>

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  REST 방식의 댓글 처리 준비

📌  REST 방식의 댓글 처리는 다음과 같은 단계로 진행

  • URL 설계와 데이터 포맷 결정
  • 컨트롤러의 JSON / XML 처리
  • 동작 확인
  • 자바스크립트를 통한 화면 처리


1)  URL 설계와 DTO 설계


  ⚡️  REST 방식은 주로 XML이나 JSON 형태의 문자열을 전송하고 이를 컨트롤러에서 처리하는 방식을 이용
  ⚡️  JSON을 이용해서 DTO에 맞는 데이터를 전송하고 스프링을 이용해서 이를 DTO 처리하도록 구성

 

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

 

프로젝트 dto 패키지에 ReplyDTO를 추가

  
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ReplyDTO {
private Long rno; // 리플 고유번호
private Long bno; // 게시판의 글 고유번호
private String replyText; // 리플 내용
private String replyWriter; // 리플 작성자
private LocalDateTime regDate, modDate;
}

 

  ✓  ReplyDTO에는 고유한 rno외에도 특정한 게시물 번호 bno를 선언
  ✓  이를 통해서 현재 댓글이 특정한 게시물의 댓글임을 알 수 있음

 


 

2)  ReplyController 준비

 

  ⚡️  ReplyController는 기존과 달리 @RestController라는 어노테이션을 활용
  ⚡️ @RestController를 이용하게 되면 메서드의 모든 리턴 값은 JSP나 Thymeleaf로 전송되는 것이 아니라 바로 JSON이나 XML 등으로 처리
  ⚡️  본격적인 개발 전에 약간의 테스트를 진행 할 수 있도록 ReplyController에 POST 방식을 처리하는 메서드를 추가


  
@RestController
@RequestMapping("/api/replies")
@Log4j2
public class ReplyController {
@Operation(summary = "Replies Post", description = "POST 방식으로 댓글 등록")
@PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Long> register(@RequestBody ReplyDTO replyDTO) {
log.info(replyDTO);
Map<String, Long> map = new HashMap<>();
map.put("rno", 111L);
return map;
}
}

 

  📍 register() 메서드의 파라미터에는 ReplyDTO를 이용해서 파라미터를 수집한다고 선언되어 있지만 앞에 @RequestBody라는 어노테이션이 존재. @RequestBody는 JSON 문자열을 ReplyDTO로 변환하기 위해 표시

  📍  @PostMapping에는 consumes 라는 속성을 사용. consumes는 해당 메서드를 받아서 소비(Consume)하는 데이터가 어떤 종류인지 명시할 수 있음. 이 경우 JSON 타입의 데이터를 처리하는 메서드임을 명시

  📍  Swagger UI에서는 필요한 JSON 데이터를 미리 구성해서 사용할 수 있도록 작성할 수 있음

  • Try it out 클릭 후 필요한 데이터만 입력해서 전송
  • register() 가 실행되고 리턴 값이 JSON으로 처리됨. @RestController는 리턴 값 자체가 JSON으로 처리
  • 서버에는 정상적으로 필요한 파라미터가 수집된 것을 확인

 

postman / Swagger 비교
  • boot 프로젝트의 경우 postman에 비해 Swagger가 작성한 애플리케이션의 특성에 맞게 테스트 할 수 있고, API 문서를 자동화 할 수 있는 장점이 존재
  • 프로젝트안에 Swagger가 들어가는 것을 기피하는 경우도 있음. 예를 들어 boot3 버전 부터는 Swagger 설정이 달라져서 프로젝트를 수정해야 함. 프로젝트와 독립된 방식으로 사용할 수 있는 postman을 선호하기도 함

 

3) @Valid와 @RestControllerAdvice

 

  ⚡️  REST 방식의 컨트롤러는 대부분 Ajax와 같이 눈에 보이지 않는 방식으로 서버를 호출하고 결과를 전송하므로 에러가 발생하면 어디에서 어떤 에러가 발생했는지 알아보기 힘든 경우가 많음 * Ajax 개발의 힘든 점 중 하나가 디버깅
  ⚡️ 이런 이유로 @Vaild 과정에서 문제가 발생하면 처리할 수 있도록 @RestControllerAdvice를 작성

 

controller 패키지에 advice 패키지를 추가하고 CustomRestAdvice 클래스를 추가

  
@Log4j2
@RestControllerAdvice
public class CustomRestAdvice {
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleBindException(BindException e) {
log.error(ex);
Map<String, String> errorsMap = new HashMap<>();
if (e.hasErrors()) {
BindingResult bindingResult = e.getBindingResult();
bindingResult.getFieldErrors().forEach(fieldError -> {
errorsMap.put(fieldError.getField(), fieldError.getCode());
});
}
return ResponseEntity.badRequest().body(errorsMap);
}
}

 

  ✓  @RestControllerAdvice을 이용하면 컨트롤러에서 발생하는 예외에 대해 JSON과 같은 응답 메시지를 생성해서 보낼 수 있음
  ✓  작성한 handleBindException() 메서드는 컨트롤러에서 BindException이 던져지는 경우 이를 이용해서 JSON 메시지와 400에러 (Bad Request)를 전송

 


4)  댓글 등록 @Valid


  ⚡️  @RestControllerAdvice를 이용해서 ReplyDTO를 검증해서 register()를 처리하도록 변경

 

ReplyDTO에 검증과 관련된 어노테이션을 추가

  
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ReplyDTO {
private Long rno; // 리플 고유번호
@NotNull
private Long bno; // 게시판의 글 고유번호
@NotEmpty
private String replyText; // 리플 내용
@NotEmpty
private String replyWriter; // 리플 작성자
private LocalDateTime regDate, modDate;
}

 

실제 동작 여부를 확인하기 위해 ReplyController의 register()를 수정

  
public class ReplyController {
@Operation(summary = "Replies Post", description = "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> map = new HashMap<>();
map.put("rno", 111L);
return map;
}
}

 

  📍 register()는 다음과 같은 점들이 수정

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

 

프로젝트를 실행하고 ReplyDTO가 체크할 내용이 없는 데이터를 Swagger UI로 전송 테스트

 

  ⚡️  ReplyDTO는 bno나 replyText 등의 값이 반드시 있어야하므로 실행 결과는 다음과 같이 문제가 있는 부분만 에러 메시지를 전송받을 수 있음

 

 

✓  결과 메시지를 보면 @Valid 과정에 문제가 있는 필드들과 메시지를 JSON 문자열로 전송
✓Ajax를 이용하는 개발에는 에러의 발생 소지가 서버인 경우도 있고, 브라우저나 자바 스크립트일 때도 있기 때문에 서버에서 먼저 확실하게 문제가 없는 것을 확인하고 화면을 개발하는 것이 좋음

 

 

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  AJax와 JASON : 댓글 기능 구현 

기존 작업한 게시판에 댓글 기능을 구현
클라이언트는 Ajax(Axios 라이브러리) · 서버는 RESTful · JPA는 다대일 방식으로 구현


(1)  Ajax와 REST 방식의 이해

 

1)  Ajax Asynchronous JavaScript And XML


  💫  Ajax 방식은 브라우저에서 서버를 호출하지만 모든 작업이 브라우저 내부에서 이루어지기 때문에 현재 브라우저의 브라우저 화면 변화 없이 서버와 통신할 수 있음

 

    ✓  실제 구현은 자바 스크립트를 이용해서 XML을 주고 받는 방식을 이용
    ✓  최근에는 JSON을 이용하는 방식을 더 선호. 스프링 부트는 Springweb을 추가하면 자동으로 관련 라이브러리를 받음

  💫  클라이언트 중심의 개발

    ✓  Ajax가 가져온 변화는 모바일에서도 Ajax 방식으로 데이터를 교환할 수 있기 때문에 활용이 커짐
    ✓  모바일에서도 일반 웹과 마찬가지로 서버의 데이터가 필요한데 이때 화면과 관련된 부분은 필요하지 않기 때문에 서버에서 순수한 데이터만 전송하는 방식이라면 클라이언트의 구현이 웹 / 앱에 관계없이 데이터를 재사용할 수 있음

 

 

2)  JSON 문자열

 

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

  • '문자열'은 어떠한 프로그래밍 언어나 기술에 종속되지 X
  • 문자열을 이용하면 데이터를 주고 받는 것에 신경 써야 하는 일은 없지만, 문자열로 복잡한 구조의 데이터를 표현하는데 문제가 발생
  • 문자열로 복잡한 데이터를 표현하기 위해서 고려되는 것이 XML과 JSON 이라는 형태.
  • JSON은 자바스크립트 문법에 맞는 문자열로 데이터를 표현하기 때문에 클라이언트에서 어떤 기술을 이용하든 공통적으로 인식할 수 있음
  • 스프링 부트 역시 JSON 관련 라이브러리 jackson-databind가 이미 포함되어 있으므로 별도의 설정 없이 바로 JSON 데이터를 만들어 낼 수 있음

 

3)  REST 방식

 

  💫  html이 아니라 json or xml 형식이 response에 사용

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

 

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



4)  REST 방식의 URL 설계

Method URI 의미 추가 데이터
GET /board/123 123번 게시물 조회  
POST /board/ 새로운 게시물 등록 신규 데이터 게시물
PUT /board/123 123번 게시물 수정 수정에 필요한 데이터
DELETE /board/123 123번 게시물 삭제  

 

 

5) springdoc-openapi 준비

 

  ⚡️  REST 방식의 테스트는 특별한 화면을 구성하는 것이 아니라 데이터를 전송하고 결과를 확인하는 방법
          ➡️  예를 들어 브라우저는 GET 방식의 데이터를 확인할 때는 유용하지만 POST 방식으로 데이터를 처리할 때는 상당히 불편하고, 사용자도 측정한 경로를 어떻게 호출하는지 알 수 없으므로 상세한 정보를 전달하기 어려운 단점이 있음
  ⚡️  REST 방식을 이용할 때는 전문적으로 API를 테스트 할 수 있는 Postman이나 springdoc-openapi 등을 이용

 

Swagger UI
  • 부트 2까지는 Swagger UI를 사용. 부트 3부터는 Swagger UI사용이 안되고 springdoc-openapi를 지원
  • openapi는 개발할 때 어노테이션 설정으로 API 문서와 테스트 할 수 있는 화면을 생성할 수 있으므로 개발자는 한번에 테스트 환경까지 구성할 수 있다는 장점이 있음

(2)  프로젝트의 준비

1)  modelMapper 설정 변경

  • RootConfig의 modelMapper 설정을 변경. STRICT ▶️ LOOSE로 변경

  
@Configuration
public class RootConfig {
@Bean
public ModelMapper getMapper() {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setFieldMatchingEnabled(true)
.setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
.setMatchingStrategy(MatchingStrategies.LOOSE);
return modelMapper;
}
}

 

 

2) bulid.gradle 설정 추가

  • 프로젝트의 bulid.gradle 파일에 'OpenAPI Starter WebMVC UI'으로 검색한 라이브러리를 추가

  
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

 

  • 서버 실행을 하고 아래 주소로 접근
    http://localhost:8080/swagger-ui/index.html

 

3) Config 클래스 추가

  • 프로젝트의 config 패키지에 SpringdocOpenapiConfig 클래스를 추가
  • 일반 컨트롤러와 REST 컨트롤러를 분리해서 설정
  • 경로에 /api가 포함된 컨트롤러의 경우에는 REST API로 인식
  • 경로에 /api가 포함안된 컨트롤러의 경우에는 COMMON API로 인식

  
@Configuration
public class SpringdocOpenapiConfig {
@Bean
public GroupedOpenApi restApi() {
return GroupedOpenApi.builder()
.pathsToMatch("/api/**")
.group("REST API")
.build();
}
@Bean
public GroupedOpenApi commonApi() {
return GroupedOpenApi.builder()
.pathsToMatch("/**/*")
.pathsToExclude("/api/**/*")
.group("COMMON API")
.build();
}
}

 

SampleJasonController 경로 수정

  
@Log4j2
@RestController
public class SampleJSONController {
@GetMapping("/api/helloArr")
public String[] helloArr() {
log.info("HelloArr...");
return new String[] {"AAA", "BBB", "CCC"};
}
}

SampleController에 @Operation 추가

  
@Operation(summary = "hello")
@GetMapping("/hello")
public void hello(Model model) {
log.info("hello...");
model.addAttribute("msg", "Hello World");
}
@Operation(summary = "ex/ex1")
@GetMapping("/ex/ex1")
public void ex1(Model model) {
List<String> list = Arrays.asList("AAA", "BBB", "CCC", "DDD");
model.addAttribute("list", list);
}


 

  ⚡️  현재 설정은 기본적으로 가장 많이 사용하는 패키지를 기반으로 패키지의 모든 클래스에 대해서 API 테스트 환경을 만들어 냄

 

 

 

 ✓  화면의 board-controller를 선택하면 다음과 같이 메서드를 선택할 수 있는 화면이 나옴

✓  특정한 메서드를 선택하고 'Try it out' 버튼을 클릭하면 파라미터를 입력할 수 있음

✓  파라미터를 입력하고 'Execute'를 클릭하면 아래에서 결과 화면을 볼수 있음

 

 

 

 

 

 

 

정적 파일 경로 문제


  ✓  Swagger UI 설정이 끝나면 기존에 동작하던 /board/list/ 에는 문제가 생길 수 있음

  ✓  이 문제를 해결하려면 Spring Web 관련 설정을 추가해 주어야만 함
  ✓  config 폴더에 CustomServletConfig 클래스를 추가

 

  • 클래스에 @EnableWebMvc 어노테이션을 추가하는 것이 가장 중요
  • Swagger UI 가 적용되면서 정적 파일의 경로가 달라졌기 때문인데 이를 CustomServletConfig로 WebMvcConfigurer 인터페이스를 구현하도록하고 addResourceHandlers를 재정의해 수정

  
@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/");
}
}

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  게시물의 수정/삭제 처리

게시물 수정/삭제에 대한 처리는 GET 방식으로 게시물을 수정이나 삭제할 수 있는 화면을 보는 것으로 시작

BoardController의 read()를 수정해서 /board/modify에도 적용되도록 수정

  
@GetMapping({"/read", "/modify"})
public void read(Long bno, PageRequestDTO pageRequestDTO, Model model) {
BoardDTO boardDTO = boardService.readOne(bno);
log.info(boardDTO);
model.addAttribute("dto", boardDTO);
}

 

templates의 board 폴더에는 modify.html을 추가

  
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:th="http://www.thymeleaf.org"
layout:decorate="~{layout/basic.html}">
<head>
<meta charset="UTF-8">
<title>Board Modify</title>
</head>
<div layout:fragment="content">
<div class="row mt-3">
<div class="col">
<div class="card">
<div class="card-header">
Board Modify
</div>
<div class="card-body">
</div> <!-- end card-body -->
</div> <!-- end card -->
</div> <!-- end col -->
</div> <!-- end row -->
</div>

 

<form> 태그를 추가하고 'dto'라는 이름으로 전달된 BoardDTO 객체를 출력

 

  📍  <form> 태그에 제목 title, 내용 contents 을 수정할 수 있도록 작성하고 전송할 때 필요한 name 속성을 확인


  
<div class="card-body">
<form th:action="@{/board/modify}" method="post" id="f1">
<div class="input-group mb-3">
<span class="input-group-text">Bno</span>
<input type="text" class="form-control" name="bno" th:value="${dto.bno}" readonly>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Title</span>
<input type="text" class="form-control" name="title" th:value="${dto.title}" >
</div>
<div class="input-group mb-3">
<span class="input-group-text">Content</span>
<textarea class="form-control col-sm-5" name="content" rows="5" >[[${dto.content}]]</textarea>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Writer</span>
<input type="text" class="form-control" name="writer" th:value="${dto.writer}" readonly>
</div>
<div class="input-group mb-3">
<span class="input-group-text">RegDate</span>
<input type="text" class="form-control" th:value="${#temporals.format(dto.regDate, 'yyyy-MM-dd HH:mm:ss')}" readonly>
</div>
<div class="input-group mb-3">
<span class="input-group-text">ModDate</span>
<input type="text" class="form-control" th:value="${#temporals.format(dto.modDate, 'yyyy-MM-dd HH:mm:ss')}" readonly>
</div>
<div class="my-4">
<div class="float-end">
<button type="button" class="btn btn-primary listBtn">List</button>
<button type="button" class="btn btn-secondary modBtn">Modify</button>
<button type="button" class="btn btn-danger removeBtn">Remove</button>
</div>
</div>
</form>
</div> <!-- end card-body -->

 

/board/modify?bno=310 형태로 수정화면 접근


1)  수정 처리

  🚀  실제 수정 작업은 POST 방식으로 처리. 수정 후에는 다시 조회 화면으로 이동해서 수정된 내용을 확인할 수 있도록 구현
  🚀  수정 후 조회 화면으로 이동시에 검색했던 조건들이 해당하지 않을 수 있어서, 수정 후에는 검색 조건 없이 단순 조회 화면으로 이동하도록 구현

BoardController에는 POST 방식의 modify()를 구현

  
@PostMapping("/modify")
public String modify(PageRequestDTO pageRequestDTO,
@Valid BoardDTO boardDTO,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
log.info("board POST modify..." + boardDTO);
if (bindingResult.hasErrors()) {
log.info("has errors......");
String link = pageRequestDTO.getLink();
redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
redirectAttributes.addAttribute("bno", boardDTO.getBno());
return "redirect:/board/modify?" + link;
}
boardService.modify(boardDTO);
redirectAttributes.addFlashAttribute("result", "modified");
redirectAttributes.addAttribute("bno", boardDTO.getBno());
return "redirect:/board/read";
}

 

  ✓  modify()에는 문제가 발생할 때 'errors'라는 이름으로 다시 수정 페이지로 이동 할 수 있도록 PageRequestDTO의 getLink()를 통해서 기존의 모든 조건을 붙여서 /board/modify'로 이동하게 구성.
  ✓  수정 작업에 이상이 없을 때는 아무런 검색이나 페이징 조건없이 /board/read로 이동

 

modify.html에 문제가 있을 때는 자바스크립트를 이용해서 처리하도록 구성

  
<script layout:fragment="script" th:inline="javascript">
const errors = [[${errors}]]
console.log(errors)
let errorMsg = ''
if(errors) {
for (let i = 0; i < errors.length; i++) {
errorMsg += `${errors[i].field}은(는) ${errors[i].code} \n`;
}
history.replaceState({}, null, null)
alert(errorMsg);
}
</script>

 

자바스크립트로 이벤트 처리하는 부분을 작성

  
const link = [[${pageRequestDTO.getLink()}]];
const formObj = document.querySelector("#f1");
document.querySelector(".modBtn").addEventListener("click", function(e){
e.preventDefault();
e.stopPropagation();
formObj.action = `/board/modify?${link}`;
formObj.method = 'post';
formObj.submit();
}, false);

 

  ✓  수정 처리를 할 때는 나중에 잘못되는 상황을 대비해서 페이지 / 검색 정보도 같이 쿼리 스트링으로 전달

 


2)  삭제 처리

  🚀  BoardController에서 remove()라는 메서드 구성, 삭제 후에는 목록으로 이동하도록 구성


  
@PostMapping("/remove")
public String remove(Long bno, RedirectAttributes redirectAttributes) {
log.info("remove()...");
boardService.remove(bno);
redirectAttributes.addFlashAttribute("result", "removed");
return "redirect:/board/list";
}

 

modify.html에는 자바스크립트를 이용해서 /board/remove를 호출하도록 작성

  
document.querySelector(".removeBtn").addEventListener("click", function(e){
e.preventDefault();
e.stopPropagation();
formObj.action = `/board/remove`;
formObj.method = 'post';
formObj.submit();
}, false);

 

  ✓ 정상적으로 삭제가 이루어질 경우 RedirectAttributes를 통해서 'result' 값이 전달되므로 list.html에서는 모달창으로 삭제 처리된 것을 알 수 있음

 

 

목록으로 이동하는 버튼의 이벤트 처리는 페이지/검색 조건을 유지하도록 구성

  
document.querySelector(".listBtn").addEventListener("click", function(e){
e.preventDefault();
e.stopPropagation();
formObj.reset();
self.location = `board/list?${link}`;
}, false);

 

 

 

 

[ 내용참고 : IT 학원 강의 ]

+ Recent posts