스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현 - 루타블의 개발일기

김주영·2022년 7월 9일
1
post-thumbnail

[본 글은 프로젝트 과정을 기록할 목적으로 작성되었으며 아래 교재에 기반하여 작성됨]

🌱 스프링 시큐리티와 스프링 시큐리티 Oauth2 클라이언트


  • 많은 서비스에서 소셜 로그인을 이용하는 이유는 다양한 로그인 관련 기능을 구글, 네이버, 페이스북 등에 맡기고 개발에 집중할 수 있기 때문이다.

🌿 spring boot 1.5 vs spring boot 2.0

OAuth2 연동 방법이 2.0에서 크게 변경되었지만 인터넷 자료들을 보면 설정 방법에 크게 차이가 없는 경우를 자주 본다. 이는 spring-security-oauth2-autoconfigure 라이브러리 덕분이다.

spring-security-oauth-autoconfigure 라이브러리를 사용할 경우 스프링 부트2에서도 1.5에서 쓰던 설정을 그대로 사용할 수 있다. 하지만 여기서는 스프링 부트 2 방식인 Spring Security Oauth2 Client 라이브러리를 사용해서 진행한다. 이유는 다음과 같다.

  1. 스프링 팀에서 기존 1.5에서 사용되던 spring-security-oauth 프로젝트는 유지 상태로 결정했으며 신규 기능은 추가하지 않고 버그 수정 정도의 기능만 추가될 예정, 신규 기능은 새 oauth2 라이브러리에서만 지원하겠다고 선언

  2. 스프링 부트용 라이브러리(starter) 출시

  3. 기존에 사용되던 방식은 확장 포인트가 적절하게 오픈되어 있지 않아 직접 상속하거나 오버라이딩 해야 하고 신규 라이브러리의 경우 확장 포인트를 고려해서 설계된 상태

📝 (참고) 스프링 부트 2 방식의 자료 찾는 요령

  1. spring-security-oauth2-autoconfigure 라이브러리를 썼는지를 확인

  2. application.properties 혹은 application.yml 정보 차이가 있는지 확인한다.

스프링 부트 1.5방식에서는 url 주소를 모두 명시해야 하지만, 2.0 방식에서는 client 인증 정보만 입력하면 된다. 또한, CommonOAuth2Provider라는 enum이 새롭게 추가되어 구글, 깃허브, 페이스북, 옥타의 기본 설정값은 모두 여기서 제공한다.

🌱 구글 서비스 등록


🔧 구글 서비스에 신규 서비스를 생성한다.

여기서 발급된 인증 정보(clientId와 clientSecret)를 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있어 무조건 발급받아야 한다.

🔧 구글 클라우드 플랫폼 주소(https://console.cloud.google.com)로 이동한다. 그리고 다음과 같이 [프로젝트 선택] 탭을 클릭한다.

🔧 [새 프로젝트] 클릭

🔧 등록될 서비스의 이름을 입력한다. 원하는 이름으로 지으면 된다.

🌿 OAuth 동의 화면 구성

🔧 생성이 완료된 프로젝트를 선택하고 왼쪽 메뉴 탭을 클릭해서 API 및 서비스 카테고리로 이동한다.

🔧 [사용자 인증 정보]를 클릭하고 [사용자 인증 정보 만들기] 버튼을 클릭한다.

🔧 [OAuth 클라이언트 ID]로 소셜 로그인을 구현할 것이기 때문에 해당 항목을 클릭한다.

🔧 클라이언트 ID가 생성되기 전에 동의 화면 구성이 필요하므로 안내에 따라 [동의 화면 구성] 버튼을 클릭한다.

🔧 User Type을 선택하는 화면이 나오는데, 여기서 외부로 해야 모든 구글 유저가 사용할 수 있다고 한다.

🔧 OAuth 동의 화면에는 필수 입력 탭이 3개 있었다. 앱 이름, 사용자 지원 이메일, 개발자 연락처 정보 등이다.

  1. 앱 이름 : 구글 로그인 시 사용자에게 노출될 애플리케이션 이름이다.

  2. 지원 이메일 : 사용자 동의 화면에서 노출될 이메일 주소이다. 보통은 서비스의 help 이메일 주소를 사용하지만, 여기서는 독자의 이메일 주소를 사용하면 된다고 한다.

🔧 범위는 이번에 등록할 구글 서비스에서 사용할 범위 목록이다. 기본값인 email/profile/openid로 설정했다.

🔧 테스트 사용자는 게시 상태가 테스트 중일 때, 앱에 접근 가능한 계정을 설정하는 것이라고 한다. 최대 100개까지 가능하다.

🌿 OAuth 클라이언트 ID 만들기

🔧 다시 한번, [인증 정보 만들기]를 클릭한 후, [OAuth 클라이언트 ID]를 클릭한다.

🔧 애플리케이션 유형을 웹 애플리케이션으로 하고 앱 이름을 작성한다.

🔧 URL 주소를 등록하기 위해 [승인된 리다이렉션 URI] 항목을 현재는 개발 단계이므로 localhost:8080으로 등록한다.

📢 승인된 리다이렉션 URI

  • 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL

  • 스프링 부트 2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원하고 있다.

  • 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없다. 시큐리티에서 이미 구현해 놓은 상태이다.

  • AWS 서버에 배포하게 되면 localhost 외에 추가로 주소를 추가해야하며, 이후 단계에서 진행하겠다.

클라이언트 ID가 생성된 것을 볼 수 있다.

🔧 아래 정보를 이용하여 클라이언트 ID와 클라이언트 보안 비밀 코드를 프로젝트에서 설정하겠다.

🌿 프로젝트에 구글 서비스 등록

🌳 application-oauth 등록

🔧 src/main/resources 패키지 아래 application-oauth.properties를 생성한다.

🔧 클라이언트 id와 클라이언트 보안 비밀 코드를 등록한다.

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

스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다. 즉, profile=xxx라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있다.

📢 scope=profile,email

  • 기본값이 openid, profile, email이기 때문에 많은 예제에서는 scope를 별도로 등록하지 않고 있다.

  • 강제로 email, profile를 등록한 이유는 openid라는 scope가 있으면 Open Id Provider로 인식하기 때문이다.

  • 이렇게 되면 OpenId Provider인 서비스(구글)와 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 한다.

  • 하나의 OAuth2Service로 사용하기 위해 일부러 openid scope를 빼고 등록한다.

🌳 application.properties에 oauth 활성 코드 추가

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.h2.console.enabled=true
spring.profiles.include=oauth //추가

🌳 .gitignore 등록

구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들이므로 외부에 노출되지 않게 application-oauth.properties 파일을 .gitignore에 등록한다.

# Created by .ignore support plugin (hsz.mobi)

.gradle
.idea

application-real-oauth.properties

🔎 application-oauth.properties가 커밋 목록에 노출되는 문제

Git의 캐시 문제 때문으로 다음 git 명령어로 캐시를 삭제한 후 다시 추가하여 커밋하면 된다.

git rm -r --cached .
git add .
git commit -m "fixed untracked files"

마지막으로 push를 누르면 앞서 커밋한 fixed untracked files가 나온다. 푸쉬 하면 application-oauth.properties 상단에 "You are editing a file which is ignored" 라고 뜬다. 그리고 파일 이름의 색도 앞에 .gitignore에 추가된 파일처럼 흐려진다.

github를 확인해보면 앞서 추가되었던 application-oauth.properties가 보이지 않게 되었다!

ref : https://jojoldu.tistory.com/307

🌱 구글 로그인 연동


🔧 domain 패키지 아래 user 패키지를 생성

🔧 user 패키지에 사용자 정보를 담당할 도메인인 User 클래스를 생성

🌿 User

package com.bbs.projects.bulletinboard.domain.user;

import com.bbs.projects.bulletinboard.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@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)
    @Column(nullable = false)
    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();
    }
}

