점프투스프링부트(스프링부트+JPA)

2장 2-06 ~ 2-09 요약 (프로젝트를 도메인 별로 분류하기, 타임리프 템플릿 설정, 질문 목록 만들기 코드 리뷰, @RequiredArgsConstructor, Model 객체, 자주 사용하는 타임리프 속성들, 루트 URL 설정, 서비스가 필요한 이유)

여행자0 2024. 12. 15. 17:54

 

2-06

도메인별로 분류하기

src/main/java 의 com.study.board 패키지 밑에 java 파일을 무작정 추가하지 말고 

question, answer, user 의 도메인별로 패키지를 나누어 관리하는 것이 바람직하다.

* 도메인이란, "질문", "답변" 처럼 프로젝트의 주요 기능을 뜻하는 말이다.

 

 

2-07

템플릿 설정하기

어떤 URL 에 접근 시, URL 과 GetMapping 된 컨트롤러를 호출하고,

컨트롤러에서는 특정 화면(템플릿)을 return 하게 한다.

이와 같이 템플릿을 return 해주는 방식을 템플릿(template) 방식이라 한다.

템플릿은 자바 코드를 삽입할 수 있는 HTML 형식의 파일을 말한다.

 

이러한 템플릿을 사용하기 위해 스프링 부트에서는 템플릿 엔진을 지원한다.

템플릿 엔진에는 Thymeleaf, Mustache, Groovy, Freemarker, Velocity 등이 있는데,

이 책에서는 스프링 진영에서 추천하는 타임리프 템플릿 엔진을 사용한다.

타임리프 공식 홈페이지 https://www.thymeleaf.org

 

 

1) build.gradle에 타임리프 설치

(... 생략 ...)

dependencies {
    (... 생략 ...)
    //타임리프 html템플릿
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
}

(... 생략 ...)

 

설치하고 Refresh Gradle Project 하면 타임리프 템플릿 언어 사용할 수 있다.

 

 

질문 목록 만들기 코드 리뷰

1) 컨트롤러 작성하기 (QuestionController.java 작성)

- src/main/java 디렉터리의 com.study.board 아래 quesetion 폴더에 QuestionController.java 를 작성한다.

package com.study.board.question;

import java.security.Principal;
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;

import com.study.board.answer.Answer;
import com.study.board.answer.AnswerForm;
import com.study.board.siteUser.SiteUser;
import com.study.board.siteUser.UserService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;


@RequiredArgsConstructor
@Controller
public class QuestionController {
    //private final QuestionRepository questionRepository;
    private final QuestionService questionService;
    private final UserService userService;
    
    //질문화면 메인
    //페이저블 적용하면서 page 파람 추가
    //getMapping으로 한 이유: 웹브라우저에서 새로고침 혹은 뒤로가기 하면 만료된페이지입니다 떠버림
    //여러 매개변수 조합하여 게시물 목록 조회시에는 get방식을 강력히 권장
    @GetMapping(value="/question")
    public String getQuestion(Model model
        , @RequestParam(value="page", defaultValue="0") int page
        , @RequestParam(value="kw", defaultValue="") String kw
    ) {
        //List<Question> qList = this.questionRepository.findAll();
        //List<Question> qList = this.questionService.getList();
        //model.addAttribute("qList", qList);

        //페이징 추가하여 조회
        //Page<Question> paging = this.questionService.getList(page);

        //키워드 검색 추가하여 페이징, 키워드로 함께 조회
        Page<Question> paging = this.questionService.getList(page, kw);

        model.addAttribute("paging", paging);
        model.addAttribute("kw", kw);
        return "question";
    }
}

 

컨트롤러 단에 @Controller, @RequiredArgsConstructor 작성

*RequiredArgsConstructor : 롬복이 제공하는 어노테이션으로, final이 붙은 속성을 생성자를 자동으로 만들어주는 역할. (생성자 객체 자동 주입)

