네로개발일기

개발자 네로의 개발 일기, 자바를 좋아합니다 !

'web/Spring'에 해당되는 글 67건


반응형

Dirty Checking

Spring Data JPA와 같은 ORM 구현체를 사용하다보면 더티체킹을 만나볼 수 있다.

@Slf4j
@RequiredArgsConstructor
@Service
public class PayService {

    private final EntityManager em;

    public void updateNative(Long id, String tradeNo) {
        EntityTransaction tx = em.getTransaction();
        
        tx.begin();
        
        Pay pay = em.find(Pay.class, id);
        pay.changeTradeNo(tradeNo);
        
        tx.commit();
    }
}

save()를 하지 않는다.

1. 트랜잭션이 시작되고

2. 엔티티를 조회하고

3. 엔티티의 값을 변경하고 

4. 트랜잭션을 커밋한다.

 

update 쿼리는 없다.

 

@RunWith(SpringRunner.class)
@SpringBootTest
public class PayServiceTest {
    
    @Autowired
    PayRepository payRepsitory;
    
    @Autowired
    PayService payService;
    
    @After
    public void tearDown() throws Exception {
        payRepository.deleteAll();
    }
    
    @Test
    public void updateDirtyChecking() {
        // given
        Pay pay = payRepository.save(new Pay("test1", 100));
        
        // when
        String updateTradeNo = "test2";
        payService.updateNative(pay.getId(), updateTradeNo);
        
        // then
        Pay saved = payRepository.findAll().get(0);
        assertThat(save.getTradeNo()).isEqualTo(updateTradeNo);
    }
}

save() 메서드로 저장하지 않아도 dirty checking으로 update 쿼리가 실행되었다.

 

JPA에서는 트랜잭션이 끝나는 시점에 변화가 있는 모든 엔티티 객체를 데이터베이스에 자동으로 반영해준다.

상태 변경 검사의 대상은 영속성 컨텍스트가 관리하는 엔티티에만 적용된다.

 

detach된 엔티티(준영속), DB에 반영되기 전 처음 생성된 엔티티(비영속) 상태의 엔티티는 Dirty Checking 대상이 아니다.

 

변경 부분만 update 하고 싶을 때는

Dirty Checking으로 생성되는 update 쿼리는 기본적으로 모든 필드를 업데이트한다.

 

JPA는 전체 필드를 업데이트하는 방식을 기본값으로 사용한다.

전체 필드를 업데이트하는 방식의 장점은 다음과 같다.

- 생성되는 쿼리가 같아 부트 실행시점에 미리 만들어서 재사용이 가능하다.

- 데이터베이스 입장에서 쿼리 재사용이 가능하다. (동일한 쿼리를 받으면 이전에 파싱된 쿼리를 재사용한다.)

 

필드가 20~30개 이상인 경우에 전체 필드 update가 부담스러운 경우 (이런 경우 정규화가 잘못된 확률이 높다.)

@DynamicUpdate로 변경 필드만 반영되도록 할 수 있다.

 

엔티티 최상단에 @DynamicUpdate를 선언해준다.

@Getter
@NoArgsConstructor
@Entity
@DynamicUpdate // 변경한 필드만 대응
public class Pay {

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

    private String tradeNo;
    private long amount;
}
728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

@Modifying 이란?

  • @Query 어노테이션(JPQL Query, Native Query)을 통해 작성된 INSERT, UPDATE, DELETE (SELECT 제외) 쿼리에서 사용되는 어노테이션이다.
  • 기본적으로 JpaRepository에서 제공하는 메서드 혹은 메서드 네이밍으로 만들어진 쿼리에는 적용되지 않는다.
  • clearAutomatically, flushAutomatically 속성을 변경할 수 있으며 주로 벌크 연산과 같이 이용된다.
  • JPA Entity Life-cycle을 무시하고 쿼리가 실행되기 때문에 해당 어노테이션을 사용할 때는 영속성 컨텍스트 관리에 주의해야 한다. (clearAutomatically, flushAutomatically를 통해 간단하게 해결할 수 있다.)

Bulk 연산

벌크 연산이란, 단건 UPDATE, DELETE 연산을 제외한 다건의 UPDATE, DELETE 연산을 하나의 쿼리로 하는 것을 의미한다. JPA에서 단건 UPDATE 같은 경우에는 Dirty Checking(변경 감지)를 통해서 수행되거나 save()를 통해 수행할 수 있다. DELETE 경우 단건, 다건 쿼리 메서드로 제공된다.

 

여기서는 @Modifying과 @Query를 사용한 벌크연산을 알아본다.

