JUINTINATION

코드로 배우는 스프링 부트 웹 프로젝트 Board Clone 본문

Java Spring Boot

코드로 배우는 스프링 부트 웹 프로젝트 Board Clone

DEOKJAE KWON 2024. 3. 10. 23:09
반응형

지난 Mac OS 자바 버전 여러 개 관리하기 글에서 잠깐 언급했듯이 스프링을 쓸 일이 생겨서 jdk 17 버전을 설치했었다. 이후에 ETRI에서 대여한 코드로 배우는 스프링 부트 웹 프로젝트 책과 해당 코드가 적힌 깃허브를 참고하여 게시판 프로젝트를 따라 치면서 스프링 공부를 시작했다. 물론 책의 버전과 지금 버전이 많이 달라져서 오류가 많이 발생했고 관련 내용은 네이버 카페의 QnA 게시판도 참고했다. 이제 그 내용을 차근차근 따라가보자.

 

구멍가게코딩단 : 네이버 카페

안녕하세요? 구멍가게 코딩단의 활동을 위한 카페입니다.

cafe.naver.com

프로젝트 생성

start.spring.io에서 다음과 같이 Thymeleaf, Lombok, Spring Data JPA, Spring Web, Spring Boot DevTools 의존성을 추가하여 org.zerock.guestbook 프로젝트를 생성한다.

이후에 Intellij에서 build.gradle 파일을 선택하고 Open as Project로 프로젝트를 실행한다. 이후에 MySQL 관련 내용과 querydsl 관련 내용을 다음과 같이 build.gradle에 추가한다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.3'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'org.zerock'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

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'
    runtimeOnly 'mysql:mysql-connector-java:8.0.31'
    annotationProcessor 'org.projectlombok:lombok'

    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
    useJUnitPlatform()
}

//sourceSets {
//    main {
//        java {
//            srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
//        }
//    }
//}
//
//compileJava.dependsOn('clean')

querydsl 관련 내용도 스프링 부트 버전이 달라지면서 책의 내용과 살짝 다른데 위와 같이 의존성 부분에 관련 내용을 추가하고 코드의 아래 부분에 있는 sourceSets 부분과 compileJava.dependsOn('clean') 부분을 추가해주고 실행한 이후에 지워준다. 다시 지워주는 이유는 기존의 Guestbook을 실행할 때도 마찬가지로 발생했던 오류 아닌 오류인데 화면이 한 번에 뜨는 것이 아니라 새로고침을 여러 번 눌러야 제대로 실행되는 문제가 있었다.

확인해보니 Q도메인 쪽에서 충돌이 일어난 것 처럼 보였는데 sourceSets 부분과 compileJava.dependsOn('clean') 부분을 지운 후에 다시 실행하니 문제없이 잘 실행됐다. 애초에 작성하지 않아도 정상적으로 실행되지는 않았던 것 같은데 이 부분은 더 확인이 필요할 것 같다.

이후에 프로젝트를 실행하면 JPA 때문에 오류가 나는데 해결을 위해 다음과 같이 application.properties 파일에 데이터베이스 관련 설정을 적어줘야 한다.

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/bootex
spring.datasource.username=root
spring.datasource.password=admin

spring.jpa.hibernate.ddl-auto=update

spring.jpa.properties.hibernate.format_sql=true

spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=debug

spring.thymeleaf.cache=false

책에서는 MariaDB를 사용했지만 나는 설치하기 귀찮아서 이미 설치되어있는 MySQL 관련 설정으로 세팅했다. 스키마 이름은 책과 동일하게 bootex로 진행했으며 나머지는 책에 있는 내용을 MySQL에 맞게 바꿔 설정했다.

프로젝트 내용

이 책에서도 부트스트랩에 있는 템플릿을 사용했다. 관련 파일들을 resources/static 폴더에 저장해두고(나는 그냥 깃허브에 있는 폴더 자체를 복붙했다.) resources/templates 폴더에 layout 폴더를 생성하고 깃허브에 있는 layout 폴더를 복붙했다. 나머지 java 코드들 관련 코드는 직접 따라 쳐보자. 책을 그대로 따라가면 글이 너무 쓸데없이 길어질 것 같아서 최종적인 코드만 적어볼까 한다. 커밋 내용과 함께 나의 진행 과정을 따라가고 싶다면 내 깃허브에 올려둔 코드를 참고하면 된다.

또한 resources/templates 폴더의 board 이라는 폴더에 실제로 보여지는 부분의 thymeleaf를 이용한 html 코드가 있는데 이 코드들까지 작성하면.. 글의 양을 감당할 수 없을 것 같아서 thymeleaf 관련 내용은 추후에 더 자세히 공부해 보는 것으로 하고 이 코드들을 보고 싶으면 깃허브로..

controller/BoardController.java

