[Spring] Google OAuth 로그인 API 키 발급 및 구글 소셜 로그인 구현

Google Cloud 프로젝트 생성

1. 먼저 아래의 주소로 들어간다.
 

Google 클라우드 플랫폼

로그인 Google 클라우드 플랫폼으로 이동

accounts.google.com

 

빨간색 밑줄친 부분을 선택하고 -> 새 프로젝트 생성을 클릭한다.

 

프로젝트 이름을 적고 만들기를 클릭한다.

 


OAuth2 동의 화면

OAuth 클라이언트 ID를 만들기 전에 먼저 동의 화면을 구성해야 한다.

 

자신이 생성한 프로젝트 이름이 맞는지 확인하고, API 및 서비스 -> 사용자 인증 정보를 클릭한다.

 

먼저 동의 화면을 구성해야한다. 우측의 동의 화면 구성을 클릭한다.

 

조직을 선택하지 않아 내부는 선택이 되지 않는다. 외부를 선택하고 만들기를 클릭하자.

 

앱 이름, 사용자 지원 이메일을 작성하고 다음으로 넘어간다.

다른 첨부할 파일이 있다면 첨부해도 된다. (여기선 없으므로 생략)

 

2.범위 에서 "범위 추가 또는 삭제"를 클릭하여 범위를 추가한다.

기본적으로 자주 사용하는 3종 세트 email, profile, openid를 선택했다.

 

테스트 사용자를 이메일 형식으로 등록한다. 앞에 입력했던 사용자 이메일을 그대로 등록하였다.


OAuth2 클라이언트 ID

동의 화면 구성이 끝났다면 클라이언트 ID를 생성할 수 있다.

 

사용자 인증 정보 만들기 -> OAuth 클라이언트 ID 클릭

 

애플리케이션 유형을 클릭하고 이름을 작성한다.
승인된 리디렉션URI에는 "http://localhost:8080/login/oauth2/code/google" 를 작성한다.

위 주소는 스프링 공식 문서 OAuth Login에 나와 있다.

https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html

 

다음화면으로 넘어가면 이제 클라이언트 ID와 비밀번호가 생성된다.

OAuth API를 사용할 수 있는 키로 기억해두어야 한다.


애플리케이션 설정 및 엔티티 클래스, 레포지토리 생성

🛠 사용기술 스택
- 스프링부트 2.7.12
- Thymeleaf
- Spring Data JPA
- lombok
- oauth2

 

build.gradle에 스프링 시큐리티 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

 

application-oauth.properties 파일 생성
spring.security.oauth2.client.registration.google.client-id=클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트 비밀번호
spring.security.oauth2.client.registration.google.scope=profile,email

 

application.properties 파일이 application-oauth.properties를 포함 하도록 다음 코드 추가
spring.profiles.include=oauth

 

시간정보를 포함하는 엔티티 클래스 ( 생략 가능 )
@Getter
@MappedSuperclass // Entity가 이 클래스를 상속할 경우 이 클래스의 필드들도 칼럼으로 인식
@EntityListeners(AuditingEntityListener.class) // 시간측정 기능 추가
public abstract class BaseTimeEntity { // 인스턴스 생성 방지를 위해 추상클래스로 선언

    @CreatedDate // Entity 생성 시 시간 자동 저장
    private LocalDateTime createdDate;

    @LastModifiedDate // Entity 값 변경 시 시간 자동 저장
    private LocalDateTime modifiedDate;
}

 

사용자 정보를 담당할 User 엔티티 클래스 생성
@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) // Enum값을 어떤 형태로 저장할지 결정합니다. (기본적은 int)
    @Column(nullable = false)
    private MyRole role; // 사용자의 권한을 관리할 Enum 클래스

    @Builder
    public User(String name, String email, String picture, MyRole 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();
    }
}

 

사용자 권한을 관리할 Enum 클래스 생성
@Getter
@RequiredArgsConstructor
public enum MyRole { // 스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다.
    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String string;
}

 

User의 CRUD를 책임질 레포지토리 생성
public interface UserRepository extends JpaRepository<User, Long> {
    // 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입되는 사용자인지 판단하기 위한 메서드
    Optional<User> findByEmail(String email);
}

