Spring Boot 6. 계시판 프로젝트

 Spring Boot 6. 계시판 프로젝트




1. 프로젝트 구조


1:N 관계

하나의 유저는 여러개의 글을 작성할 수 있음

하나의 글에는 여러 댓글이 달릴 수 있음

3개의 테이블이 서로 참조함(user, board, reply)




2. 프로젝트 생성


- 추가 라이브러리

Spring Boot DevTools, Lombok, Spring Web, Thymeleaf, Spring Data JPA












- DB 및 Thymeleaf 설정

build.gradle

dependencies 안에 mariadb 드라이버 추가

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// 추가
implementation group: 'org.mariadb.jdbc', name: 'mariadb-java-client',
version: '3.1.2'
}


application.properties

DB 관련 설정과 Thymeleaf 설정 작성

spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/[DB 이름]
spring.datasource.username=[유저내임]
spring.datasource.password=[패스워드]

spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true

spring.thymeleaf.cache=false




3. 엔티티 클래스 작성

DB의 테이블을 나타내는 클래스



- 작성 날짜와 수정날짜를 자동으로 처리되도록 하기

프로젝트 안에 entity 패키지를 생성하고 BaseEntity 추상 클래스 구현





@MappedSuperclass
@EntityListeners(value = { AuditingEntityListener.class })
@Getter
abstract class BaseEntity {

@CreatedDate
@Column(name = "regdate", updatable = false)
private LocalDateTime regDate;

@LastModifiedDate
@Column(name = "moddate")
private LocalDateTime modDate;

}

@MappedSuperclass : 아무도 상속받지 않는 슈퍼 클래스

@EntityListeners : 엔티티의 변화를 감지하는 리스너

@Getter : 자동으로 Getter 메소드 생성

@CreateDate : 해당 컬럼이 생성되는 시간

@LastModifyDate : 해당 컬럼이 마지막으로 수정된 시간

@Column : 테이블의 컬럼임을 나타냄


+ 자동으로 시간 처리를 위해 프로젝트에 생성된 ~Application 클래스에 @EnableJpaAuditing 어노테이션 추가

@SpringBootApplication
@EnableJpaAuditing // 추가
public class BoardApplication {



- 유저, 글, 댓글 테이블과 연결할 엔티티 클래스 생성

entity 패키지 안에 User, Board, Reply 클래스 생성하고 구현








@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString
public class User extends BaseEntity {

@Id
private String email;
private String password;
private String username;
}


@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString
public class Board extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String content;
}


@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString
public class Reply extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
private String text;
private String replyer;
}

@Entity :엔티티 클래스임을 나타냄

@Builder : 자동으로 생성자를 생성

@AllArgsConstructor : 모든 매개변수를 가지는 생성자 생성

@NoArgsContructor : 기본 생성자 생성

@Getter : Getter 메소드 생성

@ToSTring : 컬럼을 String으로 나타내는 toString 메소드 생성

@Id : 해당 컬럼이 PK임을 나타냄

@GeneratedValue : 자동 증가. PK 등을 자동으로 생성해줌



- 외래키 지정

1:N 관계에서는 FK가 있는 클래스의 컬럼에 @ManyToOne 어노테이션을 작성

외래키로 지정할 컬럼의 자료형은 참조할 엔티티 클래스로 지정 -> 자동으로 해당 엔티티 클래스의 PK를 참조


@ToString(exclude = "writer") // 수정
public class Board extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String content;

// 추가 (외래키로 지정)
@ManyToOne
private User writer;
}


@ToString(exclude = "board")
public class Reply extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
private String text;
private String replyer;

    // 추가 (외래키로 지정)
@ManyToOne
private Board board;
}

@ToString(exclude = ) : toString 메소드에서 반환할 때 제외할 맴버변수 지정



=> 프로젝트를 실행하면 DB에 테이블이 생성됨










- Repository 인터페이스 추가

각 엔티티에 맞는 Repository 인터페이스 추가

프로젝트 안에 repository 패키지를 생성하고 엔티티 별로 각각 JpaRepository를 상속받는 인터페이스 구현







public interface UserRepository extends JpaRepository<User, String> {}
public interface BoardRepository extends JpaRepository<Board, Long> {}
public interface ReplyRepository extends JpaRepository<Reply, Long> {}

<엔티티 클래스, PK 타입> 작성




4. 연관관계 테스트


테스트를 위한 더미 데이터 추가

PK와 FK의 관계로 이루어졌기 때문에 PK쪽에서 부터 데이터를 추가


프로젝트 안의 test 폴더에 repository 패키지를 생성하고 각 엔티티의 repository 인터페이스를 테스트하는 클래스 구현








- 테스트 데이터 추가


UserRepositoryTest 클래스에 UserRepository 인터페이스를 주입하고 User 객체 10개 추가

@SpringBootTest
public class UserRepositoryTest {

@Autowired
private UserRepository userRepository;

@Test
public void insertUser() {
IntStream.rangeClosed(1, 10).forEach(i -> {
User user = User.builder()
.email("user_" + i + "@aaa.com")
.password("1111")
.username("USER_" + i)
.build();

userRepository.save(user);
});
}
}

@SpringBootTest : 테스트 클래스임을 나타냄

@Autowired : 인터페이스를 주입해서 참조변수를 선언할 때 자동으로 참조값을 사용할 수 있도록 해줌

@Test : 테스트 메소드임을 나타냄

userRepository.save() : DB에 저장하는 메소드


<실행결과>







BoardRepositoryTest 클래스에 유저 데이터를 이용해서 Board 객체 100개 추가

@SpringBootTest
public class BoardRepositoryTest {

@Autowired
private BoardRepository boardRepository;

@Test
public void insertBoard() {

IntStream.rangeClosed(1, 100).forEach(i -> {
User user = User.builder()
.email("user_" + ((i % 10) + 1) + "@aaa.com")
.build();

Board board = Board.builder()
.title("Title..." + i)
.content("Content......" + i)
.writer(user)
.build();

boardRepository.save(board);
});

}
}

먼저 User 객체를 생성한 후 Board 객체를 생성

User 객체를 Board 객체의 writer 값으로 지정 -> 외래키로 설정했기 때문에 자동으로 해당 객체를 참조

이때 반드시 DB에 저장되어있는 User 객체를 생성해야 함

DB에 없는 email을 가진 User 객체를 생성해서 참조하면 에러 발생


<실행결과>








ReplyRepositoryTest 클래스에 1~100 사이의 임의의 번호(bno)로 댓글을 300개 작성

@SpringBootTest
public class ReplyRepositoryTest {

@Autowired
private ReplyRepository replyRepository;

@Test
public void insertReply() {

IntStream.rangeClosed(1, 300).forEach(i -> {
// 랜덤 글번호 생성 (1 ~ 100)
long bno = (long)(Math.random() * 100) + 1;

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

Reply reply = Reply.builder()
.text("Reply........" + i)
.board(board)
.replyer("guest")
.build();

replyRepository.save(reply);
});

}
}

랜덤으로 글번호를 생성하고(1~100) 해당 글번호로 Board 객체를 생성

이때 글번호는 반드시 DB에 저장되어있는 글번호여야만 함

Reply 객체를 생성하면서 board 값에 생성한 Board 객체 지정

DB에 저장


<실행결과>











* Eager loading & Lazy loading

JPA에서 연관관계의 데이터를 어떻게 가져올 것인지 어노테이션의 fetch 속성으로 지정


- Eager loading 

즉시 로딩 (default)

@ManyToOne의 경우 FK 쪽에서 엔티티를 가져올 때 자동으로 PK쪽의 엔티티도 가져옴


예시 - 글번호가 100인 글 받아오기

@Test
public void testRead() {
Optional<Board> result = boardRepository.findById(100L);

Board board = result.get();

System.out.println(board);
System.out.println(board.getWriter());
}











실행된 쿼리를 보면 자동으로 left (outer) join이 된 것을 볼 수 있음


Eager loading을 사용하면 매번 연관관계를 가지는 모든 엔티티를 같이 로딩하기 때문에 비효율 적이고 권장되지 않음


- Lazy loading

지연 로딩

FK쪽의 엔티티를 가져올 때 PK쪽의 엔티티는 가져오지 않음

엔티티 클래스의 @ManyToOne 어노테이션의 fetch 속성을 FetchType.LAZY로 지정

@ManyToOne (fetch = FetchType.LAZY) // 추가 (Lazy loading으로 지정)
private User writer;


