dev-course day28

2rlokr·2025년 4월 10일

dev-course

목록 보기
28/43
post-thumbnail

오늘 배운 것

MyBatis 실습

Case 1 : Builder

@Builder
public Items(String name, String itemCode, Integer price) {
	this.name = name;
    this.itemCode = itemCode;
    this.price = price;
}
  • @Builder 어노테이션을 붙여주면 알아서 빌더 패턴으로 생성자를 만들어준다.
  • 클래스 전체에 어노테이션을 붙이지 않고, 특정 생성자에게만 붙여주는 이유는 원하지 않는 필드까지 빌더로 만들고 싶지 않기 때문이다.
  • id는 DB에서 auto_increment, createdAtnow() 함수를 통해 알아서 담기도록 하고 싶기 때문에 해당 필드들을 제외한 생성자에 붙여준 것이다.

Case 2 : @Mock

@Slf4j
@ExtendWith(MockitoExtension.class)
class MyBatisItemRepositoryTests {

    @Mock // SpringBootTest + AutoWired (빈 등록 + 의존성 주입)
    ItemMapper mapper;
    MyBatisItemRepository repository;

    @BeforeEach
    void init() {
        repository = new MyBatisItemRepository(mapper);
    }

MyBatis는 실제 실행 시점에 동적으로 ItemMapper 인터페이스의 구현체를 자동으로 만들어준다. 하지만, 단위 테스트에서는 Spring을 완전히 띄우지 않기 때문에 MyBatis가 자동으로 구현체를 만들어주지 않는다.

지금 테스트에서 관심있는 건 ItemMapper가 실제로 어떻게 동작했는지 알고 싶지 않고, 메서드가 어떻게 동작했다고 가정하고 테스트하고 싶은 것이 관심사이다.
=> 그래서 @Mock 로 가짜 객체를 만들어서, when(...).thenReturn(...)으로 예상 동작을 시뮬레이션하는 것이다.

  • repository가 사용할 mapper로 mock 객체를 넘겨줬기 때문에, 실제 DB를 접근하는 쿼리를 수행하는 것이 아니라 시뮬레이션만! 하는 것이다.

@Mock

  • @MockItemMapper의 가짜 구현체를 만들어준다.
  • when(mapper.findByItemCode(...)).thenReturn(...) 이런 식으로, 메서드가 호출됐을 때 특정 값을 리턴해달라고 정의하는 것이다.

Case 3 : Mock를 이용한 테스트

test 1 : 정상 save

@Test
@DisplayName("상품 등록 서비스")
void item_save_test() throws Exception {

    Items item = Items.builder()
            .name(TestUtils.genRandomItemCode())
            .itemCode(TestUtils.genRandomItemCode())
            .price(TestUtils.genRandomPrice())
            .build();

    doNothing().when(mapper).save(item);

    Items saved = repository.save(item);

	assertThat(saved.getItemCode()).isEqualTo(item.getItemCode());
	verify(mapper, times(1)).save(item);
}
  1. doNothing().when(mapper).save(item);로 상황을 가정해놓는다.
    • repository 내부에서 mapper.save()을 호출해도, 진짜 저장하는 것이 아니라, doNothing()을 수행해라! (아무것도 일어나지 않는다!)

  2. repository.save(item) 을 하면 repository.save() 내부에서 mapper.save(item)을 호출한다고 가정하는 것이다. 하지만, mapper가 mock이기 때문에 실제 DB에 저장되진 않는다.
    • repository.save 은 인자로 들어온 item을 그대로 반환한다. 그게 Items saved 객체에 담긴 것이다.
    • 즉, 실제 DB에 저장된 결과가 아니고, repository 내부에서 반환해준 객체를 그냥 받아온 것이다.

