Develop/Java & Spring

[Spring Boot] 게시판 프로젝트 #4 Service, Controller 구현

jjh0119 2025. 3. 25. 18:41

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을 사용했다. 물론 구조의 일관성을 위해 둘 중에 하나만 사용해도 괜찮다.