Spring Boot 로 웹 서비스 출시하기 -2. 게시판 구현하기 (JPA를 이용한 CRUD Restful 적용)

실수·2021년 3월 8일
3

IntelliJ IDEA에 Lombok 플러그인 설치

‘Lombok’은 Getter, Setter, toString와 같은 것들을 자동 생성해 주는 유용한 라이브러리입니다.
이것을 IntelliJ IDEA에서 사용하려면, Gradle에 추가하는 것뿐만 아니라 플러그인도 설치해야 합니다. IntelliJ IDEA에서 ‘File’→’Settings’에 들어갑니다.


LomBok을 설치하여줍니다.

ORACLE에 데이터베이스 만들고 설정하기

먼저 오라클을 설치하겠습니다.


저는 빠른 작업을 위해 인텔리제이에 데이터베이스를 올려주었습니다.

자신의 포트번호와 호스트,SID,USER,PASSWORD를 입력하고 테스트 커넥션 하여 테스트가 완료되면 데이터베이스가 인텔리제이에 올라가게 됩니다.


이런식으로 쿼리작업도 가능하고

데이터값도 확인할수 있습니다.


프로젝트의 패키지 구조입니다.

자바 파일의 디렉토리를 위와 같이 구성한 이유는 3 Layer Architecture와 관련이 있습니다.

각 디렉터리의 역할은 다음과 같습니다.

controller
URL과 실행 함수를 매핑
비즈니스 로직이 있는 service를 호출하여 비즈니스 로직 처리
반환할 템플릿을 정의 및 JSON 등으로 응답
service
비즈니스 로직을 구현
데이터 처리(모델)를 담당하는 repository에서 데이터를 가져와서 controller에 넘겨주거나, 비즈니스 로직을 처리
domain > entity
DB 테이블과 매핑되는 객체(Entity)를 정의
JPA에서는 Entity를 통해 데이터를 조작함
domain > repository
데이터를 가져오거나 조작하는 함수를 정의
Interface를 implements하여 미리 만들어진 함수를 사용할 수 있으며, 또한 직접 구현이 가능
dto
controller와 service 간에 주고 받을 객체를 정의하며, 최종적으로는 view에 뿌려줄 객체
Entity와 속성이 같을 수 있으나, 여러 service를 거쳐야 하는 경우 dto의 몸집은 더 커짐
ex) AEntity에 a 속성, BEntity에 b속성이 있을 때, ZDto에 a,b 속성으로 정의될 수 있음
entity와 dto를 분리한 이유는 Entity는 DB 테이블이 정의되어 있으므로, 데이터 전달 목적을 갖는 객체인 dto를 정의하는 것이 좋다고 합니다. ( 참고 )

application.properties에 Datasource 설정 추가하기

먼저 JPA와 DBMS에 대한 Datasource 설정을 application.properties에서 하겠습니다. 본 파일은 /src/main/resources에 위치합니다.

# Database
spring.datasource.driver-class-name=oracle.jdbc.OracleDriver
spring.datasource.url=데이터베이스 주소
spring.datasource.username=아이디
spring.datasource.password=비밀번호
spring.datasource.sql-script-encoding=UTF-8


spring.jpa.hibernate.ddl-auto=update
# create = 실행될때마다 새로만들어짐
# update = 실행될때마다 다시 업로드됨 oracle에서 건드려도 이해를함
# none = 안건드림
spring.jpa.generate-ddl=false
spring.jpa.show-sql=false
spring.jpa.database=oracle
spring.jpa.database-platform=org.hibernate.dialect.Oracle10gDialect

server.port=9091

#Spring Property Setting
spring.devtools.livereload.enabled=true
spring.freemarker.cache=false

본격적으로 패키지 구조 만들기

1. 퍼블리싱

1) 헤더와 푸터

templates/common/header.html

<h1>헤더 입니다.</h1>
<hr>

templates/common/footer.html

<hr>
<h1>푸터입니다.</h1>

모든 페이지에 존재하는 헤더와 푸터입니다.

2) 게시글 리스트 페이지

templates/board/list.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>

    <link rel="stylesheet" th:href="@{/css/board.css}">
</head>
<body>
<!-- HEADER -->
<div th:insert="common/header.html" id="header"></div>

<a th:href="@{/post}">글쓰기</a>