* getQuestion 메서드의 파라미터 Model : Model 객체는 자바클래스와 템플릿의 연결 고리 역할. model 에 값을 담아두면 템플릿에서 그 값을 사용할 수 있다. Model 객체는 따로 생성할 필요 없이 메서드의 매개변수로 지정하기만 하면 스프링 부트가 자동으로 Model 객체를 생성한다. 

 

 

@GetMapping(value="/question") 로 URL 매핑을 한다.

그리하여 localhost:8080/question 접근 시, getQuestion 메서드를 실행하도록 한다.

return "question"; 에 의해 question.html 을 호출한다.

※이때 메서드 단에 @ResponseBody 가 선언되어 있으면 return 의 값은 "question" 이라는 "quesetion.html" 파일이 아닌, "문자열" 로 인식하여, 화면에 문자열 "question"이 표출된다.

 

주석처리 된 내용을 살펴보자.

//List<Question> qList = this.questionRepository.findAll()

: JpaRepository 에서 제공하는 findAll() 메서드를 사용하여 QUESTION 테이블의 모든 데이터를 조회한 코드인데, 컨트롤러 단에서는 레포지터리에 직접 접근하지 않는다. 반드시 서비스를 거쳐서 하도록 해야 하므로, 주석처리 하였다.

//List<Question> qList = this.questionService.getLst()

: questionService의 getList() 메서드를 호출한다. 해당 메서드에서는 JpaRepository 에서 제공하는 findAll() 메서드를 사용하여 QUESTION 테이블의 모든 데이터를 조회해 오도록 하였다. 페이징 처리와 키워드 검색 조건이 빠져있어 주석처리 하였다. 전체 데이터 조회 테스트용으로 사용했다.

//Page<Quesetion> paging = this.questionService.getList(page)

: 화면에서 던져주는 현재 page 값을 getList 메서드의 파라미터로 함께 넘겨서, 페이징 처리를 포함한 데이터를 조회하도록 하였다. 이 메서드는 검색조건 파라미터가 빠져있어 주석처리 하였다.

 

최종적으로 작성한 메서드는 다음과 같다.

Page<Question> paging = this.questionService.getList(page, kw);

: page와 kw는 화면에서 던져주는 파라미터이다.  컨트롤러의 파라미터에 @RequestParm으로 받게 하였다.

해당 파라미터들은 questionService 의 getList() 호출 시 넘겨주게 하였다.

 

 

 

2) 서비스 작성하기 (QuestionService.java 작성)

- src/main/java 디렉터리의 com.study.board 아래 quesetion 폴더에 QuesetionService.java 를 작성한다.

package com.study.board.question;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;

import com.study.board.DataNotFoundException;
import com.study.board.answer.Answer;
import com.study.board.siteUser.SiteUser;

