728x90

짧은 근황을 먼저 얘기하자면...

올해 3월부터 이직을 하게되어 삼쩜삼(자비스앤빌런즈) 백엔드 엔지니어로 현재 이직하여 회사를 다니고 있다.

나중에 다른 포스팅으로 해당 부분은 잘 작성해보도록 하겠다.

이전 포스팅인 @Transactional 제대로 알고쓰기 에서는 무지성 중첩 트랜잭션에 대한 포스팅이었는데,
이번에는 좀 다른 케이스였다.

해당 예외가 발생하는 것이었다.
회사에서는 Mysql을 RDBMS로 채택하여 사용해서 아래와 같은 예외가 보였지만, 예제에서는 H2 DB를 사용하기 때문에 같은 에러메시지는 아니지만,
내용은 똑같다는 점을 일단 알고 넘어가면 좋을 것 같다.

HHH000099: an assertion failure occurred (this may indicate a bug in Hibernate, but is more likely due to unsafe use of the session): org.hibernate.AssertionFailure: null id in com.lsj8367.github.DemoEntity entry (don't flush the Session after an exception occurs)

이러한 예외가 나서 에러 로그에 StackTrace를 같이 찍어주고 있었기 때문에 지켜보게 되었다.

해당 예제를 구현한 것은 깃허브에 있다.
코틀린으로 코드가 구성되어있지만, 알아보기는 정말 쉬운정도의 코드라서 문제없이 읽을 수 있을 것이다.

 

구성하기 좋게 Facade -> Service -> Repository 로 단방향으로 흐르는 구조로 구성하였다.

실제 코드와는 완전히 다르고 정말 예제만을 위해 이렇게 구성하였다.

 

 

DemoFacade
DemoService

 

DemoEntity

 

이제 Service Layer의 save 로직은 name 값엔 "name"을 code값엔 "code" 를 무조건적으로 저장하도록 구성했고, entity 조건엔 unique 제약조건으로 code 값을 설정해주었다.

 

그래서 첫 1회는 저장이 정상 수행되며, 2회 수행시에는 uk 조건을 만족하지 못해 예외가 발생할 것이다.

 

정말 잘 발생하는것을 볼 수 있다. ㅋㅋㅋㅋ

 

근데 이제 여기서 문제인것은,

중복 예외인 DataIntegrityViolationException 을 catch 절에서 잡고있는데 어떻게 된 것일까?

 

이 부분을 생각해봐야 한다.

해당 부분을 알기 위해서는 JPA를 사용할 당시에 1차캐시를 기억해야 한다.

1차 캐시는 말 그대로 영속성 컨텍스트에 해당 엔티티 값들을 저장하는 방식인데, 우리는 당연스레 쿼리를 나가는 것을 생각하지 않으니 저장 객체를 핸들링 하는 것처럼 코드를 작성하게 되는데 이부분에서 문제가 발생했던 것이다.

 

일단 당연한 얘기지만 @Transactional 내부에 있기 때문에 로직들은 전부 트랜잭션이 끝나는 부분에 맞춰서 쿼리가 나갈 것이다.

그렇기 때문에 우리는 DataIntegrityViolationException을 catch로 잡아줘서 커스텀하게 던져주는 IllegalArgumentException은 잡히지 않는 것을 볼 수 있다.

이런식으로 IllegalArgumentException을 던지는 것으로 체크가 되지 않는 것을 볼 수 있다.

 

그러면 왜 때문에 DataIntegrityViolationException이 중간에 발생했을까?

해당 사항은 위의 코드에선 flush() 를 명시적으로 호출해주는 경우에 발생했다. (실제 쿼리가 이때 반영되기 때문이다.)

그러면서 ExceptionHandler를 통해 500예외를 핸들링하는 방식으로 구현되었다.

 

실제 업무 로직에서는 findAll을 해주는 쿼리가 같이 들어있었기 때문에 이 로직이 실행되기 이전에 flush를 수행하여 save가 제대로 반영되지 않고 DataIntegrityViolationException이 발생하게 된 것이다.

 

이를 예방하기 위해서는 너무 DB레이어의 UK 제약조건만 믿고 try-catch만을 심어서 데이터를 체크해주는 그런 부분을 지양해야 된다고 생각이 들었다.


1. 반드시 중복 데이터가 들어가지 못하게 만드는 기능이라면, 이전에 이미 등록된 데이터가 있는지 여부를 확인하는 로직을 넣어주는 것도 좋은 구성인 것 같다.

2. 만약에 db uk 예외가 발생하는 경우라면 @Transactional 을 적재적소에 알맞는 위치에 넣어 구성해주고, 그 @Transactional 을 사용하는 부분에 정말로 try-catch가 필요한지 다시한번 생각해볼 필요가 있다. (무지성 트랜잭셔널 금지!!)

 

다시는 재발하지 않게 로직을 구성해주자~

728x90

'JPA' 카테고리의 다른 글

JPA template 이슈  (0) 2022.08.07
고급 주제와 성능 최적화 1  (0) 2022.08.06
컬렉션과 부가기능  (0) 2022.08.06
준영속 상태의 지연로딩을 해결하는 방법  (0) 2022.08.06
728x90

템플릿 이슈

제목과 그리고 최상단의 주제인 템플릿 이슈 사항에 대해서 정리한다.

먼저 업무에서 생겼던 이슈사항부터 소개한다.

회사에서 postgreSQL을 사용한다.

그게 중요한 것은 아닌데, 각자의 db마다 방언이 있기 나름이고

테스트에서는 h2DB로 테스트를 하면서 방언도 h2방언을 설정해주었다.

로직중에 특정 date가 현재 시간보다 작은 경우를 쿼리로 찾아야했었는데,

to_date() 함수를 어떻게 적을것인가에 대한 문제였다.

왜 이 문제가 발생했냐면,

일단 기본적으로 여러개의 조건중에 저 하나가 들어가있었고, 문자열로 된 시간형식인 yyyyMMddHHmm 형식을 가지고 비교를

했어야 했기 때문이다.

이슈 파헤치기

일단 queryDsl에서는 서브쿼리나 랜덤 등등 어떤 특정 조건을 만족해야 하는 식들같은 경우엔

하나같이 끝에 Expressions가 붙었다.

대표적인건 뭐 JPAExpressions이다.

image

위 사진은 JPAExpressions의 일부이다.

이런식으로 다 함수들이 들어있을줄 알고 date 함수도 저런곳에 있겠지 하고 실행을 시키려고 했는데

존재하지가 않았다.

검색해본 결과 또 Template 류가 나오게 되었는데

DateTemplate 라는 곳에서 date관련 기능들을 사용할 수

있다고 해서 찾아보게 되었다.

image

DateTemplate 를 만드려면

ExpressionsdateTemplate() 메소드를 사용해서 만들 수 있었다.

image

메소드의 인자들이다.

그래서 나도 똑같이 넣어주기 위해서

DateTemplate date = Expressions.dateTemplate(
    LocalDateTime.class, "template???",
            genesisReservation.reservationEndDt,
            Expressions.constant("yyyyMMddHHmm")
)

이렇게 코드를 작성해주었다.

근데 작성하고 보니까 저기값은 String의 값인데

어떻게 넣어줘야하는지 감이 잡히질 않았다.

그래서 뒤지게된 JpaQueryFactory

image

JpaQueryFactory 의 내부중 일부를 가져왔다.

JPAQueryFactory는 생성자에 별도 템플릿을 주지 않으면

JPAProvider를 통한 JpQLTemplate를 가져온다.

여기보면 비슷하게 생긴 JPQLTemplates가 있다.

저길 타고 들어가보니 또 Templates를 상속받고 있었다.

JPQLTemplate에는 이제 익숙한 부분 쿼리문들이 메소드로 쭉 이어져 있었는데

그중의 일부를 보면

image

이렇게 생긴것들이 쭉 있다.

그래서 함수를 가져다가 저 조건에 맞게 쓰면 되는구나!

하고 유레카를 외쳤다. 굉장히 오래걸렸었다...😅

다시 돌아와서 템플릿 자리에 이렇게 바꿔주었다.

DateTemplate date = Expressions.dateTemplate(
    LocalDateTime.class, "to_date({0}, {1})",
            genesisReservation.reservationEndDt,
            Expressions.constant("yyyyMMddHHmm")
)

따옴표를 써서 형식을 넣어줄거다 라고하면

to_date({0}, {1})

이렇게 해주면 잘되지만,

to_date({0}, '{1}')

이러면 형식을 맞춰주지 않으면 에러가 발생한다.

image

to_date({0}, '{1s}')

이럴경우엔 또 잘 실행이 되는데

string을 넣을 경우에 s를 붙이고 따옴표로 완성시키면 되는것인가 하면서 아직도 궁금한데 이부분은 해결하지 못했다.

마무리

이제 해결했으니까 본론으로 돌아와 querydsl을 다시 작성한다면,

@RequiredArgsConstructor
public class Test {
    private final JPAQueryFactory JpaQueryFactory;
    public void test() {
        DateTemplate toDate = Expressions.dateTemplate(
            LocalDateTime.class, "to_date({0}, {1})",
            genesisReservation.reservationEndDt,
            Expressions.constant("yyyyMMddHHmm")
        );

        return jpaQueryFactory.selectFrom(table)
                    .join(joinTable)
                        .on("조인조건")
                            .where("조건".and(toDate.lt(LocalDateTime.now())))
                     .fetch();
    }
}

제목 없음

이런식으로 잘 들어오게 된다.

값도 테스트에서 비교하면 가정법 검증이 잘된다.

사이드 프로젝트에서의 이슈 사항

비슷하지만 다른 이슈였다.

사이드 프로젝트에서는 현재 MariaDB를 사용한다.

여기서는 다른 db와 다르게 rand() 를 사용한다.

postgreSQL, h2DB는 random()을 사용하는데

이것도 마찬가지로 템플릿에 대한 이슈였다.

JPAQueryFactory에서 조금 다른걸 설명하고 싶은데

일단 조회쿼리는 JPAQuery<>를,

수정쿼리는 JPAUpdateClause, 삽입 JPAInsertClause,
삭제 JPADeleteClause를 사용한다.

그래서 나는 조회쿼리를 사용해야 했기 때문에 JPAQuery 클래스
안쪽을 찾아보았다.

image

자 이렇게 보면 아까 위에서 설명했던 JPQLTemplates가 기본값으로

설정되어있기 때문에 그 템플릿을 사용하여 실행해서

그 끝쪽에 있는 Templates에는

image

이미지 처럼 들어있었다.

그러니까 NumberExpressions의 random이 먹통이었던 것이다.

아무리 방언을 따른다고 해도 템플릿에 저장되어 있는것은

그대로 가는것 같다.

생각한 결론은,

그러면 bean으로 등록하면 되지 않을까?

였다.

그래서 바로 QuerydslConfiguration 설정을 진행해주었다.

일단 우리는 Hibernate를 사용해서

기본값은 HQLTemplates 였고, 그래서

상속받아서 구현해주었다.

import com.querydsl.core.types.Ops;
import com.querydsl.jpa.HQLTemplates;

public class MariaDBTemplates extends HQLTemplates {
    public static final MariaDBTemplates DEFAULT = new MariaDBTemplates();

    protected MariaDBTemplates() {
        this(DEFAULT_ESCAPE);
        add(Ops.MathOps.RANDOM, "rand()");
        add(Ops.MathOps.RANDOM2, "rand({0})");
    }

    public MariaDBTemplates(char escape) {
        super(escape);
    }
}

이렇게 구현해주고 나니 이제 빈을 등록해주면 되었다.

@Configuration
public class QuerydslConfig {
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }

    @Bean
    public JPAQuery<NewBook> jpaQuery() {
        return new JPAQuery<>(entityManager, MariaDBTemplates.DEFAULT);
    }
}