package org.zerock.board.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.zerock.board.dto.BoardDTO;
import org.zerock.board.dto.PageRequestDTO;
import org.zerock.board.service.BoardService;

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

    private final BoardService boardService;

    @GetMapping("/")
    public String index() {
        return "redirect:/board/list";
    }

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

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

    @PostMapping("/register")
    public String registerPost(BoardDTO dto, RedirectAttributes redirectAttributes) {
        log.info("dto..." + dto);
        Long bno = boardService.register(dto);
        log.info("BNO: " + bno);
        redirectAttributes.addFlashAttribute("msg", bno);
        return "redirect:/board/list";
    }

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

    @PostMapping("/modify")
    public String modify(BoardDTO dto, @ModelAttribute("requestDTO") PageRequestDTO requestDTO, RedirectAttributes redirectAttributes) {
        log.info("post modify.........................................");
        log.info("dto: " + dto);
        boardService.modify(dto);

        redirectAttributes.addAttribute("page", requestDTO.getPage());
        redirectAttributes.addAttribute("type", requestDTO.getType());
        redirectAttributes.addAttribute("keyword", requestDTO.getKeyword());
        redirectAttributes.addAttribute("bno",dto.getBno());
        return "redirect:/board/read";
    }

    @PostMapping("/remove")
    public String remove(long bno, RedirectAttributes redirectAttributes){
        log.info("bno: " + bno);
        boardService.removeWithReplies(bno);
        redirectAttributes.addFlashAttribute("msg", bno);
        return "redirect:/board/list";
    }
}

게시글 관련 컨트롤러이다. 각각 게시글 리스트를 볼 수 있는 기능, 게시글 작성 기능, 게시글 읽기 기능, 수정 및 삭제 기능을 수행한다.

controller/ReplyController.java

package org.zerock.board.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.zerock.board.dto.ReplyDTO;
import org.zerock.board.service.ReplyService;

import java.util.List;

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

    private final ReplyService replyService;

    @GetMapping(value = "/board/{bno}", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<List<ReplyDTO>> getListByBoard(@PathVariable("bno") Long bno) {
        log.info("bno: " + bno);
        return new ResponseEntity<>(replyService.getList(bno), HttpStatus.OK);
    }

    @PostMapping("/")
    public ResponseEntity<Long> register(@RequestBody ReplyDTO replyDTO) {
        log.info(replyDTO);
        Long rno = replyService.register(replyDTO);
        return new ResponseEntity<>(rno, HttpStatus.OK);
    }

    @DeleteMapping("/{rno}")
    public ResponseEntity<String> remove(@PathVariable("rno") Long rno) {
        log.info("RNO: " + rno );
        replyService.remove(rno);
        return new ResponseEntity<>("success", HttpStatus.OK);
    }

    @PutMapping("/{rno}")
    public ResponseEntity<String> modify(@RequestBody ReplyDTO replyDTO) {
        log.info(replyDTO);
        replyService.modify(replyDTO);
        return new ResponseEntity<>("success", HttpStatus.OK);
    }
}

게시글에 달리는 댓글 관련 컨트롤러이다. 각각 게시글에 있는 댓글 리스트를 볼 수 있는 기능, 게시글에 댓글을 작성하는 기능, 댓글의 삭제 및 수정 기능을 수행한다.

또한 BoardController와 다르게 @Controller 가 아닌 @RestController 어노테이션이 추가된 것을 확인할 수 있는데 RestController의 경우 모든 메서드의 리턴 타입을 기본으로 JSON을 사용한다. 메서드의 반환 타입은 ResponseEntity라는 객체를 이용하는데 이를 이용하면 HTTP의 상태 코드 등을 같이 전달할 수 있다.

@GetMapping() 에는 URL의 일부를 ()로 묶은 변수를 이용하는데 이는 메서드 내에서 @PathVariable 이라는 것으로 처리하는데 이를 활용하면 브라우저에서는 '/replies/board/100'과 같이 특정 게시물 번호로 조회할 때 '100'이라는 데이터를 변수로 처리하는 것이 가능해 진다.

entity/BaseEntity.java

package org.zerock.board.entity;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;

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

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

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

}

엔티티 클래스는 Spring Data JPA에서는 반드시 @Entity 라는 어노테이션을 추가해야 하며 @Entity 는 해당 클래스가 엔티티를 위한 클래스이며 해당 클래스의 인스턴스들이 JPA로 관리되는 엔티티 객체라는 것을 의미한다. 이러한 엔티티와 관련된 작업을 하다 보면 데이터의 등록 시간 및 수정 시간이 같이 자동으로 추가되고 변경되어야 하는 컬럼들이 있다. 이를 매번 프로그램 안에서 처리하는 일은 번거롭기 때문에 자동으로 처리할 수 있도록 어노테이션을 이용해서 설정한다.

BaseEntity 클래스에 @MappedSuperClass 라는 특별한 어노테이션이 적용되는데 해당 어노테이션이 적용된 클래스는 테이블로 생성되지 않고 실제 테이블은 BaseEntity 클래스를 상속한 엔티티의 클래스로 데이터베이스 테이블이 생성된다.

JPA 내부에서 엔티티 객체가 생성/변경되는 것을 감지하는 역할은 AuditingEntityListener를 통해 regDate, modDate에 적절한 값이 지정된다. @CreatedDate 는 JPA에서 엔티티의 생성 시간을 처리하고 @LastModifiedDate 는 최종 수정 시간을 자동으로 처리하는 용도로 사용한다. redDate는 updatable를 false로 설정하여 해당 엔티티 객체를 데이터베이스에 반영할 때 변경되지 않도록 하였다.

