목표
인프런으로 테스트코드를 공부하는 과정에서 학습한 내용을 정리 후 숙지
@MappedSuperclass
package sample.cafekiosk.spring.domain;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
private LocalDateTime createdDateTime;
@LastModifiedDate
private LocalDateTime modifiedDateTime;
}
Java
복사
•
@MappedSuperclass: 객체의 입장에서 공통 매핑 정보가 필요할 때 사용한다.
•
createdDateTime(생성일자), modifiedDateTime(수정일자)는 객체의 입장에서 볼 때 계속 나온다.
•
이렇게 공통 매핑 정보가 필요한 경우 부모 클래스에 선언하고 속성만 상속 받아서 사용하고 싶을 때 @MappedSuperClass 를 사용한다.
package sample.cafekiosk.spring.domain.product;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.domain.BaseEntity;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productNumber;
@Enumerated(EnumType.STRING)
private ProductType type;
@Enumerated(EnumType.STRING)
private ProductSellingStatus sellingStatus;
private String name;
private int price;
}
Java
복사
•
Product를 보면 BaseEntity를 상속하여 엔티티를 생성하는 것을 확인할 수 있다.
Enum
package sample.cafekiosk.spring.domain.product;
import java.util.List;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum ProductSellingStatus {
SELLING("판매중"),
HOLD("판매보류"),
STOP_SELLING("판매중지");
private final String text;
public static List<ProductSellingStatus> forDisplay() {
return List.of(SELLING, HOLD);
}
}
Java
복사
•
예를 들어 상품의 판매상태에 대한 데이터를 관리하고 싶을 때 enum 클래스를 생성하면 효율적으로 처리할 수 있다. enum 클래스를 사용할 때는 보통 영어 대문자를 사용하며 필요하다면 String을 활용하여 위의 방식처럼 처리할 수도 있다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productNumber;
@Enumerated(EnumType.STRING)
private ProductType type;
@Enumerated(EnumType.STRING)
private ProductSellingStatus sellingStatus;
private String name;
private int price;
}
Java
복사
•
enum 같은 경우 만약에 String인 경우 @Enumerated(EnumType.STRING)을 추가하여 enum임을 명시해줘야 엔티티 생성 후 데이터가 정상적으로 처리된다.
Entity Class의 @NoargsConstructor(access = AccessLevel.PROTECTED)
이 글을 작성하기에 앞서 해당 블로그(https://erjuer.tistory.com/106 )의 내용을 참고하였고 내용의 퀄리티가 괜찮아서 작성하신 내용을 간략하게 정리해보았다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseEntity {
... ...
}
Java
복사
먼저 몇 가지 개념에 대해 알아본다.
•
AllargsConstructor
◦
말 그대로 ‘모든 매개변수 생성자’인 것처럼 해당 클래스 내의 모든 변수값을 가진 생성자를 자동으로 만들어 준다.
@Setter
@Getter
@AllArgsConstructor
public class testDto {
private String id;
private String userName;
private String Age;
private String address;
}
// 서로 같다.
@Setter
@Getter
public class testDto {
private String id;
private String userName;
private String Age;
private String address;
public testDto(String id, String userName, String age, String address) {
this.id = id;
this.userName = userName;
this.Age = age;
this.address = address;
}
}
Java
복사
•
NoArgsConstructor
◦
해당 어노테이션의 의미는 말 그대로 ‘아무런 매개변수가 없는 생성자’ 이다.
•
생성자 접근 Level을 다음과 같은 설정값으로 줄 수 있다.
◦
access = AccessLevel.PROTECTED
◦
access = AccessLevel.PRIVATE
왜 Entity Class 에는 @NoArgsConstructor(access = AccessLevel.PROTECTED)을 사용할까?
•
@NoArgsConstructor(access = AccessLevel.PROTECTED)
•
NoArgsConstructor(access = AccessLevel.PRIVATE)
◦
다음과 같은 에러 발생
▪
Class 'StoreEntity' should have [public, protected] no-arg constructor
에러에 대한 Entity 설명을 보면
The entity class must have a no-arg constructor. The entity class may have other constructors as well.
The no-arg constructor must be public or protected.
// Entity 클래스는 매개변수가 없는 생성자의 접근 레벨이 public 또는 protected로 해야 한다.
...
An instance variable must be directly accessed only from within the methods of the entity by the entity instance itself.
// 인스턴스 변수는 직접 접근이 아닌 내부 메소드로 접근해야 한다.
Java
복사
이에 따라 @Entity 선언 후 @NoArgsConstructor에서 접근 Level에 따라 경고가 발생하고 있는 것이다(Compile 시 오류 확인 안 됨). 또한 Entity 클래스 인스턴스 변수는 직접 접근이 아닌 내부 메소드로 접근해야 한다(ex. Getter, Setter 사용).
결과부터 말하자면 원인으 Entity Proxy 조회 때문이다.
음식 엔티티 클래스 FoodEntity(이하 Food), 음식점 클래스 StoreEntity(이하 Store)로 살펴보면 Food와 Store는 N : 1 관계이다.
총 4개의 경우의 수를 살펴본다.
1.
Food와 Store 모두 (access = AccessLevel.PROTECTED)
2.
Food : (access = AccessLevel.PROTECTED) , Store : (access = AccessLevel.PRIVATE)
3.
Food : (access = AccessLevel.PRIVATE) , Store : (access = AccessLevel.PROTECTED)
4.
Food와 Store 모두 (access = AccessLevel.PRIVATE)
조회를 위한 전제 조건은 Proxy를 활용할 것이므로
•
@ManyToOne(fetch = FetchType.LAZY) 이다.
조회 테스트 코드는 다음과 같다.
@SpringBootTest
public class ProxyTest {
@Autowired
private EntityManager em;
@Test
@Transactional
public void proxyTest(){
FoodEntity foodEntity = em.find(FoodEntity.class,5L); // food_id 값이 5L인 데이터를 찾는다.
System.out.println("======= 쿼리 전송 =======");
System.out.println("Food ID : " +foodEntity.getFoodId());
System.out.println("Food Name : " + foodEntity.getFoodName());
System.out.println("Food Calorie : " + foodEntity.getFoodCalorie());
System.out.println(foodEntity.getStoreEntity().getClass());
System.out.println("======= 쿼리 결과 =======");
System.out.println("///////////////////////////////");
System.out.println("///////////////////////////////");
System.out.println("======= Store 데이터 =======");
System.out.println("Store ID : " + foodEntity.getStoreEntity().getStoreId());
System.out.println("Store Name : " + foodEntity.getStoreEntity().getStoreName());
System.out.println("Store Address : " + foodEntity.getStoreEntity().getAddress());
System.out.println("Store Number : " + foodEntity.getStoreEntity().getStoreNumber());
em.close();
}
Java
복사
1.
Food와 Store 모두 (access = AccessLevel.PROTECTED) : 정상 동작
======= 쿼리 전송 =======
Food Proxy ? Entity : class com.jpastudy.ms.domain.Entity.FoodEntity // 실제 Entity
Food ID : 5
Food Name : 항정살
Food Calorie : 500
Store Proxy ? Entity : class com.jpastudy.ms.domain.Entity.StoreEntity$HibernateProxy$LDEXNcvd
// Proxy
======= 쿼리 결과 =======
Java
복사
콘솔에서 FoodEntity 조회시 실제 Entity가 조회되지만 StoreEntity 조회시 HibernateProxy 클래스를 사용하는 것을 확인 할 수 있다.
2. Food : (access = AccessLevel.PROTECTED) , Store : (access = AccessLevel.PRIVATE) : 오류 발생
오류 로그는 다음과 같다
HHH000143: Bytecode enhancement failed because no public, protected or package-private default constructor was found for entity: com.jpastudy.ms.domain.Entity.StoreEntity. Private constructors don't work with runtime proxies!
Food 조회 시 Store는 Proxy 객체로 조회되는데 Store의 접근 권한이 PRIVATE이므로 Proxy 객체 생성하는 로직에서 오류가 발생하였다.
3. Food : (access = AccessLevel.PRIVATE) , Store : (access = AccessLevel.PROTECTED) : 정상 동작
2022-02-06 11:27:07.204 DEBUG 18716 --- [ main] org.hibernate.SQL :
select
foodentity0_.food_id as food_id1_0_0_,
foodentity0_.food_calorie as food_cal2_0_0_,
foodentity0_.food_name as food_nam3_0_0_,
foodentity0_.store_id as store_id4_0_0_
from
tb_test_food foodentity0_
where
foodentity0_.food_id=?
2022-02-06 11:27:07.245 INFO 18716 --- [ main] p6spy : #1644114427245 | took 17ms | statement | connection 2| url jdbc:mysql://localhost/test?serverTimezone=UTC
select foodentity0_.food_id as food_id1_0_0_, foodentity0_.food_calorie as food_cal2_0_0_, foodentity0_.food_name as food_nam3_0_0_, foodentity0_.store_id as store_id4_0_0_ from tb_test_food foodentity0_ where foodentity0_.food_id=?
select foodentity0_.food_id as food_id1_0_0_, foodentity0_.food_calorie as food_cal2_0_0_, foodentity0_.food_name as food_nam3_0_0_, foodentity0_.store_id as store_id4_0_0_ from tb_test_food foodentity0_ where foodentity0_.food_id=5;
======= 쿼리 전송 =======
Food Proxy ? Entity : class com.jpastudy.ms.domain.Entity.FoodEntity // 실제 Entity 객체
Food ID : 5
Food Name : 항정살
Food Calorie : 500
Store Proxy ? Entity : class com.jpastudy.ms.domain.Entity.StoreEntity$HibernateProxy$3YCd837F
// Proxy 객체
Java
복사
그 이유는 Food는 em.find를 통해 실제 Entity로 조회되었기 때문이다. 그리고 Store는 Protected이므로 정상적으로 Proxy 객체가 생성된 것을 확인할 수 있다.
4. Food와 Store 모두 (access = AccessLevel.PRIVATE) : 오류 발생
Food는 실제 Entity 객체가 생성되고 조회 쿼리가 발생하였으나 Store는 Private 선언으로 Proxy 객체 생성에 오류가 발생하였다.
Food 또한 Proxy로 조회하는 getReference를 활용하면 어떨까?
당연하게도 Food 또한 Proxy 객체 생성에 오류가 발생한다.
•
Food와 Store 모두 (access = AccessLevel.PRIVATE) 로 조회하지만 Proxy가 아닌 실제 Entity 객체로 조회한다면 어떻게 될까? 즉, EAGER (즉시로딩)
◦
@ManyToOne(fetch = FetchType.EAGER)
// Food Entity 클래스
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "store_id")
public StoreEntity storeEntity;
Java
복사
즉시 로딩일 경우 Proxy 객체가 생성되는 것이 아닌 Entity를 바로 조회하기 때문에 오류가 발생하지 않는다.
이번에 작성한 내용을 통해 Entity의 access = AccessLevel 은 Proxy와 관련이 되어 있다는 것을 알 수 있었다. 물론 Public으로 설정시에도 Proxy 객체 생성이 가능하지만 Entity 외부 접근을 차단하는 Protected를 활용하는 것이 안정성 측면에서 더 낫다는 사실도 알게 되었다.
Response 관련 코드 정리
•
ProductResponse - 객체 매개변수를 가진 of 메소드를 생성하여 서비스 단에서 map을 통해 처리한다.
package sample.cafekiosk.spring.domain.product.response;
import lombok.Builder;
import lombok.Getter;
import sample.cafekiosk.spring.domain.product.Product;
import sample.cafekiosk.spring.domain.product.ProductSellingStatus;
import sample.cafekiosk.spring.domain.product.ProductType;
@Getter
public class ProductResponse {
private Long id;
private String productNumber;
private ProductType type;
private ProductSellingStatus sellingStatus;
private String name;
private int price;
@Builder
private ProductResponse(Long id, String productNumber, ProductType type,
ProductSellingStatus sellingStatus, String name, int price) {
this.id = id;
this.productNumber = productNumber;
this.type = type;
this.sellingStatus = sellingStatus;
this.name = name;
this.price = price;
}
public static ProductResponse of(Product product) {
return ProductResponse.builder()
.id(product.getId())
.productNumber(product.getProductNumber())
.type(product.getType())
.sellingStatus(product.getSellingStatus())
.name(product.getName())
.price(product.getPrice())
.build();
}
}
Java
복사
•
ProductService
package sample.cafekiosk.spring.api.service.product;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import sample.cafekiosk.spring.domain.product.Product;
import sample.cafekiosk.spring.domain.product.ProductRepository;
import sample.cafekiosk.spring.domain.product.ProductSellingStatus;
import sample.cafekiosk.spring.domain.product.response.ProductResponse;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public List<ProductResponse> getSellingProducts() {
List<Product> products = productRepository.findAllBySellingStatusIn(
ProductSellingStatus.forDisplay());
return products.stream()
.map(ProductResponse::of)
.collect(Collectors.toList());
}
}
Java
복사