oguri's garage

MyBatis에서 JPA로 점진적 마이그레이션하기 본문

개발하다/Spring

MyBatis에서 JPA로 점진적 마이그레이션하기

oguri 2025. 10. 12. 21:01

이 글은 기존 MyBatis 코드를 유지하면서 점진적으로 JPA를 도입하는 방법을 다뤄보려고 한다.

MyBatis vs JPA 비교

특징 MyBatis JPA
SQL 작성 XML/Annotation에 직접 작성 JPQL 또는 자동 생성
객체-관계 매핑 수동 매핑 (ResultMap) 자동 매핑 (Entity)
복잡한 쿼리 유리 (SQL 직접 제어) QueryDSL, JPQL 활용
러닝 커브 낮음 (SQL 지식만 필요) 중간~높음 (ORM 개념 필요)
유지보수성 SQL 변경 시 XML 수정 필요 Entity 변경으로 자동 반영

MyBatis는 SQL을 직접 제어할 수 있어 강력하지만, 반복적인 CRUD 코드와 XML 관리가 번거롭다.
JPA는 객체 중심 개발을 가능하게 하고, 단순 쿼리는 자동으로 생성한다.


마이그레이션 전략

본 가이드는 공존 전략을 사용한다:

  • 기존 MyBatis 코드는 그대로 유지
  • 신규 기능은 JPA로 개발
  • 필요시 기존 기능을 점진적으로 JPA로 전환

 

 


Step 1: 환경 설정

의존성 추가

pom.xml에 JPA와 MyBatis를 함께 추가한다.

<!-- Spring Data JPA -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- MyBatis (기존 코드와 공존) -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>

<!-- Database Driver -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.33</version>
</dependency>


JPA 설정

application.yml에 JPA 설정을 추가한다.

spring:
  jpa:
    hibernate:
      ddl-auto: none  # 운영환경에서는 반드시 none 또는 validate
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
    open-in-view: false

 

주의: ddl-autocreateupdate로 설정하면 테이블 구조가 변경될 수 있다.


패키지 스캔 설정

JPA Repository와 MyBatis Mapper가 함께 동작하도록 설정한다.

@SpringBootApplication
@EnableJpaRepositories(basePackages = "com.example.demo.repository")
@MapperScan("com.example.demo.dao")
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

 

 


Step 2: 데이터베이스 스키마 분석

마이그레이션할 테이블의 구조를 파악한다.


체크리스트:

  • Primary Key 확인 (단일 키 vs 복합 키)
  • Foreign Key 관계 파악
  • 인덱스 확인
  • 컬럼 타입 및 제약 조건 확인

예시 테이블:

-- Product 테이블 (단일 PK)
CREATE TABLE Product (
    productId BIGINT PRIMARY KEY AUTO_INCREMENT,
    productName VARCHAR(200) NOT NULL,
    price DECIMAL(10,2),
    categoryId BIGINT,
    createdAt DATETIME,
    updatedAt DATETIME
);

-- OrderItem 테이블 (복합 PK)
CREATE TABLE OrderItem (
    orderId BIGINT,
    productId BIGINT,
    quantity INT,
    PRIMARY KEY (orderId, productId),
    FOREIGN KEY (productId) REFERENCES Product(productId)
);

 

 


Step 3: JPA Entity 클래스 생성

단일 Primary Key

package com.example.demo.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "Product")
@Getter
@Setter
public class ProductEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "productId")
    private Long productId;

    @Column(name = "productName", length = 200, nullable = false)
    private String productName;

    @Column(name = "price", precision = 10, scale = 2)
    private BigDecimal price;

    @Column(name = "categoryId")
    private Long categoryId;

    @Column(name = "createdAt")
    private LocalDateTime createdAt;

    @Column(name = "updatedAt")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}


복합 Primary Key

복합키는 @IdClass 방식으로 처리한다.

package com.example.demo.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.Objects;

@Entity
@Table(name = "OrderItem")
@IdClass(OrderItemEntity.OrderItemId.class)
@Getter
@Setter
public class OrderItemEntity {