BoardApplication.java

package org.zerock.board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class BoardApplication {

    public static void main(String[] args) {
        SpringApplication.run(BoardApplication.class, args);
    }

}

JPA를 이용하면서 AuditingEntityListener를 활성화시키기 위해 프로젝트에 @EnableJpaAuditing 설정을 추가해야 하므로 위와 같이 BoardApplication을 수정해야 정상적으로 적용된다.

entity/Member.java

package org.zerock.board.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.*;

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

    @Id
    private String email;

    private String password;

    private String name;
}

entity/Board.java

package org.zerock.board.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "writer")
public class Board extends BaseEntity {

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

    private String title;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;

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

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

entity/Reply.java

package org.zerock.board.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@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;
}

위와 같이 엔티티 클래스 작성 이후에 repository를 구현한다.

repository/MemberRepository.java

package org.zerock.board.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.zerock.board.entity.Member;

public interface MemberRepository extends JpaRepository<Member, String> {
}

JpaRepository를 사용할 때는 엔티티의 타입 정보와 @Id의 타입을 지정하게 되는데 이처럼 Spring Data JPA는 인터페이스 선언만으로도 자동으로 스프링 빈(bean)으로 등록된다.

repository/MemberRepositoryTests.java

package org.zerock.board.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.board.entity.Member;

import java.util.stream.IntStream;

@SpringBootTest
public class MemberRepositoryTests {

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void insertMembers() {
        IntStream.rangeClosed(1, 100).forEach(i -> {
            Member member = Member.builder()
                    .email("user" + i + "@test.com")
                    .password("1234")
                    .name("USER" + i)
                    .build();
            memberRepository.save(member);
        });
    }
}
  • insertMembers 테스트를 진행하면 100개의 더미 멤버 데이터를 데이터베이스에 저장하게 된다. 이후에 다른 테스트를 위해 필요하기 때문에 데이터를 다시 지우지는 않는다.

repository/search/SearchBoardRepository.java

package org.zerock.board.repository.search;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

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

SearchBoardRepository는 아래의 다른 Repository에서 쿼리 메서드나 @Query 등으로 처리할 수 없는 기능인 페이지 검색 기능을 별도의 인터페이스로 만든 것이다. 아래의 구현 클래스에 인터페이스의 기능을 Q도메인 클래스와 JPQLQuery를 이용하여 구현한다.

repository/search/SearchBoardRepositoryImpl.java

package org.zerock.board.repository.search;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.Order;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.JPQLQuery;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.QBoard;
import org.zerock.board.entity.QMember;
import org.zerock.board.entity.QReply;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

@Log4j2
public class SearchBoardRepositoryImpl extends QuerydslRepositorySupport implements SearchBoardRepository {

    public SearchBoardRepositoryImpl() {
        super(Board.class);
    }

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


        log.info("searchPage.............................");

        QBoard board = QBoard.board;
        QReply reply = QReply.reply;
        QMember member = QMember.member;

        JPQLQuery<Board> jpqlQuery = from(board);
        jpqlQuery.leftJoin(member).on(board.writer.eq(member));
        jpqlQuery.leftJoin(reply).on(reply.board.eq(board));

        // SELECT b, w, count(r) FROM Board b
        // LEFT JOIN b.writer w LEFT JOIN Reply r ON r.board = b
        JPQLQuery<Tuple> tuple = jpqlQuery.select(board, member, reply.count());

        BooleanBuilder booleanBuilder = new BooleanBuilder();
        BooleanExpression expression = board.bno.gt(0L);
        booleanBuilder.and(expression);

        if (type != null){
            String[] typeArr = type.split("");
            BooleanBuilder conditionBuilder = new BooleanBuilder();
            for (String t:typeArr) {
                switch (t) {
                    case "t" -> conditionBuilder.or(board.title.contains(keyword));
                    case "w" -> conditionBuilder.or(member.email.contains(keyword));
                    case "c" -> conditionBuilder.or(board.content.contains(keyword));
                }
            }
            booleanBuilder.and(conditionBuilder);
        }

        tuple.where(booleanBuilder);

        tuple.groupBy(board);

        Objects.requireNonNull(this.getQuerydsl()).applyPagination(pageable, tuple);

        List<Tuple> result = tuple.fetch();
        log.info(result);