<table>
    <thead>
    <tr>
        <th class="one wide">번호</th>
        <th class="ten wide">글제목</th>
        <th class="two wide">작성자</th>
        <th class="three wide">작성일</th>
    </tr>
    </thead>

    <tbody>
    <!-- CONTENTS !-->
    <tr th:each="board : ${boardList}">
        <td>
            <span th:text="${board.id}"></span>
        </td>
        <td>
            <a th:href="@{'/post/' + ${board.id}}">
                <span th:text="${board.title}"></span>
            </a>
        </td>
        <td>
            <span th:text="${board.writer}"></span>
        </td>
        <td>
            <span th:text="${#temporals.format(board.createdDate, 'yyyy-MM-dd HH:mm')}"></span>
        </td>
    </tr>
    </tbody>
</table>

<!-- FOOTER -->
<div th:insert="common/footer.html" id="footer"></div>
</body>
</html>

thymeleaf 문법을 볼 수 있습니다.

th:insert
헤더와 푸터처럼 다른 페이지를 현재 페이지에 삽입하는 역할을 합니다.
JSP의 include와 같습니다.
th:href
thymeleaf에서 html 속성은 대부분 이처럼 th: 으로 바꿔서 사용할 수 있습니다.
@{ } 의 의미는 애플리케이션이 설치된 경로를 기준으로 하는 상대경로 입니다.
예제에서 @{/post}는 URL이 http://localhost:9091/post 가 되겠네요.

3) 글쓰기 입력 폼 페이지

templates/board/write.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form  action="/post" method="post">
    제목 : <input type="text" name="title"> <br>
    작성자 : <input type="text" name="writer"> <br>
    <textarea name="content"></textarea><br>

    <input type="submit" value="등록">
</form>
</body>
</html>

