Service : Business Logic을 담당하는 부분. DB로부터 데이터를 받거나 전달해주는 역할을 한다
Controller : View로부터 오는 API 요청들을 어떻게 처리할 것인지 정의하는 역할을 한다
=> /board/list란 경로로 GET 요청이 왔을 때 어디로 보내고,
/board/post란 경로로 GET 요청이 올 때 어디로 보내고,
POST 요청이 오면 xxService로부터 Data를 받아와서view로 Attribute를 전달하는 등
@Controller : Handler가 Scan 할 수 있는 Bean 객체가 되어 Servlet용 컨테이너에 생성된다.
@Service, @Repository : 해당 Class를 루트 컨테이너에 Bean 객체로 생성
com.board.service.BoardService.java
package com.board.service;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import com.board.domain.Board;
import com.board.dto.BoardDto;
import com.board.repository.BoardRepository;
import com.board.util.Header;
import com.board.util.Pagination;
import com.board.util.Search;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
@Service
@AllArgsConstructor
public class BoardService {
//BoardRepository 객체 생성
private final BoardRepository boardRepository;
private static final Integer BLOCK_SIZE = 5;
@Transactional
public Header<List<BoardDto>> getBoardList(Integer pageNum, Integer boardSize) {
Page<Board> page = boardRepository.findAll(
PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id"))
);
List<BoardDto> boardDtoList = page.getContent()
.stream()
.map(BoardDto::fromEntity)
.toList();
Pagination pagination = createPagination(getBoardCount(), boardSize);
return Header.OK(boardDtoList, pagination);
}
@Transactional
public Header<BoardDto> getPost(Long id) {
return boardRepository.findById(id)
.map(BoardDto::fromEntity)
.map(Header::OK)
.orElseThrow(() -> new IllegalArgumentException("Board not found"));
}
@Transactional
public Header<String> savePost(BoardDto boardDto) {
boardRepository.save(boardDto.toEntity());
return Header.OK("게시글이 성공적으로 작성되었습니다.");
}
@Transactional
public Header<String> deletePost(Long id) {
boardRepository.deleteById(id);
return Header.OK("게시글이 성공적으로 삭제되었습니다.");
}
@Transactional
public Header<List<BoardDto>> searchPosts(Search search) {
String type = search.getType();
String keyword = search.getKeyword();
Integer pageNum = search.getPage();
Integer boardSize = search.getSize();
Page<Board> page;
long totalSearchCount;
// 검색 타입에 따라 적절한 검색 메서드 호출
switch (type) {
case "title":
page = boardRepository.findByTitleContaining(
keyword, PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id"))
);
totalSearchCount = boardRepository.countByTitleContaining(keyword);
break;
case "content":
page = boardRepository.findByContentContaining(
keyword, PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id"))
);
totalSearchCount = boardRepository.countByContentContaining(keyword);
break;
case "writer":
page = boardRepository.findByWriterContaining(
keyword, PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id"))
);
totalSearchCount = boardRepository.countByWriterContaining(keyword);
break;
default:
return Header.ERROR("Invalid search type");
}
List<BoardDto> boardDtoList = page.getContent()
.stream()
.map(BoardDto::fromEntity)
.toList();
Pagination pagination = createPagination((int) totalSearchCount, boardSize);
return Header.OK(boardDtoList, pagination);
}
@Transactional
public Integer getBoardCount() {
return (int) boardRepository.count();
}
private Pagination createPagination(int totalListCnt, int pageSize) {
return new Pagination(totalListCnt, pageSize);
}
}
@Transactional
public Header<List<BoardDto>> getBoardList(Integer pageNum, Integer boardSize) {
Page<Board> page = boardRepository.findAll(
PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id"))
);
List<BoardDto> boardDtoList = page.getContent()
.stream()
.map(BoardDto::fromEntity)
.toList();
Pagination pagination = createPagination(getBoardCount(), boardSize);
return Header.OK(boardDtoList, pagination);
}
Service 클래스는 가장 많은 수정이 이루어진 클래스다.
처음엔 여기에 페이징을 구현했었기 때문에 페이징에 관련한 많은 정보들을 이 클래스에서 계산하고 View로 넘겨주는 형태로 만들어졌었다.
하지만 통신으로는 최소한의 데이터만 주고받고 Client 쪽에서 계산하는 게 여러모로 효율적이라 판단해 현재의 모습이 되었다.
repository의 find() 관련 메소드를 호출할 때 Pageable 인터페이스를 구현한 Class(PageRequest)를 전달하면 Paging을 할 수 있다.
=> 첫 번째와 두 번째 인자로 page와 size를 전달하고 세 번째 인자로 정렬 방식을 결정한다.
page.getContent().stream()
반환된 Page 객체의 getContent() 메서드를 호출해 Entity를 List 형태로 꺼내어 올 수 있고 이것을 스트림으로 변환해 중간연산을 할 수 있도록 한다.
map(BoardDto::fromEntity)
map은 스트림의 각 요소에 함수를 적용하여 변환된 값을 새로운 스트림으로 반환하는 메소드이며 BoardDto::fromEntity는 메서드 레퍼런스를 활용한 표현으로 람다표현식을 간결하게 작성한 것이다.
클래스명(BoardDto)::메서드명(fromEntity)
= board -> BoardDto.fromEntity(board)
@Transactional
public Header<BoardDto> getPost(Long id) {
return boardRepository.findById(id)
.map(BoardDto::fromEntity)
.map(Header::OK)
.orElseThrow(() -> new IllegalArgumentException("Board not found"));
}
게시글 하나를 불러오는 로직도 기본적으로는 비슷하다.
하나의 게시글만 불러오기 때문에 stream을 사용하지는 않고 findById의 반환형인 Optional<Board>의 map 메서드를 활용해서 BoardDto 객체로 변환해 Header에 담아서 전송한다.
예외처리를 통해 Optional이 비어있다면 IllegalArgumentException을 던진다.
@Transactional
public Header<String> savePost(BoardDto boardDto) {
boardRepository.save(boardDto.toEntity());
return Header.OK("게시글이 성공적으로 작성되었습니다.");
}
@Transactional
public Header<String> deletePost(Long id) {
boardRepository.deleteById(id);
return Header.OK("게시글이 성공적으로 삭제되었습니다.");
}
각각 게시글 저장과 삭제 메서드로 boardRepository가 상속받은 JPA 메서드에 알맞은 인자를 넣어주기만 하면 된다.
그 후 Header에 메시지를 담아 전송한다.
@Transactional
public Header<List<BoardDto>> searchPosts(Search search) {
String type = search.getType();
String keyword = search.getKeyword();
Integer pageNum = search.getPage();
Integer boardSize = search.getSize();
Page<Board> page;
long totalSearchCount;
// 검색 타입에 따라 적절한 검색 메서드 호출
switch (type) {
case "title":
page = boardRepository.findByTitleContaining(
keyword, PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id"))
);
totalSearchCount = boardRepository.countByTitleContaining(keyword);
break;
case "content":
page = boardRepository.findByContentContaining(
keyword, PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id"))
);
totalSearchCount = boardRepository.countByContentContaining(keyword);
break;
case "writer":
page = boardRepository.findByWriterContaining(
keyword, PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id"))
);
totalSearchCount = boardRepository.countByWriterContaining(keyword);
break;
default:
return Header.ERROR("Invalid search type");
}
List<BoardDto> boardDtoList = page.getContent()
.stream()
.map(BoardDto::fromEntity)
.toList();
Pagination pagination = createPagination((int) totalSearchCount, boardSize);
return Header.OK(boardDtoList, pagination);
}
search 메서드는 View로부터 type, keyword와 함께 페이지네이션에 필요한 pageNum, boardSize를 매개변수로 전송받고 type에 따라 다른 메서드를 실행시켜 검색기능을 구현했다.
각각 boardRepository.findByTitleContaining, findByContentContaining, findByWriterContaining 메서드를 사용하여 조건에 맞는 게시글을 검색하고, PageRequest.of(pageNum - 1, boardSize, Sort.by(Sort.Direction.DESC, "id")) 사용해 페이징 처리를 했다.
Stream으로 변환하고 boardDto를 List로 만들어 보내는 건 getBoardList 메서드와 동일하다.
package com.board.controller;
import java.util.List;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import com.board.dto.BoardDto;
import com.board.service.BoardService;
import com.board.util.Header;
import com.board.util.Search;
@RestController
@AllArgsConstructor
@RequestMapping("/board") //board 경로로 들어오는 경우 아래의 Method들로 분기될 수 있도록 설정
public class BoardController {
private final BoardService boardService;
// 게시글 리스트 조회
@GetMapping({"", "/list"}))
public Header<List<BoardDto>> list(@RequestParam(value = "page", defaultValue = "1") Integer pageNum, @RequestParam(value = "size", defaultValue = "10") Integer size) {
return boardService.getBoardList(pageNum, size);
}
// 게시글 조회
@GetMapping("/post/{no}")
public Header<BoardDto> detail(@PathVariable("no") Long no) {
return boardService.getPost(no);
}
// 게시글 작성
@PostMapping("/post")
public Header<String> write(@RequestBody BoardDto boardDto) {
return boardService.savePost(boardDto);
}
// 게시글 수정
@PutMapping("/post/edit/{no}")
public Header<String> update(@PathVariable("no") Long no, @RequestBody BoardDto boardDto) {
boardDto.setId(no);
return boardService.savePost(boardDto);
}
// 게시글 삭제
@DeleteMapping("/post/{no}")
public Header<String> delete(@PathVariable("no") Long no) {
return boardService.deletePost(no);
}
// 게시글 검색
@GetMapping("/search")
public Header<List<BoardDto>> search(Search search) {
return boardService.searchPosts(search);
}
}
@RestController
@RequestMapping("/board") //board 경로로 들어오는 경우 아래의 Method들로 분기될 수 있도록 설정
후에 Board 외에도 확장할 계획이기 때문에 우선은 @RequestMapping("/board")을 통해 board 경로로 들어오는 요청을 하기의 메서드들로 분기될 수 있도록 설정
@RestController는 일반적인 @Controller에 @ResponseBody가 추가된 것으로, JSON 형식으로 객체 데이터를 반환하기 위해 사용된다. React로 View를 만들 예정이므로 REST API를 개발할 때 주로 사용하는 @RestController를 선택했다.
@GetMapping({"", "/list"}))
'/board', '/board/list' 경로로 들어오는 경우 모두 Mapping하기 위함
public Header<List<BoardDto>> list(@RequestParam(value = "page", defaultValue = "1") Integer pageNum, @RequestParam(value = "size", defaultValue = "10") Integer size)
2개의 요청 파라미터를 받고 있는데 페이지네이션에 필요한 'pageNum'과 'size'로 요청 파라미터가 있을 경우(?page=1&size=10) 그에 따른 페이지네이션을 수행하고 따로 없더라도 기본 값 1과 10으로 동작될 수 있도록 설정
@GetMapping("/post/{no}")
public Header<BoardDto> detail(@PathVariable("no") Long no)
간단한 변수 전달은 @PathVariable을 사용했다. 물론 구조의 일관성을 위해 둘 중에 하나만 사용해도 괜찮다.
'Develop > Java & Spring' 카테고리의 다른 글
[Spring Boot] 게시판 프로젝트 외전 #1 JUnit과 Mockito를 이용한 테스트 - 실전 (0) | 2025.03.25 |
---|---|
[Spring Boot] 게시판 프로젝트 외전 #1 JUnit과 Mockito를 이용한 테스트 - 이론 (0) | 2025.03.25 |
[Spring Boot] 게시판 프로젝트 #3 DTO, Repository 구현 (0) | 2025.03.25 |
[Spring Boot] 게시판 프로젝트 #2 Domain(Entity), Util 구현 (0) | 2025.03.25 |
[Spring Boot] 게시판 프로젝트 #1 세팅 (0) | 2025.03.25 |