  3. verify 함수로 mappersave함수가 진짜 한 번만 호출되었는지 검사한다.

test 2 : 잘못된 save

@Test
@DisplayName("itemcode가 없는 item을 save하면 오류가 발생할 것이다.")
void raise_exception_test_1() throws Exception {

    Items item = Items.builder()
            .name(TestUtils.genRandomItemCode())
            .price(TestUtils.genRandomPrice())
            .build();

    doThrow(RuntimeException.class).when(mapper).save(item);

    assertThatThrownBy(
            () -> {
                repository.save(item);
            }
    ).isInstanceOf(RuntimeException.class);

    verify(mapper, times(1)).save(item);
}
  1. itemCode 가 포함되어있지 않는 item 객체를 만들었다. -> 에러가 나야함.

  2. doThrow(예외종류)mapper.save(item) 을 했을 때 RuntimeException이 난다고 상황을 가정해두었다.

  3. 잘못된 객체를 저장하려고 하기 때문에 repository.save(item)을 했을 때 예외가 발생한다.

  4. verifymapper.save(item)가 한 번 호출되었는지 검사한다.

    • 예외가 나도 함수는 호출하고 -> 예외가 발생하기 때문에 테스트를 통과한다.

test 3 : thenReturn으로 반환값 미리 설정하기

@Test
@DisplayName("유효한 itemcode는 item을 조회할 수 있고 그렇지 않으면 조회가 불가능하다.")
void find_by_item_code_test() throws Exception {
    
    String VALID_ITEM_CODE = TestUtils.genRandomItemCode();
    String INVALID_ITEM_CODE = "INVALID_ITEM_CODE";

    Items validItem = Items.builder()
            .name(VALID_ITEM_CODE)
            .itemCode(VALID_ITEM_CODE)
            .price(TestUtils.genRandomPrice())
            .build();
	// 1. 유효한 itemCode로 조회한 Item 객체는 잘 조회된다는 가정
	when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(validItem));
    // 2. 유효하지 않은 itemCode로 조회한 Item은 조회가 안된다는 가정
	when(mapper.findByItemCode(INVALID_ITEM_CODE)).thenReturn(Optional.empty());


    Optional<Items> validItemOptional = 
    				repository.findByItemCode(VALID_ITEM_CODE);
    assertThat(validItemOptional.isPresent()).isTrue();
    assertThat(validItemOptional.get()).isEqualTo(validItem);
    assertThat(validItemOptional.get().getItemCode()).isEqualTo(VALID_ITEM_CODE);

    Optional<Items> invalidItemOptional = 
    				repository.findByItemCode(INVALID_ITEM_CODE);
    assertThat(invalidItemOptional.isPresent()).isFalse();
    assertThatThrownBy(
            () -> {
                invalidItemOptional.get();
            }
    ).isInstanceOf(NoSuchElementException.class);
}
  1. 이번엔 when으로 VALID_ITEM_CODE로 조회했을 때, 반환값을 설정해주었다.
    • validItemOptional을 반납해라 !
  2. INVALID_ITEM_CODE로 조회했을 때는, Optional.empty()로 반환값을 미리 설정해주었다.

=> 그렇기 때문에 직접 DB에 접근하지 않았지만 이후 validItemOptional을 얻어올 수 있는 것이다.

test 4 : update