위의 테스트 코드를 그대로 실행하면 에러 발생

Board 엔티티를 가져오는 쿼리만 실행되고 DB와의 연결이 종료되었기 때문에 User 엔티티의 값(Board.writer)를 가져오지 못함

테스트 메소드 위에 @Transactional 어노테이션을 추가

해당 메소드를 하나의 트랜잭션으로 처리하도록 해줌

-> 필요할 순간에 쿼리를 다시 실행함


예시 

@Transactional
@Test
public void testRead() {
Optional<Board> result = boardRepository.findById(100L);

Board board = result.get();

System.out.println(board);
System.out.println(board.getWriter());
}













처음에는 Board 엔티티만 가져오는 쿼리가 실행되고 board.getWriter() 메소드가 호출되면서 User 엔티티가 필요하니까 다시 쿼리를 실행해서 User 엔티티를 받아옴



- Lazy loading의 장단점

장점 : 단순하게 하나의 테이블을 이용하는 경우에 빠른 처리 가능

단점 : 필요한 순간에 쿼리를 실행해야 해서 연관관계가 복잡한 경우에 여러번의 쿼리가 실행됨


=> Lazy loading을 기본으로 사용하고 상황에 맞게 필요한 방법을 추가


* @ToString

@ToString 어노테이션을 클래스에 추가하면 해당 클래스의 모든 맴버 변수를 출력

외래키로 지정된 맴버변수를 출력하려면 해당 엔티티 객체도 불러와야 함

Lazy loading을 사용하는 경우 @ToString 어노테이션의 exclude 속성을 이용해서 외래키로 지정된 변수는 출력되지 않도록 해야함

@ToString(exclude = "board")
public class Reply extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;

private String text;
private String replyer;

@ManyToOne (fetch = FetchType.LAZY)
private Board board;
}





- JPQL & left (outer) join

Lazy loading을 사용하면서 join을 사용하는 방법으로 JPQL의 join을 사용

Spring Boot 2버전 부터는 엔티티 클래스 내에 전혀 연관관계가 없더라도 join을 사용할 수 있음

(INNER) JOIN 이나 LEFT (OUTER) JOIN 사용 가능

JpaRepository 를 상속받는 인터페이스 안에 메소드를 선언해서 사용 가능


예시 - 연관관계가 있는 경우 (글번호 100번인 Board와 User(writer) 조회)

BoardRepository  

public interface BoardRepository extends JpaRepository<Board, Long> {

@Query("select b, w from Board b left join b.writer w where b.bno=:bno")
Object getBoardWithWriter(@Param("bno") Long bno);
}

@Param 안의 이름은 @Query() 안에서 : 다음에 이름을 작성해서 사용 가능


BoardRepositoryTest

@Test
public void testReadWithWriter() {

Object result = boardRepository.getBoardWithWriter(100L);

Object[] arr = (Object[]) result;

System.out.println("______________________");
System.out.println(Arrays.toString(arr));
}


Arrays.toString() : Java에서 제공하는 배열 안의 요소들을 출력해주는 메소드




예시 - 연관관계가 없는 엔티티 join

Board 에서는 Reply를 참조하고 있지 않음

직점 join에 필요한 조건을 ON을 이용해서 작성

BoardRepository

