개요
사내 업무 성과 평가 프로젝트를 진행하고 있었다. 프로젝트에서는 MariaDB를 사용하고 있었는데, 개발 막바지에 MariaDB를 Oracle로 변경해야 했다. (상부 지시로 결정된 일이라 정확한 이유는 알 수 없었다.
이 프로젝트에서 Mybatis를 사용하고 있었는데, DB를 변경하면서 JPA로 전환하게 되었다.
이제부터 왜 JPA로 전환하게 되었고 전환하는 과정에서 느낀점들을 정리해본다
개발 환경
- Java 1.8
- Spring Boot 2.7.x
- Gradle
- Mybatis -> JPA/Hibernate, Querydsl
- MariaDB -> Oracle
- JUnit5, Mockito
Mybatis vs JPA
먼저 Mybatis와 JPA의 차이점에 대해 알아보자.
아래 특징 중 데이터베이스 독립성 항목이 가장 중요한 부분이었다.
Mybatis | JPA | |
기본 개념 | SQL 프레임워크로, SQL 쿼리 실행 결과를 객체에 매핑 | ORM(Object-Relational Mapping) 기술 표준으로, 객체와 데이터베이스 데이터를 자동으로 매핑 |
생산성 | SQL을 직접 작성 | SQL을 직접 작성하지 않고 CRUD 작업 자동화 |
성능 최적화 | 복잡한 쿼리나 성능 최적화 비교적 쉬움 | 복잡한 쿼리나 성능 최적화 어려움 |
데이터베이스 독립성 | DB 종속적 DB 변경 시 모든 SQL문을 점검하고 수정 필요 |
DB 독립적 DB 변경 시 코드 수정이 거의 필요 없음 |
유지보수 | 요구사항 변경에 따라 관련 SQL 수정 필요 | Entity가 변경되더라도 DB에 자동 반영 |
Why?
DB 변경에 대처하는 방법에는 모든 SQL을 점검 및 수정하는 방법과 JPA로 전환하는 방법이 있었다.
두 방법의 장단점을 정리해봤다.
모든 SQL 점검 및 수정 | JPA로 전환 | |
장점 | - 기존 시스템 구조를 유지할 수 있음 - 성능 최적화된 쿼리 유지 가능 |
- DB 독립성 확보 - 생산성 및 유지보수성 증가 |
단점 | - 시간과 노력이 많이 소요됨 - 휴먼 에러 발생 가능성 높음 - DB 종속성 문제 지속 |
- Service, Persistence Layer의 전반적인 리팩토링 필요 - 복잡한 쿼리의 최적화 어려움 - 테스트 코드 추가 작성 필요 |
두 방법 중 JPA로 전환하는 방법을 선택한 이유는 다음과 같다.
- 요구사항 변경에 대한 유연성
- 현재 프로젝트는 잦은 요구사항 변경이 발생하고 있었다. 요구사항 변경에 따라 Model 클래스와 관련 SQL을 모두 수정해줘야 했다. JPA의 객체 지향적 개발 방식은 이러한 변화에 유연하게 대응할 수 있다. Entity 클래스의 수정만으로 변경 사항을 빠르게 구현할 수 있다.
- DB 종속성 문제 지속
- Mybatis를 계속 사용할 경우, DB 변경마다 모든 SQL을 점검하고 수정하는 작업을 반복적으로 해야 한다. JPA는 이러한 반복 작업을 크게 줄여, 장기적으로 유지보수 비용을 줄이고 시스템의 안정성을 높일 수 있다고 판단했다.
물론 JPA로의 전환은 단기적으로 상당한 노력과 비용이 필요한 과정이다. 그러나 프로젝트의 현재 상황을 고려할 때, 필요한 투자라고 판단했다.
전환 과정에서 얻은 경험
리팩토링 전략
MyBatis에서 JPA로의 전환 과정에서 시스템의 안정성을 유지하면서 점진적인 마이그레이션을 수행하기 위해 테스트 주도 개발(TDD) 방식을 도입했다. JPA 구현을 위한 테스트 케이스를 먼저 작성한 후, 해당 테스트를 통과하는 구현 코드를 작성하는 방식으로 기능별 전환을 단계적으로 진행했다. 테스트 케이스는 기존 Mybatis와 거의 유사하기 때문에 비교적 빠르게 진행할 수 있었다. (그래서 전통적인 TDD라고 부르기는 어렵겠다) 이러한 방식으로 MyBatis와 JPA를 동시에 사용할 수 있는 구조를 설계해서 전환 기간 동안 두 기술을 병행 운영함으로써 시스템 중단 없이 원활한 전환이 가능했다.
구조 개선
시스템의 유연성을 높이고 점진적 마이그레이션을 가능하게 하는 구조 개선이 필요했다. 기존에는 Controller와 Service의 강한 결합도로 인해 변경이 어려웠고, 한 번에 전체 시스템을 전환하기에는 리스크가 컸다. 이러한 문제를 해소하기 위해 interface - abstract class - concrete class 패턴을 사용했다.
interface
DIP를 적용하여 Controller가 Service 인터페이스에 의존하도록 변경했다. Controller가 구체적인 구현체가 아닌 추상화된 인터페이스에 의존함으로써, 새로운 Service 구현체를 추가할 때 Controller의 변경할 필요가 없어졌다.
public interface UserService {
User findById(Long id);
void save(User user);
}
abstract class
공통 로직을 추상 클래스로 옮김으로써 중복 코드를 제거하고 공통 로직의 변경이 필요할 때 한 곳에서만 수정하면 되므로 유지보수가 용이해졌다.
public abstract class AbstractUserService implements UserService {
protected abstract User findUserById(Long id);
@Override
public User findById(Long id) {
User user = findUserById(id);
if (user == null) {
throw new UserNotFoundException(id);
}
return user;
}
}
concrete class
JpaService와 MybatisService가 동일한 인터페이스를 구현함으로써 일관된 API를 제공할 수 있게 되었다. 런타임 시점에 JPA와 MyBatis 구현체를 쉽게 전환할 수 있게 되어, 점진적인 마이그레이션이 가능해졌다.
@Service
@Primary
@RequiredArgsConstructor
public class MyBatisUserService extends AbstractUserService {
private final UserMapper userMapper;
@Override
protected User findUserById(Long id) {
return userMapper.selectById(id);
}
@Override
public void save(User user) {
userMapper.insert(user);
}
}
@Service
@RequiredArgsConstructor
public class JpaUserService extends AbstractUserService {
private final UserRepository userRepository;
@Override
protected User findUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
@Override
public void save(User user) {
userRepository.save(user);
}
}
Querydsl 도입
JPA를 사용하면서 몇 가지 한계가 있었다. 예를 들어, 필요한 필드만을 포함한 DTO로 데이터를 반환받기 어려웠고, 동적 쿼리 생성이 불가능해 상황마다 새로운 메서드를 만들어야 했다. 또한 복잡한 조인이나 서브쿼리를 작성하는 데 제약이 있었다. 이러한 문제를 해결하기 위해 Querydsl을 도입했다. Querydsl은 type-safe하게 JPQL을 생성해주는 프레임워크이다. 코드 기반으로 작동하기 때문에 컴파일 시점에 오류를 체크할 수 있고, JPQL이나 Criteria API보다 직관적이고 간결한 문법을 제공한다. Querydsl을 도입하여 JPA의 한계를 극복하고 쿼리 성능을 향상시킬 수 있었다. Querydsl에는 아래와 같은 장점이 있다.
- 프로젝션을 통해 필요한 필드만 선택하여 DTO로 쉽게 반환받을 수 있다.
- BooleanBuilder를 사용해 동적 쿼리를 효율적으로 생성할 수 있다.
- QueryFactory의 메서드들을 활용해 복잡한 쿼리도 자바 코드로 명확하게 표현할 수 있다.
Slow Query 개선
deleteAllByIdIn 메서드를 사용했을 때, 예상과 달리 단일 IN 절을 사용한 일괄 삭제 쿼리가 실행되지 않았다. 대신, JPA는 각 ID에 대해 개별적으로 SELECT 쿼리를 실행한 후 DELETE 쿼리를 실행하고 있었다. 이 문제를 해결하기 위해 Querydsl을 사용해서 단일 쿼리로 효율적인 벌크 삭제 작업을 수행할 수 있었고, 이를 통해 성능을 크게 개선할 수 있었다.
하지만 Querydsl로 벌크 연산을 수행하게 되면 영속성 컨텍스트를 거치지 않고 직접 데이터베이스에 쿼리를 실행하기 때문에, 영속성 컨텍스트와 데이터베이스의 상태가 불일치하는 문제가 발생했다. 이를 해결하기 위해 벌크 연산 후에 영속성 컨텍스트를 초기화하는 작업을 추가로 수행했다. 이러한 경험을 통해 JPA와 Querydsl의 동작 방식을 좀 더 이해하게 되었다. 관련 내용은 따로 포스트를 작성했다. (링크)
-- 내가 생각한 쿼리
delete from task where task_id in (1, 2)
-- 실제로 실행된 쿼리
select task_id from task where task_id in (1, 2)
delete from task where task_id = 1
delete from task where task_id = 2
영속성 컨텍스트의 이점 적극 활용
Mybatis에서 JPA로 전환하면서 영속성 컨텍스트의 강력한 이점을 활용하여 코드의 효율성을 크게 향상시킬 수 있었다. 1차 캐시로 반복적으로 실행되는 조회 쿼리를 줄일 수 있었고 Mybatis에서는 명시적으로 작성해줘야 했던 update 쿼리를 JPA에서는 엔티티 필드 변경만으로 update할 수 있었다. 영속성 전이(Cascade) 기능을 사용해 연관 엔티티의 라이프 사이클도 쉽게 관리할 수 있었다. 이러한 영속성 컨텍스트의 이점들을 적극 활용하면서 단순히 기술 전환을 넘어 애플리케이션의 전반적인 성능을 개선할 수 있었다.
N + 1
JPA의 N+1 문제를 해결하기 위해 두 가지 전략을 사용했다. 먼저, 연관관계 데이터가 필요 없는 경우에는 지연 로딩(Lazy Loading)을 적용하여 불필요한 쿼리를 방지했다. 연관관계 데이터가 필요한 경우에는 Querydsl로 DTO에 필요한 데이터만을 조회하는 방식으로 진행했다.
마무리
이번 Mybatis에서 JPA로의 마이그레이션 작업을 통해 SQL Mapper와 ORM의 차이점을 실제 경험해보면서 이해할 수 있었다. 개발을 처음 배웠을 때는 단순히 JPA가 최신 기술이고 Mybatis는 구식 기술이라는 이분법적 사고를 가졌었다. 하지만 이번 프로젝트를 통해 두 기술을 모두 다루면서, 각각의 장단점을 명확히 파악할 수 있었다. 이 경험을 통해 기술 선택은 단순히 새로운 것이 항상 좋다는 관점이 아니라, 애플리케이션의 요구사항과 개발 조직의 특성을 종합적으로 고려해야 한다는 중요한 교훈을 얻은 것 같다. 결과적으로, 적절한 기술 선택이 프로젝트의 성공과 팀의 생산성에 얼마나 큰 영향을 미치는지 깨달을 수 있었고, 앞으로의 기술 결정에 있어 더욱 신중하고 균형 잡힌 접근을 할 수 있게 해주는 귀중한 경험이 되었던 것 같다.
'Spring' 카테고리의 다른 글
Spring Boot - JPA 스키마, 데이터 초기화하기 (SQL script -> ddl-auto) (1) | 2024.11.13 |
---|---|
Spring Boot Test - Mockito로 Static Method Mock 만드는 방법 (2) | 2024.11.01 |
Spring Interceptor - 인터셉터로 로그인 체크하기 (0) | 2023.09.12 |
Spring - 회원 팔로우 기능 구현 (1) | 2023.06.08 |
Spring - Spring Boot 초기 데이터 설정 (data.sql) (0) | 2023.05.29 |