이 방법의 장점은 JPQL을 자유롭게 정의하여 사용할 수 있고, 하나의 쿼리로 많은 데이터를 변경할 수 있다는 점이다.

 

@Query에 벌크 연산 쿼리를 작성하고, @Modifying 어노테이션을 붙이지 않으면 InvalidDataAccessApiUsageException이 발생한다.

 

clearAutomatically

이 attribute는 @Modifying이 붙은 해당 쿼리 메서드 실행 직후, 영속성 컨텍스트를 clear할 것인지 지정하는 attribute이다. default 값은 false이다. 하지만 default 값은 false이다. 하지만 default 값인 false로 사용할 경우, 영속성 컨텍스트의 1차 캐시와 관련된 문제점이 발생할 수 있다.

문제점

JPA에서는 영속성 컨텍스트에 있는 1차 캐시를 통해 엔티티를 캐싱하고, DB의 접근 횟수를 줄임으로써 성능을 개선한다. 1차 캐시는 @Id값을 key 값으로 엔티티를 관리한다. 그래서 findById 등을 통해 엔티티를 조회했을 시, @Id 값이 1차 캐시에 존재한다면 DB에 접근하지 않고, 캐싱된 엔티티를 반환한다. 

 

그렇다면, 벌크 연산을 통해 데이터 변경 쿼리를 실행하고, 해당 엔티티를 조회하면 어떤 일이 발생할지 예측해보자.

1. 엔티티 객체를 하나 생성한다. (transient 상태)

2. 객체의 초기값을 설정한다.

3. 해당 엔티티의 Repository를 통해 해당 엔티티 객체를 save() 메서드를 통해 저장한다. (persistent 상태)

4. 벌크 연산을 통해 기존에 설정한 초기값을 변경한다. (SQL 실행 / 이때, flush가 발생하여 해당 쿼리 메서드가 실행되기 전의 쿼리들이 모두 flush 된다.)

5. findById()를 통해 해당 엔티티를 조회한다.

 

예제

// Article.java
@Entity
@Getter
@Setter
public class Article {

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

    private String content;
}
// ArticleRepository.java
public interface ArticleRepository extends JpaRepository<Article, Long> {

    @Modifying
    @Query("UPDATE Article a SET a.title = :title WHERE a.id = :id")
    int updateTitle(Long id, String title);
}

title를 update하는 쿼리 메서드를 정의했다. @Modifying과 @Query를 통해 직접 JPQL을 정의했다. clearAutomatically는 default인 false이다.

 

@ExtendWith(SpringExtension.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class Test {

    @Autowired
    private ArticleRepository articleRepository;
    
    @org.junit.jupiter.api.Test
    @Rollback(false)
    void update() {
        Article article = new Article(); // article - Transient 상태
        article.setTitle("before");
        articleRepository.save(article); // article - Persistent 상태
        
        int result = articleRepository.updateTitle(1L, "after"); // UPDATE SQL
        assertThat(result).isEqualTo(1);
        
        System.out.println(articleRepository.findById(1L).get().getTitle()); // "before"이 출력
    }
}

참고) @DataJpaTest를 사용하면 자동으로 embeded DB로 대체되어서 실제 DB를 확인할 수 없게 된다. AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)은 embeded DB가 아닌 properties에 정의한 DataSource를 사용할 수 있게 한다.

참고) @DataJpaTest는 기본적으로 @Transactional을 포함하고 있다. 그래서 각 테스트 메서드를 실행할 때, 데이터가 Rollback이 된다. 데이터가 Rollback이 실행된다면 실제 DB에서 테이블을 확인할 수 있으므로 @Rollback(false)으로 처리해주었다.

 

실제 DB에서는 title이 "after"로 변경되었지만, 메서드 안에서는 여전히 "before"이다.

// ArticleRepository.java
public interface ArticleRepository extends JpaRepository<Article, Long> {

    @Modifying(clearAutomatically = true)
    @Query("UPDATE Article a SET a.title = :title WHERE a.id = :id")
    int updateTitle(Long id, String title);
}

clearAutomatically를 true로 설정해주었다.

after가 출력되는 것을 알 수 있다. after로 출력되기 전에 SELECT Query가 실행된다. 이는 1차 캐시가 아닌 DB에서 Article을 조회했기 때문이다.

 

결론

JPA의 1차 캐시는 DB에 접근 횟수를 줄여 성능을 개선하는 좋은 기능이지만 @Modifying과 @Query를 이용한 벌크 연산에서는 이 기능때문에 예측하지 못한 결과가 나올 수 있다. 

