/ #JAVA#SPRING

스프링 부트로 게시판 만들기

개발 환경 구축

우분투 20.04 LTS 환경을 기준으로 하여 VScode로 원격개발을 하도록 하겠습니다.

원격 코드 개발환경 구축를 참고하여 원격 Spring Boot 개발환경을 구축합니다.

저는 gradle을 통해 Spring Boot 2.7.2 / JDK 17 환경의 프로젝트를 생성했습니다.

의존성으로는 Spring Boot DevTools, Lombok, Spring Web, Spring Data JPA, MariaDB Driver, Thymeleaf를 추가했습니다. gradle을 통해 단위 테스트 도구 juint도 추가했습니다.

MariaDB가 아닌 다른 DB의 경우 DB에 맞는 드라이버를 의존성으로 추가해주세요.

build.gradle
plugins {
    id 'org.springframework.boot' version '2.7.3'
    id 'io.spring.dependency-management' version '1.0.13.RELEASE'
    id 'java'
}

group = 'practice'
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'
    annotationProcessor 'org.projectlombok:lombok'

    // mariaDB JDBC driver
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'

    // 단위 테스트 도구
    testImplementation 'junit:junit'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // 개발툴
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
}

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

의존성 모듈의 프로퍼티를 설정합니다.

application.properties
# 타임리프 설정
spring.thymeleaf.prefix = classpath:templates/
spring.thymeleaf.suffix = .html
spring.thymeleaf.cache = false

# JPA 설정
spring.jpa.hibernate.ddl-auto = update
spring.jpa.hibernate.use-new-id-generator-mappings = false
spring.jpa.generate-ddl = true
spring.jpa.show-sql = true
spring.jpa.database = default
spring.jpa.database-platform = org.hibernate.dialect.MariaDB103Dialect
spring.jpa.properties.hibernate.format_sql = true
spring.jpa.properties.hibernate.use_sql_comments = false

# 데이터베이스 설정
spring.datasource.driver-class-name = org.mariadb.jdbc.Driver
spring.datasource.url = [DB 주소]
spring.datasource.username = [DB 아이디]
spring.datasource.password: [DB 암호]

# devtools 설정
spring.devtools.livereload.enabled = true
spring.devtools.remote.restart.enabled = true

# 서버 설정
server.port = 8080

# 로그 설정
logging.level.org.hibernate = info

다른 DB를 사용하는 경우 spring.jpa.database, spring.jpa.database-platform의 값과 데이터베이스 설정값을 바꿔주세요.

Entity 작성

애플리케이션을 개발하려면 요구사항을 분석해야 합니다. 저는 문서에 대한 CRUD와 댓글 기능을 구현하도록 하겠습니다. 추가적으로 회원과 카테고리, 그룹 기능도 구현하겠습니다.

개체-관계 다이어그램(Entity Relationship Diagram, ERD)을 통해 Entity와 테이블의 관계를 분석합니다.

img01

연관관계에 대한 설명은 추후에 하도록 하고, ERD를 토대로 엔티티를 작성하겠습니다.

우선 연관관계가 없는 column만 매핑하는데, column의 길이나 제약조건은 생각하지 않고 필드의 타입을 중점으로 매핑합니다.

Board.java
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

import lombok.*;

/**
 * 게시판 도메인
 *
 * @since : 2022-08-20 오후 6:18
 * @author : Rubisco
 * @version : 1.0.0
 */

@Getter
@Setter
@Entity
public class Board {
    
    @Id @GeneratedValue
    private Long documentId;

    private boolean isNotice;
    private String title;
    private String content;
    private Long likeCount;
    private Long dislikeCount;
    private Long readCount;
}

가장 기본적인 형태의 Entity 입니다.

ERD에서 테이블의 이름은 스네이크 표기법으로 되어있는데, Entity의 필드명을 카멜 표기법으로 정의하면 자동으로 스네이크 표기법으로 column 이름이 설정됩니다.

예를 들어 documentId 라는 필드는 document_id 라는 column과 매핑됩니다.

