1 스프링 부트 시큐리티 퀵 스타트
인증(Authentication)을 통해 사용자를 식별하고 인가(Authorization)를 통해 시스템 자원에 대한 접근을 통제한다. 직원이 특정 자원에 접근할 때 권한이 있는지 확인하는 과정을 인가라고 생각하면 된다. 스프링 시큐리티가 인증과 인가를 어떻게 처리하는지 확인해보자.
[스프링 부트 시큐리티 적용하기]
스프링 시큐리티 개념
인증과 인가 관련 코드를 모든 클래스의 메소드마다 적용하면 반복적인 코드들이 여러 곳에 등장하게 된다. 유지보수 과정에서 시큐리티 관련 코드를 수정하려고 할 떄 또다시 반복적인 작업을 할 수밖에 없으므로 이러한 문제 해결을 위해 스프링 시큐리티가 만들어졌다.
시큐리티를 적용하지 않았을 때
프로젝트 생성 사용 모듈 : DevTools, H2, JPA, Lombok, Thymeleaf, Web
테스트용 HTML 페이지 생성 후 조회
스프링 부트로 프로젝트 생성시, 시큐리티 스타터를 추가하지 않으면 시큐리티 관련 자동설정이 동작하지 않는다. 사용자는 웹 애플리케이션이 제공하는 모든 자원에 아무런 제약없이 접근 가능하다.
시큐리티를 적용했을 때
pom. xml에spring-security-test를 추가한다.
시큐리티 스타터 추가하면 시큐리티 관련 의존성이 추가되고 관련된 자동 설정들도 동작한다. 애플리케이션을 재시작하면 메모리에 인증에 필요한 사용자가 자동으로 등록.
스프링 부트는기본으로 INFO 레벨 이상의 로그만 출력되므로 더 많은 로그를 확인하고 싶다면 DEBUG 로 설정 변경
application.properties
# DatsSource Setting
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
# JPA Setting
spring.jpa.hibernate.ddl-auto=update
spring.jpa.generate-ddl=false
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# JPA 구현체로 사용할 하이버네이트가 생성한 sql을 콘솔에 출력할지 여부
spring.jpa.properties.hibernate.format_sql=true
# Logging Setting
logging.level.org.hibernate=info
# ViewResolver Setting
spring.mvc.view.prefix=/WEB-INF/board/
spring.mvc.view.suffix=.jsp
# Thymeleaf Cache Setting
spring.thymeleaf.cache=false
# Security log level Setting
logging.level.org.springframwork.security=debug
[시큐리티 커스터마이징하기]
시큐리티 설정 파일 작성하기
@EnableWebSecurity : 클래스가 시큐리티 설정 파일임을 의미. 시큐리티에 필요한 객체들 생성
WebSecurityConfigurerAdapter : 웹 시큐리티와 다양한 설정을 추가할 수 있는 configure 메소드. 오버라이딩해서 커스터마이징 가능
HttpSecurity : 애플리케이션 자원에 대한 인증과 인가 제어 가능
스프링 시큐리티 관련 환경설정 클래스
package com.studyboot.config;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@EnableWebSecurity //이 클래스로 생성된 객체는 시큐리티 설정 파일을 의미. 동시에 시큐리티에 필요한 객체들을 생성
public class SecurityConfig extends WebSecurityConfigurerAdapter{
//WebSecurityConfigurerAdapter 클래스를 빈으로 설정하기만 해도 애플리케이션은 로그인을 강제하지 않음
@Override //시큐리티와 관련된 설정시 configure 메소드 사용
protected void configure(HttpSecurity http) throws Exception {
// 애플리케이션 자원에 대한 인증과 인가 제어 가능.
super.configure(http);
}
}
시큐리티 화면 구성하기
① 컨트롤러 작성
package com.studyboot.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SecurityController {
@GetMapping("/")
public String index() {
System.out.println("index 요청"); // 모든 사용자 가능
return "index";
}
@GetMapping("/member")
public String member() {
System.out.println("member 요청"); // 인증 통과한 사용자 가능
return "member";
}
@GetMapping("/manager")
public String manager() {
System.out.println("manager 요청"); // 인증을 통과한 manager 권한 사용자 가능
return "manager";
}
@GetMapping("/admin")
public String admin() {
System.out.println("admin 요청"); // 인증을 통과한 admin 권한 사용자 가능
return "admin";
}
}
② view 페이지 생성
index.html
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>시큐리티 테스트</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<h3 align="center">인덱스 화면입니다.</h3>
</body>
</html>
member.html
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>시큐리티 테스트</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<h3 align="center">로그인 성공한 사용자만 접근할 수 있는 화면입니다.</h3>
<a th:href="@{/loginSuccess}">뒤로가기</a>
</body>
</html>
manager.html
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>시큐리티 테스트</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<h3 align="center">Manager 사용자만 접근할 수 있는 화면입니다.</h3>
<a th:href="@{/loginSuccess}">뒤로가기</a>
</body>
</html>
admin.html
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>시큐리티 테스트</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<h3 align="center">Admin 사용자만 접근할 수 있는 화면입니다.</h3>
<a th:href="@{/loginSuccess}">뒤로가기</a>
</body>
</html>
③ 시큐리티 재정의
package com.studyboot.config;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@EnableWebSecurity //이 클래스로 생성된 객체는 시큐리티 설정 파일을 의미. 동시에 시큐리티에 필요한 객체들을 생성
public class SecurityConfig extends WebSecurityConfigurerAdapter{
//WebSecurityConfigurerAdapter 클래스를 빈으로 설정하기만 해도 애플리케이션은 로그인을 강제하지 않음
@Override //시큐리티와 관련된 설정시 configure 메소드 사용
protected void configure(HttpSecurity security) throws Exception {
// 애플리케이션 자원에 대한 인증과 인가 제어 가능.
// authorizeRequests 사용자 인증과 권한 설정
// antMatchers AuthorizedUrl 반환
security.authorizeRequests().antMatchers("/").permitAll();
security.authorizeRequests().antMatchers("/member/**").authenticated();
security.authorizeRequests().antMatchers("/manager/**").hasRole("MANAGER");
security.authorizeRequests().antMatchers("/admin/**").hasRole("ADMIN");
security.csrf().disable();
/*
//빌더 패턴을 사용하므로 아래와 같이 작성가능
security.authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers("/member/**").authenticated()
.antMatchers("/manager/**").hasRole("MANAGER")
.antMatchers("/admin/**").hasRole("ADMIN");
security.csrf().disable();
*/
security.formLogin(); // 사용자에게 form 태그 기반의 로그인 화면 표시
security.formLogin()
.loginPage("/login") // 로그인시 사용할 화면 별도 지정.
.defaultSuccessUrl("/loginSuccess", true); //로그인 성공시 이동할 url 지정
}
}
사용자 인증하기
①컨트롤러 생성
package com.studyboot.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public void login() {
//로그인 페이지로 이동시켜주는 메소드
}
@GetMapping("/loginSuccess")
public void loginSuccess() {
//로그인 성공 페이지로 이동시켜주는 메소드
}
}
② 뷰 페이지 생성
login.html
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>시큐리티 테스트</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body th:align="center">
<h3 align="center">로그인</h3>
<form method="post">
<table th:align="center" border="1" th:cellpadding="0" th:cellspacing="0">
<tr>
<td bgcolor="#e4e4e4">아이디</td>
<td><input type="text" name="username"/></td>
</tr>
<tr>
<td bgcolor="#e4e4e4">비밀번호</td>
<td><input type="password" name="password"/></td>
</tr>
<tr>
<td colspan="2" align="center"><input type="submit" value="로그인"/></td>
</tr>
</table>
</form>
</body>
</html>
loginSuccess.html
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>시큐리티 테스트</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<h3 align="center">로그인 인증 성공!</h3>
<hr>
<a th:href="@{/}">INDEX 페이지 이동</a>
<a th:href="@{/member}">MEMBER 페이지 이동</a>
<a th:href="@{/manager}">MANAGER 페이지 이동</a>
<a th:href="@{/admin}">ADMIN 페이지 이동</a>
</body>
</html>
메모리 사용자 인증하기
@EnableWebSecurity //이 클래스로 생성된 객체는 시큐리티 설정 파일을 의미. 동시에 시큐리티에 필요한 객체들을 생성
public class SecurityConfig extends WebSecurityConfigurerAdapter{
// 생략
// 인증에 필요한 사용자 정보 생성
@Autowired //AuthenticationManagerBuilder 객체를 의존성 주입받음
public void authenticate(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication() // 메모리에 사용자 정보를 생성하는 메소드
.withUser("manager") // 사용자 아이디
.password("{noop}manager123") // 비밀번호. {noop}은 암호화 처리를 하지 않음
.roles("MANAGER"); // 권한 설정
auth.inMemoryAuthentication()
.withUser("admin")
.password("{noop}admin123")
.roles("ADMIN");
}
}
접근 권한 없음 페이지 처리
①Securityconfig 수정
security.formLogin()
.loginPage("/login") // 로그인시 사용할 화면 별도 지정.
.defaultSuccessUrl("/loginSuccess", true); //로그인 성공시 이동할 url 지정
security.exceptionHandling() //ExceptionHandlingConfigurer 객체 리턴
.accessDeniedPage("accessDenied"); // 인증되지 않은 사용자에게 제공할 url 지정
}
②컨트롤러 수정
@GetMapping("/accessDenied")
public String accessDenied() {
return "accessDenied";
}
③뷰 페이지 생성
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>접근권한 에러</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<h3 align="center">페이지에 대한 접근권한이 없습니다...</h3>
<a th:href="@{/login}">다시 로그인 시도하기</a>
</body>
</html>
로그아웃 처리하기
①SecurityConfig 수정
security.logout().invalidateHttpSession(true) // 현재 브라우저와 연관된 세션을 강제종료
.deleteCookies() // 쿠키 삭제
.logoutSuccessUrl("/login"); // 로그아웃 후 이동할 화면 리다이렉트
}
특정 권한 페이지 생성
manager.html
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>시큐리티 테스트</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<h3 align="center">Manager 사용자만 접근할 수 있는 화면입니다.</h3>
<a th:href="@{/loginSuccess}">뒤로가기</a>
<form action="logout" method="get">
<input type="submit" value="로그아웃"/>
</form>
</body>
</html>
2 시큐리티 이해 및 데이터베이스 연동
[스프링 시큐리티 동작 원리]
시큐리티 원리 이해하기 - 시큐리티 필터
서블리 필터는 클라이언트의 요청을 가로채서 서블릿이 수행되기 전후에 전처리와 후처리를 수행하거나 요청을 리다이렉트하는 용도로 사용된다. 여러 개의 필터가 필요한 경우에는 필터 체인을 형성하여 사용한다.
시큐리티 원리 이해하기 - 스프링 시큐리티 동작 원리 // TODO 더 공부해서 첨삭 예정
데이터베이스 연동하기
CREATE TABLE MEMBER (
ID VARCHAR2(10) PRIMARY KEY,
PASSWORD VARCHAR2(100),--비밀번호 암호화를 고려하기 길이 설정
NAME VARCHAR2(30),
ROLE VARCHAR2(12),
ENABLED BOOLEAN --사용자 계정정보 활성화 여부. 스프링 시큐리티에서 사용자를 삭제 안해도 비활성화 가능
);
INSERT INTO MEMBER(ID ,PASSWORD ,NAME ,ROLE ,ENABLED) VALUES('member', 'member123', '회원', 'ROLE_MEMBER', TRUE);
INSERT INTO MEMBER(ID ,PASSWORD ,NAME ,ROLE ,ENABLED) VALUES('manager', 'manager123', '매니저', 'ROLE_MANAGER', TRUE);
INSERT INTO MEMBER(ID ,PASSWORD ,NAME ,ROLE ,ENABLED) VALUES('admin', 'admin123', '관리자', 'ROLE_ADMIN', TRUE);
@EnableWebSecurity //이 클래스로 생성된 객체는 시큐리티 설정 파일을 의미. 동시에 시큐리티에 필요한 객체들을 생성
public class SecurityConfig extends WebSecurityConfigurerAdapter{
//생략
@Autowired
private DataSource dataSource;
@Autowired //AuthenticationManagerBuilder 객체를 의존성 주입받음
public void authenticate(AuthenticationManagerBuilder auth) throws Exception {
//사용자 정보 조회 쿼리
String query1 = "select id username, concat('{noop}', password) password, true enabled from member where id = ?";
//사용자 권한 조회 쿼리
String query2 = "select id, role from member where id = ?";
auth.jdbcAuthentication() //데이터베이스에 저장된 사용자로 인증처리 하는 메소드
.dataSource(dataSource)
.usersByUsernameQuery(query1)//사용자 정보의 컬럼명이 username과 password 와 일치해야 자동 매핑
.authoritiesByUsernameQuery(query2);//인증 성공과 접근권한이 있는 사용자의 경우 리소스접근 가능
}
}
[JPA 연동하기]
1. 엔티티 클래스와 리포지터리 작성
package com.studyboot.domain;
public enum Role {
//회원이 가질 수 있는 권한 세가지
ROLE_MEMBER, ROLE_MANAGER, ROLE_ADMIN
}
package com.studyboot.domain;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.Id;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
@Entity
public class Member {
@Id
private String id;
private String name;
private String password;
@Enumerated(EnumType.STRING)
private Role role;
private boolean enabled;
}
package persistence;
import org.springframework.data.repository.CrudRepository;
import com.studyboot.domain.Member;
public interface MemberRepository extends CrudRepository<Member, String>{
}
2. UserDetails 객체 생성
package com.studyboot.domain;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
public class SecurityUser extends User{
private static final long serialVersionUID = 1L;
public SecurityUser(Member member) {
//User 클래스의 생성자 호출 시 검색결과로 얻은 member 객체의 값 전달
super(member.getId(), "{noop}"+member.getPassword(),
AuthorityUtils.createAuthorityList(member.getRole().toString()));
}
}
3. 사용자 정의 UserDetailsService 구현
package com.studyboot.config;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.studyboot.domain.Member;
import com.studyboot.domain.SecurityUser;
import com.studyboot.persistence.MemberRepository;
@Service
public class BoardUserDetailService implements UserDetailsService {
@Autowired
private MemberRepository memberRepo;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// MemberRepository 로 회원정보 조회하여 UserDetails 타입으로 반환
Optional<Member> optional = memberRepo.findById(username);
if(!optional.isPresent()) {
throw new UsernameNotFoundException("username(" + username + ")사용자 없음.");
} else {
Member member = optional.get();
return new SecurityUser(member);
}
}
}
4. 시큐리티 설정 객체에 사용자 정의 UserDetailsService 적용
@EnableWebSecurity //이 클래스로 생성된 객체는 시큐리티 설정 파일을 의미. 동시에 시큐리티에 필요한 객체들을 생성
public class SecurityConfig extends WebSecurityConfigurerAdapter{
//WebSecurityConfigurerAdapter 클래스를 빈으로 설정하기만 해도 애플리케이션은 로그인을 강제하지 않음
@Autowired
private BoardUserDetailService boardUserDetailService;
@Override //시큐리티와 관련된 설정시 configure 메소드 사용
protected void configure(HttpSecurity security) throws Exception {
// 애플리케이션 자원에 대한 인증과 인가 제어 가능.
// authorizeRequests 사용자 인증과 권한 설정
// antMatchers AuthorizedUrl 반환
security.authorizeRequests().antMatchers("/").permitAll();
security.authorizeRequests().antMatchers("/member/**").authenticated();
security.authorizeRequests().antMatchers("/manager/**").hasRole("MANAGER");
security.authorizeRequests().antMatchers("/admin/**").hasRole("ADMIN");
security.csrf().disable();
security.formLogin(); // 사용자에게 form 태그 기반의 로그인 화면 표시
security.formLogin()
.loginPage("/login") // 로그인시 사용할 화면 별도 지정.
.defaultSuccessUrl("/loginSuccess", true); //로그인 성공시 이동할 url 지정
security.exceptionHandling() //ExceptionHandlingConfigurer 객체 리턴
.accessDeniedPage("/accessDenied"); // 인증되지 않은 사용자에게 제공할 url 지정
security.logout().invalidateHttpSession(true) // 현재 브라우저와 연관된 세션을 강제종료
.deleteCookies() // 쿠키 삭제
.logoutSuccessUrl("/login"); // 로그아웃 후 이동할 화면 리다이렉트
security.userDetailsService(boardUserDetailService);
}
}
[PasswordEncoder 사용하기]
비밀번호 암호화
사용자의 비밀번호를 암호화하지 않은 평문으로 저장하고 사용하는 것은 보안에 치명적인 문제이다. 이전에는 비밀번호에 대한 암호화를 사용하지 않기 위해서 비밀번호 앞에 지속적으로 {noop} 접두사를 붙여 사용했다.
public class PasswordEncoderFactories {
@SuppressWarnings("deprecation")
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
private PasswordEncoderFactories() {}
}
스프링 시큐리티는 패스워드를 쉽게 암호화할 수 있는 PasswordEncoder라는 인터페이스를 구현한 클래스들을 제공한다.
createDelegatingPasswordEncoder
public static PasswordEncoder createDelegatingPasswordEncoder()
Creates a DelegatingPasswordEncoder with default mappings. Additional mappings may be added and the encoding will be updated to conform with best practices. However, due to the nature of DelegatingPasswordEncoder the updates should not impact users. The mappings current are:
bcrypt - BCryptPasswordEncoder (Also used for encoding)
ldap - LdapShaPasswordEncoder
MD4 - Md4PasswordEncoder
MD5 - new MessageDigestPasswordEncoder("MD5")
noop - NoOpPasswordEncoder
pbkdf2 - Pbkdf2PasswordEncoder
scrypt - SCryptPasswordEncoder
SHA-1 - new MessageDigestPasswordEncoder("SHA-1")
SHA-256 - new MessageDigestPasswordEncoder("SHA-256")
sha256 - StandardPasswordEncoder
argon2 - Argon2PasswordEncoder
Returns: the PasswordEncoder to use
암호화 적용
1. SecurityConfig 클래스에 BCryptPasswordEncoder 객체를 리턴하는 passwordEncoder 메소드를 추가한다.
SecurityConfig.class
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
2. passwordEncoder 테스트 진행
package com.studyboot;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.annotation.Commit;
import org.springframework.test.context.junit4.SpringRunner;
import com.studyboot.domain.Member;
import com.studyboot.domain.Role;
import com.studyboot.persistence.MemberRepository;
@RunWith(SpringRunner.class)
@SpringBootTest
@Commit
public class PasswordEncoderTest {
@Autowired
private MemberRepository memberRepo;
@Autowired
private PasswordEncoder encoder; // @Bean으로 등록한 PasswordEncoder 의존성 주입
@Test
public void testInsert() {
Member member = new Member();
member.setId("manager2");
member.setPassword(encoder.encode("manager456")); // 입력한 비밀번호를 인코딩하여 저장
member.setName("매니저2");
member.setRole(Role.ROLE_MANAGER);
member.setEnabled(true);
memberRepo.save(member);
}
}
3. 테스트 결과
package com.studyboot.domain;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
public class SecurityUser extends User{
private static final long serialVersionUID = 1L;
public SecurityUser(Member member) {
//User 클래스의 생성자 호출 시 검색결과로 얻은 member 객체의 값 전달
super(member.getId(), member.getPassword(), //비밀번호의 접두사 {noop} 제거
AuthorityUtils.createAuthorityList(member.getRole().toString()));
}
}
애플리케이션을 다시 구동하고 manager2 로그인 처리 시 사용자의 비밀번호를 입력하여 로그인 시 암호화 인코딩이 되어 정상적으로 작동한다.
참고서적 : 누구나 끝까지 따라 할 수 있는 스프링 부트 퀵스타트
'Dev > SpringBoot' 카테고리의 다른 글
[SpringBoot] 웹 애플리케이션 통합 (0) | 2020.01.04 |
---|---|
[Springboot] 스프링부트 화면 개발 (0) | 2020.01.01 |
[SpringBoot] 스프링 데이터 JPA (0) | 2019.12.28 |
[SpringBoot] JPA 퀵 스타트 (0) | 2019.12.23 |
[SpringBoot] 테스트와 로깅 (0) | 2019.12.18 |