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());
}
}
https://velog.io/@songunnie/Project-Spring-Security-JWT-Refresh-Token-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0
https://sjh9708.tistory.com/170
jwt
https://velog.io/@limsubin/Spring-Security-JWT-%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EC%9E%90
jwt