이렇게 새책 테이블에 관한 random값을 사용할 수 있을것 같았고,

바로 querydsl을 짜러 갔다.

생성자 주입으로 먼저
private final JPAQuery<NewBook> jpaQuery;

를 받아준 뒤에 로직은 그대로 두면 되겠거니 해서

그대로 두었다.

정상적으로 실행된다!!!!

컴퓨터를 옮기고 작업에 추가를 해줘야겠다.

위대로 설정하면 안된다. 상황 설명해주겠다.

현재 프로젝트는 위 설정처럼 진행했다.

그럴때 나오는 로그가 신박하다.

정리했을 처음처럼 random은 한번은 잘 출력되는데 문제는 그 이후부터이다.

스크린샷 2021-09-28 오후 11 54 47

이런식으로 새로고침을 할 때마다 random이 계속 호출된다.

그러니까 이 where절과 order by만 N + 1이 되는 것이다.

이게 일회성인지는 모르겠지만, 싱글톤 빈 주입으로 진행을 해주게 되면

계속 저걸로 영구적으로 바뀌어서 그런지 계속 추가가 된다.

처음은 몇줄 안되서 이렇겠지만 심각한 문제를 발생시킬것이다.

그 해결책은, 일단 빈주입이 아니라 쓰는 부분에만 특별하게 new를 해줘서 커스텀 해주는것이다.

