본문 바로가기

Spring/JPA & Hibernate

Spring Boot JPA 활용1

반응형

Spring Boot JPA 활용 1

Field Injection보다 Constructor Injection을 사용하자.

스프링에서 등록된 빈을 사용하기 위한 3가지 DI(Dependency Injection) 방법을 제공한다.

  • 필드 주입(Field Injection)
  • 수정자 주입(Setter Injection)
  • 생성자 주입(Constructor Injection)

그러나, Spring 3점대 이후 버전에서는 필드 주입 방식을 권장하지 않는다.

인텔리 제이에서도 필드 인젝션을 사용할 경우 다음과 같이 경고 메시지를 띄운다.

그리고, Spring 4.3 부터는 Setter 메소드 인젝션보다 생성자 인젝션 방식을 권장한다. <참고>

 

그 이유는 다음과 같다.

 

  1. 단일 책임의 원칙 위반
    의존성을 주입하기가 쉽다. @Autowired 선언 아래 3개든 10개든 막 추가할 수 있으니 말이다. 여기서 Constructor Injection을 사용하면 다른 Injection 타입에 비해 위기감 같은 걸 느끼게 해준다. Constructor의 파라미터가 많아짐과 동시에 하나의 클래스가 많은 책임을 떠안는다는 걸 알게된다. 이때 이러한 징조들이 리팩토링을 해야한다는 신호가 될 수 있다.

  2. 의존성이 숨는다.
    DI(Dependency Injection) 컨테이너를 사용한다는 것은 클래스가 자신의 의존성만 책임진다는게 아니다. 제공된 의존성 또한 책임진다. Setter나 Constructor 를 이용하면 의존성이 명시적으로 드러나는 장점이 있다. 그래서 클래스가 어떤 의존성을 책임지지 않을 때, 메서드나 생성자를 통해(Setter나 Contructor) 확실히 커뮤니케이션이 되어야한다. 하지만 Field Injection은 숨은 의존성만 제공해준다.

  3. DI 컨테이너의 결합성과 테스트 용이성
    DI 프레임워크의 핵심 아이디어는 관리되는 클래스가 DI 컨테이너에 의존성이 없어야 한다. 즉, 필요한 의존성을 전달하면 독립적으로 인스턴스화 할 수 있는 단순 POJO여야한다. 생성자 주입 방식으로 코드를 짜면, DI 컨테이너 없이도 유닛테스트에서 인스턴스화 시킬 수 있고, 각각 나누어서 테스트도 할 수 있다. 컨테이너와의 결합성이 없다면 관리하거나 관리하지 않는 클래스를 사용할 수 있고, 심지어 다른 DI 컨테이너로 전환할 수 있다.
    하지만, Field Injection을 사용하면 필요한 의존성을 가진 클래스를 곧바로 인스턴스화 시킬 수 없다. (Mockito를 이용해서 할 수는 있다.)

  4. 불변성(Immutability)
    Field Injection은 final을 선언할 수 없다. 그래서 객체가 변할 수 있다. 생성자 인젝션을 사용할 경우, 필드를 final로 선언할 수 있어 객체를 변경되어 발생할 수 있는 오류를 사전에 미리 방지할 수 있다.

  5. 순환 의존성

    Constructor Injection에서 순환 의존성을 가질 경우 BeanCurrentlyCreationExeption을 발생시킴으로써 순환 의존성을 알 수 있다.

    개발을 하다 보면 여러 컴포넌트 간에 의존성이 생긴다. 그중에서도 A가 B를 참조하고, B가 다시 A를 참조하는 순환 참조도 발생할 수 있다. 하지만, 이 경우 애플리케이션은 아무런 오류 없이 정상적으로 구동된다. 실제 코드가 호출되기 전까지 문제를 발견할 수 없다.
    그러나, 생성자 주입을 사용한 경우 BeanCurrentlyInCreationException이 발생하며 애플리케이션이 구동조차 되지 않는다. 따라서 발생할 수 있는 오류를 사전에 알 수 있다.

    실행 결과에 차이가 발생하는 이유는 무엇일까? 생성자 주입 방법은 필드 주입이나 수정자 주입과는 빈을 주입하는 순서가 다르다.

    • 수정자 주입(Setter Injection)
      우선 주입(inject) 받으려는 빈의 생성자를 호출하여 빈을 찾거나 빈 팩터리에 등록한다. 그 후에 생성자 인자에 사용하는 빈을 찾거나 만든다. 그 이후에 주입하려는 빈 객체의 수정자를 호출하여 주입한다.

    • 필드 주입(Field Injection)
      수정자 주입 방법과 동일하게 먼저 빈을 생성한 후에 어노테이션이 붙은 필드에 해당하는 빈을 찾아서 주입하는 방법이다. 그러니까, 먼저 빈을 생성한 후에 필드에 대해서 주입한다.

    • 생성자 주입(Constructor Injection)
      생성자로 객체를 생성하는 시점에 필요한 빈을 주입한다. 조금 더 자세히 살펴보면, 먼저 생성자의 인자에 사용되는 빈을 찾거나 빈 팩터리에서 만든다. 그 후에 찾은 인자 빈으로 주입하려는 빈의 생성자를 호출한다. 즉, 먼저 빈을 생성하지 않는다. 수정자 주입과 필드 주입과 다른 방식이다.

      그렇기 때문에 순환 참조는 생성자 주입에서만 문제가 된다. 객체 생성 시점에 빈을 주입하기 때문에 서로 참조하는 객체가 생성되지 않은 상태에서 그 빈을 참조하기 때문에 오류가 발생한다.

      그렇다면 순환 참조 오류를 피하기 위해서 수정자 또는 필드 주입을 사용해야 할까? 오히려 그렇지 않다. 순환 참조가 있는 객체 설계는 잘못된 설계이기 때문에 오히려 생성자 주입을 사용하여 순환 참조되는 설계를 사전에 막아야 한다.