isNotice 필드는 ERD에서 String 타입이지만 boolean 타입으로 전환할 예정이므로 boolean 타입으로 선언합니다.

@Entity 어노테이션을 통해 Board 클래스가 Entity임을 선언하고, @Id 어노테이션으로 documentId가 기본키(Primary Key, PK)임을 선언했으며, @GeneratedValue 어노테이션으로 기본키의 숫자가 자동으로 증가되도록 기본키 전략을 지정해주었습니다.

또한 코드를 줄이고 가시성을 높이기 위해 롬복의 @Setter@Getter 어노테이션을 통해 세터와 게터를 생성했습니다.

그런데 Entity에 세터(Setter)가 있으면 중간에 데이터가 변형될 가능성이 있어서 영속화된 Entity의 경우 DB의 일관성을 보장할 수 없게 됩니다.

물론 트랜잭션이 끝나면 detached 상태가 되어 일관성을 보장할 수 있지만, Entity에는 전달하지 말아야 하는 데이터가 포함될 수도 있습니다. 즉, 보안성에 문제가 생길 수도 있습니다.

그러므로 DTO를 따로 만들어 Entity를 DTO로 전환하거나, 빌더(builder)를 통해 새로운 Board 객체를 만드는 것을 권장합니다.

개인적인 의견으로는 관점에 따라 필요한 DTO를 만들어 주는 것이 맞겠으나, 지금은 소규모 프로젝트 인데다가 개인이 작업하고 있으므로 빌더를 통해 새로운 Board 객체를 생성할 수 있도록 만들겠습니다.

Board.java
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

import lombok.*;

/**
 * 게시판 도메인
 *
 * @since : 2022-08-20 오후 6:18
 * @author : Rubisco
 * @version : 1.0.0
 */

@Getter
@Entity
public class Board {
    
    @Id @GeneratedValue
    private Long documentId;

    private boolean isNotice;
    private String title;
    private String content;
    private Long likeCount;
    private Long dislikeCount;
    private Long readCount;

    protected Board() {}

    public Board(
        Long documentId, 
        boolean isNotice, 
        String title, 
        String content
    ) {
        this.documentId = documentId;
        this.isNotice = isNotice;
        this.title = title;
        this.content = content;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {

        private Long documentId;
        private boolean isNotice;
        private String title;
        private String content;

        Builder() {}

        public Builder documentSrl(Long documentSrl) {
            this.documentSrl = documentSrl;
            return this;
        }

        public Builder isNotice(boolean isNotice) {
            this.isNotice = isNotice;
            return this;
        }

        public Builder title(String title) {
            this.title = title;
            return this;
        }

        public Builder content(String content) {
            this.content = content;
            return this;
        }

        public Board build() {
            return new Board(documentSrl, isNotice, title, content);
        }
    }
}

빌더 패턴은 추후에 설명하겠습니다. 빌더를 만들었더니 코드가 너무 복잡해졌습니다.

우선 비어있는 Board의 생성자는 Entity의 프록시 역할을 합니다. 자세한 설명은 생략하겠으나 어찌되었든 Entity를 생성하기 위해서는 매개변수가 없는 생성자가 필요합니다.

생성자가 없으면 자바에서 자동으로 매개변수가 없는 생성자를 만들어주지만, 빌더 패턴으로 생성자를 만들었으므로 매개변수가 없는 생성자도 같이 만들어 주어야 합니다.

외부에서 접근하는 것을 막기위해 protected 접근자를 사용했습니다.

protected Board() {}

짧은 코드지만 이 코드 또한 롬복의 @NoArgsConstructor 어노테이션을 통해 생성할 수 있습니다. 이때 access 매개변수를 통해 접근레벨을 설정할 수 있습니다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    ...
}

그 다음 코드는 필드를 주입하기 위한 생성자 입니다. 빌더만 있어도 새로운 Board 객체를 생성하는 것에는 문제가 없으나, Board 객체의 필드는 접근자가 private 이므로 필드를 주입해 줄 수 없습니다.