JPA에서 조회를 실행할 때, 1차 캐시를 확인해서 해당 엔티티가 1차 캐시에 존재한다면 DB에 접근하지 않고, 1차 캐시에 있는 엔티티를 반환한다. 하지만 벌크 연산은 1차 캐시를 포함한 영속성 컨텍스트를 무시하고 바로 Query를 실행하기 때문에 영속성 컨텍스트는 데이터 변경을 알 수 없다. 즉, 벌크 연산을 실행할 대 1차 캐시(영속성 컨텍스트)와 DB의 데이터 싱크가 맞지 않게 되는 것이다.

하지만, @Modifying의 clearAutomatically를 true로 변경해준다면, 벌크 연산 직 후 자동으로 영속성 컨텍스트를 clear 해준다. 그러면 조회를 실행하면 1차 캐시에 해당 엔티티가 존재하지 않기 때문에 DB 조회 쿼리를 실행하게 된다. 이렇게 함으로써 데이터 동기화 문제를 해결할 수 있다.

 

flushAutomatically

이 Attribute는 @Query와 @Modifying을 통한 쿼리 메서드를 사용할 때, 해당 쿼리를 실행하기 전, 영속성 컨텍스트의 변경사항을 DB에 flush할 것인지를 결정하는 Attribute이다. default 값은 false이다.

Hibernate의 FlushModeType

Spring Data JPA는 구현체로 Hibernate를 사용하고 있다. Spring Data JPA를 통해 Hibernate를 사용한다. 그 Hibernate에는 FlushModeType enum class가 있다. 그 값으론 AUTO와 COMMIT이 있고 default 값은 AUTO이다.

 

AUTO(default): flush가 쿼리 실행 시 발생한다.

COMMIT: flush가 트랜잭션이 commit 될 때 발생한다.

 

Hibernate의 FlushModeType가 default로 AUTO 였기 때문에 flushAutomatically가 false여도 쿼리 실행 전 flush가 나간다.

 

Hibernate로 직접 테스트

@Entity
@Getter
@Setter
public class Article {
    
    @Id
    @GeneratedValue
    private Long id;
    
    private String title;
    
    private String content;
    
    private Boolean isPublished = false;
    
    public void publish() {
        isPublished = true;
    }
}
public interface ArticleRepository extends JpaRepository<Article, Long> {

    @Modifying
    @Query("DELETE FROM Article a WHERE a.isPublished = TRUE")
    int deletePublic();
}

EntityManager는 JPA에서 컨텍스트 매니저를 관리하는 interface이다. 

Session은 Hibernate의 핵심이 되는 API이다. EntityManager를 상속받고 있다. 

entityManager.unwrap(Session.class)를 통해 Session을 직접 사용할 수 있다.

@Test
@DisplayName("JPA(Hibernate)를 통한 테스트 - FlushModeType.AUTO")
@Rollback(false)
void delete2() {
    Session session = entityManager.unwrap(Session.class);
    
    Article article = new Article(); // Transient
    session.save(article); //Persist
    
    Article byId = session.find(Article.class, 1L); // get by Persistence Context (NOT DB)
    byId.publish();
    
    int delete = session.createQuery("DELETE FROM Article a WHERE a.isPublished = TRUE").executeUpdate();
    
    assertThat(delete).isEqualTo(0);
}

FlushModeType.AUTO를 그대로 사용해보았다. 테스트는 실패한다.

 

@Test
@DisplayName("JPA(Hibernate)를 통한 테스트 - FlushModeType.AUTO")
@Rollback(false)
void delete2() {
    Session session = entityManager.unwrap(Session.class);
    session.setFlushMode(FlushModeType.COMMIT); // COMMIT으로 변경
    
    Article article = new Article(); // Transient
    session.save(article); //Persist
    
    Article byId = session.find(Article.class, 1L); // get by Persistence Context (NOT DB)
    byId.publish(); // isPublished를 true로 변경
    
    int delete = session.createQuery("DELETE FROM Article a WHERE a.isPublished = TRUE").executeUpdate();
    
    assertThat(delete).isEqualTo(0);
}

DELETE 쿼리가 실행되기 전 flush가 되지 않기 때문에 INSERT, UPDATE 쿼리는 실행되지 않았고 바로 DELETE 쿼리가 실행된다. 그러므로 쿼리 메서드의 반환값이 0이 된다. commit이 되는 시점에 flush가 되고 이때 INSERT, UPDATE 쿼리가 실행된다. 

 

Spring Data JPA로 테스트

application.yml에서 flushMode를 COMMIT으로 설정해줄 수 있다.

spring:
  jpa:
    properties:
      org:
        hibernate:
          flushMode: COMMIT

 

 

결론

Spring Data JPA는 구현체로 Hibernate를 사용한다. Hibernate의 FlushModeType의 default값은 AUTO이다.

