스프링 작업시 데이터베이스의 데이터를 다루기 위해서 MyBatis와 JPA를 주로 사용합니다.
우선, 알아두어야 할 선행지식이 SQL Mapper와 ORM(Object Relation Mapping)이죠.
SQL Mapper와 ORM
SQL Mapper
개발자가 직접 SQL을 작성하고 그 결과를 매핑 규칙에 적용 - MyBatis
ORM(Object Relation Mapping)
오브젝트와 테이블간의 관계(Object Relation)를 추상화하여 매핑하여 자동으로 관리 - JPA
프로그래밍에서의 "추상화(Abstraction)"란 복잡한 시스템이나 문제를 다루기 쉽게 만들기 위해, 핵심적인 부분만 남기고 불필요한 세부 사항은 숨기는 과정을 의미함
JPA와 MyBatis
MyBatis와 JPA 사용법은 인터넷에 널리고 널려서 굳이 여기서 설명할 필요는 없을 것 같습니다.
제가 이야기하고 싶은 부분은 실무에서 사용할 때 약간의 노하우에 대해서 설명하려고 하는 것입니다.
각 사용방법은 장점에 대해서 살짝 설명하면 다음과 같습니다.
JPA의 장점
- 도메인 중심 개발과 오브젝트 매핑이 편리
- CRUD, 페이징, 기본 쿼리는 거의 자동 생성
- 캐시, 지연 로딩, 영속성 컨텍스트 관리 기능
MyBatis의 장점
- 복잡한 SQL, 성능 최적화가 필요한 쿼리를 직접 컨트롤 가능
- DB Vendor-specific 기능 활용 용이
- SQL 가독성 및 튜닝 용이

