개인 공부

[Spring Boot] 카카오, 네이버, 구글 SNS 로그인

orin602 2025. 5. 17. 17:19

 

요즘 대부분의 웹 서비스는 별도의 회원가입 없이 카카오, 네이버, 구글 같은 SNS 계정으로 로그인할 수 있게 되어 있죠.

사용자 입장에서는 편리하고, 서비스 입장에서도 이탈률을 줄일 수 있는 중요한 기능입니다.

저는 지금까지 여러 프로젝트에 참여했지만, 로그인 기능을 직접 구현해 본 경험은 없었습니다. 그래서 언젠가는 꼭 한 번 제대로 구현해 봐야겠다는 생각을 갖고 있었고, 이번 개인 프로젝트를 계기로 SNS 로그인 기능을 직접 구현해보게 되었습니다.

 

이 글에서는 Spring Boot를 기반으로

  • 카카오, 네이버, 구글의 로그인 기능을 각각 연동하고,
  • 로그인한 사용자의 정보를 세션에 저장,
  • 회원 여부에 따라 자동 로그인 처리 또는 약관 페이지로 이동 및 회원가입 까지 처리하는 기능을 구현하는 과정을 담았습니다.

1. 회원 엔티티 클래스

SNS 로그인 사용자도 저장할 수 있는 회원 테이블 구성.

package com.demo.domain;

import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Temporal;
import jakarta.persistence.TemporalType;
import java.util.Date;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@DynamicInsert
@DynamicUpdate
public class Member {
	@Id
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_seq")
	@SequenceGenerator(name="member_seq", sequenceName = "member_seq", allocationSize = 1)
	private long member_seq;
	
	@Column(unique = true)
	private String id; // 아이디
	private String pwd; // 비밀번호
	private String name; // 이름
	private String email; // 이메일
	private String telephone;
	private String provider;

	private String address; // 기본 주소
	private String addressDetail; // 상세 주소

	@ColumnDefault("0")
	private int membercode; // 회원 코드

	@Temporal(value = TemporalType.TIMESTAMP)
	@ColumnDefault(value = "CURRENT_TIMESTAMP")
	private Date signupDate; // 가입 날짜

	@ColumnDefault("0") // 탈퇴 요청 기본값을 0으로 설정 (대기)
	private int withdrawalRequest; // 회원 탈퇴 요청 상태

	@PrePersist
	protected void onCreate() {
		if (this.signupDate == null) {
			// 메서드를 사용하여 엔티티가 처음 저장될 때 signupDate 필드가 'null'이면 현재 날짜 및 시간을 설정
			this.signupDate = new Date();
		}
	}
}
  • private String provider 를 통해 SNS 로그인인지 일반 회원가입인지 구분.

2. SNS 사용자 정보 DTO

SNS 로그인 후 각 플랫폼(카카오, 네이버, 구글)에서 반환하는 사용자 정보가 다르기 때문에

공통 포맷으로 받아 처리할 수 있도록 DTO 클래스를 따로 구성.

package com.demo.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

import lombok.Getter;
import lombok.Setter;

@JsonIgnoreProperties(ignoreUnknown = true)
@Getter
@Setter
public class User {

	@JsonProperty("id")
    private String id;

    @JsonProperty("email")
    private String email;

    @JsonProperty("verified_email")
    private boolean verifiedEmail;
    
    @JsonProperty("provider")
    private String provider;

    @JsonProperty("name")
    private String name;

    @JsonProperty("given_name")
    private String givenName;

    @JsonProperty("family_name")
    private String familyName;

    @JsonProperty("picture")
    private String picture;

    @JsonProperty("locale")
    private String locale;
}

 

3. Repository / Service 계층

(SNS 계정 로그인에서 사용하는 부분만 작성했습니다.)

package com.demo.persistence;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

import com.demo.domain.Member;

public interface MemberRepository extends JpaRepository<Member, Long> {
	
    @Query(value="SELECT * FROM member WHERE id =:id", nativeQuery=true)
    Member findByLoginId(String id);
	
}
package com.demo.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import com.demo.domain.Member;
import com.demo.persistence.MemberRepository;

@Service
public class MemberServiceImpl implements MemberService {

    @Autowired
    private MemberRepository memberRepo;

    @Override
    public Member getMember(String id) {
        return memberRepo.findByLoginId(id);
    }

}

 

4. HTML + JS : SNS 로그인 버튼 및 redirect

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<title>login</title>
<!-- 카카오 로그인 SDK -->
<script src="https://developers.kakao.com/sdk/js/kakao.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>

<!-- 네이버 로그인 SDK -->
<script type="text/javascript" src="https://static.nid.naver.com/js/naveridlogin_js_sdk_2.0.2.js"></script>
<!-- 네이버 로그인 라이브러리 -->
<script type="text/javascript" src="https://static.nid.naver.com/js/naverLogin_implicit-1.0.3.js" charset="utf-8"></script>

<!-- 구글 로그인 -->
<meta name="google-signin-client_id" content="발급받은 CLIENT ID">
<script src="https://accounts.google.com/gsi/client" async defer></script>

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.2/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link rel="stylesheet" th:href="@{/css/login.css}">
<script th:src="@{js/login.js}"></script>
</head>
<body>
<div class="login-container">
	<div class="social-login">
		<h1>소셜 로그인</h1>
		<div id="naver_id_login" class="login-button" style="display:block;">
		
		</div>
		<div id="kakao_id_login" style="cursor:pointer; background-color:#FEE500; border-radius:5px;">
			<img src="images/kakao_login.png" alt="카카오 로그인" onclick="kakaoLogin()">
		</div>
		<div id="google_id_login" data-client_id="발급받은 CLIENT ID"
			data-callback="onSignIn" data-auto_prompt="false">
		</div>
		<div class="g_id_signin" data-type="standard" data-shape="rect"
			data-theme="outline" data-text="signin_with" data-size="large">
		</div>
	</div>
	
</div>
<script>
    // 1. 카카오 SDK 초기화
    Kakao.init('발급받은 API KEY');
    console.log('Kakao 초기화 여부:', Kakao.isInitialized());
  
    // 2. 로그인 함수 정의
    function kakaoLogin() {
		Kakao.Auth.login({
            scope: 'profile_nickname, account_email',
            success: function (authObj) {
				console.log('인증 성공', authObj);

				Kakao.API.request({
                    url: '/v2/user/me',
                    success: function (res) {
                        console.log('사용자 정보', res);
                        const kakaoAccount = res.kakao_account;

                        const userInfo = {
                            id: res.id.toString(),
                            email: kakaoAccount.email,
                            name: kakaoAccount.profile.nickname,
                            provider: 'kakao'
                        };
            
            			fetch('/oauth/kakao/callback', {
            				method: 'POST',
            				headers: {
            					'Content-Type': 'application/json'
            				},
            				body: JSON.stringify(userInfo)
            			})
                        .then(response => response.json())
                        .then(result => {
                            if (result.status == 'login_success') {
                            	window.location.href = '/main';
                            } else if (result.status == 'signup_required') {
                            	sessionStorage.setItem('kakao_user', JSON.stringify(userInfo));
                            	window.location.href = '/oauth/contract'
                            }
            			})
                        .catch(error => {
                            console.error('오류 발생', error);
                        });
            		},
                    fail: function (error) {
                        console.error('사용자 정보 요청 실패', error);
                    }
            	});
            },
            fail: function (err) {
            	console.error('로그인 실패', err);
            }
        });
    }

</script>
<script>
    // 네이버 로그인 초기화
    var naver_id_login = new naver_id_login("발급받은 CLIENT ID", "http://localhost:9010/oauth/naver/callback");
    var state = naver_id_login.getUniqState();
    naver_id_login.setButton("green", 3, 50);
    naver_id_login.setDomain("localhost:9010");
    naver_id_login.setState(state);
    naver_id_login.setPopup(true); // 팝업 모드 활성화
    naver_id_login.init_naver_id_login();
</script>
<script>
window.onload = function() {
    google.accounts.id.initialize({
        client_id: "발급받은 CLIENT ID",
        callback: onSignIn
    });
	google.accounts.id.renderButton(
		document.getElementById("google_id_login"), {
            theme: "outline",
            size: "large",
            text: "signin_with"
		}
	);
};