@Modifying(flushAutomatically = false)여도 해당 쿼리 메서드 실행 전 flush가 실행된다.

 

flushAutomatically가 의미를 가리게 하려면 Hibernate의 FlushModeType의 값이 COMMIT이어야 한다.

 

 

 

 

 출처 

https://devhyogeon.tistory.com/4 

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

jpql delete query with join condition

DELETE FROM Post p
 WHERE p IN 
 (SELECT post FROM Project project
  JOIN project.posts post
  WHERE project.id = post.project.id)

sql delete query with join condition

DELETE posts
FROM posts
INNER JOIN projects ON projects.project_id = posts.project_id
WHERE projects.client_id = :client_id
728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

JPA에서 대량의 데이터를 삭제할 때 주의할 점이 있습니다. 결론은 @Query 어노테이션을 사용하여 직접 삭제 쿼리를 작성한다.

 

예제

의존 관리는 Gradle를 사용하고 코드 간결성을 위해 lombok을, 테스트 프레임워크로 Spock을 사용하였다.

 

build.gradle

apply plugin: 'java'
apply plugin: 'groovy'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'

dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('org.springframework.boot:spring-boot-starter-web')
    runtime('com.h2database:h2')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.spockframework:spock-core:1.1-groovy-2.4')
    testCompile('org.spockframework:spock-spring:1.1-groovy-2.4')
}

application.yml 에 설정값을 추가한다.

spring:
  jpa:
    show-sql: true

사용할 엔티티 클래스는 Customer, Shop, Item이다.

// Customer.java
@Entity
@Getter
@NoArgsConstructor
public class Customer {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    public Customer(String name) {
        this.name = name;
    }
}

// Shop.java
@Getter
@NoArgsConstructor
@Entity
public class Shop {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private String address;

    @OneToMany(mappedBy = "shop", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Item> items = new ArrayList<>();

    public Shop(String name, String address) {
        this.name = name;
        this.address = address;
    }

    public void addItem(Item item){
        if(this.items == null){
            this.items = new ArrayList<>();
        }

        this.items.add(item);
        item.updateShop(this);
    }
}

// Item.java
@Getter
@NoArgsConstructor
@Entity
public class Item {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private long price;

    @ManyToOne
    private Shop shop;

    public Item(String name, long price) {
        this.name = name;
        this.price = price;
    }

    public void updateShop(Shop shop){
        this.shop = shop;
    }
}

이 엔티티 클래스를 다룬 JpaRepository를 구현한 repository를 생성하겠습니다.

// CustomerRepository.java
public interface CustomerRepository extends JpaRepository<Customer, Long>{

    @Modifying
    @Transactional
    long deleteByIdIn(List<Long> ids);

    @Transactional
    @Modifying
    @Query("delete from Customer c where c.id in :ids")
    void deleteAllByIdInQuery(@Param("ids") List<Long> ids);
}

// ShopRepository.java
public interface ShopRepository extends JpaRepository<Shop, Long> {

    @Transactional
    @Modifying
    long deleteAllByIdIn(List<Long> ids);

    @Transactional
    @Modifying
    @Query("delete from Shop s where s.id in :ids")
    void deleteAllByIdInQuery(@Param("ids") List<Long> ids);
}

// ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, Long> {

    @Transactional
    @Modifying
    @Query("delete from Item i where i.shop.id in :ids")
    void deleteAllByIdInQuery(@Param("ids") List<Long> ids);
}

첫번째 메서드인 deleteAllByIdIn은 JpaRepository에서 제공하는 delete 메서드를 활용한 것이다.

두번째 메서드인 deleteAllByIdInQuery는 @Query를 사용하여 직접 delete 쿼리를 사용한 것이다.

 

1. 다른 엔티티와 관계가 없는 엔티티 삭제

다른 엔티티와 관계가 없는 Customer 엔티티 삭제 기능을 테스트한 예제이다.

@SpringBootTest
class CustomerRepositoryTest extends Specification {

    @Autowired
    private CustomerRepository customerRepository;
    
    def "Customer in delete" () {
        given: // 100개의 데이터를 DB에 insert
        for (int i = 0; i < 100; i++) {
            customerRepository.save(new Customer(i + "님"))
        }
        
        when: // 3개의 ID 조건으로 delete
        customerRepository.deleteByIdIn(Arrays.asList(1L, 2L, 3L))
        
        then:
        println "======THEN====="
        customerRepository.findAll().size() == 97
    }
}
Hibernate: insert into customer (id, name) values (null, ?)
Hibernate: insert into customer (id, name) values (null, ?)
Hibernate: insert into customer (id, name) values (null, ?)
...
Hibernate: insert into customer (id, name) values (null, ?)
Hibernate: select customer0_.id as id1_0_, customer0_.name as name2_0_ from customer customer0_ where customer0_.id in (?, ?, ?)
Hibernate: delete from customer where id = ?
Hibernate: delete from customer where id = ?
Hibernate: delete from customer where id = ?
=====THEN=====

