🟣 SQL을 손으로 쓰던 반복
리포지토리가 DB와 만나는 방식은 원래 단순했다. 자바에서 SQL 문자열을 만들어 보내고, 돌아온 결과 집합(ResultSet)을 한 칸씩 꺼내 객체 필드에 옮겨 담는다. 저장할 때는 반대로 객체 필드를 꺼내 INSERT 문에 끼운다.
String sql = "INSERT INTO orders(user_id, amount, status) VALUES(?, ?, ?)";
ps.setLong(1, order.getUserId());
ps.setInt(2, order.getAmount());
ps.setString(3, order.getStatus());테이블이 몇 개만 늘어도 이 옮겨 담기가 코드의 대부분을 차지한다. 컬럼을 하나 추가하면 INSERT·UPDATE·조회 매핑을 전부 손봐야 한다. 객체와 테이블은 사실상 같은 데이터를 두 벌로 표현하는데, 그 사이를 사람이 매번 손으로 잇고 있는 셈이다.
JPA는 이 옮겨 담기를 표준화한 규약이고, 하이버네이트가 그 구현이다. 자바 객체와 DB 테이블의 대응을 애너테이션으로 한 번 선언해 두면, 객체를 저장하고 조회하는 SQL을 프레임워크가 만들어 준다. 개발자는 SQL이 아니라 객체를 다룬다.
🟣 엔티티: 테이블과 짝지어진 객체
DB 테이블과 대응되는 객체를 엔티티라 부른다. 클래스에 @Entity를 붙이고, 각 행을 유일하게 가리키는 기본 키에 @Id를 붙인다.
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int amount;
}@GeneratedValue는 키 생성을 DB에 맡긴다는 표시다. IDENTITY 전략은 MySQL의 auto-increment처럼 DB가 행을 넣을 때 번호를 매기게 한다. 클라이언트에서 서버가 내려 준 id로 리소스를 식별하던 그 값이, 서버 입장에서는 이렇게 DB가 채워 주는 값이었다.
🚀 Enum을 문자열로 저장하기
주문 상태처럼 정해진 값만 갖는 필드는 자바에서 enum으로 두는 편이 안전하다. 그런데 JPA 기본값은 enum을 선언 순서 정수로 저장한다. 이게 함정이다. 나중에 enum 중간에 값을 하나 끼워 넣으면 순서가 밀려, 이미 저장된 행들의 의미가 통째로 어긋난다.
@Enumerated(EnumType.STRING)
private OrderStatus status; // "PAID", "CANCELED" 문자열로 저장그래서 enum은 거의 항상 EnumType.STRING으로 이름을 저장한다. 저장 공간을 조금 더 쓰는 대신, 값을 추가하거나 순서를 바꿔도 데이터가 안전하다. 눈에 안 띄는 기본값 하나가 나중에 데이터를 망가뜨릴 수 있다는 걸 보여 주는 사례라 인상에 남았다.
🚀 생성·수정 시각을 자동으로 채우기
거의 모든 테이블에 created_at, updated_at이 붙는다. 이걸 저장할 때마다 손으로 채우면 빠뜨리기 쉽다. JPA Auditing은 이 값을 자동으로 넣어 준다. 공통 필드를 부모 클래스로 빼고 @CreatedDate, @LastModifiedDate를 붙인 뒤, 애플리케이션에 @EnableJpaAuditing을 켜면 저장·수정 시점에 프레임워크가 시각을 채운다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTime {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}🟣 연관관계: 외래 키를 객체 참조로
테이블은 외래 키(FK)로 관계를 맺는다. 주문 테이블의 user_id 컬럼이 회원 테이블을 가리킨다. JPA는 이 FK를 객체 참조로 바꿔 보여 준다. order.getUser().getName()처럼 자바 객체를 타고 들어가면, JPA가 뒤에서 필요한 조회를 대신 한다.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
}@ManyToOne은 주문 여러 개가 회원 하나에 속한다는 뜻이고, @JoinColumn이 실제 FK 컬럼을 지정한다.
🚀 지연 로딩과 N+1
fetch = FetchType.LAZY는 연관된 객체를 실제로 쓸 때까지 조회를 미룬다. 주문만 필요할 때 회원까지 매번 같이 읽어 오면 낭비이기 때문이다. 반대로 EAGER는 항상 함께 읽는다.
지연 로딩을 기본으로 두되, 여기서 유명한 함정이 나온다. 주문 100건을 목록으로 조회한 뒤 반복문에서 각 주문의 회원 이름을 꺼내면, 주문 조회 1번에 회원 조회 100번이 따로 나간다. 이것이 N+1 문제다. 목록 하나 그리는 데 쿼리가 백 번 넘게 나가면 응답이 눈에 띄게 느려진다.
해법은 필요한 걸 처음부터 한 번에 조인해서 읽는 것이다. fetch join이나 @EntityGraph로 "이번엔 회원까지 같이 가져와라"라고 지정한다. 지연 로딩은 기본으로 두고, 함께 써야 할 자리에서만 명시적으로 한 번에 읽는 판단이 실무의 기본기다.
🟣 영속성 컨텍스트와 트랜잭션
JPA를 겉만 보면 SQL 자동 생성기 같지만, 핵심은 그 사이에 있는 영속성 컨텍스트라는 1차 캐시다. 리포지토리로 조회한 엔티티는 이 컨텍스트가 관리하는 상태가 되고, 같은 트랜잭션 안에서 같은 키를 다시 조회하면 DB로 안 가고 캐시에서 돌려준다.
여기서 편리한 동작 하나가 따라온다. 관리 중인 엔티티의 필드를 바꾸기만 하고 따로 저장을 호출하지 않아도, 트랜잭션이 끝날 때 JPA가 처음 읽은 값과 지금 값을 비교해 바뀐 것만 골라 UPDATE를 날린다. 이 자동 갱신을 더티 체킹이라 부른다.
@Transactional
public void cancel(Long orderId) {
Order order = repository.findById(orderId).orElseThrow();
order.setStatus(OrderStatus.CANCELED);
// save() 호출이 없어도 트랜잭션 끝에 UPDATE가 나간다
}이 모든 동작의 경계가 트랜잭션이다. @Transactional을 붙인 메서드는 하나의 작업 단위로 묶여, 안에서 벌어진 변경이 전부 성공해야 커밋되고 도중에 예외가 나면 전부 되돌아간다. 결제 차감과 주문 생성이 한 트랜잭션이면, 결제만 되고 주문은 안 남는 어긋난 상태가 생기지 않는다.
🟣 정리
- JPA는 객체와 테이블의 대응을 애너테이션으로 선언하고, 저장·조회 SQL을 프레임워크가 만들어 준다. 개발자는 SQL 대신 객체를 다룬다.
- enum은
EnumType.STRING으로 이름을 저장한다. 기본값인 순서 정수 저장은 값을 추가할 때 기존 데이터의 의미를 어긋나게 한다. - 연관관계는 FK를 객체 참조로 바꾼다. 지연 로딩을 기본으로 두되, N+1이 생기는 목록 조회에서는 fetch join으로 한 번에 읽는다.
- 영속성 컨텍스트는 1차 캐시다. 관리 중인 엔티티의 변경은 더티 체킹으로 트랜잭션 끝에 자동 반영되고, 트랜잭션은 그 모든 변경의 성공·실패 경계다.
- 면접에서 JPA를 물으면 "SQL을 자동으로 만든다"보다 영속성 컨텍스트·더티 체킹·N+1을 예로 드는 편이 실제로 써 봤다는 신호가 된다.
객체가 DB 행으로 오가는 길은 정리됐다. 이제 그 데이터에 접근할 자격이 있는 사람인지를 서버가 어떻게 판단하는지가 남았다. 앱이 보낸 토큰 하나로 서버가 사용자를 알아보는 방식, 다음 장의 인증으로 넘어간다.