티스토리 뷰

728x90
반응형

본 포스팅은 Spring MVC 패턴에 익숙해지기 위해 방명록을 만들고 간단한 CRUD를 구현해 보는 과정이다. 환경 상 로컬이 아닌 AWS의 EC2 환경에서 작성하는데, 이에 대한 설정 방법은 [Spring Boot] aws ec2에서 Spring Boot 프로젝트 환경설정 포스팅 참고

Spring 학습이 주 목적이기 때문에 프론트 엔드의 UI는 Bootstrap을 이용할 예정이고, 이에 대한 설명은 생략한다.

코드 참고 : https://github.com/on1ystar/guestbook2

 

GitHub - on1ystar/guestbook2

Contribute to on1ystar/guestbook2 development by creating an account on GitHub.

github.com

Goals

  • 프로젝트 구조 설계
  • 기본 설정 세팅 및 테스트
  • 방명록 등록(Create)
  • 방명록 목록 조회 및 단일 조회(Read)
  • 방명록 수정 및 삭제(Update & Delete)
  • 방명록 검색(Search)

 

프로젝트 구조 설계


먼저 방명록 프로젝트에 어떤 기능들을 구현할 지, 어떤 스택을 사용할 지 간단하게 설계해 보겠다.

요구사항

  • 방명록 목록 페이지 (메인 페이지)
    • 방명록 번호(gno), 제목(title), 작성자(writer), 등록 날짜(regDate)
    • 페이징 처리, 한 페이지 당 10개의 방명록 출력(내림차순)
    • 방명록 번호(gno) 클릭 시 조회 페이지로 이동
    • 방명록 등록 버튼을 누르면 등록을 위한 페이지 이동
    • 제목(title), 내용(content), 작성자(writer)를 조합하여 방명록 검색 가능
  • 방명록 등록 페이지
    • 방명록 제목(title), 내용(content), 작성자(writer)를 작성한 뒤 등록 가능
  • 방명록 조회 페이지
    • 방명록 제목(title), 내용(content), 작성자(writer), 등록 날짜(regDate) 표시
    • 수정 버튼을 누르면 수정 페이지로 이동
  • 방명록 수정 페이지
    • 방명록 제목(title), 내용(content) 수정 가능
    • 삭제

 

API 명세

swagger

 

주요 개발 스택

[server]

  • AWS EC2 Ubuntu 20.04

[Front-end]

  • Thymeleaf
  • Bootstrap 5.x

[Back-end]

  • Spring Boot 3.0.x
  • Java 17
  • Gradle 7.6.1
  • Spring Data JPA
  • MySQL 8.0
  • QueryDSL 5.0.0
  • Lombok

기본 설정 세팅 및 테스트


프로젝트를 생성하고 기초 세팅을 한 후, 잘 동작하는지 확인한다.

 

Spring Initializr

Spring Initializr를 이용해 Spring Boot 프로젝트를 생성한다.

이때 라이브러리는 Thymeleaf, Lombok, Spring Data JAP, Spring Web 등을 추가한다.

생성된 build.gradle 파일은 아래와 같다.