    @Id
    @Column(name = "orderId")
    private Long orderId;

    @Id
    @Column(name = "productId")
    private Long productId;

    @Column(name = "quantity")
    private Integer quantity;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "productId", insertable = false, updatable = false)
    private ProductEntity product;

    @Getter
    @Setter
    public static class OrderItemId implements Serializable {
        private Long orderId;
        private Long productId;

        public OrderItemId() {}

        public OrderItemId(Long orderId, Long productId) {
            this.orderId = orderId;
            this.productId = productId;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            OrderItemId that = (OrderItemId) o;
            return Objects.equals(orderId, that.orderId) &&
                   Objects.equals(productId, that.productId);
        }

        @Override
        public int hashCode() {
            return Objects.hash(orderId, productId);
        }
    }
}


Entity 어노테이션

어노테이션 용도 예시
@Entity JPA Entity 클래스 선언 필수
@Table(name = "...") 테이블 이름 매핑 @Table(name = "Product")
@Id Primary Key 지정 필수
@GeneratedValue 자동 증가 설정 strategy = GenerationType.IDENTITY
@Column(name = "...") 컬럼 이름 매핑 @Column(name = "productId")
@IdClass 복합키 클래스 지정 복합키 테이블용
@ManyToOne N:1 관계 매핑 Foreign Key 관계
@OneToMany 1:N 관계 매핑 역방향 관계

 

 


Step 4: JPA Repository 인터페이스 생성

package com.example.demo.repository;

import com.example.demo.entity.OrderItemEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository
public interface OrderItemRepository extends JpaRepository<
    OrderItemEntity,
    OrderItemEntity.OrderItemId> {

    // 메서드 이름 기반 쿼리 (자동 구현)
    List<OrderItemEntity> findByOrderId(Long orderId);

    // JPQL (JOIN FETCH로 N+1 문제 해결)
    @Query("SELECT oi FROM OrderItemEntity oi " +
           "JOIN FETCH oi.product " +
           "WHERE oi.orderId = :orderId")
    List<OrderItemEntity> findByOrderIdWithProduct(@Param("orderId") Long orderId);

    // 네이티브 SQL (특별한 경우만)
    @Query(value = "SELECT * FROM OrderItem WHERE orderId = ?1",
           nativeQuery = true)
    List<OrderItemEntity> findByOrderIdNative(Long orderId);
}


Repository 메서드 네이밍 규칙

Spring Data JPA는 메서드 이름만으로 쿼리를 자동 생성한다.

키워드 예시 설명
findBy findByProductId(Long id) 조회
countBy countByOrderId(Long id) 개수
deleteBy deleteByProductId(Long id) 삭제
existsBy existsByProductId(Long id) 존재 여부
And findByNameAndPrice(String name, BigDecimal price) AND 조건
Or findByNameOrCategory(String name, Long categoryId) OR 조건
In findByProductIdIn(List<Long> ids) IN 절
Like findByNameLike(String pattern) LIKE 검색
OrderBy findByNameOrderByCreatedAtDesc(String name) 정렬


참고:
Spring Data JPA Query Methods 공식 문서

 

 


Step 5: Service 레이어에서 공존

MyBatis와 JPA를 한 Service에서 함께 사용한다.

package com.example.demo.service;

import com.example.demo.dao.ProductDao;
import com.example.demo.entity.ProductEntity;
import com.example.demo.entity.OrderItemEntity;
import com.example.demo.model.ProductModel;
import com.example.demo.repository.OrderItemRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class ProductService {

    private final ProductDao productDao;
    private final OrderItemRepository orderItemRepository;

    public ProductService(
        ProductDao productDao,
        OrderItemRepository orderItemRepository
    ) {
        this.productDao = productDao;
        this.orderItemRepository = orderItemRepository;
    }

    // 기존 MyBatis 메서드
    @Transactional(readOnly = true)
    public ProductModel getProduct(Long productId) {
        return productDao.getProduct(productId);
    }

    @Transactional(readOnly = true)
    public List<ProductModel> getProductList(ProductRequest request) {
        return productDao.getProductList(request);
    }

    // 신규 JPA 메서드
    @Transactional(readOnly = true)
    public List<ProductModel> getProductsByOrderId(Long orderId) {
        List<OrderItemEntity> orderItems =
            orderItemRepository.findByOrderIdWithProduct(orderId);

        return orderItems.stream()
            .map(oi -> convertToModel(oi.getProduct()))
            .collect(Collectors.toList());
    }

    private ProductModel convertToModel(ProductEntity entity) {
        ProductModel model = new ProductModel();
        model.setProductId(entity.getProductId());
        model.setProductName(entity.getProductName());
        model.setPrice(entity.getPrice());
        model.setCategoryId(entity.getCategoryId());
        model.setCreatedAt(entity.getCreatedAt());
        model.setUpdatedAt(entity.getUpdatedAt());
        return model;
    }
}


트랜잭션 어노테이션

@Transactional(readOnly = true)   // 읽기 전용 (성능 최적화)
@Transactional                    // 쓰기 가능 (CUD)
@Transactional(isolation = Isolation.READ_COMMITTED)  // 격리 수준
@Transactional(propagation = Propagation.REQUIRES_NEW)  // 새 트랜잭션

 

 


Step 6: Controller

Service의 인터페이스가 동일하므로 Controller는 수정하지 않는다.

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{productId}")
    public ProductModel getProduct(@PathVariable Long productId) {
        return productService.getProduct(productId);
    }

    @GetMapping("/order/{orderId}")
    public List<ProductModel> getProductsByOrder(@PathVariable Long orderId) {
        return productService.getProductsByOrderId(orderId);
    }
}

 

 


실전 마이그레이션 예제

예제 1: 단순 조회

Before (MyBatis):

<!-- product.xml -->
<select id="getProduct" parameterType="Long" resultMap="productMap">
    SELECT * FROM Product WHERE productId = #{productId}
</select>
public ProductModel getProduct(Long productId) {
    return sqlSession.selectOne("product.getProduct", productId);
}

 

After (JPA):

@Repository
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
    Optional<ProductEntity> findByProductId(Long productId);
}

public ProductModel getProduct(Long productId) {
    return productRepository.findByProductId(productId)
        .map(this::convertToModel)
        .orElse(null);
}


예제 2: 조건부 조회

Before (MyBatis):