import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class QuestionService {
	
	private final QuestionRepository questionRepository;
	
	/* 페이징 없이 전체 조회
	public List<Question> getList(){
		return this.questionRepository.findAll();
	}
	*/
	
	//page: 페이징번호, kw:조회키워드
	public Page<Question> getList(int page, String kw){
		
		//데이터 조회시 최근등록 건을 제일 상단에 노출하기 위해 sort 조건 추가
		List<Sort.Order> sorts = new ArrayList<>();
		sorts.add(Sort.Order.desc("writeDate"));
		//다른 정렬조건 추가시, sorts.add 메서드 활용하여 sorts리스트에 추가하면 됨

		//page: 조회할 페이지 번호, 10: 한 페이지 보여줄 게시물 개수
		//Pageable pageable = PageRequest.of(page, 10);
		
		//게시물 최신순 조회시 PageRequest.of메서드 세번째 매개변수에 Sort객체 전달해야함
		Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
		
		//페이징처리로 데이터 조회(조회조건 검색 기능 없을 때 사용)
		//return this.questionRepository.findAll(pageable);
		
		// 1. jpa specification 사용한 조회쿼리 (아래 2개)
		//Specification<Question> spec = search(kw);	//키워드를 넘겨 JPA로 조회쿼리 작성
		//return this.questionRepository.findAll(spec, pageable);
		
		//2. @Query 어노테이션 사용하여 기존 쿼리 작성하듯 사용한 조회쿼리
		return this.questionRepository.findAllByKeyword(kw, pageable);
	}
    
    
    //검색조건 포함한 복잡한 조회쿼리 : JPA Specification 인터페이스 사용
    private Specification<Question> search(String kw){	//파라미터:조회키워드

        //쿼리의 조인문과 where문을 specification 객체로 생성하여 리턴하는 메서드
        return new Specification<>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Predicate toPredicate(Root<Question> q
            , CriteriaQuery<?> query
            , CriteriaBuilder cb) {

                /* 아래 쿼리를 jpa로 조회하는 방법임
                select
                distinct q.id,
                q.user_id,
                q.content,
                q.write_date,
                q.modify_date,
                q.subject 
                from question q 
                left outer join site_user u1 on q.user_id=u1.id --글쓴이 정보
                left outer join answer a on q.id=a.question_id --질문에 달린 답글 가져오기
                left outer join site_user u2 on a.user_id=u2.id --답쓴이 정보
                where
                q.subject like '%스프링%' 
                or q.content like '%스프링%' 
                or u1.username like '%스프링%' 
                or a.content like '%스프링%' 
                or u2.username like '%스프링%' 
                */


                query.distinct(true);  // 쿼리에 distinct 선언 (데이터 중복 제거)

                Join<Question, SiteUser> u1 = q.join("user", JoinType.LEFT);
                Join<Question, Answer> a = q.join("answerList", JoinType.LEFT);
                Join<Answer, SiteUser> u2 = a.join("user", JoinType.LEFT);

                //cb.or : 여러 조건 중 하나라도 만족하는 경우 해당 항목 반환
                return cb.or(
                cb.like(q.get("subject"), "%" + kw + "%"), 		// 제목 
                cb.like(q.get("content"), "%" + kw + "%"),      // 내용 
                cb.like(u1.get("username"), "%" + kw + "%"),    // 질문 작성자 
                cb.like(a.get("content"), "%" + kw + "%"),      // 답변 내용 
                cb.like(u2.get("username"), "%" + kw + "%")		// 답변 작성자 
                );
            }
        };
	}
}

서비스 단에 @Service, @RequiredArgsConstructor 작성 (서비스임을 표시, 상단에 final 변수 생성자 주입)

 

getList() 메서드 내에 주석처리 된 부분은 읽어보면 이해가 가능하다.

해당 메서드에서

1. jpa specification 사용한 조회쿼리와 2. @Query 어노테이션 사용한 조회쿼리 부분만 살펴보겠다. 

 

 

1. jpa specification 사용한 조회쿼리 ( getList()  에 작성된 내용 ) 

//jpa specification 사용한 조회쿼리 (아래 2개)
Specification<Question> spec = search(kw);	//키워드 포함한 조회쿼리 작성
return this.questionRepository.findAll(spec, pageable);

QuestionService 안에 작성된 search 메서드를 호출하여 조회쿼리 짠 결과는 spec 에 담는다.

search 메서드는 직접 작성한 내용이다. 

search 메서드의 return 값은 Specification 이다.

Specification 을 사용하면 SQL의 사용 없이 조회 쿼리를 짤 수 있다.

 

근데 이 부분은 공부를 많이 해봐야 할 듯하다.

현재로썬 JPA 가 익숙하지가 않고 어려운 감이 있다..

이런 식으로 작성하는 구나 하고 넘어갔고 @Query 어노테이션을 쓰기로 하였다.

 

2. @Query 어노테이션 사용한 조회쿼리 ( getList()  에 작성된 내용 ) 

//@Query 어노테이션 사용하여 기존 쿼리 작성하듯 사용한 조회쿼리
this.questionRepository.findAllByKeyword(kw, pageable);