@Override
public List<NewBook> selectRandom(int limit) {
    JPAQuery<NewBook> jpaQuery = new JPAQuery<>(entityManager, MariaDBTemplates.DEFAULT);
    return jpaQuery.from(newBook)
            .orderBy(NumberExpression.random().asc())
                .limit(limit)
            .fetch();
}

이런식으로 사용처에만 할당해주니까 정상적으로 rand()를 한번만 출력해주게 되었다.

결론

이제 조금씩 문서도 찾고, 라이브러리를 직접 뒤져가면서 학습해보니 결국 원초적인 지식이 가장 중요했다.

더 좋은 방법이 있다면 댓글로 남겨주셔도 좋습니다. 🙏

이렇게 보면 또 마이바티스처럼 되는 경향이 있다.

좀 더 데이터를 더럽고 간단하게 정제하고 자바쪽에서 부하를 걸어주는 방식을 계속 생각해야한다.

아무튼 오늘도 성장했다.

728x90
728x90

스터디도 시작한지 3개월이 되었다. 시간 참빠른것 같다.

진도가 이제 15장을 둘로 쪼개고 16장 해도 9월에는 종료가 될것이다. 😂

예외처리와 엔티티 그리고 프록시에 대해 정리한다.

예외 처리

image{: text-center}

JPA는 그림과 같이 javax.persistence.PersistenceException의 자식 클래스이다.

그리고 이 예외 클래스는 RuntimeException의 자식이다.

JPA 예외는 모두 uncheck Exception이다.

JPA 표준예외

  • 트랜잭션 롤백을 표시하는 예외
  • 트랜잭션 롤백을 표시하지 않는 예외

서비스 계층에서 데이터 접근 계층의 구현 기술에 직접 의존하는 것은 좋지 않다.
SOLID원칙 생각해보자. 이것이 예외에서도 마찬가지가 된다.

서비스에서 JPA의 예외를 그대로 사용한다면 JPA에게 의존하는것이 된다.

그래서 Spring은 이런 문제를 해결하려고 예외를 추상화해서 제공하였다.

방법

JPA 예외를 스프링 프레임워크에서 제공하는 추상화된 예외로 변경하려면 PersistenceExceptionTranslationPostProcessor를 스프링 빈으로 등록해준다.

@Repository를 사용한 곳에 여기에 예외 변환 AOP를 적용해준다.

@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslation {
    return new PersistenceExceptionTranslationPostProcessor();
}

트랜잭션 롤백 시 주의 사항

  • 트랜잭션을 롤백하여 DB의 데이터가 원래대로 복구되지만 영속성 컨텍스트까지 롤백이 된것은 아니다. 그래서 영속성 컨텍스트를 초기화 해준 후 사용해야 한다.
  • OSIV는 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 했기 때문에 자주 발생가능
    • 같은 엔티티에 여러 트랜잭션 발생 가능, 이렇게 사용하면 문제가 야기될 수 있다.
    • 넓게 설정해야한다면, 트랜잭션 롤백 시 영속성 컨텍스트를 초기화한다.

엔티티 비교

저장하는 em.persist()와 Spring data JPA의 findById()로 아이디값을 같은것을 비교하면 저장한 엔티티와 불러온 엔티티는 값만 같은 것이 아니라 인스턴스가 완전히 같다.
같은 트랜잭션 범위에 있으므로 같은 영속성 컨텍스트를 사용하기 때문이다.

  • 동일성
    • == 로 주소값을 따짐
  • 동등성
    • equals()로 값만 비교
  • DB동등성
    • @Id DB 식별자가 같다.

여지껏 내가 했던 테스트가 잘못될 수도 있었다. @Transactional 의 중요성 그리고 영속성 컨텍스트에 대해 계속 생각하며 Junit 테스트코드를 작성했어야 했다.

왜냐하면

@Test
@Transactional
void test() {
    Member member = new Member("홍길동");

    Member findMember = memberRepository.findById(1L).orElseThrow(new Member("테스트"));


    assertThat(member.getId()).isEqualTo(findMember.getId())
}

이렇게 짜주니까 이상적으로 동작했던것이었지 왜냐 ❓

엔티티를 영속화해야 DB 동등성 비교가 가능하다. 근데 만약 @Transactional이 각기 다르게 되어 동작을 할 때엔 이런 비교는 에러를 뱉게 된다.

프록시 심화

프록시는 원본 엔티티를 상속받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지, 원본 엔티티인지 확인하면서 사용할 필요가 없다.

따라서 원본 엔티티를 사용하다가 지연 로딩으로 프록시로 변경되어도 다른 로직을 건들 필요가 없다.

이렇게하면 프록시를 먼저 조회했기 때문에 어차피 원본 엔티티를 상속받아 만들어진 것이기 때문이다.

