해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요.

서비스의 데이터 구조 설계 시 JPA 엔티티 상속 전략을 활용해서 확장성과 다형성 등 객체지향의 이점을 활용할 수 있다.
예를 들어서 상위 타입인 쿠폰 엔티티가 있고, 하위 타입인 퍼센트 할인 엔티티, 고정 할인 엔티티가 있다고 가정했을 때, 쿠폰 적용 로직에 대해서 다형성 전략을 활용해 구현할 수 있다.

이 글에 정리된 예시는 JPA 엔티티 상속 전략 활용 방법 및 추상 클래스를 통한 다형성 활용 방법에 대한 내용이다.
우선 JPA 상속 전략의 종류에 대해서 알아보자

1. JPA 상속 전략 종류

JPA는 JOINED, SINGLE_TABLE, TABLE_PER_CLASS의 세 가지 상속 전략을 지원하며, 각각의 전략은 데이터베이스에 서로 다른 방식으로 테이블을 구성한다.

JOINED

  • 상속 구조를 구성하기 위해 공통 속성을 저장하는 상위 테이블과 개별 속성을 저장하는 하위 테이블로 구성된다.
  • 장점
    • 데이터 정규화, 필요한 데이터만 조회 가능, 확장 용이
  • 단점
    • 데이터 저장 시, 상위 테이블과 하위 테이블에 데이터를 저장하며, 데이터 조회 시 하위 테이블의 개수 만큼 left join을 해서 쿼리 성능이 저하될 수 있다. 

SINGLE_TABLE

  • 모든 데이터가 하나의 테이블에 저장된다.
  • 장점
    • 쿼리가 단순하고 성능이 좋다
  • 단점
    • 테이블에 많은 열이 생기고, 불필요한 컬럼이 다수 존재할 수 있다. 
    • 데이터 응집도가 떨어짐 

TABLE_PER_CLASS

  • 각 엔티티 클래스가 독립적인 테이블로 생성되며, 상속 구조를 반영하지 않는다.
  • 장점
    • 간단한 쿼리, 테이블 간의 의존성 없음
  • 단점
    • 동일한 컬럼을 여러 테이블에 중복 저장, 다형성 쿼리에서 성능 저하.

상속 전략에 따라서 테이블의 구조가 변경되며, 실제 비즈니스 로직에 영향은 없다. 
상황에 맞는 전략을 활용해보면 좋을 것 같다. 예제는 JOINED 전략을 활용해보도록 하자
개인적으로 실무에서는  JOINED, SINGLE_TABLE 두 전략을 활용할 수 있을 것 같다. 

JOINED를 활용하면 정규화 측면에서 필요한 데이터만 테이블에 저장되기 때문에 테이블 가독성이 및 데이터 응집도가 올라가지만 테이블 종류가 많아지면 조회 성능이 낮아진다는 단점이 있다. SINGLE_TABLE은 한개 테이블만 활용하기 때문에 조회 속도 및 테이블 관리에 용이하지만 서로 관련 없는 데이터들이 다수 존재하기 때문에 가독성과 응집도가 낮아지며, 컬럼이 많아질수록 데이터간 관련성을 확인하기 여러울 수 있다.    

2. JPA 상속 전략 예제

1) 테이블 구조

  • coupon
    • 모든 쿠폰의 상위 테이블이며 쿠폰 공통 정보를 저장한다
    • 모든 하위 테이블은 쿠폰 테이블의 기본키를 PK로 가지는 식별관계이다.
    • coupon_type으로 쿠폰 정보를 구별한다
  • fixed_discount_coupon
    • 고정 할인 쿠폰이며, 할인 금액 컬럼을 갖고 있다. 
  • percent_discount_coupon
    • 퍼센트 할인 쿠폰이며 할인 퍼센트 컬럼을 갖고 있다.

2) JPA 쿠폰 엔티티 

Money

public record Money(double amount) {
    public static final Money ZERO = Money.of(0);

    public static Money of(double amount) {
        return new Money(amount);
    }

    public Money calculatePercent(double percent) {
        return Money.of(this.amount * percent);
    }

    public Money minus(Money money) {
        return Money.of(this.amount - money.amount);
    }
}
  • 금액 관련 Value Object이다. 불변 객체이며, 계산 관련 메소드 실행 시 계산 결과가 반영된 새로운 Money 인스턴스를 반환한다

Coupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "coupon_type")
public abstract class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "coupon_type", insertable = false, updatable = false)
    @Enumerated(EnumType.STRING)
    private CouponType type;

    protected Coupon(String name, CouponType type) {
        this.name = name;
        this.type = type;
    }

    public abstract Money use(Money price);
}
  • 쿠폰의 공통 정보를 갖는 클래스이다. 추상클래스이며, 쿠폰의 공통 기능을 추상메소드로 정의했다.
  • @Inheritance(strategy = InheritanceType.JOINED)는 부모 엔티티로 선언한다는 것을 의미하며 상속 전략으로 JOINED 속성을 적용했다.
  • @DiscriminatorColumn(name = "coupon_type")은 하위 타입을 구분하는데 필요한 컬럼의 이름을 명시하는 어노테이션이다.
  • 쿠폰 사용 메소드를 추상메소드로 정의했다. Money 타입의 price 변수를 매개변수로 받아서 쿠폰이 적용되었을 때 결과 값을 반환한다
  • CouponType 변수 선언은 선택사항이다. 별도로 선언하지 않아도  @DiscriminatorColumn(name = "coupon_type") 와 같이 어노테이션을 선언했기 때문에 자동적으로 생성된다.
  • couponType 변수는 단순 조회용 변수이므로 insertable,updatable 속성을 false로 설정했다.

FixedDiscountCoupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@DiscriminatorValue("FIXED_DISCOUNT")
public class FixedDiscountCoupon extends Coupon {

    @AttributeOverride(name = "amount", column = @Column(name = "discount_amount"))
    @Embedded
    private Money discountAmount;

    private FixedDiscountCoupon(String name, Money discountAmount) {
        super(name, CouponType.FIXED_DISCOUNT);
        this.discountAmount = discountAmount;
    }

    public static FixedDiscountCoupon create(String name, Money discountAmount) {
        return new FixedDiscountCoupon(name, discountAmount);
    }

    @Override
    public Money use(Money price) {
        return price.minus(discountAmount);
    }
}
  • 고정 할인 쿠폰 엔티티이다. Coupon 엔티티를 상속받으며 @DiscriminatorValue("FIXED_DISCOUNT") 어노테이션의 값을 통해서 couponType이 저장된다.
  • 여기서 핵심은 추상메소드를 오버라이딩 한 use 메소드이다.
  • 추상 클래스에 대한 다형성을 활용하기 위해 use 메소드를 재정의했다. 
  • 각 쿠폰마다 할인 방식이 다르기 때문에 각 쿠폰마다 추상메소드를 재정의한다.
  • 고정할인 쿠폰은 정해진 값 만큼 할인되기 때문에 매개변수로 전달된 price에 discountAmount 값을 차감한 결과를 반환한다.

PercentDiscountCoupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@DiscriminatorValue("PERCENT_DISCOUNT")
public class PercentDiscountCoupon extends Coupon {


    @Column(name = "discount_percent")
    private double discountPercent;

    private PercentDiscountCoupon(String name, double discountPercent) {
        super(name, CouponType.PERCENT_DISCOUNT);
        this.discountPercent = discountPercent;
    }

    public static PercentDiscountCoupon create(String name, double discountPercent) {
        return new PercentDiscountCoupon(name, discountPercent);
    }

    @Override
    public Money use(Money price) {
        return price.minus(price.calculatePercent(discountPercent));
    }
}
  • 퍼센트 할인 쿠폰이며, 저장된 퍼센트 만큼 금액을 할인한다.
  • use 추상 메소드를 재정의해서 다형성 패턴을 활용할 수 있게 했다. 

CouponRepository

public interface CouponRepository extends JpaRepository<Coupon, Long> {
}
  • 쿠폰을 저장하거나 조회할 때 사용할 수 있는 CouponRepository를 정의했다.

CouponTest

class CouponTest {
    @Test
    public void 고정할인_쿠폰_사용_테스트() {
        //given
        Coupon 고정_할인_쿠폰 = FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000));

        //when
        Money discountPrice = 고정_할인_쿠폰.use(Money.of(2000));

        //then
        Assertions.assertThat(discountPrice).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_사용_테스트() {
        //given
        Coupon 퍼센트_할인_쿠폰 = PercentDiscountCoupon.create("퍼센트_할인_쿠폰", 0.1);

        //when
        Money discountPrice = 퍼센트_할인_쿠폰.use(Money.of(2000));

        //then
        Assertions.assertThat(discountPrice).isEqualTo(Money.of(1800));
    }
}
  • 고정 할인 쿠폰, 퍼센트 할인 쿠폰을 생성 후 Coupon 클래스에 저장했다.
  • 추상 클래스는 하위 클래스의 인스턴스를 저장할 수 있고 추상메소드 호출 시 각 하위클래스에서 재정의한 메소드의 구현이 동작하기 때문에 캡슐화 및 다형성을 활용할 수 있다.

CouponRepositoryTest

@DataJpaTest
class CouponRepositoryTest {

    @Autowired
    public CouponRepository couponRepository;

    @Autowired
    public TestEntityManager testEntityManager;


