Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
Tags
- design-pattern
- 회고
- 포트앤어댑터 아키텍처
- OpenSearch
- HashMap
- constructor
- 클린 아키텍처
- 클라우드아키텍처
- ReverseNested
- 글쓰기세미나
- 다짐글
- 코딩테스트
- 헥사고날 아키텍처
- 프로그래머스
- Level2
- builder-pattern
- 레벨1
- UserLand
- 글또10기
- 글또
- DevOps
- QueryDSL
- static-factory-method
- React
- 코엑스그랜드볼룸
- 가용영역
- 3계층 아키텍처
- SpringBoot
- axios
- object-creation
Archives
- Today
- Total
oguri's garage
Spring Data JPA와 MyBatis 비교 본문
개요
프로젝트 초기에 데이터 접근 기술을 선택할 때마다 고민이 된다. 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 <= #{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)