id를 PK로 하고 name, email, picture, role을 속성 값으로 하는 User 엔티티를 생성했다. id는 auto_increment로 생성하고 나머지는 빌더를 통해 객체 생성 시점에 초기화할 수 있다. 또한, update를 수행할 경우 name과 picture를 수정하고, 수정한 엔티티를 반환한다.

📢 @Enumerated(EnumType.ORDINAL)

저장된 값 순서에 따른 인덱스가 DB에 저장된다. 숫자로 저장되면 DB로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없다는 단점이 있다.

📢 @Enumerated(EnumType.STRING)

JPA를 이용하여 DB로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정한다. 이것은 문자열로 저장할 수 있도록 선언한 것이다.

🌿 Role

🔧 각 사용자의 권한을 관리할 Enum 클래스 Role을 user 패키지 아래 생성

package com.bbs.projects.bulletinboard.domain.user;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Role {

	//guest와 user라는 권한 목록 생성
    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

	//각 권한이 가질 필드 선언 + 생성자 주입
    private final String key;
    private final String title;

}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다.

🌿 UserRepository

🔧 User의 CRUD를 책임질 UserRepository를 user 패키지 아래 생성

package com.bbs.projects.bulletinboard.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    //email로 신규 가입자인지 조회
    Optional<User> findByEmail(String email);

}

JpaRepository를 상속받았기 때문에 자동 빈 등록되고, CRUD 인터페이스를 사용할 수 있다. 여기서는 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단한다.

🌿 build.gradle

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.projectlombok:lombok')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
    compile('org.springframework.boot:spring-boot-starter-mustache')
    compile('org.springframework.boot:spring-boot-starter-oauth2-client') //추가
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성이다.

spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.

🔧 앞으로 시큐리티 관련 클래스는 모두 config.auth 패키지에 담도록 하겠다.

🌿 SecurityConfig

package com.bbs.projects.bulletinboard.config.auth;

import com.bbs.projects.bulletinboard.domain.user.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
		// 인가 정책 설정
        http
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                    .authorizeRequests() //요청에 대한 보안 검사 실행
                    .antMatchers("/", "/css/**", "/images/**", 
                    "/js/**", "/h2-console/**").permitAll() //해당 URL은 모두 허용
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name()) //권한 제한
                    .anyRequest().authenticated() //어떠한 요청에도 인증을 받도록 설정
                .and()
                	//로그아웃 처리
                    .logout()
                    .logoutSuccessUrl("/")
                .and()
                	//로그인 성공 후 처리
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService);

    }

}

스프링 시큐리티 설정을 활성화하고, 사용자 로그인 인증을 구현한다. 이것은 WebSecurityConfigurerAdapter의 configure()를 오버라이드하여 진행한다. 요청 검사와 URL 필터링, 그리고 로그아웃 처리, 로그인 성공 후 처리 등을 수행했다.

📢 @EnableWebSecurity

스프링 시큐리티 설정들을 활성화시켜 준다.

📢 WebSecurityConfigurerAdapter

스프링 시큐리티의 의존성을 추가한 경우 실행된다. 이것은 스프링 시큐리티의 웹 보안 기능의 초기화 및 설정들을 담당하고, 내부적으로 getHttp() 메소드가 실행될 때 HTTPSecurity 클래스를 생성하게 된다.

📢 HttpSecurity

인증/인가 API들의 설정을 제공한다.

