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

Tool : ERDCloud 

https://www.erdcloud.com/

 

ERDCloud

Draw ERD with your team members. All states are shared in real time. And it's FREE. Database modeling tool.

www.erdcloud.com

 

 

프로젝트 전체 테이블

 

 

프로덕트 관련 테이블 

 

Product 테이블 : 제품의 기본 모델에 관한 정보를 담은 테이블

Option 테이블 : 제품이 가질 수 있는 옵션의 정보를 담은 테이블

OptionSet 테이블 : 제품에 옵션들의 정보를 포함하여 하나의 상품으로 간주되어 카트와 주문에 포함될 테이블

Category 테이블 : 카테고리 정보를 담은 테이블

CategorySet 테이블 : 카테고리와 프로덕트를 연결해주는 테이블 

Interest, RecentView 테이블 : 관심상품, 최근 본 상품 테이블 

 

-카테고리 테이블 

카테고리 테이블은 카테고리 이름, 그리고 카테고리 테이블을 셀프 참조하여 부모 카테고리를 가질 수 있어

계층형으로 구성할 수 있게 하였고 

카테고리와 옵션셋 테이블을 다대다 맵핑하여 

하나의 옵션셋은 여러개의 카테고리를 가질 수 있게 하였다. 

 

@Data

@Builder

@AllArgsConstructor

@NoArgsConstructor

@Entity

public class Category {//셀프 참조하는 오너테이블, 카테고리셋과는 종속테이블

@Id

@GeneratedValue(strategy = GenerationType.SEQUENCE)

private Long id; //pk

private String name; //카테고리 이름

 

@JoinColumn(name="parent", nullable = true)

@ManyToOne

@ToString.Exclude

private Category parent; //부모 카테고리

 

@OneToMany(mappedBy = "parent")

@Builder.Default

@ToString.Exclude

private List<Category> childTypes= new ArrayList(); //자식 카테고리들

 

@OneToMany(mappedBy = "category")

@Builder.Default

@ToString.Exclude

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

//다대다 맵핑을 위한 categorySet과 관계 설정

}

 

-프로덕트 테이블 

public class Product extends BaseEntity {//제품의 기본 모델 정보

@Id

@GeneratedValue(strategy = GenerationType.SEQUENCE)

private Long id;//pk

private String name;//제품명

private String brand;//브랜드

private Integer price;//기본 가격

private String descImage;//설명 이미지 파일

private String prevImage;//디테일이미지

private String img;//제품 이미지

 

@OneToMany(mappedBy = "product",fetch = FetchType.EAGER)

@Builder.Default

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

//하나의 제품은 부모카테고리, 자식카테고리 여러개를 가질 수 있다.

//예를 들어, 컴퓨터, 일체형PC, 브랜드PC ...

 

@ToString.Exclude

@OneToMany(mappedBy = "product",cascade = {CascadeType.REMOVE,CascadeType.PERSIST},orphanRemoval = true)

@Builder.Default

private List<OptionSet> optionSets = new ArrayList<>();

 

}

여기서 Product가 상속받은 BaseEntity는 createTime과 updateTime을 가진 엔터티로 

@MappedSuperclass

public class BaseEntity {

@CreationTimestamp

@Column(updatable = false)

@ToString.Exclude

private LocalDateTime createTime;//데이터 생성시간

@UpdateTimestamp

private LocalDateTime updateTime;//데이터 갱신시간

}

데이터의 관리를 위해 필요한 기본적인 정보를 가지는 superclass로 다른 엔터티들이 상속받아 사용할 수 있게 

@MappedSuperclass 어노테이션을 사용해 테이블로 생성되지 않고 상속하는 역할만 할 수 있게 하였다. 

 

 

프로덕트는 제품의 기본 모델에 관한 정보를 담은 테이블로 제조사, 제품 이미지, 기본 가격 등에 관한 정보를 담고 있다. 

프로덕트 테이블에 대해 고민을 많이 했는데 

컴퓨터와 같이 하나의 모델에 대해서도 다양한 옵션이 선택될 수 있고 선택된 옵션에 따라 가격이 변동 될 수 있기에 

기본 모델을 product로 하고 거기에 options을 더한 optionset을 하나의 완전한 상품으로 구상하였다. 

 

그래서 cart, orderitem, 관심상품, 최근 본 상품 등에 들어갈 제품은 product가 아닌 optionset이 되고, 

optionset이 재고, 판매량, 조회수와 같은 정보를 갖게 된다. 

실제로 제품 상세 페이지도 product가 아닌 optionset의 상세 페이지가 된다. 

 

 

public class Options {//옵션셋FK를 가지는 오너테이블

@Id

@GeneratedValue(strategy = GenerationType.SEQUENCE)

private Long id;//pk

 

private String name; //옵션명

private String value; //옵션값

private Integer extraPrice;

//해당 옵션이 옵션셋에 등록될 경우 프로덕트의 총 가격에 추가금

@ManyToOne(fetch = FetchType.EAGER)

@JoinColumn(name = "optionSetId")

@ToString.Exclude

private OptionSet optionSet;//옵션셋 FK

}

 

여기서 옵션과 옵션셋 테이블을 한번 더 분리한 이유는 

컴퓨터만을 product의 대상으로 삼고 있지 않고 다양한 전자제품을 대상으로 하기 때문에 

options가 셀 수 없이 많아질 수밖에 없고 만약 optionset에 그 많은 options들을 nullable 컬럼으로 만들어 두는 것은 매우 비효율적이라고 생각했다. 

그래서 옵션 설정에 자유도를 높이기 위해 options 테이블을 분리하였다. 

 

옵션셋에 totalPrice를 두는 것에 관해서 많은 고민을 했었다. 

원래는 기본 product의 price에 options의 extraPrice들을 모두 더해 나온 값을 표시하면 된다고 생각했는데 

제품 리스트를 조회할 때마다

매 상품마다 options를 모두 뽑아 가격 연산하는 과정을 거치는 작업은 올바른 작업은 아니라고 판단하였고 

옵션셋에 총가격 컬럼을 하나 추가하는 편이 훨씬 경제적이라고 판단하여

제품을 insert 하는 과정에서 extraPrice들을 모두 더해 optionset의 totalPrice의 컬럼에 값을 대입해주기로 하였다. 

 

 

- 관심상품, 최근 본 상품 

관심상품과 최근 본 상품은 멤버와 옵션셋을 참조키로 가진다. 그리고 최근 본 상품에는 30일 이후 자동으로 삭제되는 서비스를 구현한다. 

 

@Entity

@Data

@AllArgsConstructor

@NoArgsConstructor

@Builder

@EqualsAndHashCode(callSuper = true)

@ToString(callSuper = true)

@Table(name = "interest", uniqueConstraints = @UniqueConstraint(columnNames = {"memberId","optionSetId"}))

public class Interest extends BaseEntity{//관심상품

//유저와 옵션셋을 이어주는 중간테이블

@Id

@GeneratedValue(strategy = GenerationType.SEQUENCE)

private Long id;//pk

 

@JoinColumn(name = "memberId")

@ManyToOne

private Member member;// 유저FK

 

@JoinColumn(name = "optionSetId")

@ManyToOne

@ToString.Exclude

private OptionSet optionSet;// 옵션셋FK

 

}

 

관심상품과 최근 본 상품은 멤버와 옵션셋의 복합키로써 유니크 제약조건을 가져야한다.

+ Recent posts