728x90

#타임리프 레이아웃 

코드의 재사용이 가능한 부분을 템플릿화할 수 있게 도와주는 타임리프 라이브러리 

 

#레이아웃을 사용하는 이유 

타임리프의 insert나 replace 기능은 많이 사용하지만 이 기능에는 한가지 문제점이 있다. 

 

대부분의 사이트는 탑, 바텀, 네이게이션바 등을 고정적으로 사용한다. 

그래서 매 페이지마다 top(header), bottom, navigationBar를 insert 해주게된다. 

insert를 사용하면 탑에 내용이 바뀐다면 탑만 수정해주면 모든 페이지의 탑이 바뀌게 된다. 

 

그런데 만약 insert된 코드의 내용이 아니라 insert된 코드, 다른 코드들 간의 구조에 변화가 생긴다면 

모든 페이지의 fragment들의 구조를 바꿔줘야 한다. 

이런 한계를 커버해 줄 수 있는 기능이 layout이다. 

기본적인 레이아웃의 사용법을 이미지화한 모습이다.

 

공통요소는 레이아웃에 담아두고 바뀌는 부분만 각각의 페이지에 작성하면 된다. 

 

product.html 페이지로 forwarding, redirect되면 product.html 페이지의 코드들이 레이아웃에 merge되어 렌더링된다.

 

#Thymeleaf layout을 사용하기 위한 setting

 

gradle 추가

implementation group: 'nz.net.ultraq.thymeleaf', name: 'thymeleaf-layout-dialect', version: '3.1.0'

 

 

layout.html

<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org"

xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">

 

layout을 사용할 파일

<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org"

xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"

layout:decorate="layout/layout(title='Recently Viewed')">

<head>

여기서 layout/layout은 사용할 layout의 root이다. 

즉, resources패키지의 layout 패키지 안의 layout.html 파일이라는 뜻이다. 

(title='Recently Viewed')는 부가적인 기능으로 필요하지 않으면 작성하지 않아도 되고 layout의 root만 작성해줘도 된다.

 

 

#layout의 기본적인 사용

 

layout.html

<th:block layout:fragment="content" />

 

layout을 사용할 파일

 

<th:block layout:fragment="content">

<!-- Page Content-->

<div class="container padding-bottom-3x mb-2">

<div class="row">

<!--profile component-->

<div class="col-lg-4" th:insert="~{mypage_component :: mypage(accountPage='recentview')}"></div>

<!--profile component 끝-->

<div class="col-lg-8">

<div class="padding-top-2x mt-2 hidden-lg-up"></div>

<div class="table-responsive wishlist-table margin-bottom-none">

<!-- recentView Table-->

<!-- 하위 태그들은 size==1이면 빈상자 이미지로 대체-->

<table class="table"

th:insert="~{myProduct-component :: product-table(from='recentview')}"></table>

<!-- recentView Table-->

 

</div>

<hr class="mb-4">

</div>

</div>

</div>

 

</th:block>

layout.html에 원하는 위치에 layout:fragment="이름" 속성을 지정해주고

그 위치에 들어갈 코드를 다른 파일에서 

layout:fragment="이름" 속성을 가진 태그의 안에 작성해주면 된다. 

 

*layout은 head body 모두 작성할것이기 때문에 당연히 html 파일의 구조를 제대로 작성할텐데

layout을 사용할 파일은 head가 필요하지 않을 수도 있다. 

하지만 모든 구조를 갖춰서 작성해주는 것이  좋고, 사용하지 않는 fragment들이라도 작성해주는 것이 좋다. 

 

 

예를들어 layout에 fragment로 navigation, contents, recommendation이 있다면 

layout을 사용하는 product.html에 recommendation fragment를 사용하지 않아도 빈 fragment를 작성해준다면 fragment가 아닌 부분의 코드 위치를 잡는데도 도움이 될 것이다. 

 

#layout head merge

레이아웃의 <head> 는 모든 페이지에서 공통으로 사용할 코드를 작성하면 된다. 

다른 페이지에서 그 페이지에서만 사용할 css나 js가 있다면 그 페이지의 head에 작성하면 

그 페이지에 작성된 head만 적용되는 것이 아니라 layout의 head와 merge되어 같이 적용된다. 

 

#layout 변수 초기화 및 사용 

 

<!DOCTYPE html>

<html lang="ko" xmlns:th="http://www.thymeleaf.org">

<head></head>

<body>

<!-- content start -->

<div class="page-title" th:fragment="page-title(page_title)">

<div class="container">

<div class="column">

<h1 th:text="${page_title}">title</h1>

</div>

<div class="column">

<ul class="breadcrumbs">

<li><a href="index">Home</a></li>

<li class="separator">&nbsp;</li>

<li th:text="${page_title}">title</li>

</ul>

</div>

</div>

</div>

<!-- content end -->

</body>

</html>

레이아웃에 변수를 선언하고 레이아웃을 사용하는 각 페이지마다 그 변수를 초기화하여 사용할 수 있다.

 

기존 타임리프에는 fragment명에 (변수 선언)하면 이후에 ${변수명}으로 사용할 수 있다. 

 

레이아웃에서는 

<th:block th:if="${title!=''}">

<div class="page-title"

th:insert="~{fragment/page-title::page-title(${title})}"

th:remove="tag"></div>

</th:block>

layout.html

 

<html xmlns:th="http://www.thymeleaf.org"

xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"

layout:decorate="layout/layout(title='Recently Viewed')">

recentview.html

