250x250
Notice
Recent Posts
Recent Comments
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
Tags
- ubuntu
- Spring Boot
- IntelliJ
- Eclipse
- jpa
- Exception
- oracle
- Tomcat
- spring
- 문서
- JDBC
- error
- JavaScript
- Docker
- MySQL
- 설정
- Open Source
- 오픈소스
- Source
- STS
- Python
- myBatis
- Thymeleaf
- MSSQL
- Core Java
- git
- maven
- AJAX
- SpringBoot
- PostgreSQL
Archives
- Today
- Total
헤르메스 LIFE
[Spring Boot] 게시판 #4 - 게시판 + 댓글 본문
728x90
게시판을 작성해보려 합니다. 조금씩 살을 붙여나가 보려고 합니다.
게시판 목록의 디자인 및 일부 소스는 도뎡님의 허락을 받아 사용했습니다. 도뎡님은 MyBatis를 사용했고, 저는 JPA를 사용했습니다. 소스를 비교해보시는 것도 좋을 것 같습니다.
https://congsong.tistory.com/26
개발환경
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
'Spring Boot Framework' 카테고리의 다른 글
[SpringBoot] JPA 개발 시 p6spy를 이용해서 쿼리 로그 남기기 (0) | 2024.02.01 |
---|---|
[JPA] Query Modifying 의 사용 (0) | 2023.06.16 |
[Spring Boot] JPA에서 Boolean 처리 ( @Converter, @Convert ) (0) | 2023.04.25 |
[Spring Boot] 게시판 #3 - 게시판 목록 + 페이징처리 (0) | 2023.04.25 |
[Spring Boot] 게시판 #2 - 간단한 게시판 CRUD 개발 (0) | 2023.04.23 |