쇼핑몰만들기

[쇼핑몰만들기] 1. 구글 로그인 구현

진철 2024. 3. 2. 20:36
728x90
반응형

백엔드 개발자라면 API를 잘 사용하는 것도 중요한 영역이라고 생각한다.

따라서 기존에 게시판을 만들 때, 아이디와 비밀번호를 직접 입력하면서 정보를 입력하는 것과 달리 OAuth2 기능을 사용해보기로 했다.

OAuth2는 믿을만한 기관에서 보증을 하는 것이라고 보면 된다.

소셜 미디어나 다른 서비스에 로그인 하려고 할 때, sns를 통한 로그인을 해본 경험이 있을 것이다.

대표적으로 네이버, 카카오, 구글을 통해서 할 수 있는데, 스프링부트에서도 이를 지원한다.

 

1. 구글 클라우드에서 oauth 발급하기

이는 다른 블로그에서도 자세하게 나와있기 때문에 참고하였다.

중요하게 봐야할 점은 리다이렉트 URI 부분이다.

스프링 부트를 통해 구현하는 웹 서비스이기 때문에 우선은 로컬 포트인 8080에 맞게 이를 작성하여야 한다.

발급이 완료되면, 클라이언트 ID와 비밀번호를 잘 보관하도록 하자.

 

2. 설정하기

MySQL과 스프링 부트를 연동할 때, application.properties에 MySQL에 대한 정보를 작성한 적이 있다.

구글 OAuth도 마찬가지로 application.properties에 정보를 작성하면 편하다.

하지만 조금 더 알아본 방법으로는 스프링 부트에서는 application-xxx.properties 파일을 따로 만들어서 관리할 수 있다는 점이었다.

물론 application.properties에 아래와 같이 설정 내용을 추가해줘야 가능하다.

spring.profiles.include=xxx

아무튼 아래와 같이 application-oauth.properties 파일을 새로 생성해서 client ID와 비밀번호, 그리고 구글 클라우드에서 설정한 리다이렉트 URI를 입력해준다.

spring.security.oauth2.client.registration.google.client-id = 
spring.security.oauth2.client.registration.google.client-secret=
spring.security.oauth2.client.registration.google.scope=profile,email
spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google

 

3. User 엔티티와 User 레포지토리 생성하기

구글 로그인이 이루어질 때는 이름과 이메일, Picture와 Role(역할)에 대한 정보가 필요하다고 한다.

따라서 다음과 같이 User 엔티티와 Role enum을 생성하였다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    private String name;

    private String email;

    @Column
    private String picture;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Builder
    public User(String name, String email, String picture, Role 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();
    }
}
@Getter
@RequiredArgsConstructor
public enum Role {

    ADMIN("ROLE_ADMIN", "관리자"),
    USER("ROLE_USER", "사용자");

    private final String key;
    private final String title;
}

그리고 구글 로그인은 이메일을 통해서 이루어지기 때문에 User 레포지토리에 이메일을 통한 검색 메소드를 추가하여 중복 가입 여부를 확인할 수 있게 한다.

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email); // 중복 가입 확인
}

 

4. Config 작성하기

앞서 Role에 대한 정보를 입력한 것을 기억하고 있는가?

스프링 부트의 oauth2는 Role에 따라서 접근할 수 있는 범위를 설정할 수 있다. (Authorization을 등록하는 셈이다.)

따라서 역할에 대한 접근 범위를 아래와 같이 SecurityConfig로 설정해주었다.

이전 블로그들의 자료는 계속 Securityconfigureradaptor(이거 맞나?)를 상속 받아서 작성하던데, 스프링 부트가 업데이트 된 후부터는 filterChain으로 변경되었으니 이를 참고하자.(여기서 고생 좀 한 것 같다 ..)

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(
                        (csrfConfig) -> csrfConfig.disable()
                )
                .headers(
                        (headerConfig) -> headerConfig.frameOptions(
                                frameOptionsConfig -> frameOptionsConfig.disable()
                        )
                )
                .authorizeHttpRequests((authorizeRequest) -> authorizeRequest
                        .requestMatchers("/posts/new", "/comments/save").hasRole(Role.USER.name())
                        .requestMatchers("/login").permitAll()
                        .requestMatchers("/oauth2/authorization/google").permitAll()
                        .requestMatchers("/", "/css/**", "images/**", "/js/**", "/login", "/logout/*", "/posts/**", "/comments/**").permitAll()
                        .anyRequest().authenticated()
                )
                .logout( // 로그아웃 성공 시 / 주소로 이동
                        (logoutConfig) -> logoutConfig.logoutSuccessUrl("/")
                )
                // OAuth2 로그인 기능에 대한 여러 설정
                .oauth2Login(oauth2Login ->
                        oauth2Login
                                .loginPage("/login")  // 로그인 페이지 설정
                                .userInfoEndpoint(userInfoEndpoint ->
                                        userInfoEndpoint
                                                .userService(customOAuth2UserService)
                                )
                );

        return http.build();
    }
}

