Spring Boot Gmail 소셜 로그인 구현하기 (OAuth 2.0)

송진우·2025년 12월 23일
post-thumbnail

OAuth 2.0이란?

개념

OAuth 2.0은 사용자의 비밀번호를 공유하지 않고도 다른 서비스의 자원에 접근할 수 있도록 하는 권한 부여 프로토콜입니다.


Gmail 소셜 로그인 전체 흐름


프로젝트 구조

src/main/java/com/onandhome/
├── auth/
│   ├── controller/
│   │   └── GoogleAuthController.java      # 구글 로그인 API 엔드포인트
│   ├── service/
│   │   └── GoogleAuthService.java         # 구글 OAuth 핵심 로직
│   └── dto/
│       ├── GoogleTokenResponse.java       # 구글 토큰 응답 DTO
│       └── GoogleUserInfo.java            # 구글 사용자 정보 DTO
├── user/
│   ├── entity/User.java                   # 사용자 엔티티 (provider 필드)
│   └── UserRepository.java                # 사용자 DB 조회
├── util/JWTUtil.java                      # JWT 토큰 생성/검증
└── SecurityConfig.java                    # Spring Security 설정

1. Google Cloud Console 설정

OAuth 클라이언트 ID 발급

  1. Google Cloud Console 접속
  2. 프로젝트 생성
  3. API 및 서비스사용자 인증 정보OAuth 클라이언트 ID
  4. 애플리케이션 유형: 웹 애플리케이션
  5. 승인된 리디렉션 URI 추가:
http://localhost:3000/auth/google/callback
  1. 클라이언트 ID클라이언트 보안 비밀 복사

2. application.properties 설정

# ============================================
# Google OAuth2 설정
# ============================================
# 환경변수로 관리
google.client-id=${GOOGLE_CLIENT_ID}
google.client-secret=${GOOGLE_CLIENT_SECRET}

# 프론트엔드 콜백 URL
google.redirect-uri=http://localhost:3000/auth/google/callback

# 구글 OAuth URL
google.auth-url=https://accounts.google.com/o/oauth2/v2/auth
google.token-url=https://oauth2.googleapis.com/token
google.user-info-url=https://www.googleapis.com/oauth2/v2/userinfo

3. DTO 클래스

3.1. GoogleTokenResponse.java

구글 토큰 API 응답을 받는 DTO

@Data
public class GoogleTokenResponse {
    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("expires_in")
    private Integer expiresIn;

    @JsonProperty("token_type")
    private String tokenType;

    @JsonProperty("id_token")
    private String idToken;
}

3.2. GoogleUserInfo.java

구글 사용자 정보 API 응답을 받는 DTO

@Data
public class GoogleUserInfo {
    private String id;              // 구글 고유 ID
    private String email;           // 이메일
    private String name;            // 이름
    private String picture;         // 프로필 사진
    
    @JsonProperty("verified_email")
    private Boolean verifiedEmail;
}

4. GoogleAuthService.java

@Slf4j
@Service
@RequiredArgsConstructor
public class GoogleAuthService {
    
    private final UserRepository userRepository;
    private final RestTemplate restTemplate = new RestTemplate();

    @Value("${google.client-id}")
    private String clientId;
    
    @Value("${google.client-secret}")
    private String clientSecret;
    
    @Value("${google.redirect-uri}")
    private String redirectUri;
    
    @Value("${google.token-url}")
    private String tokenUrl;
    
    @Value("${google.user-info-url}")
    private String userInfoUrl;
    
    // 구글 인증 코드로 액세스 토큰 받기
    public GoogleTokenResponse getAccessToken(String code) {
        log.info("=== 구글 액세스 토큰 요청 ===");
        
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", clientId);
        params.add("client_secret", clientSecret);
        params.add("redirect_uri", redirectUri);
        params.add("code", code);
        
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        
        ResponseEntity<GoogleTokenResponse> response = restTemplate.exchange(
            tokenUrl,
            HttpMethod.POST,
            request,
            GoogleTokenResponse.class
        );
        
        return response.getBody();
    }
    
