
프로젝트 개요
복합 문제가 발생한 프로젝트를 코드 개선해보는 프로젝트를 수행
QueryDSL 사용 및 동적 쿼리로 교체
@Transactional 의 전파 설정에 따른 흐름 수정 및 cascade 사용하기
JWT 사용 및 Spring Security 사용으로 인한 개선 작업
AOP의 작동을 위한 어드바이스 수정
N+1 해결을 위한 JPQL 수정
EC2 환경 배포, RDS, S3 연동
대용량 데이터 처리를 위한 SQL 튜닝
수행 과정
개발 환경 세팅하기
Docker 에 익숙해질 겸 docker compose,
환경 변수 파일 (env) 를 사용해서 개발 환경을 세팅 해보았다
또 docker 명령어를 기록해두고 사용하기 위해 makefile 을 사용했다
# docker-compose.yml
services:
db:
image: mysql:8.4.8
container_name: mysql_fix-and-test-v2-dev
networks:
fix-and-test-v2-dev-network:
# docker-compose.dev.yml
services:
db:
env_file:
- .env.dev
volumes:
- ./dev-db-data:/var/lib/mysql # bind mount 사용
ports:
- "13309:3306"
dev:
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.dev up -d
down:
docker compose down
down-v:
docker compose down -v
QueryDSL 교체
QueryDSL 사용을 위해 Gradle 과 Configuration 을 세팅
공식적인 QueryDSL 프로젝트는 취약점도 있거니와
2024년 이후 프로젝트가 개선되지 않고 있어
OpenFeign 측의 QueryDSL 을 사용했다
// OpenFeign QueryDSL
implementation 'io.github.openfeign.querydsl:querydsl-core:7.1'
implementation 'io.github.openfeign.querydsl:querydsl-jpa:7.1'
annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:7.1:jpa'
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory queryFactory() {
return new JPAQueryFactory(em);
}
}
따로 QueryDSL의 구현체를 마련해주었다
여기서 동적 쿼리를 사용하기 위해 BooleanExpression 을
사용하여 검색에 있어 동적 쿼리를 구현했다
public interface TodoRepositoryWithQueryDSL {
Optional<TodoResponse> findByIdWithUserWithQueryDSL(Long todoId);
Page<TodoSearchResponse> findAllByMultiCondition(Pageable pageable, String title, String nickName, LocalDateTime created_start_at, LocalDateTime created_end_at);
}
@Repository
@RequiredArgsConstructor
public class TodoRepositoryWithQueryDSLImpl implements TodoRepositoryWithQueryDSL {
private final JPAQueryFactory jpaQueryFactory;
private BooleanExpression titleContains(String title) {
return (title != null && !title.isBlank()) ? todo.title.contains(title) : null;
}
private BooleanExpression nickNameContains(String nickName) {
return (nickName != null && !nickName.isBlank()) ? user.nickname.contains(nickName) : null;
}
private BooleanExpression createAtGoe(LocalDateTime createdAt) {
return createdAt != null ? todo.createdAt.goe(createdAt) : null;
}
private BooleanExpression createAtLoe(LocalDateTime createdAt) {
return createdAt != null ? todo.createdAt.loe(createdAt) : null;
}
@Transactional(readOnly = true)
@Override
public Optional<TodoResponse> findByIdWithUserWithQueryDSL(Long todoId) {
return Optional.ofNullable(jpaQueryFactory
.select(Projections.constructor(TodoResponse.class
, todo.id
, todo.title
, todo.contents
, todo.weather
, Projections.constructor(UserResponse.class
, user.id
, user.email
, user.nickname
)
, todo.createdAt
, todo.modifiedAt)
)
.from(todo)
.leftJoin(todo.user, user)
.where(todo.id.eq(todoId))
.fetchOne());
}
@Override
public Page<TodoSearchResponse> findAllByMultiCondition(Pageable pageable, String title, String nickName, LocalDateTime created_start_at, LocalDateTime created_end_at) {
List<TodoSearchResponse> todoSearchResponseList = jpaQueryFactory
.select(Projections.constructor(TodoSearchResponse.class
, todo.title
, comment.count()
, manager.count()
)
)
.from(todo)
.leftJoin(todo.user, user)
.leftJoin(todo.comments, comment)
.leftJoin(todo.managers, manager)
.where(titleContains(title)
, nickNameContains(nickName)
, createAtGoe(created_start_at)
, createAtLoe(created_end_at))
.groupBy(todo.title, todo.createdAt)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long totalCount = jpaQueryFactory
.select(todo.id.count())
.from(todo)
.leftJoin(todo.user, user)
.leftJoin(todo.comments, comment)
.leftJoin(todo.managers, manager)
.where(titleContains(title)
, nickNameContains(nickName)
, createAtGoe(created_start_at)
, createAtLoe(created_end_at))
.groupBy(todo.id)
.fetchOne();
if (totalCount == null) {
totalCount = 0L;
}
return new PageImpl<>(todoSearchResponseList, pageable, totalCount);
}
}
@Transactional 의 전파
매니저 데이터를 저장할 때 로그를 저장해야하는 조건이 있었다
이 때 기존 트랜잭션에 포함되면 전체가 롤백되므로
새 트랜잭션을 활용해야 하므로 전파 단계를 REQUIRES_NEW 를 사용하여 개선했다
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Long managerId, Boolean status) {
Log log = new Log(managerId, status);
logRepository.save(log);
}
JWT, Spring Security 사용
기존 코드가 ArgumentResolver와 Filter를 사용해서
JWT 를 꺼내어 기존 컨트롤러에 SpringContextHolder 를
받는 것 처럼 처리한 부분을 Spring Security 로 교체하였다
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.disable())
.sessionManagement(sessionManagementConfigurer -> sessionManagementConfigurer.disable())
.formLogin(formLoginConfigurer -> formLoginConfigurer.disable())
.httpBasic(httpBasicConfigurer -> httpBasicConfigurer.disable())
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
적용하는 것에는 문제가 딱히 없었는데, 테스트 코드가 이러면 컨트롤러 단위 테스트를 할 때 Spring Security 를 고려해야 하는 점이 있어 수정을 덧붙여서 했다
@WebMvcTest(TodoController.class)
class TodoControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private TodoService todoService;
@MockBean
private JwtFilter jwtFilter;
@BeforeEach
void setUp() throws Exception {
doAnswer(invocation -> { // t
FilterChain chain = invocation.getArgument(2);
chain.doFilter(invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(jwtFilter).doFilter(any(), any(), any());
}
// jwtFilter.doFilter가 호출시 doAnswer의 코드를 실행
// invocation -> 실제로 호출된 메서드의 정보를 담음
// invocation 의 getArugument로 각 메서드의 인자를 제공
// doFilter(request, response, chain) 으로 호출 되었으니,
// invocation.getArgument(0) = request
// invocation.getArgument(1) = response
// invocation.getArgument(2) = chain
// 필터 체인을 이제 다음 단계로 진행
@Test
@WithMockUser(username = "tester", roles = "ADMIN") // WithMockUser 어노테이션으로 인증 정보를 Mock 처리
void todo_단건_조회에_성공한다() throws Exception {
// given
long todoId = 1L;
String title = "title";
User user = new User("test@test.com", "test1234", "user-test", UserRole.ADMIN);
ReflectionTestUtils.setField(user, "id", 1L);
UserResponse userResponse = new UserResponse(user.getId(), user.getEmail(), user.getNickname());
TodoResponse response = new TodoResponse(
todoId,
title,
"contents",
"Sunny",
userResponse,
LocalDateTime.now(),
LocalDateTime.now()
);
// when
when(todoService.getTodo(todoId)).thenReturn(response);
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(todoId))
.andExpect(jsonPath("$.title").value(title));
}
}
대용량 데이터 조회 개선
테스트 코드로 500만건의 데이터를 밀어 넣는 작업을 수행하기 위해
JdbcTest 어노테이션과 관련 설정을 사용,
마지막엔 Commit 어노테이션으로 롤백되지 않고 데이터가 기록되게 하였다
테스트 파일에 환경 변수를 따로 설정하였으므로,
TestPropertySource 를 사용하였다
@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // H2 대신 실제 DB로
@Commit
@TestPropertySource(properties = {
"spring.datasource.url=${DB_URL}",
"spring.datasource.username=${DB_USER_NAME}",
"spring.datasource.password=${DB_PASSWORD}",
"spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver"
})
public class UserBulkInsertTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void five_million_data() {
String sql = "INSERT INTO users (email, password, nickname, user_role, created_at, modified_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)";
int totalDataCount = 5_000_000;
int batchSize = 100_000;
int batchCount = totalDataCount / batchSize;
for (int index = 0; index < batchCount; index++) {
int batchIndex = index;
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(@NonNull PreparedStatement ps, int i) throws SQLException {
LocalDateTime now = LocalDateTime.now();
int individualIndex = batchIndex * batchSize + i + 1;
ps.setString(1, "user-" + individualIndex + "@test.com");
ps.setString(2, "test1234");
ps.setString(3, "nick-" + individualIndex);
ps.setString(4, "ADMIN");
ps.setObject(5, now);
ps.setObject(6, now);
}
@Override
public int getBatchSize() {
return batchSize;
}
});
}
}
}
수행 후에는 nick-1 … 500000 순으로 된 user row 들이 생성된다
이 후에 nickname 으로 검색 되도록 API 를 만들어 수행해보면
1.34초나 걸리게 된다
explain 을 돌려보니 type 이 ALL 로 테이블을 풀스캔 하고 있어 수행이 느리다
그러므로 nickname 컬럼의 인덱스를 만들어 테이블에 적용해보면
42ms 로 조회 시간이 확실히 줄었고,
type 을 살펴보면 인덱스 조회를 하고 있음을 알 수 있다