        long count = tuple.fetchCount();
        log.info("COUNT: " + count);

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

SearchBoardRepositoryImpl 클래스에서 가장 중요한 점은 QuerydslRepositorySupport 클래스를 상속한다는 점이다. QuerydslRepositorySupport 클래스는 Spring Data JPA에 포함된 클래스로 Querydsl 라이브러리를 이용하여 직접 무언가를 구현할 때 사용하며 생성자가 존재하기 때문에 클래스 내에서 super() 메서드를 이용하여 호출해야 한다. 이 때 도메인 클래스를 지정하는데 null 값은 넣을 수 없다.

JPQLQuery로 다른 엔티티와 조인을 처리하기 위해서는 join() 혹은 leftJoin(), rightJoin() 등을 이용하고 필요한 경우 on()을 이용하여 조인에 필요한 부분을 완성할 수 있으며 여기서 조인된 테이블에서 select(board, member, reply.count())를 사용하여 필요한 부분만 선택하여 tuple에 저장하게 된다. 이후에 QueryDSL의 BooleanBuilder를 사용하여 동적 쿼리를 구성하는데 BooleanExpression expression = board.bno.gt(0L) 을 통해 Board의 bno가 0보다 큰지를 확인하는 조건을 추가한다. 검색 유형(type)에 따라 제목(t), 작성자 이메일(w), 내용(c)에 대한 검색 조건을 추가하고 tuple에 booleanBuilder로 쿼리를 필터링한 후에 tuple.groupBy(board)로 그룹화한다.

마지막으로 페이지네이션을 적용하여 결과를 해당 페이지의 내용으로 제한한 후에 검색 결과를 Page 객체로 변환하여 반환한다. 이때, 각 결과는 배열로 구성되어 있으며, 페이지 정보와 결과의 총 수를 포함한다.

repository/BoardRepository.java

package org.zerock.board.repository;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.zerock.board.entity.Board;
import org.zerock.board.repository.search.SearchBoardRepository;

import java.util.List;

public interface BoardRepository extends JpaRepository<Board, Long>, SearchBoardRepository {
    @Query("select b, w from Board b left join b.writer w where b.bno = :bno")
    Object getBoardWithWriter(@Param("bno") Long bno);

    @Query("SELECT b, r FROM Board b LEFT JOIN Reply r ON r.board = b WHERE b.bno = :bno")
    List<Object[]> getBoardWithReplies(@Param("bno") Long bno);

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

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

BoardRepository에는 아래 2개의 Repository와 달리 JpaRepository 뿐만 아니라 위에서 만든 SearchBoardRepository 인터페이스를 추가로 상속받게 한다.

게시글 리스트 화면에서 게시글의 정보(제목, 작성자 등)와 함께 댓글의 수를 같이 가져오기 위해서 위에서 설명한 JPQL의 조인(join)을 이용해서 처리한다.

repository/BoardRepositoryTests.java

package org.zerock.board.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Member;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.IntStream;

@SpringBootTest
public class BoardRepositoryTests {

    @Autowired
    private BoardRepository boardRepository;

    @Test
    public void insertBoards() {
        IntStream.rangeClosed(1, 100).forEach(i -> {
            Member member = Member.builder().email("user" + i + "@test.com").build();
            Board board = Board.builder()
                    .title("Title..." + i)
                    .content("Content..." + i)
                    .writer(member)
                    .build();
            boardRepository.save(board);
        });
    }

    @Transactional
    @Test
    public void readBoard() {
        Optional<Board> result = boardRepository.findById(100L);
        Board board = result.get();
        System.out.println(board);
        System.out.println(board.getWriter());
    }

    @Test
    public void readBoardWithWriter() {
        Object result = boardRepository.getBoardWithWriter(100L);
        Object[] arr = (Object[]) result;
        System.out.println("-------------------------------");
        System.out.println(Arrays.toString(arr));
    }

    @Test
    public void readBoardWithReplies() {
        List<Object[]> result = boardRepository.getBoardWithReplies(100L);
        for (Object[] arr : result) {
            System.out.println(Arrays.toString(arr));
        }
    }

    @Test
    public void readBoardWithReplyCount(){
        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));
        });
    }

    @Test
    public void readBoardByBno() {
        Object result = boardRepository.getBoardByBno(100L);
        Object[] arr = (Object[])result;
        System.out.println(Arrays.toString(arr));
    }

    @Test
    public void testSearchPage() {
        Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending()
                .and(Sort.by("title").ascending()));
        Page<Object[]> result = boardRepository.searchPage("w", "55", pageable);
        result.get().forEach(row -> {
            Object[] arr = (Object[])row;
            System.out.println(Arrays.toString(arr));
        });
    }
}
  • insertBoards 테스트를 진행하면 이전에 MemberRepositoryTests에서 만든 1번부터 100번의 id를 가진 멤버가 각각 1개의 더미 게시글 데이터를 데이터베이스에 저장하게 된다.
    • 저장된 데이터들을 보면 생성 시간과 수정 시간을 지정하지 않았음에도 자동으로 null이 아닌 값으로 생성되는 것을 확인할 수 있다.
  • readBoard 테스트를 진행하면 100번의 id를 갖는 게시글과 해당 작성자를 findById() 메서드를 통해 가져오게 된다.
    • 이 때 entity/Board에서 Member writer가 @ManyToOne 어노테이션에 fetch 속성이 FetchType.LAZY이므로 지연 로딩이 적용되어 있기 때문에 @Transactional 어노테이션을 사용하여 해당 메서드를 하나의 트랜잭션으로 처리해야 한다.
  • readBoardWithWriter 테스트를 진행하면 100번의 id를 갖는 게시글과 해당 작성자를 getBoardWithWriter() 메서드를 통해 가져오게 된다 .
    • 위의 findById() 메서드를 사용한 readBoard 테스트와 다르게 조인 처리가 되어 한 번에 board 테이블과 member 테이블을 이용하는 것을 확인할 수 있다.
  • readBoardWithReplies 테스트를 진행하면 100번의 id를 갖는 게시글과 해당 작성자를 getBoardWithReplies() 메서드를 통해 가져오게 된다 .
    • 마찬가지로 위의 findById() 메서드를 사용한 readBoard 테스트와 다르게 조인 처리가 되어 한 번에 board 테이블과 member 테이블을 이용하는 것을 확인할 수 있다.
  • readBoardWithReplyCount 테스트를 진행하면 1 페이지의 데이터를 처리한다고 가정하고 페이지 번호는 0으로 지정하여 10개의 게시글 정보, 작성자 정보, 댓글 개수를 출력한다.
    • insertBoards 테스트를 통해 100개의 더미 게시글을 만들었기 때문에 bno 기준으로 100부터 91까지의 정보가 나오게 될 것이다.
  • readBoardByBno 테스트를 진행하면 100번의 id를 갖는 게시글과 해당 작성자를 getBoardByBno() 메서드를 통해 가져오게 된다.
    • 마찬가지로 위의 findById() 메서드를 사용한 readBoard 테스트와 다르게 조인 처리가 되어 한 번에 board 테이블과 member 테이블을 이용하는 것을 확인할 수 있다.
  • testSearchPage 테스트를 진행하면 검색 조건과 페이지네이션을 이용하여 작성자에 "55"가 포함되는 검색 결과를 출력하게 된다.
    • 300개의 더미 데이터가 있으니 gno가 291, 281, ..., 218인 엔티티가 출력될 것이다.