quesetionRepository 에 findAllByKeyword 라는 메서드를 직접 등록하고, 거기서 조회하도록 했다. 

이 내용은 아래 3)에서 확인해보겠다.

 

 

 

3) 레포지터리 작성하기 (QuestionRepository.java)

- src/main/java 디렉터리의 com.study.board 아래 quesetion 폴더에 QuesetionRepository.java 를 작성한다.

package com.study.board.question;

import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;


//JpaRepository를 상속할 땐 제네릭타입으로 리포지터리의 대상이 되는 엔티티 타입과, 해당 엔티티의 PK속성을 지정하여 작성
public interface QuestionRepository extends JpaRepository<Question, Integer>{
	
	Question findBySubject(String subject);
	
	Question findBySubjectAndContent(String subject, String content);
	
	List<Question> findBySubjectLike(String subject);
	
	//페이징처리한 조회 (JpaRepository에서 제공하는 findAll()은 매개변수없음. 이건 오버로딩임)
	Page<Question> findAll(Pageable pageable);

	//키워드 검색 + 페이징처리한 조회 (JPA Specification 사용한 조회쿼리)
	Page<Question> findAll(Specification<Question> spec, Pageable pageable);

	//키워드 검색 + 페이징처리한 조회 (@Query 어노테이션 사용하여 직접 쿼리 작성)
	//이때 쿼리는 반드시 테이블 기준이 아닌 엔티티 기준으로 작성해야한다!!!!
	//조인 조건도 엔티티의 속성을 사용해서 조인해야한다!!!
	@Query("select "
            + "distinct q "
            + "from Question q " 
            + "left outer join SiteUser u1 on q.user=u1 "
            + "left outer join Answer a on a.question=q "
            + "left outer join SiteUser u2 on a.user=u2 "
            + "where "
            + "   q.subject like %:kw% "
            + "   or q.content like %:kw% "
            + "   or u1.username like %:kw% "
            + "   or a.content like %:kw% "
            + "   or u2.username like %:kw% ")
	Page<Question> findAllByKeyword(@Param("kw") String kw, Pageable pageable);
	
}

 

데이터 조회를 위해 서비스에서 사용했던 레포지터리의 메서드는 다음과 같다.

Page<Quesetion> findAll(Pageable pageable);

Page<Question> findAll(Specification<Question> spec, Pageable pageable);

Page<Question> findAllByKeyword(@Param("kw") String kw, Pageable pageable);

 

특히 2번째 3번째 줄 사용법 확인하자.

 

 

4) 템플릿 파일 작성하기 (question.html 파일 작성)

- src/main/resources 디렉터리의 templates 폴더 아래에 question.html 파일을 작성한다. 