@Test
@DisplayName("item code와 변경하고자 하는 가격이 주어지면 변경된다.")
void it_will_change() throws Exception {

    final String VALID_ITEM_CODE = TestUtils.genRandomItemCode();
    final String INVALID_ITEM_CODE = "INVALID_ITEM_CODE";

    final int TARGET_PRICE = 10_000;

    Items updated = Items.builder()
            .name(VALID_ITEM_CODE)
            .itemCode(VALID_ITEM_CODE)
            .price(TARGET_PRICE)
            .build();

	// 1. update에 어떠한 String, Integer를 파라미터로 보냈을 때, 1을 반환할 것이다는 가정
    when(mapper.update(any(String.class), any(Integer.class))).thenReturn(1);
    // 반환값을 설정해주지 않고 다음과 같이 가정해줄 수 있다. 
    // doNothing().when(mapper).update(anyString(), anyInt());

	// 2. 유효한 itemCode로 조회했을 때 updated의 옵셔널을 반환할 것이라는 가정
    when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(updated));

    repository.updatePrice(VALID_ITEM_CODE, TARGET_PRICE);
    verify(mapper, times(1)).update(VALID_ITEM_CODE, TARGET_PRICE);

    Optional<Items> itemOptional = repository.findByItemCode(VALID_ITEM_CODE);
    assertThat(itemOptional.isPresent()).isTrue();
    assertThat(itemOptional.get()).isEqualTo(updated);
	assertThat(itemOptional.get().getPrice()).isEqualTo(TARGET_PRICE);


	// 3. 유효하지 않은 itemCode로 조회했을 때 Optional.empty가 반환될 것이라는 가정
    when(mapper.findByItemCode(INVALID_ITEM_CODE)).thenReturn(Optional.empty());
    Optional<Items> invalidItemOptional = repository.findByItemCode(INVALID_ITEM_CODE);
    assertThat(invalidItemOptional.isPresent()).isFalse();
    assertThatThrownBy(
            () -> {
                invalidItemOptional.get();
            }
    ).isInstanceOf(NoSuchElementException.class);
}
  1. 메서드에 반환 타입이 있어야 when만 사용할 수 있다. 그래서 임의로 반환값 1을 넣어주었다.

사실상 repository.updatePrice(VALID_ITEM_CODE, TARGET_PRICE); 를 하지 않아도 아래의 테스트는 다 통과한다. 그렇다면 저건 왜 해준 거지...? 하는 의문이 들었다.

이건 단순히 결과가 바뀐 것보다도 변경을 일으키는 행위를 했는지, 그 행위가 예상대로 작동했는지를 검증하고 싶은 것이다.
즉, 나중에 이 테스트를 다시 봤을 때도 어떤 행위를 했을 때 어떤 결과를 기대하는지 보기 위해 가독성을 위해 한 것이라고 볼 수 있다.
좀 더 의미있는 검증을 만들기 위해 verify(mapper, times(1)).update(VALID_ITEM_CODE, TARGET_PRICE);로 내부에서 mapper.update가 호출되었는지 검증해주었다.

Hibernate 실습

Case 1 : 어노테이션