plugins {
	id 'java'
	id 'war'
	id 'org.springframework.boot' version '3.0.5'
	id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.projectlombok:lombok'
	providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

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

 

MySQL 연결을 위한 설정 세팅

application.properties (이 파일에 대해 알고 싶으면 [Spring] application.properties에 대해 참고)

# MySQL connection setting
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/on1ystar_db?serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=test
spring.datasource.password=test

# JPA setting
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true

# Thymeleaf setting
spring.thymeleaf.cache=false
  • spring.jpa.show-sql : JPA 처리 시 발생하는 SQL 출력
  • spring.jpa.properties.hibernate.format_sql : Hibernate가 동작하면서 발생하는 SQL을 포매팅 해서 출력
  • spring.jpa.hibernate.ddl-auto : 프로젝트 실행 시 자동으로 DDL을 생성할 것인지 결정(update는 변경 시에는 alter, 생성 시에는 create)
  • spring.thymeleaf.cache : 프로젝트 수정 후 만들어진 결과를 보관(캐싱)할 것인지 여부

build.gradle에도 mysql 라이브러리 의존성을 추가해 준다.

runtimeOnly 'mysql:mysql-connector-java:8.0.32'

 

Bootstrap 세팅

화면 UI는 편의를 위해 Bootstrap을 사용한다. 먼저 Bootstrap 무료 테마 사이트에서 원하는 테마를 다운 받으면 된다. 본 프로젝트 에서는 simple-sidebar를 사용했다.

다운 받은 후 src/main/resources/static 폴더로 모든 파일을 붙여 넣는다.

 

이제 레이아웃 템플릿을 만들어야 한다.

.../resorces/templates 폴더 안에 /layout/basic.html 파일을 하나 만든다.

그리고 .../resorces/static/dist/index.html 파일의 내용을 복사 후 붙여 넣는다.

그 다음 Thymeleaf 관련 설정들을 추가해 줘야 한다.

  • 파일에 Thymeleaf 선언
  • 레이아웃 include를 위한 fragment, replace
  • css 파일 및 JavaScript 파일 링크 처리
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="setContent(content)">
    <head>
        <!-- 생략 -->
        <title>Simple Sidebar - Start Bootstrap Template</title>
        <!-- Favicon-->
        <link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
        <!-- Core theme CSS (includes Bootstrap)-->
        <link th:href="@{../dist/css/styles.css}" rel="stylesheet" />
        <!-- Bootstrap core JS-->
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
        <!-- Core theme JS-->
        <script th:src="@{../dist/js/scripts.js}"></script>
    </head>
    <body>
        <!-- 생략 -->
                <!-- Page content-->
                <div class="container-fluid">
                    
                    <th:block th:replace = "${content}"></th:block>

                </div>
            </div>
        </div>
        
    </body>
</th:block>
</html>
 

수정된 부분만 표기했다. 코드를 비교해 보고 다른 부분을 직접 수정하길 바란다.

(위 Thymeleaf 문법이 이해가지 않는다면 [Spring] Thymeleaf 5가지 기본 표현식/자주 쓰는 구문 정리 포스팅 참고)

 

프로젝트 실행 테스트

세팅을 완료 했으면 프로젝트를 실행 시켜 이상이 없는지 테스트 해보자.

GuestbookApplication.java의 main 메서드 실행

실행 결과 로그 및 페이지

 

위와 같은 로그와 Error Page가 뜨면 정상이다.

여기까지가 Spring Boot를 띄우기 위한 기본 세팅 및 테스트다.

 

방명록 등록(Create)


이제 본격적인 기능 구현이다. 가장 먼저 방명록 등록 기능을 만들어 보겠다.

영속 계층 구현(Entity & Repository)

우선 뭘 하든 방명록 데이터를 가지고 다닐 객체가 필요하다. 본 프로젝트는 JPA를 활용하기 때문에 클래스를 통해 테이블 구조를 설계하고 생성까지 할 수 있다.

참고로, 테이블과 매핑되는 클래스를 Entity 객체라 하고, Entity에서 DB에 접근하기 위한 메서드 등을 포함하는 인터페이스를 Repository라고 한다.

com.example.guestbook 프로젝트 패키지 내에 entity 패키지를 하나 생성하고, 그 안에 Guestbook 클래스를 설계해 보자.

package com.example.guestbook.entity;

import jakarta.persistence.*;

import lombok.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Guestbook extends BaseEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long gno;
 
    @Column(length = 100, nullable = false)
    private String title;

    @Column(length = 1500, nullable = false)
    private String content;
    
    @Column(length = 50, nullable = false)
    private String writer;
}

 

  • pk는 gno고, 생성 전략은 GenerationType.IDENTITY(DB가 키 생성을 결정)
  • 총 4개의 필드 값

(+ 생성자 관련 어노테이션을 @AllArgsConstructor, @NoArgsConstructor, @Builder 3개나 쓴 이유가 궁금하다면 이 글을 참고)

추가로 우리는 BaseEntity라는 클래스를 하나 더 구현할 것이다.

이 클래스에는 entity 객체들의 생성 시기와 수정 시기 값을 가지는 필드들을 선언할 것이다. 모든 entity에 공통적으로 사용할 수 있는 값들이기 때문에 추상 클래스로 작성한 뒤, 이를 entity 클래스에서 상속하는 구조로 설계한다.

BaseEntity.java

package com.example.guestbook.entity;

import java.time.LocalDateTime;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.*;

import lombok.Getter;

@MappedSuperclass
@EntityListeners(value = { AuditingEntityListener.class })
@Getter
public abstract class BaseEntity {
    
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime regDate;

    @LastModifiedDate
    private LocalDateTime modDate;

}

