Spring boot

2024.10.15 Blog 프로젝트 만들기(JPA) Service 레이어 만들기

정훈5 2024. 10. 15. 10:03

 

학습 목표 

1. Service 레이어의 개념과 필요성을 알고 있다. 
2. 트랜잭션 관리 이해 및 코드에 적용해 보기 
3. Controller에서 Service 사용으로 코드 리팩토링 해보기

 

Service 레이어는 애플리케이션의 비즈니스 로직을 담당하는 계층입니다.

Controller는 클라이언트의 요청을 받고 응답을 반환하는 역할을 하며, Repository는 데이터베이스와의 상호작용을 담당합니다. 이 두 계층 사이에 Service 레이어를 도입함으로써 여러 이점을 얻을 수 있습니다.

 

userDTO.java

toEntity에 role 추가

더보기
package com.tenco.blog_v1.user;

import lombok.Data;

@Data
public class UserDTO {

    // 정적 내부 클래스로 모으자
    @Data
    public static class LoginDTO {
        private String username;
        private String password;
    }

    // 정적 내부 클래스로 모으자
    @Data
    public static class JoinDTO {
        private String username;
        private String password;
        private String email;

        public User toEntity() {
            return User.builder()
                    .username(this.username)
                    .password(this.password)
                    .role("USER")
                    .email(this.email)
                    .build();
        }
    }

    @Data
    public static class UpdateDTO {
        private String password;
        private String email;
    }
}

 

UserService 생성하기

더보기
package com.tenco.blog_v1.user;

import com.tenco.blog_v1.common.errors.Exception400;
import com.tenco.blog_v1.common.errors.Exception401;
import com.tenco.blog_v1.common.errors.Exception404;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Optional;

@RequiredArgsConstructor
@Service
public class UserService {

    // @Autowired
    private final UserJPARepository userJPARepository;

    /**
     * 회원 가입 서비스
     */
    @Transactional
    public void signUp(UserDTO.JoinDTO reqDto ) {
        // 1. username <-- 유니크 확인
        Optional<User> userOp = userJPARepository.findByUsername(reqDto.getUsername());
        if(userOp.isPresent()) {
            throw new Exception400("중복된 유저네임입니다");
        }
        // 회원 가입
        userJPARepository.save(reqDto.toEntity());
    }

    /**
     *  로그인 서비스
     *
     */
    public User signIn(UserDTO.LoginDTO reqDTO) {
        User seessionUser = userJPARepository
                .findByUsernameAndPassword(reqDTO.getUsername(), reqDTO.getPassword())
                .orElseThrow( () -> new Exception401("인증되지 않았습니다"));
        return seessionUser;
    }

    /**
     * 회원 정보 조회 서비스
     *
     * @param id 조회할 사용자 ID
     * @return 조회된 사용자 객체
     * @throws Exception404 사용자를 찾을 수 없는 경우 발생
     */
    public User readUser(int id){
        User user = userJPARepository.findById(id)
                .orElseThrow(() -> new Exception404("회원정보를 찾을 수 없습니다"));
        return user;
    }
    /**
     * 회원 정보 수정 서비스
     *
     * @param id 수정할 사용자 ID
     * @param reqDTO 수정된 사용자 정보 DTO
     * @return 수정된 사용자 객체
     * @throws Exception404 사용자를 찾을 수 없는 경우 발생
     */
    @Transactional // 트랜잭션 관리
    public User updateUser(int id, UserDTO.UpdateDTO reqDTO){
        // 1. 사용자 조회 및 예외 처리
        User user = userJPARepository.findById(id)
                .orElseThrow(() -> new Exception404("회원정보를 찾을 수 없습니다"));

        // 2. 사용자 정보 수정
        user.setPassword(reqDTO.getPassword());
        user.setEmail(reqDTO.getEmail());

        // 더티 체킹을 통해 변경 사항이 자동으로 반영됩니다.
        return user;
    }
}

 

Board.java 코드 추가

@Transient

boolean isBoardOwner

 

@Lob // 대용량 데이터 저장 가능

private string content

더보기
package com.tenco.blog_v1.board;

import com.tenco.blog_v1.user.User;
import jakarta.persistence.*;
import lombok.*;

import java.sql.Timestamp;