컨트롤러에서 클라이언트의 요청(Request)을 받으면 Board 객체의 필드이름과 동일한 파라미터를 매핑하여 필드를 주입하는데, 세터도 없고 생성자도 없으면 필드를 주입할 수 없어서 Board 객체의 필드값은 모두 null이 됩니다.

물론 Entity는 문제없이 생성되므로 DB에서 값을 조회하는 것은 문제가 없지만 DB에 값을 입력하는데 문제가 있습니다. 그러므로 생성자를 DI를 할 수 있는 환경을 만들어 줍시다.

롬복의 @AllArgsConstructor 어노테이션을 통해 전체 필드에 대한 생성자를 만들 수도 있지만, 우리는 Entity를 만드는 Board 클래스를 DTO 대신 사용하는 것이므로 필요한 필드만 선택하여 생성자를 만들어 주도록 합시다.

그 아래 코드는 빌더를 만드는 코드입니다. 빌더는 롬복의 @Builder 어노테이션을 통해 간단하게 생성할 수 있습니다. 클래스에 붙이면 전체 필드에 대한 빌더가 생성되지만 생성자에 붙이면 선택적인 빌드를 할 수 있는 빌더가 생성됩니다.

어노테이션을 적용하여 다음과 같이 코드를 줄일 수 있습니다.

Board.java
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

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

import lombok.*;

/**
 * 게시판 도메인
 *
 * @since : 2022-08-20 오후 6:18
 * @author : Rubisco
 * @version : 1.0.0
 */

@Getter
@Entity
@DynamicInsert
@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    
    @Id @GeneratedValue
    private Long documentId;

    private boolean isNotice;
    private String title;
    private String content;
    private Long likeCount;
    private Long dislikeCount;
    private Long readCount;

    @Builder
    public Board(
        Long documentId, 
        boolean isNotice, 
        String title, 
        String content
    ) {
        this.documentId = documentId;
        this.isNotice = isNotice;
        this.title = title;
        this.content = content;
    }

    public Board update(Board board) {

        Assert.notNull(title, "Title must not be null");
        Assert.notNull(content, "Content must not be null");
        Assert.notNull(isNotice, "IsNotice must not be null");

        title = board.title;
        content = board.content;
        isNotice = board.isNotice;

        return this;
    }
}

@DynamicInsert 어노테이션과 @DynamicUpdate 어노테이션에 대한 설명은 추후에 하도록 하고 마지막에 update 메소드 내부를 보면 Assert.notNull 이라는 메소드를 호출합니다.

이 메소드는 값이 null이면 예외를 발생시키는 메소드로써, 방어 코드로 입력했습니다. 테스트를 할 때 어디에서 문제가 발생했는지 쉽게 찾을 수 있기 때문에 이렇게 방어코드를 작성하는 것을 권장합니다.

이렇게만 작성해도 문제가 없겠으나, ERD에 작성한대로 각 필드마다 column과 매칭시키도록 하겠습니다. column의 속성은 필드에 @column 어노테이션을 붙여서 설정할 수 있습니다.

Board.java
import javax.persistence.Column;
import javax.persistence.Convert;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Lob;

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

import kr.kro.hex.BooleanToYNConverter;
import lombok.*;

/**
 * 게시판 도메인
 *
 * @since : 2022-08-20 오후 6:18
 * @author : Rubisco
 * @version : 1.0.0
 */