@Query("select b, r from Board b left join Reply r on r.board = b 
                                                      where b.bno = :bno")
List<Object[]> getBoardWithReply(@Param("bno")Long bno);


BoardRepositoryTest

@Test
public void testReadWithReply() {

List<Object[]> result = boardRepository.getBoardWithReply(100L);

for (Object[] arr : result) {
System.out.println(Arrays.toString(arr));
}
}





5. 화면에 필요한 데이터 가져오기


- 목록 화면에서 필요한 데이터

User : 유저 이름, 이메일

Board : 글 번호, 제목, 작성 시간

Reply : 해당 게시물의 댓글 수 


가장 많은 데이터를 가져오는 테이블이 Board이므로 BoardRepository 에 JPQL 메소드를 정의

join을 이용하고 후에 Board를 기준으로 GROUOP BY 처리를 해서 하나의 게시물 당 하나의 라인이 되도록 구현

패이징 처리를 위해 Pageable 객체를 파라미터로 전달받고 Page<Object[]> 타입을 반환


BoardRepository

@Query(value = "select b, w, count(r) " +
               "from Board b LEFT JOIN b.writer w LEFT JOIN Reply r " +
               "on b = r.board GROUP BY b",
countQuery = "select count(b) from Board b")
Page<Object[]> getBoardWithReplyCount(Pageable pageable);


BoardRepositoryTest

@Test
public void testWithReplyCount() {

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

Page<Object[]> result = boardRepository.getBoardWithReplyCount(pageable);

result.get().forEach(row -> {
Object[] arr = (Object[])row;

System.out.println(Arrays.toString(arr));
});

}

Pageable : 패이징 정보를 담음 (페이지 번호, 페이지 당 게시물 수, 정렬)

Page 객체를 get 메소드와 forEach 문을 이용해서 각 요소에 접근하고 Object[] 타입으로 다운캐스팅 해서 출력


<실행결과>








- 조회 화면에 필요한 데이터

User, Board, 댓글 수



BoardRepository

@Query("SELECT b, w, count(r) " +
"FROM Board b LEFT JOIN b.writer w " +
"LEFT OUTER JOIN Reply r ON r.board = b " +
"WHERE b.bno = :bno")
Object getBoardByBno(@Param("bno") Long bno);

목록 화면에 필요한 데이터를 가져오는 쿼리와 비슷

특정 bno에 해당하는 데이터만 가져온다는 것이 다름


BoardRepositoryTest

@Test
public void testRead3(){

Object result = boardRepository.getBoardByBno(100L);

Object[] arr = (Object[]) result;

for (Object data : arr){
System.out.println(data);
}

}


<실행결과>







6. DTO 계층과 서비스 계층


DTO : 데이터를 객체로 변환

서비스 : DTO 객체와 Entity 객제간의 변환 작업들을 수행


프로젝트 안에 dto 패키지를 추가하고 BoardDTO 클래스 생성





BoardDTO는 화면에 전달하는 데이터이기 때문에 Entity 객체와 구성이 일치하지 않음

다른 객체를 참조하지 않고 필드로 구성


BoardDTO

@Data
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BoardDTO {

private Long bno;
private String title;
private String content;
private String writerEmail;
private String writerName;
private LocalDateTime regDate;
private LocalDateTime modDate;
private int replyCount;
}

@Data : getter, setter등을 자동으로 추가

writerEmail, writerName 맴버변수는 다른 객체를 참조하지 않고 선언됨



프로젝트 안에 service 패키지를 생성하고 BoardService 인터페이스와 BoardServiceImpl 클래스 생성


BoardService

public interface BoardService {

    // 추상 메소드. 글을 등록하고 등록된 bno 반환
Long register(BoardDTO dto);
    // default 키워드로 인터페이스에 메소드 직접 구현
    // DTO 객체를 Entity 객체로 변환   
default Board dtoToEntity(BoardDTO dto) {

User member = User.builder()
.
email(dto.getWriterEmail()).build();

Board board = Board.builder()
.
bno(dto.getBno())
.
title(dto.getTitle())
.
content(dto.getContent())
.
writer(member)
.
build();

return board;
}
}


BoardServiceImpl

@Service // 서비스 클래스
@RequiredArgsConstructor
@Log4j2 // 로그 남김
public class BoardServiceImpl implements BoardService { // 인터페이스 구현

    // final 키워드로 Repository 인터페이스 자동 주입
private final BoardRepository repository;

    // 인터페이스의 추상메소드 구현
    // 글 등록 메소드 - DTO 객체를 Entity 객체로 변환해서 DB에 저장
    @Override
public Long register(BoardDTO dto){

log.info(dto); // DTO 객체 확인용 로그 남기기

Board board = dtoToEntity(dto); // Entity 객체로 변환

repository.save(board); // DB에 저장

return board.getBno(); // 등록한 bno 반환

}
}

@RequiredArgeConstructor : 필수 생성자 자동 생성(@NonNull 이나 final 필드만 포함)



- 테스트

test 폴더에 service 패키지를 추가하고 BoardServiceTest 클래스 정의

@SpringBootTest
public class BoardServiceTest {

@Autowired // 인터페이스 자동주입
private BoardService boardService;

// 테스트 메소드
@Test // 글 등록 테스트
public void testRegister() {

// 새로운 글 DTO 객체
BoardDTO dto = BoardDTO.builder()
.title("Test")
.content("Test...")
.writerEmail("user_10@aaa.com")
.build();

// 서비스의 register 메소드 테스트
Long bno = boardService.register(dto);
System.out.println("____________________");
System.out.println(bno);
}
}

<실행결과>




새로운 글이 추가됨





7. 게시물 목록 처리


파라미터를 수집하는데 사용하는 PageRequestDTO, PageResultDTO 클래스를 dto 패키지 안에 추가







- PageRequestDTO  

페이지를 요청하는 정보를 담고 있음

페이지 번호, 페이지당 게시물 수, 검색  항목, 검색어에 대한 정보를 가짐

기본 생성자에서 자동으로 페이지 번호는 1, 페이지당 게시물 수는 10으로 생성됨

getPageable(Sort sort) 메소드에서 정렬 정보를 전달받아 페이지 정보를 가지는 PageRequest 객체를 반환

@Builder
@AllArgsConstructor
@Data
public class PageRequestDTO {

private int page;
private int size;

private String type; // 제목 : t, 작성자 : w, 내용 : c
private String keyword; // 검색어


public PageRequestDTO(){
this.page = 1;
this.size = 10;
}

public Pageable getPageable(Sort sort){
return PageRequest.of(page - 1, size, sort);
}
}


- PageResultDTO

화면에 표시할 페이지의 결과에 대한 정보들을 가지고 있음

화면에 표시할 DTO 리스트, 총 페이지 번호, 현제 페이지 번호, 페이지당 게시물 수, 시작 페이지 번호, 끝 페이지 번호, 다음&이전 표시 여부, 페이지 하단에 표시할 페이지 번호 목록을 가짐

사용자 정의 생성자에서 Entity 객체들을 가지고있는 Page 객체와 Entity를 DTO로 변환하는 Funtion 인터페이스를 전달받아서 Entity 객체들을 DTO 객제로 변환해서 DTO 리스트로 만들고 총 페이지 수와 화면에 표시할 페이지 번호 리스트를 계산

makePageList(Pageable pageable) 메소드에서 페이지에 대한 정보를 갖는 Pageable 객체를 전달받아서 페이지 번호, 페이지당 게시물 수 , 시작&끝 페이지 번호, 이전&다음링크 표시 여부, 페이지에 표시할 페이지번호 리스트를 계산해서 할당


@Data
public class PageResultDTO<DTO, EN> {

// DTO 리스트
private List<DTO> dtoList;

// 총 페이지 번호
private int totalPage;

// 현재 페이지 번호
private int page;

// 페이지당 게시물 수
private int size;

// 시작 페이지 번호, 끝 페이지 번호
private int start, end;

// 이전, 다음 링크 표시 여부
private boolean prev, next;

// 페이지 번호 목록
private List<Integer> pageList;

public PageResultDTO(Page<EN> result, Function<EN, DTO> fn) {

// DTO 리스트 생성
dtoList = result.stream().map(fn).collect(Collectors.toList());

// 총 페이지수 계산 (전체 페이지 번호 개수 = 테이블의 엔티티 개수)
totalPage = result.getTotalPages();

// 화면에 표시되는 페이지 리스트 생성
makePageList(result.getPageable());

}

// 페이지 리스트 생성 메소드
private void makePageList(Pageable pageable) {
// Page객체로 부터 현재 페이지 번호와 페이지 사이즈 받아옴
this.page = pageable.getPageNumber() + 1;
this.size = pageable.getPageSize();

// 임시 마지막 체이지
int tmpEnd = (int)(Math.ceil(page / 10.0)) * 10;

this.start = tmpEnd - 9; // 시작 페이지
this.prev = start > 1; // 이전 링크 표시 여부
this.end = totalPage > tmpEnd ? tmpEnd : totalPage;// 진짜 마지막 페이지
this.next = totalPage > tmpEnd; // 다음 링크 표시 여부

// 화면에 표시되는 페이지 번호들을 List<>로 생성
pageList = IntStream.rangeClosed(start, end)
                                     .boxed().collect(Collectors.toList());
}
}



- Object[] 타입을 BoardDTO 타입으로 변환

PageResultDTO의 핵심 기능은 JPQL의 결과로 나오는 Object[]를 BoardDTO 타입으로 변환하는 것

Object[]에 있는 Board, User, Long(댓글 수) 타입의 데이터를 파라미터로 전달해서 BoardDTO 객체를 생성

BoardService 인터페이스에 entityToDTO() 메소드를 구현해서 처리

default BoardDTO entityToDTO(Board board, User user, Long replyCount) {

BoardDTO dto = BoardDTO.builder()
.bno(board.getBno())
.title(board.getTitle())
.content(board.getContent())
.regDate(board.getRegDate())
.modDate(board.getModDate())
.writerEmail(user.getEmail())
.writerName(user.getUsername())
.replyCount(replyCount.intValue()) // Long타입을 int로 변환
.build();

return dto;
}



- PageResultDTO 객체 생성

BoardService 인터페이스에 getList 추상메소드 정의

PageResultDTO<BoardDTO, Object[]> getList(PageRequestDTO pageRequestDTO);

PgaeRequestDTO를 전달받아 PageResultDTO 객체를 생성


BoardServiceImpl 클래스에 getList 메소드 구현

@Override
public PageResultDTO<BoardDTO, Object[]>
                   getList(PageRequestDTO pageRequestDTO) {

log.info(pageRequestDTO);

Function<Object[], BoardDTO> fn = (en -> entityToDTO((Board) en[0],
(User) en[1], (Long) en[2]));

Page<Object[]> result = repository.getBoardWithReplyCount(
pageRequestDTO.getPageable(Sort.by("bno").descending()));

return new PageResultDTO<>(result, fn);

}

함수형 인터페이스인 Function을 이용해서 Object[] 타입의 입력을 받고 BoardDTO 타입을 출력하는 람다 함수 정의

BoardRepository에 정의한 getBoardWithReplyCount() 메소드에 전달받은 PageRequestDTO 객체를 전달해서 해당 페이지의 Board, User, Long(댓글 수) 타입의 데이터를 포함한 Page<Object[]> 타입으로 반환받음

PageResultDTO의 사용자정의 생성자를 이용해서 PageResultDTO 객체를 생성 후 반환



- 목록 처리 테스트

BoardServiceTest 클래스에 테스트 메소드 작성

@Test
public void testList() {

PageRequestDTO pageRequestDTO = new PageRequestDTO();

PageResultDTO<BoardDTO, Object[]> pageResultDTO 
                                    = boardService.getList(pageRequestDTO);

for (BoardDTO boardDTO : pageResultDTO.getDtoList()) {
System.out.println(boardDTO);
}

}

PageRequestDTO 객체를 생성하고 BoardServiceImpl 클래스의 getList 메소드에 전달해서 PageResultDTO 객체를 반환받음


<실행결과>








8. 게시물 조회 처리

게시물의 번호를 파라미터로 전달받아서 처리

BoardService 인터페이스와 BoardServiceImpl 클래스에 get 메소드 추가


BoardService

BoardDTO get(Long bno);

BoardServiceImpl

@Override
public BoardDTO get(Long bno) {

Object result = repository.getBoardByBno(bno);

Object[] arr = (Object[]) result;

return entityToDTO((Board) arr[0], (User) arr[1], (Long) arr[2]);

}

BoardRepository 의 bno를 전달받아 Board, User, Long(댓글 수) 타입의 데이터를 Object 타입으로 반환하는 getByBno 메소드 사용

받아온 Object 타입의 result를 Object[] 타입으로 변환하고 entityToDTO 메소드를 이용해서 BoardDTO 객체로 변환해서 반환


- 게시물 조회 테스트

BoardServiceTest 클래스에 테스트 메소드 작성

@Test
public void testGet() {

Long bno = 1L;

BoardDTO result = boardService.get(bno);

System.out.println(result);

}

<실행결과>






9. 게시물 삭제 & 수정 처리


* 삭제 처리

댓글이 있는 경우 다른 유저들이 추가한 댓글도 삭제됨

FK로 게시물을 참조하는 Reply 테이블의 모든 데이터도 같이 삭제되어야 함

해당 게시물의 댓글들을 모두 삭제하고 다음으로 해당 게시물을 삭제

하나의 트랜잭션으로 처리되어야 함


ReplyRepository 에 bno를 전달받아 해당 bno를 참조하는 모든 데이터를 삭제하는 메소드 추가

@Modifying
@Query("DELETE FROM Reply r WHERE r.board.bno = :bno")
void deleteByBno(@Param("bno")Long bno);

@Modifying : JPQL을 이용해서 삭제나 수정을 하는경우 반드시 @Modifying 어노테이션을 추가


BoardService 인터페이스와 BoardServiceImpl 클래스에 bno를 전달받아 삭제를 처리하는 removeWithReplies(Long bno) 메소드 구현


BoardService

void removeWithReplies(Long bno);


BoardServiceImpl

private final ReplyRepository replyRepository;

ReplyRepository 인터페이스를 자동으로 주입받기 위한 코드 추가

@Transactional
@Override
public void removeWithReplies(Long bno) {
// 댓글 부터 삭제
replyRepository.deleteByBno(bno);
// 다음으로 게시물 삭제
repository.deleteById(bno);
}

@Transactional : 두 쿼리를 하나의 트랙잭션으로 처리하도록 함


- 삭제 테스트

BoardServiceTest 클래스에 테스트 메소드 추가

@Test
public void removeTest() {

Long bno = 100L;

boardService.removeWithReplies(bno);
}


<실행결과>






100번 글이 삭제됨.



* 수정 처리
Title과 Content 부분만 수정

BoardRepository의 save() 메소드 사용

Board 클래스에 수정에 필요한 메소드 추가(title과 content를 변경하는 메소드)

public void changeTitle(String title) {
this.title = title;
}
public void changeContent(String content) {
this.content = content;
}


BoardService 인터페이스와 BoardServiceImpl 클래스에 수정처리하는 메소드 modify(BoardDTO boardDTO) 구현

BoardService

void modify(BoardDTO boardDTO);


BoardServiceImpl

@Transactional
@Override
public void modify(BoardDTO boardDTO) {
Board board = repository.getReferenceById(boardDTO.getBno());

board.changeTitle(boardDTO.getTitle());
board.changeContent(boardDTO.getContent());

repository.save(board);
}

@Transactional : 수정작업을 처리하기 때문에 추가

getReferenceById : 필요한 순간까지 로딩을 지연해야하기 때문에 findById가 아닌 getReferenceById 메소드를 사용

불러온 Board객체의 제목과 내용을 수정

save()메소드를 이용해서 DB에 저장


- 수정 테스트

BoardServiceTest 클래스에 테스트 메소드 추가

@Test
public void testModify() {
BoardDTO dto = BoardDTO.builder()
.bno(100L)
.title("Changed Title...")
.content("Changed Content...")
.build();

boardService.modify(dto);
}

수정된 내용으로BoardDTO 객체를 생성하고 modify 메소드에 전달해서 수정작업 수행


<실행결과>




-> 99번 글의 제목과 내용이 수정됨





10. 컨트롤러와 화면 처리


프로젝트 안에 controller 패키지를 생성하고 그 안에 BoardController 클래스 생성







@Controller
@RequestMapping("/board/")
@Log4j2
@RequiredArgsConstructor
public class BoardController {

private final BoardService boardService; // 서비스 인터페이스 자동 주입
}

@Controller : 컨트롤러 클래스임을 나타냄

@RequestMapping : url에서 요청할 주소 지정




* 템플릿 다운받기

https://kumioriscodingdiary.blogspot.com/2023/04/spring-boot-4-thyme.html

참조

res -> templates 폴더 안에 layout 폴더와 board 폴더를 생성하고 템플릿 파일 작성

layout 폴더에는 아래 링크를 참조해서 basic.html, layout1.html 파일 생성

https://kumioriscodingdiary.blogspot.com/2023/04/spring-boot-5.html




1) 목록 컨트롤러 & 화면 처리