repository/ReplyRepository.java

package org.zerock.board.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Reply;

import java.util.List;

public interface ReplyRepository extends JpaRepository<Reply, Long> {
    @Modifying
    @Query("delete from Reply r where r.board.bno = :bno")
    void deleteByBno(@Param("bno") Long bno);
    List<Reply> getRepliesByBoardOrderByRno(Board board);
}

repository/ReplyRepositoryTests.java

package org.zerock.board.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Reply;

import java.util.List;
import java.util.Optional;
import java.util.stream.IntStream;

@SpringBootTest
public class ReplyRepositoryTests {

    @Autowired
    private ReplyRepository replyRepository;

    @Test
    public void insertReplies() {
        IntStream.rangeClosed(1, 300).forEach(i -> {
            long bno = (long) (Math.random() * 100) + 2;
            Board board = Board.builder().bno(bno).build();
            Reply reply = Reply.builder()
                    .text("Reply......." + i)
                    .board(board)
                    .replyer("guest")
                    .build();
            replyRepository.save(reply);
        });
    }

    @Transactional
    @Test
    public void readReply() {
        Optional<Reply> result = replyRepository.findById(1L);
        Reply reply = result.get();
        System.out.println(reply);
        System.out.println(reply.getBoard());
    }

    @Test
    public void readListByBoard() {
        List<Reply> replyList = replyRepository.getRepliesByBoardOrderByRno(Board.builder().bno(97L).build());
        replyList.forEach(reply -> System.out.println(reply));
    }
}
  • insertReplies 테스트를 진행하면 이전에 2번부터 100번 사이의 게시글 중 랜덤으로 300개를 골라 댓글을 데이터베이스에 저장하게 된다.
    • 2번부터 테스트하는 이유는 추후에 작성할 테스트에서 1번 게시글을 지우는 테스트가 있기 때문이다.
    • 이렇게 테스트를 통해 실제 사용할 데이터베이스에 영향을 미치면 좋지 않기 때문에 추후에 다른 프로젝트에 이러한 테스트를 적용할 때는 H2 데이터베이스를 사용하여 실제 데이터베이스에는 영향을 미치지 않도록 수정할 예정이다.
  • readReply 테스트를 진행하면 위에서 생성한 댓글 중 1번 댓글을 findById() 메서드를 통해 가져오게 된다.
    • 이 때 entity/Reply에서 Board board가 @ManyToOne 어노테이션에 fetch 속성이 FetchType.LAZY이므로 지연 로딩이 적용되어 있기 때문에 @Transactional 어노테이션을 사용하여 해당 메서드를 하나의 트랜잭션으로 처리해야 한다.
  • readListByBoard 테스트를 진행하면 97번 게시글에 있는 모든 댓글들을 getRepliesByBoardOrderByRno() 메서드를 통해 가져오게 된다.

dto/BoardDTO.java

package org.zerock.board.dto;

import lombok.*;

import java.time.LocalDateTime;

@Data
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BoardDTO {
    private Long bno;
    private String title, content;
    private String writerEmail, writerName;
    private LocalDateTime regDate, modDate;
    private int replyCount;
}

BoardDTO 클래스가 Board 엔티티 클래스와 다른 점은 Member를 참조하는 대신에 화면에서 필요한 작성자의 이메일(writerEmail)과 작성자의 이름(writerName)으로 처리한다는 점이다. 목록 화면에서도 BoardDTO를 이용하기 때문에 댓글의 개수를 의미하는 replyCount도 추가했다.

dto/ReplyDTO.java

package org.zerock.board.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

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

ReplyDTO는 Reply 엔티티 클래스와 유사하게 작성되지만 게시글의 번호만을 가지는 형태로 작성한다.

dto/PageRequestDTO.java

package org.zerock.board.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

@Builder
@AllArgsConstructor
@Data
public class PageRequestDTO {