레이아웃을 사용하는 파일에서 (변수명='value')라고 하면

layout.html의 ${title}에 value값이 대입된다. 

'Java > Spring Boot' 카테고리의 다른 글

JPA Object Relation Mapping  (0) 2023.10.11
JPA 계층형 게시판  (0) 2023.10.10
Spring Data JPA begins  (0) 2023.10.06
Spring CRUD with RestAPI  (0) 2023.09.20
Spring addViewControllers  (0) 2023.09.14
728x90

이번 프로젝트를 하면서 예외 처리에 대해서 고민 많았고 

실제로 작업하면서 몇번씩 수정을 거치게 되었다. 

 

일단 예외가 발생했을 때 발생원인이 명확한 경우 구체적인 예외 사유를 알 수 있게 customException을 만들어서 던지기로 했다. 

 

public class ProductCustomException extends Exception{

private String data;

private ProductExceptionMsg msg;

 

public ProductCustomException(ProductExceptionMsg msg) {

this.msg = msg;

}

 

public ProductCustomException(String data, ProductExceptionMsg msg) {

this.data = data;

this.msg = msg;

}

}

 

ProductCustomException은 

ProductExceptionMsg를 가지고 있다. 

public enum ProductExceptionMsg implements ProductMsgInterface{

FOUND_NO_MEMBER, FOUND_NO_OPTIONSET,FOUND_NO_OPTIONS, FOUND_NO_PRODUCT,

FOUND_NO_CATEGORY, FOUND_NO_RECENTVIEW,FOUND_NO_INTEREST,

ALREADY_EXISTS_RECENTVIEW,ALREADY_EXISTS_INTEREST, IS_NOT_INTERESTEDD, NEED_LOGIN, WRONG_PARAMETER

}

 

ProductMsgInterface를 상속한 ExceptionMsg는 예외명들을 가지고 있다. 

 

이렇게 하면 예외가 발생했을 때 ResponseDto에 ExceptionMsg를 보낼 수도 있고 

예외를 던지거나 잡아야할 때 Msg를 확인하고 Msg에 맞게 처리할 수 있다. 

 

그리고 ProductCustomException을 상속한 커스텀 익셉션을 만들고 

내부 클래스로 그 커스텀을 구체적으로 나눴다. 

FoundNoObjectException만 만들고 ExceptionMsg로 발생한 부분을 구분할 수도 있지만 클래스명으로 

직관적으로 알 수 있게 클래스단위로 분리하였다. 

public class FoundNoObjectException extends ProductCustomException {

 

public FoundNoObjectException(ProductExceptionMsg msg) {

super(msg);

}

public FoundNoObjectException(String data, ProductExceptionMsg msg) {

super(data,msg);

}

public static class FoundNoMemberException extends FoundNoObjectException{

public FoundNoMemberException(String data) {

super(data,ProductExceptionMsg.FOUND_NO_MEMBER);

}

public FoundNoMemberException() {

super(ProductExceptionMsg.FOUND_NO_MEMBER);

}

}

public static class FoundNoOptionSetException extends FoundNoObjectException{

public FoundNoOptionSetException(String data, ProductExceptionMsg msg) {

super(data, ProductExceptionMsg.FOUND_NO_OPTIONSET);

}

public FoundNoOptionSetException() {

super(ProductExceptionMsg.FOUND_NO_OPTIONSET);

}

}

...

}

 

그리고 dao와 service ,controller 단에서 들어오는 데이터를 검증하고 객체를 찾지 못하거나 로그인이 필요하거나 이미 존재하는 등 체크해서 알맞은 예외를 던져주었다. 

 

@Override

public void delete(Long id) throws FoundNoCategoryException {

Category find= repository.findById(id).orElseThrow(() -> new FoundNoCategoryException());

repository.deleteById(find.getId());

}

예를들어

카테고리를 삭제할 때 삭제할 카테고리를 찾지 못하면 FoundNoCategoryExcetpion을 던져주었다. 

 

public ResponseDto<?> deleteHeart(@Valid InterestDto dto) throws FoundNoMemberException, FoundNoOptionSetException {

try {

if(interestDao.isInterested(dto)) {

interestDao.delete(dto);

}else {

return ResponseDto.builder().msg(ProductExceptionMsg.IS_NOT_INTERESTEDD).build();

}

} catch (FoundNoInterestException e) {

e.printStackTrace();

return ResponseDto.builder().msg(e.getMsg()).build();

}

return ResponseDto.builder().msg(ProductSuccessMsg.UNTAP_HEART).build();

}

파라메터로 들어오는 InterestDto에 @Valid 어노테이션으로 검증을 하고 

 

**

implementation 'org.springframework.boot:spring-boot-starter-validation'

검증을 위한 validation gradle 추가 

 

deleteHeart 서비스는 제품의 하트를 눌렀을 때 관심상품에 추가하고 삭제할 때 삭제하는 서비스인데 

일단 관심상품으로 등록되어있는지 확인해야한다.

비동기 방식으로 요청을 보내기 때문에 응답이 가기전에 여러번 중복하여 요청이 오는 경우 

관심상품이 이미 삭제되었는데 삭제 요청이 올 수도 있기 때문에 

if문으로 확인하는 작업을 거치고 

그렇지 않으면 500번 대신 명확한 메시지를 담은 ResponseDto를 보내준다. 

 