영속성 컨텍스트와 프록시

작업 단위로 영속성 컨텍스트가 돌아가기 때문에 프록시로 조회하나 아니면 실제 엔티티를 조회하나

둘다 같은 객체를 반환해야 영속성 컨텍스트가 동일성을 보장하면서 운영할 수가 있다.

예를 들어

Member proxyMember = em.getReference(Member.class, "member");
Member findMember = em.find(Member.class, "member");

여기는 프록시 객체로 영속성 컨텍스트 동일성을 보장함

처음 프록시로 조회된 객체를 바로 반환시켜준다.

Member findMember = em.find(Member.class, "member");
Member proxyMember = em.getReference(Member.class, "member");

여기는 원본 엔티티 객체로 영속성 컨텍스트 동일성 보장.

이런것 까지 꼼꼼하게 생각 안하게 만들어준 JPA다.

클래스 타입비교

상속받아 만들어진 프록시 객체는 원본을 상속받은 객체이기 때문에 instanceof 로 타입을 비교해야 한다.

정리

전체적인 내용을 살펴보면 결국 영속성 컨텍스트가 얼마나 살아있는지에 대해 프록시과 원본 엔티티 동등성을 유지할 수 있는가가 주 내용이다.

작업단위를 잘 생각하고 영속성의 경계선을 파악하면 잘 사용할 수 있을것 같다. 지연로딩에 대해서 너무 간단하게 생각하지말고 부모자식으로 조회할 때의 프록시 객체를 생각하며 개발해야 한다.

728x90

'JPA' 카테고리의 다른 글

DataIntegrityViolationException에 대해서  (0) 2024.08.20
JPA template 이슈  (0) 2022.08.07
컬렉션과 부가기능  (0) 2022.08.06
준영속 상태의 지연로딩을 해결하는 방법  (0) 2022.08.06
728x90

코드는 모두 깃허브에 있음.

일단 자바의 컬렉션 인터페이스들의 특징부터 나열한다.

컬렉션

  • Collection
    • 자바가 제공하는 최상위 컬렉현, Hibernate는 중복을 허용하고, 순서를 보장하지 않는다고 가정
  • Set
    • 중복을 허용하지 않고, 순서도 보장하지 않는다.
  • List
    • 순서가 있는 컬렉션아며 중복을 허용한다.
  • Map
    • Key, Value 구조로 되어있는 컬렉션이다.

JPA와 Collection

Hibernate는 엔티티를 영속 상태로 만들 때 컬렉션 필드를 Hibernate

준비한 컬렉션으로 감싸서 사용한다.

다음 예시를 보자

@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;

    @OneToMany
    private Collection<Member> members = new ArrayList<>();
}

@DataJpaTest
class TeamTest {

    @PersistenceUnit
    EntityManagerFactory emf;

    EntityManager em;

    EntityTransaction tx;

    @BeforeEach
    void setUp() {
        em = emf.createEntityManager();
        tx = em.getTransaction();
        tx.begin();
    }


    @Test
    void 컬렉션_테스트() {
        Team team = new Team();
        System.out.println(team.getMembers().getClass());
        em.persist(team);
        System.out.println(team.getMembers().getClass());
    }
}

테스트코드 지만 단순히 이 결과를 확인하기 위해서 콘솔 출력을 진행하였다.

결과는 이렇게 나온다.

image
{: text-center}

처음 객체를 포장할때는 Team 엔티티 클래스에서 명시한 ArrayList로 포장을 하는데 엔티티를 영속상태로 바꿔주는 순간 PersistentBag 으로 변경된다.

Hibernate는 컬렉션을 효율적으로 사용하려고 영속상태로 만들때 원본의 컬렉션을
감싼 내장 컬렉션을 생성하여 이 감싼 내장 컬렉션을 사용하도록 참조를 변경한다.

그렇기 때문에 컬렉션을 사용하려면 즉시 초기화를 해주고 사용하는걸 권장한다.

다음은 Hibernate의 내장 컬렉션들과 특징이다.

컬렉션 내장컬렉션 중복 순서
Collection, List PersistentBag O X
Set PersistentSet X X
List + @OrderColumn PersistentList O O

Collection, List

CollectionList는 엔티티를 추가할 때 중복된 엔티티가 있는지 비교하지 않고

왜❓ - 중복을 허용하기 때문

단순히 저장만 하면 된다. 그렇기 때문에 엔티티를 추가해도 지연 로딩된 컬렉션을 초기화 하지 않는다.

Set

Set은 엔티티를 추가할 때 중복값을 확인하기 때문에 서로 비교를 해야한다.

그렇기 때문에 지연 로딩된 컬렉션을 초기화 한다.

List + @OrderColumn

@OrderColumn은 DB에 순서값을 저장해서 조회할 때 사용한다는 의미

순서가 있기에 DB에 순서값도 관리하는데

단점이 있어 사용하지 않는다고 한다.

순서값을 DB가 가지고 있기 때문에 하나를 지운다고 가정하면 삭제된 List의 번호에는 null이 저장된다.

NullPointerException우려

@OrderBy

책에서 나온것처럼 특정 칼럼에 @OrderBy를 주는 법도 있겠지만 이렇게 하지않고 대부분 Auditing 기능 오버라이드 하여 한다고 한다.

@Converter

컨버터는 단어 그대로 형 변환을 해주는 것이다.

예를들어 boolean 타입은 DB에 저장될 때 0과 1로 저장이 된다. 대신에 Y나 N으로 저장하고 싶다면

컨버터를 사용하면 된다.

@Converter
public class BooleanYNConverter implements AttributeConverter<Boolean, String> {

    @Override
    public String convertToDatabaseColumn(Boolean attribute) {
        return (attribute != null && attribute) ? "Y" : "N";
    }

    @Override
    public Boolean convertToEntityAttribute(String dbData) {
        return "Y".equals(dbData);
    }
}

이렇게 AttributeConverter를 구현해주고 @Converter를 명시해준다.