//WebSecurityConfigureAdapter.java파일의 일부
protected final HttpSecurity getHttp() throws Exception {
		if (this.http != null) {
			return this.http;
		}    
		.....
        
        	if (!this.disableDefaults) {
			applyDefaultConfiguration(this.http); // defualt conf icuration적용		
            		.....   
                    
		}
		configure(this.http);
        // 이곳의 configure 메서드를 override하면 우리가 원하는 보안체계를 만들 수 있다.
		return this.http;
	}
    

우리가 인증/인가의 설정을 바꾸고자 한다면 WebSecurityConfigurerAdapter 클래스를 상속한 SecurityConfig 클래스를 생성하여 configure(HttpSecurity http) 메소드를 오버라이드하며 설정해야 한다.

📢 csrf().disable().headers().frameOptions().disable()

h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.

".headers().frameOptions().disable()"는 스프링 시큐리티에서 iframe 보안 이슈로 추가된 Http 헤더다. 이것이 추가되면 iframe 기능이 정상 작동하지 않는다. iframe을 불가피하게 사용할 경우가 아니라면 비활성화하자.

ref : https://www.slipp.net/questions/415

📢 authorizeRequests

URL별 권한 관리를 설정하는 옵션의 시작점이다. 이것이 선언되어야만 antMatchers 옵션을 사용할 수 있다.

📢 antMatchers

권한 관리 대상을 지정하는 옵션이다. URL, HTTP 메소드별로 관리가 가능하다. 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 주었고, "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했다.

📢 anyRequest.authenticated

설정된 값들 이외 나머지 URL들은 어떠한 요청에도 인증을 받도록 한다. 즉, 나머지 URL들은 로그인한 인증된 사용자여야 한다.

ref : https://velog.io/@seongwon97/security

📢 logout().logoutSucceessUrl("/")

로그아웃 기능에 대한 여러 설정의 진입점이다. 로그아웃 성공 시 루트 주소로 이동한다.

📢 oauth2Login

OAuth2 로그인 기능에 대한 여러 설정의 진입점이다.

📢 userInfoEndpoint

OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.

📢 userService

소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다. 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.

🌿 CustomOAuth2UserService

🔧 config.auth 패키지 아래 CustomOAuth2UserService 생성

package com.bbs.projects.bulletinboard.config.auth;

import com.bbs.projects.bulletinboard.config.auth.dto.OAuthAttributes;
import com.bbs.projects.bulletinboard.config.auth.dto.SessionUser;
import com.bbs.projects.bulletinboard.domain.user.User;
import com.bbs.projects.bulletinboard.domain.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpSession;
import java.util.Collections;

@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의 구현체인 DefaultOAuth2UserService를 받음
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        //구현체를 통해 userRequest에 있는 정보를 빼낼 수 있음
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        //로그인 진행 중인 서비스 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        //로그인 진행 시 키가 되는 값
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();

        //OAuth2UserService를 통해 가져온 OAuth2User의 attributes를 담음
        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) {

        //email을 통해 가입자 여부 구분
        User user = userRepository.findByEmail(attributes.getEmail())
                //미가입자는 toEntity로 User 엔티티를 생성, 기가입자는 name과 picture 갱신
                .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                .orElse(attributes.toEntity());

        //처리가 끝난 User를 DB에 저장
        return userRepository.save(user);

    }

}

구글 로그인 이후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원한다. 최종적으로 로그인이 성공한 인증된 사용자를 OAuth2UserService의 구현체를 통해 반환한다.

saveOrUpdate()에서 이메일을 통해 가입 여부를 확인하고, 미가입자는 toEntity()를 통해 엔티티를 생성한다. 기가입자의 경우, name이나 picture가 변경되면 User 엔티티에 반영되도록 한다.

📢 OAuth2UserService

해당 인터페이스를 구현한 클래스는 Client에 부여된 Access Token을 사용하여 UserInfo Endpoint에서 최종 사용자(리소스 소유자)의 사용자 속성을 가져오고 OAuth2User 형태로 인증된 주체를 반환하는 역할을 한다. 즉, 로그인을 시도한 사용자의 인증을 담당한다.

📢 OAuth2UserRequest

OAuth2UserService가 UserInfo Endpoint에 대한 요청을 시작할 때 사용하는 요청이다.

public class OAuth2UserRequest {
	private final ClientRegistration clientRegistration;
	private final OAuth2AccessToken accessToken;
	private final Map<String, Object> additionalParameters;
    
    ...
    
    public OAuth2UserRequest(ClientRegistration clientRegistration, OAuth2AccessToken accessToken,
								Map<String, Object> additionalParameters) {
		Assert.notNull(clientRegistration, "clientRegistration cannot be null");
		Assert.notNull(accessToken, "accessToken cannot be null");
		this.clientRegistration = clientRegistration;
		this.accessToken = accessToken;
		this.additionalParameters = Collections.unmodifiableMap(
				CollectionUtils.isEmpty(additionalParameters) ?
				Collections.emptyMap() : new LinkedHashMap<>(additionalParameters));
	}
    
    ...
    
public final class ClientRegistration implements Serializable {
	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
	private String registrationId;
	private String clientId;
	private String clientSecret;
	private ClientAuthenticationMethod clientAuthenticationMethod = ClientAuthenticationMethod.BASIC;
	private AuthorizationGrantType authorizationGrantType;
	private String redirectUriTemplate;
	private Set<String> scopes = Collections.emptySet();
	private ProviderDetails providerDetails = new ProviderDetails();
	private String clientName;
    