    private int page, size;
    private String type, keyword;

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

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

PageRequestDTO는 화면에서 전달되는 page라는 파라미터와 size라는 파라미터를 수정하는 역할을 하나. 다만 이들은 기본값을 가지는 것이 좋기 때문에 각각 1과 10이라는 값을 이용한다.

PageRequestDTO의 진짜 목적은 JPA 쪽에서 사용하는 Pageable 타입의 객체를 생성하는 것이다. 나중에 수정의 여지(페이지 번호에 음수가 들어오는 등)가 있기는 하지만 JPA를 사용하는 경우에는 페이지 번호가 0부터 시작한다는 점을 감안하여 1페이지의 경우 0이 될 수 있도록 page - 1을 하는 형태로 작성한다. 정렬은 나중에 다양한 상황에서 쓰기 위해 별도의 파라미터로 받도록 설계했다.

dto/PageResultDTO.java

package org.zerock.board.dto;

import lombok.Data;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@Data
public class PageResultDTO<DTO, EN> {

    private List<DTO> dtoList;
    private List<Integer> pageList;
    private int totalPage, page, size;
    private int start, end;
    private boolean prev, next;

    public PageResultDTO(Page<EN> result, Function<EN, DTO> fn) {
        dtoList = result.stream().map(fn).collect(Collectors.toList());
        totalPage = result.getTotalPages();
        makePageList(result.getPageable());
    }

    private void makePageList(Pageable pageable) {

        this.page = pageable.getPageNumber() + 1;
        this.size = pageable.getPageSize();

        int tempEnd = (int) (Math.ceil(page / (double) size)) * 10;
        start = tempEnd - (size - 1);
        prev = start > 1;
        end = totalPage > tempEnd ? tempEnd : totalPage;
        next = totalPage > tempEnd;
        pageList = IntStream.rangeClosed(start, end).boxed().collect(Collectors.toList());
    }
}

JPA를 사용하는 Repository에서는 페이지 처리 결과를 Page 타입으로 반환하게 된다. 따라서 서비스 계층에서 이를 처리하기 위해서 크게 다음과 같은 내용의 별도 클래스를 만들어서 처리해야 한다.

  • Page의 엔티티 객체들을 DTO 객체로 변환하여 자료구조로 담아 주어야 한다.
  • 화면 출력에 필요한 페이지 정보들을 구성해 주어야 한다.

또한 페이징 처리를 하기 위해 화면에 10개씩 페이지 번호를 출력한다고 가정한다면 예를 들어 사용자가 5페이지를 본다면 화면의 페이지 번호는 1부터 시작하고 19페이지를 본다면 11부터 시작해야 하므로 현재 사용자가 보고 있는 페이지의 정보가 필요하다.

현재 사용자가 보고 있는 페이지의 정보를 pageable.getPageNumber() + 1로 설정하고 pageable.getPageSize()를 이용하여 페이지 사이즈(10)을 가져왔을 때 아래 조건을 통해 시작 페이지(start)와 끝 페이지(end) 번호를 설정하고 pageList를 만든다.

service/BoardService.java

package org.zerock.board.service;

import org.zerock.board.dto.BoardDTO;
import org.zerock.board.dto.PageRequestDTO;
import org.zerock.board.dto.PageResultDTO;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Member;

public interface BoardService {

    Long register(BoardDTO dto);
    PageResultDTO<BoardDTO, Object[]> getList(PageRequestDTO pageRequestDTO);
    BoardDTO get(Long bno);
    void removeWithReplies(Long bno);
    void modify(BoardDTO boardDTO);

    default Board dtoToEntity(BoardDTO dto) {
        Member member = Member.builder().email(dto.getWriterEmail()).build();
        Board board = Board.builder()
                .bno(dto.getBno())
                .title(dto.getTitle())
                .content(dto.getContent())
                .writer(member)
                .build();
        return board;
    }

    default BoardDTO entityToDTO(Board board, Member member, Long replyCount) {
        BoardDTO boardDTO = BoardDTO.builder()
                .bno(board.getBno())
                .title(board.getTitle())
                .content(board.getContent())
                .regDate(board.getRegDate())
                .modDate(board.getModDate())
                .writerEmail(member.getEmail())
                .writerName(member.getName())
                .replyCount(replyCount.intValue())
                .build();
        return boardDTO;
    }
}

BoardService 인터페이스는 차례로 새로운 게시글 등록, 게시글 리스트 불러오기, 게시글 읽기, 게시글 수정 및 삭제 시나리오를 처리한다. 또한 DTO를 엔티티로 변환하거나 엔티티를 DTO로 변환하는 기능을 처리하는데 ModelMapper 라이브러리나 MapStruct 등을 이용하기도 한다고 한다. 이 책에서는 이를 직접 default 메서드로 구현했다.

service/BoardServiceImpl.java

package org.zerock.board.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.zerock.board.dto.BoardDTO;
import org.zerock.board.dto.PageRequestDTO;
import org.zerock.board.dto.PageResultDTO;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Member;
import org.zerock.board.repository.BoardRepository;
import org.zerock.board.repository.ReplyRepository;

import java.util.Optional;
import java.util.function.Function;

@Service
@RequiredArgsConstructor
@Log4j2
public class BoardServiceImpl implements BoardService {