//방법 1
@Convert(converter = BooleanYNConverter.class, attributeName = "적용할 변수")
public class Test {
    //방법 2
    @Convert(converter = BooleanYNConverter.class)
    private boolean 변수명;
}

이렇게 있다. 그리고 추가로 모든 boolean에 대해서 적용을 시켜준다면

클래스최상단에 @Converter(autoApply = true)를 주면 된다.

리스너

JPA 리스너 기능을 사용하면 엔티티의 생명주기에 따른 이벤트 처리 가능

image
{: text-center}

이벤트의 종류와 발생 시점은 위의 이미지와 같다.

  1. PostLoad : 엔티티가 영속성 컨텍스트에 조회된 후 또는 refresh 호출한 후(2차 캐시에 저장되어 있어도 호출).
  2. PrePersist : persist() 를 호출해서 엔티티를 영속성 컨텍스트에 관리하기 직전에. 식별자 생성전략을 사용한 (이하 @GeneratedValue) 경우 엔티티에 식별자는 아직 존재하지 않는다. 또한 새로운 인스턴스를 merge할 때.
  3. PreUpdate : flushcommit을 호출해서 엔티티를 DB에 수정하기 직전
  4. PreRemove : remove()를 호출해 엔티티를 영속성 컨텍스트에서 삭제하기 직전. 영속성 전이가 일어날 때, orphanRemoval(고아객체 관련)에 대해선 flushcommit시에
  5. PostPersist : flushcommit을 호출해서 엔티티를 DB에 저장한 직후 호출. 식별자 항상 존재함. 생성전략이 IDENTITY면 식별자를 생성하기 위해 persist()를 호출한 직후 바로 호출.
  6. PostUpdate : flushcommit을 호출해서 엔티티를 DB에 수정한 직후
  7. PostRemove : flushcommit을 호출 엔티티를 DB에 삭제한 직후

이벤트 적용위치

적용 위치는 3가지이다.

  • 엔티티에 직접 적용
  • 별도의 리스너 등록
  • 기본 리스너 사용

엔티티에 직접 적용

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

    ...

    //아래로 쭉 구현
    @PrePersist
    public void prePersist() {
        ...
    }

    @PostPersist
    public void postPersist() {
        ...
    }
    ...

}

별도의 리스너 등록

이거는 JPA Auditing 생각해보면 될거같다. 결국 AuditingEntityListener 이 리스너도 안에 어노테이션으로 아래와 같이 구현되어있다.

@Configurable
public class AuditingEntityListener {

    private @Nullable ObjectFactory<AuditingHandler> handler;

    public void setAuditingHandler(ObjectFactory<AuditingHandler> auditingHandler) {

        Assert.notNull(auditingHandler, "AuditingHandler must not be null!");
        this.handler = auditingHandler;
    }

    @PrePersist
    public void touchForCreate(Object target) {

        Assert.notNull(target, "Entity must not be null!");

        if (handler != null) {

            AuditingHandler object = handler.getObject();
            if (object != null) {
                object.markCreated(target);
            }
        }
    }

    @PreUpdate
    public void touchForUpdate(Object target) {

        Assert.notNull(target, "Entity must not be null!");

        if (handler != null) {

            AuditingHandler object = handler.getObject();
            if (object != null) {
                object.markModified(target);
            }
        }
    }
}

여러개의 리스너를 등록했을 때 호출순서는

  1. 기본 리스너
  2. 부모 클래스 리스너
  3. 리스너
  4. 엔티티

와 같다.

엔티티 그래프

엔티티 그래프는 엔티티를 조회하는 시점에 연관된 엔티티들을 함께 조회하는 기능이다.
Named 엔티티 그래프는 Named쿼리 자체의 빈도수가 낮기때문에 다루지 않겠다.

EntityGraph<Team> graph = em.createEntityGraph(Team.class);

graph.addAttributeNodes("속성");

JPAQuery<Emp> query = queryFactory.selectFrom(Q클래스).where(조건);

query = query.setHint("javax.persistence.fetchgraph", graph);

query.fetchOne();

이렇게 엔티티 그래프를 정의하고 Hint로 그래프를 넣어주면 되는 방식이다.

정리

엔티티 그래프는 항상 조회하는 엔티티의 ROOT경로에서 시작해야 한다.

만약 Member엔티티에 Team이 포함되어 있다면 Member조회 후 Team으로 가야되는데 역으로 갈 수는 없다.

영속성 컨텍스트에 엔티티가 이미 로딩되어 있다면 엔티티 그래프 적용 ❌

fetchgraph와 loadgraph의 차이는 loadgraph는 엔티티 그래프의 설정한 속성과 함께 글로벌 페치전략이 FetchType.EAGER 인 관계들도 전부 포함해서 함께 조회한다.

728x90

'JPA' 카테고리의 다른 글

JPA template 이슈  (0) 2022.08.07
고급 주제와 성능 최적화 1  (0) 2022.08.06
준영속 상태의 지연로딩을 해결하는 방법  (0) 2022.08.06
JPA metamodel must not be empty!  (0) 2022.08.06
728x90

준영속 상태의 지연로딩을 해결하는 방법

JPA에서 항상 생각을 해야되는 것이 바로 영속상태, 영속성 컨텍스트위에 있는가? 를 생각해야된다.

예를 들어 우리는 DAO층에서 실제 DB와 통신을 보편적으로 진행하는데, 이쪽은 영속성 컨텍스트에 의해 관리가 되어
영속 상태를 유지한다.

하지만, Controller, View 이런 계층에서는 준영속 상태가 된다. 그래서 영속상태와 다르게 변경감지, 지연로딩이 동작하지 않게 된다.

지연로딩이 동작하지 않기 때문에 이때 지연 로딩을 시도하면 문제가 발생하는게 당연하다.

하이버네이트가 구현체라면 org.hibernate.LazyInitializationException이 발생한다.

이것을 해결하는 문제는 두가지가 있다.

  • 뷰가 필요한 엔티티를 미리 로딩
  • OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 방법