<html layout:decorate="~{common/layout}">
<div layout:fragment="content" class="container my-3">
	<div class="row my-3">
        <div class="col-6">
            <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
        </div>
        <div class="col-6">
            <div class="input-group">
				<!--조회조건 get요청을 위한 form-->
				<form th:action="@{/question}" method="get" id="searchForm">
			        <input type="hidden" id="kw" name="kw" th:value="${kw}">
			        <input type="hidden" id="page" name="page" th:value="${paging.number}">
			    </form>
				
                <input type="text" id="searchKw" class="form-control" th:value="${kw}">
                <button type="button" id="btnSearch" class="btn btn-outline-secondary">찾기</button>
            </div>
        </div>
    </div>
	
	
	<table class="table">
	    <thead class="table-dark">
	        <tr class="text-center">
				<th>번호</th>
	            <th style="width:50%">제목</th>
				<th>글쓴이</th>
	            <th>작성일시</th>
	        </tr>
	    </thead>
		<!-- 페이징 적용 전
		<tbody>
	        <tr th:each="question, loop : ${qList}">
				<td th:text="${loop.count}"></td>
				<td>
	                <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
	            </td>
	            <td th:text="${#temporals.format(question.writeDate, 'yyyy-MM-dd HH:mm')}"></td>
	        </tr>
	    </tbody>
		-->
			  
		<tbody>
			<tr class="text-center" th:each="question, loop : ${paging}">
				<!-- 어느 페이지든 간에 1~10으로 표출되는 글번호임..
				<td th:text="${loop.count}"> </td>
				-->
				<!-- 게시물번호 = 전체 개시물 수 - (현재페이지 * 페이지당 게시물 개수) - 나열인덱스 -->
				<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
				<td class="text-start">
			        <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
					<span class="text-danger small ms-2"
						th:if="${#lists.size(question.answerList) > 0}"
						th:text="${#lists.size(question.answerList)}">
					</span>
			    </td>
				<td><span th:if="${question.user != null}" th:text="${question.user.username}"></span></td>
			    <td th:text="${#temporals.format(question.writeDate, 'yyyy-MM-dd HH:mm')}"></td>
			</tr>
		</tbody>
	</table>
	
	<!--페이징번호-->
	<div th:if="${!paging.isEmpty()}">
	    <ul class="pagination justify-content-center">
	        <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
				<!--
	            <a class="page-link"
	                th:href="@{|?page=${paging.number-1}|}">
	                <span>이전</span>
	            </a>
				-->
				<a class="page-link" href="javascript:void(0)"
					th:data-page="${paging.number-1}">
				    <span>이전</span>
				</a>
	        </li>
			
			<!--th:if="${page >= paging.number-5 and page <= paging.number+5}"
			: 현재 페이지 값 기준으로 좌우 5개씩 페이지 번호가 표시되도록 만든다.
			  현재 페이지를 의미하는 paging.number보다 5만큼 작거나 큰경우만 표시
			-->
	        <li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
				th:if="${page >= paging.number-5 and page <= paging.number+5}"
	            th:classappend="${page == paging.number} ? 'active'" 
	            class="page-item">
				<!--
				<a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
				-->
				<a th:text="${page}" class="page-link" href="javascript:void(0)" th:data-page="${page}"></a>
	        </li>
			
	        <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
				<!--
	            <a class="page-link" th:href="@{|?page=${paging.number+1}|}">
	                <span>다음</span>
	            </a>
				-->
				<a class="page-link" href="javascript:void(0)" th:data-page="${paging.number+1}">
					<span>다음</span>
				</a>
	        </li>
	    </ul>
	</div>
</div>


<script layout:fragment="script" type='text/javascript'>
	
//이전,페이징번호,다음 클릭시
const page_elements = document.getElementsByClassName("page-link");
Array.from(page_elements).forEach(function(element) {
    element.addEventListener('click', function() {
//		console.log(this.dataset)
//		data-page라고 선언한부분에서 data-이 부분이 dataset을 의미함
		
        document.getElementById('page').value = this.dataset.page;
        document.getElementById('searchForm').submit();
		
		//이전, 페이징번호, 다음에다가 javascript:void(0)을 줬기때문에 여기서 따로 추가함
		//왜 위처럼 했냐면, 조회조건이 추가되면서 그 파라미터도 같이 넘겨야 하기 때문
		
    });
});

//조회버튼
const btnSearch = document.getElementById("btnSearch");
btnSearch.addEventListener('click', function() {
    document.getElementById('kw').value = document.getElementById('searchKw').value;
    document.getElementById('page').value = 0;  // 검색버튼을 클릭할 경우 0페이지부터 조회한다.
    document.getElementById('searchForm').submit();
});
</script>
</html>

 

위 코드에서 ${paging}, ${kw} 는

컨트롤러에서 model 에 담겨서 넘어온 값을 표출할 수 있게 작성한 것이다.

 

게시글 리스트가 나오는 쪽의 tbody 에서 제목 클릭시 상세 페이지 출력하도록 링크를 걸어 두었다.

<td>
	<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
</td>


제목에 상세 페이지 URL을 연결하기 위해 타임리프의 th:href 를 사용한다.

이때 URL은 반드시 @{ } 사이에 입력해야 한다.