    private final BoardRepository repository;
    private final ReplyRepository replyRepository;

    @Override
    public Long register(BoardDTO dto) {
        log.info(dto);
        Board board = dtoToEntity(dto);
        repository.save(board);
        return board.getBno();
    }

    @Override
    public PageResultDTO<BoardDTO, Object[]> getList(PageRequestDTO pageRequestDTO) {
        log.info(pageRequestDTO);
        Function<Object[], BoardDTO> fn = (en -> entityToDTO((Board)en[0],(Member)en[1],(Long)en[2]));

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

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

    @Override
    public BoardDTO get(Long bno) {
        Object result = repository.getBoardByBno(bno);
        Object[] arr = (Object[]) result;
        return entityToDTO((Board) arr[0], (Member) arr[1], (Long) arr[2]);
    }

    @Transactional
    @Override
    public void removeWithReplies(Long bno) {
        replyRepository.deleteByBno(bno);
        repository.deleteById(bno);
    }

    @Override
    public void modify(BoardDTO boardDTO) {
        Optional<Board> optionalBoard = repository.findById(boardDTO.getBno());
        if (optionalBoard.isPresent()) {
            Board board = optionalBoard.get();
            board.changeTitle(boardDTO.getTitle());
            board.changeContent(boardDTO.getContent());
            repository.save(board);
        }
    }
}

BoardServiceImpl 클래스는 BoardService 인터페이스를 상속받고 스프링에서 빈으로 처리되도록 @Service 어노테이션을 추가한다.

service/BoardServiceTests.java

package org.zerock.board.service;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.board.dto.BoardDTO;
import org.zerock.board.dto.PageRequestDTO;
import org.zerock.board.dto.PageResultDTO;

@SpringBootTest
public class BoardServiceTests {

    @Autowired
    private BoardService boardService;

    @Test
    public void testRegister() {
        BoardDTO dto = BoardDTO.builder()
                .title("Test.")
                .content("Test...")
                .writerEmail("user55@test.com")
                .build();
        System.out.println(boardService.register(dto));
    }

    @Test
    public void testList() {
        PageRequestDTO pageRequestDTO = new PageRequestDTO();
        PageResultDTO<BoardDTO, Object[]> result = boardService.getList(pageRequestDTO);
        for (BoardDTO boardDTO : result.getDtoList()) {
            System.out.println(boardDTO);
        }
    }

    @Test
    public void testGet() {
        Long bno = 100L;
        BoardDTO boardDTO = boardService.get(bno);
        System.out.println(boardDTO);
    }

    @Test
    public void testRemove() {
        Long bno = 1L;
        boardService.removeWithReplies(bno);
    }

    @Test
    public void testModify() {
        BoardDTO boardDTO = BoardDTO.builder()
                .bno(2L)
                .title("Changed Title")
                .content("Changed Content")
                .build();
        boardService.modify(boardDTO);
    }
}
  • testRegister 테스트를 진행하면 55번 유저가 예시 게시글을 DTO를 사용하여 데이터베이스에 저장한다.
  • testList 테스트를 진행하면 PageResultDTO를 통해 페이지 번호를 1로, 페이지 크기를 10으로 지정하여 PageResultDTO 객체에서 이전 페이지 여부(isPrev()), 다음 페이지 여부(isNext()), 총 페이지 수(getTotalPage()) 및 PageResultDTO 객체에 포함된 GuestbookDTO 객체 목록을 출력한다.
    • 페이지가 1이므로 PREV는 false, NEXT는 true가 출력될 것이며 마지막에 i는 1부터 10까지 순서대로 출력될 것이다.
  • testGet 테스트를 진행하면 100번 게시글을 DTO를 사용하여 가져온다.
  • testRemove 테스트를 진행하면 1번 게시글을 지우게 된다.
    • 이 부분 때문에 위에서 insertReplies 테스트를 진행할 때 2번 게시글부터 랜덤으로 선택한 것이다.
  • testModify 테스트를 진행하면 2번 게시글의 정보를 위와 같이 수정하게 된다.

service/ReplyService.java

package org.zerock.board.service;

import org.zerock.board.dto.ReplyDTO;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Reply;

import java.util.List;

public interface ReplyService {

    Long register(ReplyDTO replyDTO);
    List<ReplyDTO> getList(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 dto = ReplyDTO.builder()
                .rno(reply.getRno())
                .text(reply.getText())
                .replyer(reply.getReplyer())
                .regDate(reply.getRegDate())
                .modDate(reply.getModDate())
                .build();

        return dto;
    }
}

ReplyService 인터페이스는 차례로 새로운 댓글 등록, 댓글 리스트 불러오기, 댓글 읽기, 댓글 수정 및 삭제 시나리오를 처리한다. 또한 BoardService 인터페이스와 마찬가지로 DTO를 엔티티로 변환하거나 엔티티를 DTO로 변환하는 기능을 처리할 때 이를 직접 default 메서드로 구현했다.

service/ReplyServiceImpl.java

package org.zerock.board.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import org.zerock.board.dto.ReplyDTO;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Reply;
import org.zerock.board.repository.ReplyRepository;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class ReplyServiceImpl implements ReplyService {

    private final ReplyRepository replyRepository;

    @Override
    public Long register(ReplyDTO replyDTO) {
        Reply reply = dtoToEntity(replyDTO);
        replyRepository.save(reply);
        return reply.getRno();
    }

    @Override
    public List<ReplyDTO> getList(Long bno) {
        List<Reply> result =  replyRepository
                .getRepliesByBoardOrderByRno(Board.builder().bno(bno).build());
        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);
    }
}

ReplyServiceImpl 클래스는 ReplyService 인터페이스를 상속받고 BoardServiceImpl 클래스와 마찬가지로 스프링에서 빈으로 처리되도록 @Service 어노테이션을 추가한다.

service/ReplyServiceTests.java

package org.zerock.board.service;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.board.dto.ReplyDTO;

import java.util.List;

@SpringBootTest
public class ReplyServiceTests {