뷰가 필요한 엔티티를 미리 로딩

이름 그대로 영속성 컨텍스트가 살았을 때, 필요한 엔티티를 다 로딩하거나 초기화해서 반환하는 방법이다.

이미 다 로딩했기 떄문에, 지연 로딩이 발생할 걱정을 안해도 된다.

이 방법에서는 로딩해두는 방법에 따라 3가지로 나뉜다.

글로벌 페치 전략 수정

엔티티에서 fetch 타입을 변경하면 애플리케이션 전체에서 이 엔티티 객체를 로드할때 마다 해당 전략을 사용하므로 글로벌 페치 전략이라고 한다.

단점

사용하지 않는 엔티티까지 로딩
N + 1 문제 발생

JPQL을 사용할 때 문제가 발생하는데

Order와 Member가 다대일로 연결되어 있다고 가정한 후에
select o from Order o 를 사용해서 조회를 한다고 한다면
글로벌 페치 전략을 사용하지 않고 그냥 JPQL자체만 사용하기 때문에

  1. 일단 Order를 조회
  2. order 인스턴스들 생성
  3. member 페치 전략이 즉시로딩이므로 order조회되면 member도 조회
  4. 근데 영속성 컨텍스트에 없다? order엔티티 수만큼 계속 조회

이걸 해결하려고 나온것이 바로 아래 부분이다.

JPQL 페치 조인

여기선 join fetch를 사용하면 된다고 한다.

select o from Order o join fetch o.member

N + 1 자체의 문제를 해결해주는것은 좋다. 근데 이제 order만 조회하느냐, order에 연관된 member까지 조회하냐에 따라 메소드를 늘려야할 것이다. 이러면 내부적으로 논리적 의존관계 우려가 발생하기 때문에 잘 고려해서 사용해야 할 것이다.

강제 초기화

지연 로딩을 설정했을 때 연관된 엔티티는 프록시 객체이다.

이 가짜 객체는 실제 사용 시점에 초기화가 되는데 이것을 영속성이 살아있을 때 다 초기화를 하여 반환해준다면? 준영속에서도 사용이 가능하다.

Facade 계층 추가

뷰를 위한 프록시 초기화 담당 계층

서비스와 컨트롤러를 분리해서 그 계층 사이의 의존성을 한번 더 분리해주는 것이라고 생각하면 편하다.

Controller - Facade - Service 이렇게 말이다.

프록시를 초기화하려면 영속성 컨텍스트가 필요해서 Facade에서 트랜잭션을 시작해야 한다.

퍼사드 계층 역할 및 특징

  • 프리젠테이션, 도메인 모델 계층 간의 논리적 의존성 분리
  • 프리젠테이션 계층에서 필요로 하는 프록시 객체 초기화
  • 서비스 계층 호출하여 비즈니스 로직 실행
  • Repository 직접 호출해서 엔티티 탐색

이것도 근데 결국 프리젠테이션 계층에서의 준영속 상태 라는 것이 문제이기 때문에 고안해낸 것.

OSIV

OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어준다는 뜻.

뷰에서도 지연로딩이 가능❗️

프리젠테이션 계층에서 엔티티를 수정못하게 막는 방법은

  • 엔티티를 읽기 전용 인터페이스로 제공
  • 엔티티 래핑
  • DTO만 반환

들이 있다.

스프링 OSIV

True - 기본값

application.properties에 추가해주면 된다.

sping.jpa.open-in-view:true 

스크린샷 2021-08-22 오후 12 43 27

  • 장점

    • 그림과 같이 커넥션을 유지해서 영속성 컨텍스트 범위가 넓어진다.
    • 그래서 연관관계 LAZY fetch 전략을 서비스를 벗어난 곳에서 사용 가능하다.
  • 단점

    • 너무 오랜시간동안 DB 커넥션을 유지하여 리소스를 사용하기 때문에 실시간 트래픽이 중요한 애플리케이션에서 DB 커넥션이 모자랄 수 있음 (장애 발생)

False - 설정값

스크린샷 2021-08-22 오후 12 43 13

sping.jpa.open-in-view : false

DB 커넥션을 Transaction 내부까지만 유지
(트랜잭션은 Service에서 수행되니까 Service까지만 유지되는 것)

  • 장점
    • DB 커넥션 리소스의 효율적인 사용
    • 트랜잭션을 종료할 때 영속성 컨텍스트를 닫으면서 DB 커넥션을 반환
  • 단점
    • 모든 지연로딩을 트랜잭션 안에서 처리
    • 지연로딩에 관한 모든 로직을 Service / Repository 에서 해결해야 함

해결방안

  • Command와 Query를 분리하는 방법 (김영한님 선호)
    • Controller에서 지연로딩을 처리해야 할 때 Query용 Service를 만드는 것
  • OrderService
    • 핵심 비즈니스 로직
  • OrderQueryService
    • 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션)

결론

결국 OSIV는 DB 커넥션 리소스에 대한 효율적인 사용과 관련된 전략이다.

OSIV 실무 TIP

실시간 API 고객 서비스를 해야한다면 OSIV false설정

ADMIN 처럼 커넥션이 많지 않은 곳 OSIV true 설정

728x90

'JPA' 카테고리의 다른 글

고급 주제와 성능 최적화 1  (0) 2022.08.06
컬렉션과 부가기능  (0) 2022.08.06
JPA metamodel must not be empty!  (0) 2022.08.06
[JPA] findAll, findById 차이  (0) 2022.08.06
728x90

Spring Data JPA

JPA를 사용해도 마찬가지로 DB의 기본적인 CRUD는 어찌됐든 사용하기 마련이다.
근데 이것을 가지고 똑같이 계속 사용해서 엔티티마다 만들어 주는 개념이 아니라
인터페이스에 제네릭 타입을 사용해서 엔티티를 매칭해주어 구현을 하는것이 편리하다.

이 repository를 개발할 때 인터페이스만 정의주면 실행 시점에 Spring Data JPA가 구현 객체를 동적으로 생성해서 주입해준다.
그래서 데이터 접근 계층을 개발할 때 구현 클래스 없이 인터페이스만 작성해도 개발이 가능 하다.