4) 게시글 상세보기 페이지

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<h2 th:text="${boardDto.title}"></h2>
<p th:inline="text">작성일 : [[${#temporals.format(boardDto.createdDate, 'yyyy-MM-dd HH:mm')}]]</p>

<p th:text="${boardDto.content}"></p>

<!-- 수정/삭제 -->
<div>
    <a th:href="@{'/post/edit/' + ${boardDto.id}}">
        <button>수정</button>
    </a>

    <form id="delete-form" th:action="@{'/post/' + ${boardDto.id}}" method="post">
        <input type="hidden" name="_method" value="delete"/>
        <button id="delete-btn">삭제</button>
    </form>
</div>

<!-- 변수 셋팅 -->
<script th:inline="javascript">
    /*<![CDATA[*/
    var boardDto = /*[[${boardDto}]]*/ "";
    /*]]>*/
</script>

</body>
</html>
<p th:inline="text">
작성일 : [[${#temporals.format(boardDto.createdDate, 'yyyy-MM-dd HH:mm')}]]
</p>

th:text를 사용하면, 태그 사이에 작성한 내용은 사라지고 th:text 값으로 덮어씌어집니다.
이를 해결하기 위해 th:inline 를 사용하며, 변수는 [[ ${ } ]] 으로 표기합니다.

<input type="hidden" name="_method" value="delete"/>

RESTful API 작성을 위해 hiddenHttpMethodFilter를 이용한 것입니다.
그러면 form 태그의 method는 post이지만, 실제로는 컨트롤러에서 delete로 매핑이됩니다.

/*<![CDATA[*/ ~~~ /*]]>*/

JS에서 Java 변수를 사용하기 위한 방식입니다.참고
위에서 boardDto를 콘솔로 출력하는 스크립트를 작성하였으므로 게시글 상세 페이지에 접근 시, 개발자도구 콘솔창에서 확인할 수 있습니다.

5) 글 수정 폼페이지

templates/board/update.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form th:action="@{'/post/edit/' + ${boardDto.id}}" method="post">
    <input type="hidden" name="_method" value="put"/>
    <input type="hidden" name="id" th:value="${boardDto.id}"/>

    제목 : <input type="text" name="title" th:value="${boardDto.title}"> <br>
    작성자 : <input type="text" name="writer" th:value="${boardDto.writer}"> <br>
    <textarea name="content" th:text="${boardDto.content}"></textarea><br>

    <input type="submit" value="수정">
</form>
</body>
</html>
<input type="hidden" name="_method" value="put"/>

마찬가지로 Restful API 작성을 위한 것으로, 컨트롤러에서 put 메서드로 매핑이됩니다.

<input type="hidden" name="id" th:value="${boardDto.id}"/>

hidden 타입을 게시글 id 값을 넘겨준 이유는 JPA(BoardRepository.save())에서 insert와 update를 같은 메서드로 사용하기 때문입니다.
즉, 같은 메서드를 호출하는데 id 값이 없다면 insert가 되는 것이고, id 값이 이미 존재한다면 update가 되도록 동작됩니다. 따라서 Service에서 update를 위한 메서드는 없고, insert와 같은 메서드를 사용합니다.

2. Controller

다음으로 URL을 매핑하고, 비즈니스 로직 함수를 호출하여 view에 뿌려주는 역할을 하는 컨트롤러를 구현해보겠습니다.

com/springwebservice/mince/controller/BoardController.java

package com.springwebservice.mine.controller;

import com.springwebservice.mine.dto.BoardDto;
import com.springwebservice.mine.service.BoardService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

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";
    }

    @GetMapping("/post")
    public String write() {
        return "board/write.html";
    }

    @PostMapping("/post")
    public String write(BoardDto boardDto) {
        boardService.savePost(boardDto);

        return "redirect:/";
    }

    @RequestMapping(value = "/post/edit/{no}" , method = RequestMethod.GET)
    public String edit(@PathVariable("no") Long no, Model model) {
        BoardDto boardDTO = boardService.getPost(no);

        model.addAttribute("boardDto", boardDTO);
        return "board/update.html";
    }

    @RequestMapping(value = "/post/edit/{no}" , method = {RequestMethod.POST, RequestMethod.PUT})
    public String update(BoardDto boardDTO) {
        boardService.savePost(boardDTO);

        return "redirect:/";
    }

    @RequestMapping(value = "/post/{no}" , method = RequestMethod.GET)
    public String detail(@PathVariable("no") Long no, Model model) {
        BoardDto boardDTO = boardService.getPost(no);

        model.addAttribute("boardDto", boardDTO);
        return "board/detail.html";
    }

    @RequestMapping(value = "/post/{no}" , method = {RequestMethod.POST, RequestMethod.DELETE})
    public String delete(@PathVariable("no") Long no) {
        boardService.deletePost(no);

        return "redirect:/";
    }

    // 로그인 페이지
    @RequestMapping(value = "/login")
    public String loginpage(){

        return "login/login.html";
    }

    // 회원가입 페이지
    @RequestMapping(value = "/join")
    public String join(){

        return "login/join.html";
    }
}

@Controller
컨트롤러임을 명시하는 어노테이션입니다.
MVC에서 컨트롤러로 명시된 클래스의 메서드들은 반환 값으로 템플릿 경로를 작성하거나, redirect를 해줘야 합니다.
템플릿 경로는 templates 패키지를 기준으로한 상대경로입니다.

@RestController도 존재하는데, 이는 @Controller, @ResponseBody가 합쳐진 어노테이션입니다.
view 페이지가 필요없는 API 응답에 어울리는 어노테이션입니다.

@AllArgsConstructor
Bean 주입 방식과 관련이 있으며, 생성자로 Bean 객체를 받는 방식을 해결해주는 어노테이션입니다. 그래서 BoardService 객체를 주입 받을 때 @Autowired 같은 특별한 어노테이션을 부여하지 않았습니다.

그 밖에, @NoArgsConstructor @RequiredArgsConstructor 어노테이션이 있습니다.

@GetMapping / @PostMapping
URL을 매핑해주는 어노테이션이며, HTTP Method에 맞는 어노테이션을 작성하면 됩니다.

@RequestMapping
클라이언트의 요청 URL에 대응하는 핸들러 메서드를 찾아 요청을 위임하기 위해 사용합니다. (매핑 조건 지정)
매핑조건의 결정기준 : ①URL에 따라 ②HTTP 요청방식에 따라 ③요청 파라미터에 따라 ④요청 헤더에 따라 ⑤미디어 타입에 따라
ㆍ속성 : value, path, method, params, headers, consumes, produces, name

Restful 방식을 사용하였습니다.

(참고)

3. Service

다음으로 비즈니스 로직을 수행하는 Service를 구현해보도록 하겠습니다.
com/springwebservice/mine/service/BoardService.java

package com.springwebservice.mine.service;

import com.springwebservice.mine.domain.entity.BoardEntity;
import com.springwebservice.mine.domain.repository.BoardRepository;
import com.springwebservice.mine.dto.BoardDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;

import javax.persistence.OrderBy;
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 List<BoardDto> getBoardlist() {
        List<BoardEntity> boardEntities = boardRepository.findAll();
        List<BoardDto> boardDtoList = new ArrayList<>();

        for ( BoardEntity boardEntity : boardEntities) {
            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;
    }
    @Transactional
    public BoardDto getPost(Long id) {
        Optional<BoardEntity> boardEntityWrapper = boardRepository.findById(id);
        BoardEntity boardEntity = boardEntityWrapper.get();

        BoardDto boardDTO = BoardDto.builder()
                .id(boardEntity.getId())
                .title(boardEntity.getTitle())
                .content(boardEntity.getContent())
                .writer(boardEntity.getWriter())
                .createdDate(boardEntity.getCreatedDate())
                .build();

        return boardDTO;
    }

    @Transactional
    public Long savePost(BoardDto boardDto) {
        return boardRepository.save(boardDto.toEntity()).getId();
    }

    @Transactional
    public void deletePost(Long id) {
        boardRepository.deleteById(id);
    }


}

@AllArgsConstructor
Controller에서 봤던 어노테이션 입니다.
마찬가지로 Repository를 주입하기 위해 사용합니다.

@Service
서비스 계층임을 명시해주는 어노테이션입니다.

@Transactional
선언적 트랜잭션이라 부르며, 트랜잭션을 적용하는 어노테이션입니다.

save()
JpaRepository에 정의된 메서드로, DB에 INSERT, UPDATE를 담당합니다.
매개변수로는 Entity를 전달합니다.

findById()
PK 값을 where 조건으로 하여, 데이터를 가져오기 위한 메서드이며, JpaRepository 인터페이스에서 정의되어 있습니다.
반환 값은 Optional 타입인데, 엔티티를 쏙 빼오려면 boardEntityWrapper.get(); 이렇게 get() 메서드를 사용해서 가져옵니다.

deleteById()
PK 값을 where 조건으로 하여, 데이터를 삭제하기 위한 메서드이며, JpaRepository 인터페이스에서 정의되어 있습니다.

4. Repository

다음으로 데이터 조작을 담당하는 Repository를 구현해보겠습니다.
com/springwebservice/mine/domain/repository/BoardRepository.java

package com.springwebservice.mine.domain.repository;

import com.springwebservice.mine.domain.entity.BoardEntity;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BoardRepository extends JpaRepository<BoardEntity, Long> {
}

Repository는 인터페이스로 정의하고, JpaRepository 인터페이스를 상속받으면 됩니다.

JpaRepository의 제네릭 타입에는 Entity 클래스와 PK의 타입을 명시해주면 됩니다.
JpaRepository에는 일반적으로 많이 사용하는 데이터 조작을 다루는 함수가 정의되어 있기 때문에, CRUD 작업이 편해집니다.

5. Entity

다음으로 DB 테이블과 매핑되는 객체를 정의하는 Entity를 구현해보겠습니다.

Entity는 JPA와 관련이 깊습니다.

1) BoardEntity 구현

com/springwebservice/mine/domain/entity/BoardEntity.java

package com.springwebservice.mine.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 = "설정할 테이블 명")
public class BoardEntity extends TimeEntity {

    @Id
    @GeneratedValue(strategy= GenerationType.SEQUENCE)
    private Long id;

    @Column(length = 10, nullable = false)
    private String writer;

    @Column(length = 100, nullable = false)
    private String title;

    @Column(length = 100, nullable = false)
    private String content;

    @Builder
    public BoardEntity(Long id, String writer, String title, String content) {
        this.id = id;
        this.writer = writer;
        this.title = title;
        this.content = content;
    }
}

@NoArgsConstructor(access = AccessLevel.PROTECTED)
파라미터가 없는 기본 생성자를 추가하는 어노테이션입니다. ( JPA 사용을위해 기본 생성자 생성은 필수 )
access는 생성자의 접근 권한을 설정하는 속성이며, 최종적으로 protected BoardEntity() { }와 동일합니다.
protected인 이유는 Entity 생성을 외부에서 할 필요가 없기 때문입니다.

@Getter
모든 필드에 getter를 자동생성 해주는 어노테이션입니다.

@Setter 어노테이션은 setter를 자동생성 해주지만, 무분별한 setter 사용은 안정성을 보장받기 어려우므로 Builder 패턴을 사용합니다.
참고로 @Getter와 @Setter를 모두 해결해주는 @Data 어노테이션도 있습니다.

@Entity
객체를 테이블과 매핑 할 엔티티라고 JPA에게 알려주는 역할을 하는 어노테이션 입니다. ( 엔티티 매핑 )
@Entity가 붙은 클래스는 JPA가 관리하며, 이를 엔티티 클래스라 합니다.

@Table(name = "설정할 테이블 명")
엔티티 클래스와 매핑되는 테이블 정보를 명시하는 어노테이션입니다.
name 속성으로 테이블명을 작성할 수 있으며, 생략 시 엔티티 이름이 테이블명으로 자동 매핑됩니다.

@Id
테이블의 기본 키임을 명시하는 어노테이션 입니다.
저는 일반적으로 Id를 대체키로 사용하는 것이 좋다는 관점이며, Long 타입을 사용합니다.

@GeneratedValue(strategy= GenerationType.SEQUENCE {MySQL의 경우 IDENTITY})
기본키로 대체키를 사용할 때, 기본키 값 생성 전략을 명시합니다.

@Column
컬럼을 매핑하는 어노테이션입니다.

@Builder
빌더패턴 클래스를 생성해주는 어노테이션입니다.
@Setter 사용 대신 빌더패턴을 사용해야 안정성을 보장할 수 있습니다.

2) TimeEntity 구현