    // 액세스 토큰으로 사용자 정보 받기
    public GoogleUserInfo getUserInfo(String accessToken) {
        log.info("=== 구글 사용자 정보 요청 ===");
        
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken);
        
        HttpEntity<String> request = new HttpEntity<>(headers);
        
        ResponseEntity<GoogleUserInfo> response = restTemplate.exchange(
            userInfoUrl,
            HttpMethod.GET,
            request,
            GoogleUserInfo.class
        );
        
        return response.getBody();
    }
    
    // 구글 사용자 정보로 로그인 또는 회원가입 처리
    public User processGoogleLogin(GoogleUserInfo googleUserInfo) {
        log.info("=== 구글 로그인 처리 ===");
        
        String provider = "GOOGLE";
        String providerId = googleUserInfo.getId();
        
        // 이미 가입된 사용자인지 확인
        Optional<User> existingUser = userRepository
            .findByProviderAndProviderId(provider, providerId);
        
        if (existingUser.isPresent()) {
            log.info("기존 구글 사용자 로그인: {}", providerId);
            return existingUser.get();
        }
        
        // 신규 사용자 생성
        log.info("신규 구글 사용자 회원가입: {}", providerId);
        
        String name = googleUserInfo.getName() != null 
            ? googleUserInfo.getName() : "구글사용자";
        
        String email = googleUserInfo.getEmail();
        
        // 이메일 중복 체크
        if (email != null && userRepository.existsByEmail(email)) {
            email = "google_" + providerId + "@google.user";
        }
        
        // userId 생성
        String userId = "google_" + providerId;
        if (userRepository.existsByUserId(userId)) {
            userId = "google_" + providerId + "_" + UUID.randomUUID().toString().substring(0, 8);
        }
        
        // User 생성 및 저장
        User newUser = User.builder()
            .userId(userId)
            .password(UUID.randomUUID().toString())
            .username(name)
            .email(email)
            .provider(provider)
            .providerId(providerId)
            .role(1)                    // 일반 사용자
            .active(true)
            .build();
        
        return userRepository.save(newUser);
    }
}

핵심 메서드

  • getAccessToken(): 인증 코드 → 액세스 토큰
  • getUserInfo(): 액세스 토큰 → 사용자 정보
  • processGoogleLogin(): 사용자 정보 → 회원가입/로그인

5. GoogleAuthController.java (API)

@Tag(name = "구글 로그인")
@Slf4j
@RestController
@RequestMapping("/api/auth/google")
@RequiredArgsConstructor
public class GoogleAuthController {

    private final GoogleAuthService googleAuthService;
    private final JWTUtil jwtUtil;

    @Value("${google.client-id}")
    private String clientId;

    @Value("${google.redirect-uri}")
    private String redirectUri;

    @Value("${google.auth-url}")
    private String authUrl;

    // 구글 로그인 URL 반환
    @GetMapping("/login-url")
    public ResponseEntity<Map<String, String>> getGoogleLoginUrl() {
        log.info("=== 구글 로그인 URL 요청 ===");

        String loginUrl = authUrl
                + "?client_id=" + clientId
                + "&redirect_uri=" + redirectUri
                + "&response_type=code"
                + "&scope=openid email profile";

        Map<String, String> response = new HashMap<>();
        response.put("loginUrl", loginUrl);

        return ResponseEntity.ok(response);
    }