public ResponseDto<?> showOptionNameValues(Long categoryId) {

List<OptionNamesValues> optionNameValue = optionDao.findOptionNameValueMapByCategoryId(categoryId);

if(optionNameValue==null||optionNameValue.isEmpty()) {

log.warn("no children categories found");

return ResponseDto.builder().msg(ProductExceptionMsg.FOUND_NO_OPTIONS).build();

}

Map<String, Set<String>> dto = optionNameValue.stream().collect(Collectors.groupingBy(

OptionNamesValues::getName, Collectors.mapping(OptionNamesValues::getValue, Collectors.toSet())));

List<Map<String, Set<String>>> data = new ArrayList<>();

data.add(dto);

return ResponseDto.<Map<String, Set<String>>>builder().data(data).msg(ProductSuccessMsg.FIND_OPTION_NAME_VALUES).build();

}

 

showOptionNameValues 서비스는 카테고리를 선택했을 때 비동기방식으로 요청을 보내

카테고리의 제품들이 보유한 모든 옵션을 가져오는 서비스인데

 

optionNameValue가 없다는건 카테고리에 해당하는 제품이 없거나 옵션이 없다는 뜻이다. 

만약 이런 상황이 발생할 때 예외 페이지로 보내거나 클라이언트가 다른 요청을 보내는데 지장이 생기면 안된다. 

 

카테고리에 해당하는 제품이 없다거나 옵션이 없다면 그 카테고리나 제품에 문제가 있는 것일 수도 있고 

원래 옵셥이 없는 제품들만 있는 카테고리일 수도 있는 것이다.

 

여기서 예외를 처리할 수 있는 최선의 방법은 log로 남기고 나중에 log를 확인하고 제품이나 카테고리를 확인하는 작업을 하는 것이고

만약 진짜 옵션이 없는 상황이라면 그 카테고리를 선택했을 때 선택할 옵션이 없다는 표시를 해주는 것이 최선이라고 생각한다. 

 

 

컨트롤러까지와서 예외를 잡지 못하거나 발생한 예외에 대해 일관된 처리가 필요한 경우는 

ExceptionHandler로 처리했다. 

 

https://youarethebestcoding.tistory.com/24

 

Spring Boot Exception Handler

Local Exception Controller Controller 내에서 발생하는 Exception만 처리한다. @Controller public class LocalExceptionController { @GetMapping("/business1") public String business_method() throws BusinessException1 { boolean b = true; if(b) { thro

youarethebestcoding.tistory.com

 

restController에서 발생한 예외는 클라이언트 쪽에서 처리하기 위해 상태코드와 메세지를 담아 응답을 보내줬다.

@RestControllerAdvice

public class ExceptionRestController {

@ResponseBody

@ExceptionHandler(value = {NeedLoginException.class,FoundNoOptionSetException.class,FoundNoMemberException.class,MethodArgumentNotValidException.class})

protected ResponseEntity<?> defaultRestException(Exception e) {

ProductExceptionMsg errorMsg=null;

if (e instanceof NeedLoginException) {

errorMsg = ((NeedLoginException) e).getMsg();

return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ResponseDto.builder().msg(errorMsg).build());

} else if (e instanceof FoundNoObjectException.FoundNoMemberException) {

errorMsg = ((FoundNoObjectException.FoundNoMemberException) e).getMsg();

return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ResponseDto.builder().msg(errorMsg).build());

} else if (e instanceof FoundNoOptionSetException) {

errorMsg = ((FoundNoOptionSetException) e).getMsg();

return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ResponseDto.builder().msg(errorMsg).build());

} else if (e instanceof MethodArgumentNotValidException) {

errorMsg = ProductExceptionMsg.WRONG_PARAMETER;

}

return ResponseEntity.badRequest().body(ResponseDto.builder().msg(errorMsg).build());

}

 

}

 

예외가 발생하면 

ResponseEntity로 다시 클라이언트에 보내면 

fetch(options.url, options).then((response) => {

if (response.status === 200 || response.status === 201) {

return response.json();

} else if (response.status === 401) {

alert('로그인이 필요한 서비스입니다.');

window.location.href = "/member_login_form";// redirect

} else if (response.stataus === 404) {

window.location.href = "/404.html";

} else if (response.msg == 'WRONG_PARAMETER') {

alert('잘못된 요청입니다. 입력값을 확인해주세요.');

} else {

Promise.reject(response);

throw Error(response);

}

})

fetch 또는 ajax 등 비동기 방식으로 요청하고 받은

response의 status나 msg로 예외를 구분하고

그 예외에 대한 처리를 정의할 수 있다.

 

그리고 컨트롤러에서 발생한 예외는 404 페이지로 보내거나 로그인 폼으로 보냈다. 

@ControllerAdvice

public class ExceptionController {

@ExceptionHandler(value = {FoundNoObjectException.FoundNoOptionSetException.class,NeedLoginException.class

,FoundNoMemberException.class,NoSuchElementException.class})

protected String defaultException(Exception e, HttpSession session) {

if (e instanceof FoundNoOptionSetException) {

return "redirect:404"; //없는 상품 조회

} else if (e instanceof NoSuchElementException) {

return "redirect:404";

} else if (e instanceof NeedLoginException) {

return "redirect:member_login_form";

} else if (e instanceof FoundNoMemberException) {

return "redirect:member_login_form";

}

return "redirect:404"; //없는 상품 조회

}

 

@ExceptionHandler(Exception.class)

@ResponseStatus

protected String exception (Exception e) {

return "redirect:404";

}

 

}

 

 

 

728x90

JPA에 대해서 다룬 포스트가 있다. 

https://youarethebestcoding.tistory.com/115

 

Spring Data JPA begins

