목차
- 게시글 기본 기능 구현
- 게시글 검색(조건) 기능 구현
- 파일 기능 구현
- 서버 실행 시, 초기 데이터 자동 삽입
게시글 기본 기능 구현
게시글 등록
게시글을 등록하는 부분은 회원가입을 하는 부분과 크게 다르지 않다. PostRequestDto로 받은 데이터를 통해 게시판과 해당 글을 작성한 회원을 조회하고, 게시글 데이터로 Post 객체를 생성한 후 관계를 매핑해주면 된다. 하지만 크게 다른 점은 게시글을 등록할 때는 회원이 로그인 상태여야 한다는 점이다. 이는 jwt와 시큐리티를 이용하여 토큰 인증 방식을 사용하고 있기 때문에 로그인이 정상적으로 되어 있지 않으면 게시글을 작성할 수 없다. 로그인 후 받은 인증 토큰을 헤더에 함께 넣어 등록 요청을 해야 한다.
- 게시판은 스포츠 종목 별로 나뉘어져 있다.
- 현재 게시판은 종목을 정해두고 한 종목 당 하나의 게시판이 존재하도록 설계하였다.
- 종목 별로 기본 게시판만 존재한다.
- 한 게시판에는 여러 게시글이 존재하고, 한 멤버는 여러 게시글을 작성할 수 있기 때문에 모두
1:N관계이고 N이 게시글이다.
@Transactional
public Long resister(PostRequestDto postRequest) {
// 엔티티 조회
Member member = memberRepository.findById(postRequest.getMemberId())
.orElseThrow(() -> new BaseException(MEMBER_NOT_EXIST));
Board board = boardRepository.findById(postRequest.getBoardId())
.orElseThrow(() -> new BaseException(BOARD_NOT_EXIST));
// 게시글 생성
Post post = Post.createPostBuilder()
.title(postRequest.getTitle())
.content(postRequest.getContent())
.member(member)
.board(board)
.build();
postRepository.save(post);
return post.getId();
}
게시글 수정 및 삭제
게시판 등록과는 다르게 게시글 수정과 삭제는 현재 로그인 되어 있는 회원과 수정 및 삭제하려는 게시글을 작성한 회원이 같아야 한다.
@Transactional
public Long update(Long postId, PostRequestDto postRequest) {
Post findPost = postRepository.findById(postId)
.orElseThrow(() -> new BaseException(POST_NOT_EXIST));
SecurityUtil.checkAuthorizedMember(findPost.getMember().getMemberId());
findPost.update(postRequest.getTitle(), postRequest.getContent());
return findPost.getId();
}
@Transactional
public void delete(Long postId) {
Post findPost = postRepository.findById(postId)
.orElseThrow(() -> new BaseException(POST_NOT_EXIST));
SecurityUtil.checkAuthorizedMember(findPost.getMember().getMemberId());
postRepository.deleteById(postId);
}
- SecurityUtil.checkAuthorizedMember()를 통해서 현재 로그인되어 있는 회원과 게시글 작성한 회원이 같은지 검사할 수 있다.
SecurityUtil
public class SecurityUtil {
public static Optional<String> getCurrentMemberId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
log.debug("Security Context에 인증 정보가 없습니다.");
return Optional.empty();
}
String memberId = null;
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
memberId = springSecurityUser.getUsername();
} else if (authentication.getPrincipal() instanceof String) {
memberId = (String) authentication.getPrincipal();
}
return Optional.ofNullable(memberId);
}
public static void checkAuthorizedMember(String memberId) {
String loginId = SecurityUtil.getCurrentMemberId().orElseThrow(() -> new BaseException(NOT_LOGIN));
if(!memberId.equals(loginId)) throw new BaseException(NOT_AUTHORIZED);
}
}
- JwtFilter에서
SecurityContext에 세팅한 유저 정보를 이용한다.
게시글 검색(조건) 기능 구현
JPA에서 검색을 할 경우findById()와 같이 findBy 뒤에 엔티티의 필드 이름을 입력하면 JPQL로 적절하게 변환하여 검색 쿼리를 실행한 결과를 전달 해준다. 하지만 검색 조건이 많이 지면 쿼리 메소드가 길어지면서 가독성도 떨어지고 비효율적이다. 그래서 JPA에서는Specification을 제공한다. Specification을 이용해서 원하는 조건을 상황에 맞게 추가할 수 있기 때문에 이를 이용해서 검색 기능을 구현하고자 한다.
JPA Specification
- JPA Specification은 criteria API를 기반으로 만들어졌고, JPA Criteria는 동적 쿼리를 사용하기 위한 JPA 라이브러리이다.
- JPQL과 같이 엔티티 조회를 기본으로 하지만, JPQL은 문자열을 사용하여 쿼리를 정의하고 Criteria는 자바 객체 인스턴스로 정의한다.
- 검색 조건을 추상화하기 위해 사용하며,
Repository에서JpaSpecificationExecutor를 상속 받아 사용한다.JpaSpecificationExecutor인터페이스 내부 메소드를 살펴보면 매개 변수로Specification객체를 넘겨준다.List<T> findAll(@Nullable Specification<T> spec);Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable);
Specification명세를 정의하고 조건 쿼리를 생성하기 위해서는Specification인터페이스의toPredicate()메소드를 구현해야 한다.Predicate toPredicate(Root<T> root, CriteriaQuery query, CriteriaBuilder cb);- 메서드 안에서
root,query, criteriaBuilder를 매개변수로 받고,Predicate객체를 반환하는 함수를 작성한다.
PostSpecification
@Slf4j
public class PostSpecification {
public static Specification<Post> searchPost(Map<String, String> searchKey){
return ((root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
for(String key : searchKey.keySet()){
if(key.equals("memberId")) {
predicates.add(criteriaBuilder.like(root.get("member").get(key), "%" + searchKey.get(key) + "%"));
}
else predicates.add(criteriaBuilder.like(root.get(key), "%" + searchKey.get(key) + "%"));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
});
}
}
- 다중 필터링을 위해서 검색 조건들을 Map 형태로 저장해서 넘겨주었다.
- searchKey의 key는 엔티티의 필드 명을, value는 필드의 값을 넣어준다.
- 이 검색 조건들로 Predicate 객체를 생성하여 반환한다.
criteriaBuilder.like(root.get(key), "%" + searchKey.get(key) + "%")- Post 엔티티의 필드(root.get(key)) 값에서 searchKey의 value가 포함되어 있는지 비교하는 쿼리가 추가되고 Like와 같은 기능을 함
PostSearchRequestDto
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostSearchRequestDto {
private String title;
private String content;
private String memberId;
}
- 게시글은 제목과 내용, 회원 ID로 검색이 가능하다.
PostService
@Repository
public interface PostRepository extends JpaRepository<Post, Long>, JpaSpecificationExecutor<Post> {
}
public List<PostDto> search(PostSearchRequestDto requestDto) {
if(requestDto == null) {
return postRepository.findAll().stream().map(p -> PostDto.of(p)).collect(Collectors.toList());
}
Map<String, String> searchKeys = new HashMap<>();
if (requestDto.getMemberId() != null) searchKeys.put("memberId", requestDto.getMemberId());
if (requestDto.getTitle() != null) searchKeys.put("title", requestDto.getTitle());
if (requestDto.getContent() != null) searchKeys.put("content", requestDto.getContent());
return postRepository.findAll(PostSpecification.searchPost(searchKeys))
.stream().map(p -> PostDto.of(p))
.collect(Collectors.toList());
}
- 검색 조건을 Map으로 저장하여 넘기고, JpaSpecificationExecutor의 findAll()함수에 만들어둔 PostSpecification 명세를 넣어 검색 기능을 구현하였다.
- 이때 엔티티를 그대로 응답하지 않고 Dto로 변경하여 반환한다.
PostDto
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class PostDto {
private Long id;
private Long boardId;
private String boardNm;
private String memberId;
private String nickname;
private String title;
private String content;
private Long viewCnt;
private LocalDateTime createDate;
private LocalDateTime updateDate;
public static PostDto of(Post post) {
return PostDto.createPostDtoBuilder()
.id(post.getId())
.boardId(post.getBoard().getId())
.boardNm(post.getBoard().getBoardNm())
.memberId(post.getMember().getMemberId())
.nickname(post.getMember().getNickname())
.title(post.getTitle())
.content(post.getContent())
.viewCnt(post.getViewCnt())
.createDate(post.getCreateDate())
.updateDate(post.getUpdateDate())
.build();
}
@Builder(builderMethodName = "createPostDtoBuilder")
public PostDto(Long id, Long boardId, String boardNm, String memberId, String nickname, String title,
String content, Long viewCnt, LocalDateTime createDate, LocalDateTime updateDate) {
this.id = id;
this.boardId = boardId;
this.boardNm = boardNm;
this.memberId = memberId;
this.nickname = nickname;
this.title = title;
this.content = content;
this.viewCnt = viewCnt;
this.createDate = createDate;
this.updateDate = updateDate;
}
}
- Builder를 이용하여 Post 엔티티를 Dto로 변경한다.
게시글 검색 시, API 응답 결과
{
"status": "SUCCESS",
"data": {
"post_list": [
{
"id": 100,
"board_id": 1,
"board_nm": "축구 게시판",
"member_id": "test1",
"nickname": "testname1",
"title": "post 1",
"content": "post111",
"view_cnt": 0,
"create_date": "2023-01-01T01:01:01",
"update_date": "2023-01-01T01:01:01"
},
{
"id": 102,
"board_id": 1,
"board_nm": "축구 게시판",
"member_id": "test1",
"nickname": "testname1",
"title": "post 2",
"content": "post333",
"view_cnt": 0,
"create_date": "2023-01-01T01:01:01",
"update_date": "2023-01-01T01:01:01"
},
{
"id": 101,
"board_id": 1,
"board_nm": "축구 게시판",
"member_id": "test2",
"nickname": "testname2",
"title": "post 2",
"content": "post222",
"view_cnt": 0,
"create_date": "2023-01-01T01:01:01",
"update_date": "2023-01-01T01:01:01"
}
]
},
"message": "게시글 전체 조회 성공"
}
- 조건에 맞게 조회가 잘 되는 것을 확인할 수 있다!
파일 기능 구현
한 게시글에는 여러 개의 파일을 등록할 수 있기 때문에 1:N 관계이고, N이 파일이다.
파일 등록
PostApiController
@RestController
@RequiredArgsConstructor
@RequestMapping("/post")
public class PostApiController {
private final PostService postService;
private final FileService fileService;
/**
* 게시글 등록
*/
@PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<?> register(@RequestPart(value = "post") @Valid PostRequestDto post, @RequestPart(value = "files", required = false) List<MultipartFile> files) throws IOException {
Long postId = postService.resister(post);
if(files != null) {
fileService.saveFiles(postId, files);
}
return ResponseUtils.success("게시글 등록 성공");
}
}
consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}- 파일 등록 api 요청 시, application/json과 multipart/form-data 모두 받아야 하기 때문에 설정해준다.
- 먼저 PostService에서 게시글을 등록한 뒤, 해당 게시글의 id를 가져와서 파일을 등록한다.
FileService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class FileService {
private final PostRepository postRepository;
private final FileRepository fileRepository;
private final FileStore fileStore;
@Transactional
public void saveFiles(Long postId, List<MultipartFile> requestFiles) throws IOException {
Post post = postRepository.findById(postId).orElseThrow(() -> new BaseException(POST_NOT_EXIST));
List<UploadFile> uploadFiles = fileStore.storeFiles(requestFiles);
List<File> fileList = new ArrayList<>();
for (UploadFile uploadFile : uploadFiles) {
File file = File.createFileBuilder()
.post(post)
.uploadFileName(uploadFile.getUploadFileName())
.storeFileName(uploadFile.getStoreFileName())
.fileSize(uploadFile.getFileSize())
.build();
fileList.add(file);
}
fileRepository.saveAll(fileList);
}
}
- MulitpartFile들을 먼저 서버에 저장(
FileStore)하고, 업로드 된 파일들을 가져와 파일 레포지토리에 저장한다.
FileStore
@Component
public class FileStore {
@Value("${file.dir}")
private String fileDir;
public String getFullPath(String filename) {
return fileDir + filename;
}
public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
List<UploadFile> storeFileResult = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if(!multipartFile.isEmpty()) {
storeFileResult.add(storeFile(multipartFile));
}
}
return storeFileResult;
}
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
if(multipartFile.isEmpty()) {
return null;
}
String originalFilename = multipartFile.getOriginalFilename(); // image.png
String storeFileName = createStoreFileName(originalFilename); // 서버에 저장하는 파일명
long fileSize = multipartFile.getSize(); // 파일 크기
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFilename, storeFileName, fileSize);
}
private String createStoreFileName(String originalFilename) {
String uuid = UUID.randomUUID().toString();
String ext = extractExt(originalFilename); // 파일 확장자
return uuid + "." + ext;
}
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
}
- 서버 내부에 파일을 저장할 때는 클라이언트가 요청한 파일명 그대로 업로드 하게 되면 기존 파일 이름과 같을 경우 충돌이 발생할 수 있게 된다. 따라서 겹치지 않도록 서버에 저장할 별도의 파일명을 따로 두는 것이 좋다.
createStoreFileName(): 서버 내부에서 관리하기 위한 파일명을 만드는 메서드로, UUID를 이용해 충돌을 방지한다.extractExt(): 파일의 확장자를 별도로 추출한다.- 서버 내부에서 관리하는 파일명에 붙여서 저장하기 위해서
storeFile(): MulitpartFile을 fileDir(파일을 저장할 경로)에 storeFileName(서버에 저장할 파일 이름)으로 파일을 저장한다.multipartFile.transferTo(new File(getFullPath(storeFileName)))- 지정 경로로 서버에서 관리하는 새 파일 명으로 파일을 저장한다.
파일 수정 및 삭제
PostApiController
@RestController
@RequiredArgsConstructor
@RequestMapping("/post")
public class PostApiController {
private final PostService postService;
private final FileService fileService;
/**
* 게시글 수정
*/
@PatchMapping(value = "/{postId}", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
@ResponseStatus(HttpStatus.OK)
public ApiResponse<?> update(@PathVariable Long postId, @RequestPart @Valid PostRequestDto post,
@RequestPart(required = false) List<MultipartFile> files) throws IOException {
postService.update(postId, post);
if(files != null) {
fileService.update(postId, files);
}
return ResponseUtils.success("게시글 수정 성공");
}
/**
* 게시글 삭제
*/
@DeleteMapping("/{postId}")
@ResponseStatus(HttpStatus.OK)
public ApiResponse<?> delete(@PathVariable Long postId) {
fileService.delete(postId);
postService.delete(postId);
return ResponseUtils.success("게시글 삭제 성공");
}
}
- 파일 수정과 삭제는 파일 등록과는 다르게 로그인된 회원과 게시글을 작성한 회원이 같아야만 수정 및 삭제가 가능하다.
- 이는 게시글 수정과 삭제와 비슷하게 구현하였다.
서버 실행 시, 초기 데이터 자동 삽입
application.properties
# spring boot 2.5.x 이상 초기 데이터 설정
spring.sql.init.mode=always
spring.sql.init.data-locations=classpath:data.sql
# script 파일이 hibernate 초기화 이후 동작하게 하기 위한 옵션
spring.jpa.defer-datasource-initialization=true
- 매번 애플리케이션을 실행할 때마다 데이터를 직접 넣는 불편함이 있었다. 그래서 실행될 때마다 초기 데이터를 자동 삽입 해주는 기능이 있으면 편리할 것 같아 적용하였다.
- data.sql에 SQL 쿼리를 작성하고, 해당 파일 위치를 설정해주면 된다.
- 이때 JPA에서 테이블을 모두 생성한 후에 sql 파일이 실행되어야 하기 때문에 옵션 설정도 함께 해주어야 한다.
data.sql
INSERT INTO board (board_cd_pk, board_nm, sports) VALUES (1, '축구 게시판', '축구');
INSERT INTO board (board_cd_pk, board_nm, sports) VALUES (2, '배구 게시판', '배구');
INSERT INTO board (board_cd_pk, board_nm, sports) VALUES (3, '농구 게시판', '농구');
INSERT INTO board (board_cd_pk, board_nm, sports) VALUES (4, '배드민턴 게시판', '배드민턴');
회고
지금은 서버에 파일을 직접 저장하는 방식으로 사용하였는데, 다음에는 S3를 이용하여 파일을 저장해볼 예정이다. 그리고 지금 코드에서는 검색을 Jpa Specification을 사용하고 있지만, 이것도 조건이 더 복잡해지면 사용하기 어려울 것 같다는 생각이 들었다. 그래서 나중에는 Querydsl을 이용하는 방식으로 바꿔보려고 한다.
'Project > PE-Community' 카테고리의 다른 글
[PE-Community] 계층형 댓글 구현 (0) | 2023.11.09 |
---|---|
[PE-Community] Spring Security와 JWT 적용 (1) | 2023.10.16 |
[PE-Community] 회원가입 및 로그인 구현 (0) | 2023.09.07 |
[PE-Community] 기능 정의 및 DB 설계 (0) | 2023.08.22 |
[PE-Community] 토이 프로젝트 시작하기(주제 선정) (1) | 2023.08.21 |