@MappedSuperclass는 클래스를 테이블로 생성하지 않고, 단지 상속한 클래스에게 매핑 정보만 전달해 준다. (이 글 참고)

@EntityListeners(value = { AuditingEntityListener.class })는 엔티티를 DB에 적용하기 전, 이후에 커스텀 콜백을 요청할 수 있는 어노테이션이다.

  • @CreatedDate : entity가 생성된 시간을 감지해 저장
  • @LastModifiedDate : entity가 마지막으로 수정된 시간을 감지해 저장

 

작성한 BaseEntityGuestbook이 상속하게 한다.

public class Guestbook extends BaseEntity {

그리고 위 기능을 Spring Boot 프로젝트에 활성화하기 위해서는 main 메서드(엔트리 포인트)가 있는 GuestbookApplication@EnableJpaAuditing 어노테이션을 추가해줘야 한다.

@SpringBootApplication
@EnableJpaAuditing
public class GuestbookApplication {

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

}

(+ 위 기능에 대한 자세한 내용은 ‘JPA Auditing’ 키워드로 검색)

GuestbookRepository는 Spring Data JPA를 사용할 것이므로 더 간단하다. repository 패키지를 하나 생성 후 그 안에 GeustbookRepository 인터페이스를 작성한다.

package com.example.guestbook.repository;

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

import com.example.guestbook.entity.Guestbook;

public interface GuestbookRepository extends JpaRepository<Guestbook, Long> {
    
}

 

더미 데이터 삽입 및 테스트 케이스 작성

위에서 구현한 repository와 entity 클래스가 정상 작동하는지 확인하기 위해 JUnit을 이용한 간단한 테스트 케이스를 작성해 보자.

src 폴더와 동일한 구조로 test 폴더 안에 repository 패키지를 생성한 후 GuestbookRepositoryTests를 작성한다.

...

@SpringBootTest
@Transactional
public class GuestbookRepositoryTests {
    
    @Autowired 
    private GuestbookRepository guestbookRepository;

    @Test
    @Commit
    public void insertDummies() {
        // Given
        IntStream.rangeClosed(1, 301).forEach( i -> {
            Guestbook guestbook = Guestbook.builder()
                .title("Title..." + i)
                .content("Content..." + i)
                .writer("user"+ (i % 10))
                .build(); 
            
            guestbookRepository.save(guestbook);
        });

        // When
        List<Guestbook> guestbooks = guestbookRepository.findAll();

        // Then
        Assertions.assertThat(guestbooks.size()).isEqualTo(301);

    }
}

총 301개의 더미 데이터를 삽입했다.

테스트 클래스에서 @Transactional 어노테이션을 붙이면, 테스트 후 전부 rollback을 수행하여 테스트 이전 상태를 유지하게 되지만, 위 데이터는 테스트 후에도 남아있어야 하므로 @Commit 어노테이션을 붙여 준다.

테스트 로그를 보면 entity 클래스 작성 후 첫 사용이기 때문에 테이블을 생성하는 쿼리를 확인할 수 있다.

테스트 성공 여부는 사용하는 IDE에서 확인해 보면 된다.

 

DTO

service를 구현하기 전에 entity 객체를 운반할 DTO(Data Transfer Object)를 먼저 구현한다.

굳이 DTO를 사용하는 이유에 대해서는 [Spring] DTO를 사용하는 이유 포스팅 참고.

위와 마찬가지로 dto 패키지를 생성 후 GuestbookDTO를 작성한다.

package com.example.guestbook.dto;

import java.time.LocalDateTime;

import lombok.*;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class GuestbookDTO {
    
    private Long gno;
    private String title;
    private String content;
    private String writer;
    private LocalDateTime regDate;
    private LocalDateTime modDate;

}

 

비즈니스 계층 구현 (Service)

service는 인터페이스와 이를 구현한 클래스로 나누어서 작성한다.

사실 본 프로젝트는 service의 구현체가 1개 밖에 없기 때문에 굳이 관습적인 OCP(Open Closed Principle)로 인터페이스를 작성할 필요는 없다. 하지만 연습의 과정이기도 하고, dto를 entity로 변환해 주는 default 메서드를 인터페이스에 구현함으로써 조금이라도 인터페이스를 작성하는 이유를 만들었다.

(ModelMapper 라이브러리나 MapStruct를 사용하면 더 간편하게 객체를 매핑할 수 있다고 한다.)

service 패키지를 추가하고 GuestbookServiceGuestbookServiceImpl을 작성한다.

GuestbookServic.java

package com.example.guestbook.service;

import com.example.guestbook.dto.GuestbookDTO;
import com.example.guestbook.entity.Guestbook;

public interface GuestbookService {
    