<select id="searchProducts" resultMap="productMap">
    SELECT * FROM Product
    <where>
        <if test="categoryId != null">AND categoryId = #{categoryId}</if>
        <if test="productName != null">
            AND productName LIKE CONCAT('%', #{productName}, '%')
        </if>
        <if test="minPrice != null">AND price >= #{minPrice}</if>
    </where>
    ORDER BY createdAt DESC
    LIMIT #{offset}, #{limit}
</select>


After (JPA with Specification):

public class ProductSpecification {

    public static Specification<ProductEntity> hasCategory(Long categoryId) {
        return (root, query, cb) ->
            categoryId == null ? null : cb.equal(root.get("categoryId"), categoryId);
    }

    public static Specification<ProductEntity> nameLike(String productName) {
        return (root, query, cb) ->
            productName == null ? null : cb.like(root.get("productName"), "%" + productName + "%");
    }

    public static Specification<ProductEntity> priceGreaterThan(BigDecimal minPrice) {
        return (root, query, cb) ->
            minPrice == null ? null : cb.greaterThanOrEqualTo(root.get("price"), minPrice);
    }
}

public Page<ProductModel> searchProducts(ProductSearchRequest request) {
    Specification<ProductEntity> spec = Specification
        .where(ProductSpecification.hasCategory(request.getCategoryId()))
        .and(ProductSpecification.nameLike(request.getProductName()))
        .and(ProductSpecification.priceGreaterThan(request.getMinPrice()));

    Pageable pageable = PageRequest.of(
        request.getOffset() / request.getLimit(),
        request.getLimit(),
        Sort.by(Sort.Direction.DESC, "createdAt")
    );

    return productRepository.findAll(spec, pageable)
        .map(this::convertToModel);
}


예제 3: JOIN 쿼리

Before (MyBatis):

<select id="getProductWithOrders" resultMap="productMap">
    SELECT p.*, oi.orderId, oi.quantity
    FROM Product p
    LEFT JOIN OrderItem oi ON p.productId = oi.productId
    WHERE p.productId = #{productId}
</select>


After (JPA):

@Entity
@Table(name = "Product")
public class ProductEntity {
    @Id
    private Long productId;

    @OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
    private List<OrderItemEntity> orderItems = new ArrayList<>();
}

@Query("SELECT p FROM ProductEntity p " +
       "LEFT JOIN FETCH p.orderItems " +
       "WHERE p.productId = :productId")
Optional<ProductEntity> findByIdWithOrders(@Param("productId") Long productId);

 

 


주의사항과 해결책

N+1 문제

N+1 문제는 JPA에서 가장 흔한 성능 이슈다.


문제:

List<OrderItemEntity> items = repository.findByOrderId(orderId);
for (OrderItemEntity item : items) {
    // 각 반복마다 추가 쿼리 발생
    String name = item.getProduct().getProductName();
}


해결책 1: FETCH JOIN

@Query("SELECT oi FROM OrderItemEntity oi " +
       "JOIN FETCH oi.product " +
       "WHERE oi.orderId = :orderId")
List<OrderItemEntity> findByOrderIdWithProduct(@Param("orderId") Long orderId);


해결책 2: EntityGraph

@EntityGraph(attributePaths = {"product"})
List<OrderItemEntity> findByOrderId(Long orderId);


해결책 3: Batch Size

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100


Lazy Loading 설정

기본 전략:

  • @ManyToOne: EAGER (즉시 로딩)
  • @OneToMany: LAZY (지연 로딩)

권장:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "productId")
private ProductEntity product;


LazyInitializationException

증상:

org.hibernate.LazyInitializationException: could not initialize proxy - no Session


해결:

// 1. FETCH JOIN
@Query("SELECT p FROM ProductEntity p JOIN FETCH p.orderItems")

// 2. @Transactional 범위 확장
@Transactional(readOnly = true)
public void processData() {
    // 트랜잭션 내에서 모든 데이터 접근
}


성능 최적화

읽기 전용 트랜잭션:

@Transactional(readOnly = true)
public List<ProductModel> getAllProducts() {
    return productRepository.findAll().stream()
        .map(this::convertToModel)
        .collect(Collectors.toList());
}


Projection (필요한 필드만 조회):

public interface ProductIdAndName {
    Long getProductId();
    String getProductName();
}

@Query("SELECT p.productId as productId, p.productName as productName " +
       "FROM ProductEntity p")
List<ProductIdAndName> findAllIdAndName();


Bulk 연산:

@Modifying
@Query("UPDATE ProductEntity p SET p.price = :price " +
       "WHERE p.productId IN :ids")
int bulkUpdatePrice(@Param("price") BigDecimal price,
                    @Param("ids") List<Long> ids);

 

 


디버깅과 로깅

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE

 

 


마이그레이션 체크리스트

  • pom.xml에 JPA 의존성 추가
  • application.yml에 JPA 설정 추가
  • Entity 클래스 작성
  • Repository 인터페이스 생성
  • Service에 Repository 주입
  • Entity ↔ Model 변환 로직 구현
  • 트랜잭션 어노테이션 추가
  • N+1 문제 확인 및 해결
  • 테스트 코드 작성
  • 성능 테스트

 


마이그레이션 순서

  1. 읽기 전용 API부터 시작
  2. 단순한 테이블부터 진행
  3. 복잡한 JOIN은 후순위
  4. CUD 작업은 충분한 테스트 후 적용

 


참고 자료