기록

[Spring Boot] 2. JPA 본문

Bootcamp/Spring Boot

[Spring Boot] 2. JPA

서기A 2023. 9. 4. 15:45

엔코아 플레이데이터(Encore Playdata) Backend 2기 백엔드 개발 부트캠프 (playdata.io)

 

백엔드 개발 부트캠프

백엔드 기초부터 배포까지! 매력있는 백엔드 개발자 포트폴리오를 완성하여 취업하세요.

playdata.io


1.  EntityManagerFactory

클라이언트의 요청이 올때마다 엔티티 매니저 팩토리가 엔티티 매니저를 생성하여, 엔티티 매니저가 내부적으로 DB 커넥션 풀을 통해 DB 를 사용하게 된다.

EntityManagerFactory 동작 구조

EntityManagerFactory는 여러 스레드에서 동시에 접근해도 안전하지만, 생성하는 비용이 상당히 크다.

때문에 클라이언트의 요청이 올 때마다 생성 비용이 거의 없는 EntityManager를 생성해 사용한다.

이 때 만들어진 EntityManager는 내부적으로 DB Connection Pool을 사용해 DB를 사용하게 된다.

 

이때 참고할 점은, EntityManager는 ThreadSafe 하지 않기 때문에 여러 스레드가 동시에 접속하면 동시성 문제가 발생할 수 있다는 점이다.

이 부분을 스프링은 EntityManager를 주입할 때 가짜 프록시 엔티티 매니저를 주입해, 해당 엔티티 매니저를 호출하면, 현재 쓰레드가 사용하는 트랜잭션에 묶여 있는 영속성 컨텍스트를 호출하는 방식으로 해결하였다.


2.  영속성 컨텍스트

엔티티를 영구 저장하는 환경이라는 뜻으로, 엔티티 매니저를 통해 Entity가 영속성 컨텍스트에 접근, 내부에 저장된다.

 

영속성 컨텍스트는 트랜잭션 단위로 생성되었다가 소멸된다.

즉, JPA 가 동작하려면 영속성 컨텍스트가 필요하고, 영속성 컨텍스트는 트랜잭션 단위로 생성되고 소멸되기 때문에 JPA는 트랜잭션 단위로 동작한다는 것을 알 수 있다.

이 트랜잭션 내에서 개발자가 집어 넣거나 DB 에서 SELECT 한 모든 Entity는 영속성 컨텍스트의 관리를 받게 된다.

영속성 컨텍스트 구조

1. 엔티티의 생명주기

1) 비영속
영속성 컨텍스트와 관계가 없는 새로운 상태로, Entity 객체를 새로 생성한 상태를 의미한다.

Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

2) 영속
엔티티가 영속성 컨텍스트에게 관리되는 상태로, 생성한 Entity 객체를 EntityManager.persist()를 통해 영속성 컨텍스트 내에 저장하는 것이다.
이 때 주의할 점은, 저장만 하는 것이기 때문에 실제로 쿼리 바로 나가지 않는다. 쿼리는 commit() 시점에 날아가게 된다.

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(member);

3) 준영속
영속성 컨텍스트에 저장되었다가 분리 된 상태로, 영속성 컨텍스트 내 1차 캐시에서 객체를 삭제하는 것을 의미한다.

em.detach(member);

4) 삭제
실제 DB에게 삭제 요청을 하는 것을 의미한다.

em.remove(member);

 

2. 영속성 컨텍스트의 구조

영속성 컨텍스트는 애플리케이션과 DB 사이에 중간 계층이 존재한다. 때문에 중간 계층에서 버퍼링이나 캐싱을 할 수 있다.

* 캐싱 예시
<property name="hibernate.jdbc.batch_size" value="10"/>
영속성 컨텍스트에 10개씩 모았다가 쿼리를 날리는 기능 (버퍼링)

 

1) 1차 캐시
1차 캐시는 트랜잭션 내에서 영속성 컨텍스트를 지나는 모든 Entity를 저장하는 곳이다.

즉, 개발자가 persist를 통해 집어 넣거나 SELECT 를 통해 조회해온 모든 Entity가 1차 캐시에 저장된다.

 

