[Docker + Spring] Docker gradle Spring Boot with VS Code JPA MySQL 게시판 생성 - 2. 게시글 CRUD
1. 게시글 작성, Create
앞 게시글에서 spring의 구조에 대해 다루었다. Controller, Service, Repository, DTO, Entity를 복습하자면 아래와 같다. 해당 프로젝트에서 service는 JPA interface인 repository를 불러올 것이다.
- controller
controller는 client의 요청을 받아서 처리해준다. controller는 service를 호출해서 요청을 처리한다.
- service
service는 business logic을 수행한다.
- Repository
spring data JPA는 repository라는 interface를 제공한다. JPA를 추상화 한 것으로, interface에 맞는 규칙대로 입력하면 spring이 알아서 method와 query를 만들어서 Bean으로 등록한다.
- Entity
JPA에서 Entity는 table에 대응하는 하나의 class이다. 예를 들어 아래의 Entity는 바로 아래의 표와 같다고 받아들였다. Entity 설계를 위해서 @Id, @Column 등의 annotation을 이용한다고 한다.
- DTO
Data Transfer Object, 데이터를 주고받을 때 사용하는 객체.
*** Entity와 DTO를 분리하는 이유
Entity는 DB에 직접 사용되는 class이다. Entity class가 바뀌면 DB table도 전부 바뀌는 반면 DTO는 view를 위한 class이기 때문에 자주 바뀔 수 있다. 따라서, Entity class는 Repository와 DB 사이에서만, 나머지는 DTO를 사용한다.
그래서 총 이렇게 5가지 종류를 만들 것이다. Controller에서 service를 호출하고, service에서는 repository를 호출하고, repository 인터페이스를 만들고, repository에서 이용할 entity, 그리고 DTO를 만들면 될 것이다. Top-Down 방식(controller - service - repository - entity, dto 순으로) 개인적으로 이런 방식은 전체 구성을 눈에 그린 뒤에 하는 것이 좋아하기 때문에 이 방식으로 하려 한다.
1) controller
src/main/java/com/example/testcompose/controller/BoardController.java
package com.example.testcompose.controller;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.service.BoardService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import lombok.AllArgsConstructor;
@Controller
@AllArgsConstructor
public class BoardController{
private BoardService boardService;
...
@PostMapping("/post")
public String write(BoardDto boardDto){
boardService.savePost(boardDto);
return "redirect:/";
}
}
- import
controller는 DTO를 이용해 service와 상호작용한다. 따라서 /dto/BoardDto, BoardService를 호출할 것이므로 /service.BoardService package를 import해 둔다.
url에 대한 mapping도 알아야 하니까 Get/PostMapping을 import한다.
- annotation
@Controller 어노테이션을 이용해 이 함수가 controller라는 것을 알려주고, 또 리턴으로 template 경로 또는 redirect해 주어야 한다.
@AllArgsConstructor는 constructor parameter로 받은 Bean(객체)의 방식을 개선해 주는 방식이다. 사용하는 이유는 아래 링크 참조.
https://jojoldu.tistory.com/251#2-3-controller--dto-%EA%B5%AC%ED%98%84
- 내용
/post로 POST 요청이 날아오면 그 글을 DB에 저장해 주어야 한다. 이 method를 write라 하겠다.
boardService 내에 savePost라는 method를 만들고 boardDto를 parameter로 받아 repository를 호출하는 것을 만들어야 할 것이다.
2) service
controller에서 정의 및 호출한 boardService에 대한 method를 정의해야 한다.
src/main/java/com/example/testcompose/controller/BoardController.java
package com.example.testcompose.service;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.domain.repository.BoardRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@AllArgsConstructor
@Service
public class BoardService {
private BoardRepository BoardRepository;
@Transactional
public Long savePost(BoardDto boardDto){
return BoardRepository.save(boardDto.toEntity()).getId();
}
}
- import
service는 DTO를 이용해 repository와 상호작용한다. 따라서 /dto/BoardDto, /domain/repository/BoardRepository를 import해 준다.
service module이므로 springframework에서 service를 import한다.
- annotation
@Service
이 class가 service임을 알려준다.
@Transactional
트랜잭션을 적용하는 annotation이다.
- 내용
boardRepository 안에 save라는 method를 만들어야 한다... 만, JpaRepository에 정의되어 있는 method로, entity만 잘 정의해서 넣으면 DB에 INSERT, UPDATE를 해 주는 method라 한다. 인자로는 entity를 받기에 toEntity라는 method를 이용해 entity로 넘겨줄 것이고, 이는 구현해야 한다.
3) repository
Service에서 호출한 boardRepository를 구현하자.
src/main/java/com/example/testcompose/domain/repository/BoardRepository.java
package com.example.testcompose.domain.repository;
import com.example.testcompose.domain.entity.BoardEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BoardRepository extends JpaRepository<BoardEntity,Long>{
}
- import
repository는 Entity를 이용해 DB와 상호작용한다. 따라서 /domain/entity/BoardEntity를 import해 준다.
Repository는 JPA에서 제공하는 class를 상속받을 것이기 때문에 해당 모듈을 import해 준다.
- 내용
JpaRepository에서 CRUD에 해당하는 내용은 이미 구현되어 있기 때문에 추가적으로 구현할 내용은 없다. 기본적으로 구현 된 내용은 아래와 같다.
JpaRepository.save | Entity 저장(INSERT, UPDATE) |
JpaRepository.flush | EntityManager의 내용을 DB에 동기화 |
JpaRepository.saveAndFlush | Entity 저장 및 Flush |
JpaRepository.delete | Entity 삭제(DELETE) |
JpaRepository.deleteAll | 모든 record 삭제 |
JpaRepository.findOne | PK로 Entity를 찾아옴(SELECT) |
JpaRepository.findAll | 모든 Entity를 찾아옴(SELECT) |
JpaRepository.exists | PK에 해당하는 Entity가 존재하는지 검사 |
JpaRepository.count | Entity의 개수 |
4) entity
Entity는 DB Table이라 생각하면 된다고 했다.
src/main/java/com/example/testcompose/domain/entity/BoardEntity.java
package com.example.testcompose.domain.entity;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "board")
public class BoardEntity extends TimeEntity {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long id;
@Column(length = 10, nullable = false)
private String writer;
@Column(length = 100, nullable = false)
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Builder
public BoardEntity(Long id, String title, String content, String writer) {
this.id = id;
this.writer = writer;
this.title = title;
this.content = content;
}
}
- import
lombok에서 constructor의 접근 권한을 위해 accesslevel, getter를 자동생성 해주기 위해 getter, parameter 없이 생성하기 위해 NoArgsConstructor를 import해 준다.(JPA 사용을 하려면 constructor가 있어야 한다)
- annotation
@NoArgsConstructor(access=AccessLevel.PROTECTED)
constructor의 접근 권한을 설정하는 것이다.
@Getter
attribute에 대해 값을 get하는 method를 자동생성 해주는 것이다.
@Entity
이 class가 entity라는 것을 JPA에게 알려준다.
@Table(name="board")
이 class가 어떤 table과 mapping되는지 정보를 나타내며 해당 annotation에서는 board라는 table과 mapping하겠다는 것이다.
@Id
table의 primary key로 사용하겠다는 annotation이다.
@GeneratedValue(strategy=GenerationType.IDENTITY)
기본 키 값을 어떻게 생성할지 여부이다. IDENTITY는 PK 값 생성을 DB에 위임한다.
@Column
Entity Class의 attribute를 table의 column에 mapping하겠다는 의미이다. @Column(length=10, nullable=false, name="mycol")이 의미하는 것은 문자 길이는 10자(length), null은 허용하지 않고(nullable), table column 이름은 mycol(name)이라는 것이다.
@Builder
@Setter 대신 사용하는 것으로, Builder class를 만들어 준다. 안정성을 위해 사용한다.
- 내용
DB에 들어갈 column으로는 id, writer, title, content가 있고 이 Entity build를 위해 아래와 같이 만들어 준다. 그리고 TimeEntity를 상속받는데, TimeEntity는 시간에 대한 정보를 담을 것이다.
src/main/java/com/example/testcompose/domain/entity/TimeEntity.java
package com.example.testcompose.domain.entity;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
- import
Data 조작 시 자동으로 날짜를 수정해 주는 JPA의 Audit 기능을 사용할 것이기 때문에 AuditingEntityLister를, 게시글의 생성/수정된 시간을 저장하기 위해 CreatedData, LastModifiedData를 import한다.
- annotation
@MappedSuperClass
child class에게 mapping 정보를 상속하기 위한 annotation.
@EntityListenrs(AuditingEntityListener.class)
JPA에게 해당 Entity는 Audit 기능을 사용한다는 것을 알려줌.
@CreatedData
Entity가 생성되었을 때 시간을 저장해 주는 annotation. 이 때 (게시글이) 생성된 시간은 수정될 필요가 없기 때문에 updateable 설정을 false로 한다.
@LastModifiedData
Entity가 수정된 시간을 저장해 주는 annotation.
- 내용
게시글이 작성된 일자, 수정된 일자를 저장하기 위해 두 정보를 attribute로 만든다.
/src/main/java/com/example/testcompose/TestcomposeApplication.java
package com.example.testcompose;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing
@SpringBootApplication
public class TestcomposeApplication {
public static void main(String[] args) {
SpringApplication.run(TestcomposeApplication.class, args);
}
}
JPA Audit 기능을 위해 EnableJpaAuditing을 import하고, @EnableJpaAuditing annotation을 붙여준다.
5) dto
DTO는 service.savepost에 repository에 data를 넣어 두었다. toEntity()는 DTO에서 필요한 부분을 이용해 DTO를 Entity로 만드는 과정이다.
src/main/java/com/example/testcompose/dto/BoardDto.java
package com.example.testcompose.dto;
import com.example.testcompose.domain.entity.BoardEntity;
import lombok.*;
import java.time.LocalDateTime;
@Getter
@Setter
@ToString
@NoArgsConstructor
public class BoardDto {
private Long id;
private String writer;
private String title;
private String content;
private LocalDateTime createdDate;
private LocalDateTime modifiedDate;
public BoardEntity toEntity(){
BoardEntity boardEntity = BoardEntity.builder()
.id(id)
.writer(writer)
.title(title)
.content(content)
.build();
return boardEntity;
}
@Builder
public BoardDto(Long id, String title, String content, String writer, LocalDateTime createdDate, LocalDateTime modifiedDate) {
this.id = id;
this.writer = writer;
this.title = title;
this.content = content;
this.createdDate = createdDate;
this.modifiedDate = modifiedDate;
}
}
- import
toEntity method를 위해 boardEntity 패키지를, 시간 정보를 위해 LocaldataTime을 import.
- annotation
앞에서 다 다룬 것이니 생략.
- 내용
DTO에 id, writer, title, content, createdDate, modifiedDate attribute들이 존재하고, DTO를 Entity로 만들기 위해 builer 패턴으로 toEntity method를 정의한다.
6) 테스트
게시글을 작성하기 전에는 아무것도 찍히지 않는다.
대충 아무렇게나 찍고 다시 확인하면 잘 나오는 것을 볼 수 있다.
2. 게시글 조회, Read
1) controller
src/main/resources/templates/board/list.html line 36
<tr th:each="board : ${boardList}">
이렇게, board 내용은 boardList라는 정보를 받아서 출력한다. 따라서 Controller에서 boardList를 view로 넘겨 주어야 한다.
src/main/java/com/example/testcompose/controller/BoardController.java
package com.example.testcompose.controller;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.service.BoardService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import lombok.AllArgsConstructor;
import java.util.List;
@Controller
@AllArgsConstructor
public class BoardController{
private BoardService boardService;
@GetMapping("/")
public String list(Model model){
List<BoardDto> boardList = boardService.getBoardList();
model.addAttribute("boardList", boardList);
return "board/list.html";
}
...
}
- import
BoardDto class들의 list를 이용하기 위해 java.util.List를 import.
- 내용
boardService.getBoardList()를 이용해 boardList를 불러올 것이고, Model 객체에 View에 이 데이터를 전달할 것이다.
2) service
Controller에서 boardService.getBoardList() method를 이용해 boardList들을 불러왔다. 실제 business logic은 service에서 구현한다.
src/main/java/com/example/testcompose/service/BoardService.java
package com.example.testcompose.service;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.domain.repository.BoardRepository;
import com.example.testcompose.domain.entity.BoardEntity;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
@AllArgsConstructor
@Service
public class BoardService {
...
@Transactional
public List<BoardDto> getBoardList(){
List<BoardEntity> boardEntityList = boardRepository.findAll();
List<BoardDto> boardDtoList = new ArrayList<>(); // list 생성
for(BoardEntity boardEntity : boardEntityList){
BoardDto boardDto = BoardDto.builder()
.id(boardEntity.getId())
.title(boardEntity.getTitle())
.content(boardEntity.getContent())
.writer(boardEntity.getWriter())
.createdDate(boardEntity.getCreatedDate())
.build();
boardDtoList.add(boardDto);
}
return boardDtoList;
}
}
- import
마찬가지로 list를 사용하기 위해 해당 library import, entity 자료형을 사용해야 하므로 BoardEntity import.
- 내용
boardRepository.findAll() method를 이용해 모든 Entity를 찾고, 이 Entity List를 DTO로 바꾸고 boardList에 넣은 후 boardList를 리턴한다.
3) 테스트
CSS까지 적용시키기 위해 static/board.css를 static/css/board.css로 옮기고, 실행하면 잘 나오는 것을 볼 수 있다.
3. 상세 게시글 조회, Read
resources/templates/detail.html을 보면 boardDto를 받아서 그 안에 있는 내용을 전달해 준다. 이것에 맞춰 /post/{no}로 GET요청이 들어오면 no에 해당하는 게시글의 정보를 BoardDto class에 담아 넘겨주자. 구현 순서는 위에서 했던 것처럼 controller - service 순으로 하면 될 것이다. (repository는 이미 있는 걸 쓰니까)
1) controller
src/main/java/com/example/testcompose/controller/BoardController.java
package com.example.testcompose.controller;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.service.BoardService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import lombok.AllArgsConstructor;
import java.util.List;
@Controller
@AllArgsConstructor
public class BoardController{
private BoardService boardService;
...
@GetMapping("/post/{no}")
public String detail(@PathVariable("no") Long id, Model model){
BoardDto boardDto = boardService.getPost(id);
model.addAttribute("boardDto", boardDto);
return "/board/detail.html";
}
}
- import
딱히 없다.
- annotation
@PathVariable
url에 있는 값을 처리하는 방식이다. path 중 "no"라는 값을 id에 mapping하겠다는 것이다.
- 내용
앞 내용과 마찬가지로 boardService에서 getPost(id)라는 method를 만들고(리턴은 BoardDto형) 이 값을 model에 addAttribute하자.
2) service
src/main/java/com/example/testcompose/service/BoardService.java
package com.example.testcompose.service;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.domain.repository.BoardRepository;
import com.example.testcompose.domain.entity.BoardEntity;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@AllArgsConstructor
@Service
public class BoardService {
private BoardRepository boardRepository;
...
@Transactional
public BoardDto getPost(Long id){
Optional<BoardEntity> boardEntityWrapper = boardRepository.findById(id);
BoardEntity boardEntity = boardEntityWrapper.get();
// BoardEntity boardEntyty = boardRepository.findById(id).get();
BoardDto boardDto = BoardDto.builder()
.id(boardEntity.getId())
.title(boardEntity.getTitle())
.content(boardEntity.getContent())
.writer(boardEntity.getWriter())
.createdDate(boardEntity.getCreatedDate())
.build();
return boardDto;
}
}
- import
boardRepository의 findById method의 리턴 형은 Optional이다. 이것을 사용하기 위해 정의한다.
- 내용
Repository.findById를 이용해 id를 기반으로 값을 BoardEntity의 Optional type을 찾아오고(이게 정의된 인터페이스이다.) get()을 이용해 값을 가져온다. 주석처럼 처리해도 된다.
이후 앞에서 했던 것 처럼 Dto의 builder 함수를 이용해 build한다.
3) 테스트
상세 페이지를 클릭하면 잘 나오는 것을 볼 수 있다.
4. 게시글 수정, Update
resources/templates/detail.html을 보면 수정 버튼을 누를 시 /post/edit/{no}의 형태로 GET 요청을 한다.
그리고 resources/templates/update.html을 보면 BoardDto를 받아와서 데이터를 꺼내는 것을 볼 수 있다. 그러면 BoardService.getPost(id)를 이용해 BoardDto를 가져오고 이 값을 가진 채로 /board/update.html을 호출하면 id에 해당하는 내용을 가진 채로 update로 갈 것이다.
또, resources/templates/update.html에서 수정 버튼을 누르면 PUT 요청을 보내고 url은 /post/edit/{id}이다. PUT 요청에 대해서는 boardDto를 작성하는 BoardService.savePost(boardDto)가 있기 때문에 이것을 사용하자.
정리하자면 수정 버튼을 눌렀을 때 원래 있던 post 내용(DTO)를 돌려주는 method, PUT 요청이 왔을 때 게시글 내용을 바꾸어 주는 method 2개가 있어야 할 것이다.
1) controller
src/main/java/com/example/testcompose/controller/BoardController.java
package com.example.testcompose.controller;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.service.BoardService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import lombok.AllArgsConstructor;
import java.util.List;
@Controller
@AllArgsConstructor
public class BoardController{
private BoardService boardService;
...
@GetMapping("/post/edit/{no}")
public String edit(@PathVariable("no") Long id, Model model){
BoardDto boardDto = boardService.getPost(id);
model.addAttribute("boardDto", boardDto);
return "/board/update.html";
}
@PutMapping("/post/edit/{no}")
public String update(BoardDto boardDto){
boardService.savePost(boardDto);
return "redirect:/";
}
}
- 내용
BoardService에서 getPost(id), savePost(boardDto)를 이미 구현해 두었고, id에 해당하는 내용을 가져오는 /post/edit/{no}는 getPost(id)로, /post/edit/{no}는 savePost(boardDto)를 이용해 처리할 수 있다.
2) service
건드릴 것이 없다.
3) main
hiddenHttpMethodFilter를 설정해 주어야 한다. 이것을 하지 않으면 @PutMapping, @DeleteMapping이 작동하지 않는다. 아래 글에 따르면 HTML Form은 GET, POST만 지원하기 때문이다. 그래서 요청된 method type을 PUT, DELETE, PATCH로 변경해 주는 필터가 필요한데, 이것이 HiddenHttpMethodFilter이다. 이것을 등록해 두어야 한다.
https://hohodu.tistory.com/85
/src/main/java/com/example/testcompose/TestcomposeApplication.java
package com.example.testcompose;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.web.filter.HiddenHttpMethodFilter;
@EnableJpaAuditing
@SpringBootApplication
public class TestcomposeApplication {
public static void main(String[] args) {
SpringApplication.run(TestcomposeApplication.class, args);
}
@Bean
public HiddenHttpMethodFilter hiddenHttpMethodFilter(){
return new HiddenHttpMethodFilter();
}
}
4) 테스트
수정버튼을 누르고 수정하면 main으로 redirect되고, 내용도 바뀌어 있다.
5. 게시글 삭제, Delete
/resources/templates/detail.html을 보면, 삭제 버튼은 /post/{no}로 연결되어 있다. 이에 해당하는 method를 만들자.
1) controller
src/main/java/com/example/testcompose/controller/BoardController.java
package com.example.testcompose.controller;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.service.BoardService;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import lombok.AllArgsConstructor;
import java.util.List;
@Controller
@AllArgsConstructor
public class BoardController{
private BoardService boardService;
...
@DeleteMapping("/post/{no}")
public String delete(@PathVariable("no") Long id){
boardService.deletePost(id);
return "redirect:/";
}
}
- 내용
delete에 대한 url의 no를 받아 boardService.deletePost method에 인자로 주고, deletePost(id) method를 구현하자.
2) service
src/main/java/com/example/testcompose/service/BoardService.java
package com.example.testcompose.service;
import com.example.testcompose.dto.BoardDto;
import com.example.testcompose.domain.repository.BoardRepository;
import com.example.testcompose.domain.entity.BoardEntity;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@AllArgsConstructor
@Service
public class BoardService {
private BoardRepository boardRepository;
...
@Transactional
public void deletePost(Long id){
boardRepository.deleteById(id);
}
}
- 내용
boardRepository에서 지원하는 deleteById method를 사용해 id에 해당하는 method를 지우자.
3) 테스트
detail에 들어가서 삭제 버튼을 누르면 잘 삭제되었다.