    ...
    

OAuth2UserRequest와 인자 ClientRegistration의 일부이다. OAuth2UserRequest는 UserInfo Endpoint에 접근하는데 필요한 Access Token과 클라이언트 보안 및 서비스 정보의 식별 값을 가진 ClientRegistration 등을 멤버변수로 갖고 있다.

📢 OAuth2User

각 사용자의 first name, middle name, gender, email, phone number, address 등과 같은 하나 이상의 특성(속성)으로 구성된다. 각 사용자 속성은 이름과 값으로 구성되며, getAttribute()의 이름으로 키가 지정된다. 즉, "name", "email" 과 같은 이름으로 get()하여 값을 가져오는 식으로 사용할 수 있다. 사용자의 속성값 집합이라고 생각하면 될 것 같다.

📢 DefaultOAuth2User

이것은 OAuth2User 인터페이스의 구현체다. SimpleGrantedAuthority를 통해 인증된 사용자가 가진 권한 문자열을 저장한다. 이것을 singleton을 통해 컬렉션에 저장한다. 최종적으로, 인증된 사용자 권한, 속성값들, 로그인 식별값 등을 매개변수로 한 인증된 사용자를 반환한다.

📢 registraionId

현재 로그인 진행 중인 서비스를 구분하는 코드로 지금은 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용한다.

📢 userNameAttributeName

OAuth2 로그인 진행 시 키가 되는 필드값이다. 즉, Primary Key와 같은 의미를 가진다. 구글의 경우 기본적으로 지원하지만, 네이버 카카오 등은 기본 지원하지 않는다. 구글의 기본 코드는 "sub"이다. 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용된다.

📢 HttpSession

두 개 이상의 페이지 요청 또는 웹 사이트 방문에서 사용자를 식별하고 해당 사용자에 대한 정보를 저장하는 방법을 제공한다.

서블릿 컨테이너는 이 인터페이스를 사용하여 HTTP 클라이언트와 HTTP 서버 간의 세션을 생성하고, 여기에 저장된 값으로 사용자를 식별한다.

🌿 OAuthAttributes

🔧 config.auth.dto 아래 OAuthAttributes 생성

package com.bbs.projects.bulletinboard.config.auth.dto;

import com.bbs.projects.bulletinboard.domain.user.Role;
import com.bbs.projects.bulletinboard.domain.user.User;
import lombok.Builder;
import lombok.Getter;

import java.util.Map;

@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;
    }

    //ofGoogle 호출 -> OAuthAttributes 각 필드값 셋팅
    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.GUEST)
                .build();
    }
}

OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 DTO 클래스다. 이것은 saveOrUpdate()에서 가입 여부를 확인하고, 그 결과에 따라 User를 생성 및 갱신하는데 사용된다. 또한, toEntity()를 통해 엔티티를 생성하는 시점은 처음 가입할 때이다. 이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용한다.

🌿 SessionUser

🔧 config.auth.dto 패키지 아래 SessionUser 생성

package com.bbs.projects.bulletinboard.config.auth.dto;

import com.bbs.projects.bulletinboard.domain.user.User;
import lombok.Getter;

import java.io.Serializable;

//세션에 사용자 정보를 저장하기 위한 DTO + 직렬화 기능
@Getter
public class SessionUser implements Serializable {
    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();
    }
}

세션에 사용자 정보를 저장하기 위한 DTO 클래스다. 해당 클래스에는 인증된 사용자 정보만 필요하다. Serializable 인터페이스를 통해 객체를 바이트 코드로 변환하여 객체 또는 데이터를 외부의 다른 자바 시스템에서도 사용할 수 있도록 했다.

📝 User vs SessionUser

User 클래스를 세션에 저장할 경우, 다음과 같은 에러를 만난다.

Failed to convert from type [java.lang.Object] to type [byte []] for value 'com.bbs.projects.bulletinboard.domain.user.User@...'

User 클래스에 직렬화를 구현하지 않았다는 의미의 에러다. 하지만 오류를 해결하기 위해 User 클래스에 직렬화 코드를 넣으면 안된다. 그 이유는 User 클래스는 엔티티이기 때문이다.

엔티티 클래스는 언제 다른 엔티티와 관계가 형성될지 모른다. 예를 들어 @OneToMany, @ManyToMany 등 자식 엔티티를 갖고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다. 그래서 직렬화 기능을 가진 세션 DTO를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움이 된다.

📢 Serializable

JVM 메모리에 상주되어 있는 객체 데이터를 바이트 형태로 변환하는 기술 또는 그 반대 변환
ref : https://devlog-wjdrbs96.tistory.com/268

🌿 index.mustache 로그인 영역 추가

...