리스트 처리를 위해 BoardController 클래스에 list 메소드 정의

@GetMapping("/list")
public void list(PageRequestDTO pageRequestDTO, Model model) {
model.addAttribute("result", boardService.getList(pageRequestDTO));

}

@GetMapping : GET 요청이 들어올 때 맵핑할 주소 지정

Model : 페이지에 정보를 전달하기 위해 사용하는 객체

GET 요청이 들어올 때 파라미터로 전달되는 값들에 의해 자동으로 PageRequstDTO 객체가 생성되어 매게변수로 전달됨

Model.addAttribute() : 화면에 전달할 데이터의 이름과 데이터 지정

-> BoardService의 getList 메소드에 전달받은 PageRequestDTO 객체를 넘겨줘서 화면에 표시할 PAgeResultDTO 객체를 반환받고 "result"라는 이름으로 화면에 전달

-> 자동으로 templates 폴더에서 메소드의 이름과 동일한 템플릿 파일을 화면에 출력



templates -> board 폴더 안에 list.html 파일 생성

https://github.com/Kumiori77/Board/blob/master/src/main/resources/templates/board/list.html

참고


브라우저에서 localhost:8080/board/list로 접속하면 확인 가능

<실행결과>













하단의 페이지 번호를 클릭하면 해당 페이지로 이동가능




2) 게시물 등록 처리


컨트롤러에 화면을 보여주기 위한 GET 방식을 동작하는 메소드와 실제 등록 처리를 위해 POST 방식으로 동작하는 메소드 추가

@GetMapping("/register")
public void register() {
log.info("register get...");
}

GET 방식으로 요청했을때 다른 작업은 수행하지 않고 해당 템플릿 파일을 출력


@PostMapping("/register")
public String registerPost(BoardDTO boardDTO,
         RedirectAttributes redirectAttributes){

// 글 등록
Long bno = boardService.register(boardDTO);

redirectAttributes.addFlashAttribute("msg", bno);

log.info("BNO : "+bno);

return "redirect:/board/list";

}

요청이 성공하면 리다이랙트하기 위해 반환타입을 String으로 작성

폼에서 파라미터로 넘겨준 값들이 자동으로 BoardDTO 객체로 생성되어 매개변수로 전달됨

RedirectAttributes : 리다이랙트 시 데이터를 넘겨주기 위해 사용 -> 등록한 글의 bno 전달

BoardService의 register 메소드에 전달받은 BoardDTO 객체를 넘겨줘서 등록 처리

return 값에 "redirect:" 다음에 리다이렉트 할 주소 작성



templates -> board 폴더 안에 register.html 파일 생성

https://github.com/Kumiori77/Board/blob/master/src/main/resources/templates/board/register.html

참고


브라우저에서 localhost:8080/board/register로 접속하면 확인 가능

<실행결과>














값을 입력하고 submit 버튼을 누르면 실제로 글이 등록됨

* 주의사항 : email 에 반드시 DB에 등록된 email을 작성해야함



3) 게시물 조회 처리


해당 게시물의 bno를 전달받아서 해당하는 DTO 객체를 생성한 후 템플릿에 전달

컨트롤러에 GET방식으로 동작하는 메소드 추가

@GetMapping("/read")
public void read(@ModelAttribute("requestDTO")PageRequestDTO pageRequestDTO,
Long bno, Model model) {

BoardDTO dto = boardService.get(bno);

model.addAttribute("dto", dto);

}

@ModelAttribute : 해당 매개변수를 특별한 처리 없이 Model에 추가해서 템플릿에 전달

Model : 템플릿에 전달할 데이터를 담아두는 클래스


templates -> board -> read.html 파일 추가

https://github.com/Kumiori77/Board/blob/master/src/main/resources/templates/board/read.html

참고


목록화면에서 각 게시물의 bno를 클릭하면 해당 게시물의 조회 화면으로 이동
















4) 게시물 수정 & 삭제 처리


