Spring boot X JPA
Spring boot에서 JPA 사용하기
1. JPA(Java Persistence API)란?
1-1. 개요
- JavaSE, JavaEE를 위한 영속성(Persistence) 관리와 ORM(Object Relational Mapping [객체 - 관계형 데이터베이스 맵핑])을 위한 표준 기술
- 종류로는 Hibernate, OpenJPA, EclipseLink, TopLink Esstientials 등과 같은 구현체가 있으며, 이에 대한 표준 인터페이스가 바로 JPA이다.
1-2. 장단점
-
장점
- 객체 지향적으로 데이터 관리가 가능하기에 비즈니스 로직에 집중할 수 있다. 당연하게도 객체지향 중심으로 개발이 가능해진다.
- 테이블 생성, 변경, 관리가 쉽다.
- 쿼리에 집중하기 보다 객체 자체 개발에 대해서 집중할 수 있다. (쿼리를 사용하지 않기에)
- 빠른 개발이 가능하다.
-
단점
- 어렵고 알아야할것들이 많다.
- 잘 이해하고 사용하지 못할경우 데이터 손실 가능성이 있다. (persistence context)
- 성능상 문제가 있을 수 있다. (잘 안다면 해결이 가능하다.)
1-3. ORM(Object Relational Mapping)이란
- Object(객체) Relational(관계) Mapping(맵핑)
- 객체는 객체대로 설계하고, RDB는 RDB 대로 설계한다.
- ORM 프레임워크가 중간에서 맵핑해준다.
MyBatis, iBatis는 ORM이 아닌, SQL Mapper이다. SQL Mapper는 쿼리를 매핑한다.
2. JPA 의존성 설정
- gradle 설정
-
Dependencies 에 다음 추가
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('com.h2database:h2')
- spring-boot-stater-data-jpa: Spring boot용 Spring Data JPA 추상화 라이브러리. 스프링 부트 버전에 맞춰서 자동으로 JPA관련 라이브러리 버전을 관리해준다.
- H2: 인메모리 관계형 데이터 베이스. 별도의 설치 필요없이 의존성만으로 관리가 가능하고, 메모리에서 실행되기에 어플리케이션 재시작시 초기화된다.
3. DB (Sql-light) 설치
3-1. sqllitebrower 설치 (SQLite 테스트용)
-
Linux
snap install sqlitebrowser # 실행 sqlitebrowser
-
Windows
- 여기 에서 다운로드 받을 수 잇습니다.
- 설치 후
3-2. SQlite 데이터 베이스 생성
새 데이터베이스
클릭 후 원하는 DB 저장 위치에 save
3-3. TEST 테이블 생성
데이터베이스 구조
> 테이블 생성하기
클릭
- 테이블 이름을
TEST
라고 하고 필드에 각각 ID : INTEGER (PK)
VALUE : TEXT NOT NULL DEFAULT ""
추가
3-4. Spring-boot Sqlite JDBC 연동
- gradle dependencies 에 다음 추가
implementation('org.xerial:sqlite-jdbc') // 밑에 버전은 보안 이슈가 있었던거 같다.
-
SQLDialect.java 파일 생성 출처
-
Hibernate에는 SQLite를 위한 Dialect가 없다.
-
Dialect란 JPA에서는 개발자가 직접 SQL문을 작성하는게 아니라 JPA가 대신해주는데, 이때 사용하는게 Dialect이고 SQLDialect는 데이터베이스간의 SQL 문법 차이를 보정해주기위해 JPA가 제공하는 클래스이다.
-
SQLDialect.java
package com.test.blog.config;
import org.hibernate.dialect.Dialect;
import org.hibernate.dialect.function.SQLFunctionTemplate;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.dialect.function.VarArgsSQLFunction;
import org.hibernate.type.StringType;
import java.sql.Types;
public class SQLDialect extends Dialect {
public SQLDialect() {
registerColumnType(Types.BIT, "integer");
registerColumnType(Types.TINYINT, "tinyint");
registerColumnType(Types.SMALLINT, "smallint");
registerColumnType(Types.INTEGER, "integer");
registerColumnType(Types.BIGINT, "bigint");
registerColumnType(Types.FLOAT, "float");
registerColumnType(Types.REAL, "real");
registerColumnType(Types.DOUBLE, "double");
registerColumnType(Types.NUMERIC, "numeric");
registerColumnType(Types.DECIMAL, "decimal");
registerColumnType(Types.CHAR, "char");
registerColumnType(Types.VARCHAR, "varchar");
registerColumnType(Types.LONGVARCHAR, "longvarchar");
registerColumnType(Types.DATE, "date");
registerColumnType(Types.TIME, "time");
registerColumnType(Types.TIMESTAMP, "timestamp");
registerColumnType(Types.BINARY, "blob");
registerColumnType(Types.VARBINARY, "blob");
registerColumnType(Types.LONGVARBINARY, "blob");
// registerColumnType(Types.NULL, "null");
registerColumnType(Types.BLOB, "blob");
registerColumnType(Types.CLOB, "clob");
registerColumnType(Types.BOOLEAN, "integer");
registerFunction("concat", new VarArgsSQLFunction(StringType.INSTANCE, "", "||", ""));
registerFunction("mod", new SQLFunctionTemplate(StringType.INSTANCE, "?1 % ?2"));
registerFunction("substr", new StandardSQLFunction("substr", StringType.INSTANCE));
registerFunction("substring", new StandardSQLFunction("substr", StringType.INSTANCE));
}
public boolean supportsIdentityColumns() { return true; }
public boolean hasDataTypeInIdentityColumn() { return false; }
public String getIdentityColumnString() { return "integer"; }
public String getIdentitySelectString() { return "select last_insert_rowid()"; }
public boolean supportsLimit() {
return true;
}
protected String getLimitString(String query, boolean hasOffset) {
return new StringBuffer(query.length() + 20).append(query).append(hasOffset ? " limit ? offset ?" : " limit ?") .toString();
}
public boolean supportsTemporaryTables() { return true; }
public String getCreateTemporaryTableString() { return "create temporary table if not exists"; }
public boolean dropTemporaryTableAfterUse() { return false; }
public boolean supportsCurrentTimestampSelection() { return true; }
public boolean isCurrentTimestampSelectStringCallable() { return false; }
public String getCurrentTimestampSelectString() { return "select current_timestamp"; }
public boolean supportsUnionAll() { return true; }
public boolean hasAlterTable() { return false; }
public boolean dropConstraints() { return false; }
public String getAddColumnString() { return "add column"; }
public String getForUpdateString() { return ""; }
public boolean supportsOuterJoinForUpdate() { return false; }
public String getDropForeignKeyString() { throw new UnsupportedOperationException("No drop foreign key syntax supported by SQLiteDialect"); }
public String getAddForeignKeyConstraintString(String constraintName, String[] foreignKey, String referencedTable, String[] primaryKey, boolean referencesPrimaryKey) { throw new UnsupportedOperationException("No add foreign key syntax supported by SQLiteDialect"); }
public String getAddPrimaryKeyConstraintString(String constraintName) { throw new UnsupportedOperationException("No add primary key syntax supported by SQLiteDialect"); }
public boolean supportsIfExistsBeforeTableName() { return true; }
public boolean supportsCascadeDelete() { return false; }
}
-
application.properties 에 다음 추가 (없으면 src > main > resources > application.properties 에 생성)
spring.jpa.database-platform=com.test.blog.config.SQLDialect
spring.jpa.hibernate.ddl-auto=update
spring.datasource.url=jdbc:sqlite:TBlog.db
spring.datasource.driver-class-name=org.sqlite.JDBC
spring.datasource.username=admin
spring.datasource.password=admin
-
Test Entity 클래스 작성
package com.test.blog.test.entity;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Getter
@RequiredArgsConstructor
@Entity
public class Test {
@Id
@GeneratedValue
private int id;
private String value;
}
-
서버 구동 시 TBlog.db 파일이 생성된다.
- 해당 파일을 sqlitebrowser로 열어서 보면 다음과 같이 테이블과 컬럼들이 생성되어있다.
4. Spring boot에 JPA 적용하기
4-1. 요구사항 분석
- 사용자 (User) 관련 로직 추가
- 사용자 회원가입 (Insert)
- 사용자 탈퇴 (Delete)
- 사용자 조회 (Selete)
- 사용자 정보 변경 (Update)
- 사용자는 ID는 중복되지 않는다.
- 사용자 이름, 전화번호, 주민등록번호, 비밀번호를 가진다.
- 이름, 전화번호, 주민등록번호, 비밀 번호는 필수 입력 사항이다.
4-2. User Entity 클래스 작성
- User.java
package com.test.blog.user.entity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
@Getter
@Entity
@NoArgsConstructor
public class User {
@Id
private String id;
@Column(length = 100, nullable = false)
private String phoneNumber;
@Column(length = 100, nullable = false)
private String socialSecurityNumber;
@Column(length = 100, nullable = false)
private String password;
@Builder
public User(String id, String phoneNumber, String socialSecurityNumber, String password){
this.id = id;
this.phoneNumber = phoneNumber;
this.socialSecurityNumber = socialSecurityNumber;
this.password = password;
}
}
- @Entity: 테이블과 링크될 클래스임을 명시. 기본적으로 카멜케이스 이름을 언더 스코어 이름으로 자동 변환하여 컬럼이 삽입된다.
- @Column: 굳이 컬럼을 추가하지 않아도 Entity에 있는 필드는 전부 컬럼이 된다. 하지만 붙이는 이유는 length 와 같은 옵션들을 추가해야할때 붙인다.
- @Builder : 해당 클래스의 빌더 메소드를 생성. 생성자 상단에 선언시 해당 생성자의 파라미터에 포함된 필드들만 빌더에 포함됨.
- 위와 같이 작성 후 서버를 실행하면 다음과 같은 DB 테이블이 만들어진다.
Entity 클래스에는 Setter 메소드를 만들지 않는다. (언제 값이 변했는지 명확하게 파악하기 힘들기 때문)
4-3. JpaRepository 클래스 생성
-
Repository 작성
package com.test.blog.user.entity;
import com.test.blog.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, String> { // 제네릭의 첫번째 타입은 Entity, 두번째 타입은 PK 타입
}
- Mybatis나 ibatis에서 DAO라고 불리는 DB 레이어 접근자.
- Repositroy는 인터페이스로 생성
- @Repository 어노테이션을 안붙여도 됨.
- JpaRepository<
Entity class
, PK type
>를 상속받음. 상속시 기본적인 CRUD 메소드가 생성됨.
4-4. User Test
-
Test code 작성
package com.test.blog.user;
import com.test.blog.user.entity.User;
import com.test.blog.user.entity.UserRepository;
import org.assertj.core.api.Assertions;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.List;
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserTest {
@Autowired
UserRepository userRepository;
@After
public void cleanUp(){
userRepository.deleteAll();
}
@Test
public void insertAndSelectUser(){
final String adminId = "ADMIN";
final String adminPassword = "ADMIN1234";
User user = User.builder()
.id(adminId)
.password(adminPassword)
.phoneNumber("000-0000-0000")
.socialSecurityNumber("123456-1234567")
.build();
userRepository.save(user);
List<User> users = userRepository.findAll();
User user1 = users.get(0);
Assertions.assertThat(user1.getId()).isEqualTo(adminId);
Assertions.assertThat(user1.getPassword()).isEqualTo(adminPassword);
}
}
-
@SpringBootTest : 별다른 설정이 없으면 H2 데이터베이스를 자동으로 실행해준다.
-
@After: 단위테스트가 끝날때 마다 실행되는 메소드. 보통 단위 테스트간의 데이터 침범을 막기위해 사용한다.
-
userRepository.save : Insert/update 쿼리를 실행한다. ID에 해당하는 Row값이 있다면 Update, 없다면 Create를 한다.
-
userRepository.findAll : 해당 테이블의 모든 Row를 가져오는 메소드
application.properties에 spring.jpa.show-sql=true를 추가하면 쿼리 로그를 확인 할 수 있다.
Author And Source
이 문제에 관하여(Spring boot X JPA), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다
https://velog.io/@jiseok/Spring-boot-X-JPA
저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념
(Collection and Share based on the CC Protocol.)
장점
- 객체 지향적으로 데이터 관리가 가능하기에 비즈니스 로직에 집중할 수 있다. 당연하게도 객체지향 중심으로 개발이 가능해진다.
- 테이블 생성, 변경, 관리가 쉽다.
- 쿼리에 집중하기 보다 객체 자체 개발에 대해서 집중할 수 있다. (쿼리를 사용하지 않기에)
- 빠른 개발이 가능하다.
단점
- 어렵고 알아야할것들이 많다.
- 잘 이해하고 사용하지 못할경우 데이터 손실 가능성이 있다. (persistence context)
- 성능상 문제가 있을 수 있다. (잘 안다면 해결이 가능하다.)
- 객체는 객체대로 설계하고, RDB는 RDB 대로 설계한다.
- ORM 프레임워크가 중간에서 맵핑해준다.
MyBatis, iBatis는 ORM이 아닌, SQL Mapper이다. SQL Mapper는 쿼리를 매핑한다.
-
Dependencies 에 다음 추가
implementation('org.springframework.boot:spring-boot-starter-data-jpa') implementation('com.h2database:h2')
- spring-boot-stater-data-jpa: Spring boot용 Spring Data JPA 추상화 라이브러리. 스프링 부트 버전에 맞춰서 자동으로 JPA관련 라이브러리 버전을 관리해준다.
- H2: 인메모리 관계형 데이터 베이스. 별도의 설치 필요없이 의존성만으로 관리가 가능하고, 메모리에서 실행되기에 어플리케이션 재시작시 초기화된다.
Linux
snap install sqlitebrowser # 실행 sqlitebrowser
Windows
- 여기 에서 다운로드 받을 수 잇습니다.
새 데이터베이스
클릭 후 원하는 DB 저장 위치에 save
데이터베이스 구조
> 테이블 생성하기
클릭TEST
라고 하고 필드에 각각 ID : INTEGER (PK)
VALUE : TEXT NOT NULL DEFAULT ""
추가 implementation('org.xerial:sqlite-jdbc') // 밑에 버전은 보안 이슈가 있었던거 같다.
SQLDialect.java 파일 생성 출처
-
Hibernate에는 SQLite를 위한 Dialect가 없다.
-
Dialect란 JPA에서는 개발자가 직접 SQL문을 작성하는게 아니라 JPA가 대신해주는데, 이때 사용하는게 Dialect이고 SQLDialect는 데이터베이스간의 SQL 문법 차이를 보정해주기위해 JPA가 제공하는 클래스이다.
-
SQLDialect.java
package com.test.blog.config;
import org.hibernate.dialect.Dialect;
import org.hibernate.dialect.function.SQLFunctionTemplate;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.dialect.function.VarArgsSQLFunction;
import org.hibernate.type.StringType;
import java.sql.Types;
public class SQLDialect extends Dialect {
public SQLDialect() {
registerColumnType(Types.BIT, "integer");
registerColumnType(Types.TINYINT, "tinyint");
registerColumnType(Types.SMALLINT, "smallint");
registerColumnType(Types.INTEGER, "integer");
registerColumnType(Types.BIGINT, "bigint");
registerColumnType(Types.FLOAT, "float");
registerColumnType(Types.REAL, "real");
registerColumnType(Types.DOUBLE, "double");
registerColumnType(Types.NUMERIC, "numeric");
registerColumnType(Types.DECIMAL, "decimal");
registerColumnType(Types.CHAR, "char");
registerColumnType(Types.VARCHAR, "varchar");
registerColumnType(Types.LONGVARCHAR, "longvarchar");
registerColumnType(Types.DATE, "date");
registerColumnType(Types.TIME, "time");
registerColumnType(Types.TIMESTAMP, "timestamp");
registerColumnType(Types.BINARY, "blob");
registerColumnType(Types.VARBINARY, "blob");
registerColumnType(Types.LONGVARBINARY, "blob");
// registerColumnType(Types.NULL, "null");
registerColumnType(Types.BLOB, "blob");
registerColumnType(Types.CLOB, "clob");
registerColumnType(Types.BOOLEAN, "integer");
registerFunction("concat", new VarArgsSQLFunction(StringType.INSTANCE, "", "||", ""));
registerFunction("mod", new SQLFunctionTemplate(StringType.INSTANCE, "?1 % ?2"));
registerFunction("substr", new StandardSQLFunction("substr", StringType.INSTANCE));
registerFunction("substring", new StandardSQLFunction("substr", StringType.INSTANCE));
}
public boolean supportsIdentityColumns() { return true; }
public boolean hasDataTypeInIdentityColumn() { return false; }
public String getIdentityColumnString() { return "integer"; }
public String getIdentitySelectString() { return "select last_insert_rowid()"; }
public boolean supportsLimit() {
return true;
}
protected String getLimitString(String query, boolean hasOffset) {
return new StringBuffer(query.length() + 20).append(query).append(hasOffset ? " limit ? offset ?" : " limit ?") .toString();
}
public boolean supportsTemporaryTables() { return true; }
public String getCreateTemporaryTableString() { return "create temporary table if not exists"; }
public boolean dropTemporaryTableAfterUse() { return false; }
public boolean supportsCurrentTimestampSelection() { return true; }
public boolean isCurrentTimestampSelectStringCallable() { return false; }
public String getCurrentTimestampSelectString() { return "select current_timestamp"; }
public boolean supportsUnionAll() { return true; }
public boolean hasAlterTable() { return false; }
public boolean dropConstraints() { return false; }
public String getAddColumnString() { return "add column"; }
public String getForUpdateString() { return ""; }
public boolean supportsOuterJoinForUpdate() { return false; }
public String getDropForeignKeyString() { throw new UnsupportedOperationException("No drop foreign key syntax supported by SQLiteDialect"); }
public String getAddForeignKeyConstraintString(String constraintName, String[] foreignKey, String referencedTable, String[] primaryKey, boolean referencesPrimaryKey) { throw new UnsupportedOperationException("No add foreign key syntax supported by SQLiteDialect"); }
public String getAddPrimaryKeyConstraintString(String constraintName) { throw new UnsupportedOperationException("No add primary key syntax supported by SQLiteDialect"); }
public boolean supportsIfExistsBeforeTableName() { return true; }
public boolean supportsCascadeDelete() { return false; }
}
application.properties 에 다음 추가 (없으면 src > main > resources > application.properties 에 생성)
spring.jpa.database-platform=com.test.blog.config.SQLDialect
spring.jpa.hibernate.ddl-auto=update
spring.datasource.url=jdbc:sqlite:TBlog.db
spring.datasource.driver-class-name=org.sqlite.JDBC
spring.datasource.username=admin
spring.datasource.password=admin
Test Entity 클래스 작성
package com.test.blog.test.entity;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Getter
@RequiredArgsConstructor
@Entity
public class Test {
@Id
@GeneratedValue
private int id;
private String value;
}
서버 구동 시 TBlog.db 파일이 생성된다.
- 해당 파일을 sqlitebrowser로 열어서 보면 다음과 같이 테이블과 컬럼들이 생성되어있다.
- 사용자 회원가입 (Insert)
- 사용자 탈퇴 (Delete)
- 사용자 조회 (Selete)
- 사용자 정보 변경 (Update)
- 사용자는 ID는 중복되지 않는다.
- 사용자 이름, 전화번호, 주민등록번호, 비밀번호를 가진다.
- 이름, 전화번호, 주민등록번호, 비밀 번호는 필수 입력 사항이다.
package com.test.blog.user.entity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
@Getter
@Entity
@NoArgsConstructor
public class User {
@Id
private String id;
@Column(length = 100, nullable = false)
private String phoneNumber;
@Column(length = 100, nullable = false)
private String socialSecurityNumber;
@Column(length = 100, nullable = false)
private String password;
@Builder
public User(String id, String phoneNumber, String socialSecurityNumber, String password){
this.id = id;
this.phoneNumber = phoneNumber;
this.socialSecurityNumber = socialSecurityNumber;
this.password = password;
}
}
- @Entity: 테이블과 링크될 클래스임을 명시. 기본적으로 카멜케이스 이름을 언더 스코어 이름으로 자동 변환하여 컬럼이 삽입된다.
- @Column: 굳이 컬럼을 추가하지 않아도 Entity에 있는 필드는 전부 컬럼이 된다. 하지만 붙이는 이유는 length 와 같은 옵션들을 추가해야할때 붙인다.
- @Builder : 해당 클래스의 빌더 메소드를 생성. 생성자 상단에 선언시 해당 생성자의 파라미터에 포함된 필드들만 빌더에 포함됨.
- 위와 같이 작성 후 서버를 실행하면 다음과 같은 DB 테이블이 만들어진다.
Entity 클래스에는 Setter 메소드를 만들지 않는다. (언제 값이 변했는지 명확하게 파악하기 힘들기 때문)
Repository 작성
package com.test.blog.user.entity;
import com.test.blog.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, String> { // 제네릭의 첫번째 타입은 Entity, 두번째 타입은 PK 타입
}
- Mybatis나 ibatis에서 DAO라고 불리는 DB 레이어 접근자.
- Repositroy는 인터페이스로 생성
- @Repository 어노테이션을 안붙여도 됨.
- JpaRepository<
Entity class
,PK type
>를 상속받음. 상속시 기본적인 CRUD 메소드가 생성됨.
Test code 작성
package com.test.blog.user;
import com.test.blog.user.entity.User;
import com.test.blog.user.entity.UserRepository;
import org.assertj.core.api.Assertions;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.List;
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserTest {
@Autowired
UserRepository userRepository;
@After
public void cleanUp(){
userRepository.deleteAll();
}
@Test
public void insertAndSelectUser(){
final String adminId = "ADMIN";
final String adminPassword = "ADMIN1234";
User user = User.builder()
.id(adminId)
.password(adminPassword)
.phoneNumber("000-0000-0000")
.socialSecurityNumber("123456-1234567")
.build();
userRepository.save(user);
List<User> users = userRepository.findAll();
User user1 = users.get(0);
Assertions.assertThat(user1.getId()).isEqualTo(adminId);
Assertions.assertThat(user1.getPassword()).isEqualTo(adminPassword);
}
}
-
@SpringBootTest : 별다른 설정이 없으면 H2 데이터베이스를 자동으로 실행해준다.
-
@After: 단위테스트가 끝날때 마다 실행되는 메소드. 보통 단위 테스트간의 데이터 침범을 막기위해 사용한다.
-
userRepository.save : Insert/update 쿼리를 실행한다. ID에 해당하는 Row값이 있다면 Update, 없다면 Create를 한다.
-
userRepository.findAll : 해당 테이블의 모든 Row를 가져오는 메소드
application.properties에 spring.jpa.show-sql=true를 추가하면 쿼리 로그를 확인 할 수 있다.
Author And Source
이 문제에 관하여(Spring boot X JPA), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@jiseok/Spring-boot-X-JPA저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)