@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "board_tb")
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 기본 키 전략, DB 위임
    // 데이터베이스가 기본 키 값을 직접 관리하도록 위임
    private Integer id;
    private String title;

    @Lob // 대용량 데이터 저장 가능
    private String content;

    @ManyToOne(fetch = FetchType.LAZY) // EAGER 즉시 전략
    @JoinColumn(name = "user_id")
    private User user; // 게시글 작성자 정보

    // created_at 컬럼과 매핑하여, 이 필드는 데이터 저장시 자동으로 설정 됨
    @Column(name = "created_at", insertable = false, updatable = false)
    private Timestamp createdAt;

    // 코드 추가
    @Transient // 해당 테이블에 컬럼을 만들지 마 (테이블에 만들지 않음)
            // 즉, JPA 메모리 상에서만 활용 가능한 필드 메서드 이다.
    boolean isBoardOwner;

    @Builder
    public Board(Integer id, String title, String content, User user, Timestamp createdAt) {
        this.id = id;
        this.title = title;
        this.content = content;
        this.user = user;
        this.createdAt = createdAt;
    }

}

 

 

 

BoardService.java

더보기
package com.tenco.blog_v1.board;

import com.tenco.blog_v1.common.errors.Exception403;
import com.tenco.blog_v1.common.errors.Exception404;
import com.tenco.blog_v1.user.User;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class BoardService {

    private final BoardRepository boardRepository;
    private final BoardJPARepository boardJPARepository;

    /**
     * 게시글 ID로 조회 서비스
     */
    public Board getBoard(int boardId) {
        return boardJPARepository.findById(boardId).orElseThrow(() -> new Exception404("게시글을 찾을 수 없어요"));

    }

    /**
     * 게시글 상세보기 서비스, 게시글 주인 여부 판별
     */
    public Board getBoardDetails(int boardId, User sessionUser) {
        Board board = boardJPARepository
                .findById(boardId)
                .orElseThrow(()-> new Exception404("게시글을 찾을 수 없어요"));
        boolean isBoardOwner = false;
        if(sessionUser != null) {
            if(sessionUser.getId().equals(board.getUser().getId())) {
                isBoardOwner = true;
            }
        }

        board.setBoardOwner(isBoardOwner);
        return board;

    }

    /**
     * 게시글 삭제 서비스
     */
    public void deleteBoard(int boardId, int sessionUserId) {
        // 1.
        Board board = boardJPARepository.findById(boardId).orElseThrow(()-> new Exception404("게시글을 찾을 수 없습니다."));

        // 2. 권한 처리 - 현재 사용자가 게시글 주인이 맞는가?
        if(sessionUserId != board.getUser().getId()) {
            throw new Exception403(("게시글을 삭제할 권한이 없습니다."));
        }
        boardJPARepository.deleteById(boardId);
    }

    /**
     * 게시글 수정 서비스
     */
    @Transactional
    public void updateBoard(int boardId, int sessionUserId, BoardDTO.UpdateDTO reqDTO) {
        // 1. 게시글 존재 여부 확인

        Board board = boardJPARepository.findById(boardId).orElseThrow(()-> new Exception404("게시글을 찾을 수 없습니다."));
        
        // 2. 권한 확인
        if(sessionUserId != board.getUser().getId()) {
            throw new Exception403("게시글 수정 권한이 없습니다.");
        }

        // 3. 게시글 수정
        board.setTitle(reqDTO.getTitle());
        board.setContent(reqDTO.getContent());
        
        // 더티 체킹 처리
        
    }

    /**
     * 모든 게시글 조회 서비스
     */
    public List<Board> getAllBoards() {
        // 게시글을 ID 기준으로 내림차순으로 정렬해서 조회 해라.
        Sort sort = Sort.by(Sort.Direction.DESC, "id");
       return boardJPARepository.findAll(sort);

    }
}

UserController.java 수정

더보기
package com.tenco.blog_v1.user;

import com.tenco.blog_v1.common.errors.Exception401;
import com.tenco.blog_v1.common.errors.Exception500;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Slf4j
@Controller
@RequiredArgsConstructor
public class UserController {
    
    // DI 처리
   // private final UserRepository userRepository;
    private final UserService userService;
    private final HttpSession session;


