Spring Boot 게시판 생성하기

By | 2024년 3월 5일
Table of Contents

Spring Boot 게시판 생성하기

스프링 부트를 이용해 게시판을 생성해 봅니다.
일반적인 프로젝트가 API 서버와 UI 가 분리되고,
API 를 스프링이 담당하고,
UI 부분은 자바스크립트가 대응하는 방식이라 저는 API 서버 구성하는 부분을 중점적으로 설명합니다.

UI 부분은 제가 잘 안하는 부분이라…
설명을 하게 될지는 모르겠습니다.

데이타베이스 생성(MariaDB)

여기 에서 MariaDB 를 다운받아 설치합니다.

UTF-8 as default server’s character set 를 체크합니다.

설치된 프로그램중 MySQL Client 를 실행하고 로그인합니다.

계정을 생성하고, 데이타베이스를 생성 후, 권한을 부여합니다.

CREATE USER 'myuser'@'localhost' IDENTIFIED BY 'mypass';
CREATE USER 'myuser'@'%' IDENTIFIED BY 'mypass';

CREATE DATABASE myboard;

GRANT ALL PRIVILEGES ON myboard.* TO 'myuser'@'localhost';

FLUSH PRIVILEGES;

USE myboard;

CREATE TABLE `tbl_post` (
    `idx`           bigint(20)    NOT NULL AUTO_INCREMENT,
    `title`         varchar(100)  NOT NULL,
    `content`       varchar(3000) NOT NULL,
    `writer`        varchar(20)   NOT NULL,
    `view_cnt`      int(11)       NOT NULL DEFAULT 1,
    `delete_yn`     tinyint(1)    NOT NULL DEFAULT 0,
    `created_date`  DATETIME      NOT NULL DEFAULT current_timestamp(),
    `modified_date` datetime               DEFAULT NULL,
    PRIMARY KEY (`idx`)
);

데이타베이스 생성(Oracle 18c Express Edition)

// sqlplus
// 사용자명 입력: system
// 비밀번호 입력:
// conn/as sysdba
create user c##myuser identified by mypass;
grant connect, resource, dba to c##myuser;

CREATE TABLE tbl_post (
    idx             NUMBER(20)                          NOT NULL,
    title           VARCHAR2(100)                       NOT NULL,
    content         VARCHAR2(3000)                      NOT NULL,
    writer          VARCHAR2(20)                        NOT NULL,
    view_cnt        NUMBER(4)       DEFAULT 1           NOT NULL,
    delete_yn       NUMBER(1)       DEFAULT 0           NOT NULL,
    created_date    DATE            DEFAULT SYSDATE     NOT NULL,
    modified_date   DATE            DEFAULT             NULL
);

ALTER TABLE tbl_post ADD CONSTRAINT idx_pk PRIMARY KEY (idx);

CREATE SEQUENCE idx_seq START WITH 1;

COMMIT;

// SID 확인
SELECT instance FROM v$thread;

insert/update/delete/select

프로젝트 생성

여기 를 참조하여 프로젝트를 생성해 줍니다.
lombok, devtools, spring boot web, jpa 를 선택해 줍니다.

여기 를 참조하여 mapstruct 도 프로젝트에 추가합니다.

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.springframework.data:spring-data-commons'
    implementation 'org.mapstruct:mapstruct:1.4.1.Final'
    compileOnly 'org.projectlombok:lombok'
    compileOnly 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.1.Final'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    implementation 'org.hibernate:hibernate-core:6.4.1.Final'
    implementation 'com.oracle.database.jdbc:ojdbc10:19.22.0.0'
}

기본적인 흐름

값의 입력과 결과값은 반환은 json 에 의해 이루어집니다.

사용자입력(json) -> DTO -> Data Object -> DB
사용자반환(json) <- DTO <- Data Object <- DB

Domain 레이어 생성

스프링 부트 JPA 엔터티에 대해서는 다른 문서를 찾아보시기 바랍니다.

Post.java

