# GCP 서비스 등록
## GCP 애플리케이션 등록
먼저, GCP에 접속하여 프로젝트를 생성한다.
API 및 서비스 > OAuth 동의 화면 을 선택한다.
앱 등록을 누르고, 위 화면 처럼 정보들을 입력해준다.
사용자 인증 정보에서 사용자 인증 정보 만들기를 누른 뒤, OAuth 클라이언트 ID를 선택한다.
어플리케이션 유형은 웹 애플리케이션으로,
이름은 적당히 정한 뒤,
승인된 리디렉션 URI를 http://localhost:8080/login/oauth2/code/google로 설정한다.
위 리디렉션 URI는 SpringBoot-Security에서 자동으로 세팅해놓은 URI로, {baseUrl}/{action}/oauth2/code/{regsitrationId}로 정의된다. 현재는 localhost에서 돌리고 있어서 localhost:8080으로 설정했지만, 나중에 aws에 배포하게 되면 url을 추가해주어야한다.
이후 OAuth 클라이언트 다운로드를 눌러서 client-id와 client-secret을 모두 받아온다.
## application-oauth 등록
spring:
security:
oauth2:
client:
registration:
google:
client-id: {client-id}
client-secret: {client-secret}
scope:
- profile
- email
scope의 default setting이 profile, email, openid인데, openid가 설정되어 있으면 Open Id Provider로 인식하게 된다.
그러면 Open Id Provider인 구글 과 그렇지 않은 네이버/카카오를 각각 나누어서 2개의 OAuth2Service를 만들어야한다.
그냥 openid를 빼버리고 Open Id Provider가 아닌 서비스로 인식하게 하여 OAuth2Service를 1개만 만들자.
spring:
profiles:
include: oauth
default 세팅 파일인 application.yml 파일에 oauth 세팅을 include해준다.
application-oauth.yml
그리고 .gitignore에 oatuh 설정을 제외하여 git에 올라가지 않도록 설정해준다.
# 구글 로그인 연동하기
## 사용자 정보 정의
package com.thuthi.springboot.domain.user;
import com.thuthi.springboot.domain.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
package com.thuthi.springboot.domain.user;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "사용자")
;
private final String key;
private final String title;
}
특이한 점으로는, SpringSecurity는 권한 코드에 항상 ROLE_이 prefix로 있어야한다는 점이다.
package com.thuthi.springboot.domain.user;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
## 스프링 시큐리티 설정
implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
package com.thuthi.springboot.config.auth;
import com.thuthi.springboot.domain.user.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
/* Rest Api 기반이므로 stateless임. 따라서 csrf설정이 필요하지 않음. */
.csrf().disable()
/* HTML삽입 취약점 방어로, iframe, object등에 삽입해서 제어하거나 클릭하는 공격을 방지하는 옵션을 삭제.
* h2 console을 spring을 통해서 확인할 때 사용함. */
.headers().frameOptions().disable()
.and()
/* url별 권한 사용 */
.authorizeHttpRequests()
.requestMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
.requestMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
return http.build();
}
}
SpringSecurity 5.7 부터는 책과 달리 WebSecurityConfigurerAdapter가 deprecated되었다.
이전 프로젝트에서는 그래도 무시하고 사용했는데, SpringSecurity 6.0 부터 완전히 삭제되었다.
그래서 블로그를 참고하여 위와 같이 코드를 수정했다.
각 코드별 설명은 주석으로 자세하게 설명했다. 나도 기억 안 날 때 봐야지..!
package com.thuthi.springboot.config.auth;
import com.thuthi.springboot.config.auth.dto.OAuthAttributes;
import com.thuthi.springboot.config.auth.dto.SessionUser;
import com.thuthi.springboot.domain.user.User;
import com.thuthi.springboot.domain.user.UserRepository;
import jakarta.servlet.http.HttpSession;
import java.util.Collections;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
/*
* registrationId: 현재 로그인 진행중인 서비스를 구분하는 코드.
* 예를 들어, google로그인과 naver로그인 2개를 지원할 때 현재 어느 서비스로 접근했는지 구분하기 위해 사용 됨.
*/
String registrationId = userRequest.getClientRegistration().getRegistrationId();
/*
* userNameAttributeName: OAuth2 로그인 진행 시 키가 되는 필드값을 의미함. 즉, PK와 같은 의미.
*/
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
.getUserNameAttributeName();
/*
* OAuthAttributes: OAuth2UserService를 통해 가져온 OAuth2User의 attribute들을 담는 클래스.
* 서비스에 무관하게 관리하기 위해 정의됨.
*/
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
/*
* SessionUser: 인증된 사용자의 정보를 담는 클래스
* User는 엔티티 그 자체이기 때문에 직렬화를 하게 될 경우, 자식들 까지 한꺼번에 직렬화 하느라 쿼리가 나갈 가능성이 있다.
* 따라서 직렬화 기능을 가진 dto를 하나 더 추가하는게 유지보수성에 좋다.
*/
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey()
);
}
/*
* 로그인 한 사용자의 정보(이름, 프로필 사진)이 변경되었을 때 자동으로 update해주기 위해 사용
*/
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
해당 클래스에서는 OAuth2.0으로 로그인 이후 가져온 사용자의 정보를 기반으로 가입 및 정보수정, 세선 저장 등의 기능을 수행한다.
특히, 기존 사용자의 정보가 변경(이름, 프로필사진)될 경우 자동으로 update하도록 메서드를 작성했다.
각 변수들의 자세한 의미는 주석으로 설명했다.
package com.thuthi.springboot.config.auth.dto;
import com.thuthi.springboot.domain.user.Role;
import com.thuthi.springboot.domain.user.User;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
/**
* OAuth2UserService를 통해 가져온 OAuth2User의 attribute들을 담는 클래스
*/
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email,
String picture) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
public static OAuthAttributes of(
String registrationId,
String userNameAttributeName,
Map<String, Object> attributes
) {
if (registrationId.equals("naver")) {
return ofNaver("id", attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(
String userNameAttributeName,
Map<String, Object> attributes
) {
return OAuthAttributes.builder()
.name((String)attributes.get("name"))
.email((String)attributes.get("email"))
.picture((String)attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
/**
* 처음 가입 될 때 사용하는 메서드
* @return 새로운 User
*/
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
package com.thuthi.springboot.config.auth.dto;
import com.thuthi.springboot.domain.user.User;
import java.io.Serializable;
import lombok.Getter;
/**
* 인증된 사용자의 정보를 담는 클래스
*/
@Getter
public class SessionUser implements Serializable {
private String name;
private String email;
private String picture;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
현재 session에 있는 User의 정보를 보관하는 별도의 dto를 정의해뒀다.
왜 굳이 dto를 따로 정의했는지 생각해보자.
위 코드를 보면 유저 정보를 json형태로 바꾸어 httpSession에 보관하게 되는데, Entity인 User를 serialize하게 될 경우 문제 발생 가능성이 너무 크다.
지금은 도메인이 매우 간단하지만, User 엔티티가 List<T>형태의 필드를 갖게 될 경우 serialize 시 필드를 가져오기 위해 쿼리가 또 날라갈 수도 있다. 즉, side effect가 발생할 가능성이 크다.
## 로그인 테스트
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
<div class="col-md-12">
<!-- 로그인 기능 영역 -->
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">
글 등록
</a>
{{#userName}}
Logged in as <span id="user">{{userName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
{{/userName}}
</div>
</div>
<!-- 목록 출력 영역-->
{{#xxx}}는 xxx라는 변수가 존재할 경우 선택적 렌더링을 하게 된다.
{{^xxx}}는 xxx라는 변수가 존재하지 않을 경우 선택적 렌더링을 하게 된다.
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if (user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
이후 서버를 켠 뒤 접속 시 정상적으로 로그인은 가능하나, 게시글 등록이 안 된다.
status code를 보면 403으로, forbidden이 나오는 것을 알 수 있다. 즉, authorization실패다.
DB에 접속하여 Role을 Guest에서 User로 바꿔주면 정상적으로 글을 등록 할 수 있다.
# 로그인 정보를 어노테이션 기반으로 가져오기
SessionUser user = (SessionUser) httpSession.getAttribute("user");
컨트롤러에서 유저의 정보가 거의 필수적으로 필요한데, 매 번 위 코드를 작성하는건 굉장히 불편하다.
즉, 중복 코드가 엄청나게 발생된다.
따라서, 어노테이션으로 중복코드를 최소화 시켜보자.
package com.thuthi.springboot.config.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
어노테이션 정의를 직접 해보는게 처음이라.. @Retention에 대해 공부해보았다.
결론은 Spring이 올라가 있을 때 아래 LoginUserArgumentResolver가 어노테이션을 보고 직접 삽입해주므로 RUNTIME으로 설정되어야 한다.
package com.thuthi.springboot.config.auth;
import com.thuthi.springboot.config.auth.dto.SessionUser;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpSession httpSession;
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
return isLoginUserAnnotation && isUserClass;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return httpSession.getAttribute("user");
}
}
package com.thuthi.springboot.config;
import com.thuthi.springboot.config.auth.LoginUserArgumentResolver;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginUserArgumentResolver);
}
}
HandelerMethodArgumentResolver는 반드시 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가되어야한다.
@GetMapping("/")
public String index(@LoginUser SessionUser user, Model model) {
model.addAttribute("posts", postsService.findAllDesc());
if (user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
# 세션 저장소로 DB 사용하기
현재 Spring을 재시작시키면 로그인이 풀리게 된다.
왜냐면.. 로그인 정보가 Tomcat 메모리에 남게 되고, 메모리에서 내려가게 되면 삭제되기 때문이다.
뿐만 아니라, 만약 2대 이상의 서버에서 서비스하게 되면 Tomcat끼리 메모리를 동기화 시켜야한다. 오버헤드가 굉장히 크고 문제가 발생할 수 있는 방법이다.
따라서, Redis나 Memcached같은 인메모리DB를 사용하여 Redis용 메모리서버를 사용하거나
MySQL같은 데이터베이스를 세션저장소로 사용한다.
실제 서비스를 런칭할 경우 Redis를 사용해야겠지만, 학습하는 단계에서 redis를 사용하기에는 많이 어려우므로 MySQL를 사용하여 세션을 유지시켜보자.
implementation('org.springframework.session:spring-session-jdbc')
spring:
session:
store-type: jdbc
jdbc:
initialize-schema: always
이렇게 설정하기만 하면 끝이다...! 책에서는 h2를 사용하기 때문에 jdbc.initialize-schema를 설정해주지 않는데, 나는 MySQL을 사용했기 때문에 설정해주지 않으면 table이 생성되지 않는다.
# 네이버 로그인 연동하기
이렇게 세팅해주고 나면
이렇게 client-id와 client-secret을 던져준다.
spring:
security:
oauth2:
client:
registration:
naver:
client-id: {client-id}
client-secret: {client-secret}
redirect-uri: '{baseUrl}/{action}/oauth2/code/{registrationId}'
authorization-grant-type: authorization_code
scope:
- name
- email
- profile_mage
client-name: Naver
provider:
naver:
authorization-uri: 'https://nid.naver.com/oauth2.0/authorize'
token-uri: 'https://nid.naver.com/oauth2.0/token'
user-info-uri: 'https://openapi.naver.com/v1/nid/me'
user-name-attribute: response
책에서는 .properties로 진행하기 때문에 문제가 없는 듯 하나, yml로 진행 시 url을 반드시 따옴표로 감싸주어야한다.
url에 포함되어 있는 슬래쉬(/)를 인식하기 때문에 url을 제대로 파싱하지 못 하여 에러가 발생한다.
provider.naver.user-name-attributere가 따로 설정되어 있는데,
{
"resultcode": "00",
"message": "success",
"response": {
"email": "openapi@naver.com",
"nickname": "OpenAPI",
"profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif",
"age": "40-49",
"gender": "F",
"id": "32742776",
"name": "오픈 API",
"birthday": "10-01",
"birthyear": "1900",
"mobile": "010-0000-0000"
}
}
Naver 로그인 API 응답
SpringSecurity에서는 반드시 최상위 필드만 user_name으로 지정이 가능하다. 따라서 response를 최상위 필드로 인식하게 해주어야한다.
## 스프링 시큐리티 설정 등록
public class OAuthAttributes {
public static OAuthAttributes of(
String registrationId,
String userNameAttributeName,
Map<String, Object> attributes
) {
if (registrationId.equals("naver")) {
return ofNaver("id", attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofNaver(
String userNameAttributeName,
Map<String, Object> attributes
) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name((String)response.get("name"))
.email((String)response.get("email"))
.picture((String)response.get("profile_image"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
{{/userName}}
# 기존 테스트에 시큐리티 적용하기
SpringSecurity를 적용하지 않았던 프로젝트에 Security를 적용하고나면 테스트 코드에 많은 수정이 필요하다.
이렇게 전체 Test를 한 번 돌려보면
거의 대부분의 test가 실패함을 알 수 있다.
## 문제1. CustomOAuth2UserService를 찾을 수 없음.
main환경과 test환경의 설정 파일이 서로 로딩되지 않기 때문에 발생하는 문제다.
application.yml을 제외한 나머지 설정 파일들은 서로 동기화되지 않기 때문에 따로 application-oauth.yml을 설정해주어야한다.
spring:
profiles:
include: oauth
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/springaws
username: root
password: whxnxl55;;
jpa:
hibernate:
ddl-auto: create
show-sql: true
session:
store-type: jdbc
jdbc:
initialize-schema: always
security:
oauth2:
client:
registration:
google:
client-id: test
client-secret: test
scope:
- profile
- email
server:
servlet:
encoding:
force-response: true
## 문제2. 302 Status Code
302(리다이렉션) status code가 리턴되어 문제가 발생된다. 로그인이 되지 않은 상태로 요청을 하기 때문에 로그인 페이지로 리다이렉션 되기 때문이다.
implementation('org.springframework.security:spring-security-test')
@Test
@WithMockUser(roles = "USER")
public void Posts_등록된다() throws Exception {
...
}
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class PostsApiControllerTest {
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@BeforeEach
void beforeAll() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@Test
@WithMockUser(roles = "USER")
public void Posts_등록된다() throws Exception {
// given
...
// when
mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
// then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(title);
assertThat(all.get(0).getContent()).isEqualTo(content);
}
@Test
@WithMockUser(roles = "USER")
public void Posts_수정된다() throws Exception {
// given
...
// when
mvc.perform(put(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
// then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
@WithMockUser로 role을 정한 유저로 로그인 한 것 처럼 하여 동작할 수 있다.
그런데, @WithMockUser는 MockMvc에서만 동작하기 때문에 MockMvc를 사용하여 테스트해야한다.
다만, SpringBootTest를 WebMvcTest로 변경하게 되면 Controller만 동작하기 때문에, 변경하지 않고 MockMvc를 사용해야한다.
## 문제3. WebMvcTest에서 CustomOAuth2UserService를 찾을 수 없음
WebMvcTest는 Service와 Repository, Component는 스캔 대상이 아니다. 따라서, SpringConfig는 스캔 되지만, SpringConfig에서 스캔되지않는 CustomOAuth2UserService를 주입받길 원하기 때문에 에러가 난다. 따라서, SpringConfig를 스캔 대상에서 제외시켜주면 된다.
그리고, 마찬가지로 @WithMockUser도 붙혀준다.
@ExtendWith(SpringExtension.class)
@WebMvcTest(
controllers = HelloController.class,
excludeFilters = {
@Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
}
)
public class HelloControllerTest {
...
@WithMockUser(roles = "USER")
@Test
public void hello가_리턴된다() throws Exception {
}
...
}
## 문제4. JPA metamodel must be present
@EnableJpaAuditing으로 인해 발생한다. @EnableJpaAuditing를 사용하려면 적어도 하나의 Entity 클래스가 필요한데, @WebMvcTest는 Entity를 스캔하지 않는다.
@EnableJpaAuditing이 @SpringBootApplication과 함께 있다보니 @WebMvcTest가 스캔하게 된다. 따라서, 둘을 분리해줘야한다.
//@EnableJpaAuditing
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
package com.thuthi.springboot.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing
@Configuration
public class JpaConfig {
}