oguri's garage

Spring Data JPA와 MyBatis 비교 본문

카테고리 없음

Spring Data JPA와 MyBatis 비교

oguri 2025. 11. 6. 23:29


개요

프로젝트 초기에 데이터 접근 기술을 선택할 때마다 고민이 된다. JPA를 쓸지, MyBatis를 쓸지, 아니면 둘 다 쓸지. 간단한 CRUD는 JPA가 편하지만, 복잡한 통계 쿼리는 MyBatis가 낫다는 얘기를 들었다. 직접 사용해보면서 각각의 장단점과 선택 기준을 정리했다.






핵심 차이

JPA는 ORM(Object-Relational Mapping), MyBatis는 SQL Mapper다.

  • JPA: 객체와 테이블을 자동으로 매핑, SQL 자동 생성
  • MyBatis: SQL을 직접 작성, 결과를 객체에 매핑
JPA: 객체 → SQL 자동 생성 → DB
MyBatis: SQL 직접 작성 → 결과를 객체로 매핑

 

 

 

 




Spring Data JPA

Entity와 Repository

// Entity 정의
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(unique = true)
    private String email;

    private Integer age;

    @Builder
    public User(String name, String email, Integer age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    public void updateProfile(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
}

// Repository (인터페이스만 정의)
public interface UserRepository extends JpaRepository<User, Long> {

    // 메서드 이름으로 쿼리 자동 생성
    Optional<User> findByEmail(String email);
    // → SELECT * FROM users WHERE email = ?

    List<User> findByNameContaining(String name);
    // → SELECT * FROM users WHERE name LIKE %?%

    // @Query로 JPQL 작성
    @Query("SELECT u FROM User u WHERE u.age BETWEEN :minAge AND :maxAge")
    List<User> findByAgeRange(@Param("minAge") Integer minAge, 
                              @Param("maxAge") Integer maxAge);
}



Service 사용

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;

    // 생성
    @Transactional
    public User create(UserCreateRequest request) {
        User user = User.builder()
                .name(request.getName())
                .email(request.getEmail())
                .age(request.getAge())
                .build();

        return userRepository.save(user);
        // INSERT 쿼리 자동 생성
    }

    // 수정 (변경 감지)
    @Transactional
    public User update(Long id, UserUpdateRequest request) {
        User user = userRepository.findById(id).orElseThrow();

        // 객체만 수정하면 자동으로 UPDATE
        user.updateProfile(request.getName(), request.getAge());

        return user;  // save() 호출 불필요!
    }

    // 조회
    public User findById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }
}


핵심 특징
:

  • SQL을 작성하지 않음
  • 변경 감지(Dirty Checking)로 자동 UPDATE
  • 메서드 이름으로 쿼리 생성



복잡한 쿼리 (Querydsl)

@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<User> searchUsers(UserSearchCondition condition) {
        QUser user = QUser.user;

        return queryFactory
                .selectFrom(user)
                .where(
                    nameContains(condition.getName()),
                    ageGoe(condition.getMinAge()),
                    ageLoe(condition.getMaxAge())
                )
                .orderBy(user.createdAt.desc())
                .fetch();
    }

    private BooleanExpression nameContains(String name) {
        return hasText(name) ? user.name.contains(name) : null;
    }

    private BooleanExpression ageGoe(Integer minAge) {
        return minAge != null ? user.age.goe(minAge) : null;
    }
}

 

 

 

 




MyBatis

DTO와 Mapper

// 단순 DTO
@Getter
@Setter
public class User {
    private Long id;
    private String name;
    private String email;
    private Integer age;
    private LocalDateTime createdAt;
}

// Mapper 인터페이스
@Mapper
public interface UserMapper {
    User findById(Long id);
    List<User> findAll();
    void insert(User user);
    void update(User user);
    void delete(Long id);
    List<User> searchUsers(@Param("condition") UserSearchCondition condition);
}