@Entity
@Getter
@Setter
@Table(name = "tbl_post")
@NoArgsConstructor
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)    // MariaDB
    // @GeneratedValue(generator="idx_seq")                                         // Oracle
    // @SequenceGenerator(name="idx_seq",sequenceName="idx_seq", allocationSize=1)  // Oracle
    private Long idx;

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

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

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

    @Column(nullable = false)
    private Integer viewCnt = 1;

    @Column(nullable = false)
    private Integer deleteYn = 0;

    @Column(nullable = false)
    private LocalDateTime createdDate = LocalDateTime.now();

    @Column
    private LocalDateTime modifiedDate;
}

PostRepository.java

public interface PostRepository extends JpaRepository<Post, Long> {
}

application.yml (MariaDB)

spring:
  datasource:
    url: jdbc:mariadb://${MYSQL_HOST:localhost}:3306/myboard
    username: myuser
    password: mypass
    driver-class-name: org.mariadb.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: false

application.yml (Oracle 18c Express Edition)

spring:
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: false
    open-in-view: false
    database-platform: org.hibernate.dialect.OracleDialect
    properties:
      hibernate:
        temp:
          use_jdbc_metadata_defaults: false
  datasource:
    hikari:
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      maximum-pool-size: 10
    url: jdbc:oracle:thin:@${MYSQL_HOST:localhost}:1521:xe
    username: c##myuser
    password: mypass
    driver-class-name: oracle.jdbc.OracleDriver
    type: com.zaxxer.hikari.HikariDataSource
  main:
    allow-bean-definition-overriding: true
    lazy-initialization: true

Service 레이어 생성

DTO 를 어떻게 생성하고 운영하는지는 선택의 문제입니다.
여기서는 문서의 분량을 줄이기 위해 하나의 DTO 만 생성합니다.

PostDto.java

@Getter
@Setter
@NoArgsConstructor
public class PostDto {
    private Long idx;
    private String title;
    private String content;
    private String writer;
    private Integer viewCnt;
    private Integer deleteYn;
    private LocalDateTime createdDate;
    private LocalDateTime modifiedDate;
}

MapStruct 를 이용하면 소스코드가 매우 간단해집니다.

GenericMapper.java

public interface GenericMapper<D, E> {
    D toDto(E e);
    List<D> toDto(List<E> e);
    void updateFromDto(D dto, @MappingTarget E entity);
}

PostMapper.java

