OAuth2를 이용한 소셜 로그인 구현

Bam·2026년 1월 8일

projects

목록 보기
7/7
post-thumbnail

프로젝트 개요

  • OAuth2를 사용해서 소셜 로그인(구글 & 카카오)를 하는 간단한 예제 프로젝트
  • 회원가입, 로그인, 로그아웃, 현재 사용자 출력 네 가지 기능만 제공
  • OAuth2를 제외한 기술들에 대해 어느정도 이해가 있다는 가정 하에 작성

여기서는 구현만을 다루기 때문에 OAuth2 등의 세부 내용은 다루지 않습니다.
해당 내용은 별개 포스트로 따로 올리려고 합니다.

사용 기술

  • Thymeleaf, Java, Spring Boot 4.0, OAuth2, Spring Security

DB 연동 밎 조작에는 MySQL JDBC를 사용하였습니다.


화면 구성

화면은 로그인, 회원가입, 메인(사용자 출력) 세 가지 화면으로 구성됩니다.

로그인

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>로그인</title>
</head>
<body>
<h2>로그인</h2>
<form th:action="@{/login}" method="post">
  <div>
    <label>아이디: </label>
    <input type="text" name="username" placeholder="아이디를 입력하세요">
  </div>
  <div>
    <label>비밀번호: </label>
    <input type="password" name="password" placeholder="비밀번호를 입력하세요">
  </div>
  <button type="submit">로그인</button>
</form>

<hr>

<div>
  <a th:href="@{/oauth2/authorization/google}">
    <img src="https://developers.google.com/identity/images/btn_google_signin_dark_normal_web.png" alt="구글 로그인"
         style="height: 40px;">
  </a>
  <br>
  <a th:href="@{/oauth2/authorization/kakao}">
    <button style="background-color: #FEE500; border: none; height: 40px; cursor: pointer;">카카오 로그인</button>
  </a>
</div>

<hr>

<p>아직 회원이 아니신가요? <a th:href="@{/register}">회원가입 하러가기</a></p>
</body>
</html>

  • 구글, 카카오 로그인 버튼(<a>)을 누르면 href 속성에 의해 oauth를 통한 해당 인증 페이지로 이동합니다.

회원가입

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>회원가입</title>
</head>
<body>
<h2>회원가입</h2>
<form th:action="@{/users/register}" method="post">
  <div>
    <label>아이디: </label>
    <input type="text" name="username" required>
  </div>
  <div>
    <label>비밀번호: </label>
    <input type="password" name="password" required>
  </div>
  <button type="submit">가입하기</button>
</form>
<br>
<a th:href="@{/login}">이미 계정이 있나요? 로그인 페이지로</a>
</body>
</html>

메인 & 사용자 출력 페이지

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
<h1>메인 페이지</h1>

<div sec:authorize="isAuthenticated()">
  <p>
    <span th:if="${#authentication.principal instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)}"
          th:text="${#authentication.principal.attributes['username']}">소셜유저</span>

    <span th:unless="${#authentication.principal instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)}"
          th:text="${#authentication.principal.username}">일반유저</span>
    님, 안녕하세요!
  </p>

  <form th:action="@{/logout}" method="post">
    <button type="submit">로그아웃</button>
  </form>
</div>

<div sec:authorize="isAnonymous()">
  <p>로그인이 필요합니다.</p>
  <a th:href="@{/login}">로그인 페이지로 이동</a>
</div>
</body>
</html>


<div sec:authorize="isAuthenticated()">
  <!-- 로그인 -->
</div>

<div sec:authorize="isAnonymous()">
  <!-- 미로그인 -->
</div>
  • 사용자의 로그인 상태 여부에 따라 "로그인이 필요합니다" 또는 "000님 안녕하세요 + 로그아웃 버튼"을 표시합니다. 인증 여부는 <div sec:authorize="isAuthemticated()/isAnonymous()">에 의해 판단되어 맞는 화면을 표시하게 됩니다.
<div sec:authorize="isAuthenticated()">
  <p>
    <span th:if="${#authentication.principal instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)}"
          th:text="${#authentication.principal.attributes['username']}">소셜유저</span>

    <span th:unless="${#authentication.principal instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)}"
          th:text="${#authentication.principal.username}">일반유저</span>
    님, 안녕하세요!
  </p>

  <form th:action="@{/logout}" method="post">
    <button type="submit">로그아웃</button>
  </form>
</div>
  • 현재 로그인 된 사용자를 가져옵니다. authentication.principal
  • 이후 해당 유저가 소셜 유저인지 일반 유저인지 타입을 확인합니다.
    th:if/unless 와 instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)
  • 이때 우리는 "000님 안녕하세요"와 같이 username을 필요로 하므로 attribute를 통해 가져옵니다. 실제 보내주는 역할은 잠시 뒤 백엔드 로직의 Service 단에서 확인할 수 있습니다.

백엔드

DB 테이블 구조