    /**
     * 회원 정보 페이지 요청
     * 주소 설계 : http://localhost:8080/user/update-form
     *
     * @param
     * @return 문자열
     * 반환되는 문자열을 뷰 리졸버가 처리하면
     * 머스태치 템플릿 엔진을 통해서 뷰 파일을 렌더링 합니다.
     */
    @GetMapping("/user/update-form")
    public String updateForm(HttpServletRequest request) {

        log.info("회원 수정 페이지");
        // model.addAttribute("name", "회원 수정 페이지");

        User sessionUser = (User)session.getAttribute("sessionUser");
        if(sessionUser == null) {
            return "redirect:/login-form";
        }
        User user = userService.readUser(sessionUser.getId());
        request.setAttribute("user", user);

        return "user/update-form"; // 템플릿 경로: user/update-form/mustache
    }


    /**
     * 사용자 정보 수정
     * @param reqDTO
     * @return 메인 페이지
     */
    @PostMapping("/user/update")
    public String update(@ModelAttribute(name = "updateDTO") UserDTO.UpdateDTO reqDTO) {
        User sessionUser = (User) session.getAttribute("sessionUser");
        if (sessionUser == null) {
            return "redirect:/login-form";
        }
        // 유효성 검사는 생략
        // 사용자 정보 수정
        User updatedUser = userService.updateUser(sessionUser.getId(), reqDTO);

        // 세션 정보 동기화 처리
        session.setAttribute("sessionUser", updatedUser);
        return "redirect:/";
    }

    /**
     * 회원가입 페이지 요청
     * 주소 설계 : http://localhost:8080/join-form
     *
     * @param model
     * @return 문자열
     * 반환되는 문자열을 뷰 리졸버가 처리하면
     * 머스태치 템플릿 엔진을 통해서 뷰 파일을 렌더링 합니다.
     */
    @GetMapping("/join-form")
    public String joinForm(Model model) {

        log.info("회원가입 페이지");
        model.addAttribute("name", "회원가입 페이지");
        return "user/join-form"; // 템플릿 경로: user/join-form/mustache
    }

    /**
     * 회원 가입 기능 요청
     * @param reqDto
     * @return
     */
    @PostMapping("/join")
    public String join(@ModelAttribute(name = "joinDTO") UserDTO.JoinDTO reqDto) {

        try {
            userService.signUp(reqDto);
        } catch(DataIntegrityViolationException e) {
            // DataIntegrityViolationException :제약 조건 위반
            throw new Exception500("동일한 유저네임이 존재 합니다.");
        }
        return "redirect:/login-form";
    }

    /**
     * 로그인 페이지 요청
     * 주소 설계 : http://localhost:8080/login-form
     *
     * @param model
     * @return 문자열
     * 반환되는 문자열을 뷰 리졸버가 처리하면
     * 머스태치 템플릿 엔진을 통해서 뷰 파일을 렌더링 합니다.
     */
    @GetMapping("/login-form")
    public String loginForm(Model model) {

        log.info("로그인 페이지");
        model.addAttribute("name", "로그인 페이지");
        return "user/login-form"; // 템플릿 경로: user/login-form/mustache
    }


    /**
     * 자원에 요청은 GET 방식이지만 보안의 이유로 예외!
     * 로그인 처리 메서드
     * 요청 주소 : POST  http://localhost:8080/login
     * @param reqDTO
     * @return
     */
    @PostMapping("/login")
    public String login(UserDTO.LoginDTO reqDTO) {
        try {
            User sessionUser = userService.signIn(reqDTO);
            session.setAttribute("sessionUser", sessionUser);
            return "redirect:/";
        } catch (Exception e) {
            // 쿼리 스트링으로 error 이 들어오면 따로 처리하는 방식도 있다.
            // 로그인 실패
            throw new Exception401("유저이름 또는 비밀번호가 틀렸습니다.");
        }
    }

    /**
     * 로그아웃
     * 주소 : http://localhost:8080/logout
     * @return
     */
    @GetMapping("/logout")
    public String logout() {
        session.invalidate(); // 세션을 무효화 한다. (로그아웃)
        return "redirect:/";
    }

}

BoardService.java

더보기
package com.tenco.blog_v1.board;