in 으로 조회하는 쿼리가 처음으로 실행된다.

id별로 각각 delete가 실행된다.

 

public abstract class AbstractJpaQuery implements RepositoryQuery {
    // ... 생략
    
    @Nullable
    public Object execute(Object[] parameters) {
        return this.doExecute(this.getExecution(), parameters);
    }

    @Nullable
    private Object doExecute(JpaQueryExecution execution, Object[] values) {
        JpaParametersParameterAccessor accessor = new JpaParametersParameterAccessor(this.method.getParameters(), values);
        Object result = execution.execute(this, accessor);
        ResultProcessor withDynamicProjection = this.method.getResultProcessor().withDynamicProjection(accessor);
        return withDynamicProjection.processResult(result, new AbstractJpaQuery.TupleConverter(withDynamicProjection.getReturnedType()));
    }
    // ... 생략
}
public abstract class JpaQueryExecution {
    private static final ConversionService CONVERSION_SERVICE;

    public JpaQueryExecution() {
    }
    
    // ...생략
    
    static class DeleteExecution extends JpaQueryExecution {
        private final EntityManager em;

        public DeleteExecution(EntityManager em) {
            this.em = em;
        }

        protected Object doExecute(AbstractJpaQuery jpaQuery, JpaParametersParameterAccessor accessor) {
            Query query = jpaQuery.createQuery(accessor);
            List<?> resultList = query.getResultList();
            Iterator var5 = resultList.iterator();

            while(var5.hasNext()) {
                Object o = var5.next();
                this.em.remove(o);
            }

            return jpaQuery.getQueryMethod().isCollectionQuery() ? resultList : resultList.size();
        }
    }
    // ...생략
}
  • jpaQuery.createQuery(values)의 결과로 select ~ from Customer where ~ 쿼리가 생성된다.
  • for loop를 돌면서 1건씩 삭제한다.

 

JpaRepository에서 제공하는 deleteByXXX 등의 메서드는 먼저 조회하고 그 결과로 얻은 엔티티를 1건씩 삭제한다.

2. 관계가 있는 엔티티 삭제

@SpringBootTest
class ShopRepositoryTest extends Specification {

    @Autowired
    private ShopRepository shopRepository;
    
    @Autowired
    private ItemRepository itemRepository;
    
    def setup() {
        for (long i = 0; i <= 2; i++) {
             SHOP_ID_LIST.add(i)
        }
    }
    
    def cleanup() {
        println "======== Clean All ========="
        itemRepository.deleteAll()
        shopRepository.deleteAll()
    }
    
    def "SpringDataJPA에서 제공하는 예약어를 통해 삭제한다 - 부모&자식" () {
        given:
        createShopAndItem()

        when:
        shopRepository.deleteAllByIdIn(SHOP_ID_LIST)

        then:
        shopRepository.findAll().size() == 8
    }

    private void createShop() {
        for (int i = 0; i < 10; i++) { 
            shopRepository.save(new Shop("우아한서점" + i, "우아한 동네" + i))
        }

        println "=======End Create Shop======="
    }

    private void createShopAndItem() {
        for (int i = 0; i < 10; i++) {
            Shop shop = new Shop("우아한서점" + i, "우아한 동네" + i)

            for (int j = 0; j < 3; j++) {
                shop.addItem(new Item("IT책" + j, j * 10000))
            }

            shopRepository.save(shop)
        }

        println "=======End Create Shop & Item======="
    }
}

 

Hibernate: select shop0_.id as id1_2_, shop0_.address as address2_2_, shop0_.name as name3_2_ from shop shop0_ where shop0_.id in (?, ?)
Hibernate: select item0_.shop_id as shop_id4_1_0_, item0_.id as id1_1_0, ... from item item0_ where item0_.shop_id = ?
Hibernate: select item0_.shop_id as shop_id4_1_0_, item0_.id as id1_1_0, ... from item item0_ where item0_.shop_id = ?
Hibernate: delete from item where id = ?
Hibernate: delete from item where id = ?
Hibernate: delete from item where id = ?
Hibernate: delete from shop where id = ?
Hibernate: delete from item where id = ?
Hibernate: delete from item where id = ?
Hibernate: delete from item where id = ?
Hibernate: delete from shop where id = ?
  • in 으로 조회하는 쿼리가 처음 실행된다.
  • shop id 별로 item을 조회한다.
  • 조회된 item을 1건씩 삭제한다.
  • 조회된 shop을 1건씩 삭제한다.

 