    @Test
    public void 고정할인_쿠폰_저장() {
        Coupon coupon = couponRepository.save(FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000)));

        Assertions.assertThat(coupon.getId()).isNotNull();
        Assertions.assertThat(coupon.getType()).isEqualTo(CouponType.FIXED_DISCOUNT);
        Assertions.assertThat(((FixedDiscountCoupon) coupon).getDiscountAmount()).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_저장() {
        Coupon coupon = couponRepository.save(PercentDiscountCoupon.create("퍼센트 할인 쿠폰", 0.5));

        Assertions.assertThat(coupon.getId()).isNotNull();
        Assertions.assertThat(coupon.getType()).isEqualTo(CouponType.PERCENT_DISCOUNT);
        Assertions.assertThat(((PercentDiscountCoupon) coupon).getDiscountPercent()).isEqualTo(0.5);
    }

    @Test
    public void 고정할인_쿠폰_조회() {
        Coupon coupon = couponRepository.save(FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000)));
        testEntityManager.flush();
        testEntityManager.clear();

        Coupon findCoupon = couponRepository.findById(coupon.getId()).get();

        Assertions.assertThat(findCoupon.getId()).isNotNull();
        Assertions.assertThat(findCoupon.getType()).isEqualTo(CouponType.FIXED_DISCOUNT);
        Assertions.assertThat(((FixedDiscountCoupon) findCoupon).getDiscountAmount()).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_조회() {
        Coupon coupon = couponRepository.save(PercentDiscountCoupon.create("퍼센트 할인 쿠폰", 0.1));
        testEntityManager.flush();
        testEntityManager.clear();

        Coupon findCoupon = couponRepository.findById(coupon.getId()).get();

        Assertions.assertThat(findCoupon.getId()).isNotNull();
        Assertions.assertThat(findCoupon.getType()).isEqualTo(CouponType.PERCENT_DISCOUNT);
        Assertions.assertThat(((PercentDiscountCoupon) findCoupon).getDiscountPercent()).isEqualTo(0.1);
    }
}
  • 쿠폰을 저장하거나 조회에 대한 테스트이다. 단건 쿠폰 조회 시 Coupon 클래스를 반환하기 때문에 하위 클래스의 상세 정보를 알기 위해서 다운 캐스팅을 해야한다. 실제 로직에서는 CouponType값으로 if문 혹은 switch문을 활용해서 쿠폰 상세 정보를 알 수 있다. 
  • 추상화하는 이유는 코드의 복잡성을 낮추고, 다형성을 활용해서 객체지향 관점에서 OCP(개방 폐쇄 원칙)를 준수한 코드를 만들 수 있다.

3. 쿠폰 생성을 위한 추상 팩토리 메소드 패턴 

  • 쿠폰 생성 시 여러가지 방법이 있지만 추상 팩토리 메소드를 활용해서 간편하게 구현할 수 있다.
public record CouponCreateCommand(CouponType couponType, String couponName, Double fixedDiscountAmount,
                                  Double percentDiscountAmount) {
}

public interface CouponFactory {
    Coupon create(CouponCreateCommand command);

    CouponType getType();
}

@Component
public class FixedDiscountCouponFactory implements CouponFactory {

    @Override
    public Coupon create(CouponCreateCommand command) {
        return FixedDiscountCoupon.create(command.couponName(), Money.of(command.fixedDiscountAmount()));
    }

    @Override
    public CouponType getType() {
        return CouponType.FIXED_DISCOUNT;
    }
}

@Component
public class PercentDiscountCouponFactory implements CouponFactory {

    @Override
    public Coupon create(CouponCreateCommand command) {
        return PercentDiscountCoupon.create(command.couponName(), command.percentDiscountAmount());
    }

    @Override
    public CouponType getType() {
        return CouponType.PERCENT_DISCOUNT;
    }
}
  • CouponFactory 인터페이스에 create 메소드를 선언하고, CouponFactory를 구현한 PercentDiscountCouponFactory, FixedDiscountCouponFactory를 선언한다.
  • 여기서 트레이드 오프는 CouponCreateCommand에 모든 쿠폰에 대한 정보가 선언되어 있다는 점이다.
  • 쿠폰 종류가 늘어남에 따라 변수의 종류가 많아진다. 이 부분은 각 쿠폰 생성에 대한 API 엔드포인트를 추가하고 쿠폰 타입 별로 쿠폰 생성 서비스를 추가하면 해결할 수 있다. 
@Service
public class CouponCreateUseCase {
    private final Map<CouponType, CouponFactory> couponFactoryMap;
    private final CouponRepository couponRepository;

    public CouponCreateUseCase(List<CouponFactory> couponFactories, CouponRepository couponRepository) {
        this.couponFactoryMap = couponFactories.stream()
            .collect(Collectors.toMap(CouponFactory::getType, Function.identity()));
        this.couponRepository = couponRepository;
    }