http://projects.spring.io/spring-data/ Spring Data Spring Data’s mission is to provide a familiar and consistent, Spring-based programming model for data access while still retaining the special traits of the underlying data store. It makes it easy to us

youarethebestcoding.tistory.com

 

이번 포스팅에서는 프로젝트에서 실제로 활용한 사례와 JPQL까지 다룬다. 

 

 

# 메소드명을 작성할 때 객체를 참조하는 법

ublic interface CategoryRepository extends JpaRepository<Category, Long>{

List<Category> findByParentNull();

List<Category> findByCategorySets_Product_OptionSets_Id(Long optionSetId);

}

 

JpaRepository 인터페이스를 상속받아 목적에 맞게 메소드명을 작성한다. 

 

여기서 주목할 점은 두번째 메소드의 _언더바이다.

 

학원에서 배울때도 그렇고 인터넷에 올라온 대부분의 포스팅은 

객체를 해당 객체의 pk로 찾거나 객체의 다른 프로퍼티로 찾는 방법만 소개하고 있다. 

Category findByName(String name);

 

심지어 공식 api문서에서도 찾기 어려웠다.

https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html

 

JPA Query Methods :: Spring Data JPA

As of Spring Data JPA release 1.4, we support the usage of restricted SpEL template expressions in manually defined queries that are defined with @Query. Upon the query being run, these expressions are evaluated against a predefined set of variables. Sprin

docs.spring.io

 

그래서 실제 프로젝트에서는 다른 객체로 찾게 되는 경우가 많은데 시작부터 난관에 부딪혀 멘붕이었다.

 

그런데 Jpa는 ORM 즉 객체 관계 매핑이다. 

객체를 참조하는 방법이 없다면 ORM이라고 부를 수 없고 메소드명을 정의할때 객체를 참조하는 방법이 존재한다. 

 

List<Category> findByCategorySets_Product_OptionSets_Id(Long optionSetId);

여기서 _언더바는 객체의 참조를 의미한다. 

JpaRepository<Category, Long>

Category 타입을 가지는 JpaRepository를 상속했으므로 

 

Category를 찾을텐데 optionset의 id로 카테고리를 찾고 싶은 경우이다. 

 

public class Category {

 

private List<CategorySet> categorySets = new ArrayList<>();

 

}

category는 categorysets을 프로퍼티로 가지고 

categoryset은 product를 가지고 product는 다시 optionsets을 가진다. 

이렇게 참조에 참조를 더해서 메소드명을 작성할 수도 있다. 

 

이때 category가 가지는 List<CategorySet>의 변수명이 categorySets이므로 

메소드명에서도 CategorySet이 아니라 CategorySets (변수명 그대로) 작성해주어야 한다. 

물론 첫글자는 대분자로 작성한다. 

 

**한가지 주의할 점이 있다. 

메소드명으로 작성할때는 대소문자에 매우 유의해야한다. 

카멜케이스를 사용한다고 mId와 같이 소문자 한글자에 Id를 붙이면 

메소드명에 MId라고 적어줘야할것 같지만 실제로 사용해본 결과 그렇지 않았다. 

어차피 객체에 member라는 의미가 있으므로 불필요하게 m을 붙이는 일을 하지 않는게 좋을것 같다. 

 

#다중조건

public interface InterestRepository extends JpaRepository<Interest, Long> {

 

Boolean existsByMemberIdAndOptionSetId(Long memberId,Long optionSetId);

 

void deleteByOptionSetIdAndMemberId(Long optionSetId, Long memberId);

 

void deleteByMemberId(Long memberId);

}

 

멤버의 pk와 Optionset pk로 해당상품이 멤버의 관심상품인지 체크하기 위한 메서드로 boolean 타입을 반환받는다. 

 

여기서 조건은 memberId와 OptionSetId 두가지를 And로 받고 있다. 

아래는 실제 날아가는 쿼리문이다. 

select

*

from

(select

i1_0.id c0,

rownum rn

from

interest i1_0

where

i1_0.member_id=?

and i1_0.option_set_id=?) r_0_

where

r_0_.rn<=?

order by

r_0_.rn

member_id=? and option_set_id=? 으로 두가지 조건을 모두 적용하고 있다. 

 

delete도 마찬가지로 일단 두가지 조건에 해당하는 interest를 먼저 찾고 삭제하는 모습을 볼 수 있다. 

select

i1_0.id,

i1_0.create_time,

i1_0.member_id,

i1_0.option_set_id,

i1_0.update_time

from

interest i1_0

where

i1_0.option_set_id=?

and i1_0.member_id=?

 

delete

from

interest

where

id=?

 

 

#Projections : 필요한 속성만 조회하는 방법 

 

https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html#projections.interfaces.closed

 

Projections :: Spring Data JPA

Spring Data query methods usually return one or multiple instances of the aggregate root managed by the repository. However, it might sometimes be desirable to create projections based on certain attributes of those types. Spring Data allows modeling dedic

docs.spring.io

 

public interface OptionsRepository extends JpaRepository<Options, Long> {

List<OptionNamesValues> findDistinctByOptionSet_Product_CategorySets_Category_Id(Long id);

}

 

이 기능을 구현하기 위해서

 

나는 카테고리를 선택했을때 

해당 카테고리에 속한 모든 product 그리고 그 product의 모든 optionset들이 가지는 option들의 name과 value가 필요했다. 

 

일반적인 방법으로 Options 객체를 그대로 가져오면 중복되는 option name과 value를 거를 수가 없었고 