@Getter
@Entity
@DynamicInsert
@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    
    @Id @GeneratedValue
    private Long documentId;

    @Column(length = 1)
    @ColumnDefault("'N'")
    @Convert(converter = BooleanToYNConverter.class)
    private boolean isNotice;

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

    @Lob @Column(nullable = false)
    private String content;

    @Column(updatable = false)
    @ColumnDefault("0")
    private Long likeCount;

    @Column(updatable = false)
    @ColumnDefault("0")
    private Long dislikeCount;

    @Column(updatable = false)
    @ColumnDefault("0")
    private Long readedCount;

    @Builder
    public Board(
        Long documentId, 
        boolean isNotice, 
        String title, 
        String content
    ) {
        this.documentId = documentId;
        this.isNotice = isNotice;
        this.title = title;
        this.content = content;
    }

    public Board update(Board board) {

        Assert.notNull(title, "Title must not be null");
        Assert.notNull(content, "Content must not be null");
        Assert.notNull(isNotice, "IsNotice must not be null");

        title = board.title;
        content = board.content;
        isNotice = board.isNotice;

        return this;
    }
}

@Column 어노테이션의 속성은 이전에 작성한 JPA 글을 참고하세요.

@Convert 어노테이션은 column 타입과 필드 타입을 달리 하고 싶을때 사용합니다. isNotice 필드는 boolean 타입인데, DB에 YN 형태로 저장하고 싶다면 컨버터를 통해 전환해야 합니다. 즉, 컨버터 클래스를 만들어야 합니다. 적당한 곳에 BooleanToYNConverter.java 클래스를 만들고 다음과 같이 작성하세요.

BooleanToYNConverter.java
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;

/**
 * Boolean 타입을 YN 문자열로 전환
 *
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-21 오후 6:52
 */

@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {

    @Override
    public String convertToDatabaseColumn(Boolean attribute) {
        return (attribute != null && attribute) ? "Y" : "N";
    }

    @Override
    public Boolean convertToEntityAttribute(String dbData) {
        return "Y".equals(dbData);
    }
}

@Converter 어노테이션을 통해 컨버터라는 것을 선언하고 AttributeConverter 인터페이스를 상속받아 convertToDatabaseColumn, convertToEntityAttribute 메소드를 구현합니다.

메소드명에서 알 수 있듯이 convertToDatabaseColumn 메소드는 필드를 column으로 바꾸는 메소드이고, convertToEntityAttribute 메소드는 column을 필드로 바꾸는 메소드입니다.

content 필드에서 @Lob 어노테이션은 대용량의 문자열이나 이진스트림을 저장할 때 사용합니다.

likeCount, dislikeCount, readedCount 필드는 쿼리를 통해 값을 증가시킬 예정으로, update가 되지 않도록 설정합니다.

이제 생성 시간과 업데이트 시간을 필드에 추가해야 한데, ERD를 보면 두 필드는 모든 테이블에서 반복되어 나타납니다. 그러므로 두 필드를 가진 객체를 부모클래스로 하여 상속받도록 하겠습니다.

BaseTime 클래스를 다음과 같이 작성하세요.

BaseTime.java
import java.time.LocalDateTime;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;

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

import lombok.Getter;

/**
 * 시간 도메인
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-12 오후 12:09
 */

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTime {

    @CreatedDate
    private LocalDateTime createDate;

    @LastModifiedDate
    private LocalDateTime updateDate;
}

@MappedSuperclass 어노테이션은 이 클래스가 Entity 클래스의 부모클래스 라는 것을 선언합니다. 부모 클래스를 Entity로 선언하면 테이블이 별도로 생성되지만, MappedSuperclass를 선언하면 테이블이 생성되지 않고 상속할 수 있습니다.

@EntityListeners 어노테이션은 상태를 관찰하는 리스너를 생성합니다. 이때 스프링이 실행되는 메인 클래스에 다음과 같이 @EnableJpaAuditing 어노테이션을 달아주어야 정상적으로 작동합니다.

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

@EnableJpaAuditing
@SpringBootApplication
public class HexApplication {

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

}

나머지는 예상하듯이 @CreatedDate 어노테이션은 INSERT 상태를, @LastModifiedDate 어노테이션은 UPDATE 상태를 관찰하여 시간을 자동으로 입력해줍니다.

이제 Board 클래스에 BaseTime 클래스를 extends 해줍니다.

Board.java
public class Board extends BaseTime { ... }

Member, Comments, Category, Group 테이블에 대한 Entity 클래스도 동일한 방식으로 작성해주세요.