도메인 중심 개발(Domain-Driven Design, DDD)이라는 생소한 개념이 등장했습니다.
설명하려면 매우 깁니다. 저도 정확히 100% 이해하고 있다고 자부할 수 없습니다.
실무 프로젝트에서 DDD를 지켜가면서 코딩하는 개발자를 본 적이 없습니다. DB 중심, 서비스 중심 설계에 JPA를 ORM 도구 정도로만 쓰는 케이스가 대부분이라고 보면 됩니다. DDD는 이렇게 어려운 개념입니다.
JPA는 오브젝트 모델 중심인데, 현실은 DB 모델이 먼저 나오고 엔티티는 그걸 맞추는 구조가 되어버리는 것이 현실입니다. 82 82 문화는 개발 프로젝트에서도 예외는 아니니까요.
DB Vendor-specific 기능이 무엇일까요? 데이터베이스별로 각자 기능에 차이점이 조금씩 있습니다.
다음 예를 보면 알 수 있습니다.
-- Oracle
SELECT /*+ ORDERED USE_NL(e d) */
e.empno, d.dname
FROM emp e, dept d
WHERE e.deptno = d.deptno;
- ORDERED : FROM 절 순서대로 조인 강제
- USE_NL(e d) : e와 d를 Nested Loop Join으로 수행
-- MySQL
SELECT STRAIGHT_JOIN e.empno, d.dname
FROM emp e
STRAIGHT_JOIN dept d ON e.deptno = d.deptno;
- STRAIGHT_JOIN : 옵티마이저가 선택하는 조인 순서를 무시하고, FROM 절 순서대로 강제
- MySQL은 조인 방법(예: Nested Loop vs Hash Join)을 직접 지정하는 힌트가 없음
테이블 2개를 조인하는 방법에 대한 예시인데 오라클과 MySQL이 서로 다른 쿼리문을 보여주고 있습니다. 이러한 부분이 바로 DB Vendor-specific입니다. 쿼리문뿐만 아니라 데이터타입 형식도 다를 수 있고 함수도 서로 다를 수 있습니다.
| 기능 | 오라클 힌트 예시 | MySQL 힌트 예시 | 차이 |
| 조인 순서 강제 | /*+ ORDERED */ | STRAIGHT_JOIN | 동일 목적, 표현 다름 |
| 조인 방식(Nested Loop 등) | /*+ USE_NL, USE_HASH, USE_MERGE */ | 없음(옵티마이저 자동 선택) | 오라클만 지원 |
| 인덱스 강제 사용 | /*+ INDEX(e emp_idx) */ | USE INDEX (emp_idx) | 문법 차이 |
| 특정 실행 계획 방지 | /*+ NO_MERGE, NO_UNNEST */ | 없음 | 오라클만 지원 |
| 병렬 처리 | /*+ PARALLEL(e 4) */ | 없음 | 오라클만 지원 |
| 쿼리 블록 이름별 힌트 적용 | /*+ QB_NAME(main) */ | 없음 | 오라클만 지원 |
| DBMS | 예시 | 설명 |
| Oracle | SYSDATE, ADD_MONTHS(SYSDATE, 1) | SYSDATE는 현재 날짜, ADD_MONTHS는 월 더하기 |
| MySQL | NOW(), DATE_ADD(NOW(), INTERVAL 1 MONTH) | MySQL 전용 날짜 더하기 |
| PostgreSQL | CURRENT_DATE + INTERVAL '1 month' | 표준에 가깝지만, INTERVAL 구문 PostgreSQL 스타일 |
이러한 차이점을 신경쓰지 않고 작성할 수 있도록 JPA는 기능을 제공하고 있습니다. 그런데, JPA로만 작성하다보면 힌트를 적용할 수 없습니다. 힌트 한 줄로 쿼리 결과가 10초 걸리던 것이 0.1초로 단축할 수도 있기 때문에 매우 중요합니다. 그럴 땐 MyBatis를 사용할 수 밖에 없겠죠.
영속성 컨텍스트
그럼에도 JPA를 사용하는 주된 이유는 바로 영속성 컨텍스트(Persistence Context)에 있습니다.
영속성 컨텍스트는 JPA가 엔티티를 “1차 캐시”에 저장하고 관리하는 메모리 공간입니다. 쉽게 말하면, JPA가 데이터베이스와 엔티티 객체 사이에서 동기화를 관리하는 작업장이죠.
만약 JPA가 없고, 매번 DB에서 데이터를 읽고 수정한다면:
- 동일한 데이터를 여러 번 조회 → DB 쿼리가 계속 나감
- 수정 시 개발자가 직접 UPDATE SQL 작성
- 트랜잭션 중 변경 내용을 임시로 기억할 공간 없음
JPA는 이를 해결하기 위해 영속성 컨텍스트를 두고, 엔티티를 1차 캐시에 저장해 둡니다.
트랜잭션 범위 안에서:
- 조회
- 처음 조회 시 → DB에서 가져와 영속성 컨텍스트에 저장
- 두 번째 조회 시 → DB 안 가고 영속성 컨텍스트에서 바로 반환 (1차 캐시)
- 수정
- 엔티티 필드 값을 변경만 해도, 트랜잭션 종료 시 JPA가 변경된 부분만 감지(dirty checking)하여 UPDATE SQL 실행
- 쓰기 지연
- persist()나 변경 발생 시 바로 DB에 쓰지 않고, 모아두었다가 트랜잭션 커밋 시 한 번에 SQL 실행
영속성 컨텍스트의 특징과 설명
| 특징 | 설명 |
| 1차 캐시 | 같은 트랜잭션에서 같은 엔티티는 메모리에 1개만 존재 |
| 동일성 보장 | == 비교 시 같은 엔티티 인스턴스 반환 |
| 변경 감지(Dirty Checking) | 필드 값이 바뀌면 자동으로 UPDATE 반영 |
| 지연 쓰기 | SQL을 모아뒀다가 커밋 시 실행 |
JPA와 MyBatis의 동작을 비교해보면 다음과 같습니다.
| 구분 | JPA | MyBatis |
| 데이터 저장 위치 | 영속성 컨텍스트(메모리 1차 캐시) → 커밋 시 DB 반영 | DB 즉시 실행 |
| 변경 감지(Dirty Checking) | 지원 (필드 변경만 해도 UPDATE 자동) | 없음 (UPDATE SQL 직접 작성) |
| 쿼리 실행 시점 | 쓰기 지연 가능 (flush 시 실행) | 메서드 호출 시 바로 실행 |
| 동일성 보장 | 같은 트랜잭션 내 동일 엔티티 1개 인스턴스 유지 | 항상 새 객체 생성 |
| 트랜잭션 내 상태 관리 | JPA가 자동 관리 | 개발자가 직접 관리 |
그런데, 문제는 JPA와 MyBatis를 혼용해서 사용했을 때 발생합니다.
다음 예제를 보세요.
@Service
@RequiredArgsConstructor
public class MemberService {
private final EntityManager em;
private final MemberMapper memberMapper;
@Transactional
public void testMixedAccess() {
// 1. JPA로 조회 → 영속성 컨텍스트에 저장
Member jpaMember = em.find(Member.class, 1L);
System.out.println("JPA 조회 (처음): " + jpaMember.getName());
// 2. MyBatis로 DB 직접 수정
memberMapper.updateName(1L, "마이바티스수정");
System.out.println("MyBatis로 DB 수정 완료");
// 3. 다시 JPA로 조회 → DB 값이 변경됐어도 1차 캐시에서 가져옴
Member jpaMember2 = em.find(Member.class, 1L);
System.out.println("JPA 조회 (다시): " + jpaMember2.getName());
}
}
@Mapper
public interface MemberMapper {
@Update("UPDATE member SET name = #{name} WHERE id = #{id}")
void updateName(@Param("id") Long id, @Param("name") String name);
}
실행 결과는 다음과 같습니다.
JPA 조회 (처음): 홍길동
MyBatis로 DB 수정 완료
JPA 조회 (다시): 홍길동 ← DB에는 '마이바티스수정'이 저장돼 있지만, JPA는 여전히 예전 값 사용
- JPA는 트랜잭션 내에서 **1차 캐시(영속성 컨텍스트)**를 우선 사용
- MyBatis는 DB를 직접 변경
- JPA는 DB에 다시 쿼리를 날리지 않고, 기존 영속 엔티티를 그대로 반환
- → 결과적으로 DB 상태와 메모리 상태가 달라짐
해결방법은
- MyBatis 실행 후, 해당 엔티티를 강제로 DB에서 다시 읽기 --- em.refresh(jpaMember)
- 영속성 컨텍스트 초기화하기 --- em.clear()
- JPA와 MyBatis 같은 엔티티 동시에 건드리지 않기
동일 엔티티는 JPA로만 관리하고 MyBatis는 빠른 select 속도가 필요한 로직에서만 사용하는 것이 가장 적절하게 보입니다. 위에서 이야기했지만 힌트를 사용해서 적은 부하로 빠른 결과물을 가져와야 하는 비즈니스 로직에서는 단연코 MyBatis가 필요하기 때문입니다.
'꼰대개발자 > 프로그래밍 언어' 카테고리의 다른 글
| X(구 트위터)에 자동으로 포스팅하기 (0) | 2026.05.13 |
|---|---|
| Laravel 13 세팅할 때 발생한 오류에 대한 정리 (0) | 2026.04.15 |
| PHP5.2와 JAVA에서 호환이 가능한 AES256 암호화(2) (1) | 2025.09.01 |
| PHP5.2와 JAVA에서 호환이 가능한 AES256 암호화 (0) | 2025.09.01 |
| 객체 지향이라? 오브젝트 지향이라고 불러다오... (9) | 2025.08.08 |