Options 객체들을 모아서 그중 name과 value 속성만 골라서 각각 distinct 하는 작업을 하는것은 매우 비효율적이라고 생각했다. 

 

그래서 projections에 관해 찾아보고 interface 방식으로 적용했다. 

 

public interface OptionNamesValues {

String getName();

String getValue();

}

 

인터페이스는 getName, getValue 메소드를 가진다. 

 

찾은 name과 value는 getName, getValue 메소드를 사용해 꺼낸다. 

 

옵션명(ex.운영체제)는 옵션값(ex.윈도우11, 윈도우10, 미포함...) 과 같이 

옵션명을 키 값으로 하고 옵션값을 value로 하는 맵 형식으로 가공되어져야 한다.  

아래 코드는 service에서 name과 value를 map 형태로 가공해 dto 형태로 변환하는 과정이다.

List<OptionNamesValues> optionNameValue = optionDao.findOptionNameValueMapByCategoryId(categoryId);

Map<String, Set<String>> dto = optionNameValue.stream().collect(Collectors.groupingBy(

OptionNamesValues::getName, Collectors.mapping(OptionNamesValues::getValue, Collectors.toSet())));

 

 

 

#EntityManager 사용하기 

검색 기능을 구현하기 위해 신경을 많이 썼다. 

검색을 할때 들어갈 조건으로는 

카테고리, 옵션(다중선택), 가격범위(최소, 최대), 제품명이 있고 정렬기준도 선택이 가능하다. 

 

문제는 모든 검색이 이 모든 조건을 포함하고 있지 않고 옵션의 수에 제한이 없고 

같은 옵션 내에서 다른 옵션값을 다중 선택한 경우와 다른 옵션을 다중 선택한 경우에 논리연산이 달라진다는 것이다.

 

성능을 위해서는 입력된 조건값에 따라서 필요한 조건만 동적으로 쿼리를 생성하는 방법이 최선이라고 생각했다. 

 

처음에는 queryDSL을 적용하려고 했었는데 

eclipse에서 스프링부트3.13의 환경에서 여러 방법으로 세팅을 시도해보았는데 거의 이틀동안 세팅을 하지 못했다...

더이상 팀원들은 슬슬 서비스 들어가는데 아직 queryDSL을 세팅 못해서 repository만 붙잡고 있을 수가 없어서 

결국 jpql로 하기로 했다...ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ

 

기본적으로 학원의 오라클 db를 사용하였지만 나중에 배포하고 rds로 mysql을 사용할 것을 생각해서 

nativeQuery는 지양하기로 했고 책 사서 jpql을 좀 보니까 오히려 jpql이 객체 지향형쿼리라 

쿼리를 작성하기 더 쉬웠다. 

 

public class OptionSetSearchQuery {

 

private String searchQuery;

 

public void setOrderType(String orderType) {

this.searchQuery = this.searchQuery.replace(":orderType", orderType);

}

 

public OptionSetSearchQuery() {

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 "

+ " Order By :orderType ";

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);// default 설정

}

 

public OptionSetSearchQuery(QueryStringDataDto searchDto) {

if (searchDto.getCategory() != null) {

CategoryDto category = searchDto.getCategory();

 

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p "

+ " join fetch p.categorySets cs " + " join fetch cs.category c " + " WHERE os.stock >0 ";

categoryFilter(category);

} else {

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 ";

}

if (searchDto.getOptionset() != null) {

List<OptionDto.OptionNameValueMapDto> optionset = searchDto.getOptionset();

 

for (int i = 0; i < optionset.size(); i++) {

String key = optionset.get(i).getOptionName();

optionFilter(key, optionset.get(i).getOptionValue());

}

}

if (searchDto.getNameKeyword() != null) {

nameKeyword(searchDto.getNameKeyword());

}

if (searchDto.getMinPrice() != null && searchDto.getMaxPrice() != null) {

priceRange(searchDto.getMinPrice(), searchDto.getMaxPrice());

}

if (searchDto.getMinPrice() != null && searchDto.getMaxPrice() == null) {

onlyMinConstraint(searchDto.getMinPrice());

}

if (searchDto.getMaxPrice() != null && searchDto.getMinPrice() == null) {

onlyMaxConstraint(searchDto.getMaxPrice());

}

 

this.searchQuery += " Order By :orderType ";

if (searchDto.getOrderType() != null) {

String orderType = searchDto.getOrderType();

if (orderType.equals("판매순")) {

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);

} else if (orderType.equals("조회순")) {

setOrderType(OptionSetQueryData.BY_VIEW_COUNT);

} else if (orderType.equals("최신순")) {

setOrderType(OptionSetQueryData.BY_CREATE_TIME);

} else if (orderType.equals("최저가순")) {

setOrderType(OptionSetQueryData.BY_TOTAL_PRICE);

} else {// default

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);

}

} else {

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);

}

}

 

public void categoryFilter(CategoryDto category) {

String category_filter = "";

if (category.getName().equals("전체")) {

category_filter = "AND c.parent.id = :categoryFilter ";

category_filter = category_filter.replace(":categoryFilter", "" + category.getId() + "");

} else {

category_filter = "AND c.id = :categoryFilter ";

category_filter = category_filter.replace(":categoryFilter", "" + category.getId() + "");

}

this.searchQuery += category_filter;

}

 