시큐리티 설정 파일

보기 편하게 프로젝트 패키지 하위에 config/oauth 패키지를 생성하고 시큐리티 관련 클래스를 여기 모두 모아 놓자

 

시큐리티 환경 설정 클래스 생성 - SecurityConfig
@RequiredArgsConstructor
@EnableWebSecurity // Spring Securiy 설정 활성화
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    protected SecurityFilterChain configure(HttpSecurity http) throws Exception {
        // h2-console 화면을 사용하기 위해 해당 옵션을 disable
        http.csrf().disable().headers().frameOptions().disable()
                .and()// URL 별 권환 관리 설정 (authorizeRequests()가 선언되어야만 anyMatchers옵션 사용가능)
                .authorizeRequests()
                // antMatchers를 통해 권환 관리 대상을 지정하고, URL,HTTP 메소드별 관리 가능
                .antMatchers("/", "/css/**", "/images/**", "/js**", "/h2-console/**", "/profile").permitAll() // "/"등 지정된 URL들은 permitAll() 옵션으로 전체 열람 권한 부여
                .antMatchers("/api/v1/**").hasRole(MyRole.USER.name()) // 해당 주소는 USER 권한을 가진 사람만 가능
                .anyRequest().authenticated() // 설정된 값들 이외 나머지 URL들은 모두 인증된 사용자(로그인한)들에게만 허용
                .and()
                .logout().logoutSuccessUrl("/") // 로그아웃 성공시 해당 주소로 이동
                .and()
                .oauth2Login() // OAuth2 로그인 기능에 대한 여러 설정의 진입점
                .userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정 담당
                .userService(customOAuth2UserService) // 소셜 로그인 성공 시 후속 조치를 진행할 userService 인터페이스의 구현체 등록
                // 리소스 서버(소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시 가능.
                .and().defaultSuccessUrl("/", true);
        return http.build();
    }
}

(CustomOAuth2UserService는 만들지 않아 빨간불로 뜨는게 정상이다.)

 

 

OAuthAttributes

구글 사용자 정보를 전달하는 DTO 역할을 한다.

@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) {
        return ofGoogle(userNameAttributeName, attributes);
    }

    // OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야한다.
    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();
    }
    
    // User 엔티티 생성 (생성 시점은 처음 가입할 때)
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(MyRole.USER) // 가입할 때 기본 권한
                .build();
    }
}

 

SessionUser

세션에 사용자 정보를 저장하기 위한 DTO 클래스.

SessionUser은 인증된 사용자 정보만 필요하고, 그 외 정보들은 필요가 없어  name, email, picture만 필드로 선언한다.

@Getter
public class SessionUser implements Serializable {
    // SessionUser는 인증된 사용자 정보만 필요하므로 아래 필드만 선언한다.
    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();
    }
}

 

 

CustomOAuth2UserService

구글 로그인 이후 가져온 사용자의 정보를 기반으로 가입 및 정보 수정, 세션 저장 등의 기능을 지원하는 클래스이다.

@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 {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        // 현재 로그인 진행 중인 서비스를 구분하는 코드 (네이버 로그인인지 구글 로그인인지 구분)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        // OAuth2 로그인 진행 시 키가 되는 필드 값 (Primary Key와 같은 의미)을 의미
        // 구글의 기본 코드는 "sub", 후에 네이버 로그인과 구글 로그인을 동시 지원할 때 사용
        String userNameAttributeName = userRequest.
                getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        // OAuth2UserService를 통해 가져온 OAuthUser의 attribute를 담을 클래스 ( 네이버 등 다른 소셜 로그인도 이 클래스 사용)
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes);
        // User 클래스를 사용하지 않고 SessionUser클래스를 사용하는 이유는 오류 방지.
        httpSession.setAttribute("user", new SessionUser(user)); // SessionUser : 세션에 사용자 정보를 저장하기 위한 Dto 클래스

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

    // 사용자 정보가 변경 될시 User 엔티티에도 반영
    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);
    }
}

@Login 어노테이션 생성