Comments.java
import javax.persistence.*;

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

import lombok.*;

/**
 * 댓글 도메인
 *
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-21 오후 9:18
 **/

@Getter
@Entity
@DynamicInsert
@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comments extends BaseTime {

    @Id @GeneratedValue
    private Long commentId;

    @Lob @Column(nullable = false)
    private String content;

    @Column(updatable = false)
    @ColumnDefault("0")
    private Long likeCount;

    @Column(updatable = false)
    @ColumnDefault("0")
    private Long dislikeCount;

    @Builder
    public Comments(
            Long ,
            String commentId
    ) {
        this.commentId = commentId;
        this.content = ccommentSrlontent;
    }

    public Comments update(Comments comment) {
        Assert.notNull(content, "content must not be null");
        this.content = comment.content;
        return this;
    }
}
Member.java
import java.time.LocalDateTime;

import javax.persistence.*;

import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

import lombok.*;

/**
 * 회원 도메인
 *
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-21 오후 9:52
 */

@Getter
@Entity
@DynamicInsert
@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTime {

    @Id @GeneratedValue
    private Long memberId;

    @Column(length = 80, nullable = false, updatable = false, unique = true)
    private String id;

    @Column(length = 60, nullable = false)
    private String password;

    @Column(unique = true)
    private String email;

    @Column(length = 40, nullable = false)
    private String name;

    @Column(length = 40, nullable = false, unique = true)
    private String nickName;

    @Column(name="last_login")
    private LocalDateTime updateDate;

    @Builder
    public Member(
            Long memberId,
            String id,
            String password,
            String email,
            String name,
            String nickName
    ) {
        this.memberId = memberId;
        this.id = id;
        this.password = password;
        this.email = email;
        this.name = name;
        this.nickName = nickName;
    }
}
Category.java
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

import javax.persistence.*;

/**
 * 카테고리 도메인
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-21 오후 10:19
 */

@Getter
@Entity
@DynamicInsert
@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Category extends BaseTime {

    @Id @GeneratedValue
    private Long categoryId;

    @Column(length = 80, nullable = false, unique = true)
    private String category;

    @Builder
    public Category(Long categoryId, String category) {
        this.categoryId = categoryId;
        this.category = category;
    }
}
Group.java
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

import javax.persistence.*;

/**
 * 그룹 도메인
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-21 오후 10:52
 */

@Getter
@Entity
@DynamicInsert
@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Group extends BaseTime {

    @Id @GeneratedValue
    private Long groupId;

    @Column(length = 80, nullable = false, unique = true)
    private String group;

    @Builder
    public Group(Long groupId, String group) {
        this.groupId = groupId;
        this.group = group;
    }
}

이제 연관관계를 맺어야 하는데 여기서 설명하기에는 많은 내용이라 추후에 따로 설명하겠습니다.

Repository 작성

Entity를 작성했으니 이제 Repository를 작성하겠습니다. Repository로부터 Entity가 생성됩니다. Spring Data JPA를 사용하므로, 인터페이스를 상속받기만 하면 됩니다.

BoardRepository.java
package kr.kro.hex.persistance;

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

import kr.kro.hex.domain.Board;

/**
 * 게시판 레포지토리
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @date : 2022-08-21 오후 10:20
 */

public interface BoardRepository extends JpaRepository<Board, Long>  {}
CommentRepository.java
package kr.kro.hex.persistance;

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

import kr.kro.hex.domain.Comments;

/**
 * 댓글 레포지토리
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @date : 2022-08-21 오후 10:20
 */

public interface CommentRepository extends JpaRepository<Comments, Long>  {}
MemberRepository.java
package kr.kro.hex.persistance;

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

import kr.kro.hex.domain.Member;

/**
 * 회원 레포지토리
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @date : 2022-08-21 오후 10:20
 */

public interface MemberRepository extends JpaRepository<Member, Long>  {}
MemberRepository.java
package kr.kro.hex.persistance;

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