    @Transactional
    public Long create(CouponCreateCommand command) {
        Coupon coupon = Optional.ofNullable(couponFactoryMap.get(command.couponType()))
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰 생성 팩토리"))
            .create(command);

        return couponRepository.save(coupon).getId();
    }
}

 

  •  CouponFactory를 구현한 Bean을 List로 주입받고 CouponFactory.getType() 의 Enum 값을 Key로 Bean 자체를 Value로 정의한 Map을 생성한다.
  • 그리고 command의 CouponType으로 CouponFactory를 검색하고 Coupon 엔티티를 생성한 다음 Coupon을 저장하는 유스케이스이다. 

결론

JPA 상속 전략을 활용한 예제를 구현해보았다. 
중요한 점은 JPA 상속 전략을 사용 방법을 아는 것 보다 어떻게 엔티티를 설계하고 활용할 것인지 고민하는 것이 더 중요하다.

JPA 상속 전략에 매몰되어서 잘못 사용할 경우 오히려 복잡성이 증가하는 결과를 초례할 수 있다. 
해당 전략이 활용되면 좋은 케이스 인지 잘 판단해서 사용하자

 

1. Querydsl 이란?

JPA를 이용해서 복잡한 쿼리나 동적 쿼리를 작성할 경우 소스코드가 지저분해지는 경우가 많습니다.
이때 Querydsl 라이브러리를 이용해면 쿼리문자가 아니라 자바코드로 쿼리를 작성할 수 있습니다.

 

2. Querydsl의 장점

1. 자바코드로 쿼리를 작성하기 때문에 가독성이 좋아집니다.
2. 문법 오류를 컴파일 시점에서 잡아줍니다.
3. 동적 쿼리를 쉽게 만들 수 있습니다.

 

3. Querydsl 설정하기

테스트 환경은 다음과 같습니다. (버전을 모두 맞춰 주어야합니다!)
spring boot 2.2.2 RELEASE
maven
java 11
h2

1) pom.xml

...

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
            <version>4.1.4</version>
        </dependency>

        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-jpa</artifactId>
            <version>4.1.4</version>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
 
 ...
    
   <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.mysema.maven</groupId>
                <artifactId>apt-maven-plugin</artifactId>
                <version>1.1.3</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>process</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>target/generated-sources/java</outputDirectory>
                            <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
...
querydsl-apt : Q클래스 생성 라이브러리입니다.
querydsl-jpa : querydsl 관련 라이브러리입니다.

apt-maven-plugin을 이용해서 target/generated-sources/java package에 엔티티 별로 Q클래스를 생성합니다.

 

2) src/main/resources/application.properties

spring.datasource.username=sa
spring.datasource.password=
spring.datasource.url=jdbc:h2:mem:mydb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.hikari.jdbc-url=jdbc:h2:mem:mydb
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.show_sql=true
h2 database 설정 및 hibernate 설정을 해줍니다.

 

3) Member.java

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String account;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name;

}
Member 엔티티를 정의하고 mvn package를 하면 target/generated-sources/java package에 엔티티 별로 Q클래스가 생성됩니다. 
Q클래스는 querydsl을 이용해 엔티티의 컬럼에 접근하거나 다양한 쿼리구문을 작성할 때 사용합니다. 

 

4) MemberTest.java

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
@Commit
class MemberTest {

    @Autowired
    private EntityManager em;
    final String name = "member1";

    @BeforeEach
    public void setup(){
        Member member = Member.builder()
                .account("member@email.com")
                .name(name)
                .password("1234")
                .build();

        em.persist(member); // 미리 member를 생성한다.
    }

    @Test
    public void membersSearchTest() {
        JPAQueryFactory query = new JPAQueryFactory(em);
        QMember qMember = QMember.member;


        //querydsl을 이용해 java구문으로 query를 작성한다.
        Member member = query
                .select(qMember)
                .from(qMember)
                .where(qMember.name.eq(name))
                .fetchOne();

        assertEquals(member.getName(), name);
    }

}
Querydsl을 사용하려면 EntityManager객체를 생성자로 받은 JPAQueryFactory 객체가 필요합니다.
위 테스트를 진행하면 다음과 같은 쿼리가 DB로 전송됩니다.

---------------------------------------------------------
   select
        member0_.id as id1_0_,
        member0_.account as account2_0_,
        member0_.name as name3_0_,
        member0_.password as password4_0_ 
    from
        member member0_ 
    where
        member0_.name=?
---------------------------------------------------------

 

1. 프록시란?

프록시는 대리 응답, 중계 등 다양한 의미로 사용되는 용어입니다.
프록시는 JPA 기본 스펙은 아니지만 Hibernate에서 프록시라는 기능을 제공합니다.
프록시는 지연로딩(Lazy Loading) 기능을 위해 사용되며 지연로딩이란 연관 매핑 객체가 있을 때, 해당 객체의 조회를 미루고, 해당 객체를 사용할 때 조회하는 것을 의미합니다. 

 

2. 프록시 설명 및 사용 예제

원활한 테스트를 위해 미리 엔티티들을 생성해놓겠습니다. Member와 Team 엔티티가 있고 다대 일 관계입니다.
@Entity
public class Team {