<h1>Welcome To Rootable's Free Board</h1>
<div class="col-md-12">
    <!--로그인 기능 영역-->
    <div class="row">
        <div class="col-md-6">
            <a href="/posts/save" role="button"
               class="btn btn-primary">새 글 등록</a>
            {{#userName}}
                Logged in as: <span id="user">{{userName}}</span>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
            {{/userName}}
            {{^userName}}
                <a href="/oauth2/authorization/google"
                   class="btn btn-success active" role="button">Google Login</a>
            {{/userName}}
        </div>
    </div>
    <br>
    <!-- 목록 출력 영역 -->
    ...

📢 {{#userName}}

머스테치는 다른 언어와 같은 if문을 제공하지 않고, true/false 여부만 판단한다. 그래서 이처럼 최종값 userName을 넘겨줘서 userName이 있을 경우, 로그인한 사용자명과 로그아웃 버튼이 나오도록 했다.

📢 a href="/logout"

스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL이다. 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없다.

📢 {{^userName}}

^는 not을 의미한다. 즉, userName 값이 존재하지 않을 경우 로그인 버튼이 나타나도록 했다.

📢 a href="/oauth2/authorization/google"

스프링 시큐리티에서 기본적으로 제공하는 로그인 URL이다. 마찬가지로 개발자가 별도로 컨트롤러를 생성할 필요가 없다.

🌿 IndexController에 Model 추가

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.config.auth.LoginUser;
import com.bbs.projects.bulletinboard.config.auth.dto.SessionUser;
import com.bbs.projects.bulletinboard.service.posts.PostsService;
import com.bbs.projects.bulletinboard.web.dto.PostsResponseDto;
import com.sun.org.apache.xpath.internal.operations.Mod;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Controller
public class IndexController {

	//생성자 주입
    private final PostsService postsService;
    private final HttpSession httpSession;

    //첫 페이지 호출 시, index 뷰를 호출
    @GetMapping("/")
    public String index(Model model) {
        //Model 객체에 findAllDesc 결과를 담아 index 뷰에 전달
        model.addAttribute("posts", postsService.findAllDesc());

		//로그인 성공 시, 세션에 저장된 SessionUser를 가져올 수 있음
		SessionUser user = (SessionUser) httpSession.getAttribute("user");

        //세션에 저장된 값이 있을 때만 model에 userName 등록
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }

        return "index";
    }

    ...

}

로그인 성공 시, CustomOAuth2UserService에서 세션에 저장한 SessionUser 객체를 user라는 이름으로 저장한다. 그리고 세션에 저장된 값이 있을 경우, model에 담아 userName이라는 이름으로 index.mustache에 전달했다.

로그인 버튼이 생겼다. 😀

구글 로그인 동의 화면 후 구글 계정에 등록된 이름이 노출되는 것을 알 수 있다.

로그인 후 USER 테이블에 저장된 것을 알 수 있다.

현재 GUEST 권한으로는 게시글을 작성할 수 없다. (403 에러)

🔧 update문을 통해 권한을 USER로 변경

해당 테스트를 마지막으로 구글 로그인, 로그아웃, 회원가입, 권한관리 기능이 모두 구현되었다. 🙏🙏

🌱 애노테이션 기반으로 개선


현재 코드가 가진 문제점은 같은 코드가 반복되는 것이다. 이렇게 되면 유지보수성이 떨어지고, 완전한 수정이 어려워져 오류 가능성을 높인다. 앞서 작업한 코드에서 개선할만한 것은 IndexController에서 세션값을 가져오는 부분이라고 생각한다.

SessionUser user = (SessionUser) httpSession.getAttribute("user");

index 메소드 외에 다른 컨트롤러와 메소드에서 세션값이 필요하면 그 때마다 직접 세션에서 값을 가져와야 한다. 그래서 이러한 중복 작업을 없애기 위해 메소드 인자로 세션값을 바로 받을 수 있도록 변경하겠다.

🌿 @LoginUser

🔧 config.auth 패키지에 @LoginUser 애노테이션을 생성

package com.bbs.projects.bulletinboard.config.auth;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

📢 @Target(ElementType.PARAMETER)

해당 애노테이션이 생성될 수 있는 위치를 지정한다. 여기서 PARAMETER는 메소드의 파라미터로 선언된 객체에서만 사용할 수 있도록 한다. 이 외에도 클래스 선언문에 쓸 수 있는 TYPE 등이 있다.

📢 @Rention(RetentionPolicy.RUNTIME)

애노테이션의 라이프 사이클 즉, 애노테이션이 언제까지 살아남아 있을지를 정하는 것이다. RUNTIME은 소스가 컴파일되고 클래스 파일이 된 후에 로더에 의해 메모리에 올라갔을 때도 살아남는다. 사실상 영구적인 상태라고 볼 수 있다.
ref : https://jeong-pro.tistory.com/234

🌿 LoginUserArgumentResolver

🔧 config.auth 아래 LoginUserArgumentResolver를 생성

package com.bbs.projects.bulletinboard.config.auth;

import com.bbs.projects.bulletinboard.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Component //빈 등록
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    //생성자 주입
    private final HttpSession httpSession;

    //컨트롤러 메소드의 특정 파라미터를 지원하는지 판단
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        //@LoginUser가 붙어 있는가
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        //파라미터 클래스 타입이 SessionUser.class인가
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
        return isLoginUserAnnotation & isUserClass;
    }

    //파라미터에 전달할 객체를 생성
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        //세션에서 객체를 가져옴
        return httpSession.getAttribute("user");

    }
}

supportsParameter()를 통해 지정한 타입 및 클래스에서만 해당 파라미터를 사용하도록 할 수 있으며, 종료 후 resolveArgument()를 호출한다. resolveArgument()에서 파라미터로 사용할 객체를 가공할 수 있다.

📢 Argument Resolver

파라미터를 공통으로 처리할 수 있도록 구현된 인터페이스이다. 이것은 API 엔드포인트로부터 들어온 데이터(파라미터)를 가공하여 필요한 데이터만 추출할 때 사용한다.
ref : https://blog.neonkid.xyz/238

📢 HandlerMethodArgumentResolver

조건에 맞는 경우(supportsParameter 통과) 메소드가 있다면 이것의 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 있다.

📢 supportsParameter()

파라미터에 @LoginUser 애노테이션이 붙어 있고, 파라미터 클래스 타입이 SessionUser 클래스인 경우 true를 반환한다.

🌿 WebConfig

🔧 config 패키지 아래 WebConfig를 생성

package com.bbs.projects.bulletinboard.config;

import com.bbs.projects.bulletinboard.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

    //LoginUserArgumentResolver를 WebMvcConfigurer에 추가
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