// 구글 로그인
function onSignIn(response) {
	const userObject = parseJwt(response.credential);
	var id = userObject.sub;
	var name = userObject.name;
	var email = userObject.email;
	var provider = "google";

    post_to_url("/oauth/google/callback", {
    	id: id, name: name, email: email, provider: provider
    });
}

function parseJwt(token) {
    var base64Url = token.split('.')[1];
    var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
    var jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
    	return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
    }).join(''));

	return JSON.parse(jsonPayload);
}

function post_to_url(path, params, method='post') {
    const form = document.createElement('form');
    form.method = method;
    form.action = path;

	for(const key in params) {
		if(params.hasOwnProperty(key)) {
            const hiddenField = document.createElement('input');
            hiddenField.type = 'hidden';
            hiddenField.name = key;
            hiddenField.value = params[key];
            form.appendChild(hiddenField);
        }
	}
    document.body.appendChild(form);
    form.submit();
}
</script>
</body>
</html>

SNS 계정 로그인

  • 카카오 로그인
    • 카카오 로그인 버튼 > kakaoLogin() 함수 실행
    • Kakao.Auth.login() 호출 > 카카오 인증 창
    • 인증 성공 : Kakao.API.request('/v2/user/me')로 사용자 정보 요청
    • 받은 사용자 정보에서 id, email, name, provider: "kakao" 추출
    • PostMapping("/oauth/kakao/callback")에서 json 바디를 User 객체로 받음.
      • 회원 존재 시 세션에 loginUser 등록 후 {status: "login_success"} JSON 응답
      • 회원 없으면 세션에 임시 user 저장 후 {status: "signup_required"} JSON 응답
    • 응답 결과에 따라 로그인 성공 시 /main으로, 회원가입 필요 시 /oauth/contract로 페이지 이
  • 네이버 로그인
    • 네이버 로그인 SDK 자동 초기화 (naver_id_login.init_naver_id_login())
    • 네이버 로그인 버튼 클릭 시 팝업창에서 인증 진행
    • 인증 완료 후 콜백 URL(http://localhost:9010/oauth/naver/callback)로 리다이렉트(기본 GET 요청)
    • 사용자 정보를 받아서 직접 POST 요청으로 /oauth/naver/callback에 JSON 전송
      • GET /oauth/naver/callback → 네이버 로그인 페이지(뷰) 반환
      • POST /oauth/naver/callback → User 객체 JSON 바디로 받음
      • DB에서 사용자 조회 후 로그인 성공 또는 회원가입 필요 여부 JSON으로 반환 (카카오와 동일)
    • POST 응답에 따라 로그인 성공 시 메인 페이지, 회원가입 필요 시 회원가입 페이지로 이동
  • 구글 로그인
    • 페이지 로드 시 google.accounts.id.initialize()로 구글 로그인 초기화
    • 구글 로그인 버튼 클릭 후 로그인 성공하면 onSignIn(response) 콜백 호출
    • JWT 토큰(response.credential) 파싱해서 id, name, email, provider: "google" 추출
    • post_to_url('/oauth/google/callback', {...}) 함수로 HTML form POST 요청 생성 및 제출
    • POST /oauth/google/callback → @RequestParam으로 id, name, email, provider 받음
      • DB에서 id로 회원 조회
      • 회원 있으면 세션에 loginUser 등록 후 /oauth/autoLogin으로 리다이렉트 (로그인 성공 처리)
      • 회원 없으면 세션에 임시 user 저장 후 /oauth/contract(회원가입 동의 페이지) 리다이렉트
구분 클라이언트 요청 서버 매핑 데이터 전달 방식 응답 처리
카카오 JS fetch()
POST JSON
@PostMapping("/kakao/callback") JSON 바디 JSON 결과를 받고 JS에서 페이지 이동
네이버 네이버 SDK 팝업 > 콜백 URL 리다이렉션 > POST JSON GET /naver/callback(뷰), POST /naver/callback JSON 바디 (POST) JSON 결과를 받고 JS에서 페이지 이동
구글 JS에서 JWT 파싱 후 HTML form POST 제출 POST /google/callback @RequestParam 폼 데이터 서버에서 리다이렉트(로그인 성공/가입)

 

5. Controller 계층 : 콜백 처리 및 세션 저장

package com.demo.controller;

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import com.demo.domain.Member;
import com.demo.dto.User;
import com.demo.persistence.MemberRepository;
import com.demo.service.MemberService;
import com.demo.service.PasswordGenerator;

import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;

@Controller
@RequestMapping("/oauth")
@RequiredArgsConstructor
public class OauthController {
	
    @Autowired
    private MemberService memberService;
    @Autowired
    private MemberRepository memberRepo;
	
	// 카카오 로그인 콜백
    @PostMapping("/kakao/callback")
    @ResponseBody
    public Map<String, String> kakaoLogin(@RequestBody User user, HttpSession session) {
    	System.out.println("Received User object: " + user);  // User 객체 출력
    	
        // 회원 조회
    	Member kakaoUser = memberService.getMember(user.getId());

        Map<String, String> response = new HashMap<>();
        
        if (kakaoUser != null) {
            session.setAttribute("loginUser", kakaoUser); // 실제 DB에서 조회한 Member 객체 저장
            response.put("status", "login_success"); // 로그인 성공 응답
        } else {
        	session.setAttribute("user", user);
        	System.out.println("User info stored in session: " + session.getAttribute("user"));
            response.put("status", "signup_required"); // 회원가입 필요 응답
        }

        return response; // JSON 형식으로 응답
    }

    // 네이버 로그인 콜백
    @GetMapping("/naver/callback")
    public String naverCallback(HttpSession session) {
    	return "login/naver";
    }
    @PostMapping("/naver/callback")
    @ResponseBody
    public Map<String, String> naverLogin(@RequestBody User user, HttpSession session) {
    	System.out.println("Naver User: " + user);
    	
    	Member naverUser = memberService.getMember(user.getId());
    	Map<String, String> response = new HashMap<>();
    	
    	if (naverUser != null) {
    		session.setAttribute("loginUser", naverUser);
    		response.put("status", "login_success");
    	} else {
    		session.setAttribute("user", user);
    		System.out.println("User info stored in session: " + session.getAttribute("user"));
    		response.put("status", "signup_required");
    	}
    	return response;
    }
    
    // 구글 로그인
    @PostMapping("/google/callback")
    public String googleLogin(@RequestParam String id, @RequestParam String name, 
            @RequestParam String email, @RequestParam String provider, HttpSession session) {
    	System.out.printf("Google User info :: { ",id, ", ", name, ", ", email, ", ", provider,"}");
    	User user = new User();
    	user.setId(id);
    	user.setName(name);
    	user.setEmail(email);
    	user.setProvider(provider);
    	session.setAttribute("loginUser", user);
    	
    	Member googleUser = memberService.getMember(user.getId());
    	if (googleUser != null) {
    		session.setAttribute("loginUser", googleUser);
    		return "redirect:/oauth/autoLogin";
    	} else {
            session.setAttribute("user", user);
            return "redirect:/oauth/contract";
        }    	
    }
    @GetMapping("/contract")
    public String contractPage(HttpSession session, Model model) {
        User user = (User) session.getAttribute("user");
        System.out.println("contract 진입 user: " + user);
        if (user != null) {
            String id = user.getId();
            int exists = memberService.confirmId(id);
            if (exists == 1) {
                return "redirect:/oauth/autoLogin";
            }
            model.addAttribute("user", user);
        }
        return "login/contract";
    }
    
    @GetMapping("/autoLogin")
    public String autoLogin(HttpSession session) {
        User user = (User) session.getAttribute("user"); 
        if (user != null) {
            String id = user.getId();
            // DB에서 해당 회원 정보를 조회
            Member loginUser = memberService.getMember(id);
            session.setAttribute("loginUser", loginUser);
            session.removeAttribute("user"); // 불필요한 user 세션 제거
        }
        return "redirect:/main";
    }
    
    @GetMapping("/joinForm")
    public String joinFormPage(HttpSession session, Model model) {
        User user = (User) session.getAttribute("user");
        if (user != null) {
            model.addAttribute("name", user.getName());
            model.addAttribute("email", user.getEmail());
            model.addAttribute("id", user.getId());
            model.addAttribute("provider", user.getProvider());
        }
        return "login/joinForm";
    }
    
    @PostMapping("/confirmEmail")
    @ResponseBody
    public int confirmEmail(@RequestParam String email) {
        return memberService.confirmEmail(email);
    }
    
    @PostMapping("/joinAction")
    public String joinAction(Member vo) {
    	Member member = new Member();
        String randomPassword = PasswordGenerator.generateRandomPassword();

        member.setId(vo.getId());
        member.setName(vo.getName());
        member.setEmail(vo.getEmail());
        member.setTelephone(vo.getTelephone());
        member.setProvider(vo.getProvider());
        member.setAddress(vo.getAddress());
        member.setAddressDetail(vo.getAddressDetail());
        member.setPwd(randomPassword);
        member.setMembercode(0);

        memberRepo.save(member);
        
        return "redirect:/main";
    }
}
  • 공통 흐름
    • 사용자 인증은 각 소셜 플랫폼 SDK(JS)를 통해 클라이언트에서 진행
    • 인증 성공 후, 사용자 정보(ID, NAME, EMAIL, PROVIDER 등)를 POST 요청으로 서버에 전달.
    • memberService.getMember(id)로 DB에 사용자 존재 여부 확인
      • 존재 시 : loginUser 세션 등록 > 로그인 완료.
      • 미존재 시 : user 세션 등록 > /contract 페이지로 리다이렉션 후 회원가입 진행
  • 카카오 로그인
    • 클라이언트 요청 : JS에서 카카오 SDK를 통해 인증 및 사용자 정보 조회 후,
      /oauth/kakao/callback에 User JSON 객체를 POST
    • 서버 처리 : @PostMapping("/kakao/callback")
      • public Map<String, String> kakaoLogin(@RequestBody User user, HttpSession session) 
        • user.getId()로 DB 조회
        • 있으면 loginUser 세션에 저장, login_success 응답
        • 없으면 user 세션에 저장, signup_required응답(>> /oauth/contract 이동)
  • 네이버 로그인
    • 클라이언트 요청 : 네이버 SDK 팝업 인증 > 콜백 URL로 리다이렉션 > 이후 User JSON으로 /naver/callback POST
    • 서버 처리 : @GetMapping("/naver/callback") >> login/naver.html 반환 (프론트에서 사용자 정보 POST 전송 준비)
      @PostMapping("/naver/callback") 
      • public Map<String, String> naverLogin(@RequestBody User user, HttpSession session)
      • DB 조회 후 동일한 방식으로 세션에 loginUser or user 저장
      • 결과 JSON 응답
  • 구글 로그인
    • 클라이언트 요청 : 구글 로그인 후 받은 토큰을 파싱해 사용자 정보 추출
      <form>을 만들어 POST /oauth/google/callback로 HTML form 전송
    • 서버 처리 : @PostMapping("/google/callback") 
      • public String googleLogin(@RequestParam String id, @RequestParam String name, ...) 
      • 전달받은 파라미터로 User 객체 생성
      • DB에서 id로 회원 존재 여부 확인
      • 있으면 loginUser 세션 저장 후 /oauth/autoLogin 리다이렉션
      • 없으면 user 세션 저장 후 /oauth/contract 리다이렉션

6. 로그인 여부에 따른 페이지 이동

  • 회원가입 진행 (/oauth/contract , /oauth/joinForm , /oauth/joinAction)
    • contractPage(): 세션에 저장된 user 가져와서 확인, 이미 존재하면 자동 로그인
    • joinFormPage(): 세션의 user 정보를 form에 미리 채워줌
    • joinAction(): 전달된 Member 객체로 DB 저장 후 메인 페이지 이동

기능 테스트 (구글 계정)

각 로그인 버튼 클릭 시 팝업 창

 

비회원일 경

약관 동의 후 확인

회원 정보 입력 후 회원가입

이후에 구글 계정으로 로그인 할 시 계정을 선택하면 자동 로그인

(다른 sns계정인 네이버, 카카오도 마찬가지로 회원 조회 후 없으면

약관 페이지 > 회원가입 페이지 > 회원가입

이후로는 자동 로그인으로 같은 흐름으로 진행)


SNS 로그인 회원가입 및 자동 로그인 흐름 정리

1. 소셜 로그인 시작

플랫폼 SDK 또는 OAuth 인증을 통해 사용자가 로그인하면, 사용자 식별 정보(ID, EMAIL, NAME, PROVIDER)를 서버로 전달.

  요청 방식 콜백 경로
카카오 POST + JSON /oauth/kakao/callback
네이버 POST + JSON /oauth/naver/callback
구글 POST + HTML form (application/x-www-form-relencoded) /oauth/google/callback

 

2. 회원 여부 확인

Member kakaoUser = memberService.getMember(user.getId());

Member naverUser = memberService.getMember(user.getId());

Member googleUser = memberService.getMember(user.getId());

 

회원 존재 O > loginUser를 세션에 저장 > 자동 로그인 처리

session.setAttribute("loginUser", kakaoUser);

session.setAttribute("loginUser", naverUser);

session.setAttribute("loginUser", googleUser);

response.put("status", "login_success"); // 카카오, 네이버

return "redirect:/oauth/autoLogin"; // 구글

 

회원 존재 X > user 세션에 임시 저장 > 약관 동의 페이지(/contract)로 이동

session.setAttribute("user", user);

response.put("status", "signup_required"); // 카카오, 네이버

return "redirect:/oauth/contract"; // 구글

 

3. 약관 동의 > 회원가입

  • /oauth/contract : 약관 동의 확인
  • /oauth/joinForm : User 정보로 회원가입 form 자동 입력
  • /oauth/joinAction : DB에 신규 회원 저장

비밀번호는 자동 생성되며 저장됩니다. (member.setPwd(PasswordGenerator.generateRandomPassword());)

 

4. 이후 로그인 시

이미 가입된 SNS 계정으로 로그인하면 곧바로 로그인 처리되며, loginUser 세션을 통해 메인 페이지(/main)로 이동합니다.


아직 부족한 로그인 기능 구현

— 더 공부하고 확장해야 할 부분 정리 —

  • 이메일 인증 기반 가입 처리
    • SNS 로그인 시 제공되는 이메일 정보를 활용해서 사용자에게 이메일 인증을 요구하거나, 이메일 중복 가입 방지 등 보안성 있는 가입 절차.
      • 이메일 인증 메일 발송 후 확인 절차 필요.
      • 인증된 사용자만 회원가입 완료 처리.
      • 기존 사용자와 이메일 중복 시 처리 로직 필요.
  • 자동 로그인 시 마지막 접속일 기록
    • 유저의 활동 이력을 관리하기 위해 자동 로그인 또는 수동 로그인 시점에 마지막 접속일을 기록.
      • 엔티티 클래스인 Member에 필드를 추가하고, 로그인 시점에 해당 날짜를 DB에 저장.
      • 추후 관리자 페이지나 통계 기능에서 활용.
  • 로그인 이력 저장
    • 로그인 시도에 대해서 로그 기록을 남겨 보안상 대응이나, 유저 활동 분석에 활용.
    • 성공 or 실패 여부, 로그인 시각 등을 저장
    • 별도의 테이블을 설계하고, Spring AOP 또는 필터/인터셉터를 활용해 공통 처리.
  • 로그인 실패 / 예외 처리 메시지 세분화
    • 현재는 성공 여부에 따른 단순 응답만 처리하지만 나중에는 상세한 에러 메시지와 상태 코드 분리 처리가 필요.
      • 404 : 회원가입이 필요합니다.(회원 정보 없음)
      • 400 : 잘못된 요청입니다.(잘못된 요청 포맷)
      • 500 : 로그인 중 오류가 발생했습니다.(서버 오류)

이번 SNS 로그인 기능 구현을 통해 세 개의 플랫폼(Kakao, Naver, Google) OAuth 인증 흐름 이해 / 세션 기반 로그인 상태 처리 / 회원가입과 자동 로그인 흐름 통합을 구현해 보았고, 로그인 기능은 단순히 되는 것보다, 안전하게 잘 되는 것이 훨씬 더 중요하다는 걸 이번 구현을 통해 느꼈다. 앞으로도 보완할 부분이 많지만, 계속해서 학습하고 개선하며 안정적이면서 확장 가능한 인증 시스템을 완성해 가고 싶다.