본문 바로가기
TIL/Spring

23.07.28

by J1-H00N 2023. 7. 28.

쿼리 최적화를 발생시키기 위해서는 아래 3가지를 확인해봐야 합니다.

  1. 먼저 해당 함수나 클래스가 Transaction 안에 포함되고 있는지 봐야합니다.
    1. @Transactional 으로 함께 감싸져 있어야만 쿼리 최적화가 동작합니다.
    2. Transaction 으로 포함되어있지 않으면 repository 메소드 내부에서만 Transcation 이 최적화됩니다.
    3. Transaction Propagation (전파) 전략 체크해봐야합니다. (심화)
  2. 두번째로, 해당 엔티티의 ID 식별자 생성전략을@GeneratedValue(strategy = GenerationType.IDENTITY)로 사용한건 아닌지 확인해봐야 합니다.
    1. GenerationType.IDENTITY 로 키필드가 설정되어 있으면 데이터베이스에 실제로 저장을 해야 유일한 식별자를 구할 수 있으므로 Insert 쿼리가 즉시 데이터베이스에 전달됩니다.
    2. GenerationType.SEQUENCE 로 설정하길 추천드립니다. 이렇게하면 DB에 select nextval('thread_seq') 쿼리만 날라갑니다.
    3. GenerationType.AUTO 로 설정해서 DB에서 선호하는 방식으로 자동적용시켜줄 수도 있습니다.
  3. 마지막으로, orphanRemoval() 영속성 전이에 의해 자식의 삭제작업이 이루어지는건 아닌지 확인해봐야합니다.
    1. 이런경우 1차캐시 최적화가 아닌 삭제쿼리가 쓰기지연 저장소에 저장되어 동작하게 되므로 후처리로 발생하게 됩니다.

 

통합테스트는 아래와 같은 단점이 있기 때문에 가능하다면 슬라이스 테스트를 추천드립니다.

  • 실제 구동되는 애플리케이션의 설정, 모든 Bean을 로드하기 때문에 시간이 오래걸리고 무겁다.
  • 테스트 단위가 크기 때문에 디버깅이 어려운 편이다.
  • 결과적으로 웹을 실행시키지 않고 테스트 코드를 통해 빠른 피드백을 받을 수 있다는 장점이 희석된다.

Spring 통합 테스트

@SpringBootTest   
// SpringApplication 띄울때의 빈들을 모두 생성해줍니다.
@Transactional    
// 테스트 메소드들이 모두 트랜잭션에 포함되어 최적화 되도록 합니다. 
// 테스트 대상 함수의 실행환경에서는 Transaction이 안걸려 있을 수 있으니 실무에 사용시 주의
@Rollback(value = false) // 테스트 데이터가 롤백되지 않고 실제 DB에 반영되도록 합니다.

JPA 슬라이스 테스트

@DataJpaTest      
// SpringDataJpa 테스트에 필요한 빈들만 생성해줍니다.
@Transactional    
// 테스트 메소드들이 모두 트랜잭션에 포함되어 최적화 되도록 합니다. 
// 테스트 대상 함수의 실행환경에서는 Transaction이 안걸려 있을 수 있으니 실무에 사용시 주의
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)  
// 테스트 용 DB를 따로 설정하지 않고 main 환경 DB를 그대로 사용하도록 합니다.
@Import(JPAConfiguration.class) 
// JPAQueryFactory와 같이 테스트시 필요한 빈들을 정의해놓은 Configuration 설정합니다.
@Rollback(value = false)
// 테스트 데이터가 롤백되지 않고 실제 DB에 반영되도록 합니다.

 

TestContainers

  • 도커 환경에서 데이터베이스를 실행하여 테스트 환경을 쉽게 구축할 수 있게 해주는 라이브러리
  • 개발 환경에 데이터베이스를 사용하지 않기 때문에 테스트 때문에 발생하는 더미 데이터를 줄일 수 있다.
  • H2와 같은 인메모리 DB를 사용하는 것이 아니라서 실제 환경과 거의 비슷한 환경으로 데이터베이스를 테스트할 수 있다.
  • 테스트가 느려지는 단점이 있다.

 

현업에서 Datasource 설정하는 방법