SpringDataJpa에서 deleteByXXX 등의 메소드 사용시

  • 삭제 대상들을 전부 조회하는 쿼리가 1번 발생한다.
  • 삭제 대상들은 1건씩 삭제 된다.
  • cascade = CascadeType.DELETE으로 하위 엔티티와 관계가 맺어진 경우 하위 엔티티들도 1건씩 삭제가 진행된다.

해결책

직접 범위 조건의 삭제 쿼리를 작성하면 된다.

    @Transactional
    @Modifying
    @Query("delete from Customer c where c.id in :ids")
    void deleteAllByIdInQuery(@Param("ids") List<Long> ids);

이를 실행하면 

Hibernate: delete from customer where id in (?, ?, ?)

만약 Shop Item 같이 서로 연관관계가 있는 경우에는 Shop만 삭제시 에러가 발생할 수 있습니다.

Item을 먼저 삭제 후, Shop을 삭제하면된다.

itemRepository.deleteAllByIdInQuery(SHOP_ID_LIST)
shopRepository.deleteAllByIdInQuery(SHOP_ID_LIST)
Hibernate: delete from item where shop_id in (?, ?, ?)
Hibernate: delete from shop where id in (?, ?, ?)

 

 출처 

https://jojoldu.tistory.com/235

 

JPA에서 대량의 데이터를 삭제할때 주의해야할 점

안녕하세요? 이번 시간엔 JPA에서 대량의 데이터를 삭제할때 주의해야할 점을 샘플예제로 소개드리려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (

jojoldu.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

 이전 글 

https://frogand.tistory.com/114

 

[Spring] @RequestBody, @RequestParam, @ModelAttribute의 차이

Client에서 받은 요청을 객체로 바인딩하기 위해 사용하는 방법에는 총 @RequestBody, @RequestParam, @ModelAttribute 총 3가지가 있다. 🥑 @RequestParam @RequestParam은 1개의 HTTP 요청 파라미터를 받기 위해..

frogand.tistory.com

https://frogand.tistory.com/162

1. @RequestBody와 @ModelAttribute

// Controller.java
@PostMapping
public ResponseEntity<String> createPost(@ModelAttribute PostDto postDto) {
}

@PostMapping
public ResponseEntity<String> createComment(@RequestBody CommentDto commentDto) {
}

@RequestBody와 @ModelAttribute는 클라이언트 측에서 보낸 데이터를 Java 코드에서 사용할 수 있는 오브젝트로 만들어주는 공통점이 있다. 하지만 두 어노테이션은 세부 수행 동작에서 큰 차이가 있다. 

 

2. @RequestBody

Annotation indicating a method parameter should be bound to the body of the web request. The body of the request is passed through an HttpMessageConverter to resolve the method argument depending on the content type of the request.

POST HTTP1.1 /requestbody
Body:
{ “password”: “1234”, “email”: “kevin@naver.com” }

@RequestBody 어노테이션의 역할은 클라이언트가 보내는 HTTP 요청 본문(JSON, XML 등)을 Java 객체로 변환하는 것이다. HTTP 요청 본문 데이터는 Spring에서 제공하는 HttpMessageConverter를 통해 타입에 맞는 객체로 변환된다.

 

// Controller.java

@PostMapping("/requestbody")
public ResponseEntity<RequestBodyDto> testRequestBody(@RequestBody RequestBodyDto dto) {
    return ReponseEntity.ok(dto);
}
// RequestBodyDto.java

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class RequestBodyDto {

    private String name;
    private long age;
    private String password;
    private String email;
}
// ControllerTest.java

@Test
void requestsBody() throws Exception {
    
    ObjectMapper objectMapper = new ObjectMapper();
    RequestBodyDto dto = new RequestBodyDto("req", 1L, "pass", "email");
    String requestBody = objectMapper.writeValueAsString(dto);
    
    mockMvc.perform(post("/requestbody")
           .contentType(MediaType.APPLICATION_JSON_VALUE)
           .content(requestBody))
           .andExpect(status().isOk())
           .andExpect(jsonPath("name").value("req"))
           .andExpect(jsonPath("age").value("1"))
           .andExpect(jsonPath("password").value("pass"))
           .andExpect(jsonPath("email").value("email"));
}

RequestBodyDto 객체를 JSON 문자열로 변환한 뒤, 이를 Post 요청 본문에 담아 보내고 다시 응답 본문으로 받는 테스트이다. 해당 테스트를 실행하면 요청 본문의 JSON 값이 DTO로 잘 변환되어 성공한다.

 

1) 생성자와 setter가 없다면?