수정 화면은 조회화면과 유사하기 때문에 GET 방식으로 요청할 때에는 조회에서 사용한 메소드를 동일하게 사용 (@GetMapping("/read") 를 ({"/read", "/midify"}) 로 수정

@GetMapping({"/read", "/modify"}) // "/modify" 추가
public void read(@ModelAttribute("requestDTO")PageRequestDTO pageRequestDTO,
Long bno, Model model) {


수정과 삭제는 POST 방식으로 처리

수정과 삭제 작업후 각각 조회 페이지와 목록 페이지로 리다이렉트 하기 때문에 반환 타입을 String으로 지정하고 "redirect:리다이렉트할 주소"를 반환

컨트롤러에 메소드 추가


// 수정 처리
@PostMapping("/modify")
public String modify(BoardDTO dto, @ModelAttribute("requestDTO")
PageRequestDTO pageRequestDTO, RedirectAttributes redirectAttributes) {

boardService.modify(dto);

redirectAttributes.addAttribute("page", pageRequestDTO.getPage());
redirectAttributes.addAttribute("type", pageRequestDTO.getType());
redirectAttributes.addAttribute("keyword", pageRequestDTO.getKeyword());
redirectAttributes.addAttribute("bno", dto.getBno());

return "redirect:/board/read";
}

RedirectAttributes : 리다이렉트 할 때 전달할 정보를 담아두는 객체

BoardService의 modify 메소드로 수정작업 수행

페이지를 요청하는 정보와 bno 정보를 RedirectAttributes 객체에 추가

조회 페이지로 리다이렉트 하도록 반환값 지정


// 삭제 처리
@PostMapping("/remove")
public String remove(long bno, RedirectAttributes redirectAttributes) {

boardService.removeWithReplies(bno);

redirectAttributes.addAttribute("msg", bno);

return "redirect:/board/list";

}

BoardService의 removeWithReplies 메소드를 이용해 해당 들에 작성된 댓글들을 모두 삭제하고 해당 게시물을 삭제하는 작업 수행

RedirectAttributes 객체에 삭제 성공시 화면에 표시하는 모달창에 이용하는 bno 추가

목록 페이지로 리다이렉트 하도록 반환값 지정



templates -> board -> modify.html 파일 추가

https://github.com/Kumiori77/Board/blob/master/src/main/resources/templates/board/modify.html

참고


조회 페이지에서 modify 버튼을 클릭하면 수정화면으로 이동

수정 작업을 수행하고 modify 버튼을 클릭하면 글이 수정되고 조회 페이지로 이동

remove 버튼을 클릭하면 해당 게시물이 삭제되고 목록 화면으로 이동

















11. JPQL을 이용한 검색 기능 구현


여러 엔티티 타입을 JPQL로 직접 처리하는 경우 Object[] 타입으로 반환되기 때문에 작성하는 방법이 다름

Querydsl을 이용해서 동적으로 복잡한 검색조건이나 조인, 서브쿼리등의 기능 구현

Querydsl을 이용하기 위해서는 일반 엔티티 객체가 아닌 Q도메인 객체를 이용

Querydsl 라이브러리를 이용해서 엔티티 객체를 Q도메인 객체로 변환


- 설정 변경

build.gradle 파일에 설정 추가

- 가장 위에 변수 사용 관련 코드 추가

buildscript {
ext {
queryDslVersion = "5.0.0"
}
}


- dependencies 항목에 querydsl 관련 라이브러리 추가

dependencies { 
                ...

// Querydsl 관련 라이브러리 추가
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta"

annotationProcessor(
"jakarta.persistence:jakarta.persistence-api",
"jakarta.annotation:jakarta.annotation-api",
"com.querydsl:querydsl-apt:${queryDslVersion}:jakarta"
)
}

- 맨 아래에 코드 추가

sourceSets {
main {
java {
srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
}
}
}

compileJava.dependsOn('clean')

load gradle changes (코끼리 아이콘) 를 클릭해서 변경된 사항 다운로드


-> 프로젝트를 실행하면 build 폴더 안에 Q도메인 클래스가 자동으로 생성됨











- Repository 확장

쿼리 메소드나 @Query 등으로 처리할 수 없는 기능은 별도의 인터페이스로 설계

해당 인터페이스에 대한 구현 클래스 작성 -> QuerydslRepositorySupport 클래스 상속받음

인터페이스의 기능을 Q도메인 클래스 와 JPQLQuery를 이용해서 구현


repository 패키지 안에 search 패키지를 추가하고 SearchBoardRepository 인터페이스와 SearchBoardRepositoryImpl 클래스 생성

(구현 클래스의 이름은 반드시 "인터페이스 이름 + Impl"로 작성)


SearchBoardRepository

public interface SearchBoardRepository {

Board search1();

}


SearchBoardRepositoryImpl

@Log4j2 // 로그 남기기 위함
// QuerydslRepositorySupport 클래스 상속, SearchBoardRepository 인터페이스 구현
public class SearchBoardRepositoryImpl extends QuerydslRepositorySupport
implements SearchBoardRepository {

// 생성자
public SearchBoardRepositoryImpl() {
super(Board.class); // 도메인 클래스 지정
}

// 인터페이스의 추상 메소드 구현
@Override
public Board search1() {
return null;
}
}

QuerydslRepositorySupport 클래스는 생성자가 존재하기 때문에 상속받은 클래스 내에서 super()를 이용해서 호출 -> 도메인 클래스를 지정(null 불가)


BoardRepository 인터페이스를 SearchBoardRepository 인터페이스를 상속받도록 수정

public interface BoardRepository extends JpaRepository<Board, Long>,
SearchBoardRepository { // 추가


BoardRepositoryTest 클래스에 테스트 메소드 추가

@Test
public void testSearch1() {
boardRepository.search1();
}



- JPQLQuery 객체를 이용한 메소드 구현

@Override
public Board search1() {

log.info("Search1________________");

QBoard board = QBoard.board; // Q도메인 객체 생성

// Q도메인 객체와 from() 메소드를 이용해서 JPQLQuery<> 객체 생성
JPQLQuery<Board> jpqlQuery = from(board); // 조회할 대상 엔티티 지정

// JPQLQuery의 select 메소드와 where 메소드를 이용해서
// bno가 1인 Board 객제를 가져오도록 설정
jpqlQuery.select(board).where(board.bno.eq(1L));

List<Board> result = jpqlQuery.fetch(); // 실제 데이터 가져옴

log.info(result); // 결과 출력

return null;
}

<실행결과>






- JPQL을 이용한 Join 처리

join(), leftJoin(), rightJoin() 메소드 사용

조인 조건이 필요하다면 on() 메소드 사용

조인 처리 후에 select() 메소드를 이용해서 결과를 검색

select() 메소드 뒤에 groupBy() 메소드를 이용해서 집합 함수 처리 가능


조인을 이용해서 하나의 엔티티 객체 단위로 받아오는 것이 아닌 각각의 데이터를 추출하는 경우에는 Tuple 클래스를 이용해서 값을 받아옴


* 예시 - 게시물과 게시물의 댓글들 가져오기

@Override
public Board search1() {

log.info("Search1________________");

// Q도메인 객체 생성
QBoard board = QBoard.board;
QReply reply = QReply.reply;
QUser member = QUser.user;

// 조인 처리
JPQLQuery<Board> jpqlQuery = from(board);
jpqlQuery.leftJoin(member).on(board.writer.eq(member));
jpqlQuery.leftJoin(reply).on(reply.board.eq(board));

// Tuple 클래스를 이용해서 결과를 받아오도록 지정
// seelct(), groupBy() 메소드 사용
JPQLQuery<Tuple> tuple = jpqlQuery.select(board, member.email,
                                                reply.count());
tuple.groupBy(board);

log.info(tuple);


// Tuple 객체를 이용해서 결과를 받아옴
List<Tuple> result = tuple.fetch();

for (Tuple t : result) {
log.info(t);
}

return null;
}

<실행결과>





모든 게시물에 대한 데이터 받아옴





- 실제 검색기능 구현

검색어와 타입 파라미터를 추가로 전송해서 검색 기능을 구현하고 결과를 패이징 처리해서 결과를Page<Object[]> 타입으로 반환


SearchBoardRepository 인터페이스에 searchPage 추상메소드 구현

public interface SearchBoardRepository {
         ...  
Page<Object[]> searchPage(String type, String keyword, Pageable pageable);

}

매개변수로 검색어, 검색 타입, 패이징 처리 데이터를 갖은 Pageable 객체를 전달

DTO 객체를 가능하면 Repository 영역에서 다루지 않기 위해 PageRequestDTO 자체를 파라미터로 전달하지 않고 따로따로 전달



* 검색 처리

검색조건은 BooleanExpression 객체와 BooleanBuilder 객체를 이용해서 처리

BooleanExpression 객체는 조건에 대한 표현식을 담고, BooleanBuilder 객체는 여러 조건들을 결함하는 역할



* 정렬 처리

매개변수로 전달받은 pageable 객체로 부터 getSort() 메소드로 Sort 객체를 추출

stream.forEach() 메소드를 이용해서 Sort 객체의 모든 order 객체를 순환하면서 정렬 정보 (오름, 내림 차순, 정렬 기준)을 받아옴

동적으로 경로 표현식을 만드는 PathBuilder 객체를 생성해서 정렬 기준 클래스와 별칭을 생성자에 작성

Tuple 객체의 orderBy() 메소드로 정렬 지정 -> OrderSpecifier 객체를 생성해서 전달하는데 OrderSpecifier 객체의 생성자에 Order 객체와 PathBuilder 객체.get(정렬 기준)을 전달해서 생성


* 페이징 처리 

groupBy() 메소드 이후에 처리

Tuple 객체의 offset(), limit() 메소드를 이용해서 어디서 부터 가져올지와 한 페이지당 얼마의 게시물을 가져올 지 지정

해당 내용은 Pageable 객체의 getOffset(), getPageSize() 메소드를 이용해서 받아옴


* 결과 반환

Page 인터페이스의 구현체인 PageImpl클래스<Object[]> 타입으로 결과 반환

Tuple 객체를 toArray() 메소드를 이용해서 List 로 변환해서 반환

Pageable객체와 전체 글 개수를 함께 생성자에 전달



* 예시

@Override
public Page<Object[]> searchPage(String type, String keyword,
                    Pageable pageable) {

log.info("Search Page______________________");

// Q 도메인 객체 생성
QBoard board = QBoard.board;
QReply reply = QReply.reply;
QUser user = QUser.user;

// 조인 조건 처리
JPQLQuery<Board> jpqlQuery = from(board);
jpqlQuery.leftJoin(user).on(board.writer.eq(user));
jpqlQuery.leftJoin(reply).on(reply.board.eq(board));

// Tuple 객체로 select() 결과를 받아오도록 지정
JPQLQuery<Tuple> tuple = jpqlQuery.select(board, user, reply.count());

// 검색 조건 처리를 위한 객체 생성
BooleanBuilder booleanBuilder = new BooleanBuilder();
BooleanExpression expression = board.bno.gt(0L); // 조건식 (bno > 0)

// and 조건으로 조건식 결합
booleanBuilder.and(expression);

// 검색 처리
if (type != null) {
String[] typeArr = type.split(""); // 검색 타입을 문자열 리스트로 변환

// 검색 조건을 연결할 BppleanBuilder 객체 생성
BooleanBuilder conditionBuilder = new BooleanBuilder();

// 검색 조건에 따른 검색 조건식을 생성해서 BooleanBuilder 객체에 연결
for (String t : typeArr) {
switch (t) {
case "t":
conditionBuilder.or(board.title.contains(keyword));
break;
case "w":
conditionBuilder.or(user.email.contains(keyword));
break;
case "c":
conditionBuilder.or(board.content.contains(keyword));
break;
}
}
booleanBuilder.and(conditionBuilder);
}
// Tuple 객체의 where() 메소드를 이용해서 검색 조건 지정
tuple.where(booleanBuilder);


// 정렬 처리
Sort sort = pageable.getSort(); //Pageable 객체로 부터 Sort 객체 받아옴

// 검색 조건 순회
sort.stream().forEach(order -> {
// 정렬 방향과 정렬 기준 받아오기
Order direction = order.isAscending() ? Order.ASC : Order.DESC;
String prop = order.getProperty();

// 정렬의 기준이 되는 Board 클래스의 경 표현식 생성
PathBuilder orderByExpression =
                     new PathBuilder(Board.class, "board");

// Tuple 객체에 정렬 조건 지정
tuple.orderBy(new OrderSpecifier(direction,
                             orderByExpression.get(prop)));
});    

tuple.groupBy(board); // 집계 함수 묶어줌

// 페이지 처리
tuple.offset(pageable.getOffset());
tuple.limit(pageable.getPageSize());

List<Tuple> result = tuple.fetch(); // 실제 데이터 받아옴

for (Tuple t : result) {
log.info(t);
}

long count = tuple.fetchCount(); // 한 페이지 당 글 수
log.info("count : " + count);

// 결과 반환
return new PageImpl<Object[]>(
result.stream().map(t -> t.toArray())
                         .collect(Collectors.toList()),
pageable,
count
);
}


테스트 코드

@Test
public void testSearchPage() {
Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());
boardRepository.searchPage("t", "1", pageable);
}

<실행결과>






-> 검색 조건에 해당하는 결과를 페이징 처리해서 반환



* 목록 화면에서 검색 기능 처리

BoardServiceImpl 클래스의 getList 메소드 수정

기존의 검색 기능 없이 getBoardWithReplyCount 메소드를 사용해서 게시물만 받아오던 부분을 주석 처리하고 searchPage메소드로 검색 기능을 추가해서 게시물을 받아오도록 수정

@Override
public PageResultDTO<BoardDTO, Object[]> getList(
                         PageRequestDTO pageRequestDTO) {

log.info(pageRequestDTO);

Function<Object[], BoardDTO> fn = (en -> entityToDTO((Board) en[0],
                             (User) en[1], (Long) en[2]));

// Page<Object[]> result = repository.getBoardWithReplyCount(
// pageRequestDTO.getPageable(Sort.by("bno").descending()));

// 검색 처리를 위해 수정
Page<Object[]> result = repository.searchPage(
pageRequestDTO.getType(),
pageRequestDTO.getKeyword(),
pageRequestDTO.getPageable(Sort.by("bno").descending()));

return new PageResultDTO<>(result, fn);
}

<실행결과>









검색하면 해당하는 게시물만 보여줌





12. @RestController와 JSON을 이용한 댓글 처리


모든 댓글은 게시물 조회 화면에서 처리

Ajax를 이용해서 컨트롤러와 JSON 포맷으로 데이터를 교환


조회 페이지에 댓글 숫자를 표시하고 클릭하면 해당 댓글들을 Ajax로 가져와서 화면에 표시

버튼을 클릭하면 댓글을 작성하는 모달창 출력 -> Ajax의 POST 방식으로 댓글 전송

특정 댓글을 선택하면 모달창으로 수정과 삭제 수행


1) JSON과 Ajax로 댓글 처리


ReplyRepository에 특정 게시물 번호로 댓글의 목록을 가져오는 기능 추가

public interface ReplyRepository extends JpaRepository<Reply, Long> { 

                
...

List<Reply> getRepliesByBoardOrderByRno(Board board);

}

Board 객체를 파라미터로 받고 해당하는 모든 댓글을 Rno 순서대로 가져오는 메소드


ReplyRepositoryTest 클래스에  테스트 메소드 작성

@Test
public void testLsitByBoard() {

List<Reply> replyList = replyRepository.getRepliesByBoardOrderByRno(
Board.builder().bno(81L).build()
);

replyList.forEach(r -> System.out.println(r));
}






2) ReplyDTO, ReplyService, ReplyController