public class MemeberRepository extends JpaRepository<Member, Long> {
    List<Member> findAll();
}

이런식으로 구현을 해준다.
MemberRepository에는 @Repository 를 넣어주지 않는다.

왜냐면

JpaRepository를 상속받은 JpaRepositoryImplementation 를 구현한
SimpleJpaRepository

image
{: text-center}

그림과 같이 @Repository 를 가지고 있다.

그래서 인터페이스만 잘 작성해주면 된다.

제네릭에는 JpaRepository<엔티티, 식별자타입> 이렇게 만들어준다.

JpaRepository의 주요 메서드

  • save() : 새로운 엔티티는 저장하고 이미 있는 엔티티는 수정한다.
  • delete() : 엔티티 하나를 삭제한다. 내부에서 EntityManager.remove() 를 호출한다.
  • findOne() : 엔티티 하나를 조회한다. 내부에서 EntityManager.find() 를 호출한다.
  • getOne() : 엔티티를 프록시로 조회한다. 내부에서 EntityManager.getReference() 를 호출한다.
  • findAll() : 모든 엔티티를 조회한다. 정렬이나 페이징 조건을 파라미터로 전달할 수 있다.
  • findBy() : 엔티티 하나를 조회한다. Optional 타입의 객체를 반환한다.

반환 타입

Spring Data JPA는 만약 조회 결과가 없는 경우 컬렉션을 반환하는 메소드에서는 null을 반환한다.

단건으로 두 건 이상 겹쳐서 조회가 될 때 다음의 예제를 보자.

public interface MemberRepository extends JpaRepository<Member, Long> {
    Member findByName(String name);
}

@DataJpaTest
class MemberTest {
  @Test
  void 두건_조회_테스트() {
      Address address = Address.builder()
          .city("city")
          .street("street")
          .zipcode("zipcode")
          .build();

      Address comAddress = Address.builder()
          .city("cocity")
          .street("costreet")
          .zipcode("cozipcode")
          .build();

      Period period = Period.of("20210714", "20210714");

      Member member = Member.builder()
          .name("name")
          .age(26)
          .period(period)
          .homeAddress(address)
          .companyAddress(comAddress)
          .build();

      Member member2 = Member.builder()
          .name("name")
          .age(26)
          .period(period)
          .homeAddress(address)
          .companyAddress(comAddress)
          .build();

      memberRepository.save(member);
      memberRepository.save(member2);

      memberRepository.findByName("name");
  }
}

같은 삽입 코드를 두개 복사했다. 감안해서 보자.

일단 Member 엔티티인 member, member2를 저장하고 Name 컬럼의 값으로 하나를 가져오는 것인데, 복사해서 붙여넣었으니 같은 값이 즉 2개 이다.
하나의 데이터만 가져오지 못한다면,

image
{: text-center}

NonUniqueResultException 을 발생시킨다.

정확하게 1개 반환하는 것이 아니라면 List로 받는게 오히려 좋을 수도 있겠다.

findById()와 findAll()의 차이를 알고 싶다면

쿼리 메서드

Spring Data JPA가 제공하는 기능이다. 메소드 이름만으로 쿼리를 생성하는 기능이 있는데 인터페이스에 메소드만 선언해주면 해당 메소드 이름으로의 적절한 JPQL 쿼리를 생성해서 날려준다.

  • 메소드 이름으로 쿼리 생성
  • 메소드 이름으로 JPA NamedQuery 호출
  • @Query 사용하여 인터페이스에 직접 정의

쿼리 메소드의 규칙에 따라서 정의를 해주어야 동작한다.

쿼리 메소드 규칙

키워드 JQPL 예
And findByNameAndAddress where x.Name = ?1 and x.Address = ?2
Or findByNameOrAddress where x.Name = ?1 or x.Address = ?2
Distinct findDistinctByLastnameAndFirstname select distinct … where x.lastname = ?1 and x.firstname = ?2
Is、Equals findByFirstname, findByFirstnameIs, findByFirstnameEquals … where x.firstname = ?1
Between findByStartDateBetween … where x.startDate between ?1 and ?2
LessThan findByAgeLessThan … where x.age < ?1
LessThanEqual findByAgeLessThanEqual … where x.age <= ?1
GreaterThan findByAgeGreaterThan … where x.age > ?1
GreaterThanEqual findByAgeGreaterThanEqual … where x.age >= ?1
After findByStartDateAfter … where x.startDate > ?1
Before findByStartDateBefore … where x.startDate < ?1
IsNull、Null findByAge(Is)Null … where x.age is null
IsNotNull、NotNull findByAge(Is)NotNull … where x.age not null
Like findByFirstnameLike … where x.firstname like ?1
NotLike findByFirstnameNotLike … where x.firstname not like ?1
StartingWith findByFirstnameStartingWith … where x.firstname like ?1 ( %가 뒤에 추가된 매개 변수)
EndingWith findByFirstnameEndingWith … where x.firstname like ?1 ( %가 앞에 추가된 매개 변수)
Containing findByFirstnameContaining … where x.firstname like ?1 ( %가 래핑된 매개 변수)
OrderBy findByAgeOrderByLastnameDesc … where x.age = ?1 order by x.lastname desc
Not findByLastnameNot … where x.lastname <> ?1
In findByAgeIn(Collection ages) … where x.age in ?1
NotIn findByAgeNotIn(Collection ages) … where x.age not in ?1
True findByActiveTrue() … where x.active = true
False findByActiveFalse() … where x.active = false
IgnoreCase findByFirstnameIgnoreCase … where UPPER(x.firstname) = UPPER(?1)

NamedQuery

보통 @NamedQuery 로 정의하는 방식인데, 이거는 MyBatis를 쓰는 방식같은 느낌이 많이 든다.

이것은 전의 포스팅을 보면 이해가 쉬울 수 있다.

@Query, Repository 메소드에 쿼리 정의

