https://console.cloud.google.com/
구글 로그인 연동
1. oauth2 의존성 추가
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
2. 어플리케이션 설정에 oatuh2 설정 추가한 어플리케이션 설정파일 새로 만들기 - apllication-oauth.properties
spring.security.oauth2.client.registration.google.client-id=생성된 클라이언트 아이디
spring.security.oauth2.client.registration.google.client-secret=생성된 클라이언트 비밀번호
spring.security.oauth2.client.registration.google.scope=email
3. 추가된 파일을 포함해 동작하도록 설정
spring.profiles.include=oauth
4. SecurityConfig 클래스 수정
: HttpSecurity 설정에서 oauth2Login() 부분 추가
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((auth) -> {
auth.antMatchers("/sample/all").permitAll();
auth.antMatchers("/sample/member").hasRole("USER");
});
//인가/인증 절차에서 문제 발생시 권한 획득을 유도하는 페이지 리턴
http.formLogin();
//csrf 토큰 비활성화
http.csrf().disable();
//스프링 시큐리티에서 제공하는 로그아웃 처리
http.logout();
//oauth2 로그인을 위한 메서드 추가
http.oauth2Login();
return http.build();
}
소셜 로그인을 통한 사용자 정보 연동
- 소셜 로그인 처리 시에 사용자 이메일 정보 추출
- loadUser()을 이용해 사용자 이메일 추출
- 현재 데이터베이스와 연동해 사용자 정보 관리
- loadUser()의 파라미터나 리턴타입을 변환해 원하는 정보 추출
- 기존 방식 로그인과 소셜 로그인 모두 동작하도록 설정
- 이메일을 이용한 회원가입 처리
소셜 로그인의 핵심 인터페이스
: OAuth2UserService
-> OAuth 버전의 UserDetailsService로 OAuth 인증 결과의 처리를 담당한다.
OAuth2UserService 인터페이스가 구현하는 클래스 목록
: CustomUserTypesOAuth2UserService, DefaultOAuth2UserService, DelegatingOAuth2UserService, OidcUserService
(1) 구현클래스 중 DefaultOAuth2UserService 클래스를 상속해 ClubOAuth2UserDetailsService 클래스를 작성
: @Log4j2를 이용해 먼저 동작여부를 파악한다.
ClubOAuth2UserDetailsService에서 DefaultOAuth2UserService의 loadUser() 메서드 오버라이딩
package com.club.boot5.security.service;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
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.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
@Log4j2
//@Service 어노테이션이 스프링의 빈으로 자동 등록
@Service
public class ClubOAuth2UserDetailsService extends DefaultOAuth2UserService {
//DefaultOAuth2UserService의 loadUser() 메서드 오버라이딩
// public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//
// return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
// }
// }
// }
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)throws OAuth2AuthenticationException{
log.info("--------------");
log.info("userRequest : "+ userRequest);
return super.loadUser(userRequest);
//OAuth2UserRequest 타입의 파라미터와 OAuth2User라는 타입의 리턴타입을 반환하는데
//기존의 로그인 처리에 사용하던 파라미터와 리턴타입의 불일치 문제 발생
//-> 변환해서 처리 필요
}
}
: 하지만, loadUser()메서드의 파라미터와 반환타입이 기존 로그인의 타입과의 불일치 문제 -> 변환해서 사용
(2) loadUser() / loadUserByUsername() 메서드의 파라미터와 리턴타입 변환을 통한 일치작업 수행
파라미터 | 리턴타입 | |
loadUserByUsername | String username | UserDetails |
loadUser | OAuth2UserRequest userRequest | OAuth2User |
: 사용자의 이메일을 추출해 String username으로 변환한다.
OAuth2UserRequest는 어떤 서비스를 통해 로그인했는지 Map<String, Object>형태로 전달된 값의 데이터를 추출한다.
->최대한 많은 정보를 조회하도록 loadUser() 메서드를 수정
변경 전의 loadUser()
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)throws OAuth2AuthenticationException{
log.info("--------------");
log.info("userRequest : "+ userRequest);
return super.loadUser(userRequest);
//OAuth2UserRequest 타입의 파라미터와 OAuth2User라는 타입의 리턴타입을 반환하는데
//기존의 로그인 처리에 사용하던 파라미터와 리턴타입의 불일치 문제 발생
//-> 변환해서 처리 필요
}
변경 후의 loadUser()
@Log4j2
//@Service 어노테이션이 스프링의 빈으로 자동 등록
@Service
public class ClubOAuth2UserDetailsService extends DefaultOAuth2UserService {
//DefaultOAuth2UserService의 loadUser() 메서드 오버라이딩
// public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//
// return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
// }
// }
// }
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)throws OAuth2AuthenticationException{
log.info("--------------");
log.info("userRequest : "+ userRequest);
String clientName = userRequest.getClientRegistration().getClientName();
log.info("clientName : "+ clientName);
//additionalParameters () 메소드를 사용해 맵을 전달 하여 OAuth2AuthorizationRequest에 매개변수를 추가한다.
//즉, 최대한 많은 정보를 얻기 위해 사용하는 메서드
log.info(userRequest.getAdditionalParameters());
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("===============");
//키, 값으로 이루어진 Map<String, Object>형태로 데이터를 전달하므로
oAuth2User.getAttributes().forEach((k,v)->{
log.info(k+":"+v);
});
//return super.loadUser(userRequest);
return oAuth2User;
//OAuth2UserRequest 타입의 파라미터와 OAuth2User라는 타입의 리턴타입을 반환하는데
//기존의 로그인 처리에 사용하던 파라미터와 리턴타입의 불일치 문제 발생
//-> Map 자료구조를 통해 변환 수행
}
}
- OAuth로 연결한 클라이언트 이름과 사용한 파라미터를 출력
- 처리 결과로 나오는 OAuth2User 객체 내부의 값들을 확인
- 구글에 등록한 프로젝트의 API범위에 따라 객체 내부의 값들이 결정된다.
- sub, picture, email, email_verified 항목 출력
(3) 소셜 이메일을 통한 회원 가입 처리
: OAuth2User로 알아낸 이메일 주소로 데이터베이스에 추가하는 작업 진행
-> 패스워드 문제 발생
먼저, DB에 소셜 이메일을 저장하는 로직 구현 순서
- ClubOAuth2UserDetailsService 클래스의 loadUser() 메서드를 필요한 객체를 주입받는 구조로 변경
-> saveSocialMember() 메서드 작성 - 회원가입 여부 파악 후, 소셜로그인 여부를 확인해
-> saveSocialMember() 메서드를 통해 ClubMemberRepository에서 소셜 로그인한 이메일 처리
ClubOAuth2UserDetailsService 소셜 로그인 회원가입 처리
변경 전의 loadUser() 메서드
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)throws OAuth2AuthenticationException{
log.info("--------------");
log.info("userRequest : "+ userRequest);
String clientName = userRequest.getClientRegistration().getClientName();
log.info("clientName : "+ clientName);
//additionalParameters () 메소드를 사용해 맵을 전달 하여 OAuth2AuthorizationRequest에 매개변수를 추가한다.
//즉, 최대한 많은 정보를 얻기 위해 사용하는 메서드
log.info(userRequest.getAdditionalParameters());
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("===============");
//키, 값으로 이루어진 Map<String, Object>형태로 데이터를 전달하므로
oAuth2User.getAttributes().forEach((k,v)->{
log.info(k+":"+v);
});
//return super.loadUser(userRequest);
return oAuth2User;
//OAuth2UserRequest 타입의 파라미터와 OAuth2User라는 타입의 리턴타입을 반환하는데
//기존의 로그인 처리에 사용하던 파라미터와 리턴타입의 불일치 문제 발생
//-> Map 자료구조를 통해 변환 수행
}
변경 후의 loadUser() 메서드
@Log4j2
//@Service 어노테이션이 스프링의 빈으로 자동 등록
@Service
//소셜 로그인한 이메일을 이용해 회원 가입 처리하기 위해 의존성 추가
//: ClubmemberRepository, PasswordEncoder
@RequiredArgsConstructor
public class ClubOAuth2UserDetailsService extends DefaultOAuth2UserService {
private final ClubMemberRepository clubMemberRepository;
private final PasswordEncoder passwordEncoder;
//DefaultOAuth2UserService의 loadUser() 메서드 오버라이딩
// public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//
// return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
// }
// }
// }
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)throws OAuth2AuthenticationException{
log.info("--------------");
log.info("userRequest : "+ userRequest);
//OAuth2UserRequest 객체
String clientName = userRequest.getClientRegistration().getClientName();
log.info("clientName : "+ clientName); //google로 출력
//additionalParameters () 메소드를 사용해 맵을 전달 하여 OAuth2AuthorizationRequest에 매개변수를 추가한다.
//즉, 최대한 많은 정보를 얻기 위해 사용하는 메서드
log.info(userRequest.getAdditionalParameters());
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("===============");
//키, 값으로 이루어진 Map<String, Object>형태로 데이터를 전달하므로
oAuth2User.getAttributes().forEach((k,v)->{
log.info(k+":"+v);
//sub, pictrue, email, email_verified, EMAIL 출력
});
//회원가입된 이메일 정보가 없는걸로 간주
String email = null;
//oAuth2User의 이메일 정보 추출
if(clientName.equals("Google")){
email=oAuth2User.getAttribute("email");
}
log.info("EMAIL: "+ email);
//해당 이메일 정보를 이용해 소셜로그인한 정보를 이용해 회원가입처리
ClubMember member=saveSocialMember(email);
//return super.loadUser(userRequest);
return oAuth2User;
//OAuth2UserRequest 타입의 파라미터와 OAuth2User라는 타입의 리턴타입을 반환하는데
//기존의 로그인 처리에 사용하던 파라미터와 리턴타입의 불일치 문제 발생
//-> Map 자료구조를 통해 변환 수행
}
//소셜 로그인한 이메일을 이용해 회원가입 메서드 -> 리턴타입이 ClubMember로 작성해, 기존 데이터베이스에 인서트할 수 있도록
private ClubMember saveSocialMember(String email){
//기존에 동일한 이메일로 가입 여부 확인 -> 존재할 경우 조회만
//: null확인을 할 수 있는 Optional로 작성 -> (이메일, 소셜이메일여부)
Optional<ClubMember> result = clubMemberRepository.findByEmail(email, true);
if(result.isPresent()){
return result.get();
}
//없다면 회원 추가 패스워드를 1111 / 이름은 이메일 주소
ClubMember clubMember = ClubMember.builder()
.email(email)
.name(email)
.password(passwordEncoder.encode("1111"))
.fromSocial(true)
.build();
//권한 설정 후, 리포지토리에 엔티티 저장
clubMember.addMemberRole(ClubMemberRole.USER);
clubMemberRepository.save(clubMember);
return clubMember;
}
}
소셜 로그인 대상자에 대한 고려사항
- 소셜 로그인 할 경우 패스워드와 사용자 이름 고정되는데, 변경가능여부를 판단해야한다.
- 소셜 로그인을 사용하는 사용자에 대해서 폼방식의 로그인 가능여부를 판단해야한다.
(4) 브라우저에서 이메일 주소가 아닌 사용자 번호를 출력하는 loadUser()
: DefaultOAuth2UserService의 loadUser()가 OAuth2User 타입 객체 반환하지 않는 문제
-> 컨트롤러의 경우 ClubAuthMemberDTO이므로, 타입문제 때문에 null 리턴
사용자 이메일 주소를 출력하기 위한 문제의 해결
- 컨트롤러 : OAuth2User 객체를 ClubAuthMemberDTO로 변환하도록 수정
- 브라우저 : loadUser()가 OAuth2User 타입 객체를 반환하도록 수정
1. ClubAuthMemberDTO
: OAuth2User타입이 인터페이스로 설계되어있으므로 DTO를 수정해서 해결
수정 전의 ClubAuthMemberDTO
package com.club.boot5.security.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
@Log4j2
@Getter
@Setter
@ToString
//DTO 역할 수행 + 스프링 시큐리티에서 인가/인증
public class ClubAuthMemberDTO extends User {
private String email;
private String name;
private boolean fromSocial;
//필요한 속성인 소셜로그인 체크 여부 속성을 추가한다.
public ClubAuthMemberDTO(String username, String password, boolean fromSocial,Collection<? extends GrantedAuthority> authorities){
//email -> username
//name -> name
//fromSocial -> fromSocial
//password는 부모 클래스 사용하므로 변수로 선언하지 않는다.
//따라서, email과 fromSocial은 별도로 setter 작성
//부모의 클래스에 사용자 정의 생성자가 존재하기 때문에 반드시 별도로 호출해야한다.
super(username, password, authorities);
this.email=username;
this.fromSocial=fromSocial;
}
}
수정 후의 ClubAuthMemberDTO
: OAuth2User를 구현해 OAuth2User가 가지고 있는 사용자 정보를 추출할 수 있도록 생성자를 만든다.
package com.club.boot5.security.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.Map;
@Log4j2
@Getter
@Setter
@ToString
//DTO 역할 수행 + 스프링 시큐리티에서 인가/인증
public class ClubAuthMemberDTO extends User implements OAuth2User {
private String email;
private String name;
private boolean fromSocial;
//소셜로그인의 정보를 가져오기 위한 멤버변수추가
private String password;
private Map<String, Object> attr;
//소셜 로그인으로 인한 OAuth2User를 ClubAuthMemberDTO로 변환하기 위한 생성자
public ClubAuthMemberDTO(String username, String password,
boolean fromSocial, Collection<? extends GrantedAuthority> authorities, Map<String, Object> attr) {
this(username,password,fromSocial,authorities);
this.attr=attr;
}
@Override
public Map<String, Object> getAttributes(){
return this.attr;
}
//기존 로그인을 통한 인증을 위한 생성자
public ClubAuthMemberDTO(String username, String password,
boolean fromSocial, Collection<? extends GrantedAuthority> authorities) {
//email -> username
//name -> name
//fromSocial -> fromSocial
//password는 부모 클래스 사용하므로 변수로 선언하지 않는다.
//따라서, email과 fromSocial은 별도로 setter 작성
//부모의 클래스에 사용자 정의 생성자가 존재하기 때문에 반드시 별도로 호출해야한다.
super(username, password, authorities);
this.email=username;
//소셜로그인의 인증 정보를 가져오기 위해 추가
this.password=password;
this.fromSocial=fromSocial;
}
}
:OAuth2User가 Map 타입으로 인증결과를 attributes로 가지고 있기 때문에,
-> attr 변수를 만들고 getAttributes()메서드를 오버라이드 한다.
2. loadUser()
: ClubOAuth2UserDetailsService 클래스에서 OAuth2User의 데이터를 ClubAuthMemberDTO로 전달
변경 전의 loadUser()
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)throws OAuth2AuthenticationException{
log.info("--------------");
log.info("userRequest : "+ userRequest);
//OAuth2UserRequest 객체
String clientName = userRequest.getClientRegistration().getClientName();
log.info("clientName : "+ clientName); //google로 출력
//additionalParameters () 메소드를 사용해 맵을 전달 하여 OAuth2AuthorizationRequest에 매개변수를 추가한다.
//즉, 최대한 많은 정보를 얻기 위해 사용하는 메서드
log.info(userRequest.getAdditionalParameters());
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("===============");
//키, 값으로 이루어진 Map<String, Object>형태로 데이터를 전달하므로
oAuth2User.getAttributes().forEach((k,v)->{
log.info(k+":"+v);
//sub, pictrue, email, email_verified, EMAIL 출력
});
//회원가입된 이메일 정보가 없는걸로 간주
String email = null;
//oAuth2User의 이메일 정보 추출
if(clientName.equals("Google")){
email=oAuth2User.getAttribute("email");
}
log.info("EMAIL: "+ email);
//해당 이메일 정보를 이용해 소셜로그인한 정보를 이용해 회원가입처리
ClubMember member=saveSocialMember(email);
//return super.loadUser(userRequest);
return oAuth2User;
//OAuth2UserRequest 타입의 파라미터와 OAuth2User라는 타입의 리턴타입을 반환하는데
//기존의 로그인 처리에 사용하던 파라미터와 리턴타입의 불일치 문제 발생
//-> Map 자료구조를 통해 변환 수행
}
//소셜 로그인한 이메일을 이용해 회원가입 메서드 -> 리턴타입이 ClubMember로 작성해, 기존 데이터베이스에 인서트할 수 있도록
private ClubMember saveSocialMember(String email){
//기존에 동일한 이메일로 가입 여부 확인 -> 존재할 경우 조회만
//: null확인을 할 수 있는 Optional로 작성 -> (이메일, 소셜이메일여부)
Optional<ClubMember> result = clubMemberRepository.findByEmail(email, true);
if(result.isPresent()){
return result.get();
}
//없다면 회원 추가 패스워드를 1111 / 이름은 이메일 주소
ClubMember clubMember = ClubMember.builder()
.email(email)
.name(email)
.password(passwordEncoder.encode("1111"))
.fromSocial(true)
.build();
//권한 설정 후, 리포지토리에 엔티티 저장
clubMember.addMemberRole(ClubMemberRole.USER);
clubMemberRepository.save(clubMember);
return clubMember;
}
변경 후의 loadUser()
@Log4j2
//@Service 어노테이션이 스프링의 빈으로 자동 등록
@Service
//소셜 로그인한 이메일을 이용해 회원 가입 처리하기 위해 의존성 추가
//: ClubmemberRepository, PasswordEncoder
@RequiredArgsConstructor
public class ClubOAuth2UserDetailsService extends DefaultOAuth2UserService {
private final ClubMemberRepository clubMemberRepository;
private final PasswordEncoder passwordEncoder;
//DefaultOAuth2UserService의 loadUser() 메서드 오버라이딩
// public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//
// return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
// }
// }
// }
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)throws OAuth2AuthenticationException{
log.info("--------------");
log.info("userRequest : "+ userRequest);
//OAuth2UserRequest 객체
String clientName = userRequest.getClientRegistration().getClientName();
log.info("clientName : "+ clientName); //google로 출력
//additionalParameters () 메소드를 사용해 맵을 전달 하여 OAuth2AuthorizationRequest에 매개변수를 추가한다.
//즉, 최대한 많은 정보를 얻기 위해 사용하는 메서드
log.info(userRequest.getAdditionalParameters());
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("===============");
//키, 값으로 이루어진 Map<String, Object>형태로 데이터를 전달하므로
oAuth2User.getAttributes().forEach((k,v)->{
log.info(k+":"+v);
//sub, pictrue, email, email_verified, EMAIL 출력
});
//회원가입된 이메일 정보가 없는걸로 간주
String email = null;
//oAuth2User의 이메일 정보 추출
if(clientName.equals("Google")){
email=oAuth2User.getAttribute("email");
}
log.info("EMAIL: "+ email);
//먼저 OAuth2User 데이터를 ClubAuthMemberDTO로 전달하기 위한 작업 수행
// //해당 이메일 정보를 이용해 소셜로그인한 정보를 이용해 회원가입처리
// ClubMember member=saveSocialMember(email);
// //return super.loadUser(userRequest);
// return oAuth2User;
ClubMember clubMember=saveSocialMember(email);
ClubAuthMemberDTO clubAuthMemberDTO = new ClubAuthMemberDTO(
clubMember.getEmail(),
clubMember.getPassword(),
true,
clubMember.getRoleSet().stream().map(
role->new SimpleGrantedAuthority("ROLE_"+role.name())
).collect(Collectors.toList()),
//oAuth2User의 인증정보를 제공한다.
oAuth2User.getAttributes()
);
clubAuthMemberDTO.setName(clubMember.getName());
return clubAuthMemberDTO;
//OAuth2UserRequest 타입의 파라미터와 OAuth2User라는 타입의 리턴타입을 반환하는데
//기존의 로그인 처리에 사용하던 파라미터와 리턴타입의 불일치 문제 발생
//-> Map 자료구조를 통해 변환 수행
}
//소셜 로그인한 이메일을 이용해 회원가입 메서드 -> 리턴타입이 ClubMember로 작성해, 기존 데이터베이스에 인서트할 수 있도록
private ClubMember saveSocialMember(String email){
//기존에 동일한 이메일로 가입 여부 확인 -> 존재할 경우 조회만
//: null확인을 할 수 있는 Optional로 작성 -> (이메일, 소셜이메일여부)
Optional<ClubMember> result = clubMemberRepository.findByEmail(email, true);
if(result.isPresent()){
return result.get();
}
//없다면 회원 추가 패스워드를 1111 / 이름은 이메일 주소
ClubMember clubMember = ClubMember.builder()
.email(email)
.name(email)
.password(passwordEncoder.encode("1111"))
.fromSocial(true)
.build();
//권한 설정 후, 리포지토리에 엔티티 저장
clubMember.addMemberRole(ClubMemberRole.USER);
clubMemberRepository.save(clubMember);
return clubMember;
}
}
loadUser() 변경점
- saveSocialMember()한 결과로 나오는 ClubMember로 ClubAuthMemberDTO 구성
- OAuth2User의 모든 데이터를 ClubAuthMemberDTO로 전달해 필요할 때 사용할 수 있도록 변경
'Server Programming > Spring Boot Backend Programming' 카테고리의 다른 글
2장. 웹과 데이터베이스 (0) | 2022.11.25 |
---|---|
1장. 웹 프로그래밍 시작 (0) | 2022.11.24 |
[Spring 부트 - 운동 클럽 프로젝트] 1. 스프링 시큐리티 연동 (2) CSRF 와 접근 제한 설정 (0) | 2022.10.19 |
[Spring 부트 - 운동 클럽 프로젝트] 1. 스프링 시큐리티 연동 (1) 기본 설정 (0) | 2022.10.19 |
[Spring 부트 - 영화 리뷰 프로젝트] 6. Ajax로 영화 리뷰 처리 (2) 리뷰 등록 / 수정 /삭제 (0) | 2022.10.18 |