이 1차 캐시에 Entity가 저장될 때 기본적으로 Entity의 기본키와, 이름으로 객체를 저장하게 되고, 만일 1차 캐시에 처음 들어오는 Entity거나 변경을 마친 Entity 라면 스냅샷을 떠서 함께 저장하게 된다.

 

왜 이런 구조를 띄는지 생각해보면, 한 트랜잭션 내에서 데이터를 조회할 때마다 DB에 다녀오는 것은 비효율적이라 볼 수 있다. 때문에 첫 조회 때 1차 캐시에 데이터를 담아두고, 이 후 트랜잭션이 종료되기 전에 동일한 엔티티를 조회하게 되면 1차 캐시에 임시 저장해둔 엔티티를 반환하여, DB 까지 다녀오는 자원을 줄일 수 있게 되는 것이다.

1차캐시에서 조회하는 예시
Member findMember = em.find(Member.class, "member1");

즉, find 메서드 실행 시 일단 영속성 컨텍스트를 조회해 데이터가 있는지 확인 후, 있으면 해당 객체를 반환하고
없으면 select 쿼리를 실행해 통해 DB로 가서 영속성 컨텍스트 내부에 해당 객체를 저장, 반환하게 된다.

 

2) 동일성 (identity) 보장
영속성 컨텍스트에서 동일한 조회 쿼리를 날리면, 같은 엔티티가 반환된다.

* 1차 캐시가 비어있는 상황
1. 처음에 데이터를 조회하면 SELECT 가 DB로 날아가고, 조회한 Entity를 1차 캐시에 저장, 반환한다.
2. 이후 동일한 데이터를 조회하면 1차 캐시에 저장한 Entity를 반환한다.

* 1차 캐시 내에 데이터가 있는 상황
1. 처음 데이터를 조회하면 1차 캐시 내의 Entity를 반환한다.
2. 두번째로 데이터를 조회하면 마찬가지로 1차 캐시 내의 Entity를 반환한다.

 

3) 트랜잭션을 지원하는 쓰기 지연 (Transactional Write-Behind)

쓰기 지연이란,  em.persist(member) 를 통해 영속성 컨텍스트에 저장하는 시점에 INSERT를 DB로 날리는 것이 아니라,
트랜잭션을 commit()을 하는 시점에 flush가 호출되어, INSERT 를 DB로 날린다.


4) 변경 감지 (Dirty Checking)
어플리케이션 내에서 setter 등을 통해 Entity의 값이 달라지면, JPA가 commit 되는 시점에 1차 캐시 내에 저장해둔 스냅샷과 저장 되어있는 Entity를 비교하여 서로 다르다면 UPDATE 를 알아서 만들어 DB로 보낸다.

 

때문에 기본적으로 JPA에서 UPDATE가 일어난다면, 쿼리를 직접 작성하는 것이 아니라 영속성 컨텍스트의 변경감지 기능을 통해 UPDATE 시키는 것이 옳다.


5) 지연 로딩 (lazy loading)

3. flush
영속성 컨텍스트의 변경 내용을 DB에 반영하는 것으로, 보통 트랜잭션이 commit 될때 플러시가 일어난다.

JPQL을 사용하는 등 flush가 먼저 실행되는 경우도 있는데,  이때 쿼리가 DB로 날아간다고 커밋되는 것은 아니다.
즉, 영속성 컨텍스트의 변경사항과 DB에 저장 된 데이터를 서로 맞춰주는 작업을 해준다. 


1) flush 가 발생하는 경우

1. 변경 감지
2. 수정 된 엔티티를 쓰기 지연 SQL 저장소에 등록할 때
3. 쓰기 지연 SQL 저장소의 쿼리를 DB에 전송할 때 (등록, 수정, 삭제 쿼리)

2) flush 방법

1. em.flush() : flush 직접호출
2. 트랜잭션 commit : flush 자동 호출
3. JPQL 쿼리 실행 : flush 자동 호출

JPQL은 그저 JPA 형식으로 쿼리를 작성해, 실제 DB에서 쓰는 SQL로 변경해주는 역할을 한다.
즉, JPQL이 SQL로 번역되서 날아갈 때 DB에 데이터가 없다면 에러가 발생할 수있다.
때문에 JPQL 실행 시엔 자동으로 flush 가 호출된다.


