XML 파일 300개. MyBatis mapper가 300개라니. 새벽 2시에 이 숫자를 세다가 '이걸 다 JPA로 바꿔야 한다고?'라는 생각에 잠이 확 깼다. 상사는 '3개월이면 되지 않냐'고 했고, 실제로는 8개월이 걸렸다. 이건 그 8개월간의 전쟁 기록이다.

MyBatis와 JPA의 공존이 가능한가?

결론부터 말하면 가능합니다. Spring Boot에서는 MyBatis와 JPA를 동시에 사용할 수 있으며, 같은 데이터베이스에 대해 두 기술을 혼용할 수 있습니다.

dependencies {
    // MyBatis (기존)
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

    // JPA (신규)
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // 동일한 DB 드라이버
    runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc'
}

다만 주의할 점이 있습니다. 같은 테이블에 대해 MyBatis와 JPA를 동시에 사용하면 영속성 컨텍스트와 직접 SQL이 충돌할 수 있습니다. 이에 대해 아래에서 자세히 다루겠습니다.

점진적 마이그레이션 전략

Phase 1: 신규 모듈은 JPA로

가장 안전한 접근법은 신규 테이블과 모듈은 JPA로 개발하고, 기존 모듈은 MyBatis를 유지하는 것입니다.

// 신규 모듈 - JPA Entity
@Entity
@Table(name = "notification")
public class Notification {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(columnDefinition = "NVARCHAR(MAX)")
    private String content;

    @Enumerated(EnumType.STRING)
    private NotificationType type;

    @CreatedDate
    private LocalDateTime createdAt;
}

// 신규 모듈 - JPA Repository
public interface NotificationRepository extends JpaRepository<Notification, Long> {
    List<Notification> findByTypeAndCreatedAtAfter(
        NotificationType type, LocalDateTime after);
}

Phase 2: 읽기 전용 Entity 도입

기존 MyBatis가 관리하는 테이블에 대해 읽기 전용 JPA Entity를 만들 수 있습니다. 이렇게 하면 조회 로직에서 JPA의 편리한 메서드를 활용할 수 있습니다.

@Entity
@Table(name = "employee")
@Immutable  // Hibernate가 UPDATE/INSERT를 시도하지 않음
public class EmployeeReadOnly {
    @Id
    private Long empNo;

    @Column(name = "emp_name")
    private String name;

    @Column(name = "dept_code")
    private String departmentCode;
}

// 읽기 전용 Repository
public interface EmployeeReadOnlyRepository extends JpaRepository<EmployeeReadOnly, Long> {
    List<EmployeeReadOnly> findByDepartmentCode(String deptCode);

    @Query("SELECT e FROM EmployeeReadOnly e WHERE e.name LIKE %:keyword%")
    Page<EmployeeReadOnly> searchByName(@Param("keyword") String keyword, Pageable pageable);
}

Phase 3: 쓰기 작업 마이그레이션

충분한 테스트 후에 점진적으로 쓰기 작업도 JPA로 전환합니다. 이때 핵심은 한 테이블에 대해 MyBatis와 JPA의 쓰기가 동시에 발생하지 않도록 하는 것입니다.

영속성 컨텍스트 충돌 문제

JPA와 MyBatis를 같은 테이블에서 혼용할 때 가장 주의해야 할 부분입니다.

@Service
@Transactional
public class OrderService {

    // JPA로 주문 조회
    public Order getOrder(Long orderId) {
        return orderRepository.findById(orderId).orElseThrow();
    }

    // MyBatis로 주문 상태 업데이트 (위험!)
    public void updateStatus(Long orderId, String status) {
        orderMapper.updateStatus(orderId, status);
        // JPA 영속성 컨텍스트에 캐시된 Order 객체는
        // 여전히 이전 상태를 가지고 있음!
    }
}

이 문제를 해결하는 방법은 다음과 같습니다:

@Service
@Transactional
public class OrderService {
    @PersistenceContext
    private EntityManager entityManager;

    public void updateStatus(Long orderId, String status) {
        orderMapper.updateStatus(orderId, status);

        // 영속성 컨텍스트를 초기화하여
        // 다음 조회 시 DB에서 최신 데이터를 가져오도록 함
        entityManager.clear();
    }
}