TestContainers 에서 제공하는 애노테이션으로 쉽게 구성할 수 있다고하지만, 현업에서는 애플리케이션을 작성할 때 데이터베이스 설정 부분을 Spring에서 제공하는 기본 설정으로만 구성하는 경우가 없어서 사용하기 어려워 Datasource 부분을 자바 코드로 작성하는 곳이 많습니다.

 

TestContainers 적용 예시

// build.gradle

testImplementation "org.testcontainers:testcontainers:1.17.6"
testImplementation 'org.testcontainers:junit-jupiter:1.17.6'
testImplementation 'org.testcontainers:mysql:1.17.6'

 

# application.yml

spring:
  profiles:
    active: local
  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQL8Dialect
    open-in-view: false
    properties.hibernate:
      hbm2ddl.auto: create-only
      enable_lazy_load_no_trans: false
      implicit_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
      physical_naming_strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
      default_batch_fetch_size: 100
  data:
    web:
      pageable:
        default-page-size: 20 # page 파라미터 없을 경우에 default 값
        max-page-size: 100 # size 파라미터 없을 경우에 default 값
        one-indexed-parameters: true # 페이지 시작을 1부터 (currentPage - 1)
logging:
  config: classpath:log4j2.xml

decorator:
  datasource:
    p6spy:
      enable-logging: true # p6spy 활성화 여부
// ContainerDataSourceConfiguration.java

import javax.sql.DataSource;

import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.utility.DockerImageName;

import com.zaxxer.hikari.HikariDataSource;

@Configuration
public class ContainerDataSourceConfiguration {
	private static final MySQLContainer<?> MY_SQL_CONTAINER = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.27"))
		.withDatabaseName("ci")
		.withUsername("teasun")
		.withPassword("pass");

	static {
		MY_SQL_CONTAINER.start();
	}

	@Bean
	public DataSource dataSource() {
		return DataSourceBuilder.create()
			.type(HikariDataSource.class)
			.url(MY_SQL_CONTAINER.getJdbcUrl())
			.driverClassName(MY_SQL_CONTAINER.getDriverClassName())
			.username(MY_SQL_CONTAINER.getUsername())
			.password(MY_SQL_CONTAINER.getPassword())
			.build();
	}

}
// @RepositoryTest.java

@DataJpaTest(excludeAutoConfiguration = {DataSourceAutoConfiguration.class, TestEntityManagerAutoConfiguration.class, DataSourceConfiguration.class})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({ContainerDataSourceConfiguration.class, JpaConfiguration.class, QueryDslConfiguration.class})
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepositoryTest {
}

 

 

FixtureMonkey

  • 네이버에서 만든 테스트 생성 객체를 자동으로 생성해주는 자바 라이브러리
  • Mock 객체를 보다 쉽게 생성하기 위해서 사용
# build.gradle
testImplementation 'com.navercorp.fixturemonkey:fixture-monkey:0.4.9'
testImplementation 'com.navercorp.fixturemonkey:fixture-monkey-javax-validation:0.4.9'
// UserFixture.java

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserFixture {
	private Long userId;
	@NotNull
	private UserType type;
	@NotNull
	private UserStatus status;

	public User toEntity() {
		return User.builder()
			.userId(userId)
			.type(type)
			.status(status)
			.build();
	}
}

예시

var fixturemonkey = FixtureMonkey
		.labMonkeyBuilder()
		.objectIntrospector(BeanArbitraryIntrospector.INSTANCE)
		.plugin(new JavaxValidationPlugin())
		.build();

var user = fixturemonkey.giveMeBuilder(UserFixture.class)
			.set("userId", FixtureMonkeyUtils.getUserId())
			.build()
			.sample()
			.toEntity();

 

 

테스트 간단 정리

  • 단위 테스트
    • 각 계층(클래스) 별로 테스트 케이스 작성
  • 통합 테스트
    • 실행될 때마다 랜덤하게 변경되는 시나리오를 만들고 그에 따른 데이터를 미리 생성(Docker 환경의 데이터베이스)
    • 모든 엔드포인트에 대해서 테스트
    • 사전에 데이터를 미리 만들어둔 것을 통해서, 결과를 예측하고 검증할 수 있음
  • 단위테스트는 Pull Request 에서 검증하는 용도(CI)
  • 통합테스트는 정기 배포 당일 생성한 브랜치에 대해서 검증하고 검증이 완료된다면 자동으로 배포하는 프로세스(CD)

 

Propagation (전파 전략)

@RunWith(SpringRunner.class)
@SpringBootTest
public class TransTest {

