들어가며...
Form 기반 Spring Security 연재글의 첫번째로 Database에 저장된 데이터와 Mapping되는 Object인 Account, Role 두개의 class를 구현하도록 하겠습니다. Database연동은 MyBatis가 아닌 Spring Data JPA를 사용하였습니다.
- Table of Contents
- JPA란?
- 의존성 라이브러리 추가
- Account.java 구현
- Role.java 구현
JPA란 Java Persistence API의 약자로 간략하게 정의하면 관계형 데이터베이스의 데이터를 Object로 Mapping하는 기술 명세라고 생각하면 될 듯 합니다.
JPA는 Object Mapping에 대한 기술 명세만 제공하며 이를 바탕으로 실제로 기능을 구현한 라이브러들이 있는데 대표적인 것이 Hibernate입니다. Spring 진영에서는 Hibernate를 바탕으로 자체적으로 구현한 Spring Data JPA를 제공합니다.
제가 참여했던 프로젝트에서는 대부분 MyBatis를 사용하여 이 작업을 수행하였는데 잠시 살펴보니 세계적인 추세는 JPA인 듯 합니다. 아래는 Google Trend에서 JPA와 MyBatis의 검색량을 비교해 보았습니다.
● 2020년 3월 13일 기준 JPA, MyBatis 검색량 (by Google Trend)
대부분의 국가에서는 JPA에 대한 검색량이 높으나 유독 중국, 대한민국, 일본의 경우만 MyBatis에 대한 검색량이 많음을 알 수 있습니다.
실제로 아래의 class정의를 보시면 매우 간단한 방법으로 DBMS <=> Object가 Mapping되는 것을 확인할 수 있습니다. 하지만 데이터베이스의 관계가 복잡해지고 그로인해 SQL문이 복잡해지면 아무래도 Object Mapping방식으로는 한계가 있는 듯 합니다. 하여 이 ORM(Object Relatinal Mapping) 방식으로 구현이 어려운 Query는 직접 SQL을 사용할 수도 있는 방법을 제공하고 있습니다.
JPA만으로도 책한권을 쓸 수 있을만큼 내용이 방대하여 본 글에서는 이정도로 정리하고 아래 class내용을 살펴보면서 단편적인 내용에 대해 추가 설명하도록 하겠습니다.
의존성 라이브러리 추가
Spring Starter Project로 원하는 이름으로 신규 프로젝트를 생성하시고 build.gradle 에 아래의 5가지 의존성 라이브러리를 추가합니다.
- Spring Data JPA
- Spring Security
- Thymeleaf
- Spring Web
- h2 database
● build.gradle
plugins {
id 'org.springframework.boot' version '2.2.5.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}
group = 'com.demo.spring'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
runtimeOnly 'com.h2database:h2'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
Account.java 구현
package com.demo.security.auth.model;
import java.util.Date;
import java.util.Set;
import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;
import javax.persistence.ManyToMany;
import javax.persistence.Table;
import javax.persistence.Transient;
import javax.persistence.UniqueConstraint;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.validator.constraints.Length;
@Entity
@Table(name = "account", uniqueConstraints = {@UniqueConstraint(name = "NAME_EMAIL_UNIQUE", columnNames = {"USERNAME", "EMAIL"})})
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(nullable = false)
@NotBlank
@Length(min = 4)
private String username;
@Column(nullable = false)
@NotBlank
@Length(min = 4)
private String password;
@Transient
@NotBlank
private String confirmPassword;
@Column(nullable = false)
@NotBlank
@Email
private String email;
@Column(nullable = false)
private Boolean isActive;
@CreationTimestamp
private Date regDate;
@ManyToMany(cascade = CascadeType.MERGE)
@JoinTable(name = "user_role",
joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
private Set<Role> roles;
////////////////////////////////////////////////////////////////////////////////
//< getter and setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getConfirmPassword() {
return confirmPassword;
}
public void setConfirmPassword(String confirmPassword) {
this.confirmPassword = confirmPassword;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public Date getRegDate() {
return regDate;
}
public void setRegDate(Date regDate) {
this.regDate = regDate;
}
public Set<Role> getRoles() {
return roles;
}
public void setRoles(Set<Role> roles) {
this.roles = roles;
}
}
사용자의 정보를 저장하는 Table의 Mapping Object로 "User"라는 Class는 Spring Security에서 사용하고 있어 혼란스러울 수 있어 "Account"라는 이름으로 정의하였습니다.
import된 라이브러리들을 봐도 알 수 있듯이 JPA 관련 어노테이션 외에도 Validation관련된 어노테이션도 있음을 알 수 있습니다. 이번 글에서는 JAP 어노테이션에 관련된 내용들에서만 설명하고 Validation과 관련된 설명은 차후 포스팅에서 하도록 하겠습니다.
그럼 JAP 어노테이션관련 내용을 하나하나 알아보도록 하겠습니다.
● @Entity
- 해당 class를 특정 Table과 매핑하겠다는 의미의 Annotation
▶ 주의할 점
- parameter가 없는 기본 생성자가 반드시 필요. Object 초기화시 별도의 작업을 할 필요가 없을 경우에는 별도로 구현하지 않아도 됨.
- final, enum, interface 등의 object는 @Entity로 사용 불가
- 테이블과 매핑되는 변수는 final 사용불가
▶ 속성
- name (ex: @Entity(name = "Account")
- Entity의 이름을 명시적으로 정의하고자 할때 사용
- 정의되어 있지 않을 경우에는 Class명을 사용
- Class명이 동일한 것이 있을 경우 활용
● @Table
- 해당 Object와 매핑될 테이블 정보를 명시
▶ 속성
- name(ex:@Table(name="account") : 테이블의 이름 명시, 정의되어 있지 않을 경우 기본값으로 @Entity에서 정의한 name 속성값을 참조한다.
- catalog : catalog를 지원하는 데이터베이스일 경우 해당 catalog의 이름을 명시
- schema : schema를 지원하는 테이터베이스일 경우 해당 schema 이름을 명시
- uniqueConstraints(DDL) : DDL 매핑 시 Unique 제약조건을 사용할 경우 명시
@Table(name = "account", uniqueConstraints = {@UniqueConstraint(name = "NAME_EMAIL_UNIQUE", columnNames = {"USERNAME", "EMAIL"})})
위 설명을 토대로 실제 구현된 내용을 살펴보면 해당 Entity 클래스는 데이터베이스 테이블의 "account"와 매핑하겠다는 의미입니다. 속성에서도 설명하였듯이 추가로 "uniqueConstraints" 를 사용하였는데 아래에서도 설명하겠지만 unique속성은 @Column 어노테이션에서도 지정할 수도 있으나 위처럼 두 개 이상의 컬럼에 유니크 속성을 지정하려면 @Table 어노테이션의 "uniqueConstraint" 속성을 지정하여 사용하여야 합니다. constraint의 이름과 "username", "email" 컬럼에 대해서 unique 속성을 정의하였습니다.
※ 참고 : Sample project라서 DDL schema를 별도로 작성하지 않은 이유도 있고 JPA의 어노테이션을 되도록 많이 포함시켜 사용법을 익히고자 일부러 사용한 것도 있습니다. 실제 실무 프로젝트에서는 DDL Schema는 별도로 관리하는 편이 좋기 때문에 실제 unique 속성은 사용할 일이 없을 것입니다.
● @Id
- 기본키(Primary Key)로 사용할 Value값에 설정
● @GeneratedValue
- 기본키를 자동 생성하고자 할 때 설정
▶ Type
- IDENTITY : 기본 키 생성을 데이터베이스에 위임하고자 할 때 (ex: MySQL의 AUTO INCREMENT)
- SEQUENCE : 테이터베이스의 시퀀스를 사용하여 기본키를 할당하고자 할 때 (ex: Oracle sequenc)
- TABLE : 키 생성용 테이블을 사용
● @Column
- Object의 필드를 테이블의 컬럼과 매핑.
- 가장 많이 사용되고 속성도 많지만 대부분의 속성이 DDL과 관련되어 있어 거의 사용되지 않고 주로 "name", "nullable"만 사용한다.
▶ 속성
- name : 매핑되는 컬럼의 이름
- insertable : true > 저장 시 해당 필드도 같이 저장, false > 저장 시 해당 필드는 저장하지 않음 (read only)
- updatable : true > 수정 시 해당 필드도 같이 수정, false > 실제 데이터베이스에 수정하지 않음 (read only)
- nullable (DDL) : null 값 허용 여부를 정의
- unique (DDL) : 해당 컬럼에 unique 제약조건 생성. 두 개 이상의 유니크 제약조건이 필요할 경우에는 @Table의 uniqueConstraints 를 사용해야 한다.
- length (DDL) : 문자의 길이값 설정 (String 타입에서만 사용)
● @Transient
- 실제 테이블의 컬럼에는 없으나 Object에서 별도로 필요한 값일 경우 해당 annotation을 명시. 다시 말해서 Entity로 선언된 Object가 Serialize될 때 해당 어노테이션으로 정의된 값은 제외됨
● @CreationTimestamp
- 해당 Object가 저장될 때 시간값을 (Timestamp) 자동으로 생성하여 넣어준다. > INSERT시 동작
- 갱신 시 시간값을 자동 생성 저장하고자 할 경우에는 @UpdateTimestamp 어노테이션을 설정하면 된다.
● @ManyToMany
두개의 테이블을 Join하는 방법으로 @JoinTable과 함께 사용하여 대상 테이블 전체 또는 @JoinColumn을 같이 사용하여 특정 컬럼값과 일치하는 레코드만을 join할 수도 있다.
위 Account.java에서는 Account <=> Role 테이블을 다대다 단방향 형태로 join하도록 하였다. 즉, 사용자의 Role(권한) 정보를 join하여 가져오는 것이다. 이를 위해 @JoinColumn을 같이 사용하였으며 위와 같이 설정하였을 경우 사용자의 기본키와 매핑되는 Role의 권한에 대한 키본키를 "user_role"이라고 정의한 테이블을 생성하여 별도로 저장하게 된다.
위에 정의한 @ManyToMany, @JoinTable의 내용을 풀어쓰자면 다음과 같다.
@ManyToMany(cascade = CascadeType.MERGE)
@JoinTable(name = "user_role",
joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
private Set<Role> roles;
Account에 (즉, 사용자의) Role을 저장할 때 생성되는 사용자의 "id"와 해당 사용자의 Role과 일치하는 Role 테이블의 "id"값을 "user_role"이라는 테이블에 각각 "user_id", "role_id" 컬럼명으로 저장해라 라고 이해하면 될 듯 합니다. 따라서, 사용자 테이블에는 최초 아무런 레코드가 없어도 되나 Role 테이블에는 해당 프로젝트에서 사용할 권한계층을 미리 저장해두어야 합니다. (ADMIN, USER, GUEST...)
※ 참고 : Object 매핑의 가장 핵심은 위에서 주로 설명한 Entity의 매핑보다는 @ManyToMany와 같이 연관관계의 매핑일 것입니다. 해당 분은 추후에 기회가 된다면 별도로 정리하는 시간을 가져보도록 하겠습니다.
Role.java 구현
package com.demo.security.auth.model;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(unique = true)
private String role;
////////////////////////////////////////////////////////////////////////////////
//< getter and setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}
JPA관련된 어노테이션에 대해서는 위에서 설명하였으므로 생략하고 해당 테이블의 레코드들은 프로그램 실행 전 미리 등록되어 있어야 합니다.
마무리...
두개의 간단한 Object로 사용할 Java class이나 JPA관련된 어노테이션을 설명하다 보니 글이 길어졌습니다. 앞으로 설명하게 될 Repository까지는 Spring Security의 내용보다는 Spring Data JPA와 관련이 많습니다. 기회가 되면 JPA관련 부분은 별도로 공부하여 정리하는 시간이 필요할 듯 합니다.
U2ful은 ♥입니다. @U2ful Corp.