댓글을 서비스와 컨트롤러에서 처리하기 위한 ReplyDTO 클래스를 DTO 패키지 안에 생성

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

엔티티 클래스와 다르게 Board 클래스 대신 Long 타입으로 bno 매개변수 지정


Service  패키지 안에 ReplyService 인터페이스와 ReplyServiceImpl 클래스 생성

-> 댓글 등록, 리스트, 댓글 수정, 삭제, entity <-> DTO 기능 구현


ReplyService 인터페이스

public interface ReplyService {

Long register(ReplyDTO replyDTO); // 댓글 등록
List<ReplyDTO> getReplyList(Long bno); // 댓글 목록
void modify(ReplyDTO replyDTO); // 댓글 수정
void remove(Long rno); // 댓글 삭제

default Reply dtoToEntity(ReplyDTO replyDTO) {
Board board = Board.builder().bno(replyDTO.getBno()).build();

Reply reply = Reply.builder()
.rno(replyDTO.getRno())
.text(replyDTO.getText())
.replyer(replyDTO.getReplyer())
.board(board).build();

return reply;
}
default ReplyDTO entityToDTO(Reply reply) {
ReplyDTO replyDTO = ReplyDTO.builder()
.rno(reply.getRno())
.text(reply.getText())
.replyer(reply.getReplyer())
.regDate(reply.getRegDate())
.modDate(reply.getModDate()).build();

return replyDTO;
}
}


ReplyServiceImpl 클래스

@Service
@RequiredArgsConstructor
public class ReplyServiceImpl implements ReplyService{

// final 키워드로 참조값 자동 주입
private final ReplyRepository replyRepository;

@Override // 등록
public Long register(ReplyDTO replyDTO) {

Reply reply = dtoToEntity(replyDTO);
replyRepository.save(reply);

return reply.getRno();
}

@Override // 목록
public List<ReplyDTO> getReplyList(Long bno) {

List<Reply> result = replyRepository
.getRepliesByBoardOrderByRno(Board.builder().bno(bno).build());
// Reply 객체를 ReplyDTO 객체로 변환해서 반환
return result.stream().map(reply -> entityToDTO(reply))
.collect(Collectors.toList());
}

@Override // 수정
public void modify(ReplyDTO replyDTO) {

Reply reply = dtoToEntity(replyDTO);
replyRepository.save(reply);

}

@Override // 삭제
public void remove(Long rno) {
replyRepository.deleteById(rno);
}
}