    @Autowired
    Parent parent;


    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED) // 트랜잭션 생성 방지
    public void transactionalPropagationTest() {
        parent.parentMethod(); // 부모 메서드 실행
    }

}

@Component
public class Parent {

    @Autowired
    Child child;


    @Transactional
    public void parentMethod() {
        System.out.println("Parent");
        child.calledMethod();
    }
}

@Component
public class Child {

    @Transactional(propagation = ?) // <- 여기에 하나씩 넣어보세요.
    public void calledMethod() {
        System.out.println("Child");
    }
}

 

1. PROPAGATION_REQUIRED ( JpaTransactionManager Default )

  • 부모 트랜잭션이 존재할 경우 부모 트랜잭션에 참여한다.
  • 부모 트랜잭션이 없을 경우 새 트랜잭션을 시작한다.

 

일반적으로 사용되는 트랜잭션의 전파유형이다.

 

 

즉, 어떻게 해서든 (상속받든, 내가 새로만들든) Transaction 을 시작하는 전파 유형

 

 

2. PROPAGATION_SUPPORTS

  • 부모 트랜잭션이 존재할 경우 부모 트랜잭션에 참여한다.
  • 부모 트랜잭션이 없을 경우 non-transactional 하게 동작한다.

 

부모 따라서 전파되는 유형

 

 

3. PROPAGATION_MANDATORY

  • 부모 트랜잭션이 존재할 경우 부모 트랜잭션에 참여한다.
  • 부모 트랜잭션이 없을 경우 Exception이 발생한다.

 

트랜잭션에 참여하도록 강제하는 유형

 

 

4. PROPAGATION_REQUIRES_NEW

  • 부모 트랜잭션 유무에 상관없이 새 트랜잭션을 시작한다.
  • 부모 트랜잭션이 존재할 경우 부모 트랜잭션을 중지 시킨다.

 

무조건 새 트랜잭션을 생성하도록 강제하는 유형

 

 

5. PROPAGATION_NOT_SUPPORTED

  • 부모 트랜잭션 유무에 상관없이 non-transactional 하게 동작한다.
  • 부모 트랜잭션이 존재할 경우 부모 트랜잭션을 중지 시킨다.

6. PROPAGATION_NEVER

  • 부모 트랜잭션이 존재할 경우 Exception이 발생한다.

항상 non-transactional 하게 동작하는 유형

 

 

Isolation (격리 수준)

조회시점에 다른 트랜잭션들과 얼만큼 격리하여 얼마나 정확한 데이터 목록을 얻을지 결정한다.

  1. Read Uncommited : 다른 트랜잭션에서 커밋되지 않은 내용도 참조할 수 있다
  2. Read Commited : 다른 트랜잭션에서 커밋되 내용만 참조할 수 있다.
  3. Repeatable Read : 트랜잭션에 진입하기 이전에 커밋된 내용만 참조할 수 있다.
  4. Serializable : 트랜잭션에 진입하면 락을 걸어 다른 트랜잭션이 접근하지 못하게 한다. (성능이 매우 떨어짐)

1번이 가장 빠르지만 안전하지 않고, 4번이 가장 안전하나 성능이 떨어져 현업에서는 2번이 주로 쓰이고 가끔 중요한 정보를 다룰 땐 3번이 쓰인다.

 

rollbackFor

@Transactional 은 기본적으로 Unchecked Exception, Error 만을 rollback 해줍니다.

그렇기 때문에 모든 예외에 대해서 rollback을 진행하고 싶을 경우(rollbackFor = Exception.class) 를 붙여야 합니다.

 

Exception 구분

Checked Exception : 예상된 에러로 해당 쓰레드내에서 회복이 가능한 예외

Unchecked Exception, Error : 예상치 못한 에러로 회복이 불가능한 예외

따라서, 근본적으로 HTTP API는 잘못된 요청에서 회복할 수 없다

클라이언트는 백엔드 API의 예외로부터 회복할 수 없다. 예외 발생 시 request의 라이프 사이클은 400, 404, 500 등 응답을 반환하고 끝이 난다. 클라이언트가 이 request를 회복 시킬 수는 없다. 다만 새로운 요청을 보낼 뿐이다. 클라이언트는 아무것도 할 수 없고 백엔드는 회복할 수 없다. 현재 프로그램의 흐름 내에서 회복할 수 있을때만 checked exception을 사용해야한다

 

 