    Long register(GuestbookDTO guestbookDTO);

    default Guestbook dtoToEntity(GuestbookDTO guestbookDTO) {
        Guestbook guestbook = Guestbook.builder()
            .gno(guestbookDTO.getGno())
            .title(guestbookDTO.getTitle())
            .content(guestbookDTO.getContent())
            .writer(guestbookDTO.getWriter())
            .build();
        
        return guestbook;
    }
}

방명록 등록을 위한 register 추상 메서드 1개와 dto 객체 파라미터를 entity 객체로 변환한 뒤 반환하는 dtoToEntity 메서드 1개를 작성했다.

GuestbookServiceImpl.java

...

@Service
@Log4j2
@RequiredArgsConstructor
public class GuestbookServiceImpl implements GuestbookService {

    private final GuestbookRepository guestbookRepository;

    @Override
    public Long register(GuestbookDTO guestbookDTO) {
        
        log.info("register -> " + guestbookDTO);

        Guestbook guestbook = dtoToEntity(guestbookDTO);

        Guestbook result = guestbookRepository.save(guestbook);

        log.info("registered -> " + result);

        return result.getGno();

    }
}

구현은 dto를 entity로 변환한 뒤, JpaRepositorysava 메서드를 호출하면 된다.

구현이 됐으면 꼭 간단하게라도 테스트를 작성하자.

service/GuestbookServiceTests.java

...

@SpringBootTest
@Transactional
public class GuestbookServiceTests {
    
    @Autowired
    private GuestbookService guestbookService;
    @Autowired
    private GuestbookRepository guestbookRepository;

    @Test
    public void registerTest() {
        
        // Given
        String title = "Test title";
        String content = "Test content";
        String writer = "Test writer";
        GuestbookDTO guestbookDTO = GuestbookDTO.builder()
            .title(title)
            .content(content)
            .writer(writer)
            .build();

        // When
        Long registeredGno = guestbookService.register(guestbookDTO);

        // Then
        Optional<Guestbook> resultOptional = guestbookRepository.findById(registeredGno);
        Guestbook guestbook = null;
        if(resultOptional.isPresent()) {
            guestbook = resultOptional.get();
        }
        Assertions.assertThat(guestbook.getTitle()).isEqualTo(title);
        Assertions.assertThat(guestbook.getContent()).isEqualTo(content);
        Assertions.assertThat(guestbook.getWriter()).isEqualTo(writer);
    }
}

테스트 결과 INSERT 쿼리가 생성됐고 로그도 확인할 수 있다.

 

프레젠테이션 계층 구현 (Controller & View)

방명록 등록 기능의 마지막 단계인 프레젠테이션 계층 구현이다. 사용자의 요청 및 응답을 처리하는 controller와 화면 구성을 위한 .html 파일을 Thymeleaf 템플릿을 사용해 작성할 것이다.

이때 방명록 등록 버튼이 어떤 페이지에 있을 것인지 정해야 하는데, 본 프로젝트에서는 간단하게 방명록 목록을 보여주는 /guestbook/list 에 등록 버튼을 만들도록 하겠다. 추후 이 페이지에서는 페이징 처리가 된 방명록 목록을 화면에 출력할 예정이다.

먼저 방명록 등록 버튼부터 만들어 보자.

마찬가지로 controller 패키지를 생성 후 GuestbookController.java를 작성.

...

@Controller
@RequestMapping("guestbook")
@Log4j2
public class GuestbookController {
    
    @GetMapping("/")
    public String index() {

        log.info("GET request : /guestbook/");

        return "redirect:/guestbook/list";
    }

    @GetMapping("/list")
    public void list() {

        log.info("GET request : /guestbook/list");

    }
}

아직 데이터를 전달 받거나 넘겨주지 않아도 되기 때문에 간단한 형태다.

홈(인텍스) 페이지를 /guestbook/list로 할 것이기 때문에, /로 GET 요청이 올 경우 /list로 리다이렉트 한다.

다음은 templates/guestbook/list.html에 등록 버튼을 만들어 본다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">

<th:block th:replace="~{/layout/basic :: setContent(~{this::content})}">