    @Id
    @GeneratedValue
    private Integer id;

    private String name;
    
    //getter,setter 생략
}

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
    
    //getter, setter 생략
}

 

1) 프록시 객체 조회

public class JpaMain {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("my-persistence");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {

            Member member = new Member();
            member.setName("kim");
            em.persist(member);

            em.flush(); // 쓰기 지연 쿼리 실행
            em.clear(); // 영속성 컨텍스트 초기화

            //Member 프록시 객체 조회
            Member findMember = em.getReference(Member.class, member.getId());

            System.out.println("before");
            findMember.getName(); // name 조회 시 DB 조회 쿼리 실행
            System.out.println("after");

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();
    }
}
프록시 객체를 조회 할 때 em.getReference(Class, id) 메소드를 사용합니다. 
em.getReference 메소드를 실행하면 ID값만 갖고 있는 프록시 객체가 생성되고 영속성 컨테스트에 저장됩니다.
그리고 프록시 객체의 특정 데이터에 접근 할 때 DB로 데이터를 조회해 데이터를 채워넣습니다.
이러한 조회 방식을 LAZY 조회라고 합니다. 

위 코드는 Member 프록시 객체를 조회 한 후 member의 name을 호출하는 예제입니다. 그리고 name을 호출 할 때 member를 조회하는 것을 알 수 있습니다.
// findMember.getName(); 실행 시 member 객체를 조회한다. 

before
Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.name as name2_0_0_,
        member0_.team_id as team_id3_0_0_ 
    from
        Member member0_ 
    where
        member0_.id=?
after

 

2) 프록시 객체의 구조 및 초기화

프록시 객체의 구조

프록시 객체는 엔티티를 상속받습니다. 그리고 프록시 객체에 Entity target 멤버변수가 있는데, 이 target 변수로 원본 엔티티에 접근합니다. 프록시라는 이름이 붙어진 것도 이때문입니다.

Member 엔티티로 예를 들자면 em.getReference(Member.class, 1L); 실행 시 Member 프록시 객체를 반환하고, target은 null로 초기화 됩니다. 그리고 member.getName() 메소드 실행 시 member 데이터를 조회하고 영속성 컨텍스트에 저장됩니다. 그리고 target 변수는 영속성 컨텍스트의 member 객체를 참조하게 되고, target.getName()과 같이 name 데이터를 반환합니다.

프록시 객체 초기화

 

3) 프록시의 특징

1. 실제 클래스를 상속받아서 만들어진다.
2. 실제 클래스와 겉 모양이 같다.
3. 사용하는 입장에서 진짜 객체인지 프록시 객체인지 구분하지 않고 사용한다.
4. 프록시 객체는 실제 객체의 참조(target)를 보관한다.
5. 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.
6. 프록시 객체는 처음 사용할 때 한번만 초기화한다.

 

4) 프록시 초기화 확인

public class JpaMain {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("my-persistence");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {

            //Member 프록시 객체 조회
            Member findMember = em.getReference(Member.class, 2L);

            // 초기화 유무 확인
            System.out.println(emf.getPersistenceUnitUtil().isLoaded(findMember));//false
            Hibernate.initialize(findMember);//프록시 객체 초기화
            System.out.println(emf.getPersistenceUnitUtil().isLoaded(findMember));//true

            tx.commit();
        } catch (Exception e) {
            System.out.println(e);
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();
    }
}

 

5) 지연 로딩

프록시 객체는 지연로딩 전략을 사용할 때 사용합니다.
사용 하는 방법은 연관 관계 매핑 객체의 Fetch 전략을 LAZY 속성으로 선언하면 엔티티 조회 시 해당 연관 관계 매핑 객체는 프록시 객체로 초기화 됩니다.   
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;


참조

https://www.inflearn.com/course/ORM-JPA-Basic/lecture/21709?tab=curriculum
김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 (강추!)
엔티티 매핑 시 자주 사용되는 어노테이션을 정리한 글입니다.

 

1. @Entity

@Entity 어노테이션이 선언된 클래스를 DB 테이블과 매핑합니다.

 

2. @Column

필드 속성을 지정할 때 사용합니다.
이름 설명 기본 값
name 필드 명을 지정 객체의 필드 이름
insertable 등록 가능 여부 true
updatable 수정 가능 여부 true
nullable DDL 생성 시 설정 값에 따라 null 가능여부를 설정 true
unique DDL 생성 시 간단하게 한 컬럼에 유니크 제약조건을 설정 false
columnDefinition 테이블 컬럼 속성을 직접 설정
ex : varchar(50) not null default 'none'
 
length 문자 길이 제약 조건을 설정 255
precision, scale BigDecimal, BigInteger 타입에서 사용한다. precision은 소수점을 포함한 전체 자릿수, scale은 소수점의 자릿수 0,0

 