XML Mapper

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.mapper.UserMapper">

    <resultMap id="userResultMap" type="com.example.domain.User">
        <id property="id" column="id"/>
        <result property="name" column="name"/>
        <result property="email" column="email"/>
        <result property="age" column="age"/>
        <result property="createdAt" column="created_at"/>
    </resultMap>

    <!-- 단건 조회 -->
    <select id="findById" resultMap="userResultMap">
        SELECT id, name, email, age, created_at
        FROM users
        WHERE id = #{id}
    </select>

    <!-- 생성 -->
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO users (name, email, age, created_at)
        VALUES (#{name}, #{email}, #{age}, NOW())
    </insert>

    <!-- 수정 -->
    <update id="update">
        UPDATE users
        SET name = #{name},
            age = #{age},
            updated_at = NOW()
        WHERE id = #{id}
    </update>

    <!-- 동적 SQL -->
    <select id="searchUsers" resultMap="userResultMap">
        SELECT id, name, email, age, created_at
        FROM users
        <where>
            <if test="condition.name != null">
                AND name LIKE CONCAT('%', #{condition.name}, '%')
            </if>
            <if test="condition.minAge != null">
                AND age >= #{condition.minAge}
            </if>
            <if test="condition.maxAge != null">
                AND age &lt;= #{condition.maxAge}
            </if>
        </where>
        ORDER BY created_at DESC
    </select>

</mapper>



Service 사용

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserMapper userMapper;

    // 생성
    @Transactional
    public User create(UserCreateRequest request) {
        User user = new User();
        user.setName(request.getName());
        user.setEmail(request.getEmail());
        user.setAge(request.getAge());

        userMapper.insert(user);
        return user;
    }

    // 수정
    @Transactional
    public User update(Long id, UserUpdateRequest request) {
        User user = userMapper.findById(id);

        user.setName(request.getName());
        user.setAge(request.getAge());

        userMapper.update(user);  // 명시적 호출 필요!
        return user;
    }

    // 조회
    public User findById(Long id) {
        return userMapper.findById(id);
    }
}


핵심 특징
:

  • SQL을 직접 작성
  • 명시적으로 update() 호출
  • XML에서 쿼리 관리



복잡한 쿼리

<!-- 통계 쿼리 -->
<select id="getUserStatistics" resultType="UserStatisticsDto">
    SELECT 
        DATE_FORMAT(created_at, '%Y-%m') AS month,
        COUNT(*) AS totalUsers,
        AVG(age) AS avgAge,
        COUNT(CASE WHEN age >= 20 AND age < 30 THEN 1 END) AS users20s,
        COUNT(CASE WHEN age >= 30 AND age < 40 THEN 1 END) AS users30s
    FROM users
    WHERE created_at >= DATE_SUB(NOW(), INTERVAL 1 YEAR)
    GROUP BY DATE_FORMAT(created_at, '%Y-%m')
    ORDER BY month DESC
</select>

<!-- JOIN 쿼리 -->
<select id="findUsersWithOrders" resultMap="userWithOrdersResultMap">
    SELECT 
        u.id, u.name, u.email,
        o.id AS order_id, o.order_number
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    WHERE u.id = #{userId}
</select>

<resultMap id="userWithOrdersResultMap" type="UserWithOrdersDto">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <collection property="orders" ofType="OrderDto">
        <id property="id" column="order_id"/>
        <result property="orderNumber" column="order_number"/>
    </collection>
</resultMap>

 

 

 

 




주요 차이점

CRUD 비교

작업 JPA MyBatis
조회 findById() 자동 SQL 직접 작성
생성 save() 자동 INSERT XML에 INSERT 작성
수정 변경 감지 자동 update() 명시 호출
삭제 delete() 자동 SQL 직접 작성



복잡한 쿼리

JPA:

// Querydsl 필요, 복잡한 통계는 Native Query로 우회
@Query(value = "SELECT DATE_FORMAT(created_at, '%Y-%m') as month, " +
               "COUNT(*) as count FROM users GROUP BY month",
       nativeQuery = true)
List<Object[]> getMonthlyStatistics();


MyBatis
:

<!-- SQL을 자유롭게 작성 -->
<select id="getMonthlyStatistics">
    SELECT 
        DATE_FORMAT(created_at, '%Y-%m') AS month,
        COUNT(*) AS count,
        AVG(age) AS avgAge
    FROM users
    GROUP BY month
</select>



N+1 문제

JPA:

// ❌ N+1 문제 발생
@OneToMany(mappedBy = "user")
private List<Order> orders;

List<User> users = userRepository.findAll();  // 1번
for (User user : users) {
    user.getOrders().size();  // N번 (각 user마다 쿼리)
}

// ✅ Fetch Join으로 해결
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();


MyBatis
:

<!-- JOIN으로 한 번에 조회, N+1 문제 없음 -->
<select id="findUsersWithOrders">
    SELECT u.*, o.*
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
</select>

 

 

 

 




장단점

JPA

장점:

  • 높은 생산성: SQL 자동 생성, 보일러플레이트 최소화
  • 객체 지향적: 엔티티에 비즈니스 로직, 명확한 객체 관계
  • DB 독립적: MySQL → PostgreSQL 변경 용이
  • 변경 감지: 객체 수정만으로 자동 UPDATE

단점:

  • 학습 곡선 높음: 영속성 컨텍스트, 지연 로딩 등 복잡한 개념
  • 복잡한 쿼리 어려움: 통계, 집계 쿼리는 Querydsl이나 Native Query 필요
  • N+1 문제: Fetch Join, BatchSize 등으로 해결 필요
  • 성능 튜닝 어려움: 자동 생성 SQL 제어가 어려움



MyBatis

장점:

  • 완전한 SQL 제어: 복잡한 쿼리 자유롭게 작성
  • 낮은 학습 곡선: SQL만 알면 바로 사용 가능
  • 쉬운 성능 튜닝: 쿼리를 직접 최적화
  • 명확한 동작: 실행되는 SQL이 그대로 보임

단점:

  • 낮은 생산성: 모든 SQL을 직접 작성
  • 객체 지향적이지 않음: 단순 DTO, 비즈니스 로직 분산
  • DB 종속적: DB 변경 시 SQL 전체 수정
  • 유지보수 어려움: 테이블 변경 시 XML 모두 수정

 

 

 




선택 기준

JPA를 선택할 때

  • 간단한 CRUD 중심: 게시판, 회원 관리, 블로그
  • 도메인 주도 설계: 복잡한 도메인 로직, 엔티티 중심 개발
  • 빠른 개발: 스타트업, MVP, 프로토타입
  • DB 독립성: 멀티 테넌시, DB 마이그레이션 가능성



MyBatis를 선택할 때

  • 복잡한 쿼리: 통계, 리포트, 대시보드, BI 시스템
  • 성능 최적화: 대용량 데이터, 쿼리 튜닝 필수
  • 레거시 DB 통합: 기존 스키마 변경 불가, 복잡한 테이블 구조
  • SQL 제어 필수: DB 특화 기능, 스토어드 프로시저



혼합 사용 (추천)

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;  // JPA
    private final UserMapper userMapper;          // MyBatis

    // 간단한 CRUD → JPA
    @Transactional
    public User create(UserCreateRequest request) {
        User user = User.builder()
                .name(request.getName())
                .email(request.getEmail())
                .build();
        return userRepository.save(user);
    }

    // 복잡한 통계 → MyBatis
    @Transactional(readOnly = true)
    public List<UserStatistics> getStatistics() {
        return userMapper.getUserStatistics();
    }

    // 간단한 조회 → JPA
    public User findById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }

    // 복잡한 조회 (JOIN) → MyBatis
    public List<UserWithOrdersDto> findUsersWithOrders() {
        return userMapper.findUsersWithOrders();
    }
}

 

 

 

 


정리

핵심 차이:

  • JPA: ORM, SQL 자동 생성, 객체 중심, 높은 생산성
  • MyBatis: SQL Mapper, SQL 직접 작성, 완전한 제어

선택 기준:

  • 간단한 CRUD, 빠른 개발 → JPA
  • 복잡한 쿼리, 성능 최적화 → MyBatis
  • 실무에서는 혼합 사용 추천 (CRUD는 JPA, 통계는 MyBatis)

 

 

 




참고 자료