| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- constructor
- 헥사고날 아키텍처
- builder-pattern
- 회고
- 3계층 아키텍처
- object-creation
- UserLand
- 레벨1
- OpenSearch
- DevOps
- QueryDSL
- Level2
- 코엑스그랜드볼룸
- design-pattern
- 가용영역
- HashMap
- SpringBoot
- 클린 아키텍처
- 코딩테스트
- 글또
- static-factory-method
- 포트앤어댑터 아키텍처
- 다짐글
- ReverseNested
- React
- 프로그래머스
- axios
- 글또10기
- 클라우드아키텍처
- 글쓰기세미나
- Today
- Total
oguri's garage
MyBatis에서 JPA로 점진적 마이그레이션하기 본문
이 글은 기존 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-auto를 create나 update로 설정하면 테이블 구조가 변경될 수 있다.
패키지 스캔 설정
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 문제 확인 및 해결
- 테스트 코드 작성
- 성능 테스트
마이그레이션 순서
- 읽기 전용 API부터 시작
- 단순한 테이블부터 진행
- 복잡한 JOIN은 후순위
- CUD 작업은 충분한 테스트 후 적용
참고 자료
'개발하다 > Spring' 카테고리의 다른 글
| Spring에서 실행 시간 측정하기 (0) | 2025.10.17 |
|---|---|
| @ControllerAdvice와 @ExceptionHandler로 전역 예외 처리하기 (0) | 2025.10.16 |
| (Spring) @Transactional을 Service 계층에 적용해야 하는 이유와 올바른 사용법 (0) | 2025.10.10 |
| (Spring) Jackson 역직렬화 동작 방식과 안전한 코딩 패턴 (0) | 2025.10.04 |
| Spring vs Spring Boot 핵심 차이점 (0) | 2025.10.03 |