3. @Temporal

날짜 타입 매핑할 때 사용합니다. (LocalDate, LocalDateTime 사용 시 생략 가능)

 

4. @Enumerated

자바 enum 타입을 매핑할 때 사용합니다.
이름 설명 기본값
value EnumType.ORDINAL
enum 순서를 테이블에 저장

EnumType.String
enum 이름을 테이블에 저장
EnumType.ORDINAL

 

5. @Lob

데이터베이스 BLOB, CLOB 타입과 매핑합니다.

 

6. @Transient

해당 필드를 매핑하지 않습니다. 테이블과 연관 없는 필드를 지정할때 사용합니다.

 

7. @OneToMany, @ManyToOne ... 

연관 관계 매핑은 따로 정리하겠습니다.

1. Goal

1) 기본키 매핑 방법
2) 기본키 자동 생성 전략 4가지

 

2.  기본키 매핑 방법

@Entity
public class User {
    @Id
    private Long id;
}
기본키로 지정하고 싶은 변수에 @Id 어노테이션을 선언하면 해당 컬럼이 기본키로 지정됩니다.

 

3. 기본키 자동 생성 전략 4가지

기본키를 자동으로 생성하려면 @GeneratedValue(strategy = GenerationType.AUTO) 어노테이션을 기본키 변수 위에 선언해주면 됩니다. 기본키 자동 생성 전략은 4 가지가 있으며 전략속성을 생략하면 AUTO 속성으로 지정됩니다.

 

1) AUTO

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
}

 

dialect 값에 따라서 기본키 자동 생성 전략이 지정됩니다. ex) mysql : auto_increment, oracle : sequence ...

 

2) IDENTITY

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}
기본키 생성을 데이터베이스에 위임해 줍니다. 주로 MySQL, PostgreSQL, SQL Server, DB2에서 사용하며, AUTO_INCREMENT를 이용해 기본키를 생성합니다.

JPA는 보통 commit 시점에서 모아둔 쿼리들을 DB로 전송합니다. 그런데 AUTO_INCREMENT는 Insert 시 기본키 값이 생성됩니다. AUTO_INCREMENT 사용 시 commit을 하지 않으면 기본키 값을 알 수 없다는 말인데, 이러한 경우 해당 기본키 값을 참조하는 로직이 있거나 영속성 객체를 만들 때 문제가 생깁니다. 그래서 AUTO_INCREMNT를 사용하면 예외적으로 persist 시점에 바로 DB로 쿼리를 전송해 PK 값 생성 후 객체에 저장합니다.

단점으로는 Insert 쿼리를 모아서 한번에 DB로 전송하는 것이 불가능합니다.

 

3) SEQUENCE

@Entity
@SequenceGenerator(
        name = "USER_SEQ_GENERATOR",
        sequenceName = "USER_SEQ", // 시퀸스 명
        initialValue = 1, // 초기 값
        allocationSize = 1 // 미리 할당 받을 시퀸스 수
)
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USER_SEQ_GENERATOR")
    private Long id;
}

// create sequence USER_SEQ start with 1 increment by 1
    # 키 생성 및 초기화
    create table USER_SEQ (
       next_val bigint
    ) engine=InnoDB

    insert into USER_SEQ values ( 1 )
    
    # 키 조회    
    select
        next_val as id_val 
    from
        USER_SEQ for update
         
    # 키 업데이트
    update
        USER_SEQ 
    set
        next_val= ? 
    where
        next_val=?
데이터베이스 시퀀스는 유일한 값을 순서대로 생성하는 특별한 데이터베이스 오브젝트입니다.
(오라클, PostgreSQL, DB2, H2, MariaDB (10.3 버전 이상) 에서 사용가능)

단점은 persist 시 next sequence를 DB에서 발급 받으며, 이는 잦은 DB 호출로 인한 성능 저하로 연결될 수 있습니다.
그래서 allocationSize 속성을 통해 최대한 DB호출을 줄여야합니다.

장점은 쓰기지연을 통해 SQL 쿼리를 commit시 한번에 DB로 전송해 DB 호출을 최소화할 수 있습니다.
속성 설명 기본 값
name 식별자 생성기 이름 필수
sequenceName 데이터베이스에 등록할 sequence 명 hibernate_sequence
initalValue sequence 초기 값 1
allocationSize sequence 호출 시 증가하는 수,
jpa는 sequence를 증가 시키다가 할당 받은 sequence를 모두 소모하면 db에 접근하여 sequence를 할당 받을 만큼 증가 시킨다. (성능 최적화를 위해 사용됨)
50
catalog, schema 데이터베이스 catalog, schema 이름  

 

4) TABLE

