헤르메스 LIFE

[Spring Boot] 게시판 #4 - 게시판 + 댓글 본문

Spring Boot Framework

[Spring Boot] 게시판 #4 - 게시판 + 댓글

헤르메스의날개 2023. 5. 2. 00:26
728x90

octopus_bbs.zip
1.29MB

게시판을 작성해보려 합니다. 조금씩 살을 붙여나가 보려고 합니다.

게시판 목록의 디자인 및 일부 소스는 도뎡님의 허락을 받아 사용했습니다. 도뎡님은 MyBatis를 사용했고, 저는 JPA를 사용했습니다. 소스를 비교해보시는 것도 좋을 것 같습니다.

https://congsong.tistory.com/26

 

스프링 부트(Spring Boot) - 페이징(Paging) & 검색(Search) 처리하기 1/2 [Thymeleaf, MariaDB, IntelliJ, Gradle, MyBat

본 게시판 프로젝트는 단계별(step by step)로 진행되니, 이전 단계를 진행하시는 것을 권장드립니다. DBMS 툴은 DBeaver를 이용하며, DB는 MariaDB를 이용합니다. (MariaDB 설치하기) 화면 처리는 HTML5 기반

congsong.tistory.com


개발환경

STS 4.17.2.RELEASE

OpenJDK Runtime Environment Zulu11.62+17-CA (build 11.0.18+10-LTS)

Spring Boot 2.7.9

lombok

devtools

postgresql 14.1

Gladle

Thymeleaf


build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.10'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'octopus'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
    // Default : 2.7.10
	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'
    // Default : 2.0.0.RELEASE
    implementation 'org.springframework.plugin:spring-plugin-core:2.0.0.RELEASE'
	
	// Model Mapper 라이브러리 추가
    implementation 'org.modelmapper:modelmapper:2.4.2'
	
	// jpa query logging
    implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.1'
	
	// Thymeleaf Layout
	implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
	
	// jackson-databind
    implementation 'com.fasterxml.jackson.core:jackson-databind'
	
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'org.postgresql:postgresql'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

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

application.yml

##############################################################
# Server port
server:
  servlet:
    context-path: /
    encoding:
      enabled: true
      charset: UTF-8
      force: true
    session:
      timeout: 18000                              # 30분, Default 기본단위 : 초
      
  tomcat:
    uri-encoding: UTF-8                         # Spring Default : UTF-8
##############################################################
# -Dspring.profiles.active=local
spring:
  profiles:
    include: local
  thymeleaf:
    cache: false

##############################################################
# Spring Message 처리
  messages:
    basename: messages/messages, messages/exception		# 각각 ResourceBundle 규칙을 따르는 쉼표로 구분된 기본 이름 목록
    always-use-message-format: false	# 인수가 없는 메시지도 구문 분석하여 항상 MessageFormat 규칙을 적용할지 여부
    encoding: UTF-8
    fallback-to-system-locale: true		# locale에 해당하는 file이 없을 경우 system locale을 사용 ( default : true )
    use-code-as-default-message: true	# 해당 코드를 찾을 수 없을 경우 Code 를 출력한다. ( default : false )
    cache-duration: 3                 # default는 forever
    #cache-seconds: -1					      # 로드된 자원 번들 파일 캐시 만료(초). -1로 설정하면 번들은 영원히 캐시됩니다.
    
##############################################################
# Swagger pathmatch
# spring.mvc.pathmatch.matching-strategy=ant_path_matcher
  mvc:
    pathmatch:
      matching-strategy: ant-path-matcher

application-local.yml

# Server port
server:
  port: 9090

##############################################################
# Spring Message 처리
spring:
  config.activate.on-profile: local
  devtools:
    livereload:
      enabled: true

  jpa:
    open-in-view: false
    #database-platform: org.hibernate.dialect.PostgreSQLDialect
    #show-sql: true       # System.out 으로 출력. logging.level.org.hibernate.SQL=debug 로 대체합니다. 
    hibernate:
      # create : entity를 drop cascade 하고 다시 생성
      # update : entity가 수정되면 수정된 내용만 반영
      # create-drop,validate, none
      # 하이버네이트가 자동으로 생성해주는 DDL은 신뢰성이 떨어지기 때문에 
      # 절대로! 운영DB 환경에서 그대로 사용하면 안되고, 직접 DDL을 작성하는 것을 권장
      ddl-auto: none

##############################################################
# Spring Database 처리
  datasource:
    #sql-script-encoding: UTF-8           #spring boot 1.5.x 버전에서 사용. - Deprecated!
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/springboot
    #driver-class-name: com.p6spy.engine.spy.P6SpyDriver
    #url: jdbc:p6spy:postgresql://localhost:5432/springboot
    username: hermeswing
    password: pass
##############################################################
# - 경로 : /src/main/resources 경로 하위
# - schema.sql : 테이블 생성 스크립트
# - data.sql : 데이터 입력 스크립트
# - 파일이름규칙 : schema-${platform}.sql , data-${platform}.sql
#   ex. schema-h2.sql , shcema-postgres.sql
#spring.datasource.initialization-mode=always # Spring Boot <v2.5.x
# DB초기화(schema.sql, data.sql) , [always : 기동 시 매번 동작, never : 기동 시 동작하지 않음]
  sql:
    init:
      schema-locations: classpath*:initdata/schema-postgresql.sql
      data-locations: classpath*:initdata/data-postgresql.sql
      mode: always             # Spring Boot >=v2.5.0
      encoding: UTF-8          # Spring Boot >=v2.5.0       # insert script 에서 한글처리 ( DB의 encoding설정이 ko_KR.UTF-8이어야 함.  )

# Logging
logging:
  level:  # 각 package 별로 로깅 레벨을 지정할 수 있다.
    root : INFO
    octopus: DEBUG
    org:
      springframework: INFO

  pattern:
    console: "%-5level %d{yy-MM-dd HH:mm:SSS}[%thread] %boldMagenta([▶ %logger.%method ◀])[%line]: - %msg%n"
    file: "%-5level %d{yy-MM-dd HH:mm:SSS}[%thread] %logger.%method[%line]: - %msg%n"
    level: "[%X{FUNCTION_NAME}:%X{X-Track-Id}:%X{LOGIN_USER_ID}] 5p%"
  file:
    name: /Octopus/logs/app_log.log  # 로깅 파일 위치이다.
  logback:
    rollingpolicy:
      max-file-size: 100MB               #default 10M 로그 파일 하나당 최대 파일 사이즈이다.
      #max-history: 31                   #default 7 로그 파일 삭제 주기이다. 7일 이후 로그는 삭제한다.
      max-history: 1                   #default 7 로그 파일 삭제 주기이다. 7일 이후 로그는 삭제한다.
      file-name-pattern: app_log.%d{yyyy-MM-dd}.%i.gz

# p6spy 를 위한 설정
decorator.datasource.p6spy.enable-logging: true     # false : Disable p6spy

logback-local.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
    <!-- 로그 파일이 저장될 경로 -->
    <property name="LOG_PATH" value="C:/Octopus/local/logs" />

    <!-- pattern -->
    <property name="LOG_PATTERN" value="%-5level %d{yy-MM-dd HH:mm:SSS}[%thread] %30logger[%method:%line] - %msg%n" />

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <appender name="ROLLING-FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${LOG_FILE_DEV}.log</file>
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <!-- Rolling 정책 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- .gz,.zip 등을 넣으면 자동 일자별 로그파일 압축 -->
            <fileNamePattern>${LOG_PATH}/${LOG_FILE_DEV}.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <!-- 파일당 최고 용량 kb, mb, gb -->
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!-- 일자별 로그파일 최대 보관주기(~일), 해당 설정일 이상된 파일은 자동으로 제거 -->
            <maxHistory>1</maxHistory>
        </rollingPolicy>
    </appender>

	<logger name="org.springframework.orm.jpa.JpaTransactionManager" level="DEBUG"  additivity="false"/>

    <logger name="octopus" level="debug" additivity="false">
        <!-- ref="appender name 지정" -->
        <appender-ref ref="CONSOLE" />
        <!-- <appender-ref ref="ROLLING-FILE" /> -->
    </logger>
    <root level="debug">
        <appender-ref ref="CONSOLE" />
        <!-- <appender-ref ref="ROLLING-FILE" /> -->
    </root>

</configuration>

BoardController.java

package octopus.bbs.posts.controller;

import java.util.HashMap;
import java.util.Map;

import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import octopus.base.anotation.LoginUser;
import octopus.base.model.CommonResult;
import octopus.base.model.ListResult;
import octopus.base.model.MessageDto;
import octopus.base.model.SingleResult;
import octopus.base.model.UserSessionDto;
import octopus.base.service.ResponseService;
import octopus.bbs.posts.dto.BoardDto;
import octopus.bbs.posts.dto.BoardSearchDto;
import octopus.bbs.posts.service.BoardService;

@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("/posts")
public class BoardController {
    
    private final MessageSourceAccessor messageSourceAccessor;
    private final BoardService          boardService;
    private final ResponseService       responseService;
    
    // 사용자에게 메시지를 전달하고, 페이지를 리다이렉트 한다.
    private String showMessageAndRedirect(final MessageDto params, Model model) {
        model.addAttribute("params", params);
        
        return "common/messageRedirect";
    }
    
    // 쿼리 스트링 파라미터를 Map에 담아 반환
    private Map<String, Object> queryParamsToMap(final BoardSearchDto queryParams) {
        Map<String, Object> data = new HashMap<>();
        data.put("page", queryParams.getPage());
        data.put("recordSize", queryParams.getRecordSize());
        data.put("pageSize", queryParams.getPageSize());
        data.put("keyword", queryParams.getKeyword());
        data.put("searchType", queryParams.getSearchType());
        return data;
    }
    
    @GetMapping("/findAll")
    public @ResponseBody ListResult<BoardDto> findAll() {
        return responseService.getListResult(boardService.findAll());
    }
    
    /**
     * <pre>
     * 게시글 리스트 페이지
     * </pre>
     * 
     * @return
     */
    @GetMapping("/list")
    public String findAllOfPage(@ModelAttribute("params") final BoardSearchDto params,
            Model model) {
        
        PageRequest pageRequest = PageRequest.of(params.getPage(), params.getRecordSize(),
                Sort.by(Sort.Direction.DESC, "id"));
        
        model.addAttribute("result", boardService.findAllOfPage(params, pageRequest));
        
        return "post/list";
    }
    
    /**
     * <pre>
     * 게시글 리스트 페이지
     * Pageable 참조소스
     * </pre>
     * 
     * @return
     */
    @GetMapping("/list/page")
    public String findAllOfPage2(@ModelAttribute("params") final BoardSearchDto params,
            @PageableDefault(page = 0, size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable,
            Model model) {
        // page : 페이지
        // size : record의 갯수
        model.addAttribute("result", boardService.findAllOfPage2(params, pageable));
        
        return "post/list";
    }
    
    @GetMapping("/id/{id}")
    public @ResponseBody SingleResult<BoardDto> findCodeByCd(@PathVariable Long id) {
        return responseService.getSingleResult(boardService.findById(id));
    }
    
    // 게시글 상세 페이지
    @GetMapping("/view")
    public String openPostView(@RequestParam final Long id, Model model) {
        model.addAttribute("result", boardService.findById(id));
        
        return "post/view";
    }
    
    // 게시글 수정 페이지
    @GetMapping("/write")
    public String openPostWrite(@RequestParam(value = "id", required = false) final Long id,
            Model model) {
        if (id != null) {
            BoardDto dto = boardService.findById(id);
            dto.parseDate(dto.getCrtDt(), dto.getMdfDt());
            log.debug("board dto :: {}", dto);
            model.addAttribute("result", dto);
        }
        
        return "post/write";
    }
    
    @PostMapping("/save")
    public String save(final BoardDto dto,
            @LoginUser UserSessionDto userDto, Model model) {
        
        dto.setCrtId(userDto.getUserId());
        dto.setMdfId(userDto.getUserId());
        
        log.debug("BoardDto :: {}", dto);
        
        boardService.save(dto);
        
        MessageDto message = new MessageDto(messageSourceAccessor.getMessage("msg.itIsSaved"),
                "/posts/list", RequestMethod.GET, null); // 저장되었습니다.
        return showMessageAndRedirect(message, model);
    }
    
    @PostMapping("/rest-save")
    public @ResponseBody SingleResult<BoardDto> restSave(final @RequestBody BoardDto dto,
            @LoginUser UserSessionDto userDto) {
        
        dto.setCrtId(userDto.getUserId());
        dto.setMdfId(userDto.getUserId());
        
        log.debug("BoardDto :: {}", dto);
        
        return responseService.getSingleResult(boardService.save(dto));
    }
    
    @PostMapping("/update")
    public String update(final BoardDto dto,
            @LoginUser UserSessionDto userDto, Model model) {
        
        dto.setMdfId(userDto.getUserId());
        
        log.debug("dto :: {}", dto);
        
        boardService.update(dto);
        MessageDto message = new MessageDto(messageSourceAccessor.getMessage("msg.itIsSaved"),
                "/posts/list", RequestMethod.GET, null); // 저장되었습니다.
        return showMessageAndRedirect(message, model);
    }
    
    @PutMapping("/rest-update")
    public @ResponseBody SingleResult<String> restUpdate(final @RequestBody BoardDto dto,
            @LoginUser UserSessionDto userDto) {
        
        dto.setMdfId(userDto.getUserId());
        
        log.debug("dto :: {}", dto);
        
        boardService.restUpdate(dto);
        
        return responseService.getSingleResult(messageSourceAccessor.getMessage("msg.itIsSaved")); // 저장되었습니다.
    }
    
    @DeleteMapping("/id/{id}")
    public @ResponseBody CommonResult restDelete(@PathVariable Long id) {
        
        boardService.delete(id);
        
        return responseService
                .getSuccessResult(messageSourceAccessor.getMessage("msg.itIsDeleted")); // 삭제되었습니다.
    }
}

BoardService.java

package octopus.bbs.posts.service;

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

import org.modelmapper.ModelMapper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import octopus.base.config.ModelMapperConfig;
import octopus.base.model.Pagination;
import octopus.base.model.PagingListResult;
import octopus.bbs.posts.dto.BoardDto;
import octopus.bbs.posts.dto.BoardSearchDto;
import octopus.bbs.posts.dto.TBoardM;
import octopus.bbs.posts.repository.BoardRepository;

@Service
// @AllArgsConstructor
@RequiredArgsConstructor
@Slf4j
public class BoardService {
    // @AllArgsConstructor를 사용하는 경우
    // private CodeDRepository codeDRepository;
    
    private final BoardRepository   boardRepository;
    private final ModelMapperConfig modelMapperConfig;
    
    @Transactional // 선언적 트랜잭션을 사용
    public BoardDto findById(Long id) {
        Optional<TBoardM> board = boardRepository.findById(id);
        
        log.debug("board :: {}", board.get());
        
        ModelMapper modelMapper = modelMapperConfig.stricMapper();
        if (board.isPresent()) {
            boardRepository.updateCnt(id);
            return modelMapper.map(board.get(), BoardDto.class);
        } else {
            return new BoardDto();
        }
    }
    
    @Transactional(readOnly = true) // 선언적 트랜잭션을 사용
    public List<BoardDto> findAll() {
        List<BoardDto> list = boardRepository.findAll().stream()
                .map(data -> new BoardDto(data))
                .collect(Collectors.toList());
        
        log.debug("list :: {}", list);
        
        return list;
    }
    
    @Transactional(readOnly = true) // 선언적 트랜잭션을 사용
    public PagingListResult<BoardDto> findAllOfPage(final BoardSearchDto params,
            PageRequest pageRequest) {
        log.info("Offset :: {}", pageRequest.getOffset());
        log.info("PageSize :: {}", pageRequest.getPageSize());
        log.info("PageNumber :: {}", pageRequest.getPageNumber());
        
        List<BoardDto> boardList = null;
        Page<TBoardM>  list      = boardRepository.findAll(pageRequest);
        log.debug("board :: {}", list);
        
        if (list.hasContent()) {
            boardList = list.stream().map(data -> new BoardDto(data))
                    .collect(Collectors.toList());
            
            List<TBoardM> board     = list.getContent();             // 검색된 데이터
            int           totalPage = list.getTotalPages();          // 전체 페이지 수
            boolean       hasNext   = list.hasNext();                // 다음 페이지 존재여부
            int           totalCnt  = (int) list.getTotalElements(); // 검색된 전체 건수
            boolean       isData    = list.hasContent();             // 검색된 자료가 있는가?
            
            log.debug("boardList :: {}", boardList);
            log.debug("totalPage :: {}", totalPage);
            log.debug("hasNext :: {}", hasNext);
            log.debug("totalCnt :: {}", totalCnt);
            log.debug("isData :: {}", isData);
            
            // Pagination 객체를 생성해서 페이지 정보 계산 후 SearchDto 타입의 객체인 params에 계산된 페이지 정보 저장
            Pagination pagination = new Pagination(totalCnt, params);
            params.setPagination(pagination);
            
            return new PagingListResult<>(boardList, pagination);
        } else {
            return new PagingListResult<>(Collections.emptyList(), null);
        }
    }
    
    /**
     * Pageable 참조소스
     * 
     * @param params
     * @param pageable
     * @return
     */
    @Transactional(readOnly = true) // 선언적 트랜잭션을 사용
    public PagingListResult<BoardDto> findAllOfPage2(BoardSearchDto params, Pageable pageable) {
        log.info("Offset :: {}", pageable.getOffset());
        log.info("PageSize :: {}", pageable.getPageSize());
        log.info("PageNumber :: {}", pageable.getPageNumber());
        
        List<BoardDto> boardList = null;
        Page<TBoardM>  list      = boardRepository.findAll(pageable);
        log.debug("board :: {}", list);
        
        if (list.hasContent()) {
            boardList = list.stream().map(data -> new BoardDto(data))
                    .collect(Collectors.toList());
            
            List<TBoardM> board     = list.getContent();             // 검색된 데이터
            int           totalPage = list.getTotalPages();          // 전체 페이지 수
            boolean       hasNext   = list.hasNext();                // 다음 페이지 존재여부
            int           totalCnt  = (int) list.getTotalElements(); // 검색된 전체 건수
            boolean       isData    = list.hasContent();             // 검색된 자료가 있는가?
            
            log.info("board :: {}", board);
            log.info("totalPage :: {}", totalPage);
            log.info("hasNext :: {}", hasNext);
            log.info("totalCnt :: {}", totalCnt);
            log.info("isData :: {}", isData);
            
            // Pagination 객체를 생성해서 페이지 정보 계산 후 SearchDto 타입의 객체인 params에 계산된 페이지 정보 저장
            Pagination pagination = new Pagination(totalCnt, params);
            params.setPagination(pagination);
            
            return new PagingListResult<>(boardList, pagination);
        } else {
            return new PagingListResult<>(Collections.emptyList(), null);
        }
    }
    
    @Transactional
    public BoardDto save(BoardDto dto) {
        
        log.debug("BoardDto :: {}", dto);
        
        TBoardM board = dto.toEntity();
        
        log.debug("tCodeM :: {}", board);
        
        TBoardM saveBoard = boardRepository.save(board);
        
        return new BoardDto(saveBoard);
    }
    
    @Transactional
    public void update(BoardDto dto) {
        
        log.debug("BoardDto :: {}", dto);
        
        Optional<TBoardM> board = boardRepository.findById(dto.getId());
        
        log.debug("board 1 :: {}", board.get());
        
        board.get().updateBoard(dto);
        
        log.debug("board 2 :: {}", board.get());
    }
    
    @Transactional
    public void restUpdate(BoardDto dto) {
        
        log.debug("BoardDto :: {}", dto);
        
        Optional<TBoardM> board = boardRepository.findById(dto.getId());
        
        log.debug("board 1 :: {}", board.get());
        
        board.get().updateBoard(dto);
        
        log.debug("board 2 :: {}", board.get());
    }
    
    @Transactional
    public void delete(Long id) {
        log.debug("삭제될 ID :: {}", id);
        
        boardRepository.deleteById(id);
    }
}

BoardRepository.java

package octopus.bbs.posts.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

import octopus.bbs.posts.dto.TBoardM;

// @Repository : JpaRepository를 사용하면 @Repository를 사용하지 않아도 됨.
public interface BoardRepository extends JpaRepository<TBoardM, Long> {
    String UPDATE_CNT = "UPDATE TBoardM a" +
            "   SET a.readCnt = a.readCnt + 1 " +
            " WHERE a.id = ?1";
    
    @Modifying(clearAutomatically = true, flushAutomatically = true)
    // @Query(value=UPDATE_CNT, nativeQuery=true)
    @Query(value = UPDATE_CNT)
    int updateCnt(Long id);
}

TBoardM.java

package octopus.bbs.posts.dto;

import javax.persistence.Column;
import javax.persistence.Convert;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.Proxy;
import org.springframework.util.Assert;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import octopus.base.converter.BooleanToYNConverter;
import octopus.base.model.BaseEntity;

@Getter // getter를 자동으로 생성합니다.
// @Setter // 객체가 무분별하게 변경될 가능성 있음
// @ToString(exclude = { "crtId", "crtDt", "mdfId", "mdfDt" }) // 연관관계 매핑된 엔티티 필드는 제거. 연관 관계 필드는 toString()에서 사용하지 않는 것이
// // 좋습니다.
@ToString(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 인자없는 생성자를 자동으로 생성합니다. 기본 생성자의 접근 제어자가 불명확함. (access =
                                                   // AccessLevel.PROTECTED) 추가
@DynamicInsert // insert 시 null 인필드 제외
@DynamicUpdate // update 시 null 인필드 제외
// @AllArgsConstructor // 객체 내부의 인스턴스멤버들을 모두 가지고 있는 생성자를 생성 (매우 위험)
@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) // Post Entity에서 User와의 관계를 Json으로 변환시 오류 방지를 위한 코드
@Proxy(lazy = false)
@Entity // jpa entity임을 선언. 실제 DB의 테이블과 매칭될 Class
@Table(name = "T_BOARD_M")
public class TBoardM extends BaseEntity {
    private static final long serialVersionUID = 1L;
    
    @Id // PK 필드임
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // ID
    
    @Column(nullable = false, length = 200)
    private String title; // 제목
    
    @Column(name = "contents", length = 5000)
    private String contents; // 내용
    
    private Integer readCnt; // 조회수
    
    @Column(length = 1)
    @Convert(converter = BooleanToYNConverter.class)
    private Boolean noticeYn; // 공지여부
    
    @Builder
    public TBoardM(Long id, String title, String contents, Integer readCnt, Boolean noticeYn,
            String crtId,
            String mdfId) {
        Assert.hasText(title, "Title must not be empty");
        Assert.hasText(crtId, "crtId must not be empty");
        Assert.hasText(mdfId, "mdfId must not be empty");
        
        this.id       = id;
        this.title    = title;
        this.contents = contents;
        this.readCnt  = readCnt;
        this.noticeYn = noticeYn;
        super.crtId   = crtId;
        super.mdfId   = mdfId;
    }
    
    /**
     * 게시판 Update
     */
    public void updateBoard(BoardDto dto) {
        this.title    = dto.getTitle();
        this.contents = dto.getContents();
        this.noticeYn = dto.getNoticeYn();
        super.mdfId   = dto.getMdfId();
    }
    
}

BoardDto.java

package octopus.bbs.posts.dto;

import javax.persistence.Convert;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import octopus.base.converter.BooleanToYNConverter;
import octopus.base.model.BaseDto;

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString(callSuper = true)
@Builder
@EqualsAndHashCode(callSuper = true) // true의 경우 부모 클래스 필드 값들도 동일한지 체크하며, false(기본값)일 경우 자신 클래스의 필드 값만 고려한다.
public class BoardDto extends BaseDto {
    private static final long serialVersionUID = 1L;
    
    private Long    id;
    private String  title;
    private String  contents;
    private Integer readCnt;
    
    @Convert(converter = BooleanToYNConverter.class)
    private Boolean noticeYn; // 공지여부
    
    public TBoardM toEntity() {
        return TBoardM.builder().title(title).contents(contents)
                .crtId(getCrtId()).mdfId(getMdfId()).build();
    }
    
    public BoardDto(TBoardM board) {
        this.id       = board.getId();
        this.title    = board.getTitle();
        this.contents = board.getContents();
        this.readCnt  = board.getReadCnt();
        this.noticeYn = board.getNoticeYn();
        super.crtId   = board.getCrtId();
        super.crtDt   = board.getCrtDt();
        super.mdfId   = board.getMdfId();
        super.mdfDt   = board.getMdfDt();
    }
}

BoardSearchDto.java

package octopus.bbs.posts.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import octopus.base.model.SearchDto;

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString(callSuper = true)
@Builder
@EqualsAndHashCode(callSuper = true) // true의 경우 부모 클래스 필드 값들도 동일한지 체크하며, false(기본값)일 경우 자신 클래스의 필드 값만 고려한다.
public class BoardSearchDto extends SearchDto {
    
    private String keyword;    // 검색 키워드
    private String searchType; // 검색 유형
    
}

ComentController.java

package octopus.bbs.comment.controller;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import octopus.base.anotation.LoginUser;
import octopus.base.model.UserSessionDto;
import octopus.bbs.comment.dto.CommentDto;
import octopus.bbs.comment.service.CommentService;

@Slf4j
@RestController
@RequiredArgsConstructor
public class ComentController {
    private final CommentService commentService;
    
    // 댓글 리스트 조회
    @GetMapping("/posts/{postId}/comments")
    public List<CommentDto> findAllComment(@PathVariable final Long postId) {
        return commentService.findAllComment(postId);
    }
    
    // 댓글 상세정보 조회
    @GetMapping("/posts/{postId}/comments/{id}")
    public CommentDto findCommentById(@PathVariable final Long postId,
            @PathVariable final Long id) {
        return commentService.findCommentById(id);
    }
    
    // 신규 댓글 생성
    @PostMapping("/posts/{postId}/comments")
    public CommentDto saveComment(@PathVariable final Long postId,
            final CommentDto dto, @LoginUser UserSessionDto userDto) {
        
        dto.setCrtId(userDto.getUserId());
        dto.setMdfId(userDto.getUserId());
        
        log.debug("BoardDto :: {}", dto);
        
        CommentDto saveComment = commentService.saveComment(dto);
        return saveComment;
    }
    
    // 기존 댓글 수정
    @PatchMapping("/posts/{postId}/comments/{id}")
    public CommentDto updateComment(@PathVariable final Long postId, @PathVariable final Long id,
            @RequestBody final CommentDto dto, @LoginUser UserSessionDto userDto) {
        
        dto.setMdfId(userDto.getUserId());
        log.debug("BoardDto :: {}", dto);
        
        commentService.updateComment(dto);
        return commentService.findCommentById(id);
    }
}

CommentService.java

package octopus.bbs.comment.service;

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

import javax.transaction.Transactional;

import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import octopus.base.config.ModelMapperConfig;
import octopus.bbs.comment.dto.CommentDto;
import octopus.bbs.comment.dto.TCommentM;
import octopus.bbs.comment.repository.CommentRepository;

@Service
@RequiredArgsConstructor
@Slf4j
public class CommentService {
    
    private final CommentRepository commentRepository;
    private final ModelMapperConfig modelMapperConfig;
    
    /**
     * 댓글 리스트 조회
     * 
     * @param postId - 게시글 번호 (FK)
     * @return 특정 게시글에 등록된 댓글 리스트
     */
    public List<CommentDto> findAllComment(final Long postId) {
        List<CommentDto> list = commentRepository.findAll().stream()
                .map(data -> new CommentDto(data))
                .collect(Collectors.toList());
        
        log.debug("list :: {}", list);
        
        return list;
    }
    
    /**
     * 댓글 상세정보 조회
     * 
     * @param id - PK
     * @return 댓글 상세정보
     */
    public CommentDto findCommentById(final Long id) {
        Optional<TCommentM> comment = commentRepository.findById(id);
        
        log.debug("comment :: {}", comment.get());
        
        ModelMapper modelMapper = modelMapperConfig.stricMapper();
        if (comment.isPresent()) {
            return modelMapper.map(comment.get(), CommentDto.class);
        } else {
            return new CommentDto();
        }
    }
    
    /**
     * 댓글 저장
     * 
     * @param params - 댓글 정보
     * @return Generated PK
     */
    @Transactional
    public CommentDto saveComment(CommentDto dto) {
        log.debug("CommentDto :: {}", dto);
        
        TCommentM comment = dto.toEntity();
        
        log.debug("TCommentM :: {}", comment);
        
        TCommentM saveComment = commentRepository.save(comment);
        
        return new CommentDto(saveComment);
    }
    
    /**
     * 댓글 수정
     * 
     * @param params - 댓글 정보
     * @return PK
     */
    @Transactional
    public void updateComment(CommentDto dto) {
        log.debug("CommentDto :: {}", dto);
        
        Optional<TCommentM> comment = commentRepository.findById(dto.getId());
        
        log.debug("comment 1 :: {}", comment.get());
        
        comment.get().updateComment(dto);
        
        log.debug("comment 2 :: {}", comment.get());
    }
    
    /**
     * 댓글 삭제
     * 
     * @param id - PK
     * @return PK
     */
    @Transactional
    public void deleteComment(Long id) {
        commentRepository.deleteById(id);
    }
    
}

CommentRepository.java

package octopus.bbs.comment.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import octopus.bbs.comment.dto.TCommentM;

// @Repository : JpaRepository를 사용하면 @Repository를 사용하지 않아도 됨.
public interface CommentRepository extends JpaRepository<TCommentM, Long> {
}

TCommentM.java

package octopus.bbs.comment.dto;

import javax.persistence.Column;
import javax.persistence.Convert;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.Proxy;
import org.springframework.util.Assert;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import octopus.base.converter.BooleanToYNConverter;
import octopus.base.model.BaseEntity;

@Getter // getter를 자동으로 생성합니다.
// @Setter // 객체가 무분별하게 변경될 가능성 있음
// @ToString(exclude = { "crtId", "crtDt", "mdfId", "mdfDt" }) // 연관관계 매핑된 엔티티 필드는 제거. 연관 관계 필드는 toString()에서 사용하지 않는 것이
// // 좋습니다.
@ToString(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 인자없는 생성자를 자동으로 생성합니다. 기본 생성자의 접근 제어자가 불명확함. (access =
                                                   // AccessLevel.PROTECTED) 추가
@DynamicInsert // insert 시 null 인필드 제외
@DynamicUpdate // update 시 null 인필드 제외
// @AllArgsConstructor // 객체 내부의 인스턴스멤버들을 모두 가지고 있는 생성자를 생성 (매우 위험)
@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) // Post Entity에서 User와의 관계를 Json으로 변환시 오류 방지를 위한 코드
@Proxy(lazy = false)
@Entity // jpa entity임을 선언. 실제 DB의 테이블과 매칭될 Class
@Table(name = "T_COMMENT_M")
public class TCommentM extends BaseEntity {
    private static final long serialVersionUID = 1L;
    
    @Id // PK 필드임
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // Comment ID
    
    @Column(nullable = false)
    private Long postId; // 상위 게시물 ID
    
    @Column(nullable = false)
    private String contents; // 내용
    
    @Builder
    public TCommentM(Long id, Long postId, String contents,
            String crtId,
            String mdfId) {
        Assert.hasText(crtId, "crtId must not be empty");
        Assert.hasText(mdfId, "mdfId must not be empty");
        
        this.id       = id;
        this.postId   = postId;
        this.contents = contents;
        super.crtId   = crtId;
        super.mdfId   = mdfId;
    }
    
    /**
     * 게시판 Update
     */
    public void updateComment(CommentDto dto) {
        this.postId   = dto.getPostId();
        this.contents = dto.getContents();
        super.mdfId   = dto.getMdfId();
    }
    
}

CommentDto.java

package octopus.bbs.comment.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import octopus.base.model.BaseDto;

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString(callSuper = true)
@Builder
@EqualsAndHashCode(callSuper = true) // true의 경우 부모 클래스 필드 값들도 동일한지 체크하며, false(기본값)일 경우 자신 클래스의 필드 값만 고려한다.
public class CommentDto extends BaseDto {
    private static final long serialVersionUID = 1L;
    
    private Long   id;
    private Long   postId;
    private String contents;
    
    private String modalWriter;
    private String modalContent;
    
    public TCommentM toEntity() {
        return TCommentM.builder().postId(postId).contents(contents)
                .crtId(getCrtId()).mdfId(getMdfId()).build();
    }
    
    public CommentDto(TCommentM comment) {
        this.id       = comment.getId();
        this.postId   = comment.getPostId();
        this.contents = comment.getContents();
        super.crtId   = comment.getCrtId();
        super.crtDt   = comment.getCrtDt();
        super.mdfId   = comment.getMdfId();
        super.mdfDt   = comment.getMdfDt();
    }
}

list.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="layout/basic">
    <th:block layout:fragment="title">
        <title>리스트 페이지</title>
    </th:block>

    <th:block layout:fragment="content">
        <div class="page_tits">
            <h3>게시판 관리</h3>
            <p class="path"><strong>현재 위치 :</strong> <span>게시판 관리</span> <span>리스트형</span> <span>리스트</span></p>
        </div>

        <div class="content">
            <section>
                 <!--/* 검색 */-->
                <div class="search_box">
                    <form id="searchForm" onsubmit="return false;" autocomplete="off">
                        <div class="sch_group fl">
                            <select id="searchType" name="searchType" title="검색 유형 선택">
                                <option value="">전체 검색</option>
                                <option value="title">제목</option>
                                <option value="content">내용</option>
                                <option value="writer">작성자</option>
                            </select>
                            <input type="text" id="keyword" name="keyword" placeholder="키워드를 입력해 주세요." title="키워드 입력" />
                            <button type="button" class="bt_search" onclick="movePage(1);"><i class="fas fa-search"></i><span class="skip_info">검색</span></button>
                        </div>
                    </form>
                </div>

                <!--/* 리스트 */-->
                <table class="tb tb_col">
                    <colgroup>
                        <col style="width:50px;"/><col style="width:7.5%;"/><col style="width:auto;"/><col style="width:10%;"/><col style="width:15%;"/><col style="width:7.5%;"/>
                    </colgroup>
                    <thead>
                        <tr>
                            <th scope="col"><input type="checkbox"/></th>
                            <th scope="col">번호</th>
                            <th scope="col">제목</th>
                            <th scope="col">작성자</th>
                            <th scope="col">등록일</th>
                            <th scope="col">조회</th>
                        </tr>
                    </thead>

                    <!--/* 리스트 데이터 렌더링 영역 */-->
                    <tbody id="list">

                    </tbody>
                </table>

                <!--/* 페이지네이션 렌더링 영역 */-->
                <div class="paging">

                </div>

                <!--/* 버튼 */-->
                <p class="btn_set tr">
                    <a th:href="@{/posts/write}" class="btns btn_st3 btn_mid">글쓰기</a>
                </p>
            </section>
        </div> <!--/* .content */-->
    </th:block>


        <th:block layout:fragment="script">
        <script th:inline="javascript">
        /*<![CDATA[*/

            // 페이지가 로드되었을 때, 딱 한 번만 함수를 실행
            window.onload = () => {
                setQueryStringParams();

                findAllPost();
            }


            // 쿼리 스트링 파라미터 셋팅
            function setQueryStringParams() {

                if ( !location.search ) {
                    return false;
                }

                const form = document.getElementById('searchForm');

                new URLSearchParams(location.search).forEach((value, key) => {
                    if (form[key]) {
                        form[key].value = value;
                    }
                })
            }


            // 게시글 리스트 조회
            function findAllPost() {

                // 1. PagingResponse의 멤버인 List<T> 타입의 list를 의미
                const list = [[ ${result.list} ]];

                // 2. 리스트가 비어있는 경우, 행에 "검색 결과가 없다"는 메시지를 출력하고, 페이지 번호(페이지네이션) HTML을 제거(초기화)한 후 로직을 종료
                if ( !list.length ) {
                    document.getElementById('list').innerHTML = '<td colspan="6"><div className="no_data_msg">검색된 결과가 없습니다.</div></td>';
                    drawPage();
                }

                // 3. PagingResponse의 멤버인 pagination을 의미
                const pagination = [[ ${result.pagination} ]];

                // 4. @ModelAttribute를 이용해서 뷰(HTML)로 전달한 SearchDto 타입의 객체인 params를 의미
                const params = [[ ${params} ]];

                // 5. JPA의 page는 0부터 시작함. 
                //    리스트에 출력되는 게시글 번호를 처리하기 위해 사용되는 변수 (리스트에서 번호는 페이지 정보를 이용해서 계산해야 함)
                //alert("totalRecordCount :: " + pagination.totalRecordCount + " >> params.page :: " + params.page + " >> params.recordSize :: " + params.recordSize);
                //let num = pagination.totalRecordCount - ((params.page - 1) * params.recordSize);
                let num = pagination.totalRecordCount - (params.page * params.recordSize);
				
                // 6. 리스트 데이터 렌더링
                drawList(list, num);

                // 7. 페이지 번호 렌더링
                drawPage(pagination, params);
            }


            // 리스트 HTML draw
            function drawList(list, num) {
                // 1. 렌더링 할 HTML을 저장할 변수
                let html = '';

                /*
                 * 2. 기존에 타임리프(Thymeleaf)를 이용해서 리스트 데이터를 그리던 것과 유사한 로직
                 *    기존에는 게시글 번호를 (전체 데이터 수 - loop의 인덱스 번호)로 처리했으나, 현재는 (전체 데이터 수 - ((현재 페이지 번호 - 1) * 페이지당 출력할 데이터 개수))로 정밀히 계산
                 */
                list.forEach(row => {
                    html += `
                        <tr>
                            <td><input type="checkbox" /></td>
                            <td>${row.noticeYn === false ? num-- : '공지'}</td>
                            <td class="tl"><a href="javascript:void(0);" onclick="goViewPage(${row.id});">${row.title}</a></td>
                            <td>${row.crtId}</td>
                            <td>${dayjs(row.createdDate).format('YYYY-MM-DD HH:mm')}</td>
                            <td>${row.readCnt}</td>
                        </tr>
                    `;
                })

                // 3. id가 "list"인 요소를 찾아 HTML을 렌더링
                document.getElementById('list').innerHTML = html;
            }


            // 페이지 HTML draw
            function drawPage(pagination, params) {

                // 1. 필수 파라미터가 없는 경우, 페이지 번호(페이지네이션) HTML을 제거(초기화)한 후 로직 종료
                if ( !pagination || !params ) {
                    document.querySelector('.paging').innerHTML = '';
                    throw new Error('Missing required parameters...');
                }

                // 2. 렌더링 할 HTML을 저장할 변수
                let html = '';

                // 3. 이전 페이지가 있는 경우, 즉 시작 페이지(startPage)가 1이 아닌 경우 첫 페이지 버튼과 이전 페이지 버튼을 HTML에 추가
                if (pagination.existPrevPage) {
                    html += `
                        <a href="javascript:void(0);" onclick="movePage(1)" class="page_bt first">첫 페이지</a>
                        <a href="javascript:void(0);" onclick="movePage(${pagination.startPage - 1})" class="page_bt prev">이전 페이지</a>
                    `;
                }

                /*
                 * 4. JPA의 page는 0부터 시작함.
                 *    시작 페이지(startPage)와 끝 페이지(endPage) 사이의 페이지 번호(i)를 넘버링 하는 로직
                 *    페이지 번호(i)와 현재 페이지 번호(params.page)가 동일한 경우, 페이지 번호(i)를 활성화(on) 처리
                 */
                html += '<p>';
                for (let i = pagination.startPage; i <= pagination.endPage; i++) {
                    html += (i !== (params.page + 1))
                        ? `<a href="javascript:void(0);" onclick="movePage(${i});">${i}</a>`
                        : `<span class="on">${i}</span>`
                }
                html += '</p>';

                // 5. 현재 위치한 페이지 뒤에 데이터가 더 있는 경우, 다음 페이지 버튼과 끝 페이지 버튼을 HTML에 추가
                if (pagination.existNextPage) {
                    html += `
                        <a href="javascript:void(0);" onclick="movePage(${pagination.endPage + 1});" class="page_bt next">다음 페이지</a>
                        <a href="javascript:void(0);" onclick="movePage(${pagination.totalPageCount});" class="page_bt last">마지막 페이지</a>
                    `;
                }

                // 6. class가 "paging"인 요소를 찾아 HTML을 렌더링
                document.querySelector('.paging').innerHTML = html;
            }


            // 페이지 이동
            function movePage(page) {

                // 1. 검색 폼
                const form = document.getElementById('searchForm');

                // 2. PA의 page는 0부터 시작함.
                //    drawPage( )의 각 버튼에 선언된 onclick 이벤트를 통해 전달받는 page(페이지 번호)를 기준으로 객체 생성
                const queryParams = {
                    page: (page) ? (page - 1) : 0,
                    recordSize: 10,
                    pageSize: 10,
                    searchType: form.searchType.value,
                    keyword: form.keyword.value
                }

                /*
                 * 3. location.pathname : 리스트 페이지의 URI("/posts/list")를 의미
                 *    new URLSearchParams(queryParams).toString() : queryParams의 모든 프로퍼티(key-value)를 쿼리 스트링으로 변환
                 *    URI + 쿼리 스트링에 해당하는 주소로 이동
                 *    (해당 함수가 리턴해주는 값을 브라우저 콘솔(console)에 찍어보시면 쉽게 이해하실 수 있습니다.)
                 */
                location.href = location.pathname + '?' + new URLSearchParams(queryParams).toString();
            }


            // 게시글 상세 페이지로 이동
            function goViewPage(id) {
                const queryString = (location.search) ? location.search + `&id=${id}` : `?id=${id}`;
                location.href = '/posts/view' + queryString;
            }

        /*]]>*/
        </script>
    </th:block>
</html>

view.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="layout/basic">
    <th:block layout:fragment="title">
        <title>상세 페이지</title>
    </th:block>

    <th:block layout:fragment="content">
        <div class="page_tits">
            <h3>게시판 관리</h3>
            <p class="path"><strong>현재 위치 :</strong> <span>게시판 관리</span> <span>리스트형</span> <span>상세정보</span></p>
        </div>

        <div class="content">
            <section>
                <table class="tb tb_row">
                    <colgroup>
                        <col style="width:10%;"/><col style="width:23%;"/><col style="width:10%;"/><col style="width:23%;"/>
                    </colgroup>
                    <tbody>
                        <tr>
                            <th scope="row">글 유형</th>
                            <td th:text="${result.noticeYn == false ? '일반' : '공지'}"></td>

                            <th scope="row">등록일</th>
                            <td th:text="${#temporals.format( result.crtDt, 'yyyy-MM-dd HH:mm' )}"></td>
                        </tr>
                        <tr>
                            <th scope="row">제목</th>
                            <td>[[ ${result.title} ]]</td>

                            <th scope="row">조회</th>
                            <td colspan="3">[[ ${result.readCnt} ]]</td>
                        </tr>
                        <tr>
                            <th scope="row">이름</th>
                            <td colspan="3">[[ ${result.crtId} ]]</td>
                        </tr>
                        <tr>
                            <th scope="row">내용</th>
                            <td colspan="3">[[ ${result.contents} ]]</td>
                        </tr>
                    </tbody>
                </table>
                <p class="btn_set">
                    <button type="button" onclick="goWritePage();" class="btns btn_bdr4 btn_mid">수정</button>
                    <button type="button" onclick="deletePost();" class="btns btn_bdr1 btn_mid">삭제</button>
                    <button type="button" onclick="goListPage();" class="btns btn_bdr3 btn_mid">뒤로</button>
                </p>

                <!--/* 댓글 작성 */-->
                <div class="cm_write">
                    <fieldset>
                        <legend class="skipinfo">댓글 입력</legend>
                        <div class="cm_input">
                            <p><textarea id="contents" name="contents" onkeyup="countingLength(this);" cols="90" rows="4" placeholder="댓글을 입력해 주세요."></textarea></p>
                            <span><button type="button" class="btns" onclick="saveComment();">등 록</button> <i id="counter">0/300자</i></span>
                        </div>
                    </fieldset>
                </div>

                <!--/* 댓글 렌더링 영역 */-->
                <div class="cm_list">

                </div>
            </section>
        </div> <!--/* .content */-->

        <!--/* 댓글 수정 popup */-->
        <div id="commentUpdatePopup" class="popLayer">
            <h3>댓글 수정</h3>
            <div class="pop_container">
                <table class="tb tb_row tl">
                    <colgroup>
                        <col style="width:30%;" /><col style="width:70%;" />
                    </colgroup>
                    <tbody>
                        <tr>
                            <th scope="row">작성자<span class="es">필수 입력</span></th>
                            <td><input type="text" id="modalWriter" name="modalWriter" placeholder="작성자를 입력해 주세요." /></td>
                        </tr>
                        <tr>
                            <th scope="row">내용<span class="es">필수 입력</span></th>
                            <td><textarea id="modalContent" name="modalContent" cols="90" rows="10" placeholder="수정할 내용을 입력해 주세요."></textarea></td>
                        </tr>
                    </tbody>
                </table>
                <p class="btn_set">
                    <button type="button" id="commentUpdateBtn" class="btns btn_st2">수정</button>
                    <button type="button" class="btns btn_bdr2" onclick="closeCommentUpdatePopup();">취소</button>
                </p>
            </div>
            <button type="button" class="btn_close" onclick="closeCommentUpdatePopup();"><span><i class="far fa-times-circle"></i></span></button>
        </div>
    </th:block>

    <th:block layout:fragment="script">
        <script th:inline="javascript">
        /*<![CDATA[*/

            window.onload = () => {
                findAllComment();
            }


            // 전체 댓글 조회
            function findAllComment() {

                const postId = [[ ${result.id} ]];

                $.ajax({
                    url : `/posts/${postId}/comments`,
                    type : 'get',
                    dataType : 'json',
                    async : false,
                    success : function (result) {

                        // 1. 조회된 데이터가 없는 경우
                        if ( !result.length ) {
                            document.querySelector('.cm_list').innerHTML = '<div class="cm_none"><p>등록된 댓글이 없습니다.</p></div>';
                            return false;
                        }

                        // 2. 렌더링 할 HTML을 저장할 변수
                        let commentHtml = '';

                        // 3. 댓글 HTML 추가
                        result.forEach(row => {
                            commentHtml += `
                                <div>
                                    <span class="writer_img"><img src="/images/default_profile.png" width="30" height="30" alt="기본 프로필 이미지"/></span>
                                    <p class="writer">
                                        <em>${row.crtId}</em>
                                        <span class="date">${dayjs(row.createdDate).format('YYYY-MM-DD HH:mm')}</span>
                                    </p>
                                    <div class="cont"><div class="txt_con">${row.contents}</div></div>
                                    <p class="func_btns">
                                        <button type="button" onclick="openCommentUpdatePopup(${row.id});" class="btns"><span class="icons icon_modify">수정</span></button>
                                        <button type="button" class="btns"><span class="icons icon_del">삭제</span></button>
                                    </p>
                                </div>
                            `;
                        })

                        // 4. class가 "cm_list"인 요소를 찾아 HTML을 렌더링
                        document.querySelector('.cm_list').innerHTML = commentHtml;
                    },
                    error : function (request, status, error) {
                        console.log(error)
                    }
                })
            }

            // 게시글 삭제
            function deletePost() {

                const id = [[ ${result.id} ]];

                if ( !confirm(id + '번 게시글을 삭제할까요?') ) {
                    return false;
                }
/*
                let inputHtml = '';

                new URLSearchParams(location.search).forEach((value, key) => {
                    inputHtml += `<input type="hidden" name="${key}" value="${value}" />`;
                })

                const formHtml = `
                    <form id="deleteForm" action="/posts/delete" method="post">
                        ${inputHtml}
                    </form>
                `;

                const doc = new DOMParser().parseFromString(formHtml, 'text/html');
                const form = doc.body.firstChild;
                document.body.append(form);
                document.getElementById('deleteForm').submit();
*/
                $.ajax({
                    url : `/posts/id/${id}`,
                    type : 'delete',
                    dataType : 'json',
                    async : false,
                    success : function (result) {
                        alert(result.msg);
                        goListPage();
                    },
                    error : function (request, status, error) {
                        console.log(error)
                    }
                })
            }


            // 게시글 수정 페이지로 이동
            function goWritePage() {
                location.href = '/posts/write' + location.search;
            }


            // 게시글 리스트 페이지로 이동
            function goListPage() {
                const queryString = new URLSearchParams(location.search);
                queryString.delete('id');
                location.href = '/posts/list' + '?' + queryString.toString();
            }


            // 댓글 길이 카운팅
            function countingLength(contents) {
                if (contents.value.length > 300) {
                    alert('댓글을 300자 이하로 입력해 주세요.');
                    contents.value = contents.value.substring(0, 300);
                    contents.focus();
                }
                document.getElementById('counter').innerText = contents.value.length + '/300자';
            }


            // 댓글 저장
            function saveComment() {

                const contents = document.getElementById('contents');
                isValid(contents, '댓글');

                const postId = [[ ${result.id} ]];
                const params = {
                    postId : postId,
                    contents : contents.value,
                    writer : '홍길동'
                }
//console.log(params);
                $.ajax({
                    url : `/posts/${postId}/comments`,
                    type : 'post',
                    //contentType : 'application/json; charset=utf-8',
                    //dataType : 'json',
                    //data : JSON.stringify(params),
                    dataType : 'text',
                    data : params,
                    async : false,
                    success : function (result) {
                        alert('저장되었습니다.');
                        contents.value = '';
                        document.getElementById('counter').innerText = '0/300자';
                        findAllComment();
                    },
                    error : function (request, status, error) {
                        console.log(error)
                    }
                })
            }


            // 댓글 수정 팝업 open
            function openCommentUpdatePopup(id) {

                const postId = [[ ${result.id} ]];

                $.ajax({
                    url : `/posts/${postId}/comments/${id}`,
                    type : 'get',
                    dataType : 'json',
                    async : false,
                    success : function (result) {
                        document.getElementById('modalWriter').value = result.crtId;
                        document.getElementById('modalContent').value = result.contents;
                        document.getElementById('commentUpdateBtn').setAttribute('onclick', `updateComment(${id})`);
                        layerPop('commentUpdatePopup');
                    },
                    error : function (request, status, error) {
                        console.log(error)
                    }
                })
            }


            // 댓글 수정 팝업 close
            function closeCommentUpdatePopup() {
                document.querySelectorAll('#modalContent, #modalWriter').forEach(element => element.value = '');
                document.getElementById('commentUpdateBtn').removeAttribute('onclick');
                layerPopClose('commentUpdatePopup');
            }


            // 댓글 수정
            function updateComment(id) {

                const writer = document.getElementById('modalWriter');
                const contents = document.getElementById('modalContent');
                isValid(writer, '작성자');
                isValid(contents, '수정할 내용');

                const postId = [[ ${result.id} ]];
                const params = {
                    id : id,
                    postId : postId,
                    contents : contents.value,
                    crtId : writer.value
                }

                $.ajax({
                    url : `/posts/${postId}/comments/${id}`,
                    type : 'patch',
                    contentType : 'application/json; charset=utf-8',
                    dataType : 'json',
                    data : JSON.stringify(params),
                    async : false,
                    success : function (result) {
                        alert('수정되었습니다.');
                        closeCommentUpdatePopup();
                        findAllComment();
                    },
                    error : function (request, status, error) {
                        console.log(error)
                    }
                })
            }

        /*]]>*/
        </script>
    </th:block>
</html>

write.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="layout/basic">
    <th:block layout:fragment="title">
        <title>글작성 페이지</title>
    </th:block>

    <th:block layout:fragment="content">
        <div class="page_tits">
            <h3>게시판 관리</h3>
            <p class="path"><strong>현재 위치 :</strong> <span>게시판 관리</span> <span>리스트형</span> <span>글작성</span></p>
        </div>

        <div class="content">
            <section>
                <form id="saveForm" method="post" autocomplete="off">
                    <!--/* 게시글 수정인 경우, 서버로 전달할 게시글 번호 (PK) */-->
                    <input type="hidden" id="id" name="id" th:if="${result != null}" th:value="${result.id}" />

                    <!--/* 서버로 전달할 공지글 여부 */-->
                    <input type="hidden" id="noticeYn" name="noticeYn" />
                    <table class="tb tb_row">
                        <colgroup>
                            <col style="width:15%;" /><col style="width:35%;" /><col style="width:15%;" /><col style="width:35%;" />
                        </colgroup>
                        <tbody>
                            <tr>
                                <th scope="row">공지글</th>
                                <td><span class="chkbox"><input type="checkbox" id="isNotice" name="isNotice" class="chk" /><i></i><label for="isNotice"> 설정</label></span></td>

                                <th scope="row">등록일</th>
                                <td colspan="3"><input type="text" id="createDate" name="createDate" readonly /></td>
                            </tr>

                            <tr>
                                <th>제목 <span class="es">필수 입력</span></th>
                                <td colspan="3"><input type="text" id="title" name="title" maxlength="50" placeholder="제목을 입력해 주세요." /></td>
                            </tr>

                            <tr>
                                <th>이름 <span class="es">필수 입력</span></th>
                                <td colspan="3"><input type="text" id="crtId" name="crtId" maxlength="10" placeholder="이름을 입력해 주세요." /></td>
                            </tr>

                            <tr>
                                <th>내용 <span class="es">필수 입력</span></th>
                                <td colspan="3"><textarea id="contents" name="contents" cols="50" rows="10" placeholder="내용을 입력해 주세요."></textarea></td>
                            </tr>
                        </tbody>
                    </table>
                </form>
                <p class="btn_set">
                    <button type="button" id="saveBtn" onclick="savePost();" class="btns btn_st3 btn_mid">저장</button>
                    <button type="button" onclick="goListPage();" class="btns btn_bdr3 btn_mid">뒤로</button>
                </p>
            </section>
        </div> <!--/* .content */-->
    </th:block>

    <th:block layout:fragment="script">
        <script th:inline="javascript">
        /*<![CDATA[*/

            window.onload = () => {
                renderPostInfo();
            }


            // 게시글 상세정보 렌더링
            function renderPostInfo() {
                const post = [[ ${result} ]];
//console.log(post);
                if ( !post ) {
                    initCreatedDate();
                    return false;
                }

                const form = document.getElementById('saveForm');
                const fields = ['id', 'title', 'contents', 'crtId', 'noticeYn'];
                form.isNotice.checked = post.noticeYn;
                //form.createDate.value = dayjs(post.createDate).format('YYYY-MM-DD HH:mm');
                form.createDate.value = post.createDate;

                fields.forEach(field => {
                    form[field].value = post[field];
                })
            }


            // 등록일 초기화
            function initCreatedDate() {
                document.getElementById('createDate').value = dayjs().format('YYYY-MM-DD');
            }


            // 게시글 저장(수정)
            function savePost() {
                const form = document.getElementById('saveForm');
                const fields = [form.title, form.crtId, form.contents];
                const fieldNames = ['제목', '이름', '내용'];

                for (let i = 0, len = fields.length; i < len; i++) {
                    isValid(fields[i], fieldNames[i]);
                }

                new URLSearchParams(location.search).forEach((value, key) => {
                    const input = document.createElement('input');
                    input.type = 'hidden';
                    input.name = key;
                    input.value = value;
                    form.append(input);
                })

                document.getElementById('saveBtn').disabled = true;
                form.noticeYn.value = form.isNotice.checked;
                form.action = [[ ${result == null} ]] ? '/posts/save' : '/posts/update';
                form.method = 'post';
                form.submit();
            }


            // 게시글 리스트 페이지로 이동
            function goListPage() {
                const queryString = new URLSearchParams(location.search);
                queryString.delete('id');
                location.href = '/posts/list' + '?' + queryString.toString();
            }

        /*]]>*/
        </script>
    </th:block>
</html>

Spring Boot Start 옵션 : Run Configuration >> Spring Boot App >> Arguments Tab > VM arguments > -Dspring.profiles.active=local

 

728x90