이 방법이 더 간단하고 편리하게 정의할 수 있는 느낌이 더 강하다.

이름없는 NamedQuery 라고 생각하면 좋을듯? 싶다 😁

장점이라고 하면 애플리케이션 실행 시점에 문법 오류를 발견할 수 있어서 좋다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m from Memer m where m.name = ?1")
    Member findByName(String name);
}

사용은 이런식으로 하면 되겠다.

Native SQL을 사용하려면 @Query("sql", nativeQuery=true) 로 설정하면 되고 이 네이티브 쿼리에서는 파라미터 바인딩 숫자가 0부터 시작한다.

0부터 시작하면 뭐하나 인덱스 밀려서 쓰면 값 에러 날 수 있으니 권장하지 않는다.

위치 기준 파리미터 주의점

위치(순서) 기준보다는 이름 기준의 바인딩을 추천한다. 위치 기준의 바인딩을 사용하면 추후에 요소가 추가되면 밀리거나 땡겨질 수가 있기 때문❗️

그래서 아래와 같은 방법을 더 많이 쓴다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m from Memer m where m.name = :name")
    Member findByName(@Param("name") String name);
}

정렬

Spring Data JPA에는 쿼리 메소드에 페이징 그리고 정렬기능을 사용하도록 2가지 파라미터를 제공한다.

  • org.springframework.data.domain.Sort : 정렬 기능
  • org.springframework.data.domain.Pageable : 페이징 기능 (내부에 Sort) 포함

여기서 정렬 실습에 대한 에러도 같이 남긴다.

우선 sql DDL 그리고 DML 이다.

member.sql

CREATE TABLE IF NOT EXISTS MEMBER
(   ID              BIGINT GENERATED BY DEFAULT AS IDENTITY,
    NAME            VARCHAR(255),
    AGE             INTEGER NOT NULL,
    COMPANY_CITY    VARCHAR(255),
    COMPANY_STREET  VARCHAR(255),
    COMPANY_ZIPCODE VARCHAR(255),
    CITY            VARCHAR(255),
    STREET          VARCHAR(255),
    ZIPCODE         VARCHAR(255),
    END_DATE        VARCHAR(255),
    START_DATE      VARCHAR(255),
    PRIMARY KEY (ID)
);

INSERT INTO MEMBER (name, age, company_city, company_street, company_zipcode, city, street, zipcode, end_date, start_date) VALUES ('테스트', 33, 'city1', 'street', 'zipcode', 'cocity1', 'costreet', 'cozipcode', '20210714', '20210714');
같은 쿼리 여러개 넣었다고 생각하자.
...
...
...

처음에 이렇게 쿼리메소드를 구현하였다.🙂

public interface MemberRepository extends JpaRepository {
    //기존 로직
    ...

    Page<Member> findByName(String name, Pageable pageable);
}

그리고 테스트코드에서는...

Page<Member> members = memberRepository.findByName("테스트", PageRequest.of(0, 3, Sort.by(Order.desc("age"))));

이렇게 Page<T>를 사용하면 페이징 처리를 할 수있다.

첫 페이지는 0부터 시작한다.

이슈 발생

여기서 생긴 이슈는 분명 Sort.by(Order.desc("정렬할 컬럼")); 으로 정렬을 넣어줬음에도 쿼리에서는 정렬을 해주지 않았고 근데 정렬방식은 DESC로 나오는 경우였다.

findByName
{: text-center}

result
{: text-center}

이렇게 페이징을 위해 count쿼리는 나온다. 근데 where 이후로 order by 가 없는 것이다.

스터디를 하는 분과 같이 의논을 Code With Me로 같이 삽질을 좀 했다.

p.s 시간 내주셔서 감사합니다!

결과 알아낸 것은 내가 쿼리 메소드로 구현한건 findBy 로 시작하는 쿼리메소드를 오버로딩해서 Pageable만 추가해줬다.

이렇게 하니까 결국 기본으로 Spring Data JPA에서 정의된 쿼리메소드 참조를 하기 때문에 우선순위가 그쪽으로 배정되어서 조회는 ASC로 되는가보다.

해결책

public interface MemberRepository extend JpaRepository {
    Page<Member> findMembersByName (String name, Pageable pageable);
}

보다시피 메서드 이름을 findMembersByName 으로 바꿨고,

@DataJpaTest
@ActiveProfiles("test")
public class DbTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    @Sql("classpath:member.sql")
    void dbTest() throws Exception {
        Page<Member> members = memberRepository.findMembersByName("테스트", PageRequest.of(0, 3, Sort.by(Order.desc("age"))));


        Pageable pageable = members.getPageable();

        System.out.println("pageable = " + pageable);
        System.out.println("pageable.getSort() = " + pageable.getSort());
    }
}

이렇게 해주니까

findMembersByName
{: text-center}

잘 나오게 된다.

2021-08-22 수정본

위 부분은 사실 단순한 해결이었고, 진정한 해결책은 우선순위는 @NamedQuery 에 있다.

실습때문에 Member 엔티티에 NamedQuery를 추가했었어서 이 부분이 먼저 구현되어 비정상적으로 정렬이 됐던 것이다. 스터디원들 다 같이 모여 해결했다. 이럴때 집단지성이 굉장히 좋은것 같다👍

아무튼 구문자체에는 오류가 없었고 네임드 쿼리 쓰지말자! Mybatis를 보면 볼 수록 생각나게 한다. 이제 정을 떼는것이 좋을것 같다.

좀 더 편리하게 사용하려면 interface다중상속이 가능하기 때문에
이렇게 상속해서 사용해도 될 것 같다.

이상으로 12장 포스팅을 마치도록 하겠다.

728x90

'Spring' 카테고리의 다른 글

Jasypt  (0) 2022.08.07
Spring -> Spring Boot 마이그레이션 2  (0) 2022.08.06
Spring -> Spring Boot 마이그레이션  (0) 2022.08.05
[Spring] MockMvc Bean 주입 에러  (0) 2022.08.04

+ Recent posts