Filed Injection


@Service
@Transactional(readOnly = true)
public class ItemService {

    @Autowired
    private ItemRepository itemRepository;
    
    // ...
}

Setter Methode Injection


@Service
@Transactional(readOnly = true)
public class ItemService {
    
    private ItemRepository itemRepository;

    @Autowired
    public void setItemRepository(final ItemRepository itemRepository) {
        this.itemRepository = itemRepository;
    }
    
    // ...
}

Constructor Injection


@Service
@Transactional(readOnly = true)
public class ItemService {

    private final ItemRepository itemRepository;

    @Autowired
    public ItemService(final ItemRepository itemRepository) {
        this.itemRepository = itemRepository;
    }
    
    // ...
}

이에 추가적으로 Lombok@RequiredArgsConstructor를 통해 DI를 적용하여, 더욱 깔끔하게 코드를 작성할 수 있다.

 

4.3 이전


@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class ItemService {

    private final ItemRepository itemRepository;
    
    // ,,,
}

 

4.3 이후 OR 스프링 부트


@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {

    private final ItemRepository itemRepository;
    
    // ,,,
}

 

참고로, Spring Boot에서는 EntityManager 를 @PersistenceContext 가 아닌 @Autowired를 통해 인젝션할 수 있게 해준다.

따라서, 이 또한 @RequredArgsConstructor + final 선언으로 제거할 수 있다.

 

BEFORE


@Repository
public class MemberRepository {
	@PersistenceContext
    private EntityManager em;
    
    // ...
    
}

AFTER


@Repository
@RequiredArgsConstructor
public class MemberRepository {

	private final EntityManager em;
    
    // ...
    
}
Lombock의 @RequiredArgsConstructor

이 어노테이션은 초기화 되지않은 final 필드나, @NonNull 이 붙은 필드에 대해 생성자를 생성해 주고, @NotNull 이 붙은 필드에 대해서는 null 체크가 실행되고, 파라미터가 null인 경우에는 NullPointerException을 일으킨다.
주로 의존성 주입(Dependency Injection) 편의성을 위해서 사용된다.