import kr.kro.hex.domain.Member;

/**
 * 회원 레포지토리
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @date : 2022-08-21 오후 10:20
 */

public interface MemberRepository extends JpaRepository<Member, Long>  {}
CategoryRepository.java
package kr.kro.hex.persistance;

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

import kr.kro.hex.domain.Category;

/**
 * 카테고리 레포지토리
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @date : 2022-08-21 오후 10:20
 */

public interface CategoryRepository extends JpaRepository<Category, Long>  {}
GroupRepository.java
package kr.kro.hex.persistance;

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

import kr.kro.hex.domain.Group;

/**
 * 그룹 레포지토리
 * 
 * @author : Rubisco
 * @version : 1.0.0
 * @date : 2022-08-21 오후 10:20
 */

public interface GroupRepository extends JpaRepository<Group, Long>  {}

Service 작성

이제 어플리케이션과 DB를 연결해주는 서비스를 작성해 보겠습니다. 게시판의 CRUD 서비스를 만들겠습니다.

서비스는 @Service 어노테이션을 통해 선언할 수 있습니다. 우선 아래와 같이 CRUD 메소드를 정의한 인터페이스를 작성합니다.

BoardService.java
import java.util.List;

import kr.kro.hex.domain.Board;

/**
 * 게시판 서비스의 인터페이스
 *
 * @see Board 게시판 Entity
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-21 오후 11:04
 */

public interface BoardService {

    void insertBoard(Board board);

    List<Board> getBoardList();

    Board getBoard(Board board);

    void updateBoard(Board board);

    void deleteBoard(Board board);
}

인터페이스를 상속받은 구현체를 만드세요. 인텔리제이의 경우 구현체부터 만들면 인터페이스 추출이 가능합니다.

BoardServiceImpl.java
import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import kr.kro.hex.domain.Board;
import kr.kro.hex.persistance.BoardRepository;
import kr.kro.hex.service.BoardService;
import lombok.RequiredArgsConstructor;

/**
 * 게시판 서비스의 구현체
 *
 * @see Board 게시판 Entity
 * @see BoardRepository 게시판 레포지토리
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-21 오후 11:04
 */

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BoardServiceImpl implements BoardService {
    
    /** 게시판 레포지토리 */
    private final BoardRepository boardRepo;

    @Override
    public void insertBoard(Board board) {}

    @Override
    public List<Board> getBoardList() {
        return null;
    }

    @Override
    public Board getBoard(Board board) {
        return board;
    }

    @Override
    public void updateBoard(Board board) {}

    @Override
    public void deleteBoard(Board board) {}
}

@RequiredArgsConstructor 어노테이션은 final 키워드가 있는 필드를 매개변수로 하는 생성자를 만들어 줍니다. 즉, 생성자 주입을 가능한 상태로 만듭니다.

@Transactional 어노테이션은 트랜잭션 처리를 할 수 있도록 하는데, 클래스에 붙으면 전체 메소드에 트랜잭션 처리 기능이 생깁니다 매개변수 readOnly는 조회만 가능하도록 합니다. flush를 자동으로 호출하지 않기 때문에 수동으로 flush 하지 않는 한 CUD 작업은 동작하지 않습니다. 그러므로 스냅샷을 생성하거나 dirty checking을 하지 않아서 성능이 향상된 효과를 볼 수 있습니다.

CUD 작업이 필요한 메소드인 경우 메소드에 @Transactional 어노테이션을 붙이면 됩니다.

이제 메소드 단위로 개발을 하시면 됩니다. 간단하게 CRUD 로직을 구현했습니다.

BoardServiceImpl.java
import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import kr.kro.hex.domain.Board;
import kr.kro.hex.persistance.BoardRepository;
import kr.kro.hex.service.BoardService;
import lombok.RequiredArgsConstructor;