MSSQL 특화 설정

기업 환경에서 많이 사용하는 MSSQL과 JPA를 연동할 때 필요한 설정입니다.

spring:
  jpa:
    database-platform: org.hibernate.dialect.SQLServerDialect
    hibernate:
      ddl-auto: validate  # 운영에서는 반드시 validate
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    properties:
      hibernate:
        format_sql: true
        default_schema: dbo
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true

ddl-auto: validate는 매우 중요합니다. Entity와 실제 테이블 구조가 일치하지 않으면 애플리케이션 기동 시 오류를 발생시켜 잠재적인 데이터 이슈를 사전에 방지합니다.

성능 비교와 최적화

N+1 문제 해결

JPA를 처음 도입할 때 가장 많이 겪는 성능 이슈입니다. 연관 엔티티를 조회할 때 불필요한 쿼리가 대량 발생하는 문제입니다.

// N+1 문제 발생
@Entity
public class Department {
    @OneToMany(mappedBy = "department")
    private List<Employee> employees;  // Lazy Loading
}

// 해결: Fetch Join 사용
@Query("SELECT d FROM Department d JOIN FETCH d.employees WHERE d.id = :id")
Optional<Department> findByIdWithEmployees(@Param("id") Long id);

// 또는 EntityGraph 사용
@EntityGraph(attributePaths = {"employees"})
Optional<Department> findById(Long id);

대량 데이터 처리

배치 처리에서 JPA는 MyBatis보다 불리할 수 있습니다. 대량 INSERT의 경우 JPA의 batch 설정을 활용하거나, 해당 부분만 MyBatis 또는 JDBC Template을 사용하는 것이 실용적입니다.

// JPA Batch Insert (hibernate.jdbc.batch_size=50 설정 필요)
@Transactional
public void batchInsert(List<Item> items) {
    for (int i = 0; i < items.size(); i++) {
        entityManager.persist(items.get(i));
        if (i % 50 == 0) {
            entityManager.flush();
            entityManager.clear();
        }
    }
}

마이그레이션 체크리스트

  1. MyBatis와 JPA 의존성이 충돌하지 않는지 확인
  2. 트랜잭션 매니저 설정 검증 (JpaTransactionManager가 MyBatis도 처리 가능)
  3. Entity와 테이블 매핑이 정확한지 validate 모드로 확인
  4. 영속성 컨텍스트 충돌 지점 식별 및 처리
  5. N+1 문제 체크 (Hibernate SQL 로그 활성화)
  6. 대량 데이터 처리 성능 테스트
  7. 기존 MyBatis 단위 테스트가 여전히 통과하는지 확인
실무 경험 공유: 50개가 넘는 MyBatis XML 매퍼를 JPA로 전환하는 작업을 4개월에 걸쳐 진행한 적이 있습니다. 가장 힘들었던 부분은 동적 쿼리 변환이었고, 결국 QueryDSL을 도입해서 해결했습니다. 다만 솔직히 전체 쿼리의 일부는 너무 복잡해서 Native Query로 남겨둘 수밖에 없었습니다. "100% JPA 전환"이라는 이상은 현실에서는 달성하기 어렵더라고요.

마이그레이션 완료 후 1년, 솔직한 회고

마이그레이션을 완료한 지 1년이 지난 지금 돌아보면, 전환 자체는 성공적이었지만 기대와 달랐던 부분도 있습니다. JPA 도입으로 단순 CRUD 개발 속도는 체감상 2배 이상 빨라졌고, 엔티티 중심의 도메인 모델링 덕분에 코드 가독성도 크게 개선되었습니다.

반면, 신규 팀원이 JPA의 영속성 컨텍스트와 지연 로딩 개념을 익히는 데 예상보다 시간이 걸렸고, N+1 문제로 인한 성능 이슈가 초기 3개월간 꾸준히 발생했습니다. 결국 각 기술의 장점을 살리면서 점진적으로 전환하는 것이 가장 안전하고 실용적인 접근법이라는 결론은 변하지 않았습니다. 기존 복잡한 SQL은 MyBatis가, 신규 CRUD는 JPA가 담당하는 하이브리드 구조가 현실적인 해답입니다.

Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글