이는 스프링 의존성 주입의 특징 중 
"어떠한 빈(Bean)에 생성자가 오직 하나만 있고, 생성자의 파라미터 타입이 빈으로 등록 가능한 존재라면 이 빈은 @Autowired 어노테이션 없이도 의존성 주입이 가능하다."
는 특징을 이용한다.

<참고>

연관관계 메소드를 이용한 객체 지향적 코드

테이블 지향이 아닌 객체 지향적 설계를 하기위해 테이블 구조를 객체로 맵핑할 경우, 서로 다른 두 객체의 양방향 연관관계 설계를 해야하는 경우가 발생한다.

이때, 하나의 엔티티에 연관관계 메소드를 생성하여, 이 메소드를 통해서 CUD를 수행할 경우, 값의 변경 시점을 파악하기 쉬워 더욱 객체 지향적 코드를 구현할 수 있다.

이렇게 연관관계 메소드를 통해 연관관계의 주인 엔티티(@JoinColumn 혹은 @JoinTable)에서 종속 엔티티의 CRUD를 담당하고, 주인이 아닌 쪽( mappedBy )에서는 R만 가능하도록구성한다.

 

이때, 연관관계 메소드는 연관관계 주인(외래키를 가지고 있는) 엔티티에 구현하는 것이 적절하다.

 

다음 예제에서는 Member 와 Order 는 일대다 , 다대일의 양방향 관계. 따라서 연관관계의 주인을 정해야 하는데, 외래 키가 있는 주문을 연관관계의 주인으로 정하는 것이 좋다. 그러므로 연관관계의 주인인 Order에는 member ORDERS.MEMBER_ID 외래 키와 매핑하고, 연관관계의 주인이 아닌 Memeber에는 mappedBy 로 맵핑 정보를 표기해줬다.

 

BEFORE


@Entity
@Getter
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List orders = new ArrayList<>();

}

@Entity
@Table(name = "Orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    Member member;
    
    public void setMember(Member member) {
        this.member = member;
	}
    
}

@Transactional(readOnly = true)
@Service
public class OrderService {
	
    // ...
    order.serMember(member);
    member.getOrders().add(order);

}

 

AFTER


@Entity
@Getter
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List orders = new ArrayList<>();

}

@Entity
@Table(name = "Orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    Member member;

    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }
}

@Transactional(readOnly = true)
@Service
public class OrderService {
	
    // ...
    order.setMember(member);

}

위와 같이, 연관관계의 주인인 Order 의 연관관계 메소드 setMember를 통해, order의 member를 세팅할 때, member의 orders도 함께 변경해줌으로써, 코드가 더욱 객체지향스러워 졌다.

비즈니스 메소드를 이용한 객체 지향적 코드 (Setter 메소드를 지양하자)

비즈니스 로직을 개발하기 위해, 엔티티의 속성 값을 변경해줘야 할 경우, 기존에는 서비스 클래스에서 setter 메소드를 이용하여 값을 변경해주었다. 그러나, 강의에서는 setter 메소드가 아닌, 비즈니스 메소드를 생성하여, 그 메소드를 통해 엔티티를 변경할 것을 권장한다. 그 이유는 setter를 무작정 생성하는 경우 클래스의 인스턴스 값들이 언제 변경되는지 명확하게 알 수 없기 때문이다. 또한, 해당 엔티티 내에 비즈니스 메소드를 구현함으로써 더욱 응집력있고, 메소드 명으로 어떤 로직이 담겨있는 지, 파악하기 쉽게 구성할 수 있다.

BEFORE


@Service
public class Service {
	public void purchase(){
    
        // ...
        
        if (item.stockQuantity < quantity) {
            throw new NoEnoughStockException("need more stock");
        }
        item.setStockQuantity(item.getStockQuantity() -= quantity);
    }
}

AFTER

해당 필드를 가지고 있는 엔티티 클래스에 비즈니스 메소드를 생성한다.