    <th:block th:fragment="content">

        <h1 class="mt-4">GuestBook List Page
            <span>
                <a th:href="@{/guestbook/register}">
                    <button type="button" class="btn btn-outline-primary">REGISTER</button>
                </a>
            </span>
        </h1>

    </th:block>

</th:block>

이전에 만들어 뒀던 layout/basic.html을 include해서 작성했다.

버튼을 누르면 하이퍼 링크를 통해 /guestbook/register로 이동한다.

화면을 보면,

방명록 목록 페이지

잘 그려지는 것을 확인할 수 있다.

이제 방명록 등록 페이지와 이를 위한 요청 및 응답 처리를 해보자.

  • GET /guestbook/register → 방명록 등록 페이지 요청
  • POST /guestbook/register → 방명록 등록 요청

 

GuestbookController.java

    ...

    @GetMapping("/register")
    public void register() {

        log.info("GET request : /guestbook/register");

    }

    @PostMapping("/register") 
    public String register(GuestbookDTO guestbookDTO, RedirectAttributes redirectAttributes) {

        log.info("POST request : /guestbook/register");

        Long registeredGno = guestbookService.register(guestbookDTO);

        log.info("registered gno : " + registeredGno);

        redirectAttributes.addFlashAttribute("msg", "success");

        return "redirect:/guestbook/list";
    }
}

Spring MVC는 url 파라미터 값이나 form 데이터에서 각 태그의 name 속성 값을 key로 하고, value 값을 key의 value 값으로 하는 데이터를 자동으로 수집하는 기능이 있다. 따라서 GuestbookDTO 객체의 필드 이름에 해당하는 값이 자동 수집되므로 우리는 GuestbookDTO 파라미터로 선언만 해 놓으면 된다.

방명록 등록이 성공적으로 이루어 지면, RedirectAttributes 객체를 통해 리다이렉트 시 success라는 문자열을 msg에 담아 같이 전송한다.

(+RedirectAttributes에 대한 자세한 설명은 추후 포스팅 예정)

마지막으로 register.html을 만들어 보겠다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">

<th:block th:replace="~{/layout/basic :: setContent(~{this::content})}">

    <th:block th:fragment="content">

        <h1 class="mt-4">GuestBook Register Page</h1>

        <form th:action="@{/guestbook/register}" method="post">
            <div class="form-group">
                <label>Title</label>
                <input type="text" class="form-control" name="title" placeholder="Enter Title" required>
            </div>
            <div class="form-group">
                <label>Content</label>
                <textarea class="form-control" row="5" name="content" required></textarea>  
            </div>
            <div class="form-group">
                <label>Writer</label>
                <input type="text" class="form-control" name="writer" placeholder="Enter Writer" required>
            </div>
            <button type="submit" class="btn btn-primary">Submit</button>
        </form>

    </th:block>

</th:block>

방명록 등록 시 title, content, writer를 입력하고 버튼을 누르면 /guestbook/register로 POST 요청을 한다.

이때 중요한 것은 name 속성의 값이 GuestbookDTO 필드(컬럼 또는 지역 변수) 이름과 같아야 한다.

그리고 만약 등록이 성공하면 success 메세지를 방명록 목록 화면에서 모달 창으로 띄워주는 JavaScript 코드를 list.html에 추가한다.

...

				</h1>

        <!-- Modal -->
        <button type="button" id="modalButton" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#myModal" hidden></button>
        <div class="modal" tabindex="-1" role="dialog" id="myModal">
            <div class="modal-dialog" role="document">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title">Modal Title</h5>
                        <button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
                            <span aria-hidden="true">&times;</span>
                        </button>
                    </div>
                    <div class="modal-body">
                        <p>[[${msg}]]</p>
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
                    </div>
                </div>
            </div>
        </div>

        <script th:inline="javascript">
        
            const msg = [[${msg}]];

            console.log(msg);

            if(msg) {
                const modalButton = document.getElementById("modalButton");
                modalButton.click()
            }
        </script>

    </th:block>

</th:block>

 

방명록 등록 페이지
방명록 목록 페이지에서 등록 성공 모달 창
방명록 등록 로그
데이터베이스 guestbook 테이블 조회

 

이로써 방명록 등록 기능까지 구현됐다.

이후 기능은 글이 너무 길어졌으니, 다음 포스팅에서 마무리 하겠다.

 

References

728x90
반응형
댓글