지난 글에서 Spring MVC의 흐름을 살펴봤다. HTTP 요청이 DispatcherServlet에 들어와서 Controller → Service → Repository 계층을 거쳐 응답으로 나가는 구조였다.
그런데 Repository 계층에서 실제로 DB와 어떻게 통신하는지는 다루지 않았다. MyBatis를 써본 사람이라면 XML에 SQL을 직접 작성하는 방식에 익숙할 것이다.
이번 글에서는 그 방식과 완전히 다른 접근법인 JPA를 다룬다. 읽으면서 이런 질문들을 생각해보자.
MyBatis로 개발하다 보면 반복되는 패턴이 있다.
<!-- UserMapper.xml -->
<select id="findById" resultType="User">
SELECT id, name, email FROM users WHERE id = #{id}
</select>
<insert id="save">
INSERT INTO users (name, email) VALUES (#{name}, #{email})
</insert>
<update id="update">
UPDATE users SET name = #{name}, email = #{email} WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM users WHERE id = #{id}
</delete>
users 테이블이든 orders 테이블이든 products 테이블이든, CRUD 쿼리의 구조는 거의 똑같다. 테이블 이름과 컬럼만 다를 뿐이다.
이 반복을 없앨 수 없을까? 그 질문에서 JPA가 시작된다.
JPA는 ORM(Object-Relational Mapping) 기술이다.
Object(Java 객체) ↔ Relational(관계형 DB 테이블) 을 Mapping(연결)한다는 뜻이다.
핵심 아이디어는 간단하다. Java 클래스를 보면 DB 테이블에 필요한 정보가 다 있다.
public class User {
private Long id; // 컬럼 이름 + 타입
private String name; // 컬럼 이름 + 타입
private String email; // 컬럼 이름 + 타입
}
JPA는 이 정보를 읽어서 SQL을 자동으로 만들어낸다.
모든 Java 클래스가 테이블은 아니다. JPA에게 "이 클래스를 테이블로 취급해라"고 알려주는 어노테이션이 @Entity다.
@Entity
public class User {
@Id // 이 필드가 PK다
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 자동으로 PK 생성
private Long id;
private String name;
private String email;
}
@Id로 어떤 필드가 Primary Key인지 명시한다. PK 자동 생성 전략은 @GeneratedValue로 지정할 수 있다.
MyBatis에서는 Mapper 인터페이스에 XML 쿼리를 연결했다. JPA에서는 JpaRepository를 상속받는 것으로 끝이다.
public interface UserRepository extends JpaRepository<User, Long> {
// 아무것도 안 써도 아래 메서드들이 전부 동작한다
}
JpaRepository<User, Long> 에서
User → 어떤 Entity를 다룰지Long → 그 Entity의 PK 타입이 두 가지 정보만 넘겨주면, save, findById, findAll, deleteById 등이 자동으로 제공된다.
구현 클래스는 필요 없다. Spring이 앱 시작 시점에 SimpleJpaRepository라는 구현체를 자동으로 만들어서 Bean으로 등록해준다.
기본 CRUD 외에 조건이 필요한 쿼리는 메서드 이름으로 선언한다.
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByName(String name); // WHERE name = ?
List<User> findByNameAndEmail(String name, String email); // WHERE name = ? AND email = ?
}
XML도 없고, SQL도 없다. 메서드 이름 규칙에 맞게 선언하면 JPA가 알아서 쿼리를 만들어준다.
| 항목 | MyBatis | JPA |
|---|---|---|
| SQL 작성 | 개발자가 직접 XML에 작성 | JPA가 자동 생성 |
| CRUD | 4개 쿼리 직접 작성 | JpaRepository 상속으로 자동 제공 |
| 조건 쿼리 | XML에 WHERE절 직접 작성 | 메서드 이름으로 선언 |
| 결과 매핑 | resultMap 설정 필요 | Entity 클래스로 자동 매핑 |
JPA에서 가장 중요한 개념이다.
영속성 컨텍스트는 DB와 Java 애플리케이션 사이에 있는 임시 저장 공간이다. 이걸 관리하는 객체가 EntityManager다.
도서관(DB) ←→ 책상(EntityManager/영속성 컨텍스트) ←→ 나(애플리케이션)
도서관에서 책을 빌려오면 책상 위에 올려두는 것처럼, DB에서 Entity를 가져오면 영속성 컨텍스트에 보관된다. 책상 위에 있는 동안은 도서관까지 안 가도 바로 꺼내볼 수 있다.
Spring Data JPA를 쓰면 EntityManager를 직접 다룰 일은 거의 없다. JpaRepository가 내부적으로 대신 처리해준다. 하지만 영속성 컨텍스트가 어떻게 동작하는지는 알아야 한다.
영속성 컨텍스트의 핵심 기능이다.
Entity를 가져올 때 원본 상태를 스냅샷으로 찍어둔다. 트랜잭션이 끝날 때 현재 상태와 스냅샷을 비교해서 달라진 부분이 있으면 자동으로 UPDATE 쿼리를 실행한다.
// Before: MyBatis
userMapper.update(user); // UPDATE 쿼리를 직접 호출해야 한다
// After: JPA
User user = userRepository.findById(1L).get();
user.setName("김철수"); // 값만 바꾸면 끝
// save() 호출 없음. 트랜잭션 종료 시 자동으로 UPDATE 실행
계좌 이체를 생각해보자.
1번은 성공했는데 2번에서 오류가 나면 어떻게 돼야 할까? 1번 출금도 취소되어야 한다. 이게 트랜잭션이다.
Spring에서는 @Transactional 어노테이션으로 선언한다.
@Service
public class UserService {
@Transactional
public void transfer(Long fromId, Long toId, int amount) {
// 메서드 시작 → 트랜잭션 시작
// 중간에 예외 발생 → 자동 Rollback
// 정상 종료 → Commit
}
}
@Transactional과 영속성 컨텍스트를 합치면 전체 흐름이 이렇다.
@Transactional 시작
↓
findById() → DB에서 Entity 조회 + 스냅샷 저장
↓
Entity 값 수정 (save() 호출 없음)
↓
@Transactional 종료
↓
Dirty Checking → 스냅샷과 비교 → UPDATE 쿼리 자동 실행
↓
Commit → DB에 반영
예외가 발생하면 Commit까지 가지 않고 Rollback으로 처음 상태로 돌아간다.
JPA의 핵심은 SQL을 직접 쓰는 대신 Java 클래스와 DB 테이블을 대응시키는 것이다. @Entity로 테이블을 선언하고, JpaRepository로 CRUD를 얻고, 영속성 컨텍스트가 변경을 감지해서 자동으로 쿼리를 실행한다.