    @Autowired
    private ReplyService replyService;

    @Test
    public void testGetList() {
        Long bno = 97L;
        List<ReplyDTO> replyDTOList = replyService.getList(bno);
        replyDTOList.forEach(replyDTO -> System.out.println(replyDTO));
    }
}
  • testGetList 테스트를 진행하면 100번 게시글의 댓글 리스트를 출력하게 된다.

터미널로 실행하기

실행 화면을 보여주기 전에 먼저 기존에 IntelliJ에서 BoardApplication을 run해야 실행됐는데 터미널로 실행하는 것을 보여주고 시작하겠다.

이전에도 시도했었는데 계속 오류가 나길래 확인해보니 jdk 버전 문제였다. 처음에 지난 Mac OS 자바 버전 여러 개 관리하기 글에서 jdk 버전을 전체적으로 11로 맞췄으면서 이 프로젝트에 대해 로컬 버전을 17로 맞추지 않았었으며 BoardApplication을 run했을 때와 달리 터미널로 모두 실행을 완료한 이후에도 페이지가 제대로 뜨지 않는 현상이 있었는데 결론적으로 이 문제는 Querydsl 문제도 아니었고 컨트롤러 문제도 아니었으며 황당하게도 thymeleaf 문법 문제였다. 이 부분은 내 깃허브에 수정하여 올려두었으니 확인해보면 될 것 같다.

위와 같이 $ ./gradlew clean build -x test 명령어를 실행하여 테스트 코드를 실행하지 않고 빌드한다.

이후에 $ cd build/libs 명령어를 실행하여 해당 디렉터리로 이동한 후 $ java -jar board-0.0.1-SNAPSHOT.jar 명령어를 실행하면 끝이다. 아무튼 이걸 테스트하는 과정에서 더 알게된 것도 많고 남아있던 버그들도 몇 개 수정하게 되었다.


실행 화면

애플리케이션을 실행한 후에 localhost:8080/board/list에 접속하면 위와 같이 1페이지의 게시글 리스트들이 보인다.

여기서 register 버튼을 누르면 위와 같이 새로운 게시글을 등록할 수 있는 페이지가 나온다. 모든 내용을 작성한 이후에 Submit 버튼을 누르면 해당 게시글이 데이터베이스에 저장되고 아래와 같이 1페이지의 방명록 리스트들이 보이는 페이지로 리디렉션된다.

이후에 방금 작성한 게시글의 bno인 103을 누르면 아래와 같이 해당 게시글을 볼 수 있는 페이지로 이동한다.

이 페이지에서 Modify 버튼을 누르면 아래와 같이 해당 게시글을 수정할 수 있는 페이지로 넘어가게 되며 List 버튼을 누르면 기존의 1페이지의 방명록 리스트들이 보이는 페이지로 리디렉션된다.

게시글은 제목과 내용만 수정할 수 있으며 수정된 이후에는 수정된 게시글의 내용을 다시 보여준다. 또한 이 페이지에서 Remove 버튼을 누르게 되면 데이터베이스에서 해당 방명록이 삭제되고 아래와 같이 자동으로 1페이지의 게시글 리스트들이 보이는 페이지로 리디렉션된다.

예시로 99번 게시글에 들어가서 Reply Count 버튼을 누르게 되면 아래와 같이 댓글 목록이 뜨게 된다.

여기서 Add Reply 버튼을 누르게 되면 아래와 같이 댓글을 추가할 수 있는 모달 창이 뜨게 된다.

여기서 모든 내용을 입력한 후에 Save 버튼을 누르게 되면 다음과 같이 맨 아래에 방금 추가한 댓글을 볼 수 있다.

여기서 댓글을 클릭하게 되면 해당 댓글을 삭제하거나 수정할 수 있는 모달창이 뜨게 된다.


결론

위에서 언급한 프로젝트의 문제점들을 디버깅하는 과정에서 아직 내가 모르는 것이 많다는 것을 느끼게 되었다. 어노테이션 관련한 추가적인 공부가 끊임없이 생길 것 같다는 생각이 들었고 나중에 시간이 된다면 이 프로젝트에도 도커라이징을 해볼 생각이다. 로그인도 적용되지 않은 아주아주 간단한 프로젝트지만 이를 기반으로 스프링 부트를 활용한 나만의 토이 프로젝트를 진행할 생각에 설렘 반 걱정 반이다.

728x90
Comments