이번 구글 로그인 연동하기는 보안, 로그인 관련 파트라서 어려움을 많이 느꼈다. 여러번 사용해보고 토이 프로젝트에도 소셜 로그인 기능을 녹여봐야겠다.
먼저 사용자 정보를 담당할 도메인인 User 클래스를 생성해주고 다음 코드를 작성해준다.
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)
    JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정함
    기본적으로는 int로 된 숫자가 저장된다.
    숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 가 없다.
    그래서 문자열로 저장될 수 있도록 선언
     */
    @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();
    }
}
각 사용자의 권한을 관리할 Enum 클래스 Role을 생성해준다.
Role
@Getter
@RequiredArgsConstructor
public enum Role {
    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");
    private final String key;
    private final String title;
}
/*
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야함
 */
User의 CRUD를 책임질 UserRepository도 생성해준다.
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}
스프링 시큐리티를 설정해주기 위해 관련 의존성을 build.gradle에 추가해준다.
build.gradle
implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성한다.
config.auth 패키지를 생성하고 해당 패키지에 시큐리티 관련 클래스를 모두 담아준다. 
SecurityConfig
@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/**", "/image/**", "/js/**", "/h2-console/**").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                        .logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService);
    }
}
처음에 이 코드를 작성하질 않아서 정상적으로 작동하지 않았다. 만약 처음 로컬 url로 접속 시 index 페이지가 아닌 로그인 페이지로 이동하고 로그인 버튼을 눌러도 반응하지 않는다면 이 코드를 작성했는지 확인하면 된다.
CustomOAuth2UserService 클래스를 생성해서 구글 로그인 이후 가져온 사용자 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원
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 registratrionId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        OAuthAttributes attributes = OAuthAttributes.of(registratrionId, 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()))
                    .orElse(attributes.toEntity());
            return userRepository.save(user);
        }
    }
OAuthAtrribute
@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){
        if("naver".equals(registrationId)){
            return ofNaver("id", 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();
    }
    private static OAuthAttributes ofNaver(String userNameAtrributeName, 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(userNameAtrributeName)
                .build();
    }
    public User toEntity(){
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}
SessionUser
@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();
    }
}
테스트를 위해 index.mustache에 코드를 수정해준다.
index.mustache
<h1>스프링부트로 시작하는 웹 서비스 Ver.2</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}}
<!--머스테치는 다른 언어와 달리 if문을 제공하지 않기에 최종값을 넘겨줘야함 -->
                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 기능을 제공하지 않기에 userName이 있다면 userName을 노출시키도록 구성
a href="/logout" : 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 url
스프링 시큐리티에서 제공하기에 개발자가 해당 url에 해당하는 컨트롤러를 만들 필요가 없다.
{{^userName}} : 머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용한다.
a href="/oauth2/authorization/google" : 스프링 시큐리티에서 기본적으로 제공하는 로그인 url, 스프링 시큐리티에서 제공하기에 개발자가 별도의 컨트롤러를 만들 필요 없다.
index.mustache에서 userName을 사용할 수 있도록 IndexController에 userName을 model에 저장하는 코드를 추가한다.
IndexController
@GetMapping("/")
   public String index(Model model){ //Model: 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있다.
       //여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달한다.
       model.addAttribute("posts", postsService.findAllDesc());
       SessionUser user = (SessionUser) httpSession.getAttribute("user");
       if(user != null){
           model.addAttribute("userName", user.getName());
       }
       return "index";
   }
(SessionUser)httpSession.getAttribute("user"): 로그인 성공 시 세션에 SessionUser를 저장하도록 구성, 로그인 성공시 httpSession.getAtrribute("user")에서 값을 가져올 수 있도록 함
if(user != null) : 세션에 저장된 값이 있을 때만 model에 userName으로 등록