ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [스프링 인 액션] Chapter 3 - 데이터로 작업하기 :: 스프링 데이터 JPA를 사용해서 데이터 저장하고 사용하기
    개발서적읽기/Spring in Action 제 5판 2020. 7. 30. 00:05




    스프링 데이터 프로젝트는 여러 개의 하위 프로젝트로 구성되는 


    다소 규모가 큰 프로젝트다.


    그리고 대부분의 하위 프로젝트는 다양한 DB 유형을 사용한 


    데이터 persistence에 초점을 둔다.


    가장 많이 알려진 스프링 데이터 프로젝트들은 다음과 같다.


    - 스프링 데이터 JPA : RDB의 JPA persistence


    - 스프링 데이터 MongoDB : 몽고 문서형 DB persistence


    - 스프링 데이터 Neo4 : Neo4j 그래프 DB persistence


    - 스프링 데이터 Redis : Redis 키-값 스토어 persistence


    - 스프링 데이터 카산드라 : 카산드라 DB persistence


    스프링 데이터에서는 repository 인터페이스를 기반으로 


    이 인터페이스를 구현하는 repository를 자동 생성해준다.


    스프링 데이터가 작동하는 방법을 알기 위해 이번 장 앞에서 작성한 JDBC 기반의


    repository를 스프링 데이터 JPA로 교체할 것이다.


    우선 스프링 데이터 JPA를 프로젝트 빌드 파일에 추가해야 한다.




    ■스프링 데이터 JPA를 프로젝트에 추가하기


    스프링 데이터 JPA는 JPA 스타터를 통해서 스프링 부트 애플리케이션에서 사용할 수 있다.


    이 스타터 의존성에는 스프링 데이터 JPA는 물론이고 


    JPA를 구현한 Hibernate까지도 포함된다.

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

    만일 다른 JPA 구현 라이브러리를 사용하고 싶다면


    Hibernate 의존성을 제외하고 우리가 선택한 JPA 라이브러리를 포함해야 한다.


    예를 들어, Hibernate 대신 EclipseLink를 사용하고 싶다면 다음과 같이 의존성을 추가한다.

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <exclusions>
    <exclusion>
    <artifactId>hibernate-entitymanager</artifactId>
    <groupId>org.hibernate</groupId>
    </exclusion>
    </exclusions>
    </dependency>
    <dependency>
    <groupId>org.eclipse.persistence</groupId>
    <artifactId>eclipseLink</artifactId>
    </dependency>




    ■도메인 객체에 애노테이션 추가하기


    스프링 데이터는 repository를 생성할 때 놀랄 만한 일을 수행한다.


    그러나 JPA 매핑 어노테이션을 우리 객체에 추가해야 한다.

    package tacos;
    /*
    * @USER JungHyun
    * @DATE 2020-07-04
    * @DESCRIPTION
    */

    import lombok.AccessLevel;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import lombok.RequiredArgsConstructor;

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

    @Data
    @RequiredArgsConstructor
    @NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
    @Entity
    public class Ingredient {

    @Id
    private final String id;
    private final String name;
    private final Type type;

    public enum Type {
    WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
    }
    }

    Ingredient를 JPA 개체(entity)로 선언하려면 


    반드시 @Entity 애노테이션을 추가해야 한다.


    그리고 이것의 id 속성에는 반드시 @Id를 지정하여 이 속성이 DB의 개체를 


    고유하게 식별한다는 것을 나타내야 한다.


    JPA 애노테이션과 더불어 Ingredient에는 클래스 수준의 


    @NoArgsConstructor 애노테이션도 추가되었음을 알 수 있다.


    JPA에서는 개체가 인자 없는 생성자를 가져야 한다.


    따라서 Lombok의 @NoArgsConstructor를 지정한 것이다.


    하지만 여기서는 인자 없는 생성자의 사용을 원치 않으므로 


    access 속성을 private으로 설정하여 클래스 외부에서 사용하지 못하게 했다.


    그리고 Ingredient에는 초기화가 필요한 final 속성들이 있으므로


    force 속성을 true로 설정하였다. 이에 따라 Lombok이 자동 생성한 생성자에서


    그 속성들을 null로 설정한다.


    @Data는 인자가 있는 생성자를 자동으로 추가한다.


    그러나 @NoArgsConstructor가 지정되면 그런 생성자는 제거된다.


    하지만 위 코드처럼 @RequiredArgsConstructor를 추가하면 private의 인자 없는


    생성자와 더불어 인자가 있는 생성자를 여전히 가질 수 있다.

    package tacos;
    /*
    * @USER JungHyun
    * @DATE 2020-07-04
    * @DESCRIPTION
    */

    import lombok.Data;

    import javax.persistence.*;
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    import java.util.Date;
    import java.util.List;

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

    private Date createdAt;

    @NotNull
    @Size(min = 5, message = "Name must be at least 5 characters long")
    private String name;

    @ManyToMany(targetEntity = Ingredient.class)
    @Size(min = 1, message = "You must choose at least 1 ingredient")
    private List<Ingredient> ingredients;

    @PrePersist
    void createdAt() {
    this.createdAt = new Date();
    }
    }

    Ingredient와 Taco에도 @Entity가 지정되었으며, id 속성에는 @Id가 지정되었다.


    id 속성에는 DB가 자동으로 생성해주는 ID값이 사용된다.


    따라서 strategy 속성의 값이 GenerationType.AUTO로 설정된 


    @GeneratedValue 애노테이션이 지정되었다.


    Taco 및 이것과 연관된 Ingredient들 간의 관계를 선언하기 위해 


    ingredients 속성에는 @ManyToMany 애노테이션이 지정되었다.


    또한 @PrePersist 애노테이션이 지정되어 있는 새로운 메서드인


    createdAt()이 있다. 이 메서드는 Taco가 저장되기 전에 createdAt 속성을


    현재 일자와 시간으로 설정하는 데 사용된다. 


    마지막으로 Order를 JPA 개체로 바꿔보자.

    package tacos;
    /*
    * @USER JungHyun
    * @DATE 2020-07-04
    * @DESCRIPTION
    */

    import lombok.Data;
    import org.hibernate.validator.constraints.CreditCardNumber;

    import javax.persistence.*;
    import javax.validation.constraints.Digits;
    import javax.validation.constraints.NotBlank;
    import javax.validation.constraints.Pattern;
    import java.io.Serializable;
    import java.util.ArrayList;
    import java.util.Date;
    import java.util.List;


    @Data
    @Entity
    @Table(name = "Taco_Order")
    public class Order implements Serializable {
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private Date placedAt;

    @NotBlank(message = "Name is required")
    private String deliveryName;
    @NotBlank(message = "Street is required")
    private String deliveryStreet;
    @NotBlank(message = "City is required")
    private String deliveryCity;
    @NotBlank(message = "State is required")
    private String deliveryState;
    @NotBlank(message = "Zip code is required")
    private String deliveryZip;
    @CreditCardNumber(message = "Not a valid credit card number")
    private String ccNumber;
    @Pattern(regexp = "^(0[1-9]|1[0-2])([\\/])([1-9][0-9])$", message = "Must be formatted MM/YY")
    private String ccExpiration;
    @Digits(integer = 3, fraction = 0, message = "Invalid CVV")
    private String ccCVV;

    @ManyToMany(targetEntity = Taco.class)
    private List<Taco> tacos = new ArrayList<>();

    public void addDesign(Taco design) {
    this.tacos.add(design);
    }

    @PrePersist
    void placedAt() {
    this.placedAt = new Date();
    }

    }

    Order는 SQL 예약어이기 때문에 @Table로 테이블을 따로 명시했다.




    ■JPA repository 선언하기


    JDBC 버전의 repository에서는 repository가 제공하는 메서드를 


    우리가 명시적으로 선언하였다.


    그러나 스프링 데이터에서는 그 대신 CrudRepository 인터페이스를 


    확장할 수 있다. 

    package tacos.data;
    /*
    * @USER JungHyun
    * @DATE 2020-07-29
    * @DESCRIPTION
    */

    import org.springframework.data.repository.CrudRepository;
    import tacos.Ingredient;

    public interface IngredientRepository extends CrudRepository<Ingredient, String> {
    }
    package tacos.data;
    /*
    * @USER JungHyun
    * @DATE 2020-07-29
    * @DESCRIPTION
    */

    import org.springframework.data.repository.CrudRepository;
    import tacos.Order;

    public interface OrderRepository extends CrudRepository<Order, Long> {
    }
    package tacos.data;
    /*
    * @USER JungHyun
    * @DATE 2020-07-29
    * @DESCRIPTION
    */

    import org.springframework.data.repository.CrudRepository;
    import tacos.Taco;

    public interface TacoRepository extends CrudRepository<Taco, Long> {
    }

    기존에 정의되어있던 메소드는 삭제했다.


    CrudRepository 인터페이스에는 DB의 CRUD 연산을 위한 많은 메서드가 선언되어 있다.


    CrudRepository는 매개변수화 타입이다. 


    첫 번째 매개변수는 Repository에 저장되는 개체 타입이며


    두 번째 매개변수는 개체 ID 속성의 타입이다.


    CrudRepository 인터페이스를 확장한 3개의 인터페이스를 선언했으므로


    CrudRepository 인터페이스에 정의된 많은 메서드의 구현을 포함해서 3개의 인터페이스를


    구현하는 클래스를 작성해야 한다고 생각할 수 있다. 그러나 그럴 필요가 없다.


    바로 이것이 스프링 데이터 JPA의 장점이다.


    애플리케이션이 시작될 때 스프링 데이터 JPA가 


    각 인터페이스 구현체를 자동으로 생성한다.


    JdbcIngredientRepository, JdbcTacoRepositoryt, JdbcOrderRepository를 삭제하자.


    다음으로 부트스트랩 클래스를 변경하자.

    package tacos;

    import org.springframework.boot.CommandLineRunner;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.context.annotation.Bean;
    import tacos.data.IngredientRepository;

    @SpringBootApplication
    public class TacoCloudApplication {

    public static void main(String[] args) {
    SpringApplication.run(TacoCloudApplication.class, args);
    }

    @Bean
    public CommandLineRunner dataLoader(IngredientRepository repo) {
    return new CommandLineRunner() {
    @Override
    public void run(String... args) throws Exception {
    repo.save(new Ingredient("FLTO", "Flour Tortilla", Ingredient.Type.WRAP));
    repo.save(new Ingredient("COTO", "Corn Tortilla", Ingredient.Type.WRAP));
    repo.save(new Ingredient("GRBF", "Ground Beef", Ingredient.Type.PROTEIN));
    repo.save(new Ingredient("CARN", "Carnitas", Ingredient.Type.PROTEIN));
    repo.save(new Ingredient("TMTO", "Diced Tomatoes", Ingredient.Type.VEGGIES));
    repo.save(new Ingredient("LETC", "Lettuce", Ingredient.Type.VEGGIES));
    repo.save(new Ingredient("CHED", "Cheddar", Ingredient.Type.CHEESE));
    repo.save(new Ingredient("JACK", "Monterrey Jack", Ingredient.Type.CHEESE));
    }
    };
    }
    }

    애플리케이션이 시작되면서 식자재 데이터를 DB에 미리 저장할 필요가 있기 때문에 


    data.sql 파일에서 저장했었던 작업을 dataLoader() 에서 작업되도록 했다.


    다음으로 Converter를 변경하자.

    package tacos.web;
    /*
    * @USER JungHyun
    * @DATE 2020-07-29
    * @DESCRIPTION
    */

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.core.convert.converter.Converter;
    import org.springframework.stereotype.Component;
    import tacos.Ingredient;
    import tacos.data.IngredientRepository;

    import java.util.Optional;

    @Component
    public class IngredientByIdConverter implements Converter<String, Ingredient> {

    private IngredientRepository ingredientRepo;

    @Autowired
    public IngredientByIdConverter(IngredientRepository ingredientRepo) {
    this.ingredientRepo = ingredientRepo;
    }

    @Override
    public Ingredient convert(String id) {
    Optional<Ingredient> optionalIngredient = ingredientRepo.findById(id);
    return optionalIngredient.isPresent() ? optionalIngredient.get() : null;
    }
    }

    이제 JDBC에서 테스트했던 것들을 모두 테스트 해볼 수 있다.


    한편, 스프링 데이터 JPA의 CrudRepository에서 제공하는 메서드들은 


    범용적인 데이터 저장에는 훌륭하다. 


    그러나 기본적인 데이터 저장 이상의 요구사항이 있다면?


    지금부터는 우리 도메인에 고유한 쿼리를 수행하기 위해 repository를 


    커스터마이즈하는 방법을 알아본다.




    ■JPA repository 커스터마이징하기


    CrudRepository에서 제공하는 기본적인 CRUD 연산에 추가하여


    특정 ZIP 코드로 배달된 모든 주문 데이터도 DB로부터 가져와야 한다고 하자.


    이것은 다음과 같이 OrderRepository에 메서드를 선언하면 쉽게 해결될 수 있다.

    List<Order> findByDeliveryZip(String deliveryZip);

    repository 구현체를 생성할 때 스프링 데이터는 해당 repository 인터페이스에


    정의된 메서드를 찾아 메서드 이름을 분석하며, 저장되는 객체(Order)의 


    컨텍스트에서 메서드의 용도가 무엇인지 파악한다.


    본질적으로 스프링 데이터는 일종의 DSL(Domain Specific Language)을 정의하고


    있어서 persistence에 관한 내용이 repository 메서드의 시그니처에 표현된다.


    스프링 데이터는 findByDeliveryZip()이 주문 객체 Order를 찾으려고 한다는 것을 안다.


    왜냐하면 OrderRepository에서 CrudRepository의 매개변수를 Order로 지정했기 때문이다.


    그리고 메서드 이름인 findByDeliveryZip()은 이 메서드가 Order의 deliveryZip 속성과 


    일치하는 모든 개체를 찾아야 한다는 것을 확실하게 판단하도록 해준다.


    스프링 데이터는 findByDeliveryZip()보다 더 복잡한 메서드 이름도 처리할 수 있다.


    repository 메서드 이름은 동사, 생략 가능한 처리 대상, By 단어, 그리고 서술어로 구성된다.


    findByDeliveryZip()의 경우에는, 동사가 find이고 서술어가 DeliveryZip이며


    처리 대상은 지정되지 않았지만 묵시적으로 Order가 된다.


    더 복잡한 예를 생각해 보자.


    지정된 일자 범위 내에서 특정 ZIP 코드로 배달된 모든 주문을 쿼리해야 한다고 가정하자.


    아래처럼 메서드를 정의하면 될 것이다.

    List<Order> readOrdersByDeliveryZipAndPlacedAtBetween(String deliveryZip, Date startDate, Date endDate);

    아래 그림은 스프링 데이터가 어떻게 readOrdersByDeliveryZipAndPlacedAtBetween()를


    분석하고 이해하는지 보여준다.

    이 메서드 이름의 동사는 read다. 


    또한 스프링 데이터는 find, read, get이 하나 이상의 개체를 읽는 동의어임을 안다.


    만일 일치하는 개체의 수를 의미하는 정수를 반환하는 메서드를 원한다면 


    count를 동사로 사용할 수도 있다. 


    한편 메서드의 처리 대상이 생략되더라도 Orders가 된다.


    스프링 데이터는 처리 대상에서 대부분의 단어를 무시한다. 


    따라서 메서드 이름이 readPuppiesBy...일 경우에도 여전히 Order 개체를 찾는다.


    Order가 CrudRepository 인터페이스의 매개변수로 지정된 타입이기 때문이다.


    서술어는 메서드 이름의 By 단어 다음에 나오며


    메서드 시그니처에서 가장 복잡한 부분이다.


    위 경우에는 deliveryZip과 placedAt을 나타낸다.


    deliveryZip 속성은 메서드의 첫 번째 인자로 전달된 값과 반드시 같아야 한다.


    그리고 deliveryZip 값이 메서드의 마지막 두 개 인자로 전달된 값 사이에 포함되는


    것이어야 함을 나타내는 것이 Between 키워드다.


    묵시적으로 수행되는 Equals와 Between 연산 이외에도 아래 연산자를 사용할 수 있다.


    - IsAfter, After, IsGreaterThan, GreaterThan


    - IsGreaterThanEqual, GreaterThanEqual


    - IsBefore, Before, IsLessThan, LessThan


    - IsLessThanEqual, LessThanEqual


    - IsBetween, Between


    - IsNull. Null


    - IsNotNull. NotNull


    - IsIn, In


    - IsNotIn, NotIn


    - IsStartingWith, StartingWith, StartsWith


    - IsEndingWith, EndingWith, EndsWith


    - IsContaining, Containing, Contains


    - ISLike, Like


    - IsNotLike, NotLike


    - IsTrue. True


    - IsFalse, False


    - Is, Equals


    - IsNot, Not


    - IgnoringCase, IgnoresCase


    모든 String 비교에서 대소문자를 무시하기 위해 IgnoreCase와 IgnoresCase 대신


    AllIgnoringCase 또는 AllIgnoresCase를 메서드 이름으로 사용할 수 있다.


    지정된 열의 값을 기준으로 결과를 정렬하기 위해 메서드 이름의 끝에


    OrderBy를 추가할 수도 있다.


    복잡한 쿼리의 경우에는 메서드 이름만으로는 감당하기 어렵다.


    이런 경우에는 어떤 이름이든 우리가 원하는 것을 지정한 후 해당 메서드가 호출될 때


    수행되는 쿼리에 @Query 애노테이션을 지정하면 된다.

    @Query("Order o where o.deliveryCity='Seattle'")
    List<Order> readOrderDeliveredInSeattle();


    댓글

Designed by Tistory.