목차
객체와 테이블 사이를 손으로 나른다
users 테이블에서 사용자 하나를 읽어 User 객체로 만든다고 하자. ORM 없이 JDBC로 하면 이렇게 생겼다.
String sql = "SELECT id, email, name FROM users WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
User user = new User();
user.setId(rs.getLong("id")); // 컬럼 하나를
user.setEmail(rs.getString("email")); // 꺼내서
user.setName(rs.getString("name")); // 필드에 넣는다
return user;
}
}가운데 세 줄이 하는 일은 테이블의 칸을 객체의 필드로 옮겨 담는 것뿐이다. 그런데 이 일이 테이블마다, 쿼리마다 되풀이된다. 컬럼을 하나 추가하면 이걸 쓰는 자리를 다 찾아 한 줄씩 더해야 한다. 저장할 때는 반대 방향으로 또 나른다.
객체와 테이블은 생김새가 달라서, 둘 사이에는 늘 이 “옮겨 담기”가 낀다. 지겹고, 빠뜨리기 쉽다.
ORM은 객체와 테이블을 이어준다
ORM(Object-Relational Mapping)은 객체와 테이블을 이어주는 도구다. 옮겨 담는 일을 대신 해준다.
이어주는 규칙은 단순하다.
| 객체 세계 | 테이블 세계 |
|---|---|
| 클래스 | 테이블 |
| 필드 | 컬럼 |
| 객체 하나 | 행(row) 하나 |
이 대응만 한 번 선언해두면, 앞에서 손으로 나르던 일을 ORM이 알아서 한다. 자바에서 ORM의 표준 규격이 JPA이고, 그걸 실제로 구현한 것이 Hibernate다. Spring Boot는 기본으로 Hibernate를 쓴다.
매핑을 선언한다
대응을 알려주는 건 애너테이션 몇 개다.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
private String name;
// 생성자·getter 생략
}@Entity- 이 클래스는 테이블과 이어진다는 표시@Table(name = "users")- 어느 테이블인지. 클래스 이름과 같으면 생략해도 된다@Id- 이 필드가 기본 키(행을 구분하는 값)@Column- 컬럼 세부. 이름이 필드와 같으면 생략해도 된다
선언은 여기까지다. “email을 어느 컬럼에서 꺼내라”를 코드로 쓰지 않는다. 매핑을 한 번 적어두면 나르기는 ORM이 한다.
SQL 대신 객체로 다룬다
이제 사용자를 저장하고 조회하는 코드를 보자. Spring Data JPA를 쓰면 이렇다.
public interface UserRepository extends JpaRepository<User, Long> {
}이게 전부다. 본문이 없는 인터페이스 하나인데, save·findById 같은 메서드가 이미 들어 있다. 쓰는 쪽은 이렇게 된다.
User user = new User("kim@example.com", "김");
userRepository.save(user); // ORM이 INSERT INTO users ... 를 만든다
User found = userRepository.findById(1L).orElseThrow();
// ORM이 SELECT ... FROM users WHERE id = 1 을 만든다SELECT도 INSERT도 손으로 쓰지 않았다. 객체를 저장하라고, 아이디로 찾아오라고 말했을 뿐이고 SQL은 ORM이 만든다.
앞에서 ResultSet에서 한 줄씩 꺼내던 그 일이 통째로 사라졌다. 이것이 ORM이 주는 값어치다. 개발자는 객체로 생각하고, 테이블과의 변환은 도구에 맡긴다.
연관관계도 매핑한다
필드와 컬럼만 잇는 게 아니다. 객체 사이의 관계도 잇는다.
주문(Order)이 회원(Member)에 속한다고 하자. 객체 세계에선 Order가 Member를 참조로 들고, 테이블 세계에선 orders에 member_id라는 외래 키가 있다. ORM은 이 둘을 애너테이션으로 잇는다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne // 주문 여럿이 회원 하나에 속한다
@JoinColumn(name = "member_id") // 이 외래 키가
private Member member; // 이 객체 참조가 된다
}이러면 조인 SQL을 손으로 안 쓰고도 order.getMember().getName()처럼 객체를 타고 관계를 넘나든다. ORM이 member_id로 회원을 찾아 채워준다.
편해 보이지만, 바로 여기서 뒤에서 볼 문제가 싹튼다. 객체를 타고 넘나드는 그 한 줄이, SQL로는 조회 하나이기 때문이다.
불일치는 공짜로 사라지지 않는다
여기서 한 가지는 짚고 가야 한다. 객체와 테이블은 애초에 생각하는 방식이 다르다.
- 잇는 방법이 다르다. 객체는 다른 객체를 참조로 가리킨다(
order.getMember()). 테이블은 외래 키 컬럼(member_id)으로 가리킨다. - 모양이 다르다. 객체엔 상속도 있고 컬렉션도 있다. 테이블은 그냥 평평한 행이다.
이 간극을 객체-관계 임피던스 불일치라고 부른다. ORM은 이 간극을 메워주지만, 메우는 값을 치른다. 객체를 다루는 편한 코드 뒤에서 ORM이 SQL을 만드는데, 그 SQL이 늘 우리가 기대한 모양은 아니다.
그래서 ORM을 쓸 때 실무에서 가장 먼저 배우는 습관이 하나 있다.
ORM도 결국 SQL을 만든다
편한 객체 코드에 가려져 있을 뿐, 밑에서 도는 건 SQL이다. 그래서 실무에선 ORM이 어떤 SQL을 만드는지 눈으로 본다. 그걸 안 보면 대표적으로 이 문제에 걸린다.
주문 목록을 뽑아서 각 주문의 회원 이름을 찍는 코드다.
List<Order> orders = orderRepository.findAll(); // (1) 주문 전체를 SELECT
for (Order order : orders) {
System.out.println(order.getMember().getName()); // 주문마다 회원을 꺼낸다
}Order가 회원을 **지연 로딩(lazy)**으로 물고 있으면, getMember()는 그때그때 회원을 조회한다. 그래서 주문이 100개면 쿼리가 1번(주문) + 100번(회원) 나간다. 코드는 반복문 하나인데 DB는 101번 두드려진다.
이게 N+1 문제다. ORM이 객체 코드를 SQL로 바꾸다 보니, 객체로는 자연스러운 반복문 하나가 뒤에서 쿼리 폭탄이 된다. 객체만 보고 SQL을 안 보면 안 보이는 함정이다.
해결은 “회원을 같이 가져와라”라고 알려주는 것이다.
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember(); // 주문과 회원을 한 번의 SELECT로이러면 쿼리가 1번으로 준다. 요점은 특정 해법이 아니라 태도다. ORM은 SQL을 감춰주는 게 아니라 대신 써주는 것이고, 실무에선 그 SQL을 확인하는 습관이 ORM을 제대로 쓰는 첫걸음이다.
정리
| ORM이란 | 객체와 테이블을 이어주는 도구. 옮겨 담기를 대신한다 |
| 대응 | 클래스=테이블, 필드=컬럼, 객체=행 |
| 어떻게 쓰나 | 매핑을 애너테이션으로 선언(@Entity), SQL 대신 객체로 저장·조회 |
| 자바에선 | 규격은 JPA, 구현은 Hibernate (Spring Boot 기본) |
| 공짜가 아니다 | 객체와 테이블은 생각하는 방식이 달라(임피던스 불일치) 간극을 메우는 값을 치른다 |
| 그래서 실무에선 | ORM이 만드는 SQL을 본다. 안 보면 N+1 같은 함정에 걸린다 |
객체로 생각하고 SQL은 도구에 맡기되, 그 도구가 무슨 SQL을 만드는지는 놓지 않는 것. 그게 ORM을 쓰는 감각이다.