주의할 점은, flush를 한다고 1차 캐시를 비우는게 아니다. 오직 영속성 컨텍스트 내 쓰기 지연 SQL 저장소에 있는 내용을 DB에 반영 시킬 뿐이다. 때문에 flush를 강제 호출 한 뒤엔 em.clear()로 영속성 컨텍스트를 초기화하는게 좋다.


5. 준영속
1차 캐시에 저장되어 있는 영속 상태의 Entity가 영속성 컨텍스트에서 분리되는 것을 의미한다. (detach)
때문에 영속성 컨텍스트가 제공하는 기능을 사용할 수 없게된다. (변경 감지 등)

1) 준영속 상태로 만드는 방법

1. em.detach() : 특정 엔티티만 준영속 상태로 전환
2. em.clear() : 영속성 컨텍스트 완전 초기화
3. em.close() : 영속성 컨텍스트 종료

3. JPA 실습_1) DAO

Spring Data JPA가 제공하는 메서드를 통해 DAO의 기능을 구현해보았다.

 

여기서 눈여겨 봐야할 점은 updateProductName() 이다. 해당 부분은 기존 데이터를 update 하기 위해,

1. findById() 를 통해 수정할 데이터를 가져온다.
2. setter 를 통해 데이터를 수정한다.
3. save() 를 통해 수정한 데이터를 DB에 반영한다.

이 순서로 코드가 진행된다. 하지만 여기서 save() 의 코드를 보자면,

Spring Data JPA 의 save()

Entity가 새로만들어진 것이면 의도대로 persist() 가, 반면 새로 만들어진 Entity 가 아니라면 merge() 가 동작하는 것을 알 수 있다.

이 부분이 문제인데, merge() 는 평범한 UPDATE 가 아니라 SELECT + UPDATE 의 조합으로, UPDATE 만 나가는 것이 아니라 SELECT 도 동시에 나간다. 때문에 개발자의 의도와는 다르게 하나의 쿼리가 더 포함되서 나가는 것이다. 

 

JPA에서 기본적으로 UPDATE는 변경감지를 통해서 동작하게 되어있다. 때문에 Spring Data JPA 에 update() 라는 메서드는 존재하지 않고, 해당 부분이 merge() 로 구현되어 있는 것이다.

그렇다면 merge() 를 언제 사용해야 할까?

merge()는 SELECT 와 UPDATE의 조합이기 때문에 준영속 상태의 Entity를 영속상태로 전환할 수가 있다.
즉, merge()는 detach() 되어 영속성 컨텍스트의 관리에서 벗어난 Entity를 다시 영속성 컨텍스트의
관리 하에 포함시키고 싶을 때 사용할 수 있다.

해당 메서드의 실행 결과는 다음과 같다.

updateProductName() 실행 결과

분명 merge()로 되어있기 때문에 한번의 SELECT 가 더 동작할 것이라 예상했지만, 실제로 나간 SELECT 는 하나이다.

왜냐하면, findById() 에서 데이터를 가져와 영속성 컨텍스트의 1차 캐시 내에 저장했기 때문에 merge() 가 동작하긴 했지만 실제로 SELECT 가 나가지는 않고 1차 캐시 내에 저장한 데이터를 조회해 왔기 때문이다.

 

그러나 눈에 보이진 않지만 SELECT 라는 동작이 이미 실행은 된 것과 마찬가지기 때문에, merge() 를 구별없이 사용하면

개발자의 의도와 다른 여러 조회 쿼리가 나갈 가능성이 높다. 때문에 UPDATE 시에는 최대한 영속성 컨텍스트의 변경 감지를 통해 UPDATE를 하고, 한번에 여러 UPDATE를 해야하는 경우에는 벌크 연산을 사용하는게 맞다고 생각한다.

@RequiredArgsConstructor
@Component
public class ProductDAOImpl implements ProductDAO {

    /**
     * @RequiredArgsConstructor 를 통한 생성자 주입
     */
    private final ProductRepository productRepository;

    /**
     * Spring Data JPA 내의 save() 호출
     * 파라미터로 받아온 product 를 영속성 컨텍스트를 통해 DB에 저장
     * @param product
     * @return savedProduct
     */
    @Override
    public Product insertProduct(Product product) {
        Product savedProduct = productRepository.save(product);
        return savedProduct;
    }