- 테스트 코드 작성

Test 패키지 안의 Service 패키지 안에 ReplyServiceTest 클래스 생성

@SpringBootTest
public class ReplyServiceTest {

@Autowired // 자동 주입
private ReplyService service;

}


목록 조회 기능 테스트 메소드 작성

@Test
public void testGetList() {
Long bno = 81L;

List<ReplyDTO> replyDTOList = service.getReplyList(bno);

replyDTOList.forEach(r -> System.out.println(r));
}




컨트롤러를 이용해서 조회 회면에서 Ajax로 댓글을 표시

댓글 데이터를 JSON으로 반들어서 처리 -> 별도의 화면은 필요 없고 데이터만 전송

=> @RestController 사용


controller 패키지에 ReplyController 클래스 추가

@RestController
@RequestMapping("replies/")
@Log4j2
@RequiredArgsConstructor
public class ReplyController {
// final 키워드로 인터페이스 참조값 자동 주입
private final ReplyService replyService;

}


게시글의 Bno를 전달받아 해당하는 댓글 리스트를 JSON으로 반환하는 메소드 작성

@GetMapping(value = "/board/{bno}", 
                       produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<ReplyDTO>>
                             getListByBoard(@PathVariable("bno")Long bno) {

return new ResponseEntity<>(replyService.getReplyList(bno),
                                      HttpStatus.OK);

}

@GetMapping을 이용해서 결로를 지정할 때 {}를 이용해서 경로 변수를 지정할 수 있음 -> 메소드 내에서 @PathVariable을 이용해서 처리

@GetMapping의 produces 값을 MediaType.APPLICATION_JSON_VALUE로 지정해서 JSON 형식으로 반환하도록 설정

ResponseEntity<> 객체는 HTTP 응답의 본문, 상태코드, 헤더등을 포함하는 객체로 <> 안에 본문의 타입을 지정

ReplyService에서 구현한 getReplyList 메소드로 해당 bno에 대한 댓글 리스트를 받아와서 ResponseEntity 객체를 생성해서 반환


프로젝트를 실행하고 localhost:8080/replies/board/bno값으로 접근하면 해당 댓글들을 JSON 형식으로 볼 수 있음



 




3) 조회 화면에서 처리

조회 화면에 게시물의 댓글 수를 표시하고 클릭하면 Ajax로 데이터 처리


read.html 의 아래쪽에 해당 글의 댓글 수를 표시하는 코드 추가

<!--  댓글 조회  -->
<div>
<div class="mt-4">
<h5><span class="badge replyCount">Reply Count
-[[ ${dto.replyCount} ]]-</span></h5>
</div>
<div class="list-group replyList">

</div>
</div>


badge 클래스의 스타일을 약간 수정

cmd or ctrl을 누른 체로 badge 클릭 -> 해당 css 페이지로 이동 -> 아래와 같이 수정

.badge {
--bs-badge-padding-x: 0.65em;
--bs-badge-padding-y: 0.65em; /* 수정 */
--bs-badge-font-size: 1em; /* 수정 */
--bs-badge-font-weight: 700;
--bs-badge-color: #fff;
--bs-badge-border-radius: 0.375rem;
background: #0f5132; /* 추가 */


* a태그인 버튼의 밑줄을 없애려면 css 파일에 아래 코드 추가

a {
text-decoration: none;
}


<실행결과>















Reply Count 를 클릭했을 때 동작하는 이벤트 처리 


jquery를 이용하기 때문에 상단에 jquery 를 사용하기 위한 코드 추가

<script src="https://code.jquery.com/jquery-3.7.0.js"></script>


이벤트 처리 코드는 하단에 <script> 코드를 이용해서 추가

<script th:inline="javascript">
$(document).ready(function() {
var bno = [[ ${dto.bno} ]];
var listGroup = $(".replyList");

$(".replyCount").click(function () {
$.getJSON("/replies/board/" + bno, function (arr) {
console.log(arr);
})
})
});
</script>

$(document).ready : 페이지가 완전히 로드된 후 실행

jquery의 getJSON을 이용해서 해당 댓글들을 JSON 으로 받아와서 콘솔창에 출력










4) 댓글 조회 기능 분리

댓글 조회 기능은 댓글이 추가되거나 삭제되는 상황에 다시 댓글을 가져와야 함

댓글을 가져오는 기능을 함수로 분리해서 여러 상황에서 호출해서 사용할 수 있도록 처리

날짜 처리를 위한 함수 구현 -> Date 타입의 데이터를 String으로 변환

댓글을 불러와서 <div> 태그를 생성해서 댓글 내용을 화면에 표시


read.html 파일 수정

$(document).ready(function() {  }) 안에(페이지가 완전히 준비되면 호출되는 함수) 날짜처리 함수와 댓글을 불러오는 함수 추가

$(document).ready(function() {
var bno = [[ ${dto.bno} ]];
var listGroup = $(".replyList");

// 날짜 처리 위한 함수 추가
function formatTime(str){
var date = new Date(str);

return date.getFullYear() + "/" +
(date.getMonth() + 1) + "/" +
date.getDate() + " " +
date.getHours() + ":" +
date.getMinutes();
}

// 특정 게시글의 댓글을 처리하는 함수 추가
function loadJSONData() {
$.getJSON("/replies/board/" + bno, function (arr){
console.log(arr);
var str = "";
// 댓글 수 갱신
$(".replyCount").html(" Reply Count -" + arr.length + "-");

// 각각의 댓글 요소를 받아와서 <div> 안에 댓글 내용 작성
$.each(arr, function (idx, reply){
console.log(reply);
str += "<div class='card-body' data-rno='" + reply.rno +
"'><b>" + reply.rno + "</b>";
str+="<h5 class='card-title'>" + reply.text + "</h5>";
str+="<h6 class='card-subtitle mb-2 text-muted'>" + reply.replyer +
"</h6>";
str+="<p class='card-text'>" + formatTime(reply.regDate) + "</p>";
str+="</div><br>"
});
listGroup.html(str);
});
}
});
...

getMonth() 함수는 1월을 0으로 반환하기 때문에 1을 더해줌

.html() 함수는 해당 클래스의 태그의 내용을 변경하는 함수

.each() 함수는 배열의 각 요소에 함수를 적용하는 함수. 매개변수로 배열과 함수를 전달하고 전달되는 함수의 매개변수로는 현제 요소의 인덱스와 현제 요소가 매개변수로 전달됨

불러온 각각의 댓글 요소를 이용해서 <div> 태그를 작성하고 미리 댓글을 표시할 영역으로 지정한 클래스가 'replyGroup' 인 <div> 태그의 내용에 .html() 함수를 이용해서 추가

클래스가 card-body인 <div>태그의 data-rno 속성은 사용자 정의 속성으로 - 다음에 속성의 이름을 지정. .data() 함수에 속성 이름을 전달해서 해당 값에 접근할 수 있음


기존에 Reply Count 를 클릭하면 호출되는 함수의 내용을 지우고 위에서 정의한 loadJSONData 함수를 호출해서 Reply Count를 클릭했을 때 댓글을 불러와서 화면에 표시하도록 구현

// 클릭 시 호출
$(".replyCount").click(function () {
loadJSONData();
})


<실행결과>














5) 댓글 처리를 위한 모달창 추가

버튼을 클릭하면 모달창을 띄우고 모달창에서 댓글 추가, 수정, 삭제 기능을 수행하도록 구현


Reply Count 버튼 위에 모달창을 띄울 Add Reply 버튼 추가

<div class="mt-4">
<!-- 댓글 추가 버튼 -->
<h5><span class="badge addReply">Add Reply</span></h5> <!-- 추가 -->
<h5><span class="badge replyCount">Reply Count
                                     -[[ ${dto.replyCount} ]]-</span></h5>
</div>









read.html의 Reply Count 버튼 아래쪽에 모달창 코드 추가

<!--  댓글 처리 모달창   -->
<!--tabindex를 -1로 해서 화면에 안보이게 함-->
<div class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">