// RequestBodyDto.java

@NoArgsConstructor // 기본 생성자
// @AllArgsConstructor
@Getter
// @Setter
public class RequestBodyDto {

    private String name;
    private long age;
    private String password;
    private String email;
}
// ControllerTest.java

@Test
void requestBody() throws Exception {
    String requestBody = "{\"name\":\"req\",\"age\":1,\"password\":\"pass\",\"email\":\"email\"}\n";

    mockMvc.perform(post("/requestbody")
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .content(requestBody))
            .andExpect(status().isOk())
            .andExpect(jsonPath("name").value("req"))
            .andExpect(jsonPath("age").value("1"))
            .andExpect(jsonPath("password").value("pass"))
            .andExpect(jsonPath("email").value("email"));
}

RequestBodyDto의 필드를 바인딩해줄 수 있는 생성자 및 setter 메서드를 삭제하고 테스트를 실행해도 테스트는 성공한다.

어떻게 기본 생성자만을 가지고 JSON 값을 Java 객체로 재구성할 수 있을까?

2) MappingJackson2HttpMessageConverter

org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver 클래스의 readWithMessageConverters()라는 메서드에 브레이크 포인트를 찍고 다시 Post 요청을 보내면, Spring에 등록된 여러 MessageConverter 중 MappingJackson2HttpMessageConverter를 사용한다.

 

내부적으로 ObjectMapper를 통해 JSON 값을 Java 객체로 역직렬화하는 것을 알 수 있다. 역직렬화란 생성자를 거치지 않고 리플렉션을 통해 객체를 구성하는 매커니즘이다. 직렬화 가능한 클래스들은 기본 생성자가 항상 필수이다. 따라서 @RequestBody에 사용하려는 RequestBodyDto가 기본 생성자를 정의하지 않으면 데이터 바인딩에 실패한다.

 

어떻게 ObjectMapper는 JSON에 명시된 필드명 Key를 Java 객체의 필드명에 매핑시켜 값을 대입할까?

How Jackson ObjectMapper Matches JSON Fields to Java Fields
To read Java objects from JSON with Jackson properly, it is important to know how Jackson maps the fields of a JSON object to the fields of a Java object, so I will explain how Jackson does that.
By default Jackson maps the fields of a JSON object to fields in a Java object by matching the names of the JSON field to the getter and setter methods in the Java object. Jackson removes the "get" and "set" part of the names of the getter and setter methods, and converts the first character of the remaining name to lowercase.
For instance, the JSON field named brand matches the Java getter and setter methods called getBrand() and setBrand(). The JSON field named engineNumber would match the getter and setter named getEngineNumber() and setEngineNumber().
If you need to match JSON object fields to Java object fields in a different way, you need to either use a custom serializer and deserializer, or use some of the many Jackson Annotations.

Jackson ObjectMapper는 JSON 오브젝트의 필드를 Java 오브젝트의 필드에 매핑할 때 getter 혹은 setter 메서드를 사용한다. getter나 setter 메서드명의 접두사(get, set)를 지우고, 나머지 문자의 첫 문자를 소문자로 변환한 문자열을 참조하여 필드명을 찾아낸다.

 

RequestBodyDto에 getter 및 setter 메서드가 모두 정의되어 있지 않으면, 테스트 실행시 HttpMessageNotWritableException 예외가 발생해 실패한다.

 

3) conclusion

- @RequestBody를 사용하면 요청 본문의 JSON, XML, TEXT 등의 데이터가 적합한 HttpMessageConverter를 통해 파싱되어 Java 객체로 변환된다.

- @RequestBody를 사용할 객체는 필드를 바인딩할 생성자나 setter 메서드가 필요없다.

다만, 직렬화를 위해 기본 생성자는 필수다.

또한, 데이터 바인딩을 위한 필드명을 알아내기 위해 getter나 setter 중 한가지는 정의되어 있어야 한다.

 

3. @ModelAttribute

Annotation that binds a method parameter or method return value to a named model attribute, exposed to a web view. Supported for controller classes with @RequestMapping methods.

POST HTTP1.1 /modelattribute
Request params: id=13 name=kevin

@ModelAttribute 어노테이션의 역할은 클라이언트가 보내는 HTTP 파라미터들을 특정 Java 객체에 바인딩(매핑)하는 것이다. /modelattribute?name=req&age=1 같은 query string 형태 혹은 요청 본문에 삽입되어있는 Form 형태의 데이터를 처리한다.

// Controller.java