    /**
     * Spring Data JPA 의 getById() 호출
     * 영속성 컨텍스트 내에 Product 객체를 상속받은 프록시 객체 생성
     * 이 후 해당 객체에 접근하는 시점에 DB에 해당 쿼리가 날아간다.
     * @param number
     * @return selectedProduct
     */
    @Override
    public Product selectProduct(Long number) {
        Product selectedProduct = productRepository.getById(number);
        return selectedProduct;
    }

    /**
     * Spring Data JPA 의 findById() 호출
     * 영속성 컨텍스트 내 1차 캐시 조회
     * 조회 후 저장 된 객체가 없다면, flush 를 통해 DB 에서 데이터를 가져와 영속성 컨텍스트에 저장, Optional 형태로 반환
     * @param number
     * @param name
     * @return updatedProduct
     */
    @Override
    public Product updateProductName(Long number, String name) throws Exception {
        Optional<Product> selectedProduct = productRepository.findById(number);

        Product updatedProduct;
        if (selectedProduct.isPresent()) {
            Product product = selectedProduct.get();
            product.setName(name);
            product.setUpdateAt(LocalDateTime.now());

            updatedProduct = productRepository.save(product);
        } else {
            throw new Exception();
        }

        return updatedProduct;
    }

    /**
     * Spring Data JPA 의 findById() 호출
     * 영속성 컨텍스트 내 1차 캐시 조회
     * 조회 후 저장 된 객체가 없다면, flush 를 통해 DB 에서 데이터를 가져와 영속성 컨텍스트에 저장, Optional 형태로 반환
     * Spring Data JPA 의 delete() 호출
     * @param number
     */
    @Override
    public void deleteProduct(Long number) throws Exception {
        Optional<Product> selectedProduct = productRepository.findById(number);

        if (selectedProduct.isPresent()) {
            Product product = selectedProduct.get();

            productRepository.delete(product);
        } else {
            throw new Exception();
        }
    }
}

4. JPA 실습_2) Service

컨트롤러를 통해 들어온 요청을 JPA 로 만든 DAO에 전달, ResponseDto 를 만드는 역할을 하는 서비스를 구현하였다.

 

이 때 RequestDto 객체를 통해 DAO 의 파라미터로 사용되는 Product 객체를 만드는 부분과,

DAO에서 return 받은 Product 객체를 통해 ResponseDto 객체를 만드는 것은 공통 사항이기 때문에,

메서드를 추출해서 구현하였다.

@RequiredArgsConstructor
@Service
public class ProductServiceImpl implements ProductService {

    /**
     * @RequiredArgsConstructor 를 통한 생성자 주입
     */
    private final ProductDAO productDAO;

    /**
     * ProductDAO 내의 selectProduct() 호출, SELECT 된 Product 객체를 return 받음
     * setProductResponseDto() 호출, ProductResponseDto 객체 생성 및 set
     * @param number
     * @return setProductResponseDto(product)
     */
    @Override
    public ProductResponseDto getProduct(Long number) {
        Product product = productDAO.selectProduct(number);
        return setProductResponseDto(product);
    }

    /**
     * setProduct() 호출, Product 객체 생성 및 set
     * ProductDAO 내의 insertProduct() 호출, INSERT 된 Product 객체를 return 받음
     * setProductResponseDto() 호출, ProductResponseDto 객체 생성 및 set
     * @param productRequestDTO
     * @return setProductResponseDto(savedProduct)
     */
    @Override
    public ProductResponseDto saveProduct(ProductRequestDto productRequestDTO) {
        Product product = setProduct(productRequestDTO);
        Product savedProduct = productDAO.insertProduct(product);
        return setProductResponseDto(savedProduct);
    }

    /**
     * ProductDAO 내의 updateProductName() 호출, UPDATE 된 Product 객체를 return 받음
     * setProductResponseDto() 호출, ProductResponseDto 객체 생성 및 set
     * @param number
     * @param name
     * @return setProductResponseDto(changedProduct)
     */
    @Override
    public ProductResponseDto changeProductName(Long number, String name) throws Exception {
        Product changedProduct = productDAO.updateProductName(number, name);
        return setProductResponseDto(changedProduct);
    }

    /**
     * ProductDAO 내의 deleteProduct() 호출, Product DELETE
     * @param number
     */
    @Override
    public void deleteProduct(Long number) throws Exception {
        productDAO.deleteProduct(number);
    }