@Login 어노테이션을 파라미터에 붙이면 자동으로 세션에 있는 로그인 회원을 찾아주고, 세션에 없다면 null을 반환하도록 만들어보자.

 

@Login
@Target(ElementType.PARAMETER) // 어노테이션 생성 위치
@Retention(RetentionPolicy.RUNTIME) // 어노테이션 유지 기간
public @interface Login {
}

 

LoginUserArgumentResolver

HandlerMethodArgumentResolver컨트롤러 메서드에서 특정 조건에 맞는 파라미터가 있을 때 원하는 값을 바인딩해주는 인터페이스이다.

 

이를 구현하여 원하는 조건을 설정하여 원하는 값을 반환하도록 하자.

public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    /**
     * 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
     * 여기서는 파라미터에 @Login 어노테이션이 붙어 있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true를 반환한다.
     */
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(Login.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 {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        // 이미 세션이 있다면 그 세션을 돌려주고, 세션이 없으면 null을 돌려준다.
        HttpSession session = request.getSession(false);

        if (session == null) {
            return null;
        }
        return session.getAttribute("user");
    }
}

 

WebConfig

LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가 해주어야 한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginUserArgumentResolver());
    }
}

 


컨트롤러, html 추가 및 결과 확인

해당 코드들에는 Service 로직 코드, 타임리프 레이아웃 등 따로 설명하지 않은 코드가 적용되어 있다.

이렇게 OAuth2를 적용하여 소셜 로그인 기능을 추가할 수 있다는 것만 보자. 

 

HomeController
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final PostsService postsService;

    @GetMapping("/")
    public String home(@Login SessionUser user, Model model) {
        // 세션에 저장된 값이 있을 때만 model에 userName 등록
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
}

 

index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/layout}">
<th:block layout:fragment="content">
    <h1>게시판 웹 서비스</h1>
    <div class="col-md-12">
        <!-- 로그인 기능 영력 -->
        <div class="row">
            <div class="col-md-6">
                <button class="btn btn-primary" type="button">글 등록하러 가기</button>
                <span th:if="${userName}"> <!-- userName이 존재하는 경우 -->
                Logged in as : <span id="user" th:text="${userName}"></span>
                <a href="/logout" role="button" class="btn btn-info active">Logout</a>
                </span>

                <span th:if="${userName == null}"> <!-- userName이 존재하지 않는 경우 -->
                <button class="btn btn-success active" th:onclick="|location.href='@{/oauth2/authorization/google}'" type="button">Google Login</button>
                </span>
            </div>
        </div>
        <br>

 

 

결과

구글 로그인 버튼을 클릭하면 다음과 같이 구글 로그인 화면으로 이동하는 것을 볼 수 있다.

 

게시글을 작성하면 작성한 정보에 맞게 User 엔티티 클래스가 매핑되어 DB에 저장된 것을 확인할 수 있다.

 

데이터베이스를 세션 저장소로 사용

현재는 세션이 내장 톰캣 메모리에 저장되어 애플리케이션을 재시작하면 로그인이 풀리게 된다.

이를 해결하기 위해 데이터베이스를 세션 저장소로 사용하는 방법이 있다.

여러 방법이 있지만 데이터베이스를 세션 저장소로 사용하는 것이 가장 간단한 방법이다.

하지만, 로그인 요청이 많다면 DB IO가 발생해 성능 이슈가 발생할 수 있어 다른 방법을 알아보는 것을 추천한다.

 

build.grdle에 다음과 같이 의존성을 등록하자.

implementation 'org.springframework.session:spring-session-jdbc'

 

그 다음으로 application.properties에 세션 저장소를 jdbc로 선택하도록 코드를 추가한다.

spring.session.store-type=jdbc

 

gitignore에 application-oauth.properties 추가하여 커밋 방지

application-oauth.properties 파일은 구글로그인을 위한 ID와 비밀번호가 포함되어 있기에 git에 올라가는 것을 막기 위해 gitignore에 해당 파일을 추가해줘야한다.

// gitignore가 제대로 작동하지 않을 경우 (캐시 문제로 캐시 내용을 전부 삭제)
git rm -r --cached .
git add .
git commit -m "fixed untracked files"