/**
 * 게시판 서비스의 구현체
 *
 * @see Board 게시판 Entity
 * @see BoardRepository 게시판 레포지토리
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-21 오후 11:04
 */

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BoardServiceImpl implements BoardService {
    
    /** 게시판 레포지토리 */
    private final BoardRepository boardRepo;

    @Override
    public void insertBoard(Board board) {
        boardRepo.save(board);
    }

    @Override
    public List<Board> getBoardList() {
        return boardRepo.findAll();
    }

    @Override
    public Board getBoard(Board board) {
        return boardRepo.findById(board.getDocumentId()).get();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateBoard(Board board) {
        boardRepo.save(getBoard(board).update(board));
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteBoard(Board board) {
        boardRepo.deleteById(board.getDocumentId());
    }
}

단위 테스트

서비스가 잘 만들어 졌는지 단위테스트를 해보겠습니다. 단위테스트는 Spring에서도 권하는 사항이며, 뷰를 따로 만들 필요가 없기 때문에 메소드 단위로 개발을 했다면 단위테스트를 하시는 것을 추천합니다.

VScode에서는 클래스 이름위에 오른쪽 마우스 클릭을 하여 소스작업 -> Generate test... 를 클릭하면 테스트 클래스를 만들 수 있고, 인텔리제이의 경우 Shift + Ctrl + T를 눌러 간단하게 테스트 클래스를 생성할 수 있습니다.

BoardServiceImpl.java
import org.junit.jupiter.api.Test;

public class BoardServiceImplTest {
    @Test
    void testDeleteBoard() {

    }

    @Test
    void testGetBoard() {

    }

    @Test
    void testGetBoardList() {

    }

    @Test
    void testInsertBoard() {

    }

    @Test
    void testUpdateBoard() {

    }
}

자동으로 import 되어 있는 org.junit.jupiter.api.Test는 org.juit.Test로 변경해줍시다. 또한 @SpringBootTest 어노테이션을 붙여서 스프링 부트를 테스트한다는 것을 선언하고, @RunWith(SpringRunner.class) 어노테이션을 붙여 스프링 러너를 함께 실행하도록 합니다.

메소드는 public 접근자로 모두 바꾸고 @Autowired 어노테이션으로 BoardService를 주입해주세요.

코드를 다음과 같이 작성하고 testInsertBoard -> testGetBoardList -> testUpdateBoard -> testGetBoard -> testDeleteBoard 순서로 실행시키면서 DB 내용을 확인해보세요. 정상적으로 작동하는 것을 볼 수 있습니다.

BoardServiceImplTest.java
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import kr.kro.hex.domain.Board;
import kr.kro.hex.service.BoardService;


@RunWith(SpringRunner.class)
@SpringBootTest
public class BoardServiceImplTest {

    @Autowired
    private BoardService boardservice;

    @Test
    public void testDeleteBoard() {
        boardservice.deleteBoard(
            boardservice.getBoard(
                Board.builder().documentId(1L).build()
            )
        );
    }

    @Test
    public void testGetBoard() {
        System.out.println(
            boardservice.getBoard(
                Board.builder().documentId(1L).build()
            ).getTitle()
        );
    }

    @Test
    public void testGetBoardList() {
        for (Board board : boardservice.getBoardList()) {
            System.out.println(board.getTitle());
        }
    }

    @Test
    public void testInsertBoard() {
        boardservice.insertBoard(
            Board.builder()
                .isNotice(false)
                .title("테스트 제목")
                .content("테스트 내용")
                .build()
        );
    }

    @Test
    public void testUpdateBoard() {
        boardservice.updateBoard(
            Board.builder()
                .documentId(1L)
                .title("제목 수정")
                .content("내용 수정")
                .build()
        );
    }
}

Controller 작성

컨트롤러는 @Controller 어노테이션을 통해 선언합니다. DispatcherServlet에서 클라이언트의 요청(Request)을 받으면 핸들러를 통해 컨트롤러의 메소드를 검색합니다. 이때 핸들러가 찾을 수 있도록 메소드에 어노테이션을 선언하는데 @RequestMapping, @GetMapping, @PostMapping, @PatchMapping, @DeleteMapping을 사용합니다.

보통 Get 요청에는 Read, Post 요청에는 Create, Patch 요청에는 Update, Delete 요청에는 Delete 처리를 합니다. 또한 클래스에 @ReqpuestMapping 어노테이션을 붙여서 엔트리 포인트를 설정 할 수도 있습니다.

저는 BoardController를 다음과 같이 작성했습니다.

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.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import kr.kro.hex.domain.Board;
import kr.kro.hex.service.BoardService;
import kr.kro.hex.service.CategoryService;
import lombok.RequiredArgsConstructor;

/**
 * 게시판 서비스 컨트롤러
 *
 * @see BoardService 게시판 서비스
 * @see CategoryService 카테고리 서비스
 *
 * @author : Rubisco
 * @version : 1.0.0
 * @since : 2022-08-08 오후 6:24
 */

@Controller
@RequiredArgsConstructor
@RequestMapping(path = "/board")
public class BoardController {
    
    /** 게시판 서비스 */
    private final BoardService boardService;

    /** 카테고리 서비스 */
    private final CategoryService categoryService;

    /**
     * 게시글 목록 뷰를 반환
     *
     * @author Rubisco
     * @param model 모델
     * @return getBoardList.html
     */
    @GetMapping()
    public String getBoardListView(Model model) {
        model.addAttribute("boardList", boardService.getBoardList());
        return "getBoardList";
    }

    /**
     * 게시글 뷰를 반환
     *
     * @see Board
     * @author Rubisco
     * @param board 게시글
     * @param model 모델
     * @return getBoard.html
     */
    @GetMapping("/{documentId}")
    public String getBoardView(Board board, Model model) {
        model.addAttribute("nl", System.getProperty("line.separator"));
        model.addAttribute("board", boardService.getBoard(board));
        return "/board/getBoard";
    }

    /**
     * 게시글 작성 페이지 뷰를 반환
     *
     * @author Rubisco
     * @param model 모델
     * @return insertBoard.html
     */
    @GetMapping(params = "act=write")
    public String insertBoardView(Model model) {
        model.addAttribute("categoryList", categoryService.getCategoryList());
        return "/board/insertBoard";
    }

    /**
     * 게시글 수정 페이지 뷰를 반환
     *
     * @see Board
     * @param board 게시글
     * @param model 모델
     * @return insertBoard.html
     * @author Rubisco
     */
    @GetMapping(params = {"documentId","act=update"})
    public String updateBoardView(Board board, Model model) {
        model.addAttribute("board", boardService.getBoard(board));
        model.addAttribute("categoryList", categoryService.getCategoryList());
        return "/board/insertBoard";
    }

    /**
     * 게시글 등록 요청을 처리
     *
     * @see Board
     * @author Rubisco
     * @param board 게시글
     * @return redirect:/board
     */
    @PostMapping()
    public String insertBoardController(Board board) {
        boardService.insertBoard(board);
        return "redirect:/board";
    }

    /**
     * 게시글 수정 요청을 처리
     *
     * @see Board
     * @param board 게시글
     * @author Rubisco
     * @return redirect:/board/{documentId}
     */
    @PatchMapping(params = "documentId")
    public String updateBoard(Board board) {
        boardService.updateBoard(board);
        return "redirect:/board/"+board.getDocumentId();
    }

    /**
     * 게시글 삭제 요청을 처리
     *
     * @author Rubisco
     * @param board
     * @return redirect:/board
     */
    @DeleteMapping(params = "documentId")
    public String deleteBoard(Board board) {
        boardService.deleteBoard(board);
        return "redirect:/board";
    }
}

이제 컨트롤러에서 반환하는 뷰의 이름에 맞는 템플릿을 작성하면 프로그램은 정상적으로 작동할 수 있게 됩니다.

뷰는 타임리프 템플릿 엔진을 사용해서 작성할것인데 이또한 내용이 많으므로 추후에 따로 글을 올리겠습니다.