@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Item {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List categories = new ArrayList<>();

    public void addStock(int quantity) {
        this.stockQuantity += quantity;
    }

    public void removeStock(int quantity) {
        if (this.stockQuantity < quantity) {
            throw new NoEnoughStockException("need more stock");
        }
        this.stockQuantity -= quantity;
    }
}

서비스 클래스에서는 비즈니스 로직만 수행하고, 엔티티 수정은 해당 엔티티의 비즈니스 메소드를 통해 수행한다.


@Service
public class Service {
	public void purchase(){

		// ...
        item.removeStock(quantity);
    }
}

도메인 모델 패턴 VS 트랜잭션 스크립트 패턴 

이는 책임지는 쪽이 Domain Level이냐 Script Level이냐에 따라 구분된다.

도메인 모델 패턴

  • 도메인 모델은 아키텍처상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
  • 도메인 계층에 도메인의 핵심 규칙을 구현하고, 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
  • 서비스 계층 은 단순히 엔티티에 필요한 요청을 위임하는 역할을 하고, 엔티티에서 비즈니스 로직을 처리한다.
  • 핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에, 규칙이 바뀌거나 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 된다.

트랜잭션 스크립트 패턴

  • 하나의 트랜잭션 안에서 필요한 모든 로직을 수행하는 패턴이다. 
  • 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분 의 비즈니스 로직을 처리하는 것
  • 구현이 매우 쉽고 단순하지만 구조가 복잡해질 수록 모듈화의 복잡도 역시 높아진다.
  • 하나의 강건한 로직이 해당 모듈에서만 구현되어야할 경우 side effect 를 예방할 수 있고, 좀 더 효율적인 코드를 작성할 수 있다.

 

<참고>

@Transaction

@Transactional은 기본적으로 readOnly = false 이다.

readOnly는 현재 해당 그 트랜잭션 내에서 데이터를 읽기 작업만 하는 지 설정하는 것이다. 이걸 설정하면 read 락(lock)과 write 락을 따로 쓰는 DB의 경우 해당 트랜잭션에서 의도치 않게 데이터를 변경하는 일을 막아줄 뿐 아니라, 하이버네이트를 사용하는 경우에는 FlushMode를 Manual로 변경하여 dirty checking을 생략하게 해준다거나 DB에 따라 DataSource의 Connection 레벨에도 설정되어 약간의 최적화가 가능하다.

 

데이터의 변경이 없는 읽기 전용 메서드에 readOnly = true 를 지정하면, 약간의 성능 향상시킬 수 있다. 

 

따라서, 다음과 같이 클래스 범위에 @Transactional(readOnly = true) 를 선언해주고, 변경 작업이 있는 메소드에만 @Transactional를 추가적으로 선언해줄 수 있다.


@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {

    private final ItemRepository itemRepository;

    @Transactional
    public void saveItem(Item item) {
        this.itemRepository.save(item);
    }

}

변경 감지와 Merge

변경 감지 (Dirty Checking)

JPA는 영속성 컨텍스트에 존재하는 엔티티의 변경을 감지하고, 이를 데이터베이스 커밋 시점에 반영시킨다.

준영속 엔티티

준영속 엔티티란, 영속성 컨텍스트의 관리 대상이었던 적이 있고, 데이터 베이스에 저장되어서 식별자가 존재하지만, 현재는 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다.

 

다음 상품 수정 API 에서 itemId를 식별자 값으로 가지는 Book 은 데이터베이스에 존재한다.

그러나, 상품 수정 API가 호출되는 시점에서 영속성 컨텍스트에는 해당 book 엔티티는 존재하지 않는다.

이러한 상황에서, book은 준영속 상태라고 한다.


@Controller
@RequiredArgsConstructor
public class ItemController {

	// ...
  
    /**
     * 상품 수정
     */
    @PostMapping(value = "/items/{itemId}/edit")
    public String updateItem(@PathVariable final String itemId, @ModelAttribute("form") BookForm form) {
        Book book = new Book();
        book.setId(form.getId());
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(form.getAuthor());
        book.setIsbn(form.getIsbn());
        this.itemService.saveItem(book);
        return "redirect:/items";
    }
}

 

