
앞선 포스트에서는 ORM의 이점과, 이를 자바 진영에서 구현한 JPA에 대해 알아보았다.
이번 포스트에서는,
JPA에서 테이블과 클래스를 매핑할 때 사용하는 @Entity 어노테이션에 대해 알아보려고 한다.
추가로 엔티티를 식별하는 기준인 @Id 어노테이션에 대해서도 정리하도록 한다.
엔티티(Entity)란 데이터베이스의 테이블과 1대1로 매핑되며,
데이터를 객체 형태로 표현하는 자바 클래스를 말한다.
ORM이 존재하기 이전 데이터베이스에서 사용자 목록을 조회하고자할 때는 다음과 같은 쿼리를 DB에 보내야했다.
SELECT * FROM users
이후 해당 쿼리를 받은 DB는 테이블 형태의 결과물을 ResultSet이라는 객체에 담아 반환했다.
자바와 같은 객체지향 언어에서 ResultSet으로 받아온 데이터들을 다루기 위해서는,
추가로 while이나 for 반복문을 돌며 데이터를 하나씩 꺼내며 new User를 진행해야했고
하나씩 데이터를 세팅해주어야 하여 시간적으로도 코드 작성 레벨에서도 아쉬움이 남았다.
이를 JPA에서 해결해주는 방식이 @Entity 어노테이션인 것이다.
해당 어노테이션은 다음과 같이 클래스 선언문 상단에 붙여서 사용 가능하며
@Entity
public class User {
...
각 필드를 컬럼에 매핑하여 JPA가 해당 객체를 데이터베이스 테이블과 연동할 수 있도록 구성한다.
@Entity 어노테이션이 붙었다면 다음과 같은 일련의 과정을 거쳐 관리된다.
영속성 컨텍스트에 대한 자세한 내용은 해당 포스트에서 확인한다.
@Entity의 사용 이유는 사실상 ORM의 이점과 크게 다르지 않다.
데이터베이스의 데이터를 하나의 객체로 묶어 구조와 규칙을 함께 관리하여 코드의 오류를 줄일 수 있고
자바 코드를 통해 작성된 백엔드 비즈니스 로직상에서 일관된 설계를 진행할 수 있게 해준다.
데이터베이스의 문제점
데이터베이스는 좋은 저장소지만, 비즈니스 규칙을 담을 수 없다는 단점이 존재한다.
엔티티가 존재하지 않는다면, 개발자는 데이터베이스에서 불러온 값에 대하여
서비스 클래스 내부에서 데이터의 상태와 관련된 비즈니스 로직을 처리해야한다.이는 일종의 파편화를 유발하는데,
동일 데이터에 대한 상태를 여러 서비스에서 사용할 경우 각각의 서비스에서 모두 구현해야하며
추후 정책이 바뀌게 된다면 전체 서비스를 돌며 해당 부분 코드를 수정해야할 것이다.
@Entity를 사용한다면 이러한 엔티티 상태를 변경하는 비즈니스 로직을
엔티티 클래스 내부에 넣을 수 있다.
이를 통해 비즈니스 로직의 파편화가 줄어들고, 하나의 클래스가 명확한 책임을 가지게 된다.
객체 스스로 상태를 책임지고 관리
엔티티 클래스 내부에 상태와 관련된 정책을 두면, 객체가 자신의 상태를 스스로 관리할 수 있다.
즉, 데이터와 그 데이터를 변경하는 행위를 한 곳에 모아둘 수 있다.
이렇게 하면 상태 변경 규칙이 엔티티 내부에 모이기 때문에,
정책이 변경되었을 때 수정 범위를 줄일 수 있고 객체의 일관성도 유지하기 쉬워진다.
@Entity가 붙은 클래스에는 기본 생성자가 반드시 존재해야 한다.
기본 생성자가 없는 클래스에 @Entity 어노테이션을 붙이면
애플리케이션 실행 시점에 JPA 구현체가 엔티티 객체를 생성하지 못해 에러가 발생할 수 있다.
이는 JPA 구현체가 데이터베이스에서 데이터를 읽어온 뒤,
엔티티 객체를 생성할 때 리플렉션을 사용하기 때문이다.
리플렉션(Reflection)
런타임에 클래스의 메타데이터를 분석하고, 생성자나 메서드, 필드에 동적으로 접근할 수 있게 해주는 기술
JPA 구현체인 Hibernate는 실행 중에 엔티티 객체를 동적으로 생성해야 한다.
이때 리플렉션을 통해 기본 생성자를 호출한 뒤, 필드에 값을 주입하는 방식으로 객체를 만든다.
따라서 파라미터가 있는 생성자만 존재하고 기본 생성자가 없다면,
Hibernate가 어떤 생성자를 어떤 인자로 호출해야 할지 알 수 없기 때문에
엔티티 객체를 생성하지 못하고 에러가 발생할 수 있다.
기본 생성자는 public 또는 protected 접근 제어자를 가져야 하며,
외부에서 무분별하게 생성하지 못하도록 protected 기본 생성자를 자주 사용한다.
Hibernate가 엔티티 객체를 관리하려면, 먼저 JVM 메모리에 올릴 수 있는 객체를 생성할 수 있어야 한다.
이를 위해 엔티티 클래스에는 기본 생성자가 필요하다.
Hibernate는 기본 생성자를 통해 빈 엔티티 객체를 생성한 뒤,
리플렉션을 사용해 데이터베이스에서 조회한 값을 각 필드에 주입하는 방식으로 동작한다.
JPA를 공부하다 보면 이후에 지연 로딩과 프록시 개념을 만나게 되는데
JPA는 성능 최적화를 위해 관련 엔티티를 즉시 조회하지 않고,
실제 객체 대신 프록시 객체를 먼저 넣어두는 방식을 사용할 수 있다.
프록시는 실제 엔티티처럼 보이지만,
필요한 시점이 되기 전까지는 실제 데이터를 조회하지 않는 가짜 객체에 가깝다.
Hibernate는 이러한 프록시 객체를 만들 때 원본 엔티티 클래스를 상속하는 방식을 사용한다.
final 클래스는 다른 클래스가 상속할 수 없기 때문에 Hibernate가 프록시 클래스를 만들 수 없다.
그 결과 지연 로딩이 제대로 동작하지 않을 수 있다.
추가로 필드에도 final을 사용하지 않는 것이 좋다.
Hibernate는 기본 생성자를 통해 빈 엔티티 객체를 만든 뒤,
리플렉션을 사용해 데이터베이스에서 조회한 값을 필드에 주입하는 것을 앞서 확인했다.
그러나 final 필드는 생성자 이후에 값을 변경할 수 없기 때문에,
Hibernate가 필드 값을 채워 넣는 과정과 맞지 않는다.
데이터베이스를 설계할 때 특정 테이블에 필수로 들어가는 요소 중 하나가 PK이다.
이러한 PK는 테이블에서 행을 식별하게 해주는 기준이되며, 자바에서 이를 표현하고 다루는 방식이 @Id이다.
데이터베이스는 DDL을 통해 특정 컬럼이 PK임을 명시하고 알 수 있지만,
자바에서는 이를 @Id를 통해 엔티티를 식별하는 유일한 키값으로 활용한다.
즉 @Id 어노테이션은 엔티티의 식별자를 지정해주는 기본 키 매핑 어노테이션이라 할 수 있다.
기본 키를 할당하는 방식은 크게 직접 할당과 자동 생성으로 나눌 수 있다.
1. 직접 할당
직접 할당은 개발자가 자바 코드에서 직접 ID 값을 넣어주는 방식이다.
User user = new User();
user.setId(1L);
이 방식은 개발자가 직접 식별자 값을 관리해야 하므로 실무에서는 자주 사용하지 않는다.
2. 자동 생성
자동 생성은 데이터베이스의AUTO_INCREMENT, 시퀀스,
또는 UUID 같은 방식을 사용해 식별자 값을 자동으로 생성하는 방식이다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
일반적인 서비스에서는 직접 ID를 넣기보다 자동 생성 전략을 사용하는 경우가 많다.
생성 전략에는 여러 종류가 있으며, 이는 추후 다른 포스트에서 다루도록 한다.
자연키(Natural Key)와 대리키(Surrogate Key)
기본 키는 크게 자연키와 대리키로 구분된다.
자연키는 비즈니스적인 의미를 가지면서 유일성을 만족하는 값이며,
대리키는 비즈니스 의미 없이 식별만을 위해 인위적으로 만든 값을 말한다.
엔티티의 기본 키로는 자연키보다 대리키를 사용하는 것이 좋다.
이메일은 현재는 중복되지 않는 값처럼 보이지만, 나중에 사용자가 이메일을 변경할 수 있다.
만약 이메일을 기본 키로 사용하고 있었다면, 기본 키 자체를 변경해야 한다.
기본 키가 변경되면 해당 키를 외래 키로 참조하는 게시글, 주문, 결제, 장바구니 등
여러 테이블도 함께 영향을 받는다.
이 과정에서 많은 업데이트가 발생하고, 데이터 정합성 문제나 락 문제가 생길 수 있다.
따라서 기본 키는 비즈니스 정책이 변해도 바뀌지 않는 값이어야 한다.
이런 이유로 보통 의미 없는 순차 번호나 UUID 같은 대리키를 사용한다.
이메일이나 전화번호처럼 유일해야 하는 값은 기본 키로 사용하기보다
UNIQUE 제약 조건을 걸어 중복을 막는 것이 좋다.
@Id는 단순히 데이터베이스의 PK와 매핑하기 위한 어노테이션만은 아니다.
JPA는 @Id 값을 기준으로 영속성 컨텍스트 안에서 엔티티를 식별하고 관리한다.
즉 @Id는 JPA가 객체를 구분하는 기준이다.
동일 트랜잭션 안에서 같은 ID의 회원을 두 번 조회하면,
JPA는 두 번째 조회에서 DB로 바로 가지 않고 영속성 컨텍스트의 1차 캐시를 먼저 확인한다.
User user1 = em.find(User.class, 1L);
User user2 = em.find(User.class, 1L);
이때 JPA는 User 타입과 id = 1이라는 값을 기준으로 이미 관리 중인 엔티티가 있는지 확인한다.
같은 엔티티가 1차 캐시에 있다면 DB를 다시 조회하지 않고 기존 객체를 반환한다.
또한 JPA의 변경 감지(Dirty Check) 기능도 식별자를 기준으로 동작한다.
조회해온 엔티티의 값을 변경하면, JPA는 트랜잭션이 끝날 때 변경된 내용을 감지하고 UPDATE 쿼리를 실행한다.
user.changeNickname("새이름");
개발자가 직접 UPDATE SQL을 작성하지 않아도, JPA는 해당 엔티티가
어떤 row에 대응되는지 @Id 값을 통해 알고 있기 때문에 DB에 변경 내용을 반영할 수 있다.
1차 캐시와 변경 감지에 대한 자세한 내용은 영속성 컨텍스트를 다룰 때 더 깊게 살펴보도록 한다.
데이터베이스에 다음같은 DDL을 가지는 USERS 테이블이 존재한다 가정하자.
CREATE TABLE USERS (
id BIGINT NOT NULL,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
nickname VARCHAR(50) NOT NULL,
image VARCHAR(500),
PRIMARY KEY (id)
);
id, email, password, nickname, image의 컬럼을 가지고 있고
id는 PK(Primary Key)에 해당한다.
이를 @Entity 어노테이션을 통해 엔티티 클래스와 매핑시킨다면
@Entity
@Table(name = "USERS")
public class User {
@Id
private Long id;
private String email;
private String password;
private String nickname;
private String image;
protected User() {
}
}
위 코드처럼 나타내볼 수 있다. 이때 @Table(name = "USERS")는 어떤 테이블의 이름을 찾을지를 지정하며
해당 어노테이션이 붙어있지 않다면 자동으로 클래스의 이름으로 테이블을 찾는다.
현재 해당 클래스의 이름은 User로 되어있기에, 데이터베이스에 저장되어있는 USERS테이블은 찾지 못한다.
컬럼에 해당하는 값들은 자동으로 필드의 이름들과 동일한 변수에 매핑된다.
@Entity 어노테이션이 붙어 JPA가 관리 대상으로 인식하는 클래스이며, DB의 테이블과 매핑된다.