public void optionFilter(String optionName, List<String> optionValue) {

if (optionValue != null) {

String valueString = "o.value= :optionValue";

if (optionValue.size() == 1) {

valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");

} else if (optionValue.size() > 1) {

valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");

for (int i = 1; i < optionValue.size(); i++) {

valueString += " OR o.value=" + "'" + optionValue.get(i) + "' ";

}

}

String option_filter = "AND EXISTS ( SELECT 1 FROM Options o WHERE o.optionSet = os AND o.name = :optionName AND ("

+ valueString + ") ) ";

option_filter = option_filter.replace(":optionName", "'" + optionName + "'");

this.searchQuery += option_filter;

}

}

 

public void priceRange(int minPrice, int maxPrice) {

String price_range = "AND os.totalPrice between :minPrice and :maxPrice ";

price_range = price_range.replace(":minPrice", String.valueOf(minPrice));

price_range = price_range.replace(":maxPrice", String.valueOf(maxPrice));

this.searchQuery += price_range;

}

 

private void onlyMinConstraint(int minPrice) {

String minConstraint = "AND os.totalPrice >= :minPrice ";

minConstraint = minConstraint.replace(":minPrice", String.valueOf(minPrice));

this.searchQuery += minConstraint;

}

 

private void onlyMaxConstraint(int maxPrice) {

String maxConstraint = "AND os.totalPrice <= :maxPrice ";

maxConstraint = maxConstraint.replace(":maxPrice", String.valueOf(maxPrice));

this.searchQuery += maxConstraint;

}

 

public void nameKeyword(String nameKeyword) {

String name_keyword = "AND LOWER(os.product.name) like LOWER(:nameKeyword) ";

name_keyword = name_keyword.replace(":nameKeyword", "'%" + nameKeyword + "%'");

this.searchQuery += name_keyword;

}

 

public String build() {

return this.searchQuery;

}

 

}

이 클래스는 입력받아 전달받은 데이터를 확인하고 그 데이터에 맞춰 쿼리를 build하는 클래스로 작성하였다. 

 

기본 생성자로

public OptionSetSearchQuery() {

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 "

+ " Order By :orderType ";

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);// default 설정

}

기본 쿼리를 작성하고 

카테고리가 선택되면 

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p "

+ " join fetch p.categorySets cs " + " join fetch cs.category c " + " WHERE os.stock >0 ";

categoryFilter(category);

 

기본쿼리는 필요한 객체들을 join하는 쿼리로 기본 쿼리를 수정하고 카테고리를 수정한다. 

 

String name_keyword = "AND LOWER(os.product.name) like LOWER(:nameKeyword) ";

name_keyword = name_keyword.replace(":nameKeyword", "'%" + nameKeyword + "%'");

 

제품명 키워드가 들어있으면 위와같이 쿼리를 위치에 붙여준다. 

 

public void optionFilter(String optionName, List<String> optionValue) {

if (optionValue != null) {

String valueString = "o.value= :optionValue";

if (optionValue.size() == 1) {

valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");

} else if (optionValue.size() > 1) {

valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");

for (int i = 1; i < optionValue.size(); i++) {

valueString += " OR o.value=" + "'" + optionValue.get(i) + "' ";

}

}

String option_filter = "AND EXISTS ( SELECT 1 FROM Options o WHERE o.optionSet = os AND o.name = :optionName AND ("

+ valueString + ") ) ";

option_filter = option_filter.replace(":optionName", "'" + optionName + "'");

this.searchQuery += option_filter;

}

}

옵션이 선택되면 실행될 코드이다.

 

valueString은 옵션값이 다중선택된 경우 or 연산을 해야하기 때문에 value의 size만큼 or연산을 반복하여 valueString을 완성하고 

name을 붙여 

exists 쿼리를 작성하고 조건문 위치에 붙여준다. 

 

이렇게 작성한 쿼리를 사용할 repository 를 만들어준다. 

@Repository

@RequiredArgsConstructor

public class OptionSetQueryRepository {

@PersistenceContext

private EntityManager em;

 

public List<OptionSet> findByFilter(QueryStringDataDto dataDto){

String jpql = new OptionSetSearchQuery(dataDto).build();

TypedQuery<OptionSet> query = em.createQuery(jpql,OptionSet.class);

return query.getResultList();

}

public List<ProductListOutputDto> findForMemberByFilter(QueryStringDataDto dataDto, String username){

String mainJpql = new OptionSetSearchQuery(dataDto).build();

TypedQuery<OptionSet> query = em.createQuery(mainJpql,OptionSet.class);

String findHeartJpql = "SELECT i.optionSet.id FROM Interest i WHERE i.member.userName= :username";

TypedQuery<Long> heart = em.createQuery(findHeartJpql,Long.class);

heart.setParameter("username", username);

List<Long> heartOptionSetId = heart.getResultList();

List<OptionSet> searchResult = query.getResultList();

List<ProductListOutputDto> finalResult = searchResult.stream().map(t -> {

ProductListOutputDto productDto = new ProductListOutputDto(t);

productDto.setIsInterested(heartOptionSetId.contains(t.getId()));

return productDto;

}).collect(Collectors.toList());

return finalResult;

}

 

}

로그인한 경우 조회한 아이템에 멤버 아이디로 관심상품 여부도 판별해야하기 때문에 메소드를 두가지로 만든다.

 

일단 jpql 쿼리로 결과를 얻기 위해 entityManager가 필요했다. 

EntityManager에 @PersistenceContext 어노테이션을 붙여준다. 

 

QueryStringDataDto는 카테고리, 가격, 키워드 등 조건 중 입력된 값이 들어있고 

그 값을 받아 

String jpql = new OptionSetSearchQuery(dataDto).build();

jpql 쿼리를 생성했다. 

TypedQuery<OptionSet> query = em.createQuery(jpql,OptionSet.class);

entityManager의 createQuery 메서드에 jpql쿼리와 반환받을 타입을 파라메터로 넣는다. 

TypedQuery는 쿼리를 실행하고 결과를 핸들링할 타입을 받을 인터페이스이다. 

쿼리 실행 결과는 getResultList() 메소드로 얻을 수 있다. 

 

728x90

클라이언트는 브라우저에서 요청을 하고 응답을 받는다. 

이때 요청은 서버에서 필요한 데이터를 담아야하고 

서버측에서 응답할땐 클라이언트에게 공개할 데이터만 담아서 보내면 된다. 

일반 사용자는 그저 클릭하고 타자를 입력할 뿐 요청하는 방법, 응답 받아서 확인하는 방법은 알지 못한다. 

 

일반 사용자가 클릭, 키보드 입력으로 어떤 데이터를 보내게 할지,

데이터를 응답으로 보냈을 때 어떻게 전달할지는 개발자가 해야할 일이다. 

 

요청을 보내고 응답을 보내는데는 엔티티의 모든 데이터가 필요하지 않다. 

필요한 정보만 받을 수 있고

 

클라이언트에게 공개하기 싫은 정보를 제외하고 응답을 보낼 수도 있고, 

클라이언트에게 보낼 데이터를 가공할 수도 있다. 

 

요청, 응답을 위해 필요한 데이터를 전달할 객체를 DTO Data Transfer Object 라고 한다. 

 

그래서 Presentation Layer와 Business Layer는 DTO로 데이터를 전달하고 

DB 계층에 접근하여 Data를 처리할 때는 Entity를 이용해야한다. 

 

@Data

@Builder

@AllArgsConstructor

@NoArgsConstructor

public class CategoryDto {

 

@NotNull

private Long id;

@NotBlank

private String name;

private Long parentId;

 

public Category toEntity() {

return Category.builder()

.id(id)

.name(name)

.parent(Category.builder().id(getParentId()).build())

.build();

}

 

public CategoryDto(Category entity){

this.id=entity.getId();

this.name=entity.getName();

if(entity.getParent()!=null&&entity.getParent().getId()!=null) {

this.parentId=entity.getParent().getId();

}

}

@AllArgsConstructor

@NoArgsConstructor

@Data

@Builder

public static class CategorySaveDto {

@NotBlank

private String name;

private Long parentId;

 

public Category toEntity() {

return Category.builder()

.name(name)

.parent(Category.builder().id(parentId).build())

.build();

}

CategorySaveDto(Category entity){

this.name=entity.getName();

this.parentId=entity.getParent().getId();

}

}

}

 

카테고리 DTO이다.

카테고리 엔티티는 ParentCategory 객체를 가지지만 DTO는 parentCategoryId를 가진다. 

만약 엔티티 그대로 서비스로직을 진행하거나 응답으로 보낸다면 

서비스 로직을 수행하기 위해 Category 객체를 생성할때 ParentCategory객체도 필요할 수 있고, 

응답을 하는 과정에서 Category 객체의 필드인 ParentCategory 객체에 참조하면서 순환 참조의 늪에 빠질 수 있다. 

 

카테고리 saveDto는 category를 insert 할때 사용할 dto이다. 

id는 pk로 auto increase할 것이기 때문에 필요하지 않고 name과 parentId만 있으면 된다. 

 

카테고리 DTO 내부에 innerclass로 saveDTO를 static으로 만들어두면 

Category 관련 dto를 CategoryDTO 하나의 클래스내에 정리하여 관리할 수 있다. 

 

 

그리고 각 dto에는 toEntity와 dto 생성자가 존재하는 이는 entity와 dto사이의 변환을 위한 맵퍼라고 생각하면 된다. 

 

이를 편하게 해주는 MapStruct라는 라이브러리가 존재한다. 

이에 관해선 나중에 다뤄보기로 한다. 

 

DTO는 응답을 보낼 데이터를 가공할 수도 있다고 하였다. 

 

public class ProductListOutputDto {//리스트,히트상품,관심,최근상품리스트

private String brand;

private String name;

private String updateTime;

private String pImage;

private Integer totalPrice;

private String totalPriceString;

private Long osId;

@Builder.Default

private List<OptionDto.OptionBasicDto> optionSet = new ArrayList<>();

private Boolean isInterested;

private String optionSetDesc;

 

 

public ProductListOutputDto(OptionSet entity) {

this.totalPriceString=new DecimalFormat("#,###").format(entity.getTotalPrice());

this.brand=entity.getProduct().getBrand();

this.name=entity.getProduct().getName();

this.totalPrice = entity.getTotalPrice();

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

this.updateTime = entity.getUpdateTime().format(formatter);

this.pImage=entity.getProduct().getImg();

this.osId=entity.getId();

this.optionSet = entity.getOptions().stream().map(t -> new OptionDto.OptionBasicDto(t)).collect(Collectors.toList());

this.isInterested=false;

StringBuilder sb = new StringBuilder();

for (OptionBasicDto option : this.optionSet) {

sb.append(option.getName()+":"+option.getValue());

sb.append("/"); // 나머지 값은 '/'

}

String result = sb.toString();

if (result.endsWith("/")) {

result = result.substring(0, result.length() - 1); // 마지막 '/' 제거

}

this.optionSetDesc=result;

}

public ProductListOutputDto(OptionSet entity, String username) {

this.totalPrice = entity.getTotalPrice();

this.brand=entity.getProduct().getBrand();

this.name=entity.getProduct().getName();

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

this.updateTime = entity.getUpdateTime().format(formatter);

this.pImage=entity.getProduct().getImg();

this.totalPriceString=new DecimalFormat("#,###").format(entity.getTotalPrice());

this.osId=entity.getId();

this.optionSet = entity.getOptions().stream().map(t -> new OptionDto.OptionBasicDto(t)).collect(Collectors.toList());

this.isInterested=entity.getInterests().stream().anyMatch(t -> t.getMember().getUserName().equals(username));

StringBuilder sb = new StringBuilder();

for (OptionBasicDto option : this.optionSet) {

sb.append(option.getName()+":"+option.getValue());

sb.append("/"); // 나머지 값은 '/'

}

String result = sb.toString();

if (result.endsWith("/")) {

result = result.substring(0, result.length() - 1); // 마지막 '/' 제거

}

this.optionSetDesc=result;

}

}

 

프로덕트 테이블은 brand, name, pImage만을 갖고  

옵션셋 테이블은 totalPrice, osId를 가지고 

옵션 테이블은 options를 가진다. 

 

하지만 제품 전체 조회 페이지에서 제품의 데이터를 뿌릴때는 

이 세 테이블의 컬럼을 가져올뿐만 아니라 날짜 형식을 설정하고

options 의 name과 value들로 Optionset Desc상세설명 String으로 가공한다.

 

그리고 로그인한 상태인 경우에는 username을 받아 해당 제품이 유저의 관심상품인지 여부도 표시해야한다. 

this.isInterested=entity.getInterests().stream().anyMatch(t -> t.getMember().getUserName().equals(username));

스트림을 이용해 제품에 관심상품으로 등록한 username 중에 로그인한 username과 일치하는지를 체크하여 boolean 타입으로 저장한다. 

 

또한 DTO는 다른 DTO들로 구성할 수도 있다. 

public class UploadProductDto {

private ProductSaveDto product;

private List<OptionDto> options;

private OptionSetCreateDto optionSet;

}

 

 

엔티티와 관련 없이도 필요한 데이터를 객체로 모아서 받을 수도 있다. 

 

public class QueryStringDataDto {

 

private String orderType;

@NotEmpty

@Builder.Default

private List<OptionDto.OptionNameValueMapDto> optionset=new ArrayList<OptionDto.OptionNameValueMapDto>();

private Integer minPrice;

private Integer maxPrice;

private String nameKeyword;

@NotBlank

private CategoryDto category;

@Builder.Default

private Integer firstResult=0;

 

}

 

이 객체는 입력값에 따라 필요한 쿼리를 동적으로 생성하기 위해 

정렬기준, 키워드, 카테고리, 옵션, 최소~최대 가격에 관한 데이터를 입력받아 요청할 때 쓰는 DTO 이다. 

 

 

응답 전용 DTO를 만들수도 있다

 

public class ResponseDto<T> { //HTTP 응답으로 사용할 DTO

private ProductMsgInterface msg;

private List<T> data;//다른 모델의 DTO도 담을 수 있게 제네릭

//보통 여러개의 데이터를 리스트에 담아 처리하기 때문에 리스트

}

 

응답을 할때는 기본적으로 Data를 보내고 상태코드나, 상태 메세지를 보낸다.

data는 List<T> 형태로 하면 여러 타입의 객체를 리스트 형태로 받을 수 있어 

응답할 때 공용으로 사용하기 좋게 하였고 

 

ProductMsgInterface라는 msg를 갖고 있는데 

 

public enum ProductExceptionMsg implements ProductMsgInterface{

FOUND_NO_MEMBER, FOUND_NO_OPTIONSET,FOUND_NO_OPTIONS, FOUND_NO_PRODUCT,

FOUND_NO_CATEGORY, FOUND_NO_RECENTVIEW,FOUND_NO_INTEREST,

ALREADY_EXISTS_RECENTVIEW,ALREADY_EXISTS_INTEREST, IS_NOT_INTERESTEDD, NEED_LOGIN, WRONG_PARAMETER

}

public enum ProductSuccessMsg implements ProductMsgInterface{

ADD_RECENTVIEW, REMOVE_MY_RECENTVIEWS, REMOVE_RECENTVIEW, REMOVE_OLD_RECENTVIEWS, FIND_MY_RECENTVIEWS,

TOP_CATEGORY, CHILDREN_CATEGORY, ADD_CATEGORY, UPDATE_CATEGORY, REMOVE_CATEGORY,

TAP_HEART, UNTAP_HEART, REMOVE_MY_INTERESTS,

FIND_OPTIONSET_BY_ID, UPLOAD_PRODUCT, FIND_OPTION_NAME_VALUES, FOUND_NO_OTHER_OPTIONSETS, FIND_OTHER_OPTIONSETS, SEARCH_PRODUCTS, UPDATE_OPTIONSET, REMOVE_OPTION, REMOVE_OPTIONSET, REMOVE_PRODUCT, IS_MY_INTEREST, MY_INTERESTS, UPDATE_OPTION

}

 

ProductExceptionMsg와 ProductSuccessMsg를 만들어 ProductMsgInterface로 상위 캐스팅하였다. 

 

이렇게 하면 예외가 발생할 때는 예외 메세지를 응답DTO에 넣어줄 수 있고

성공했을 때는 성공메세지를 응답DTO에 넣어줄 수 있다. 

 

 

+ Recent posts