N+1 문제란?

엔티티 하나을 조회하기 위해서 1:N 로 연관된 엔티티까지 조회 쿼리문이 N+1번 날라가는 이슈

이로 인해 아래와 같은 시스템에 심각한 성능 저하가 일어날 수 있다

  • Comment 조회 - 1번
  • Comment의 갯수(각 Comment가 가지고 있는 Board 조회) - N번

이렇게 하면 N+1번의 쿼리가 발생하는 것이다

 

N+1 문제 해결방법 3가지

  1. GlobalFetch
  2. Fetch Join
  3. EntityGraph

아래에서 세가지 방법이 어떤 것인지 어떻게 사용하는 것인지에 대해서 살펴보도록 하자

 

Global Fetch Strategy

글로벌 패치 전략이란, 엔티티를 생성할 때(컴파일 시점) 결정 되는 연관관계 전략이다

public class Comment {

    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "thread_id")
    Board thread;
}

해결방법은 @ManyToOne 속성에 fetch 속성으로 LAZY를 주면 된다

 

Fetch Join

조인할 때 연관된 엔티티나 컬렉션를 함께 조회하려고 할 때 사용한다 결과는 EAGER와 똑같지만 과정은 다르다 EAGER의 경우에는 N+1 쿼리가 발생하지만 Fetch Join의 경우에는 한번이 쿼리문으로 해결이 가능하다

public interface CommentRepository extends JpaRepository<Comment, Long> {

    @Query("select c from Comment c join fetch c.board")
    List<Comment> findAll();
}

Spring Data JPA 에서는 @Query 어노테이션을 이용하여 JPQL를 생성할 수 있다

사용하는 방법은 위와 동일하게 join fetch 뒤에 연관된 엔티티나 컬렉션을 적어주면 된다

 

EntityGraph - 현업 사용 (+ QueryDSL도 현업 사용)

@EntityGraph도 마찬가지로 EntityGraph 상에 있는 Entity들의 연관관계 속에서 필요한 엔티티와 컬렉션을 함께 조회하려고 할때 사용한다

public interface CommentRepository extends JpaRepository<Comment, Long> {

    @EntityGraph(attributePaths = {"thread"}, type = EntityGraph.EntityGraphType.LOAD)
    List<Comment> findAll();
}
  • Spring Data JPA에서 적용하려는 메소드 위에 @EntityGraph 어노테이션을 달고 옵션을 준다
  • attributePaths는 같이 조회할 연관 엔티티명을 적으면 된다.
  • , (콤마)를 통하여 여러개를 줄 수도 있다

 

type은 EntityGraphType.LOAD, EntityGraphType.FETCH 2가지가 있다

  • LOAD : attributePaths에 정의한 엔티티들은 EAGER, 나머지는 글로벌 패치 전략에 따라 패치한다
    • 일단 attributePaths 는 EAGER, 나머지는 매핑 설정 따라서
  • FETCH : attributePaths에 정의한 엔티티들은 EAGER, 나머지는 LAZY로 패치한다
    • 나빼고 다 LAZY!!

 

 

강의를 듣던 중 부모 entity에 있는 컬렉션에서 자식 객체를 제거했을 때, cascade를 남기면 아무 변화가 없고 orphanRemoval을 남기면 delete 쿼리가 날아가는 상황이 발생해 cascade.remove와 orphanRemoval = true의 차이에 대해 토의하게 되는 기회가 있어 결론을 남긴다.

private List<Child> childList = new ArrayList<>();
해당 list 는 부모 - 자식 관계를 설쟁하주는 것이고, 영속성 컨텍스트 생명주기에는 관련이 없다

List 내에 있는 child 엔티티를 제거하는 건 영속성 컨텍스트에서 제거하는 게 아니라, 부모 - 자식 관계를 끊어주는 것이다

orphanRemvoval = true 일 때는 부모-자식 관계가 끊어진 고아객체를 제거하고,

cascadeType.All 일 때는 영속성 전이를 담당하기에 관계를 끊어준다고 하더라도 제거가 되지 않는다.

'TIL > Spring' 카테고리의 다른 글

23.08.04  (0) 2023.08.04
23.08.03  (0) 2023.08.03
23.07.27  (0) 2023.07.27
23.07.26  (0) 2023.07.26
23.07.25  (0) 2023.07.25