CREATE TABLE `users` (
    `uid` INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(255) NOT NULL,
    `password` VARCHAR(255) NOT NULL,
    `provider` VARCHAR(255) DEFAULT 'LOCAL',
    `email` VARCHAR(255),
);
  • 로그인, 메시지 출력에 사용할 username
  • 소셜 로그인 정보와 구조을 맞추기 위한 password, email -> 따로 사용하지는 않습니다.
  • 로그인 주체를 나타낼 provider
    • 직접 회원가입 하는 경우 'LOCAL', 소셜 로그인을 하는 경우 'google/kakao'가 들어갑니다.

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
			.authorizeHttpRequests(auth -> auth.requestMatchers("/", "/login", "/register", "/users/**").permitAll()
				.anyRequest().authenticated())
			.formLogin(form -> form.loginPage("/login")
            .defaultSuccessUrl("/", true).permitAll())

            //소셜 로그인(OAuth2) 설정
            .oauth2Login(oauth2 -> oauth2.loginPage("/login")
            	.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
                .defaultSuccessUrl("/", true))
            
            //로그아웃 설정
            .logout(logout -> logout.logoutSuccessUrl("/").invalidateHttpSession(true));

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}
  • Securiry filter chain 설정은 다음과 같습니다.

    • 개발 환경 및 간단 프로젝트라서 csrf.disable() 상태로 설정합니다. 실제 운영 코드에서는 disable 상태이면 안됩니다. (여기서는 이 내용이 중요하지 않아 다루지 않음!!!)
    • /, /login, /register, /users/ 요청에 대해서 별다른 인증 없이도 접근을 허용합니다. 마찬가지로 실제 서비스는 일부 접근에 대해 인증을 요구해야합니다.
    • formLogin()으로 로그인 페이지로 커스텀 로그인 페이지(login.html)을 설정합니다. 이 설정이 로그인 요청 시 가로채어 일반/소셜 로그인을 수행하므로 컨트롤러에서는 로그인 요청을 따로 명시하지 않습니다.
    • oauth2Login()으로 OAuth2를 이용한 로그인 기능을 설정합니다. userService(customOAuth2Userservice)를 이용해 소셜(구글과 카카오)로부터 받은 유저 정보로 회원가입 또는 로그인 등의 정보를 받습니다.
    • logout 설정을 통해 로그아웃으로 세션을 무효화하고 메인 페이지로 이동시킵니다
  • passwordEncoder는 비밀번호 암호화 없이 저장하기 위한 내용으로 실제 서비스에서는 반드시 암호화 과정을 거쳐야합니다. 여기서는 단순 데이터 삽입이 중요해서 암호화 과정을 생략하기 위해 작성했습니다.

Controller

PageController

@Controller
@RequiredArgsConstructor
public class PageController {

    @GetMapping("/")
    public String index() {
        return "index";
    }

    @GetMapping("/login")
    public String login() {
        return "login";
    }

    @GetMapping("/register")
    public String register() {
        return "register";
    }
}
  • 각 페이지 간 이동만 담당합니다.

UserController

@Controller
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {

    private final UserSaveService userSaveService;

    @PostMapping("/register")
    public String register(@ModelAttribute UserRegisterRequest userRegisterRequest) {
        userSaveService.save(userRegisterRequest);
        return "redirect:/login";
    }
}
  • 일반 회원가입 방식의 회원가입 요청을 처리합니다.
  • 회원가입 후 로그인 페이지로 이동합니다.

DTO, Entity

//UserRegisterRequest.java
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserRegisterRequest {

    private String username;
    private String password;
}
  • 일반 회원가입 시 폼에 입력한 정보를 서비스단에 전달하는 DTO
//UserEntity.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEntity {

    private Long uid;
    private String username;
    private String password;
    private String email;
    private String provider;
}
  • 데이터 베이스 테이블과 1:1 매칭될 자바 객체인 엔티티입니다.

Repository

@Repository
@RequiredArgsConstructor
public class UserRepository {

    private final JdbcTemplate jdbcTemplate;

    public void save(UserEntity userEntity) {
        String sql = "INSERT INTO `users` (username, password, email, provider) VALUES (?, ?, ?, ?)";
        jdbcTemplate.update(sql,
                userEntity.getUsername(),
                userEntity.getPassword(),
                userEntity.getEmail(),
                userEntity.getProvider()
        );
    }

    public boolean existsByUsername(String username) {
        String sql = "SELECT COUNT(*) FROM users WHERE username = ?";
        Integer count = jdbcTemplate.queryForObject(sql, Integer.class, username);
        return count != null && count > 0;
    }

    public boolean existsByEmail(String email) {
        String sql = "SELECT COUNT(*) FROM users WHERE email = ?";
        Integer count = jdbcTemplate.queryForObject(sql, Integer.class, email);
        return count != null && count > 0;
    }

