안녕하딤니카?
한참 티스토리 글을 안 올리다가 이제야 돌아왔습니다
사실 쓸 글은 많았는데 우선순위로 두지 않아서 이모양 이 꼴이 되었음 ㅜ
이제 프로젝트도 어느정도 정리 되었고 지금부터는 티스토리에 글을 정리하면서 프로젝트를 진행해야겠다 싶어서
글을 작성해봅니다... 우하하
저는 지금 개인 프로젝트로 쇼핑몰을 첨부터 만들어보고있는데여
여태 뭔가 처음부터 끝까지 한 적이 없는 것 같아서
혼자서 해볼까... 하고 시작했습니다
다른 기능은 무리없이 잘 개발 했는데 (폼 회원가입, 폼 로그인, 상품 등록 관리, 장바구니, 구매 이런거)
어우 소셜 로그인 구현에서 좀 애를 먹었습니다 하하하 ㅜ
저랑 비슷한 고충을 겪고 계신 분들이 많을 것 같아... 정리해봅니다
사실 제가 나중에 볼라고 정리하는거라 두서가 상당히 없을 수 있음
그럼 시작합니다
- Spring Security, OAuth 의 동작 원리는 나중에 따로 정리할 예정이고 여기서는 동작 순서에 맞춰서 어찌 구현했는지 정리합니다
- 제 구현방식은 최대한 백엔드에서 처리하고, 프론트에서는 처리하는 게 별로 없습니다... 이게 맞다고 생각하기도 하고... 참고부탁
- 여기서는 구글과 네이버 로그인 기능만 추가했습니다 (카카오는 나중에
- JWT 안 넣었습니다!!!!!
1. 소셜 로그인 기능 개발을 위한 사전 세팅
먼저, 소셜 로그인 기능 개발을 위해서는 구글과 네이버에서 API 신청을 해야 합니다
id와 secret 을 받아야하기때문이져
네이버부터 시작하겠습니따
1-1. 네이버 로그인 API 등록
NAVER Developers
네이버 오픈 API들을 활용해 개발자들이 다양한 애플리케이션을 개발할 수 있도록 API 가이드와 SDK를 제공합니다. 제공중인 오픈 API에는 네이버 로그인, 검색, 단축URL, 캡차를 비롯 기계번역, 음
developers.naver.com


자 여기서 잉차잉차 API 발급하시고
중요한거는 네이버에서 어떤 정보를 가져올건지 선택해야되는데
보통 로그인 할 때 필요한건 회원 이름이랑 이메일 정도라고 생각하는데
간혹 프로필사진 등록 같이 진행하게끔 개발하신다면 프로필사진도 가져오면 되구여
뭐... 저는 최소한의 정보만 가져오고싶어서 회원이름이랑 이메일 주소만 가져오도록 체크했습니다

그 다음은 서비스 URL과 콜백 URL을 설정해줘야하는데
서비스URL은... 혹시 도메인 구매했으면 그거 적어주시구여
저는 도메인은 나중에 세팅할거라 일단 저렇게 진행했습니다 각자 포트번호 맞춰서 적어주기!
그리고 콜백 URL은 Spring Security 가 기본적으로 '/login/oauth2/code/{registrationId}' 형식의 콜백 url을 기대하기 때문에,
진짜 증말로 특별한 경우가 아니라면 저 URL로 설정해주시면 됩니다

저렇게 하고 API 등록하면 API 키와 시크릿을 발급받을 수 있는데, 이거 가지고 있으면 됩니다
1-2. 구글 로그인 API 등록
그 다음은 구글인데, 구글은 API을 사용하시려면 GCP 을 가입하셔야합니다
무료 체험 기간 있으니까 후딱 가입하심 됩니다
https://cloud.google.com/free/?gad_source=1&gclid=CPOnsLTH64MDFX1MwgUdULgIkw&gclsrc=ds&hl=ko&userloc_1009841-network_g=&utm_campaign=japac-KR-all-en-dr-BKWS-all-core-trial-EXA-dr-1605216&utm_content=text-ad-none-none-DEV_c-CRE_668690472449-ADGP_Hybrid+%7C+BKWS+-+EXA+%7C+Txt+~+GCP_General_core+brand_main-KWID_43700077514871058-aud-1461360047880%3Akwd-87853815&utm_medium=cpc&utm_source=google&utm_term=KW_gcp
cloud.google.com


여기서도 리다이렉션 URI 를 스프링 시큐리티가 기대하는 콜백 URL로 맞춰서 입력해주시고
사용자 정보 어디까지 가져올건지 체크 후 저장해주세여
그리고 저렇게 저장하면 API 키랑 시크릿 생성되는데 그거 가지고 있으면 구글과 네이버 API 발급 끝~
1-3. application.yml 설정
그리고 발급받은 키랑 시크릿 값을 이제 application.yml 에 입력해주겠습니다
# OAuth2 소셜로그인 설정
spring:
security:
oauth2:
client:
registration:
google:
client-name: google
client-id: ${GOOGLE_CLIENT}
client-secret: ${GOOGLE_SECRET}
redirect-uri: http://localhost:8080/login/oauth2/code/google
authorization-grant-type: authorization_code
scope: # google API의 범위 값
- email
- profile
naver:
client-name: naver
client-id: ${NAVER_CLIENT}
client-secret: ${NAVER_SECRET}
redirect-uri: http://localhost:8080/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope: # naver API의 범위 값
- name
- email
# 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
혹시 깃허브에 올린다면, 클라이언트 ID값과 시크릿값은 꼭 가리고 올리세요!
(환경변수 설정해서 저렇게 가리고 올리거나, 아니면 yml 파일을 아예 깃이그노어로 등록해서 안 올리는 방법도 있습니다)
(저는 시크릿을 포함한 민감정보를 아예 노출하고 싶지 않아서 yml 파일은 깃허브에 안 올리는 방법을 선택했습니다)
암튼 저렇게 설정해주시면 됩니다
provider 설정은 네이버, 카카오 같은 국내서비스 연동 로그인 시에 수동 설정해줘야하고
구글, 페이스북, 깃헙 등... 은 설정 안 해주셔도 됩니다
2. SecurityConfig 등록 및 설정 (Spring Security 6 기준)
시큐리티5에서 6으로 넘어오면서 SecurityFilterChain 안에 설정을 다 넣어주셔야됩니다
각자 개발 상황에 따라서 설정이 바뀔 수 있지만 일단 oauth2 관련 설정만 살펴보겠습니다
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService) {
this.customOAuth2UserService = customOAuth2UserService;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.formLogin((formLogin) -> formLogin
.usernameParameter("email") // 로그인 시 사용할 파라미터로 email 을 사용
.failureUrl("/members/login/error") // 로그인 실패 시 이동할 페이지
.loginPage("/members/login") // 로그인 페이지 설정
.defaultSuccessUrl("/")) // 로그인 성공시 이동할 페이지
.logout((logout) -> logout
.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) // 로그아웃 url 설정
.logoutSuccessUrl("/") // 로그아웃 성공 시 이동할 url
.invalidateHttpSession(true)) // 기존에 생성된 사용자 세션도 invalidateHttpSession 을 통해 삭제하도록 처리
;
// 특정 URL에 대한 권한 설정
// permitAll() : 모든 사용자가 인증(로그인) 없이 해당 경로에 접근
// hasAnyAuthority("ROLE_ADMIN") : /admin 으로 시작하는 경로는 ROLE_ADMIN Role 일 경우에만 접근 가능
// 명시한 나머지 경로는 모두 인증을 요구하도록 설정
http.authorizeHttpRequests((authorizeRequests) -> {
authorizeRequests
.requestMatchers("/favicon.ico", "/error").permitAll()
.requestMatchers("/css/**", "/js/**", "/img/**").permitAll()
.requestMatchers("/", "/members/**", "/item/**", "/images/**").permitAll()
.requestMatchers("/admin/**").hasAnyAuthority("ROLE_ADMIN")
.anyRequest().authenticated();
});
// oauth2
http
.oauth2Login((oauth2) -> oauth2
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
.userService(customOAuth2UserService)));
// 인증되지 않은 사용자가 리소스에 접근하였을 때 수행되는 핸들러 등록
http.exceptionHandling(authenticationManager -> authenticationManager
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()));
return http.build();
}
저는 기존에 폼 로그인 개발을 진행했어서 formLogin 메서드쪽을 작성해뒀습니다
폼 로그인 개발 안 하셨으면 disable 해주시면 됩니다
기타 Basic 인증 방식, 세션 설정 같은 경우는 각자 개발 상황에 맞춰서 개별 설정해주시면 됩니다
여기서 주목할 곳은 oauth2 부분인데요
로그인 API에서 정보를 처리할 CustomOAuth2UserService 를 구현할거라 저렇게 등록해둔겁니다!
다른거 다 치우고 저 부분 잘 등록해둬야합니다
그 이유는 저도 알고싶지않았어요
3. 유저 정보 저장을 위한 DTO, 엔티티 계층 개발
일단 먼저, 로그인 API 에서 받는 정보를 처리할 DTO 계층 구현 먼저 해주겠습니다
public interface OAuth2Response {
//제공자 (Ex. naver, google, ...)
String getProvider();
//제공자에서 발급해주는 아이디(번호)
String getProviderId();
//이메일
String getEmail();
//사용자 실명 (설정한 이름)
String getName();
}
OAuth2Response 인터페이스를 만들어주고, 이 Response 를 상속받은 GoogleResponse 와 NaverResponse 를 만들어줍니다
왜냐면 구글과 네이버가 보내주는 정보의 형식이 다르기 때문입니다
public class GoogleResponse implements OAuth2Response {
private final Map<String, Object> attribute;
public GoogleResponse(Map<String, Object> attribute) {
this.attribute = attribute;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderId() {
return attribute.get("sub").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
}
public class NaverResponse implements OAuth2Response {
private final Map<String, Object> attribute;
public NaverResponse(Map<String, Object> attribute) {
this.attribute = (Map<String, Object>) attribute.get("response");
}
@Override
public String getProvider() {
return "naver";
}
@Override
public String getProviderId() {
return attribute.get("id").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
}
그리고 DTO로 API로부터 받은 데이터를 한 번 감싸줄거라, SocialMemberDto 를 따로 만들어주겠습니다
@Getter
@Setter
public class SocialMemberDto {
private Role role;
private String name;
private String username;
}
그리고 스프링 시큐리티에서 제공하는 OAuthUser 를 상속받는 CustomOAuthUser 를 만들어줍니다
public class CustomOAuth2User implements OAuth2User {
private final SocialMemberDto socialMemberDto;
public CustomOAuth2User(SocialMemberDto socialMemberDto) {
this.socialMemberDto = socialMemberDto;
}
@Override
public Map<String, Object> getAttributes() {
// google 이 가지는 Attribute랑 naver 가 가지는 Attribute 가 달라서 하단에 따로 구현하고, 이 부분은 null 로 유지
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
Role role = socialMemberDto.getRole();
String authority = role.name().startsWith("ROLE_") ? role.name() : "ROLE_" + role.name();
authorities.add(() -> authority);
return authorities;
}
@Override
public String getName() {
return socialMemberDto.getName();
}
public String getUsername() {
return socialMemberDto.getUsername();
}
}
저는 여기서 Role 클래스를 따로 만들어서 사용하는데,
스프링 시큐리티에서 기대하는 role name 은 ROLE_ 로 시작하는 메서드라, ROLE_ 로 시작하면 그냥 role name 을 그대로 가져오고, 아니면 ROLE_ 을 붙여주는 로직을 추가했습니다
그냥 다 붙여주면 상관없는데 혹시몰라 이렇게했습니다
그 다음은 로그인 정보를 DB에 저장하기 위해 엔티티 계층을 개발하겠습니다
@Entity
@Getter
@Setter
@Table(name = "social_member")
public class SocialMember extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.TABLE, generator = "social_member_generator")
@TableGenerator(
name = "social_member_generator",
table = "social_member_id_gen",
pkColumnName = "gen_name",
valueColumnName = "gen_val",
pkColumnValue = "SocialMember_ID",
allocationSize = 1
)
private Long id;
private String username;
private String name;
private String email;
@Enumerated(EnumType.STRING)
private Role role;
}
저는 SocialMember 엔티티의 기본키 생성 전략을 TABLE 로 정해서 저런거고
각자 맞는 기본키 생성 전략을 선택하셔서 엔티티를 만들어주세요
DB에 저장해야되는 정보는
1. id (PK)
2. username (로그인 API에서 받을 사용자마다의 고유한 값)
3. name (사용자 이름)
4. email(사용자의 이메일)
5. role(ROLE_USER, ROLE_ADMIN 등등...)
입니다
4. 로그인 정보를 저장하고, 처리할 서비스 계층 개발
다음은 로그인 API에서 받은 사용자의 정보를 처리하고, 저장할 서비스계층을 개발하겠습니다
우선 저는 Spring Data JPA 를 사용하고 있기 때문에, repository 인터페이스를 먼저 만들어주겠습니다
public interface SocialMemberRepository extends JpaRepository<SocialMember, Long> {
SocialMember findByUsername(String username);
}
그 다음은 CustomOAuth2UserService 를 만들어주겠습니다
여기서는 로그인한 API 가 구글인지, 네이버인지 판단하여 맞는 Response 를 거쳐 유저 정보를 처리하고,
발급 받은 정보로 사용자를 특정할 아이디값을 만들어 username 에 저장해줍니다
그리고, 처음 로그인을 하는 유저인지, 이미 로그인을 했던 유저인지 확인 후,
없으면 새로 저장, 로그인 이력이 있으면 바뀐 정보를 update 하는 순서로 서비스계층을 구현했습니다
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final SocialMemberRepository socialMemberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
System.out.println(oAuth2User);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response = null;
if (registrationId.equals("naver")) {
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
}
else if (registrationId.equals("google")) {
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
}
else {
return null;
}
// 리소스 서버에서 발급 받은 정보로 사용자를 특정할 아이디값 생성
String username = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
// 유저 정보를 DB에 저장하는 로직
// 1. 이미 로그인해서 유저가 DB에 존재 하는지 확인
SocialMember existData = socialMemberRepository.findByUsername(username);
if (existData == null) { // 2. 없으면 DB에 유저 정보 저장
SocialMember socialMember = new SocialMember();
socialMember.setUsername(username);
socialMember.setEmail(oAuth2Response.getEmail());
socialMember.setName(oAuth2Response.getName());
socialMember.setRole(Role.ROLE_USER);
socialMemberRepository.save(socialMember);
SocialMemberDto socialMemberDto = new SocialMemberDto();
socialMemberDto.setUsername(username);
socialMemberDto.setName(oAuth2Response.getName());
socialMemberDto.setRole(Role.ROLE_USER);
return new CustomOAuth2User(socialMemberDto);
}
else { // 3. 이미 있으면 이름, 이메일값만 update (username는 유지)
existData.setEmail(oAuth2Response.getEmail());
existData.setName(oAuth2Response.getName());
socialMemberRepository.save(existData);
SocialMemberDto socialMemberDto = new SocialMemberDto();
socialMemberDto.setUsername(existData.getUsername());
socialMemberDto.setName(oAuth2Response.getName());
socialMemberDto.setRole(existData.getRole());
return new CustomOAuth2User(socialMemberDto);
}
}
5. 컨트롤러 계층, 뷰 개발 (html, thymeleaf 활용)
이제 최종으로 컨트롤러 계층을 개발해서 로그인을 찐으로 해보겠습니다
@Controller
@RequiredArgsConstructor
public class OAuth2Controller {
private static final Logger logger = LoggerFactory.getLogger(OAuth2Controller.class);
@GetMapping("/login/oauth2/code/google")
public ModelAndView googleLoginSuccess(OAuth2AuthenticationToken authentication) {
OidcUser user = (OidcUser) authentication.getPrincipal();
logger.info("Google login successful for user: {}, email: {}", user.getName(), user.getEmail());
return new ModelAndView("redirect:/");
}
@GetMapping("/login/oauth2/code/naver")
public ModelAndView naverLoginSuccess(OAuth2AuthenticationToken authentication) {
OidcUser user = (OidcUser) authentication.getPrincipal();
logger.info("Naver login successful for user: {}, email: {}", user.getName(), user.getEmail());
return new ModelAndView("redirect:/");
}
@GetMapping("/loginFailure")
public ModelAndView loginFailure() {
logger.error("Login failed - unable to authenticate user.");
return new ModelAndView("redirect:/member/memberLoginForm"); // 실패 시 현재 로그인 페이지로 리다이렉션
}
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout1}">
<!-- 사용자 CSS 추가 -->
<th:block layout:fragment="css">
<style>
.error {
color: #bd2130;
}
</style>
</th:block>
<div layout:fragment="content">
<form role="form" method="post" action="/members/login">
<div class="form-group">
<label th:for="email">이메일주소</label>
<input type="email" name="email" class="form-control" placeholder="이메일을 입력해주세요">
</div>
<div class="form-group">
<label th:for="password">비밀번호</label>
<input type="password" name="password" id="password" class="form-control" placeholder="비밀번호 입력">
</div>
<p th:if="${loginErrorMsg}" class="error" th:text="${loginErrorMsg}"></p>
<button class="btn btn-primary">로그인</button>
<button type="button" class="btn btn-primary" onClick="location.href='/members/new'">회원가입</button>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
<!-- 소셜 로그인 버튼 추가 -->
<div class="social-buttons">
<h3>소셜 로그인:</h3>
<a href="/oauth2/authorization/google" class="btn btn-danger">Google 로그인</a>
<a href="/oauth2/authorization/naver" class="btn btn-success">Naver 로그인</a>
</div>
</div>
</html>
이전에 SecurityConfig 에서 CustomOAuth2UserService 를 등록했기 때문에, OAuth2 로그인 프로세스 중 사용자 정보를 가져오는 단계에서 CustomOAuth2UserService 의 loadUser 메서드가 호출됩니다
CustomOAuth2UserService 에서 반환된 OAuthUser 객체를 사용하여 OAuth2AuthenticationToken 을 생성 후, 사용자의 인증 상태를 나타내고, 보안 컨텍스트에 저장합니다.
그리고 컨트롤러에서 정의된 리다이렉션 경로로 이동해서 로그인 성공 또는 실패, 그리고 이후 작업을 관리합니다
6. 최종 구현 화면