    /**
     * Product 객체를 통해 ProductResponseDto 객체 set, return
     * @param product
     * @return productResponseDto
     */
    private ProductResponseDto setProductResponseDto(Product product) {
        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setNumber(product.getNumber());
        productResponseDto.setName(product.getName());
        productResponseDto.setPrice(product.getPrice());
        productResponseDto.setStock(product.getStock());
        return productResponseDto;
    }

    /**
     * ProductRequestDto 객체를 통해 Product 객체 set, return
     * @param productRequestDTO
     * @return product
     */
    private Product setProduct(ProductRequestDto productRequestDTO) {
        Product product = new Product();
        product.setName(productRequestDTO.getName());
        product.setPrice(productRequestDTO.getPrice());
        product.setStock(productRequestDTO.getStock());
        product.setCreatedAt(LocalDateTime.now());
        product.setUpdateAt(LocalDateTime.now());
        return product;
    }
}

5. JPA 실습_3) RestController

Swagger를 통해 Http Method를 호출하여, API가 잘 동작하는지 확인해보았다.

@RequiredArgsConstructor
@RestController
@RequestMapping("/product")
public class ProductController {

    /**
     * @RequiredArgsConstructor 를 통한 생성자 주입
     */
    private final ProductService productService;

    /**
     * ProductService 내의 getProduct() 호출, ProductResponseDto 객체를 return 받음
     * Message Body 에 ProductResponseDto 객체를 담아 response
     * @param number
     * @return ResponseEntity.status(HttpStatus.OK).body(productResponseDto)
     */
    @GetMapping
    public ResponseEntity<ProductResponseDto> getProduct(Long number) {
        ProductResponseDto productResponseDto = productService.getProduct(number);
        return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
    }

    /**
     * ProductService 내의 saveProduct() 호출, ProductResponseDto 객체를 return 받음
     * Message Body 에 ProductResponseDto 객체를 담아 response
     * @param productRequestDTO
     * @return ResponseEntity.status(HttpStatus.OK).body(productResponseDto)
     */
    @PostMapping
    public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductRequestDto productRequestDTO) {
        ProductResponseDto productResponseDto = productService.saveProduct(productRequestDTO);
        return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
    }

    /**
     * ProductService 내의 changeProductName() 호출, ProductResponseDto 객체를 return 받음
     * Message Body 에 ProductResponseDto 객체를 담아 response
     * @param changeProductNameDto
     * @return ResponseEntity.status(HttpStatus.OK).body(productResponseDto)
     */
    @PutMapping
    public ResponseEntity<ProductResponseDto> changeProductName
            (@RequestBody ChangeProductNameDto changeProductNameDto) throws Exception {
        ProductResponseDto productResponseDto = productService.changeProductName(changeProductNameDto.getNumber(), changeProductNameDto.getName());
        return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
    }

    /**
     * ProductService 내의 deleteProduct() 호출, DELETE
     * Message Body 에 String 을 담아 response
     * @param number
     * @return ResponseEntity.status(HttpStatus.OK).body("정상적으로 삭제되었습니다.")
     */
    @DeleteMapping
    public ResponseEntity<String> deleteProduct(Long number) throws Exception {
        productService.deleteProduct(number);
        return ResponseEntity.status(HttpStatus.OK).body("정상적으로 삭제되었습니다.");
    }

}

6. 37일차 후기

JPA 에서 가장 중요하다고 생각 되는 것은 영속성 컨텍스트와 연관 관계 매핑인데, 그 중 영속성 컨텍스트에 관련한 내용으로 실습을 진행하였다.

 

실무에서 JPA를 사용하기 위해서는 영속성 컨텍스트가 어떤 역할을 하는지, 어떤 구조로 동작하는지를 반드시 알아야 JPA를 사용하며 의도하지 않은 동작이 발생했을 때 수정이 가능하다.

왜냐하면 영속성 컨텍스트의 역할을 제대로 알지 못한다면 어느 시점에 쿼리가 나가는지, 왜 의도하지 않은 쿼리가 나가는지 전혀 알 수 없기 때문이다.

 

때문에, 앞으로 배울 Spring Data JPA, QueryDSL 을 제대로 활용하려면 반드시 해당 부분을 완전히 이해하고 넘어가야 한다는 생각이 들었다.