하지만 여기서 엄청난 문제점을 맞이하고 말았다.

필자가 원하는 것은 로그인 버튼을 눌렀을 때, 로그인 화면이 뜨고 해당 화면에서 "구글 로그인 화면"을 눌러야지 구글 연동 화면이 나오는 것이었다.

하지만 로그인 버튼을 누르면 자동으로 OAuth 화면으로 이동한다는 문제가 발생하였다.

심지어 home 화면에서 로그인 버튼을 누르면 "/ddd"로 이동하게 설정하였는데, 그것을 무시하고 바로 "/login"으로 이동하였다는 점이다.

알고 봤더니, SecurityConfig의 .aouth2Login 메서드에서 .loginPage를 잘못 설정해서 발생한 문제였다.

따라서 해당 주소를 아래와 같이 수정하여 문제를 해결할 수 있었다.

.oauth2Login(oauth2Login ->
        oauth2Login
                .loginPage("/oauth2/authorization/google")  // 로그인 페이지 설정
                .userInfoEndpoint(userInfoEndpoint ->
                        userInfoEndpoint
                                .userService(customOAuth2UserService)
                )
);

 

5. CustomOAuth2UserService 작성하기

해당 클래스는 구글 로그인 이후 가져온 사용자의 정보(email, name, picture)들을 기반으로 가입 및 정보수정, 세션 저장을 자동으로 할 수 있도록 한다.

@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와 같은 의미)
        // 구글의 경우 기본적으로 코드를 지원
        // 하지만 네이버, 카카오 등은 기본적으로 지원 X
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        // OAuth2UserService를 통해 가져온 OAuth2User의 attribute 등을 담을 클래스
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        // 사용자 저장 또는 업데이트
        User user = saveOrUpdate(attributes);

        // 세션에 사용자 정보 저장
        httpSession.setAttribute("user", new SessionUser(user));

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

    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
                // 구글 사용자 정보 업데이트(이미 가입된 사용자) => 업데이트
                .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                // 가입되지 않은 사용자 => User 엔티티 생성
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}

그리고 추가적으로 해당 User에 대한 정보를 전달하는 OAuthAttributes와 User를 직렬화하여 전달하는 SessionUser를 작성한다.

User를 바로 사용하지 않고 굳이 SessionUser를 사용하는 이유는 다음과 같다.

세션에 저장하기 위해 User클래스를 세션에 저장하려고 하니 User 클래스에 직렬화를 구현하지 않았다는 에러가 난다.

Entity 클래스에는 언제 다른 엔티티와의 관계가 형성될지 모르기 때문에 직렬화 코드를 넣지 않는게 좋다.

뿐만 아니라, @OneToMany, @ManyToMany등 자식 엔티티를 갖고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다.

따라서 User를 직접적으로 사용하지 않고 이를 직렬화 기능을 가진 세션 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;
    }

    // OAuth2User에서 반환하는 사용자 정보는 Map
    // 따라서 값 하나하나를 변환해야 한다.
    public static OAuthAttributes of(String registrationId,
                                     String userNameAttributeName,
                                     Map<String, Object> attributes) {

        return ofGoogle(userNameAttributeName, attributes);
    }

    // 구글 생성자
    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(Role.USER)
                .build();
    }
}
@Getter
public class SessionUser implements Serializable { // 직렬화 기능을 가진 세션 DTO

    // 인증된 사용자 정보만 필요 => name, email, picture 필드만 선언
    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();
    }
}

 

6. 또 다른 문제

해당 코드들을 통해서 구글 로그인이 잘 작동되는 것을 확인할 수 있었다.

하지만, 구글 로그인 후 로그아웃을 하고 다시 로그인을 하니 계속 400 에러가 발생하였다.

이는 브라우저의 캐시를 삭제하고 다시 접근하니 문제가 해결되었다.

아마, 세션을 통해 로그인이 되었다고 생각하는데, 계속 로그인을 시도하려는 접근 때문에 발생한 오류이지 않나 생각이 든다.

 

728x90
반응형