프론트 디자인 신경을 1도 안 써서 허접한 점은 좀 봐주세요 (???
gif 속도가 왜이렇게 느리적느리적되지... 프리미어프로 키기 귀찮아서 변환 툴 돌렸는데 저렇게되었네요 ㅎ...
실제로 저렇게 느리지 않습니다 ㅜㅜㅜ
얼레벌레 정리했는데 대충 흐름은 아시겠죠...? 이거 뭐 마무리를 어떻게 해야하는지 모르겠네
끝!!! 궁금하신건 댓글 달아주세요
그럼 20000~
🍀
좋아하는 것을 계속 좋아하세요!
반드시 행복해집니다
[Github] https://github.com/chujaeyeong
[E-mail] chujy1224@gmail.com
'Project' 카테고리의 다른 글
| Swagger 도구를 사용한 API 문서 작성 방법과 사용 시 이점 - 쇼핑몰 프로젝트 06 (1) | 2024.06.09 |
|---|---|
| JPA CascadeType 설정으로 인한 리뷰 삭제 문제 해결하기 - 쇼핑몰 프로젝트 05 (2) | 2024.06.08 |
| Principal 객체를 활용하여 인증된 사용자의 정보를 반환하기 - 쇼핑몰 프로젝트 04 (3) | 2024.05.27 |
| 여러 유형의 회원을 하나의 엔티티로 관리하기 - 쇼핑몰 개인 프로젝트 03 (0) | 2024.05.27 |
| Spring Boot 3.x + Spring Security 6 + OAuth2 소셜 로그인의 흐름 - 쇼핑몰 개인 프로젝트 02 (1) | 2024.05.16 |