생성한 Argument Resolver를 사용하기 위해서는 WebMvcConfigurer에 등록해야 한다. HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가해야 한다. 다른 HandlerMethodArgumentResolver가 필요하다면 같은 방식으로 추가해 주면 된다.

🌿 IndexController 개선

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.config.auth.LoginUser;
import com.bbs.projects.bulletinboard.config.auth.dto.SessionUser;
import com.bbs.projects.bulletinboard.service.posts.PostsService;
import com.bbs.projects.bulletinboard.web.dto.PostsResponseDto;
import com.sun.org.apache.xpath.internal.operations.Mod;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Controller
public class IndexController {

	//생성자 주입
    private final PostsService postsService;

    //첫 페이지 호출 시, index 뷰를 호출
    //로그인 성공 시, 세션에 저장된 SessionUser를 가져올 수 있음
    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        //Model 객체에 findAllDesc 결과를 담아 index 뷰에 전달
        model.addAttribute("posts", postsService.findAllDesc());

        //세션에 저장된 값이 있을 때만 model에 userName 등록
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }

        return "index";
    }
	
    ...

}

🌱 세션 저장소로 DB 사용


현재 만든 서비스는 애플리케이션을 재실행하면 로그인이 풀린다. 그 이유는 세션이 내장 톰캣의 메모리에 저장되기 때문이다. 기본적으로 세션은 실행되는 WAS(Web Application Server)의 메모리에서 저장되고 호출된다. 메모리에 저장되다 보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화된다. 즉, 배포할 때마다 톰캣이 재시작되는 것이다.

또 다른 문제점은 2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야만 한다. 그래서 실제 현업에서는 세션 저장소에 대해 다음의 3가지 중 한 가지를 선택한다.

  1. 톰캣 세션 사용

    • 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식
    • 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요하다.
  2. MySQL과 같은 DB를 세션 저장소로 사용

    • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법
    • 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있다.
    • 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용한다.
  3. Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용

    • B2C 서비스에서 가장 많이 사용하는 방식
    • 실제 서비스를 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요하다.

여기서는 두 번째 방식인 DB를 세션 저장소로 사용하겠다. 그 이유는 설정이 간단하고 사용자가 많은 서비스가 아니며 비용 절감을 위해서다.

🌿 build.gradle에 spring-session-jdbc 등록

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.projectlombok:lombok')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
    compile('org.springframework.boot:spring-boot-starter-mustache')
    compile('org.springframework.boot:spring-boot-starter-oauth2-client')
    compile('org.springframework.session:spring-session-jdbc') //추가
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

🌿 application.properties 추가

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.h2.console.enabled=true
spring.profiles.include=oauth
spring.session.store-type=jdbc //추가

세션 저장소를 jdbc로 선택하도록 코드를 추가한다.

세션을 위한 테이블 2개(SPRING_SESSION, SPRING_SESSION_ATTRIBUTES)가 생성된 것을 볼 수 있다. JPA로 인해 세션 테이블이 자동 생성되었기 때문이다.

로그인한 세션이 등록된 것을 볼 수 있다.

지금은 기존과 동일하게 스프링을 재시작하면 세션이 풀린다. 이유는 H2 기반으로 스프링이 재실행될 때 H2도 재시작되기 때문이다. 이후 AWS로 배포하게 되면 AWS의 DB 서비스인 RDS(Relational Database Service)를 사용하게 되니 이때부터는 세션이 풀리지 않는다.

🌱 네이버 로그인


🌿 네이버 API 등록

🔧 다음과 같이 각 항목을 채운다.

URL : https://developer.naver.com/apps/#/register?api=nvlogin

서비스 URL은 필수 입력값이다. Callback URL은 구글에서 등록한 리디렉션 URL과 같은 역할을 한다.

등록을 완료하면 ClientID와 ClientSecret가 생성된다.

🌿 application-oauth.properties에 등록

...