    public Optional<UserEntity> findByUsername(String username) {
        String sql = "SELECT * FROM users WHERE username = ? AND provider = 'LOCAL'";

        try {
            UserEntity user = jdbcTemplate.queryForObject(sql, (rs, rowNum) ->
                            UserEntity.builder()
                                    .uid(rs.getLong("uid"))
                                    .username(rs.getString("username"))
                                    .password(rs.getString("password"))
                                    .provider(rs.getString("provider"))
                                    .email(rs.getString("email"))
                                    .build()
                    , username);
            return Optional.ofNullable(user);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
}
  • 일반/소셜 회원가입 시 유저 정보를 DB에 저장하는 save()
  • 가입 정보 중복 체크에 사용할 existsByXxxxx()
  • 일반 회원 로그인 시 DB에서 해당 유저를 탐색하는 findByUsername()

Service

UserSaveService

@Service
@Transactional
@RequiredArgsConstructor
public class UserSaveService {

    private final UserRepository userRepository;

    public void save(UserRegisterRequest userRegisterRequest) {
        if (userRepository.existsByUsername(userRegisterRequest.getUsername())) {
            throw new RuntimeException("username already exists.");
        }

        UserEntity userEntity = UserEntity.builder()
                .username(userRegisterRequest.getUsername())
                .password(userRegisterRequest.getPassword())
                .email("NONE")
                .provider("LOCAL")
                .build();
        userRepository.save(userEntity);
    }
}
  • 일반 회원가입 서비스를 제공하는 서비스 레이어입니다.
  • 폼 -> 컨트롤러를 통해 전달된 UserRegisterRequest DTO를 받아 중복 검사 후 엔티티로 변환하여 DB 저장을 수행합니다.

CustomOAuth2UserService

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        String email;
        String username;

        // 1. 서비스별 정보 추출
        if ("google".equals(provider)) {
            email = oAuth2User.getAttribute("email");
            username = oAuth2User.getAttribute("name");
        } else { // kakao
            Map<String, Object> attributes = oAuth2User.getAttributes();
            Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
            Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

            email = (String) kakaoAccount.get("email");
            username = (String) profile.get("nickname");
        }

        // 2. DB 저장 (이미 있다면 무시, 없다면 신규 저장)
        saveOrUpdate(provider, email, username);

        // 3. 화면 출력을 위해 'name' 속성을 강제로 주입
        Map<String, Object> modifiedAttributes = new HashMap<>(oAuth2User.getAttributes());
        modifiedAttributes.put("username", username); // 구글/카카오에서 가져온 실명을 'name'에 저장

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("USER")),
                modifiedAttributes,
                userNameAttributeName
        );
    }

    private void saveOrUpdate(String provider, String email, String username) {
        if (!userRepository.existsByEmail(email)) {
            UserEntity userEntity = UserEntity.builder()
                    .username(username)
                    .password("oauth")
                    .email(email)
                    .provider(provider)
                    .build();
            userRepository.save(userEntity);
        }
    }
}

@Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        String email;
        String username;

        // 1. 서비스별 정보 추출
        if ("google".equals(provider)) {
            email = oAuth2User.getAttribute("email");
            username = oAuth2User.getAttribute("name");
        } else { // kakao
            Map<String, Object> attributes = oAuth2User.getAttributes();
            Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
            Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

            email = (String) kakaoAccount.get("email");
            username = (String) profile.get("nickname");
        }

        // 2. DB 저장 (이미 있다면 무시, 없다면 신규 저장)
        saveOrUpdate(provider, email, username);

        // 3. 화면 출력을 위해 'name' 속성을 강제로 주입
        Map<String, Object> modifiedAttributes = new HashMap<>(oAuth2User.getAttributes());
        modifiedAttributes.put("username", username); // 구글/카카오에서 가져온 실명을 'name'에 저장

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("USER")),
                modifiedAttributes,
                userNameAttributeName
        );
    }
  • OAuth2User를 통해 소셜 로그인 대상 유저 정보를 받아옵니다.
  • provider는 서비스 제공자 여기서는 구글 또는 카카오를 의미합니다.
  • 소셜 서비스 제공자마다 제공되는 데이터 형태가 조금씩 달라서 name, email과 같은 정보들을 다르게 가공해서 가져옵니다.
  • 화면에서 name님 안녕하세요라는 문구를 가져오기 때문에 username을 따로 가져와서 Attribute에 삽입합니다.
  • return 구문에서 사용자 정보와 권한을 주고 스프링 시큐리티에게 해당 사용자 정보를 세션에 등록하라고 넘깁니다.
private void saveOrUpdate(String provider, String email, String username) {
        if (!userRepository.existsByEmail(email)) {
            UserEntity userEntity = UserEntity.builder()
                    .username(username)
                    .password("oauth")
                    .email(email)
                    .provider(provider)
                    .build();
            userRepository.save(userEntity);
        }
    }
  • loadUser의 2단계에서 사용하는 사용자 저장 메소드입니다.
  • 이메일 기준으로 검색하여 이미 존재하면 별다른 동작을 하지 않고, 존재하지 않는 경우에 한해 DB 저장을 수행합니다.

CustomUserDetailService

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not exist"));

        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .roles("USER")
                .build();
    }
}
  • 스프링 시큐리티가 일반 사용자 로그인을 처리할 때 DB로 부터 정보를 가져와 처리하기 위한 서비스 레이어입니다.
  • DB로부터 입력된 username을 대조해 유저 정보를 가져오고 이를 스프링 시큐리티가 이해하고 사용할 수 있는 UserDetails라는 객체로 만들어서 반환합니다.

0개의 댓글