import com.tenco.blog_v1.common.errors.Exception403;
import com.tenco.blog_v1.common.errors.Exception404;
import com.tenco.blog_v1.user.User;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class BoardService {

    private final BoardRepository boardRepository;
    private final BoardJPARepository boardJPARepository;

    /** 추가해 주세요
     * 새로운 게시글을 작성하여 저장합니다.
     *
     * @param reqDTO 게시글 작성 요청 DTO
     * @param sessionUser 현재 세션에 로그인한 사용자
     */
    @Transactional // 트랜잭션 관리: 데이터베이스 연산이 성공적으로 완료되면 커밋, 실패하면 롤백
    public void createBoard(BoardDTO.SaveDTO reqDTO, User sessionUser){
        // 요청 DTO를 엔티티로 변환하여 저장합니다.
        boardJPARepository.save(reqDTO.toEntity(sessionUser));
    }


    /**
     * 게시글 ID로 조회 서비스
     */
    public Board getBoard(int boardId) {
        return boardJPARepository.findById(boardId).orElseThrow(() -> new Exception404("게시글을 찾을 수 없어요"));

    }

    /**
     * 게시글 상세보기 서비스, 게시글 주인 여부 판별
     */
    public Board getBoardDetails(int boardId, User sessionUser) {
        Board board = boardJPARepository
                .findById(boardId)
                .orElseThrow(()-> new Exception404("게시글을 찾을 수 없어요"));
        boolean isBoardOwner = false;
        if(sessionUser != null) {
            if(sessionUser.getId().equals(board.getUser().getId())) {
                isBoardOwner = true;
            }
        }

        board.setBoardOwner(isBoardOwner);
        return board;

    }

    /**
     * 게시글 삭제 서비스
     */
    public void deleteBoard(int boardId, int sessionUserId) {
        // 1.
        Board board = boardJPARepository.findById(boardId).orElseThrow(()-> new Exception404("게시글을 찾을 수 없습니다."));

        // 2. 권한 처리 - 현재 사용자가 게시글 주인이 맞는가?
        if(sessionUserId != board.getUser().getId()) {
            throw new Exception403(("게시글을 삭제할 권한이 없습니다."));
        }
        boardJPARepository.deleteById(boardId);
    }

    /**
     * 게시글 수정 서비스
     */
    @Transactional
    public void updateBoard(int boardId, int sessionUserId, BoardDTO.UpdateDTO reqDTO) {
        // 1. 게시글 존재 여부 확인

        Board board = boardJPARepository.findById(boardId).orElseThrow(()-> new Exception404("게시글을 찾을 수 없습니다."));
        
        // 2. 권한 확인
        if(sessionUserId != board.getUser().getId()) {
            throw new Exception403("게시글 수정 권한이 없습니다.");
        }

        // 3. 게시글 수정
        board.setTitle(reqDTO.getTitle());
        board.setContent(reqDTO.getContent());
        
        // 더티 체킹 처리
        
    }

    /**
     * 모든 게시글 조회 서비스
     */
    public List<Board> getAllBoards() {
        // 게시글을 ID 기준으로 내림차순으로 정렬해서 조회 해라.
        Sort sort = Sort.by(Sort.Direction.DESC, "id");
       return boardJPARepository.findAll(sort);

    }
}

 

BoardController.java

더보기
package com.tenco.blog_v1.board;

import com.tenco.blog_v1.common.errors.Exception404;
import com.tenco.blog_v1.user.User;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;

import java.util.List;
import java.util.Objects;

@Slf4j
@RequiredArgsConstructor
@Controller
public class BoardController {

    // 네이티브 쿼리 연습
 //   private final BoardNativeRepository boardNativeRepository;
    // JPA API , JPQL
 //   private final BoardRepository boardRepository;
    private final BoardService boardService;
    private final HttpSession session;


    // 게시글 수정 화면 요청
    // board/id/update
    @GetMapping("/board/{id}/update-form")
    public String updateForm(@PathVariable(name = "id") Integer id, HttpServletRequest request) {

        // 세션에서 로그인한 사용자 정보 가져오기
        User sessionUser = (User) session.getAttribute("sessionUser");
        if (sessionUser == null) {
            return "redirect:/login-form"; // 로그인하지 않은 경우 로그인 페이지로 리다이렉트
        }

        // 게시글 상세 조회 서비스 호출
        Board board = boardService.getBoardDetails(id, sessionUser);
        if (board == null) {
            throw new Exception404("게시글이 존재하지 않습니다");
        }

        // 조회한 게시글을 요청 속성에 추가
        request.setAttribute("board", board);

        // 수정 폼 템플릿 반환
        return "board/update-form"; // src/main/resources/templates/board/update-form.mustache
    }