@Mapper(
        componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.IGNORE,
        nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface PostMapper extends GenericMapper<PostDto, Post> {
}

PostService.java

@RequiredArgsConstructor
@Service
public class PostService {
    private final PostRepository postRepository;
    private final PostMapper mapper = Mappers.getMapper(PostMapper.class);

    @Transactional
    public PostDto insert(PostDto dto) {
        // validate(OperationMode.INSERT, dto);
        Post e = new Post();
        mapper.updateFromDto(dto, e);
        return mapper.toDto(postRepository.save(e));
    }

    @Transactional
    public PostDto update(PostDto dto) {
        // validate(OperationMode.UPDATE, dto);
        Post e = get(dto.getIdx());
        mapper.updateFromDto(dto, e);
        e.setModifiedDate(LocalDateTime.now());
        return mapper.toDto(postRepository.save(e));
    }

    @Transactional
    public void delete(Long idx) {
        Post e = get(idx);
        e.setModifiedDate(LocalDateTime.now());
        e.setDeleteYn(1);
    }

    @Transactional
    public void undelete(Long idx) {
        Post e = get(idx);
        e.setModifiedDate(LocalDateTime.now());
        e.setDeleteYn(0);
    }

    @Transactional
    public PostDto getOne(Long idx) {
        Post e = get(idx);
        e.setViewCnt(e.getViewCnt() + 1);
        return mapper.toDto(get(idx));
    }

    private Post get(Long idx) {
        Optional<Post> e = postRepository.findById(idx);
        if (e.isEmpty()) {
            throw new RuntimeException("data not found");
        }
        return e.get();
    }
}

Controller 레이어 생성

@RequiredArgsConstructor
@RestController
public class PostController {
    private final PostService service;

    @PostMapping("/api/v1/ins")
    public PostDto insert(@RequestBody PostDto dto) {
        return service.insert(dto);
    }

    @PostMapping("/api/v1/upd")
    public PostDto update(@RequestBody PostDto dto) {
        return service.update(dto);
    }

    @GetMapping("/api/v1/del")
    public void delete(@RequestParam("id") Long id) {
        service.delete(id);
    }

    @GetMapping("/api/v1/undel")
    public void undelete(@RequestParam("id") Long id) {
        service.undelete(id);
    }

    @GetMapping("/api/v1/")
    public PostDto get(@RequestParam("id") Long id) {
        return service.getOne(id);
    }
}

테스트

VSCode 에 Thunder Client 플러그인을 설치하면 API 를 호출할 수 있습니다.

search

다른 API 와 다르게 생각해야 할 부분이 많지만,
이 문서는 기초지식을 주목적으로 하기에,
가장 간단한 형태의 검색을 구현합니다.

페이징을 구현하고, 검색조건은 없거나 1나의 검색조건을 구현합니다.

QueryDSL 세팅

여기 를 참조하여 QueryDSL 을 세팅합니다.

커스텀 쿼리 생성

public interface PostRepositoryCustom {
    Page<Post> search(Integer pageNo, Integer pageSize, String searchStr);
}
import static com.example.board_api.domain.QPost.post;

public class PostRepositoryImpl extends QuerydslRepositorySupport implements PostRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;

    public PostRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
        super(Post.class);
        this.jpaQueryFactory = jpaQueryFactory;
    }

    @Override
    public Page<Post> search(Integer pageNo, Integer pageSize, String searchStr) {
        Pageable pageable = PageRequest.of(pageNo, pageSize);

        List<Post> list = jpaQueryFactory
                .selectFrom(post)
                .where(
                        post.title.contains(searchStr)
                        .or
                        (post.content.contains(searchStr))
                        .or
                        (post.writer.contains(searchStr))
                )
                .orderBy(post.idx.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Post> count = jpaQueryFactory
                .selectFrom(post)
                .where(
                        post.title.contains(searchStr)
                        .or
                        (post.content.contains(searchStr))
                        .or
                        (post.writer.contains(searchStr))
                );

        return PageableExecutionUtils.getPage(list, pageable, () -> count.fetch().size());
    }
}
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom {
}

서비스 레이어

@Setter
@Getter
public class SearchRequestDto {
    private Integer pg;
    private Integer sz;
    private String s;
}
@RequiredArgsConstructor
@Service
public class PostService {
    public List<PostDto> search(SearchRequestDto dto) {
        if (dto.getPg() == null || dto.getPg() < 0) {
            dto.setPg(0);
        }
        if (dto.getSz() == null || dto.getSz() < 1 || dto.getSz() > 50) {
            dto.setSz(50);
        }
        Page<Post> list = postRepository.search(dto.getPg(), dto.getSz(), dto.getS());
        return mapper.toDto(list.getContent());
    }
}

컨트롤러 레이어

@RequiredArgsConstructor
@RestController
public class PostController {
    @GetMapping("/api/v1/search")
    public List<PostDto> search(SearchRequestDto dto) {
        return service.search(dto);
    }
}

QueryEntity

또는 QueryEntity 를 이용해 보다 where 절을 간소화할 수 있습니다.

@QueryEntity
public class PostExpression {
    @QueryDelegate(Post.class)
    public static BooleanExpression matchSearchKeyword(QPost post,String searchStr) {
        if (searchStr == null || searchStr.isEmpty()) {
            return null;
        }
        return post.title.contains(searchStr).or(post.content.contains(searchStr)).or(post.writer.contains(searchStr));
    }
}
public class PostRepositoryImpl extends QuerydslRepositorySupport implements PostRepositoryCustom {
    @Override
    public Page<Post> search(Integer pageNo, Integer pageSize, String searchStr) {
        Pageable pageable = PageRequest.of(pageNo, pageSize);

        List<Post> list = jpaQueryFactory
                .selectFrom(post)
                .where(
                        post.matchSearchKeyword(searchStr)
                )
                .orderBy(post.idx.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Post> count = jpaQueryFactory
                .selectFrom(post)
                .where(
                        post.matchSearchKeyword(searchStr)
                );

        return PageableExecutionUtils.getPage(list, pageable, () -> count.fetch().size());
    }
}