@Getmapping("/modelattribute")
public ResponseEntity<ModelAttributeDto> testModelAttribute(@ModelAttribute ModelAttributeDto dto) {
    return ReponseEntity.ok(dto);
}
// ModelAttributeDto.java

@AllArgsConstructor
@Getter
public class ModelAttributeDto {

    private String name;
    private long age;
    private String password;
    private String email;
}
// ControllerTest.java

@Test
void modelAttribute() throws Exception {
    mockMvc.perform(get("/modelattribute")
            .param("name", "req")
            .param("age", "1")
            .param("password", "pass")
            .param("email", "naver"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("name").value("req"))
            .andExpect(jsonPath("age").value("1"))
            .andExpect(jsonPath("password").value("pass"))
            .andExpect(jsonPath("email").value("naver"));
}

먼저, Http 파라미터와 함께 Get 요청을 테스트하자. Http 파라미터들은 URL 뒤에 붙어 /modelattribute?name=req&age=1&password=pass&email=naver 형태의 query string이 된다. 테스트 실행 결과는 ModelAttributeDto{name='req', age='1', password='pass', email='naver'}로 데이터가 잘 바인딩된다.

 

// Controller.java

@PostMapping("/modelattribute") 
public ResponseEntity<ModelAttributeDto> testModelAttribute(@ModelAttribute ModelAttributeDto dto) {
    return ResponseEntity.ok(dto);
}
// ControllerTest.java

@Test
void modelAttribute() throws Exception {
    ObjectMapper objectMapper = new ObjectMapper();
    ModelAttributeDto modelAttributeDto = new ModelAttributeDto("req", 1L, "pass", "email");
    String requestBody = objectMapper.writeValueAsString(modelAttributeDto);

    mockMvc.perform(post("/modelattribute")
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .content(requestBody))
            .andExpect(status().isOk())
            .andExpect(jsonPath("name").value("req"))
            .andExpect(jsonPath("age").value("1"))
            .andExpect(jsonPath("password").value("pass"))
            .andExpect(jsonPath("email").value("email"));
}

Post 요청 테스트를 해보자. 이 테스트를 실행하면 실패한다. @ModelAttribute는 Form 형식의 HTTP 요청 본문 데이터만을 인식해 매핑하지만, JSON 형태의 데이터를 전송하고 있다. 데이터가 바인딩되지 않거나 415 Unsupported Media Type 에러가 발생한다.

 

// ControllerTest.java

mockMvc.perform(post("/modelattribute")
        .contentType(MediaType.APPLICATION_FORM_URLENCODED)
        .content("name=req&age=1&password=pass&email=naver"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("name").value("req"))
        .andExpect(jsonPath("pass").value("pass"))
        //...

이와 같이 contentType을 x-www-form-url-encoded로 요청 본문 내용을 Form 형식으로 보내도록 테스트를 수정하면 테스트 실행 결과로 ModelAttributeDto{name='req', age=1, password='pass', email='naver'}로 데이터가 잘 바인딩됨을 확인할 수 있다.

1) 생성자가 없을 때는 setter를

@RequestBody 예제처럼 필드에 접근해 데이터를 바인딩할 수 있는 ModelAttributeDto의 생성자를 삭제해보자.

// ModelAttributeDto.java

// @AllArgsConstructor
@Getter
public class ModelAttributeDto {

    private String name;
    private long age;
    private String password;
    private String email;
}

 

ModelAttributeDto{name='null', age=0, password='null', email='null'}가 출력된다. Post 요청으로 HTTP 파라미터는 정상적으로 보냈지만, Controller에서 데이터를 ModelAttributeDto에 바인딩하지 못하고 있다.

그럼 ModelAttributeDto에 setter 메서드를 추가하고 테스트를 실행하면, 테스트는 생성자가 있을 때처럼 성공하게 됩니다.

2) conclusion

- @ModelAttribute를 사용하면 HTTP 파라미터 데이터를 Java 객체에 매핑한다.

따라서, 객체의 필드에 접근해 데이터를 바인딩할 수 있는 생성자나 setter 메서드가 필요하다.

- query string 및 form 형식이 아닌 데이터는 처리할 수 없다.

 

 참고 

https://tecoble.techcourse.co.kr/post/2021-05-11-requestbody-modelattribute/

 

@RequestBody vs @ModelAttribute

1. @RequestBody와 @ModelAttribute Controller.java @RequestBody와 @ModelAttribute는 클라이언트 측에서 보낸 데이터를 Java…

tecoble.techcourse.co.kr

https://jenkov.com/tutorials/java-json/jackson-objectmapper.html#how-jackson-objectmapper-matches-json-fields-to-java-fields

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !