Spring Data JPA 설정
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
데이터 저장소로 h2 사용
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
<scope>test</scope>
</dependency>
- JDBC를 지원하고 라이브러리 추가만으로 사용 가능하므로 로컬에서 개발시에 많이 쓰인다.
Spring Data JPA의 Repository 구조
스프링을 사용할때 자바 클래스들은 ApplicationContext를 사용하여 빈으로 등록하고 콘텍스트를 통해 제어하는 것처럼 ORM을 사용할때는 엔티티 매니저로 영속성 콘텍스트를 제어해서 자바 객체와 데이터베이스 간의 데이터 통신 및 동기화 제어를 수행한다.
엔티티 매니저의 기능을 보다 쉽게 사용할수 있도록 Repository 인터페이스를 제공하고 Repository 인터페이스를 확장해서 데이터베이스에 대한 기본적인 CRUD 메서드를 사용할수 있는 Repository에 대한 구현체를 사용할수 있다.
Repository
|
CrudRepository
|
PagingAndSortingRepository
|
JpaRepository
-
Repository 인터페이스에는 아무런 메서드가 없다.
-
이 인터페이스를 CrudRepository 인터페이스가 상속받아서 save, find, delete와 같은 데이터베이스 조작에 관련된 메서드를 선언한다.
-
PagingAndSortingRepository는 인터페이스로 이름처럼 paging처리를 할수 있도록 find 메서드에 파라미터로 sort 객체와 pageable 객체를 추가한것이다.
-
JpaRepository는 인터페이스로 PagingAndSortingRepository를 상속받아서 데이터베이스와 객체 간에 동기화를 할수 있는 save, saveAndFlush 메서드를 추가한 인터페이스다.
-
기본적으로 사용자는 Repository 클래스를 만들때 JpaRepository 인터페이스를 상속받으면 대부분의 기능을 구현할수도 있다.
-
이렇게 사용자가 Repository 인터페이스들을 상속받아서 사용할수 있도록 SpringData JPA의 각 인터페이스들은 공통적으로 noRepository 어노테이션이이 선언되어 있다.
-
이 어노테이션의 목적은 사용자가 정의한 Repository와 골격이 되는 SpringData Repository 인터페이스들을 구분해서 사용자가 만든 Repository들만 스프링이 인식하는 빈이 되도록 하는것이다.
데이터베이스와 객체 매핑
Entity클래스 설정
데이터베이스 스키마의 내용을 자바 클래스로 표현할수 있는 대상을 Entity클래스라고 한다.
Entity클래스 해당 클래스에 @Entity 애노테이션을 선언하는것으로 엔티티 매니저가 관리해야할 대상임을 인식시킬수 있다.
데이터베이스와 키 매핑
데이터베이스와 자바 클래스 간의 매핑 시에 가장 중요한 역할은 키값이다.
데이터베이스에서 제공하는 auto_increment, sequence와 같이 유일성을 보장하는 요소들과 키값 역할을 하는 클래스의 필드를 매핑함으로써 유일성을 보장받을수 있다.
package ee.swan.entity;
import java.time.LocalDate;
import javax.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Data
@Table("tbl_user")
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String userName;
private Integer age;
private LocalDate createAt;
@PrePersist
public void beforeCreate() {
createAt = LocalDate.now();
}
}
값 매핑
- 회원등급, 상품 유/무료 구분과 같은 코드성 데이터들에 대해서 주로 Enum을 이용해서 값을 매핑한다.
- 데이터베이스와 매핑시에는 Enum으로 선언한 후에 @Enumerated 을 사용해서 매핑하면된다.
- 일반 사용자와 관리자 구분을 하는 UserRole enum 생성
package ee.swan.entity;
public enum UserRole {
USER, //1
ADMIN //2
}
@Column(name = "role")
@Enumerated(EnumType.ORDINAL)
private UserRole role;
- @Enumerated 속성으로 @EnumType을 지정할때 ORDINAL을 하면 값이 int로 할당되고 STRING으로 하면 ENUM의 이름으로 할당된다.
- 컬럼도 테이블명과 마찬가지로 데이터베이스의 필드명과 자바 클래스의 필드명이 서로 다를 경우 @Column을 사용해서 데이터베이스 필드명을 지정해 매핑할수 있다.
UserRepository 클래스
Entity 클래스가 데이터베이스 테이블을 매핑하는 역할을 한다면 Repository는 Entity 조작에 필요한 쿼리를 메서드화 해서 사용할수 있는 역할을 한다.
JpaRepository를 상속하고 UserEntity을 조작하는 Repository를 생성한다.
package ee.swan.entity.repository;
import ee.swan.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
}
- Repository 인터페이스를 만들때 Spring Data JPA에서 제공하는 미리 만들어진 Repository 인터페이스를 상속받는것으로 준비가 끝난다.
- JpaRepository를 상속받을때 제네릭으로 첫번째 파라미터에는 UserEntity 클래스명, 두번째 파라미터에는 Long 입력했다.
JpaRepository의 선언부
public interface JpaRepository<T, ID>
자바 제네릭 Type의 약어
E - Element(요소를 의미)
K - Key(키를 의미)
N - Number(숫자를 의미)
T - Type(타입을 의미)
V - Value(값을 의미)
JpaRepository 쿼리 메서드 사용
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
UserEntity findByUserName(@Param("userName") String userName);
}
테스트 코드
package ee.swan.entity.repository;
import ee.swan.entity.UserEntity;
import ee.swan.entity.UserRole;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class UserRepositoryTest {
@Autowired
UserRepository userRepository;
@Test
public void testSave() {
userRepository.save(new UserEntity("이순신", 20, UserRole.ADMIN));
userRepository.save(new UserEntity("주몽", 30, UserRole.ADMIN));
UserEntity user = userRepository.findByUserName("주몽");
System.out.println("이름" + user.getUserName() + ", 나이: "+
user.getAge() + ", 권한: " + user.getRole() + ", 생성일 " + user.getCreateAt());
}
}
연관관계
연관(association) 관계는 하나 이상의 객체가 연결되어 있는 상태를 나타낸다.
하나의 객체가 다른 객체가 소유하거나 참조하는 형태가된다.
학교(School) ------------->(1..*) 학생(Student)
- 단방향 연관관계에서는 화살표 방향을 참조하는 클래스를 가리킨다.
- 다수성은 1:N, 1:1 등을 나타낸다
package ee.swan.entity;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import lombok.Data;
@Entity
@Data
public class School {
@Id
@GeneratedValue
@Column(name = "SCHOOL_ID")
private Long id;
private String name;
private String address;
private String telNumber;
public School(String name) {
this.name = name;
}
}
package ee.swan.entity;
import javax.persistence.*;
import lombok.Data;
@Entity
@Data
public class Student {
@Id
@GeneratedValue
@Column(name = "STUDENT\_ID")
private Long id;
private String userName;
private String grade;
@JoinColumn(name = "SCHOOL_ID")
@ManyToOne(fetch = FetchType.LAZY)
private School school;
}
학생이 N이고 학교가 1이므로 @ManyToOne을 사용하고 @JoinColumn 에서 SCHOOL_ID을 지정한다.
@ManyToOne, @OneToMany와 같은 연관관계들은 각각 다른 로딩방식을 가진다.
@ManyToOne은 즉시 로딩이 기본이다.
즉시 로딩으로 실행될때 연결된 엔티티 정보까지만 한번에 가져오라고 하므로 성능 문제가 발생할수 있다.
그래서 @ManyToOne을 사용할때는 FetchType.LAZY를 지정해 지연 로딩되도록 하는것이 좋다.
리포지터리 생성
package ee.swan.entity.repository;
import ee.swan.entity.School;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SchoolRepository extends JpaRepository<School, Long> {
}
package ee.swan.entity.repository;
import ee.swan.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
}
package ee.swan.service;
import ee.swan.entity.School;
import ee.swan.entity.Student;
import ee.swan.entity.repository.SchoolRepository;
import ee.swan.entity.repository.StudentRepository;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SchoolService {
@Autowired
private SchoolRepository schoolRepository;
@Autowired
private StudentRepository studentRepository;
@Transactional
public void findStudentInfo() {
School school = new School("백제고");
schoolRepository.save(school);
Student student1 = new Student("유신");
Student student2 = new Student("온조");
Student student3 = new Student("나라");
student1.setSchool(school);
student2.setSchool(school);
student3.setSchool(school);
studentRepository.save(student1);
studentRepository.save(student2);
studentRepository.save(student3);
List<Student> students = studentRepository.findAll();
students.stream().forEach(s -> {
System.out.println(s.getUserName() + ", " + s.getSchool().getName());
});
}
}
1:N 관계
학교를 기준으로 생각하면 1:N 관계가 되는데 기존 학생 객체를 기준으로 조회했던 데이터를 학교 객체를 이용해서 출력해본다.
School 클래스에서 Student 클래스에 대한 연관관계를 추가한다.
Student가 다수이므로 List나 Set 같은 컬렉션을 사용해 선언한다.
package ee.swan.entity;
import java.util.Set;
import javax.persistence.\*;
import lombok.Data;
@Entity
@Data
public class School {
@Id
@GeneratedValue
@Column(name = "SCHOOL_ID")
private Long id;
private String name;
private String address;
private String telNumber;
@OneToMany(mappedBy = "school")
private Set<Student> students;
public School(String name) {
this.name = name;
}
}
- mappedBy는 연관과계의 주인을 명시하기 위해 사용하는데,
- 연관관계의 주인은 다수쪽이다.
- mappedBy의 school값은 school 클래스가 아니라 Student.school이라고 생각하면 편하다.
- students에 데이터를 추가할수 있도록 학생 등록하는 메서드도 School 클래스에 추가한다.
public void registerStudent(Student student) {
if (students == null) {
students = new HashSet<>();
}
students.add(student);
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("School{");
sb.append("id=").append(id);
sb.append(", name='").append(name).append('\'');
sb.append(", address='").append(address).append('\'');
sb.append(", telNumber='").append(telNumber).append('\'');
sb.append(", students=").append(students);
sb.append('}');
return sb.toString();
}
@Transactional
public void findSchoolInfo() {
School school = new School("신라고");
school.registerStudent(new Student("신문"));
school.registerStudent(new Student("유신"));
school.registerStudent(new Student("선덕"));
School school1 = new School("고구려고");
school1.registerStudent(new Student("주몽"));
school1.registerStudent(new Student("문덕"));
school1.registerStudent(new Student("장수"));
schoolRepository.saveAll(new HashSet<>(){{
add(school);
add(school1);
}});
List<School> schools = schoolRepository.findAll();
schools.stream().forEach(s -> {
System.out.println(s.toString());
});
}
테스트코드
@Test
public void testSchoolInfo() {
schoolService.findSchoolInfo();
}
학교를 기준으로 정보가 출력된다.
QueryDSL를 이용한 Type Safe한 쿼리 작성
의존성 추가
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-core</artifactId>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
</dependency>
QueryDSLRepositorySupport 활용
Spring Data JPA에서는 querydsl을 함께 사용할수 있는 기반 클래스를 제공하는데,
그 클래스가 QueryDSLRepositorySupport이다.
그래서 Spring Data JPA를 사용할때 기존 Repository에 기능을 추가하는 형태로 개발을 진행할수 있다.
QueryDSLRepositorySupport 클래스는 추상 클래스이므로 구현체를 만들어야 한다.
구현체를 만들기 위한 3가지 절차.
- Q클래스 생성
- 커스텀 Repository 인터페이스 생성
- QueryDSLRepositorySupport 구현체 작성
커스텀 Repository 생성
1번은 빌드를
2번은 JPA에 지원하지 않는 쿼리에 대한 Repository 인터페이스를 만들기 위한 작업
검색을 지원하는 Like 메서드 추가
UserRepositoryCustom 클래스 생성
package ee.swan.entity.repository;
import java.util.List;
public interface UserRepositoryCustom {
List findAllLike(String keyword);
}
UserRepositoryCustom 인터페이스를 사용하는 구현체 작성
QueryDSLRepositorySupport는 기본생성자가 없고 생성자가 domainClass 파라미터를 필요로 한다.
public QuerydslRepositorySupport(Class<?> domainClass) {
Assert.notNull(domainClass, "Domain class must not be null!");
this.builder = (new PathBuilderFactory()).create(domainClass);
}
그래서 구현체 클래스를 만들때 동일한 형태로 작성한다.
public UserRepositoryImpl(Class<?> domainClass) {
super(domainClass);
}
부모 클래스에 대한 규격은 일치하지만 기본 생성자가 없어서 런타임시에 오류가 발생한다.
자바에서는 오버로딩이 가능하므로 생성자도 파라미터를 다르게 해서 여러개를 만들수 있다.
그런데 기본생성자를 추가하려고 하면 부모 클래스인 QueryDSLRepositorySupport에 기본 생성자가 없어서 QueryDSLRepositorySupport를 상속받는 클래스에서는 기본생성자를 만들수 없다.
기본생성자를 작성하면 오류가 발생
there is no default constructor available
엔티티 클래스에 대한 의존성이 강해서 엔티티 클래스를 생성할때 전달받지 못하면 인스턴스가 생성되어도 사용할수 없기 때문에 막혀있다.
자바에서는 super는 부모 클래스의 생성자를 호출하는 키워드다.
엔티티 클래스 타입의 파라미터가 필요한 생성자는 만들고 있는 UserRepositoryImpl 클래스가 아니라 부모 클래스인 QueryDSLRepositorySupport이므로 super 메서드를 이용해서 부모 클래스인 QueryDSLRepositorySupport 클래스의 생성자에 엔티티 클래스를 파라미터로 전달한다.
setEntityManager 메서드를 @Autowired해서 entityManager에 의존성을 주입한다.
package ee.swan.entity.repository;
import com.querydsl.jpa.JPQLQuery;
import ee.swan.entity.QUserEntity;
import ee.swan.entity.UserEntity;
import java.util.List;
import javax.persistence.EntityManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.stereotype.Repository;
@Repository
public class UserRepositoryImpl extends QuerydslRepositorySupport implements UserRepositoryCustom {
public UserRepositoryImpl() {
super(UserEntity.class);
}
@Override
@Autowired
public void setEntityManager(EntityManager entityManager) {
super.setEntityManager(entityManager);
}
@Override
public List findAllLike(String keyword) {
QUserEntity qUserEntity = QUserEntity.userEntity;
JPQLQuery<UserEntity> query = from(qUserEntity);
List<UserEntity> resultList = query.where(qUserEntity.userName.like(keyword)).fetch();
return resultList;
}
}
JPQLQuery 객체를 이용한 like 쿼리 작성
생성자 문제는 super로 해결했고, 실제로 필요한 like 기능을 JPQLQuery을 이용해 작성한다.
QUserEntity를 찾을 수 없는 경우 QClass를 만든다.
Q클래스는 도메인 클래스의 각각의 필드들을 타입 Path 예) StringPath 와 같은 타입으로 final로 저장하고 있다.
그리고 도메인 클래스의 경우에는 static final 변수로 선언되어 있다.
QUserEntity, QStudent 와 같은 Q클래스 타입으로 변수를 선언한 후에 QueryEntity.도메인 클래스명으로 값을 할당한 후 실제로 쿼리를 전송하는 역할을 하는 JPQLQeury 객체를 QueryDSLRepositorySupport에 from 메서드를 사용해서 생성한다.
JPQLQeury 객체만 생성되면 이후부터는 쿼리 표현식 메서드들을 사용해서 데이터를 처리할수 있다.
package ee.swan.entity;
import static com.querydsl.core.types.PathMetadataFactory.*;
import com.querydsl.core.types.dsl.*;
import com.querydsl.core.types.PathMetadata;
import javax.annotation.Generated;
import com.querydsl.core.types.Path;
/**
* QUserEntity is a Querydsl query type for UserEntity
*/
@Generated("com.querydsl.codegen.EntitySerializer")
public class QUserEntity extends EntityPathBase<UserEntity> {
private static final long serialVersionUID = -142250870L;
public static final QUserEntity userEntity = new QUserEntity("userEntity");
public final NumberPath<Integer> age = createNumber("age", Integer.class);
public final DatePath<java.time.LocalDate> createAt = createDate("createAt", java.time.LocalDate.class);
public final NumberPath<Long> id = createNumber("id", Long.class);
public final EnumPath<UserRole> role = createEnum("role", UserRole.class);
public final StringPath userName = createString("userName");
public QUserEntity(String variable) {
super(UserEntity.class, forVariable(variable));
}
public QUserEntity(Path<? extends UserEntity> path) {
super(path.getType(), path.getMetadata());
}
public QUserEntity(PathMetadata metadata) {
super(UserEntity.class, metadata);
}
}
where절에 qUserEntity 속성.like로 like 검색을 구현한다.
@Override
public List findAllLike(String keyword) {
QUserEntity qUserEntity = QUserEntity.userEntity;
JPQLQuery<UserEntity> query = from(qUserEntity);
List<UserEntity> resultList = query.where(qUserEntity.userName.like(keyword)).fetch();
return resultList;
}
테스트코드
@SpringBootTest
class UserRepositoryTest {
@Autowired
UserRepository userRepository;
@Autowired
UserRepositoryImpl userRepositoryImpl;
@Test
public void testJPQLQeury() {
userRepository.save(new UserEntity("이순신", 20, UserRole.ADMIN));
userRepository.save(new UserEntity("주몽", 30, UserRole.ADMIN));
userRepository.save(new UserEntity("김유신", 16, UserRole.USER));
userRepository.save(new UserEntity("최영", 40, UserRole.ADMIN));
userRepository.save(new UserEntity("강감찬", 21, UserRole.ADMIN));
userRepository.save(new UserEntity("홍길동", 34, UserRole.ADMIN));
List<UserEntity> resultList = userRepositoryImpl.findAllLike("%신%");
System.out.println("검색키워드의 검색수" + resultList.size());
resultList.stream().forEach(System.out::println);
}
}
결과
검색키워드의 검색수2
UserEntity(id=1, userName=이순신, age=20, createAt=2020-08-22, role=ADMIN)
UserEntity(id=3, userName=김유신, age=16, createAt=2020-08-22, role=USER)
집계 함수 사용
최대값, 최소값, 평균값 등 데이터에 대한 집계 연산이 필요한 경우
querydslSupport를 사용하면 Native Query를 사용하지 않고 표현식 메서드를 사용해서 값을 구할수 있다.
인터페이스인 UserRepositoryCustom에 min, max값을 구할수 있는 메서드를 추가한다.
package ee.swan.entity.repository;
import java.util.List;
public interface UserRepositoryCustom {
List findAllLike(String keyword);
int maxAge();
int minAge();
}
@Override
public int maxAge() {
QUserEntity qUserEntity = QUserEntity.userEntity;
return from(qUserEntity).select(qUserEntity.age.max()).fetchOne();
}
@Override
public int minAge() {
QUserEntity qUserEntity = QUserEntity.userEntity;
return from(qUserEntity).select(qUserEntity.age.min()).fetchOne();
}
fetchOne은 단건 조회시 사용하는 메서드다.
UserRepository에 저장된 UserEntity 데이터 중에서 age 필드의 최대값 최소값을 출력한다.
테스트
@Test
public void testSummary() {
userRepository.save(new UserEntity("이순신", 20, UserRole.ADMIN));
userRepository.save(new UserEntity("주몽", 30, UserRole.ADMIN));
userRepository.save(new UserEntity("김유신", 16, UserRole.USER));
userRepository.save(new UserEntity("최영", 40, UserRole.ADMIN));
userRepository.save(new UserEntity("강감찬", 21, UserRole.ADMIN));
userRepository.save(new UserEntity("홍길동", 34, UserRole.ADMIN));
int maxAge = userRepositoryImpl.maxAge();
int minAge = userRepositoryImpl.minAge();
System.out.println("maxAge:" + maxAge + ", minAge:" + minAge);
}
결과
maxAge:40, minAge:16
또 다른 방법
package ee.swan.config;
import com.querydsl.jpa.impl.JPAQueryFactory;
import javax.persistence.EntityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.annotation.Persistent;
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
JPAQueryFactory를 주입받아 Querydsl을 사용할수 있게 된다.
검증할 Repository를 생성한다. 기존꺼 사용
그리고 QueryDslRepository를 만든다.
package ee.swan.entity.repository;
import com.querydsl.jpa.impl.JPAQueryFactory;
import ee.swan.entity.QStudent;
import ee.swan.entity.Student;
import java.util.List;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.stereotype.Repository;
import static ee.swan.entity.QStudent.student;
@Repository
public class StudentRepositorySupport extends QuerydslRepositorySupport {
private final JPAQueryFactory jpaQueryFactory;
public StudentRepositorySupport(JPAQueryFactory jpaQueryFactory) {
super(Student.class);
this.jpaQueryFactory = jpaQueryFactory;
}
public List<Student> findByName(String name) {
return jpaQueryFactory.selectFrom(student)
.where(student.userName.eq(name))
.fetch();
}
}
빈으로 등록된 queryFactory를 생성자로 주입받아 사용한다.
QStudent.student로 사용한다.
import static를 사용하면 student로 사용할수 있다.
테스트
package ee.swan.entity.repository;
import ee.swan.entity.Student;
import java.util.List;
import org.aspectj.lang.annotation.After;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.internal.bytebuddy.matcher.ElementMatchers.is;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class StudentRepositorySupportTest {
@Autowired
private StudentRepository studentRepository;
@Autowired
private StudentRepositorySupport studentRepositorySupport;
@AfterEach
public void tearDown() throws Exception {
studentRepository.deleteAllInBatch();
}
@Test
public void testQeurydsl() {
studentRepository.save(new Student("세종대왕"));
studentRepository.save(new Student("근초대왕"));
List<Student> result = studentRepositorySupport.findByName("세종대왕");
assertEquals(result.size(), is(1));
assertEquals(result.get(0).getUserName(), is("세종대왕"));
}
}
출저 - 스프링 부트로 배우는 자바 웹 개발
저자 - 윤석진
'JAVA > Spring Boot' 카테고리의 다른 글
Spring MyBatis사용 및 설정 (1) | 2020.10.13 |
---|---|
메이븐 멀티 프로젝트 구성 (0) | 2020.09.04 |
REST API (0) | 2020.08.21 |
스프링 부트 기본설정 (0) | 2020.08.20 |
스프링부트 보안 OAuth2 (0) | 2018.04.12 |