@Entity
@TableGenerator(
        name = "USER_SEQ_GENERATOR",
        table = "MY_SEQUENCES",
        pkColumnValue = "USER_SEQ",
        allocationSize = 1
)
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE, generator = "USER_SEQ_GENERATOR")
    private Long id;
}
    create table MY_SEQUENCES (
       sequence_name varchar(255) not null,
        next_val bigint,
        primary key (sequence_name)
    ) engine=InnoDB
    
    
    insert into MY_SEQUENCES(sequence_name, next_val) values ('USER_SEQ',0)
    
    # 키 조회
    select
        tbl.next_val 
    from
        MY_SEQUENCES tbl 
    where
        tbl.sequence_name=? for update
    
    # 키 업데이트    
    update
        MY_SEQUENCES 
    set
        next_val=?  
    where
        next_val=? 
        and sequence_name=?
모든 데이터베이스에서 사용이 가능하며, 키 생성 전용 테이블을 생성해서 키 값을 관리합니다.
최적화되지 않은 테이블에서 키를 생성하기 때문에 성능상의 이슈가 발생할 수 있습니다. 그래서 상용 서비스에서 사용하지 않는 것이 좋습니다.

참조 : 인프런 - 자바 ORM 표준 JPA 프로그래밍 - 기본편 (김영한님)

'IT > JPA & Hibernate' 카테고리의 다른 글

[JPA] 프록시와 지연로딩  (0) 2021.05.18
[JPA] 엔티티 매핑 어노테이션 정리  (0) 2021.05.10
[JPA] @MappedSuperclass 사용법  (0) 2021.05.08
[JPA] 상속 관계 매핑  (0) 2021.05.08
[JPA] Hibernate 연동 방법  (0) 2021.05.05

1. @MappedSuperclass란?

엔티티 별로 공통 필드가 존재하는 경우 불 필요한 중복 코드를 제거하기 위해 사용합니다.

 

2. 사용방법

1) BaseEntity.java

import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
public abstract class BaseEntity {

    private LocalDateTime createDt;
    private LocalDateTime updateDt;

}
@MappedSuperclass 어노테이션을 클래스명 위에 선언하며, 공통 클래스를 별도로 생성하는 일이 없다면 추상 클래스로 선언하는 것이 좋습니다. 등록일, 수정일 같이 여러 테이블에서 공통으로 사용되는 필드를 테이블에 컬럼으로 등록할 때 사용합니다.

 

2) Product.java

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Product extends BaseEntity{
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private int price;

}
BaseEntity 클래스를 상속 받으면 BaseEntity에 선언된 변수들이 컬럼으로 등록됩니다.

1. 상속 관계 매핑이란?

슈퍼타입 테이블, 서브타입 테이블을 자바에서 상속관계인 것 처럼 사용하는 방법입니다.

 

2. 상속 관계 매핑 전략의 종류

1) JOINED (조인전략)

슈퍼타입과 서브타입을 식별관계로 사용하는 방법입니다.

 

2) SINGLE_TABLE (단일 테이블 전략)

슈퍼타입과 서브타입을 하나의 테이블로 생성하는 전략입니다.


3) TABLE_PER_CLASS (타입 별 테이블 생성 전략) (비추천)

파일 타입 별로 테이블을 생성하는 전략입니다.

3. 상속 관계 매핑 적용

1) FileInfo.java

import javax.persistence.*;

@Entity
@Table(name = "file_info")
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "data_type")
public abstract class FileInfo {
    @Id
    @GeneratedValue
    private Long id;

    private String url;

    @Column(name = "org_name")
    private String orgName;

    private String length;
    
    /*
    setter, getter 생략
    */
}
슈퍼 타입 클래스입니다. JOINED 전략을 사용할 경우 슈퍼 타입과 서브 타입은 식별관계가 되므로 서브 타입은 슈퍼타입 기본키를 외래키로 가짐과 동시에 기본키로 선언합니다.

@Inheritance 어노테이션을 이용해 JOINED, SINGLE_TABLE, TABLE_PER_CLASS 전략을 선택할 수 있으며, JOINED 전략을 보통 사용합니다. 하지만 검색속도 최적화를 위해 SINGLE_TABLE 전략을 사용하기도 합니다.
(TABLE_PER_CLASS 전략은 웬만하면 사용하지 않는 것을 추천합니다.)

@DiscriminatorColumn 어노테이션은 JOINED, SINGLE_TABLE 전략일 경우 사용하며, 서브타입의 구분값을 저장하는 컬럼입니다. name 속성을 이용해 구분 컬럼의 이름을 지정할 수 있습니다.

 

2) Image.java, Sound.java, Video.java

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue(value = "IMAGE")
public class Image extends FileInfo {

    private String type;

    private int width;
    private int height;
    
    /*
    setter,getter 생략
    */
}

@Entity
public class Sound extends FileInfo {

    private Long runtime;
    private String type;
    
    /*
    setter,getter 생략
    */    
}

@Entity
public class Video extends FileInfo {

    private Long runtime;

    private String type;
    