//@Setter
@Getter
@Entity
@Table(name="items")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Items {

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

    private String name;
    private String itemCode;

    @Setter
    private Integer price;
  • @Setter 어노테이션은 클래스 레벨에서 지양해야 한다.
    • 협업하는 다른 개발자가 setter가 열려있으면, 바꿔도 되는 필드인 줄 알고 중요한 값들을 바꿔버릴 수 있기 때문에 위험하다.
    • 열어둘 필드에만 개별적으로 어노테이션을 달아줘야 한다.
  • @Entity 객체에는 꼭 기본 생성자가 필요하다. -> @NoArgsConstructor 필수
  • @NoArgsConstructor의 접근 제어자 기본 설정은 public이다. 하지만, 생성자를 외부에서 막 쓰이게 하고 싶지는 않아서 protected로 제한해두는 것이 안전하다.
  • @Entity에는 꼭 @Id가 있어야 한다.

Case 2 : DDL-auto

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Orders {

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

    @Column(unique = true, nullable = false)
    private String orderCode;

    private LocalDateTime orderedAt = LocalDateTime.now();

}

이 클래스를 보고 스키마를 짤 수 있을 것 같나요? -> YES
그렇다면 똑똑한 JPA도 가능하다!

application.yml

spring:
  jpa :
    hibernate:
      ddl-auto: create

이 설정을 해두면, JPA가 @Entity 붙은 클래스들을 스캔해서 테이블을 실행할 때마다 알아서 생성해준다. (이미 있을 경우, 지우고 생성해준다.)

Case 3 : EntityManager

@Repository
@RequiredArgsConstructor
public class HibernateItemRepository {

    private final EntityManager entityManager;
  • repositoryentityManagerprivate final로 선언해둔다.
  • 그럼 알아서 Spring Boot가 EntityManagerFactory에서 생성한 EntityManager을 자동으로 주입해준다.
  1. spring-boot-starter-data-jpa 의존성을 추가하면
    Spring Boot는 자동으로 JPA 설정을 구성해준다.
  2. 그 설정 안에서 EntityManagerFactory 빈이 자동 생성된다. (내부적으로 Hibernate 사용)
  3. 그럼 Spring이 알아서 이 팩토리에서 만든 EntityManager를 꺼내서 의존성을 주입해준다.

CRUD : create

public Items save(Items items) {
	entityManager.persist(items); // 이 때는 아직 DB에 저장된 것 X
	return items;
}
  • DB에 쿼리로 접근하는 것이 아니라 엔티티 매니저의 영속성 컨텍스트 내에서 엔티티를 관리해준다.
  • .persist()transient-> managed 상태로 영속화시켜준다.
  • 이 메서드에서는 return items하면 commit이 되어서 DB에 반영된다.
  • entityManager.flush(); 로 커밋 전에 action queue에 있는 쿼리를 DB로 보내 반영할 수도 있다.

CRUD : Read (1)

public Optional<Items> findById(Long id) {
	Items items = entityManager.find(Items.class, id); // 명시해준 클래스 타입으로 반환이된다.
	return Optional.ofNullable(items);
}
  • DB에서 꺼내온 객체는 영속화된 상태로 가져와지고, 그 시점으로 스냅샷이 찍힌다.
  • 마찬가지로, 쿼리로 객체를 가져오는 것이 아니라 .find() 메서드를 이용해서 객체를 가져온다.
  • .find() 파라미터 내에 명시해준 클래스 타입으로 값이 반환되고, 파라미터에 넣은 id로 객체를 찾는다.

CRUD : Read (2)

public Optional<Items> findByItemCode(String itemCode) {
		// 간단히 아래의 방법으로도 얻어올 수 있다.
		// Items items = entityManager.find(Items.class, itemCode);
		// return Optional.ofNullable(items);

// SQL
	String sql = """
			SELECT 
				i.item_id as id,
				i.code as itemCode,
				i.name
			FROM
				items i
			WHERE
				i.code = ?
			""";

	try{
    	//JPQL : SELECT 엔티티_별칭 FROM 엔티티명 엔티티_별칭 WHERE 조건
		String jpql = "select i from Items i where i.itemCode = :itemCode";

		Items findItem = entityManager.createQuery(jpql, Items.class)
			.setParameter("itemCode", itemCode)
			.getSingleResult();
		return Optional.of(findItem);
	} catch(NoResultException e) {
		return Optional.empty();
	}
}

JPQL (Java Persistence Query Language)
: JPA에서 사용하는 쿼리 언어 (객체지향적으로 만들어짐)
SQL (Structured Query Language)
: DB에 특화된 언어

  • JPQL 안에서는 :{필드명}으로 바인딩 변수를 사용한다.
  • entityManager.createQuery를 통해 jqpl 쿼리를 보내고, Items.Class로 타입을 설정한다.
  • .setParameter로 바인딩 변수에 어떤 값을 넣어줄지 정한다. 쿼리 내의 변수 이름은 이 .setParameter의 파라미터 이름과 동일하게 설정해주면 된다.
  • .getSingleResult()로 결과값을 한 개의 row만 받겠다고 설정한 것이다.
  • 찾고자한 결과가 없을 시 NoResultException 예외가 발생한다. 그럴 때는 Optional.empty()를 반환해준다.

CRUD : update & delete

public void updatePrice(String itemCode, Integer price){
    Optional<Items> itemOptional = findByItemCode(itemCode);

    Items item = itemOptional.orElseThrow();
    item.setPrice(price); 
}

public void delete(Items items) {
    entityManager.remove(items);
}
  • EntityManager은 변경 감지의 기능을 가지고 있다. 그렇기 때문에 UPDATE를 할 때마다 쿼리를 던져주는 것이 아니라, 마지막에 commit할 때만 스냅샷과 비교해 달라진 필드들을 반영한 update문을 action queue에 올려준다.
  • 그렇기 때문에 따로 메서드를 쓰지 않아도 되고, setter를 이용해 값을 변경해주면 된다.

Springboot 없이 Hibernate로 DB 연동 및 테스트

resources/META-INF/persistence.xml

<persistence-unit name="grepp-hibernate-exp2">
  <properties>
    <property name="jakarta.persistence.jdbc.driver" value="org.h2.Driver"/>
    <property name="jakarta.persistence.jdbc.url" value="jdbc:h2:./test" />
    <property name="jakarta.persistence.jdbc.user" value="sa"/>
    <property name="jakarta.persistence.jdbc.password" value=""/>
  </properties>

</persistence-unit>
<persistence-unit name="grepp-hibernate-exp1">

  <class>io.silver.domain.eg1.Member</class>
  
  <properties>
    <!--     url, username, password      -->
    <property name="jakarta.persistence.jdbc.driver" value="com.mysql.cj.jdbc.Driver"/>
    <property name="jakarta.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/grepp_hibernate_test" />
    <property name="jakarta.persistence.jdbc.user" value="happy"/>
    <property name="jakarta.persistence.jdbc.password" value="day"/>
  </properties>

</persistence-unit>
  • 사용할 엔티티들을 <class>를 통해 명시해줘야 한다.
  • persistence-unit 하나당 → EntityManagerFactory 하나를 가질 수 있다.
  • 두 개의 DB를 연결해두었고, 각 persistence-unit별 EntityManagerFactory를 가질 수 있다.

EntityManagerFactory, EntityManager

static EntityManagerFactory entityManagerFactory;
EntityManager entityManager;

@BeforeAll 
static void init() {
	entityManagerFactory =
		Persistence.createEntityManagerFactory("grepp-hibernate-exp1");
}

@BeforeEach
void setUp() {
    entityManager = entityManagerFactory.createEntityManager();
}

@AfterEach
void close() {
    entityManager.close();
}

@AfterAll
static void tearDown() {
	entityManagerFactory.close();
}
  • EntityManagerFactory는 무겁고 만드는 데 비용이 많이 든다. 그렇기 때문에 애플리케이션 전체에서 딱 하나 생성해서 재사용한다.
  • EntityManager는 쓰레드 안전하지 않아서, 테스트/요청마다 새로 생성해서 써야 한다.

=> 그렇기 때문에 EntityManagerFactory는 테스트 전체 전/후에만 만들고 닫아주면 된다.

  • @BeforeAll, @AfterAll : static으로 선언해줘야 한다.

반면에, 이전 테스트에서 남은 컨텍스트가 다음 테스트에 영향을 주면 안 되기 때문에 EntityManager은 테스트마다 만들어주고 닫아줘야한다.

  • @BeforeEach , @AfterEach

느낀 점

와.. 오늘 와이래 어렵냐며.. 흑흑 복습할 것도 너무 많고, 새로운 것들 투성이 ~ 오전에 스무스하게 잘 듣다가 Mock에서 완전히 어질어질했다. 아니 실제로 안되는데 여기서 이걸 왜 하는 거지? 이게 진짜 DB에 반영된 건가? 아닌데 왜 해? 이런 너낌스.. 나만 어지러운 것 같아서 점심시간을 이용해서 좀 이해했다.. 그랬는데~ 점심 먹고도 쉽진 않드라고예.. 후

근데 오랜만에 날 응? 응? 응? 하게 만든 것 같아서 또 흥미로웠어. 막이래 호호호 그치만 적당히하라구. 그만 어려워. 귀엽게 봐주는 데에는 한계가 있어~~ .. 아무튼 재밌지만 어렵다.

이제 확실히 진도도 좀 나갔고, 여러가지 보다 보니 머리에서 섞이고 섞이고 하는 것도 있는 것 같다. 확실히 복습의 필요성을 더더욱 느낀다..! 주말에 또 달려야겠다 ㅎ.. 이번주도 무사히 끝내게 해주세요

0개의 댓글