다음으로 BoardEntity는 TimeEntity를 상속하고 있는데요.

TimeEntity는 데이터 조작 시 자동으로 날짜를 수정해주는 JPA의 Auditing 기능을 사용합니다.
com/springwebservice/mine/domain/entity/TimeEntity.java

package com.springwebservice.mine.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;
}

@MappedSuperclass
테이블로 매핑하지 않고, 자식 클래스(엔티티)에게 매핑정보를 상속하기 위한 어노테이션입니다.

@EntityListeners(AuditingEntityListener.class)
JPA에게 해당 Entity는 Auditing기능을 사용한다는 것을 알리는 어노테이션입니다.

@CreatedDate
Entity가 처음 저장될때 생성일을 주입하는 어노테이션입니다.
이때 생성일은 update할 필요가 없으므로, updatable = false 속성을 추가합니다.
속성을 추가하지 않으면 수정 시, 해당 값은 null이 되어버립니다.

@LastModifiedDate
Entity가 수정될때 수정일자를 주입하는 어노테이션입니다.

3) @EnableJpaAuditing

마지막으로 JPA Auditing 활성화를 위해 Application에서 @EnableJpaAuditing 어노테이션을 추가해줍니다.
com/springwebservice/mine/MineApplication.java

package com.springwebservice.mine;

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

@EnableJpaAuditing
@SpringBootApplication
public class MineApplication {

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

}

6. DTO

마지막으로 데이터 전달 객체인 dto를 구현해보겠습니다.
com/springwebservice/mine/dto/BoardDto.java

package com.springwebservice.mine.dto;

import com.springwebservice.mine.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;
    }
}

toEntity()
dto에서 필요한 부분을 빌더패턴을 통해 entity로 만듭니다.
필요한 Entity는 이런식으로 추가하면 됩니다.

dto는 Controller <-> Service <-> Repository 간에 필요한 데이터를 캡슐화한 데이터 전달 객체입니다.
그런데 예제에서 Service에서 Repository 메서드를 호출할 때, Entity를 전달한 이유는 JpaRepository에 정의된 함수들은 미리 정의되어 있기 때문입니다. 그래서 Entity를 전달할 수 밖에 없었는데, 요점은 각 계층에서 필요한 객체전달은 Entity 객체가 아닌 dto 객체를 통해 주고받는 것이 좋다는 것입니다.

7. 테스트

리스트보기

게시물작성

작성완료

상세정보

수정

수정완료

삭제

profile
컴린이

0개의 댓글