관계형 데이터베이스에서 데이터를 다루고 관리하려면 SQL을 작성해야 한다. JPA를 사용하기 전의 자바 애플리케이션에서는 보통 JDBC API를 사용해서 SQL을 데이터베이스에 전달하는 코드를 직접 작성했다.
이해를 위해 상품을 CRUD하는 기능을 개발한다고 해보자. 먼저 상품 정보를 표현하기 위한 Product 객체를 만든다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class Product {
private int productId; // 상품 번호, PK
private String productName; // 상품명
private int categoryId; // 카테고리 번호, FK
private int price; // 가격
private int stock; // 재고
private String manufacturer; // 제조사
private LocalDate createdAt; // 등록일
}
이제 상품 데이터를 데이터베이스에 저장하거나 조회하려면 DAO 객체를 만들어야 한다. 일반적으로는 먼저 SQL 문을 정의하고, JDBC API를 사용해서 SQL을 실행한 뒤, 조회 결과를 다시 자바 객체에 매핑하는 방식으로 작업을 진행한다.
public class ProductDao {
// 상품 삽입 기능
public int insert(Product product) {
String sql = """
INSERT product (product_name, category_id, price, stock, manufacturer, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""";
try (
Connection connection = DBUtil.getConnection();
PreparedStatement pstmt = connection.prepareStatement(sql);
)
{
pstmt.setString(1, product.getProductName());
pstmt.setInt(2, product.getCategoryId());
pstmt.setInt(3, product.getPrice());
pstmt.setInt(4, product.getStock());
pstmt.setString(5, product.getManufacturer());
pstmt.setDate(6, Date.valueOf(product.getCreatedAt()));
return pstmt.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
return 0;
}
// 상품 리스트 조회 기능
public List<Product> selectList(int count) {
List<Product> products = new ArrayList<>();
String sql = """
SELECT product_id, product_name, category_id, price, stock, manufacturer, created_at
FROM product ORDER BY price DESC LIMIT ?
""";
try (
Connection connection = DBUtil.getConnection();
PreparedStatement pstmt = connection.prepareStatement(sql);
)
{
pstmt.setInt(1, count);
try (ResultSet rs = pstmt.executeQuery();) {
while (rs.next()) {
products.add(mpaProduct(rs));
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return products;
}
// 상품 단건 조회 기능
public Product selectOne(int productId) {
String sql = """
SELECT product_id, product_name, category_id, price, stock, manufacturer, created_at
FROM product WHERE product_id = ?
""";
try (
Connection connection = DBUtil.getConnection();
PreparedStatement pstmt = connection.prepareStatement(sql);
)
{
pstmt.setInt(1, productId);
try (ResultSet rs = pstmt.executeQuery();) {
if (rs.next()) {
return mpaProduct(rs);
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
// 상품 삭제 기능
public int delete(int productId) {
String sql = """
DELETE FROM product
WHERE product_id = ?
""";
try (
Connection connection = DBUtil.getConnection();
PreparedStatement pstmt = connection.prepareStatement(sql);
) {
pstmt.setInt(1, productId);
return pstmt.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
return 0;
}
private Product mpaProduct(ResultSet rs) throws SQLException {
return Product.builder()
.productId(rs.getInt("product_id"))
.productName(rs.getString("product_name"))
.categoryId(rs.getInt("category_id"))
.price(rs.getInt("price")) .stock(rs.getInt("stock"))
.manufacturer(rs.getString("manufacturer"))
.createdAt(rs.getDate("created_at").toLocalDate())
.build();
}
}
보다시피 고작 상품 CRUD를 구현하는데도 코드량이 상당하다. 그리고 자세히 보면 반복되는 코드도 많다. 예를 들어 다음과 같은 작업이 거의 모든 메서드에서 반복된다.
Connection connection = DBUtil.getConnection();
PreparedStatement pstmt = connection.prepareStatement(sql);
그리고 조회 기능에서는 ResultSet에서 값을 하나씩 꺼내 객체에 직접 넣어주는 작업도 필요하다.
.productId(rs.getInt("product_id"))
.productName(rs.getString("product_name"))
.categoryId(rs.getInt("category_id"))
.price(rs.getInt("price"))
.stock(rs.getInt("stock"))
.manufacturer(rs.getString("manufacturer"))
.createdAt(rs.getDate("created_at").toLocalDate())
아래와 같이 상품 객체를 데이터베이스가 아니라 자바 컬렉션에 저장하듯이 다룰 수 있다면 얼마나 편리할까?
list.add(product);
Product product = list.get(productId);
하지만 현실적으로는 이렇게 할 수 없다. 자바 객체와 관계형 데이터베이스는 서로 비슷해 보이지만, 실제로는 데이터를 표현하는 방식이 다르다. 객체는 필드와 메서드를 가지고 있고, 객체끼리 참조를 통해 연결된다. 반면 관계형 데이터베이스는 데이터를 테이블, 행, 열, 외래 키 중심으로 관리한다.
그래서 JDBC를 사용할 때는 중간에서 SQL과 JDBC API를 이용해 객체와 테이블 사이의 변환 작업을 개발자가 직접 처리해야 한다. 문제는 테이블이 하나만 있는 것이 아니라는 점이다. 상품 테이블뿐만 아니라 회원, 주문, 카테고리, 재고, 결제 같은 테이블이 계속 늘어난다면 각 테이블마다 비슷한 DAO 코드를 반복해서 작성해야 한다. 더 큰 문제는 변경에 취약하다는 것이다. 예를 들어 Product에 필드 하나가 추가되기만 해도 INSERT SQL, SELECT SQL, PreparedStatement 파라미터 바인딩, ResultSet 매핑 코드를 모두 수정해야 한다.
또한 JDBC 기반 개발에서는 객체만 보고 해당 객체가 어떤 데이터를 온전히 가지고 있는지 판단하기 어렵다. 어떤 DAO 메서드는 상품 기본 정보만 조회하고, 어떤 DAO 메서드는 카테고리 정보까지 조인해서 조회할 수 있다. 결국 개발자는 객체를 신뢰하기보다 DAO 메서드와 SQL을 직접 열어보고 “이 객체가 어디까지 조회된 객체인지” 확인해야 한다.
따라서 SQL과 JDBC를 직접 사용해서 코드를 작성했을 때의 문제점은 다음과 같이 정리할 수 있다.
결국 JDBC를 직접 사용하는 방식에서는 개발자가 비즈니스 로직에 집중하기보다 SQL 작성, 파라미터 바인딩, 결과 매핑 같은 반복 작업에 많은 시간을 사용하게 된다.
애플리케이션이 발전할수록 내부 복잡성은 점점 증가한다. 이런 복잡성을 관리하기 위해 자바 같은 객체지향 언어를 사용하는 것은 매우 자연스러운 선택이다.
객체지향 언어를 사용하면 비즈니스 요구사항을 객체로 모델링할 수 있다. 예를 들어 상품, 카테고리, 주문, 회원 같은 개념을 각각 객체로 표현하고, 객체들이 서로 협력하도록 만들 수 있다. 문제는 이렇게 만든 객체를 데이터베이스에 저장할 때 발생한다.
알다시피 객체는 속성과 기능을 가진다. 그리고 하나의 객체는 다른 객체를 참조할 수도 있다. 따라서 객체지향적으로 상품과 카테고리를 모델링한다면 아래와 같은 형태가 더 자연스러울 것이다.
public class Product {
private int productId;
private String productName;
private Category category;
private int price;
private int stock;
}
상품 입장에서는 categoryId라는 숫자 값만 가지고 있는 것보다 Category 객체를 직접 참조하는 것이 더 객체지향적이다.
String categoryName = product.getCategory().getCategoryName();
하지만 관계형 데이터베이스에서는 객체 참조라는 개념이 없다. 대신 외래 키를 사용한다.
SELECT * FROM product p
JOIN category c ON p.category_id = c.category_id;
즉, 객체는 참조를 통해 연관된 객체를 탐색하지만, 관계형 데이터베이스는 외래 키와 조인을 통해 연관된 데이터를 조회한다. 이 차이는 단순한 문법 차이가 아니다. 객체지향과 관계형 데이터베이스가 데이터를 바라보는 관점 자체가 다르기 때문에 발생하는 문제다.
객체지향에서는 추상화, 캡슐화, 상속, 다형성, 참조, 객체 그래프 탐색 같은 개념을 중요하게 생각한다. 반면 관계형 데이터베이스는 데이터를 테이블에 정규화해서 저장하고, SQL을 통해 필요한 데이터를 집합적으로 조회하는 방식에 더 가깝다.
대표적인 차이를 정리하면 다음과 같다.
| 객체지향 | 관계형 데이터베이스 |
|---|---|
| 객체는 참조로 연관 객체를 탐색한다 | 테이블은 외래 키와 조인으로 연관 데이터를 찾는다 |
| 객체는 상속과 다형성을 사용할 수 있다 | 테이블에는 상속과 다형성 개념이 직접 존재하지 않는다 |
| 객체는 식별자뿐만 아니라 동일성도 중요하다 | 데이터베이스는 기본 키를 기준으로 행을 구분한다 |
| 객체 그래프를 자유롭게 탐색할 수 있다 | 필요한 데이터는 SQL로 명시적으로 조회해야 한다 |
| 객체는 상태와 행위를 함께 가진다 | 테이블은 주로 데이터 중심으로 구성된다 |
이처럼 객체지향 프로그래밍과 관계형 데이터베이스는 지향하는 방향이 다르다. 이 차이를 흔히 패러다임의 불일치(Impedance Mismatch)라고 한다.
결국 개발자는 객체지향적으로 코드를 작성하고 싶지만, 데이터를 저장하고 조회하는 순간 관계형 데이터베이스 구조에 맞춰 SQL을 작성해야 한다. 이 과정에서 객체 중심의 설계가 테이블 중심의 코드로 흔들리기 쉽다.
이러한 문제를 해결하기 위해 등장한 기술이 JPA다. JPA(Java Persistence API)는 자바 진영의 ORM 기술 표준이다. 여기서 ORM(Object-Relational Mapping)은 말 그대로 객체와 관계형 데이터베이스를 매핑한다는 뜻이다.
즉, ORM은 객체와 테이블 사이의 차이를 중간에서 해결해주는 기술이다. JPA를 사용하면 개발자는 SQL과 JDBC API를 직접 다루는 대신, 객체를 중심으로 데이터를 저장하고 조회할 수 있다. 마치 자바 컬렉션에 객체를 저장하듯이 엔티티를 데이터베이스에 저장하고, 식별자를 이용해 객체를 조회할 수 있다.
JPA는 애플리케이션과 JDBC 사이에서 동작한다. 개발자가 JPA를 통해 엔티티를 저장하거나 조회하면, JPA가 내부적으로 적절한 SQL을 생성하고 JDBC API를 사용해서 데이터베이스와 통신한다.

em.persist(product);
Product product = em.find(Product.class, productId);
여기서 em(Entity-Manager)는 JPA에서 엔티티를 저장, 조회, 수정, 삭제할 때 사용하는 핵심 객체다.
다만 여기서 한 가지 짚고 넘어갈 부분이 있다. JPA는 구현체가 아니라 자바 진영의 ORM 표준 인터페이스라는 점이다. 실제로 JPA를 구현한 대표적인 구현체로는 Hibernate가 있다. 스프링 부트에서 JPA를 사용한다고 할 때 대부분 내부적으로 Hibernate를 함께 사용한다.
따라서 JPA를 배운다는 것은 단순히 특정 라이브러리 사용법을 배우는 것이 아니라, 자바 객체와 관계형 데이터베이스를 어떻게 매핑하고 관리할 것인지에 대한 표준 방식을 배우는 것이라고 볼 수 있다.
첫 번째 이유는 생산성이다.
JDBC를 직접 사용할 때는 SQL 작성, 파라미터 바인딩, ResultSet 처리, 객체 매핑 코드를 반복해서 작성해야 했다. 반면 JPA를 사용하면 객체를 마치 자바 컬렉션에 저장하듯이 다룰 수 있다.
em.persist(product); // 저장
Product product = em.find(Product.class, productId); // 조회
상품을 저장할 때 직접 INSERT SQL을 작성하지 않아도 된다. 상품을 조회할 때도 직접 SELECT SQL을 작성하고 ResultSet을 하나씩 꺼내 객체에 담는 작업을 하지 않아도 된다.
물론 JPA가 SQL을 완전히 몰라도 되게 만들어주는 것은 아니다. 기본적인 CRUD SQL과 반복적인 JDBC 코드를 직접 작성하는 부담을 크게 줄여주는 것이다.
복잡한 조회가 필요하거나 성능 최적화가 필요한 경우에는 JPQL, QueryDSL, Native SQL 등을 사용해야 할 수 있다. 또한 JPA가 실제로 어떤 SQL을 실행하는지 이해하는 능력도 중요하다.
즉, JPA는 SQL 지식을 대체하는 기술이라기보다 반복적인 SQL 작성과 객체 매핑 작업을 줄여주고, 개발자가 객체 중심으로 비즈니스 로직을 작성할 수 있도록 도와주는 기술이다.
두 번째 이유는 유지보수성이다.
JDBC 방식에서는 필드 하나가 추가되어도 여러 부분을 수정해야 한다. 예를 들어 상품에 description이라는 설명 필드가 추가되었다고 해보자.
그러면 다음과 같은 코드를 모두 수정해야 한다.
INSERT SQLSELECT SQLPreparedStatement 파라미터 바인딩 코드ResultSet 매핑 코드물론 JPA를 사용한다고 해서 모든 변경이 자동으로 해결되는 것은 아니다. 엔티티 필드를 추가하고, 필요한 경우 테이블 컬럼도 추가해야 한다. 하지만 JDBC를 직접 사용할 때처럼 모든 CRUD SQL과 매핑 코드를 일일이 수정하는 부담은 크게 줄어든다. 특히 단순한 CRUD에서는 JPA가 변경된 엔티티 구조를 기반으로 SQL을 생성해주기 때문에 반복적인 수정 작업이 줄어든다.
세 번째 이유는 객체와 관계형 데이터베이스 사이의 패러다임 불일치를 줄여준다는 점이다.
객체는 객체답게 참조를 사용할 수 있고, 데이터베이스는 데이터베이스답게 외래 키를 사용할 수 있다. JPA는 이 둘 사이를 매핑해준다. 예를 들어 객체에서는 다음과 같이 Product가 Category 객체를 참조하게 만들 수 있다.
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String productName;
@ManyToOne
@JoinColumn(name = "category_id")
private Category category;
}
보통 객체 입장에서는 다음처럼 자연스럽게 카테고리를 탐색할 수 있다.
Category category = product.getCategory();
String categoryName = category.getCategoryName();
하지만 데이터베이스에서는 product 테이블의 category_id 외래 키를 사용해서 category 테이블과 관계를 맺는다. JPA는 이 차이를 중간에서 매핑해준다. 덕분에 개발자는 테이블 구조에만 맞춘 코드가 아니라 객체 관계를 중심으로 코드를 작성할 수 있다.
물론 JPA가 모든 패러다임 불일치를 완벽하게 없애주는 것은 아니다. 객체와 관계형 데이터베이스는 여전히 다른 모델이다. 하지만 JPA는 이 차이를 상당 부분 완화해주고, 개발자가 더 객체지향적인 방식으로 애플리케이션을 설계할 수 있도록 도와준다.
네 번째 이유는 성능 최적화 기능이다.
JPA는 애플리케이션과 데이터베이스 사이에서 다양한 최적화 기능을 제공한다. 대표적인 예가 1차 캐시다.
JPA는 엔티티를 영속성 컨텍스트라는 공간에서 관리한다. 같은 영속성 컨텍스트 안에서 동일한 식별자로 엔티티를 다시 조회하면, 데이터베이스를 다시 조회하지 않고 이미 관리 중인 엔티티를 반환한다.
int productId = 1;
Product productA = em.find(Product.class, productId);
Product productB = em.find(Product.class, productId);
JDBC를 직접 사용했다면 같은 상품을 조회하는 SQL이 두 번 실행될 수 있다. 하지만 JPA는 첫 번째 조회 결과를 영속성 컨텍스트에 보관하고, 두 번째 조회에서는 이미 관리 중인 객체를 반환할 수 있다. 스프링 환경에서는 같은 트랜잭션 범위 안에서 이러한 효과를 볼 수 있다.
또한 JPA는 변경 감지 기능도 제공한다. 엔티티를 조회한 뒤 값을 변경하면, 트랜잭션이 끝나는 시점에 JPA가 변경된 내용을 감지해서 필요한 UPDATE을 실행한다.
Product product = em.find(Product.class, productId);
product.changePrice(30000);
위 코드에서 별도로 UPDATE 문을 직접 작성하지 않아도, JPA는 엔티티의 변경 사항을 감지해서 데이터베이스에 반영할 수 있다.
이 외에도 JPA는 쓰기 지연, 지연 로딩, 즉시 로딩, 플러시 같은 다양한 기능을 제공한다. 이러한 기능들은 편리하지만 잘못 사용하면 성능 문제가 발생할 수도 있기 때문에 JPA의 동작 원리를 이해하는 것이 중요하다.
다섯 번째 이유는 데이터 접근 추상화와 벤더 독립성이다.
관계형 데이터베이스는 MySQL, Oracle, PostgreSQL 등 여러 종류가 있다. 각 데이터베이스는 SQL 문법, 자료형, 함수, 페이징 방식 등이 조금씩 다르다. JDBC를 직접 사용하는 방식에서는 애플리케이션 코드가 특정 데이터베이스에 강하게 의존할 수 있다. 예를 들어 MySQL에 맞춰 작성한 SQL이 Oracle에서는 그대로 동작하지 않을 수 있다.
JPA는 이러한 차이를 줄이기 위해 Dialect라는 개념을 사용한다. Dialect는 데이터베이스 방언이라는 뜻으로, JPA 구현체가 특정 데이터베이스에 맞는 SQL을 생성할 수 있도록 도와준다.
예를 들어 사용하는 데이터베이스가 MySQL인지, PostgreSQL인지, Oracle인지 설정하면 JPA는 해당 데이터베이스에 맞는 SQL을 생성하려고 한다.

물론 데이터베이스를 변경한다고 해서 모든 문제가 자동으로 해결되는 것은 아니다. 데이터베이스마다 지원하는 함수, 자료형, 락 동작, 인덱스 전략 등이 다를 수 있기 때문이다. 하지만 JPA는 데이터베이스별 차이를 상당 부분 추상화해주기 때문에, 순수 JDBC로 모든 SQL을 직접 작성하는 방식보다 특정 벤더에 대한 의존을 줄일 수 있다.
JPA를 처음 접하면 “이제 SQL을 몰라도 되는 것 아닌가?” 라고 생각할 수 있다. 하지만 그렇지는 않은 것 같다. 학습하면서 JPA는 SQL을 직접 작성하는 부담을 줄여주는 기술이지, SQL과 데이터베이스 지식을 완전히 대체하는 기술은 아니라는 느낌이 들었다.
따라서 JPA를 제대로 사용하기 위해서는 다음과 같은 내용에 대한 이해가 필수인 것 같다.
특히 성능 문제를 해결하려면 JPA가 내부적으로 실행하는 SQL을 반드시 확인할 수 있어야 한다. JPA를 사용하더라도 최종적으로 데이터베이스와 통신할 때는 SQL이 실행되기 때문이다. JPA를 잘 사용하기 위해 객체지향과 관계형 데이터베이스에 대한 학습도 게을리해서는 안 되겠다.