<!-- 모달 해더 -->
<div class="modal-header">
<!--제목-->
<h5 class="modal-title">Reply</h5>
<!--X 버튼-->
<button type="button" class="btn-close"
data-bs-dismiss="modal" aria-label="Close">
</button>
</div>

<!--모달 바디-->
<div class="modal-body">
<!--댓글 입력창-->
<div class="form-group">
<input class="form-control" type="text" name="replyText"
placeholder="Reply Text...">
</div>
<!--작성자 입력창-->
<div class="form-group">
<input class="form-control" type="text" name="replyer"
placeholder="Replyer">
<!--안보이도록 hidden 타입, rno를 전달-->
<input type="hidden" name="rno">
</div>
</div>

<!--모달 푸터-->
<div class="modal-footer">
<button type="button" class="btn btn-danger replyRemove">
Remove</button> <!--삭제 버튼-->
<button type="button" class="btn btn-warning replyModify">
Modify</button> <!--수정 버튼-->
<button type="button" class="btn btn-primary replySave">
Save</button> <!--댓글등록 버튼-->
<button type="button" class="btn btn-outline-secondary replyClose">
Close</button> <!--취소 버튼-->
</div>
</div>
</div>
</div>




<script> 태그의 $(document).ready(function() {  }) 함수 안에 코드 추가

모달창을 다룰 변수를 선언

Add Reply 버튼 클릭시 모달창을 띄우도록 처리

Add Reply 버튼 클릭시에는 댓글 입력 작업을 수행해야 하므로 입력창의 내용을 비우고 모달 푸터의 모든 버튼을 안보이게 한 후에 필요한 버튼(Save, Close)만 보이도록 처리

// 댓글을 처리하는 모달창 변수
var modal = $(".modal");

// 댓글 추가 버튼 클릭시 호출
$(".addReply").click(function () {
// 모달창 띄우기
modal.modal("show");

// 댓글 입력창 초기화
$("input[name='replyText']").val("");
$("input[name='replyer']").val("");

// 모달창의 모든 버튼 안보이게 하기
$(".modal-footer .btn").hide();
// 댓글 입력에 필요한 버튼만 보이게 하기
$(".replySave, .replyClose").show();
});









* 모달창 닫기 버튼 기능 구현

$(document).read(function() { } ) 안에 코드 추가 

// 모달창 닫기
$(".replyClose").click(function () {
modal.modal("hide");
});



* 댓글 등록 구현

Save 버튼 클릭시 호출되는 함수를 정의해서 구현 

JSON 형태의 데이터로 처리하기 위해 javascript 객체로 데이터를 처리한 후 JSON.stringify() 함수를 이용해서 JSON 문자열로 변경

$.ajax() 함수를 이용해서 서버에 JSON 데이터 전송

// 댓글 저장
$(".replySave").click(function () {
var reply = { // 댓글 정보를 담은 객체
bno: bno,
text: $("input[name='replyText']").val(),
replyer: $("input[name='replyer']").val()
}
console.log(reply);

// JSON 포맷으로 변경해서 전송
$.ajax({
url: "/replies/", // 전송할 url
method: "post", // 요청 방식
data: JSON.stringify(reply), // JSON 문자열로 변환
contentType: "application/json; charset=utf-8",
dataType: "json",
success: function (data) { // 성공시 수행할 작업 지정
console.log(data);

var newRno = parseInt(data);

alert(newRno + "번 댓글이 등록되었습니다.");
modal.modal("hide"); // 모달 숨기기
loadJSONData(); // 댓글 갱신
}
});
});


ReplyController에 등록 처리

// 댓글 등록 처리
@PostMapping("")
public ResponseEntity<Long> register(@RequestBody ReplyDTO replyDTO){
log.info(replyDTO);
// DB의 댓글 테이블에 새로운 댓글 저장
Long rno = replyService.register(replyDTO);

return new ResponseEntity<>(rno, HttpStatus.OK);
}

@RequestBody : JSON으로 들어오는 데이터를 자동으로 해당 타입의 객체로 매핑


<실행결과>













* 댓글 클릭시 모달창 띄우기

댓글을 클릭하면 도달창을 띄워서 댓글의 삭제와 수정 작업 수행

$(document).ready(function() { } )안에 작성

댓글을 표시한 <div> 태그의 data-rno 속성으로 해당 댓글의 번호를 알아내고 나머지 요소(댓글 내용, 작성자)는 css 클래스를 이용해서 받아옴

<input> 태그에 해당 댓글의 내용과 작성자 표시

모든 버튼을 숨기고 필요한 버튼(Delete, Modify, Close)만 표시

// 댓글 클릭시 모달창 출력
// .on(호출할 이벤트, 하위 클래스명, 수행할 동작)
$(".replyList").on("click", ".card-body", function () {

// 클릭한 태그의 data-rno의 값을 받아서 변수에 저장
var rno = $(this).data("rno");

// <input> 태그에 해당 댓글의 내용과 작성자, rno 정보 입력
$("input[name='replyText']").val( $(this).find(".card-title").html());
$("input[name='replyer']").val( $(this).find(".card-subtitle").html());
$("input[name='rno']").val(rno);

$(".modal-footer .btn").hide(); // 모든 버튼 숨기기
$(".replyRemove, .replyModify, .replyClose").show(); // 필요한 버튼만 보이기

modal.modal('show'); // 모달 표시
});









* 댓글 수정 구현

PUT 방식으로 댓글의 번호, 게시물 번호, 내용, 작성자 전송

모든 내용을 하나의 javascript 객체로 구형하고 JSON.stringify() 함수를 이용해서 JSON 문자열로 변환해서 전송

컨트롤러에서는 성공하면 "success" 문자열을 반환하도록 구현


$(document).ready(function() { } ) 안에 코드 작성

// 댓글 수정
$(".replyModify").click(function () {
var rno = $("input[name='rno']").val(); // 모달창에 전달된 댓글 번호(hidden)

// 전송할 내용들을 javascript 객체로 구성
var reply = {
rno: rno,
bno: bno,
text: $("input[name='replyText']").val(),
replyer: $("input[name='replyer']").val()
}
console.log(reply);

// JSON으로 변환해서 전송
$.ajax({
url: "/replies/" + rno,
method: "put", // PUT 방식으로 요청
data: JSON.stringify(reply), // JSON 문자열로 변환
contentType: "application/json; charset=utf-8",
success: function (result) { // 성공시 호출
console.log("RESULT : " + result);

if (result == "success") {
alert("댓글이 수정되었습니다."); // 알림창 출력
modal.modal("hide"); // 모달창 숨기기
loadJSONData(); // 댓글 갱신
}
}
});
});


ReplyController에 댓글 수정 기능 구현

// 댓글 수정 처리
@PutMapping("/{rno}")
public ResponseEntity<String> modify(@RequestBody ReplyDTO replyDTO) {
log.info(replyDTO);

replyService.modify(replyDTO); // 댓글 수정

// 성공시 문자열"success" 반환
return new ResponseEntity<>("success", HttpStatus.OK);
}













* 댓글 삭제 구현

ajax를 이용해서 댓글의 번호를 DELETE 방식으로 전송

특정 댓글을 클릭하면 모달창을 띄우고 삭제 버튼을 클릭해서 삭제 처리

컨트롤러에서는 성공하면 "success" 문자열을 반환하도록 구현


$(document).ready(function() { } ) 안에 코드 작성

// 댓글 삭제
$(".replyRemove").on("click", function () {
var rno = $("input[name='rno']").val(); // 모달창에 전달된 댓글 번호(hidden)

// 댓글 번호 전송
$.ajax({
url: "/replies/" + rno, // url로 rno 전송
method: "delete", // DELETE 방식으로 요청
success: function (result) { // 성공시 호출
console.log("result : " + result)
if (result == "success"){ // 컨트롤러에서 반환한 결과가 "success"이면
alert("댓글이 삭제되었습니다."); // 알림창 출력
modal.modal("hide"); // 모달창 숨기기
loadJSONData(); // 댓글 갱신
}
}
});
});


ReplyController에 댓글 삭제 기능 구현

// 댓글 삭제 처리
@DeleteMapping("/{rno}")
public ResponseEntity<String> remove(@PathVariable("rno") Long rno){
log.info("DELETE rno : " + rno);

replyService.remove(rno); // 댓글 삭제

// 성공하면 문자열 "success" 반환
return new ResponseEntity<>("success", HttpStatus.OK);
}



















댓글

이 블로그의 인기 게시물

Spring Boot 2. Maria 데이터베이스 & Spring Data JPA

Django 10. DB 연동