    /*
    setter,getter 생략
    */
}
서브 타입 클래스입니다. @DiscriminatorColumn 어노테이션을 사용했을 경우 서브타입 구분 값에 클래스 명이 저장됩니다.
@DiscriminatorValue 어노테이션을 사용했을 경우 서브타입 구분 값을 지정할 수 있습니다.

 

4. 테스트

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class JpaMain {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("my-persistence");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {
            Image image = new Image();

            image.setOrgName("google_icons.png");
            image.setUrl("https://ssl.gstatic.com/gb/images/p1_c9bc74a1.png");
            image.setType("png");

            //image 영속화
            em.persist(image);
            
            // 쓰기 지연에 쌓아둔 SQL을 DB에 전송한다.
            em.flush();
            // 영속성 캐시를 제거한다.
            em.clear();

            //image 조회
            Image findImage = em.find(Image.class, image.getId());

            System.out.println(findImage.getUrl());
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();

    }
}

 

@Inheritance(strategy = InheritanceType.JOINED) 전략으로 테스트했으며, Image를 저장하면 file_info와 image 테이블에 각각 저장됩니다. 

그리고 image를 조회할 경우 file_info와 image 테이블을 조인해서 select 합니다. 

 

# commit 시 작동되는 쿼리

# Image 저장 시, file_info와 image 테이블에 각각 저장한다.
        insert 
        into
            file_info
            (length, org_name, url, data_type, id) 
        values
            (?, ?, ?, 'IMAGE', ?)
		
        insert 
        
        into
            Image
            (height, type, width, id) 
        values
            (?, ?, ?, ?)
            
# image 조회 시, image 테이블과 file_info 테이블을 조인해서 select한다             
    select
        image0_.id as id2_0_0_,
        image0_1_.length as length3_0_0_,
        image0_1_.org_name as org_name4_0_0_,
        image0_1_.url as url5_0_0_,
        image0_.height as height1_1_0_,
        image0_.type as type2_1_0_,
        image0_.width as width3_1_0_ 
    from
        Image image0_ 
    inner join
        file_info image0_1_ 
            on image0_.id=image0_1_.id 
    where
        image0_.id=?            

도움이 되셨다면 공감 버튼 한번씩 눌러주세요!

1. 테스트 환경

JAVA 1.8
Maven
Mysql

 

2. Hibernate 연동 및 테스트

1) Maven 프로젝트 생성

 

2) pom.xml

hibernate와 mysql connector 라이브러리를 추가해줍니다.
    <dependencies>
        <!-- https://mvnrepository.com/artifact/org.hibernate/hibernate-entitymanager -->
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-entitymanager</artifactId>
            <version>5.3.10.Final</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>6.0.6</version>
        </dependency>


    </dependencies>

 

3) persistence.xml 작성

DB 접속 정보 및 hibernate 설정 정보를 작성합니다.
persistence.xml 파일의 경로는 반드시 src/main/resources/META-INF 아래에 위치해야 합니다.
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2"
             xmlns="http://xmlns.jcp.org/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
<!--  EntityManagerFactory 생성 시 사용되는 persistence name 입니다. -->
    <persistence-unit name="my-persistence">
        <properties>
            <!-- 필수 속성 -->
            <property name="javax.persistence.jdbc.driver" value="com.mysql.jdbc.Driver"/>
            <property name="javax.persistence.jdbc.user" value="username"/>
            <property name="javax.persistence.jdbc.password" value="password"/>
            <property name="javax.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/my_db?characterEncoding=UTF-8&amp;serverTimezone=UTC"/>
           
            <!-- 하이버네이트 사용 시 여러 가지 종류의 DB를 사용할 수 있는데, 명시적으로 사용하는 DB를 적어서 해당 디비에서 사용하는 문법을 적용할 수 있습니다.-->
            <property name="hibernate.dialect" value="org.hibernate.dialect.MySQL8Dialect"/>

            <!-- 옵션 -->
            <!-- sql query를 보여줍니다.-->
            <property name="hibernate.show_sql" value="true"/>
            <!-- sql query를 정리해서 보여줍니다.-->
            <property name="hibernate.format_sql" value="true"/>
            <!-- sql의 comments를 보여줍니다.-->
            <property name="hibernate.use_sql_comments" value="true"/>
            
        </properties>
    </persistence-unit>
</persistence>

 

4) Member entity 생성 및 테이블 생성

Member.java

package entity;

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class Member {
    @Id
    private Long id;

    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Member table

CREATE TABLE `member` (
	`id` BIGINT(20) NOT NULL,
	`name` VARCHAR(255) NULL DEFAULT NULL,
	PRIMARY KEY (`id`)
)

 

5) Member 객체 저장 테스트

import entity.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("my-persistence");

        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Member member = new Member();
            member.setId(1L);
            member.setName("kim");

            em.persist(member);

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

        emf.close();

    }
}

+ Recent posts