    /**
     * 게시글 수정 처리 메서드
     * 요청 주소: **POST http://localhost:8080/board/{id}/update**
     *
     * @param id        수정할 게시글의 ID
     * @param updateDTO 수정된 데이터를 담은 DTO
     * @return 게시글 상세보기 페이지로 리다이렉트
     */
    @PostMapping("/board/{id}/update")
    public String update(@PathVariable(name = "id") Integer id, @ModelAttribute(name = "updateDTO") BoardDTO.UpdateDTO updateDTO) {

        // 세션에서 로그인한 사용자 정보 가져오기
        User sessionUser = (User) session.getAttribute("sessionUser");
        if (sessionUser == null) {
            return "redirect:/login-form"; // 로그인하지 않은 경우 로그인 페이지로 리다이렉트
        }

        // 게시글 수정 서비스 호출
        boardService.updateBoard(id, sessionUser.getId(), updateDTO);

        // 수정 완료 후 게시글 상세보기 페이지로 리다이렉트
        return "redirect:/board/" + id;
    }




    /**
     * 게시글 삭제 처리 메서드
     * 요청 주소: **POST http://localhost:8080/board/{id}/delete**
     *
     * @param id 삭제할 게시글의 ID
     * @return 메인 페이지로 리다이렉트
     */
    @PostMapping("/board/{id}/delete")
    public String delete(@PathVariable(name = "id") Integer id) {
        // 세션에서 로그인한 사용자 정보 가져오기
        User sessionUser = (User) session.getAttribute("sessionUser");

        // 세션 유효성 검증
        if (sessionUser == null) {
            return "redirect:/login-form"; // 로그인 페이지로 리다이렉트
        }

        // 게시글 삭제 서비스 호출
        boardService.deleteBoard(id, sessionUser.getId());

        // 메인 페이지로 리다이렉트
        return "redirect:/";
    }

    /**
     * 게시글 작성 폼을 표시하는 메서드
     * 요청 주소: **GET http://localhost:8080/board/save-form**
     *
     * @return 게시글 작성 페이지 뷰
     */
    @GetMapping("/board/save-form")
    public String saveForm() {
        // 게시글 작성 폼 템플릿 반환
        return "board/save-form";
    }


    /**
     * 게시글 작성 처리 메서드
     * 요청 주소: **POST http://localhost:8080/board/save**
     *
     * @param dto 게시글 작성 요청 DTO
     * @return 메인 페이지로 리다이렉트
     */
    @PostMapping("/board/save")
    public String save(@ModelAttribute BoardDTO.SaveDTO dto) {
        // 세션에서 로그인한 사용자 정보 가져오기
        User sessionUser = (User) session.getAttribute("sessionUser");

        // 세션 유효성 검증
        if (sessionUser == null) {
            return "redirect:/login-form"; // 로그인 페이지로 리다이렉트
        }

        // 게시글 작성 서비스 호출
        boardService.createBoard(dto, sessionUser);

        // 메인 페이지로 리다이렉트
        return "redirect:/";
    }

    /**
     * 게시글 상세보기 처리 메서드
     * 요청 주소: **GET http://localhost:8080/board/{id}**
     *
     * @param id      게시글의 ID
     * @param request HTTP 요청 객체
     * @return 게시글 상세보기 페이지 뷰
     */
    @GetMapping("/board/{id}")
    public String detail(@PathVariable Integer id, HttpServletRequest request) {
        // 세션에서 로그인한 사용자 정보 가져오기
        User sessionUser = (User) session.getAttribute("sessionUser");
        Board board = boardService.getBoardDetails(id, sessionUser);

        // 현재 사용자가 게시글의 작성자인지 확인하여 isOwner 필드 설정
        boolean isOwner = false;
        if (sessionUser != null) {
            if (Objects.equals(sessionUser.getId(), board.getUser().getId())) {
                isOwner = true;
            }
        }

        // 뷰에 데이터 전달
        request.setAttribute("isOwner", isOwner);
        request.setAttribute("board", board);
        return "board/detail";
    }


    /**
     * 메인 페이지를 표시하는 메서드
     * 요청 주소: **GET http://localhost:8080/**
     *
     * @param model 뷰에 전달할 모델 객체
     * @return 메인 페이지 뷰
     */
    @GetMapping("/")
    public String index(Model model) {
        // 모든 게시글 조회 서비스 호출
        List<Board> boardList = boardService.getAllBoards();
        // 조회한 게시글 목록을 모델에 추가
        model.addAttribute("boardList", boardList);
        // 메인 페이지 템플릿 반환
        return "index";
    }

}