쿼리 최적화를 발생시키기 위해서는 아래 3가지를 확인해봐야 합니다.
- 먼저 해당 함수나 클래스가 Transaction 안에 포함되고 있는지 봐야합니다.
- @Transactional 으로 함께 감싸져 있어야만 쿼리 최적화가 동작합니다.
- Transaction 으로 포함되어있지 않으면 repository 메소드 내부에서만 Transcation 이 최적화됩니다.
- Transaction Propagation (전파) 전략 체크해봐야합니다. (심화)
- 두번째로, 해당 엔티티의 ID 식별자 생성전략을@GeneratedValue(strategy = GenerationType.IDENTITY)로 사용한건 아닌지 확인해봐야 합니다.
- GenerationType.IDENTITY 로 키필드가 설정되어 있으면 데이터베이스에 실제로 저장을 해야 유일한 식별자를 구할 수 있으므로 Insert 쿼리가 즉시 데이터베이스에 전달됩니다.
- GenerationType.SEQUENCE 로 설정하길 추천드립니다. 이렇게하면 DB에 select nextval('thread_seq') 쿼리만 날라갑니다.
- GenerationType.AUTO 로 설정해서 DB에서 선호하는 방식으로 자동적용시켜줄 수도 있습니다.
- 마지막으로, orphanRemoval() 영속성 전이에 의해 자식의 삭제작업이 이루어지는건 아닌지 확인해봐야합니다.
- 이런경우 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 (격리 수준)
조회시점에 다른 트랜잭션들과 얼만큼 격리하여 얼마나 정확한 데이터 목록을 얻을지 결정한다.
- Read Uncommited : 다른 트랜잭션에서 커밋되지 않은 내용도 참조할 수 있다
- Read Commited : 다른 트랜잭션에서 커밋되 내용만 참조할 수 있다.
- Repeatable Read : 트랜잭션에 진입하기 이전에 커밋된 내용만 참조할 수 있다.
- 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가지
- GlobalFetch
- Fetch Join
- 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 일 때는 영속성 전이를 담당하기에 관계를 끊어준다고 하더라도 제거가 되지 않는다.