이러한 준영속 상태의 엔티티를 수정하기 위한 2가지 방법이 있다.

 

준영속 엔티티를 수정하는 방법

  • 변경 감지 기능을 이용

@Controller
@RequiredArgsConstructor
public class ItemController {

	// ...
  
    /**
     * 상품 수정
     */
    @PostMapping(value = "/items/{itemId}/edit")
    public String updateItem(@PathVariable final String itemId, @ModelAttribute("form") BookForm form) {
        Book book = new Book();
        book.setId(form.getId());
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(form.getAuthor());
        book.setIsbn(form.getIsbn());
        this.itemService.updateItem(book.getId(), book);
        return "redirect:/items";
    }
}

상품 수정 updateItem(book.getId(), book) 호출 


@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {

    private final ItemRepository itemRepository;

    @Transactional
    public void saveItem(Item item) {
        this.itemRepository.save(item);
    }

    @Transactional
    public void updateItem(Long itemId, Book bookParam) {
        /**
         * Book bookParam : 준영속 상태의 Book 엔티티
         * findItem : 영속 상태 객체
         */
        Item findItem = this.itemRepository.findOne(itemId);
        
        // 얻어온 영속 상태의 객체에 수정을 해준다. => JPA의 변경 감지 기능으로 데이터 수정 반영
        findItem.setPrice(bookParam.getPrice());
        findItem.setName(bookParam.getName());
        findItem.setStockQuantity(bookParam.getStockQuantity());
    }

 

  • merge() 기능을 이용


    준영속 상태 merge 과정

    1. merge()를실행한다.
    2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.

      2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다.

    3. 조회한 영속 엔티티( mergeMember )member 엔티티의 값을 채워 넣는다. (member 엔티티의 모든 값mergeMember에 밀어 넣는다. 이때 mergeMember회원1”이라는 이름이 회원명변경으로 바뀐다.)
    4. 영속 상태인 mergeMember를 반환한다.

 


@Controller
@RequiredArgsConstructor
public class ItemController {

	// ...
  
    /**
     * 상품 수정
     */
    @PostMapping(value = "/items/{itemId}/edit")
    public String updateItem(@PathVariable final String itemId, @ModelAttribute("form") BookForm form) {
        Book book = new Book();
        book.setId(form.getId());
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(form.getAuthor());
        book.setIsbn(form.getIsbn());
        this.itemService.saveItem(book);
        return "redirect:/items";
    }
}

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {

    private final ItemRepository itemRepository;

    @Transactional
    public void saveItem(Item item) {
        this.itemRepository.save(item);
    }
}

@Repository
@RequiredArgsConstructor
public class ItemRepository {

    private final EntityManager entityManager;

    public void save(Item item) {
        if (item.getId() == null) {
            this.entityManager.persist(item);
        } else {
            this.entityManager.merge(item);
        }
    }
}

병합시 동작 방식을 간단히 정리

 

  • 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회한다.
  • 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체한다.(병합한다.)
  • 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터베이스에 UPDATE SQL이 실행
주의
변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된다. 병합시 값이 없으면 null 로 업데이트 할 위험도 있다. (병합은 모든 필드를 교체한다.)

따라서, 병합 기능이 아닌 변경 감지 기능으로 명확하게 해줘야한다.

  • 컨트롤러에서 어설프게 엔티티를 생성하지 마세요.
  • 트랜잭션이 있는 서비스 계층에 식별자( id )와 변경할 데이터를 명확하게 전달하세요.(파라미터 or dto)
  • 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하세요.
  • 트랜잭션 커밋 시점에 변경 감지가 실행됩니다.

 

반응형

'Spring > JPA & Hibernate' 카테고리의 다른 글

영속성 컨텍스트 (persistence context)  (0) 2021.03.02
트랜잭션  (0) 2021.01.05
Spring Data JPA - H2 연동  (0) 2020.12.24
JPQL(Java Persistence Query Language) - 2  (0) 2020.12.22
JPQL(Java Persistence Query Language) - 1  (0) 2020.12.20