여기서는 문자열 /question/detail/ 과 ${question.id} 값이 조합되었기 때문에, 

좌우에 | 를 추가하여 달았다.

즉, 타임리프에서는 문자열과 자바 객체 값을 더할 때는 좌우를 | 로 감싸 주어야 한다.

 

 

자주 사용하는 타임리프 속성

1. 분기문 속성

th:if="${question != null}"

 

2. 반복문 속성

th:each="question : ${questionList}"

반복문 속성은 다음과 같이 사용할 수도 있다.

th:each="question, loop : ${questionList}"

 

loop.index : 현재 순서 표출, 0부터 증가

loop.count : 현재 순서 표출, 1부터 증가

loop.size : 반복하는 객체의 요소 개수

loop.first : 루프의 첫 번째 순서인 경우 true

loop.last : 루프의 마지막 순서일 경우 true

loop.odd : 루프의 홀수 번째인 경우 true

loop.even : 루프의 짝수 번째인 경우 true

loop.current : 현재 대입된 객체(여기서는 question 과 동일)

 

3. 텍스트 속성

th:text="${question.subject}"

텍스트는 th:text 대신 다음처럼 대괄호를 사용해서 값을 직접 출력할 수 있다.

 

<tr th:each="question : ${questionList}">
    <td>[[${question.subject}]]</td>
    <td>[[${question.createDate}]]</td>
</tr>

 

 

2-08

루트 URL 사용하기

package com.study.board.main;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MainController {
	
    @GetMapping("/")
    public String getMain() {
        return "redirect:/question";
    }

}

localhost:8080 조회 시, localhost:8080/question 화면을 호출하도록 redirect 설정을 했다.

위와 같이 redirect 설정을 하면, @GetMapping("/question") 으로 매핑된 메서드가 호출이 된다.

* redirect 란 : 클라이언트가 요청하면 새로운 URL 로 전송하는 것

 

 

2-09

서비스가 필요한 이유

QuestionController.java 코드에서 살펴봤던 것 처럼

Controller 는 Service 를 호출하고, Service 는 Repository 를 호출하도록 하는 것이 좋다.

왜냐하면...

 

1. 복잡한 코드를 모듈화 할 수 있다.

A 컨트롤러가 어떤 기능을 수행하기 위해 C라는 리포지터리의 메서드 a,b,c를 순서대로 실행한다라고 가정하자.

B 컨트롤러도 A 컨트롤러와 동일한 기능을 수행한다면, C 리포지터리의 메서드 a,b,c를 호출해야 하므로 코드가 중복된다.

이런 경우에 C 리포지터리의 a,b,c 메서드를 호출하는 서비스를 만들고, A,B 컨트롤러는 이 서비스를 호출하여 사용할 수 있다. 즉, 모듈화가 가능하다!

 

 

2. 엔티티 객체를 DTO 객체로 변환할 수 있다.

: 앞에서 작성한 Question, Answer 클래스는 모두 엔티티 클래스이다.

엔티티 클래스는 DB와 직접 관계 있는 클래스이므로, 컨트롤러 또는 템플릿 엔진에 전달하여 사용하는 것은 좋지 않다.

왜냐하면 엔티티 객체에 민감한 데이터가 포함될 수 있기 때문에, 타임리프에서 엔티티 객체를 직접 사용하면 이 데이터가 노출될 수 있기 때문이다.

 

이러한 이유로 엔티티 클래스는 컨트롤러에서 사용하지 않도록 설계하는 것이 좋다.

그래서 엔티티 클래스를 대신할 DTO 클래스가 필요한 것이고, 엔티티를 DTO 객체로 변환하는 작업도 필요하다.

이러한 변환 작업을 할 때 "서비스" 가 필요하다. 

즉, 서비스는 컨트롤러와 리포지터리 중간에서 엔티티와 DTO를 서로 변환하여 양방향에 전달하는 역할을 한다.

* 이 책에서는 간결한 코드를 위해 DTO 를 사용하고 있지는 않음.