JPA를 사용할 때, 용어와 개념 실무에서 발생할수 있는 실수들을 정리해보았습니다.
그전에 JPA가 어떤 역할을 하는지 확인해보겠습니다.
SQL문을 통해 DB를 제어하는 것과 달리, Method를 통해 DB를 조작하여
개발자가 객체 모델을 이용한 비즈니스 로직에 집중할 수 있게 돕습니다.
Spring Data JPA를 만약 사용하신다면, JPA를 편하게 사용할 수 있도록 추상화되어 내부적인 동작을 이해할 필요가 있습니다.
JPA의 동작 원리를 확인하고, Spring Data JPA에서 동작하는 방식을 확인해보겠습니다.
위 그림으로 하나씩 설명해보겠습니다.
용어부터 알아보겠습니다.
flush : persistence context의 변경 내용을 db에 반영합니다. 일반적으로는 commit명령이 들어오면 자동으로 실행됩니다.
- 엄밀히 말하면, commit 명령 직전에 flush가 실행됩니다.
detach : entity를 준영속상태로 바꾼다.
clear : persistence context를 초기화 한다.
close : persistence context를 종료한다.
merge : 준영속 상태를 영속 상태로 만든다.
find : 식별자를 이용해 entity를 찾는다. find로 받은 객체는 영속 상태로 관리됩니다.
persist : 생성된 entity를 persistence context에 저장한다.
remove : 식별자 값을 통해 entity를 삭제한다.
여기서 persistence context는 1차 캐시라고 생각하시면됩니다.
Persistence Context에 따로 저장했더라도, DB에 저장되지 않은 상태에서 detach, clear, close를 한다면 해당 정보는 DB에 저장되지 않습니다.
예를 통해 알아보겠습니다.
// 1. 영속 상태
Member member = em.find(Member.class, 1L);
// 2. detach
em.detach(member); // 이제 member는 준영속 상태 member의 메모리 상 관리만 하고 변경 감지 X
// 3. 값 변경
member.setName("변경됨");
// 4. flush 해도 DB 반영 안됨
em.flush(); // 아무 변화 없음
// 5. 다시 영속화
Member merged = em.merge(member); // 병합 후 다시 영속 상태로
그렇다면, 어짜피 데이터베이스에 저장할 것도 아닌데 영속상태와 비영속 상태를 구분해서 학습곡선을 높이는 걸까요?
우선, 영속 상태에 들어가게되면 1차 캐시에 저장되고 변경 감지를 하기 위해
트랜잭션 commit 시에 flush 비용이 발생합니다.
항상 영속 상태로 두면 캐시는 쌓이게 됨으로 out of memory가 될 수 있고, 비용적도 높습니다.
따라서 비영속 상태로 바꾸게 됩니다. 비영속 상태로 바꾸게 되면 setter를 통해 변경을 하더라도 DB에 따로 반영되지 않습니다.
Member member = em.find(Member.class, 1L);
em.detach(member); // 이제 변경해도 DB 반영 안 됨
사용 사례는 아래와 같습니다.
- 엔티티를 잠깐 분리해서 DB와 연결을 끊고 싶을 때
- Web 등 계층 분리된 아키텍처에서 자주 사용됨 - 서비스 계층에서 영속성 컨텍스트가 끝났지만, 엔티티를 DTO처럼 클라이언트에게 전달해야 할 때(이때 전달된 객체는 자동으로 준영속 상태가 됩니다.)
- 수정 시에 준영속 객체를 통해 DB변환을 요청하는 데 사용
persist(), merge() 전부 저장하는 것 아닌가?
- persist와 merge의 차이
구분 persist() merge()
상태 | 비영속 → 영속 | 준영속 또는 새로운 객체 → 영속 (복사) |
ID 필드 | null이어야 함 (새 객체 저장) | null이거나 기존 ID가 있어도 됨 |
반환값 | void (그 객체가 바로 영속됨) | 새로운 영속 객체 반환, 원래 객체는 여전히 준영속 |
동작 방식 | 영속성 컨텍스트에 등록해서 관리 시작 | 기존 영속 객체 찾고, 값 복사해서 반환 |
사용 용도 | 새 엔티티 저장 | 수정된 준영속 엔티티 반영 |
예외 | ID 존재하면 EntityExistsException 발생 | 없음. 없는 ID면 새로 insert함 |
실수할 수 있는 것들!
1. flush() 없이 DB에 반영된다고 착각
// persist()
Member member = new Member(); // 비영속
member.setName("홍길동");
em.persist(member); // 영속 상태
// member == 영속된 객체 (변경감지 적용)
// merge()
Member detached = new Member(); // 준영속 또는 새 객체
detached.setId(1L);
detached.setName("변경된 이름");
Member merged = em.merge(detached);
// detached는 여전히 준영속 <-- 정확히 알것
// merged는 영속 상태 (복사된 객체)
detached.setName("바뀐이름");
// 준 영속이기에 더 이상 영속 아님 ==> 업데이트 되지 않음
2. em.clear() 호출 후 객체가 영속 상태 줄알고 수정
Member member = em.find(Member.class, 1L);
em.clear(); // 모든 영속 상태 객체 날아감 (1차 캐시 비움)
member.setName("변경된 이름"); // ❌ 더 이상 영속 아님 → UPDATE 안 됨!
3. 같은 객체 여러개 만들고 비교시 다름
Member m1 = em.find(Member.class, 1L);
em.clear(); // 1차 캐시 제거
Member m2 = em.find(Member.class, 1L);
System.out.println(m1 == m2); // false
4. 트랜잭션 바깥에서 엔티티 수정
Member member = em.find(Member.class, 1L);
em.getTransaction().commit(); // 트랜잭션 종료 → 영속성 컨텍스트 종료
member.setName("수정"); // ❌ 변경 감지 안 됨 → DB 반영 X
JPA는 하나의 트랜잭션으로 관리한다. 따라서 프로세스에서 벗어나면, commit되었기에 영속 상태로 관리되지 않아
수정되지 않습니다. 따라서 트랜잭션에 대한 관리를 정확하게 구현해야합니다.
예를 하나 들어보겠습니다. serviceA, serviceB가 동일한 트랜잭션으로 관리하는 상황입니다.
컨트롤러에서 @Transactional을 넣으면 될까요?
Spring AOP에서 프록시가 붙도록 설정되어 있지 않기에 Transational이 적용되지 않습니다.
그리고 권장하는 방법이 아닙니다.
컨트롤러는 웹과 관련된 업무를 하고, 서비스에서 비즈니스 로직을 담당하기 때문입니다.
@Service
@Transactional // ✅ 트랜잭션 시작
public class CoordinatorService {
public void runBoth() {
serviceA.doSomething();
serviceB.doSomething();
}
}
@Service
@Transactional // ✅ 있어도 기존 트랜잭션에 참여함
public class ServiceA {
public void doSomething() {
// 기존 트랜잭션 사용
}
}
@Service
@Transactional // ✅ 있어도 기존 트랜잭션에 참여함
public class ServiceB {
public void doSomething() {
// 기존 트랜잭션 사용
}
}
위처럼 Transactional에 있다는 것을 코드를 작성하는 사람도 확인할 수 있고, 별도의 트랜잭션 전파방식을 변경해 데이터를 관리하는데 용이하게 만들 수도 있습니다.
상위 서비스가 @Transactional, 하위도 있음 | 하위는 참여 |
상위만 @Transactional, 하위는 없음 | 하위도 참여 |
하위만 @Transactional, 상위는 없음 | 하위에서 시작됨 |
하위에 @Transactional(REQUIRES_NEW) | 하위는 새 트랜잭션 생성 |
기본적으로 전파방식은 REQUIRES이고, REQUIRES_NEW, NESTED, NOT_SUPPORTED도 있습니다.
이를 로그로 확인해보고 싶다면 application.yaml에서 아래와 같이 만들면 확인해볼 수 있습니다.
# application.yml
logging:
level:
org.hibernate.SQL: debug
org.springframework.transaction.interceptor: trace
JPA에서 JPARepository가 적용되는 방식 이해하기
JPA는 기본적으로 하나의 트랜잭션에서 수행됩니다. 따라서 기본적인 JPA를 적용할 때, 다음과 같이 적용해줘야 합니다.
// 순수한 JPA의 형태 --> Spring Data JPA가 등
public static void main(String[] args) {
EntityManager em = factory.createEntityManager();
EntityTransaction tr = em.getTransaction();
tr.begin();
try {
Member member = new Member();
member.setUsername("Scott");
em.persist(member); // db 저장
Member findMember = em.find(Member.class, member.getId()); // select 쿼리
System.out.println(findMember.getName());
transaction.commit();
} catch (Exception e) {
tr.rollback();
} finally {
em.close();
factory.close();
}
}
@Repository
public class MemberRepository {
@PersistenceContext;
EntityManager em;
public void save(Member member) {
em.persist(member);
}
public void findByUsername(String name) {
return em.createQuery("SELECT m FROM Member m WHERE m.name = :name", Member.class)
.setParameter("name", name)
.getResult();
}
public void validateUsername(Member member) {
List<Member> findMembers = memberRepository.findByUsername(member.getUsername());
if(!findMembers.isEmpty()) {
throw new IllegalStateException("이미 존재하는 회원");
}
}
}
하지만 모든 DB를 위와 같은 방법으로 설정하기에는 반복되는 코드가 많고 가독성도 좋지 않습니다.
따라서 Spring Data JPA에서는 다음 그림과 같은 의존성을 가집니다.
이렇게 만들어진 JpaRepository는 위 코드를 아래와 같이 바꿀 수 있습니다.
public interface UserRepository extends JpaRepository<User, Long>{
}
- findAll() : 해당 엔티티 테이블에 있는 모든 데이터를 조회한다.
- save() : 대상 엔티티를 DB에 저장한다.
- saveAll() : Iterable 가능한 객체를 저장한다.
- delete() : 데이터베이스에서 대상 엔티티를 삭제한다.
와 같은 코드를 만들어 놓아 약속된 방법으로 사용을 하는 것입니다.
조금 더 자세하게 의존된 Repository를 살펴보겠습니다.
우선, CrudRepository입니다.
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
Optional<T> findById(ID primaryKey);
Iterable<T> findAll();
long count();
void delete(T entity);
boolean existsById(ID primaryKey);
// … more functionality omitted.
}
위 와 같은 기능들을 지원합니다.
이번에는 PagingAndSortingRepository입니다.
public interface PagingAndSortingRepository<T, ID> {
Iterable<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
}
참고) flush()만 된 상태에서도 select 할수 있을까요?
네. 조건부로 할수 있습니다.
트랜잭션 내에서만 유효하며, 변경 사항을 볼 수 있는 건 DB가 아니라
JPA의 1차 캐시 혹은 실제 DB에 flush로 보낸 경우입니다.
참고) JPA는 하나의 트랜잭션 단위로 관리된다면, 스레드는 어떻게 관리가 되는가?
JpaTransactionManager는 EntityManager를 ThreadLocal에 보관합니다.
따라서 @Transactional 메서드가 실행되면, 현재 스레드에 알맞은 트랜잭션 및 EntityManager를 자동 할당 스레드마다 entity manager를 사용해야합니다.
entity manager는 스레드-세이프 아님!에 유의하세요.
참고) 만약 하나의 트랜잭션에 2개의 스레드를 사용하고 싶다면 어떻게 해야할까?
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> {
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
em1.persist(new Member("스레드1"));
em1.getTransaction().commit();
em1.close();
});
@RestController
@RequiredArgsConstructor
@Transactional // 가능은 함 (다만, Spring AOP 프록시가 붙도록 설정되어야 함)
public class MyController {
private final ServiceA serviceA;
private final ServiceB serviceB;
@PostMapping("/run")
public ResponseEntity<String> run() {
serviceA.doSomething();
serviceB.doSomething();
return ResponseEntity.ok("ok");
}
}
'기술 스텍 > Spring' 카테고리의 다른 글
IntelliJ 단축키(mac) (0) | 2024.11.28 |
---|---|
JPA에서 IDENTITY상태일때, persist할 때 DBMS에 반영되는가? (0) | 2024.06.17 |
Spring Cloud (0) | 2023.10.11 |
SpringBoot(1) DI (0) | 2023.01.24 |