#registration
spring.security.oauth2.client.registration.naver.client-id=네이버 ClientID
spring.security.oauth2.client.registration.naver.client-secret=네이버 ClientSecret
spring.security.oauth2.client.registration.naver.redirect-uri=
{baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization_grant_type=
authorization_code
spring.security.oauth2.client.registration.naver.scope=
name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

#provider
spring.security.oauth2.client.provider.naver.authorization_uri=
https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token_uri=
https://nid.naver.com.oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=
https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user_name_attribute=response

네이버에서는 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 CommonOAuth2Provider에서 해주던 값들도 전부 수동으로 입력해야 한다.

📢 user_name_attribute=response

기준이 되는 user_name의 이름을 네이버에서는 response로 해야 한다. 이유는 네이버의 회원 조회 시 반환되는 JSON 형태 때문이다.

//네이버 오픈 API의 로그인 회원 결과
{
	"resultcode": "00",
    "message": "success",
    "response": {
    	"email": "openapi@naver.com",
        "nickname": "OpenAPI",
        "profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif"
        "age": "40-49",
        "gender": "F",
        "id": "32742776",
        "name": "오픈API",
        "birthday": "10-01"
    }
}

스프링 시큐리티에선 하위 필드를 명시할 수 없다. 최상위 필드들만 user_name으로 지정 가능하다. 하지만 네이버의 응답값 최상위 필드는 resultCode, message, response다. 이러한 이유로 스프링 시큐리티에서 인식 가능한 필드는 저 3개 중에 골라야 한다.

response를 user_name으로 지정하고 이후 자바 코드로 response의 id를 user_name으로 지정하겠다.

🌿 스프링 시큐리티 설정 등록

🌳 OAuthAttributes

package com.bbs.projects.bulletinboard.config.auth.dto;

import com.bbs.projects.bulletinboard.domain.user.Role;
import com.bbs.projects.bulletinboard.domain.user.User;
import lombok.Builder;
import lombok.Getter;

import java.util.Map;

@Getter
public class OAuthAttributes {
    ...

    //ofGoogle 호출 -> OAuth2Attributes 각 필드값 셋팅
    public static OAuthAttributes of(String registrationId,
                                     String userNameAttributeName,
                                     Map<String, Object> attributes) {

		//registrationId로 네이버 서비스 식별
        if ("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }
        
        return ofGoogle(userNameAttributeName, attributes);
    }

    ...

	//response 필드의 속성값 추출 후 DTO의 각 속성 값 변환 및 설정
    private static OAuthAttributes ofNaver(String userNameAttributeName,
                                           Map<String, Object attributes) {

        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
        
    }

    ...
}

네이버 오픈 API의 response 필드 내에 있는 속성값들로 DTO(OAuthAttributes)를 채웠다. CustomOAuth2UserService에서 넘겨준 registrationId를 통해 서비스를 구분하고, DTO를 반환한다.

🌳 index.mustache에 네이버 로그인 버튼 추가

...
            {{/userName}}
            {{^userName}}
                <a href="/oauth2/authorization/google"
                   class="btn btn-success active" role="button">Google Login</a>
                <a href="/oauth2/authorization/naver"
                   class="btn btn-secondary active" role="button">Naver Login</a>
            {{/userName}}
        ...

📢 /oauth2/authorization/naver

네이버 로그인 URL은 application-oauth.properties에 등록한 redirect-uri 값에 맞춰 자동으로 등록된다.

/oauth2/authorization/까지는 고정이고 마지막 Path만 각 소셜 로그인 코드를 사용하면 된다. 여기서는 naver가 마지막 Path가 된다.

네이버 로그인 버튼이 생겼다! 😀

구글과 마찬가지로 로그인 동의 후 로그인이 잘 된 것을 볼 수 있다.

🌱 기존 테스트에 시큐리티 적용


기존 테스트에 시큐리티 적용으로 문제가 되는 부분들을 해결하도록 하겠다.

기존에는 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성했다. 하지만, 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있다. 기존의 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정하겠다.

🌿 문제 1. CustomOAuth2UserService을 찾을 수 없음

return_hello의 에러 메시지를 보면 "No qualifying bean of type 'com.bbs.projects.bulletinboard.config.auth.CustomOAuth2UserService"라는 메시지가 등장한다.

이는 CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없기 때문에 발생한다. 그런데 분명 application-oauth.properties에 설정값들을 추가했다.

이는 src/main 환경과 src/test 환경의 차이 때문이다. 둘은 본인만의 환경 구성을 가지는데, src/main/resources/application.properties가 테스트 코드를 수행할 때도 적용되는 이유는 test에 application.properties가 없으면 main의 설정을 그대로 가져오기 때문이다. 다만, 자동으로 가져오는 옵션의 범위는 application.properties 파일까지다. 즉, application-oauth.properties는 test에 파일이 없다고 가져오는 파일은 아니라는 점이다.

🌳 src/test/resources/application.properties

🔧 테스트 환경을 위한 application.properties를 만들기 위해 가짜 설정값을 등록한다.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.h2.console.enabled=true
spring.profiles.include=oauth
spring.session.store-type=jdbc

# Test OAuth

spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.client-secret=test
spring.security.oauth2.client.registration.google.scope=profile,email

🌿 문제 2. 302 Status Code

PostsApiControllerTest의 테스트 로그를 보면 응답 결과로 200(정상 응답) 상태 코드를 원했는데 결과는 302(리다이렉션 응답) 상태 코드가 와서 실패했다. 이는 스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문이다. 그래서 이런 API 요청은 임의로 인증된 사용자를 추가하여 API만 테스트해 볼 수 있게 하겠다.

🌳 build.gradle

🔧 스프링 시큐리티 테스트를 위한 여러 도구를 지원하는 spring-security-test를 build.gradle에 추가

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.projectlombok:lombok')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
    compile('org.springframework.boot:spring-boot-starter-mustache')
    compile('org.springframework.boot:spring-boot-starter-oauth2-client')
    compile('org.springframework.session:spring-session-jdbc')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.security:spring-security-test') //추가
}

🌳 PostsApiControllerTest 수정

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.domain.posts.Posts;
import com.bbs.projects.bulletinboard.domain.posts.PostsRepository;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import com.bbs.projects.bulletinboard.web.dto.PostsUpdateRequestDto;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

import static org.assertj.core.api.Assertions.*;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    ...

    //게시글 등록 테스트
    @Test
    @WithMockUser(roles = "USER") //임의 인증된 사용자 추가
    public void register_posts() throws Exception{
        ...

    //게시글 갱신 테스트
    @Test
    @WithMockUser(roles = "USER") //임의 인증된 사용자 추가
    public void update_posts() throws Exception{
        ...

}

임의의 가짜 사용자를 넣어 ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가지게 된다.

📢 @WithMockUser(roles="USER")

인증된 모의(가짜) 사용자를 만들어서 사용한다. roles을 통해 권한을 추가할 수 있다. 또한, MockMvc에서만 작동한다.

📢 Mock 객체

실제 객체를 다양한 조건으로 인해 제대로 구현하기 어려운 경우 가짜 객체를 만들어 사용하는데, 이를 Mock 객체라 한다.

  • 활용
    - 테스트 작성을 위한 환경 구축이 어려운 경우
    - 테스트가 특정 경우나 순간에 의존적인 경우
    - 시간이 걸리는 경우
    ref : https://heegs.tistory.com/16

🌳 @SpringBootTest에서 MockMvc 사용하는 방법

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.domain.posts.Posts;
import com.bbs.projects.bulletinboard.domain.posts.PostsRepository;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import com.bbs.projects.bulletinboard.web.dto.PostsUpdateRequestDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    ...

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc; //Web Api 환경 제공

	//매번 테스트 시작 전에 MockMvc 인스턴스 생성
    @Before
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    ...

    //게시글 등록 테스트
    @Test
    @WithMockUser(roles = "USER") //임의 인증된 사용자 추가
    public void register_posts() throws Exception{
        //given
        String title = "title";
        String content = "content";
        //요청 파라미터들 셋팅
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        //컨트롤러 테스트를 위한 url 변수 선언
        String url = "http://localhost:" + port + "/api/v1/posts";

        //when
        //API 요청 수행
        mvc.perform(post(url) //해당 URL로 POST 요청
        				//객체 전송을 위해 JSON 타입 지정
                        .contentType(MediaType.APPLICATION_JSON_UTF8) 
                        //각 속성값들을 문자열로 바디에 삽입
                        .content(new ObjectMapper().writeValueAsString(requestDto))) 
                .andExpect(status().isOk());

        //then
        //DB 전체 필드 조회를 통한 검증
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

    //게시글 갱신 테스트
    @Test
    @WithMockUser(roles = "USER") //임의 인증된 사용자 추가
    public void update_posts() throws Exception{
        //given
        //각 속성 값 임의 지정
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savedPosts.getId();
        String expectedTitle = "title2"; //테스트용 title
        String expectedContent = "content2"; //테스트용 content

        //갱신용 DTO에 테스트용 title과 content 저장
        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        //컨트롤러 테스트를 위한 url 변수 선언
        String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;

        //갱신용 DTO를 통해 requestEntity를 얻음
        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

        //when
        //API 요청 수행
        mvc.perform(put(url) //해당 URL로 PUT 요청
        				//객체 전송을 위해 JSON 타입 지정
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        //각 속성값들을 문자열로 바디에 삽입
                        .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        //then
        //DB 전체 필드 조회를 통한 검증
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);

    }

}

MockMvc를 통해 제공받은 API 환경에서 mvc.perform을 통해 API 요청을 수행할 수 있다. 우선, 테스트 전에 항상 @Before로 MockMvc 인스턴스를 생성한다. 이를 위해 WebApplicationContext가 필요하다. 이렇게 요청을 수행한 후 andExpect()를 통해 성공여부를 검증한다.

📢 MockMvc

Web Api 환경을 제공하여 테스트에서 HTTP 메시지를 완성하고 API 요청을 할 수 있도록 지원한다.

📢 @Before

매번 테스트 시작 전에 수행할 작업

테스트의 에러가 제거된 것을 볼 수 있다.

🌿 문제 3. @WebMvcTest에서 CustomOAuth2UserService을 찾을 수 없음

1번을 통해 스프링 시큐리티 설정은 잘 작동했지만, @WebMvcTest는 CustomOAuth2UserService를 스캔하지 않는다.

@WebMvcTest는 WebSecurityConfigurerAdapter, WebMvcConfigurer를 비롯한 @ControllerAdvice, @Controller를 읽는다. 즉, @Repository, @Service, @Component는 스캔 대상이 아니다. 따라서, SecurityConfig는 읽었지만, SecurityConfig를 생성하기 위해 필요한 CustomOAuth2UserService(@Service)는 읽을 수가 없어 에러가 발생한 것이다.

🔧 스캔 대상에서 SecurityConfig를 제거한다.

🌳 HelloControllerTest에 필터 추가

@RunWith(SpringRunner.class)
@WebMvcTest(controllers = HelloController.class,
        excludeFilters = {
        @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
        }
)
public class HelloControllerTest {

...

	@WithMockUser(roles = "USER")
    @Test
    public void return_helloDto() throws Exception{
    
    ...
    
	@WithMockUser(roles = "USER")
    @Test
    public void return_helloDto() throws Exception{
    
    ...

excludeFilters를 통해 컴포넌트 스캔 대상에서 SecurityConfig를 제거했다. 마찬가지로 테스트마다 인증된 가짜 사용자(@WithMockUser)를 사용했다.

📢 FilterType.ASSIGNABLE_TYPE

클래스를 기준으로 객체를 가져온다. classes를 통해 지정한 클래스, 상속이나 구현한 클래스까지 포함한다.
ref : https://nankisu.tistory.com/4

😨😨 테스트를 다시 돌려보면 추가 에러가 발생한다.

이 에러는 @EnableJpaAuditing으로 인해 발생한다. @EnableJpaAuditing를 사용하기 위해선 최소 하나의 @Entity 클래스가 필요하다. @WebMvcTest이다 보니 당연히 없다.

@EnableJpaAuditing가 @SpringBootApplication와 함께 있다보니 @WebMvcTest에서도 스캔하게 되었다. 그래서 @EnableJpaAuditing과 @SpringBootApplication 둘을 분리하도록 하겠다.

🔧 Application.java에서 @EnableJpaAuditing를 제거

🔧 config 패키지에 JpaConfig를 생성하여 @EnableJpaAuditing 추가

🌳 JpaConfig

package com.bbs.projects.bulletinboard.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing //JPA Auditing 활성화
public class JpaConfig {
}

드디어 모든 테스트 코드가 정상적으로 작동했다! 😄😄

다음 챕터부터 AWS를 이용해 해당 서비스를 직접 배포하고 운영하는 과정을 진행하도록 하겠다.

0개의 댓글