JPA에서는 애플리케이션 실행 시점에 자동으로 DDL(Data Definition Language)를 생성하는 기능을 제공합니다. 이를 Automatic Schema Generation이라고 합니다. 이 기능을 사용하면 개발자가 직접 데이터베이스 스키마를 생성하거나 변경할 필요 없이, JPA가 엔티티 클래스와 매핑 정보를 바탕으로 DDL을 자동으로 생성하여 스키마를 생성하거나 변경할 수 있습니다.
DDL 생성 기능에 제약조건을 추가할 수도 있습니다. 예를 들어, 회원 이름은 필수로 설정하되, 이름이 10자를 초과하면 안된다고 가정하면 아래와 같이 설정할 수 있습니다.
import jakarta.persistence.*;
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 10)
private String name;
// getters and setters
}
유니크 제약조건도 추가할 수 있습니다. 위 코드에 member 테이블에 name과 age 필드로 이루어진 유니크 제약 조건을 추가하는 방법은 다음과 같습니다.
@Entity
@Table(name = "member", uniqueConstraints = {@UniqueConstraint(name = "NAME_AGE_UNIQUE", columnNames = {"NAME", "AGE"})})
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 10)
private String name;
// getters and setters
}
Automatic Schema Generation 기능은 JPA 구현체마다 다를 수 있고, 대표적인 구현체인 Hibernate의 경우 hibernate.hbm2ddl.auto 설정을 통해 제어할 수 있습니다.
옵션 | 설명 |
---|---|
create | 기존테이블 삭제 후 다시 생성 (DROP + CREATE) |
create-drop | create와 같으나 종료 시점에 테이블 DROP |
update | 변경분만 반영 (운영 DB에는 절대 사용하면 안됨) |
validate | 엔티티와 테이블이 정상 매핑되었는지만 확인 |
none | Automatic Schema Generation을 사용하지 않음 (정해진 명령어는 아니지만 관례상 사용) |
Automatic Schema Generation 기능은 개발 환경에서는 매우 유용하나, 운영 환경에서는 자동으로 스키마 생성 및 변경이 위험할 수 있습니다. 특히, 운영 장비에는 절대 create, create-drop, update 사용하면 안 됩니다. 자칫 잘못하면 테이블이 드랍되는 위험이 있으므로 운영 환경에서는 반드시 수동으로 데이터베이스 스키마를 생성하고 변경해야 합니다.
개발 초기 단계에서는 create 또는 update까진 로컬에선 사용할 수 있지만, 공용 개발 서버로 넘어가는 경우에는 create 등은 사용하면 안되고, validate나 update까지정도 가능할 수 있습니다. 스테이징과 운영 서버는 가급적 none을 권장하되 validate까지는 가능할 수 있습니다.
JPA를 사용해서 테이블과 매핑할 클래스는 @Entity
를 붙여서 선언해줍니다.
@Entity
public class Member {
@Id
private Long id;
private String name;
// getter, setter ...
}
@Table 어노테이션은 JPA에서 엔티티 클래스와 데이터베이스 테이블을 매핑하기 위한 어노테이션입니다.
기본적으로 JPA는 엔티티 클래스의 이름을 기준으로 해당 클래스와 매핑할 데이터베이스 테이블의 이름을 결정합니다. 따라서 엔티티 클래스의 이름과 데이터베이스 테이블의 이름이 일치한다면 @Table 어노테이션을 사용하지 않아도 됩니다.
하지만 엔티티 클래스의 이름과 데이터베이스 테이블의 이름이 다르거나, 매핑 정보를 추가로 설정하고 싶은 경우에는 @Table 어노테이션을 사용합니다. @Table 어노테이션을 사용하면 다음과 같은 설정을 할 수 있습니다.
- name: 매핑할 데이터베이스 테이블의 이름을 설정합니다.
- catalog: 테이블이 속한 카탈로그 이름을 설정합니다.
- schema: 테이블이 속한 스키마 이름을 설정합니다.
- uniqueConstraints: 테이블의 유니크 제약 조건을 설정합니다.
@Entity @Table(name = "member", catalog = "mydb", schema = "public", uniqueConstraints = {@UniqueConstraint(columnNames = {"name", "age"})}) public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "name", nullable = false) private String name; @Column(name = "age", nullable = false) private Integer age; // getters and setters }
회원 테이블에 요구사항을 추가하여 필드를 구성해봅시다.
위 요구사항을 반영한 엔티티 모델은 다음과 같다.
package com.example.hellojpa;
import jakarta.persistence.*;
import java.util.Date;
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "name")
private String name;
private Integer age;
@Enumerated(EnumType.STRING)
private RoleType roleType;
@Temporal(TemporalType.TIMESTAMP)
private Date createDate;
@Temporal(TemporalType.TIMESTAMP)
private Date lastModifiedDate;
@Lob
private String description;
// getter, setter ...
}
속성 | 설명 | 기본값 |
---|---|---|
name | 필드와 매핑할 테이블의 칼럼 이름 | 객체의 필드 이름 |
insertable, updatable | 등록, 변경 가능 여부 | True |
nullable(DDL) | null 값의 허용 여부를 설정한다. false로 설정하면 DDL 생성 시에 not null 제약조건이 붙는다. | True |
unique(DDL) | @Table의 uniqueConstraints와 같지만 한 컬럼에 간단히 유니크 제약조건을 걸 때 사용한다. (하지만, 이름을 반영하기 어려워서 잘 쓰지 않는다.) | |
columnDefinition(DDL) | 데이터베이스 컬럼 정보를 직접 줄 수 있다. ex) varchar(100) default 'EMPTY' | 필드의 자바 타입과 방언 정보를 사용 |
length(DDL) | 문자 길이 제약조건, String 타입에만 사용한다. | 255 |
precision, scale(DDL) | BigDecimal 타입에서 사용한다(BigInteger도 사용가능). precision은 소수점을 포함한 전체 자릿수를, scale은 소수의 자릿수다. 참고로 double, float 타입에는 적용되지 않는다. 아주 큰 숫자나 정밀한 소수를 다루어야 할 때만 사용한다. | precision = 19, scale = 2 |
날짜 타입(java.util.Date, java.util.Calendar)을 매핑할 때 사용하는데, 요즘 하이버네이트에서는 LocalDate, LocalDateTime을 지원하고, 이를 사용하면 @Temporal은 생략 가능
데이터베이스 BLOB, CLOB 타입과 매핑할 때 사용합니다.
BLOB(Binary Large Object)과 CLOB(Character Large Object)는 데이터베이스에서 사용되는 데이터 유형입니다.
BLOB은 이진 데이터를 저장하는 데 사용되며, 예를 들어 이미지, 동영상, 오디오 등과 같은 이진 파일을 저장하는 데 적합합니다.
반면에, CLOB는 문자 데이터를 저장하는 데 사용되며, 예를 들어 긴 텍스트, 문서, HTML 또는 XML 문서 등과 같은 문자열 데이터를 저장하는 데 적합합니다.
@Transient
private Integer temp;
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
직접 할당시에는 @Id만 사용하지만, 대개의 경우 @GeneratedValue을 같이 붙여서 기본 키를 자동 생성하여 사용합니다.
기본 키 전략 권장사항
기본 키는 절대 변하면 안됩니다. 많은 시간이 흘러도 항상 유일하고 null이 아니여야 합니다. 가령, 주민등록번호조차 변동될 수 있으므로 대개의 자연키는 기본 키로 설정되기 어렵습니다. 따라서 대리키(대체키)를 사용해야 하는 것입니다. 자료형 또한 10억은 가볍게 넘겨도 유효해야 하므로 int, Integer 등은 적합하지 않고, 일반적으로 Long 타입을 권장합니다.
>>> 권장 : Long 타입 + 대체키 + 키 생성전략 사용
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
기본 키 생성을 데이터베이스에 위임하는 전략으로, 주로 MySQL, PostgreSQL, SQL Server, DB2에서 사용합니다.
MySQL의 AUTO_INCREMENT가 IDENTITY 전략인데, 이는 데이터베이스에 INSERT SQL을 실행한 이후에 ID값을 알 수 있습니다. 즉, DB에 반영이 된 이후에 PK 값을 확인할 수 있는 것이죠. 하지만 JPA는 보통 트랜잭션 커밋 시점에 INSERT SQL을 실행하므로, IDENTITY 전략을 사용할 경우 em.persist() 시점에 즉시 INSERT SQL을 실행시키고 DB에서 식별자를 조회하는 방법으로 사용할 수 있습니다.
@Entity
@SequenceGenerator(
name = "MEMBER_SEQ_GENERATOR",
sequenceName = "MEMBER_SEQ", //매핑할 데이터베이스 시퀸스 이름,
initialValue = 1, allocationSize = 1)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "MEMBER_SEQ_GENERATOR")
private Long id;
키 생성 전용 테이블을 하나 만들어두고 데이터베이스 시퀀스를 흉내내는 전략입니다. 이는 모든 데이터베이스에 적용이 가능하지만, 성능이 좋지 않다는 치명적인 단점이 있습니다.
JPA의 연관관계 매핑 기능을 통해 객체 중심 모델링과 테이블 중심 모델링의 패러다임 불일치를 해결한다.
Member, Team 엔티티 두 개가 있으며, 멤버 여러 명이 한 팀에 소속되는 연관관계가 있다고 가정해봅시다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "username")
private String name;
private int age;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
위 엔티티는 서로 상관관계가 아직 반영되지 않은 상황입니다. 객체 지향 모델링에 따라 단방향으로 연관관계를 설정하면 다음과 같습니다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "username")
private String name;
private int age;
// 연관관계 추가
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
단방향을 설정할 때에는 FK를 가진 엔티티에만 @JoinColumn 어노테이션과 같이, 다대일, 일대다, 일대일 등 어떤 연관관계인지에 따른 어노테이션을 붙이면 끝납니다. 이렇게 완성한 단방향 연관관계를 통해 다음과 같이 멤버에 새로운 팀을 설정할 수 있습니다.
// 새로운 팀 객체 생성
Team newTeam = new Team();
newTeam.setName("new team");
em.persist(newTeam);
// 회원에 새로운 팀 설정
member.setTeam(newTeam);
하지만 반대 방향으로 객체 그래프를 탐색하는 등의 기능을 수행할 일이 잦은 경우, 단방향으로는 부족할 수 있습니다. 이를 해결하기 위해 고안된 것이 양방향 매핑입니다.
양방향 매핑의 경우, 연관관계의 주인을 설정하는 것이 매우 중요합니다. 이를 설정한 모습은 다음과 같습니다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "username")
private String name;
private int age;
// Member 클래스는 동일
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
// 양방향 연관관계 설정
// "나는 반대 테이블의 team 이라는 이름에 매핑되어있어"
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<Member>();
}
위와 같이 설정한 경우에 아래와 같이 조회가 가능합니다.
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size(); // 역방향 조회
테이블 지향 모델링에서 두 테이블 간의 연관관계를 관리하기 위해서는 외래 키 하나를 설정하면 되지만, 객체 지향 모델링에서는 이를 사용할 수 없었습니다. 따라서 설정하는 '양방향 관계'는 사실 양방향 관계가 아닌, 서로 다른 단방향 관계 2개입니다. 하지만 DB 입장에선 둘 중 하나를 기준으로 외래 키를 관리해야 하므로 "연관관계 주인"이라는 개념이 등장하게 됩니다.
객체의 두 관계 중 하나를 연관관계의 주인으로 지정합니다. 연관관계의 주인이 외래 키를 관리하는 테이블이 되고, 해당 데이터의 등록과 수정을 담당합니다. 주인이 아닌 객체는 읽기만 가능합니다. 그렇다면 누가 주인이 될지 감이 잡히나요?
ERD 상의 외래 키를 가진 객체가 연관관계의 주인이 됩니다. 위 Member와 Team 객체 간의 연관관계에서는 Member가 연관관계의 주인이 되었습니다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
// 역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);
현재 예시에서의 연관관계의 주인은 Member 엔티티입니다. 하지만 위 코드에서는 team에 있는 Members 필드에만 멤버 인스턴스를 입력하고 있습니다. 이처럼 연관관계 주인에 값을 입력하지 않고 그 반대 방향에만 값을 입력해주면 안됩니다. 최소한 연관관계의 주인에 값을 우선 넣어주어야 하며, 순수한 객체 관계를 고려하면 항상 양쪽 모두에 값을 넣어주는 것을 권장합니다. 이러한 로직을 구현할 때에는 연관관계 편의 메소드를 사용하는 것을 권장합니다.
위 내용에 따라 개선한 코드는 다음과 같습니다.
// Member 클래스 내용 중 (예시)
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
// 연관관계 편의 메소드
public void setTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
// 연관관계 주인에 먼저 값 입력 (연관관계
member.setTeam(team);
em.persist(member);
// 이후 역방향에도 값 입력
team.getMembers().add(member);
em.persist(member);
위와 같이 양방향 매핑 시 주의할 점은, lombok을 이용한 toString() 메소드 사용 등의 관계에서 잘못하면 무한루프에 걸릴 수 있습니다. 따라서 가급적 toString() 메소드는 alt + insert
를 통해 generate toString()으로 생성하여 문제가 발생하지는 않을지 체크하는 습관이 필요합니다.
사실, 단방향 매핑만으로도 이미 연관관계 매핑은 끝났다고 봐도 됩니다. 하지만 JPQL을 사용할 때 역방향으로 탐색하는 경우가 종종 발생하기 때문에 양방향 매핑 방법을 숙지해두는 것이 더 좋습니다. 단방향 매핑을 잘 구성해두고, 양방향은 필요할 때 추가해도 테이블에 영향을 주지 않으니 무방합니다.
*** 다대일, 일대다와 일대일 매핑 방법에 대하여 더욱 자세히 알고 싶다면 첨부 링크를 참조 (이후 별도로 정리하겠습니다.)
1:1 관계에서 외래키는 주테이블에 존재하는 것으로 하자. 그게 제일 편하다.
관계형 데이터베이스 모델링은 기본적으로 상속관계를 구현할 수 없습니다. 하지만 슈퍼타입 서브타입 관계를 정의하는 모델링 기법을 활용하여 객체 상속 구조와 유사하게 RDB에 매핑할 수 있습니다.
슈퍼타입 서브타입 논리 모델을 실제 물리 모델로 구현하는 방법은 다음 3가지로 정리됩니다. 하나하나 살펴볼까요?
join
이용장점 | 단점 |
---|---|
테이블 정규화 | 성능 저하 (조회 시 조인을 많이 사용) |
외래 키 참조 무결성 제약조건 활용 가능 | 조회 쿼리가 복잡함 |
저장공간 효율화 | 데이터 저장시 INSERT SQL 2번 호출 |
장점 | 단점 |
---|---|
성능 우수 (조인 불필요) | 항상 모든 필드를 채우지 않으므로, 자식 엔티티가 매핑한 컬럼은 모두 null을 허용해야 함 |
조회 쿼리가 단순함 | 단일 테이블에서 모든 데이터를 저장하므로 테이블이 커질 수 있어, 상황에 따라 조회 성능이 악화될 가능성 존재. |
장점 | 단점 |
---|---|
서브 타입을 명확하게 구분하여 처리해야 할 때 효과적임 | 여러 자식 테이블을 함께 조회할 때 UNION SQL이 필요하여 성능이 느림 |
not null 사용 가능 | 자식 테이블을 통합해서 쿼리하기 어려움 |
@MappedSuperclass 어노테이션은 테이블과 관계 없이, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할을 수행합니다. @Entity 클래스는 엔티티나 @MappedSuperclass로 지정한 클래스만 상속이 가능하여 이를 활용하여 모델링합니다. 이는 주로, 등록일, 수정일, 등록자, 수정자 같은 전체 엔티티에서 공통으로 적용하는 정보를 모을 때 사용합니다.