    // 구글 로그인 콜백 처리
    @GetMapping("/callback")
    public ResponseEntity<Map<String, Object>> googleCallback(
            @RequestParam("code") String code,
            HttpSession session) {

        log.info("=== 구글 로그인 콜백 처리 시작 ===");
        Map<String, Object> response = new HashMap<>();

        try {
            // 1. 액세스 토큰 받기
            GoogleTokenResponse tokenResponse = googleAuthService.getAccessToken(code);
            
            // 2. 사용자 정보 받기
            GoogleUserInfo googleUserInfo = googleAuthService.getUserInfo(
                tokenResponse.getAccessToken()
            );
            
            // 3. 로그인 처리
            User user = googleAuthService.processGoogleLogin(googleUserInfo);

            // 4. 세션에 저장
            session.setAttribute("userId", user.getUserId());
            session.setAttribute("username", user.getUsername());
            session.setAttribute("role", user.getRole());

            // 5. JWT 토큰 생성
            Map<String, Object> claims = new HashMap<>();
            claims.put("id", user.getId());
            claims.put("userId", user.getUserId());
            claims.put("role", user.getRole());

            String accessToken = jwtUtil.generateToken(claims, 60);          // 1시간
            String refreshToken = jwtUtil.generateToken(claims, 60 * 24 * 7); // 7일

            // 6. 응답 반환
            response.put("success", true);
            response.put("message", "구글 로그인 성공");
            response.put("accessToken", accessToken);
            response.put("refreshToken", refreshToken);
            response.put("user", Map.of(
                    "id", user.getId(),
                    "userId", user.getUserId(),
                    "username", user.getUsername(),
                    "email", user.getEmail() != null ? user.getEmail() : "",
                    "role", user.getRole()
            ));

            return ResponseEntity.ok(response);

        } catch (Exception e) {
            log.error("구글 로그인 실패", e);
            response.put("success", false);
            response.put("message", "구글 로그인 중 오류: " + e.getMessage());
            return ResponseEntity.status(500).body(response);
        }
    }
}

API 엔드포인트

  • GET /api/auth/google/login-url → 구글 로그인 URL 생성
  • GET /api/auth/google/callback?code=xxx → 인증 코드 처리 및 JWT 발급

6. User 엔티티 (소셜 로그인 필드)

@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true, nullable = false)
    private String userId;
    
    private String password;
    private String username;
    private String email;
    
    // 소셜 로그인 필드
    private String provider;        // "GOOGLE", "KAKAO", "NAVER"
    private String providerId;      // 소셜 플랫폼의 고유 ID
    
    private Integer role;           // 0: 관리자, 1: 일반사용자
    private Boolean active;
}

7. UserRepository

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUserId(String userId);
    Optional<User> findByEmail(String email);
    
    // 소셜 로그인용
    Optional<User> findByProviderAndProviderId(String provider, String providerId);
    
    boolean existsByUserId(String userId);
    boolean existsByEmail(String email);
}

실제 화면


8. 테스트

Postman/Talent API 테스트

1. 로그인 URL 가져오기

GET http://localhost:8080/api/auth/google/login-url

응답:

{
  "loginUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=xxx&redirect_uri=http://localhost:3000/auth/google/callback&response_type=code&scope=openid email profile"
}

2. 콜백 테스트

GET http://localhost:8080/api/auth/google/callback?code=4/0AeanBKo...

응답:

{
  "success": true,
  "message": "구글 로그인 성공",
  "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
  "refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
  "user": {
    "id": 15,
    "userId": "google_123456789",
    "username": "김민수",
    "email": "minsu@gmail.com",
    "role": 1
  }
}

데이터베이스 확인

-- 구글 로그인 사용자 조회
SELECT * FROM users 
WHERE provider = 'GOOGLE'
ORDER BY created_at DESC;

결과:

+----+------------------+----------+------------------+----------+------------+------+--------+
| id | userId           | username | email            | provider | providerId | role | active |
+----+------------------+----------+------------------+----------+------------+------+--------+
| 15 | google_123456789 | 김민수    | minsu@gmail.com   | GOOGLE   | 123456789  | 1    | 1      |
+----+------------------+----------+------------------+----------+------------+------+--------+

💡 핵심 정리

OAuth 2.0 흐름

[프론트엔드]          [백엔드]           [구글]
    │                  │                 │
    │──① URL 요청────▶│                 │
    │◀─② URL 반환─────│                 │
    │                  │                 │
    │──③ 로그인────────────────────────▶│
    │◀─④ 인증 코드──────────────────────│
    │                  │                 │
    │──⑤ code 전송────▶│                 │
    │                  │──⑥ 토큰 요청──▶│
    │                  │◀─⑦ 토큰────────│
    │                  │──⑧ 정보 요청──▶│
    │                  │◀─⑨ 정보────────│
    │                  │ ⑩ DB + JWT     │
    │◀─⑪ JWT 반환─────│                 │

0개의 댓글