Backend/API

[Google API] 구글 캘린더 연동

dddzr 2025. 7. 26. 15:26

캘린더 시스템에서 google 캘린더 연동을 위해 API연동을 했다.

 

📌1. Google Cloud에 프로젝트 생성

https://console.cloud.google.com/

 

Google 클라우드 플랫폼

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

accounts.google.com

 

등록하는 건 캡쳐를 못 했는데

ClientID랑 시크릿키를 발급받으면 됩니다!

 

📌2. 프로젝트 코드 수정

2.1. 의존성 추가

pom.xml

        <!-- Google Calendar API -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>com.google.api-client</groupId>
            <artifactId>google-api-client</artifactId>
            <version>2.2.0</version>
        </dependency>
        <dependency>
            <groupId>com.google.oauth-client</groupId>
            <artifactId>google-oauth-client-jetty</artifactId>
            <version>1.34.1</version>
        </dependency>
        <dependency>
            <groupId>com.google.apis</groupId>
            <artifactId>google-api-services-calendar</artifactId>
            <version>v3-rev411-1.25.0</version>
        </dependency>
        <dependency>
            <groupId>com.google.api-client</groupId>
            <artifactId>google-api-client-gson</artifactId>
            <version>2.2.0</version>
        </dependency>

 

2.2. application.yml

security:
    oauth2:
      client:
        registration:
          google:
            client-id: {}.apps.googleusercontent.com
            client-secret: {}
            scope: profile, email, https://www.googleapis.com/auth/calendar
            redirect-uri: "https://localhost:1443/google/oauth2/callback"
            client-name: Google
        provider:
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
            token-uri: https://oauth2.googleapis.com/token
            user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo

 

2.3. 프론트엔드에서 Google 로그인 창 띄우기

function connectGoogleCalendar() 
    // 직접 Google OAuth2 인증 URL 생성
    const CLIENT_ID = '{}.apps.googleusercontent.com';
    const REDIRECT_URI = 'https://localhost:1443/google/oauth2/callback';
    const url = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=' + CLIENT_ID + '&redirect_uri='
        + REDIRECT_URI + '&response_type=code&scope=https://www.googleapis.com/auth/calendar&access_type=offline&prompt=consent';

    // 새 창에서 OAuth2 인증 진행
    const authWindow = window.open(url, '_blank', 'width=500,height=600');

    // OAuth2 callback 응답 처리 (메시지 기반)
    window.addEventListener('message', function(event) {
        if (event.data && event.data.type === 'oauth2_callback') {
            const result = event.data.result;

            if (result.success && result.accessToken) {
                // 성공 시 로컬스토리지에 토큰 저장
                localStorage.setItem('googleAccessToken', result.accessToken);
                // localStorage.setItem('googleRefreshToken', result.refreshToken);
                console.log('OAuth2 인증 성공: 토큰이 저장되었습니다.');

                // Google Calendar 데이터 로드
                loadGoogleSchedule();
            } else {
                // 실패 시 에러 메시지 표시
                console.error('OAuth2 인증 실패:', result.error);
                alert('구글 캘린더 연동에 실패했습니다: ' + result.error);
            }
        }
    });
}

window.addEventListener('message', function(event) {
    if (event.data.type === 'oauth2_success') {
        console.log('OAuth2 인증 성공:', event.data.data);
        handleOAuth2Success(event.data.data);
    } else if (event.data.type === 'oauth2_error') {
        console.log('OAuth2 인증 실패:', event.data.data);
        handleOAuth2Error(event.data.data);
    }
});


// OAuth2 성공 처리
function handleOAuth2Success(data) {
    console.log('OAuth2 인증 성공:', data);
    loadDataAndUpdateView({loadGoogleSchedules: true});
}


// OAuth2 실패 처리
function handleOAuth2Error(data) {
    console.error('구글 캘린더 OAuth2 인증 실패:', data);
   
    let errorMessage = '구글 캘린더 연동에 실패했습니다.';
   
    if (data.message) {
        if (data.message.includes('access_denied')) {
            errorMessage = '구글 캘린더 접근 권한이 거부되었습니다.';
        } else if (data.message.includes('invalid_request')) {
            errorMessage = '인증 요청이 잘못되었습니다. 다시 시도해주세요.';
        } else {
            errorMessage = data.message;
        }
    }
   
    console.error('OAuth2 에러:', errorMessage);
}

 

2.4. 프론트엔드 구글 연동 해제

function disconnectGoogleCalendar() {
    // localStorage에서 accessToken 꺼내기
    const accessToken = localStorage.getItem('googleAccessToken');

    axios.post('/google/disconnect', {
        googleAccessToken: accessToken,
        // refreshToken은 HttpOnly 쿠키라 JS에서 못 읽음. 서버에서 쿠키로 받아 처리.
        // googleRefreshToken: null
    }, {
        withCredentials: true // 쿠키 전송하려면 필수!
    })
    .then(response => {
        if (response.data.success) {
            // 토큰 삭제
            localStorage.removeItem('googleAccessToken');
            $('#google-schedule').prop('checked', false);
            alert('구글 캘린더 연동이 해제되었습니다.');
        } else {
            alert('연동 해제 실패: ' + response.data.message);
        }
    })
    .catch(error => {
        alert('서버 요청 오류: ' + error.message);
    });
}

 

2.5. 백엔드 서비스

로그인 콜백, 일정 조회, 업로드

*user AccessToken 저장할 테이블 생성 전이라 로컬 스토리지에 accessToken, 쿠키에 refreshToken 저장했다. 수정해야함.

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import kr.co.example.calender.service.GoogleCalendarService;
import kr.co.example.calender.util.SecurityUtil;
import kr.co.example.calender.dto.ScheduleDTO;

import java.util.List;
import java.util.Map;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.stream.Collectors;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ResponseStatusException;

import jakarta.servlet.http.HttpServletResponse;

@RestController
@RequestMapping("/google")
public class GoogleAPIController {

    private final GoogleCalendarService googleCalendarService;

    private final String clientId = "{}.apps.googleusercontent.com";
    private final String clientSecret = "{}";


    public GoogleAPIController(GoogleCalendarService googleCalendarService) {
        this.googleCalendarService = googleCalendarService;
    }


    // 직접 OAuth2 콜백 처리 (Spring Security OAuth2 필터를 우회)
    @GetMapping("/oauth2/callback")
    public void handleGoogleCallback(@RequestParam("code") String code,
                                   @RequestParam(value = "state", required = false) String state,
                                   @RequestParam(value = "error", required = false) String error,
                                   HttpServletRequest request,
                                   HttpServletResponse response) throws Exception {
        try {
            System.out.println("Google OAuth2 Callback - Code: " + code);
            System.out.println("Google OAuth2 Callback - State: " + state);
            System.out.println("Google OAuth2 Callback - Error: " + error);
           
            Map<String, Object> result;
           
            if (error != null) {
                result = Map.of(
                    "success", false,
                    "error", "OAuth2 error: " + error
                );
            } else {
                // 직접 Google OAuth2 토큰 교환
                Map<String, String> tokens = exchangeCodeForToken(code);
                String accessToken = tokens.get("access_token");
                String refreshToken = tokens.get("refresh_token");
               
                // TODO: DB에 토큰 저장 (DB 컬럼 추가 후 구현)
                // googleCalendarService.saveOrUpdateToken(userId, accessToken, refreshToken, expiresAt);
               
                if (refreshToken != null) {
                    // refreshToken을 HttpOnly 쿠키로 설정
                    Cookie cookie = new Cookie("googleRefreshToken", refreshToken);
                    cookie.setPath("/");
                    cookie.setHttpOnly(true); // JS에서 접근 불가
                    cookie.setSecure(true);   // HTTPS에서만 전송
                    cookie.setMaxAge(30 * 24 * 60 * 60); // 유지 기간 설정(30일)
                    response.addCookie(cookie);
                }

                result = Map.of(
                    "success", true,
                    "message", "OAuth2 인증 성공",
                    "accessToken", accessToken
                    // , "refreshToken", refreshToken
                );
            }
           
            // JSON 결과를 HTML 페이지로 전달
            response.setContentType("text/html;charset=UTF-8");
            response.getWriter().write("""
                <!DOCTYPE html>
                <html>
                <head>
                    <title>OAuth2 인증 처리</title>
                </head>
                <body>
                    <script>
                        const result = %s;
                       
                        if (window.opener) {
                            window.opener.postMessage({
                                type: 'oauth2_callback',
                                result: result
                            }, '*');
                        }
                        window.close();
                    </script>
                    <p>OAuth2 인증을 처리하고 있습니다...</p>
                </body>
                </html>
                """.formatted(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(result)));
           
        } catch (Exception e) {
            System.err.println("Google OAuth2 callback error: " + e.getMessage());
            e.printStackTrace();
           
            Map<String, Object> errorResult = Map.of(
                "success", false,
                "error", e.getMessage()
            );
           
            response.setContentType("text/html;charset=UTF-8");
            response.getWriter().write("""
                <!DOCTYPE html>
                <html>
                <head>
                    <title>OAuth2 인증 실패</title>
                </head>
                <body>
                    <script>
                        const result = %s;
                       
                        if (window.opener) {
                            window.opener.postMessage({
                                type: 'oauth2_callback',
                                result: result
                            }, '*');
                        }
                        window.close();
                    </script>
                    <p>OAuth2 인증에 실패했습니다.</p>
                </body>
                </html>
                """.formatted(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(errorResult)));
        }
    }
   
    /**
     * Google OAuth2 인증 코드를 액세스 토큰으로 교환
     */
    private Map<String, String> exchangeCodeForToken(String code) throws Exception {
        String tokenUrl = "https://oauth2.googleapis.com/token";
       
        // 요청 파라미터 설정
        Map<String, String> params = new HashMap<>();
        System.out.println("clientId: " + clientId);
        System.out.println("clientSecret: " + clientSecret);
        params.put("client_id", clientId);
        params.put("client_secret", clientSecret);
        params.put("code", code);
        params.put("grant_type", "authorization_code");
        params.put("redirect_uri", "https://localhost:1443/google/oauth2/callback");
       
        // HTTP 헤더 설정
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
       
        // 요청 본문 생성
        String requestBody = params.entrySet().stream()
            .map(entry -> entry.getKey() + "=" + entry.getValue())
            .collect(Collectors.joining("&"));
       
        HttpEntity<String> request = new HttpEntity<>(requestBody, headers);
       
        // 토큰 교환 요청
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<Map> response = restTemplate.postForEntity(tokenUrl, request, Map.class);
       
        if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
            Map<String, Object> tokenResponse = response.getBody();
            String accessToken = (String) tokenResponse.get("access_token");
            String refreshToken = (String) tokenResponse.get("refresh_token");
           
            if (accessToken != null) {
                System.out.println("Google Access Token 획득 성공 [access_token: " + accessToken + ", refresh_token: " + refreshToken + "]");
                return Map.of("access_token", accessToken, "refresh_token", refreshToken);
            } else {
                throw new IllegalStateException("액세스 토큰을 찾을 수 없습니다: " + tokenResponse);
            }
        } else {
            throw new IllegalStateException("토큰 교환 실패: " + response.getStatusCode() + " - " + response.getBody());
        }
    }
   
    @PostMapping("/refresh-token")
    public ResponseEntity<?> refreshGoogleAccessToken(
        @CookieValue(value = "googleRefreshToken", required = false) String googleRefreshToken
    ) {
        if (googleRefreshToken == null || googleRefreshToken.isEmpty()) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body(Map.of("success", false, "message", "리프레시 토큰이 없습니다."));
        }


        // 토큰 요청 파라미터 구성
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("client_id", clientId);
        params.add("client_secret", clientSecret);
        params.add("grant_type", "refresh_token");
        params.add("refresh_token", googleRefreshToken);


        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);


        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);


        try {
            RestTemplate restTemplate = new RestTemplate();
            ResponseEntity<Map> response = restTemplate.postForEntity(
                "https://oauth2.googleapis.com/token",
                request,
                Map.class
            );


            Map<String, Object> responseBody = response.getBody();
            if (responseBody != null && responseBody.get("access_token") != null) {
                String newAccessToken = (String) responseBody.get("access_token");


                return ResponseEntity.ok(Map.of(
                    "success", true,
                    "accessToken", newAccessToken,
                    "expiresIn", responseBody.get("expires_in")
                ));
            } else {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .body(Map.of("success", false, "message", "access token 발급 실패"));
            }


        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(Map.of("success", false, "message", "토큰 재발급 중 오류 발생", "error", e.getMessage()));
        }
    }
   
    @PostMapping("/disconnect")
    public ResponseEntity<Map<String, Object>> disconnectGoogleCalendar(
            @CookieValue(value = "googleRefreshToken", required = false) String googleRefreshToken,
            @RequestBody Map<String, String> requestBody,
            HttpServletResponse response)
    {
        String userId = SecurityUtil.getAuthUserId();
   
        try {
            // 1. DB에 저장된 토큰 삭제 (refreshToken 쿠키도 함께 삭제할 것이므로)
            // if (userId != null) {
            //     googleCalendarService.removeToken(userId);
            // }
   
            // 2. 쿠키 제거 (refreshToken)
            Cookie deleteCookie = new Cookie("googleRefreshToken", null);
            deleteCookie.setPath("/");
            deleteCookie.setHttpOnly(true);
            deleteCookie.setSecure(true);
            deleteCookie.setMaxAge(0); // 즉시 만료
            response.addCookie(deleteCookie);
   
            // 3. Google OAuth2 토큰 무효화
            if(requestBody.containsKey("googleAccessToken")) {
                String googleAccessToken = requestBody.get("googleAccessToken");
                revokeGoogleToken(googleAccessToken);
            }


            if (googleRefreshToken != null) {
                revokeGoogleToken(googleRefreshToken);
            }
   
            return ResponseEntity.ok(Map.of(
                "success", true,
                "message", "구글 캘린더 연동이 해제되었습니다."
            ));
   
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
                "success", false,
                "message", "연동 해제 중 오류 발생: " + e.getMessage()
            ));
        }
    }


    private void revokeGoogleToken(String token) {
        try {
            String revokeUrl = "https://oauth2.googleapis.com/revoke";
   
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
   
            MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
            params.add("token", token);
   
            HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
            ResponseEntity<String> response = new RestTemplate().postForEntity(revokeUrl, request, String.class);
   
            if (response.getStatusCode().is2xxSuccessful()) {
                System.out.println("토큰 revoke 완료: " + token);
            } else {
                System.err.println("revoke 실패: " + response.getBody());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
   
   
    // 구글 캘린더 일정
    @GetMapping("/events")
    public List<Map<String, Object>> getEvents(@RequestHeader(value = "X-Google-Access-Token", required = false) String accessToken) throws Exception {
        // TODO: DB에서 토큰 조회 (DB 컬럼 추가 후 구현)
        // String accessToken = userGoogleTokenService.getAccessToken(userId);
       
        if (accessToken == null || accessToken.trim().isEmpty()) {
            throw new IllegalStateException("Google Access Token이 없습니다. 다시 인증해주세요.");
        }
       
        List<Map<String, Object>> calendarList = new ArrayList<>();
        try {
            calendarList = googleCalendarService.getGoogleCalendarList(accessToken);
        } catch (HttpClientErrorException.Unauthorized e) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "구글 인증 정보가 유효하지 않습니다.", e);
   
        } catch (HttpClientErrorException e) {
            throw new ResponseStatusException(e.getStatusCode(), "Google API 오류: " + e.getMessage(), e);
   
        } catch (Exception e) {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류: " + e.getMessage(), e);
        }
        List<Map<String, Object>> result = new ArrayList<>();
       
        for(Map<String, Object> calendar : calendarList) {
            if(!calendar.get("accessRole").equals("owner")) {
                continue;
            }
           
            String calendarId = (String) calendar.get("id");
            String calendarName = (String) calendar.get("summary");
           
            // 카테고리별로 정보와 이벤트를 분리하여 반환
            Map<String, Object> calendarInfo = new HashMap<>();
            calendarInfo.put("calendarId", calendarId);
            calendarInfo.put("calendarName", calendarName);
            calendarInfo.put("backgroundColor", calendar.get("backgroundColor"));
            calendarInfo.put("foregroundColor", calendar.get("foregroundColor"));
            calendarInfo.put("accessRole", calendar.get("accessRole"));
            calendarInfo.put("primary", calendar.get("primary"));


            try {
                List<Map<String, Object>> calendarEvents = googleCalendarService.getGoogleEvents(accessToken, calendarId);
                calendarInfo.put("events", calendarEvents);
            } catch (Exception e) {
                System.err.println("캘린더 " + calendarName + " (" + calendarId + ")에서 이벤트 가져오기 실패: " + e.getMessage());
                calendarInfo.put("events", new ArrayList<>());
            }


            result.add(calendarInfo);
        }
       
        return result;
    }


    // 구글 캘린더 목록 조회
    @GetMapping("/calendars")
    public List<Map<String, Object>> getCalendars(@RequestHeader(value = "X-Google-Access-Token", required = false) String accessToken) throws Exception {
        // TODO: DB에서 토큰 조회 (DB 컬럼 추가 후 구현)
        // String accessToken = userGoogleTokenService.getAccessToken(userId);
       
        if (accessToken == null || accessToken.trim().isEmpty()) {
            throw new IllegalStateException("Google Access Token이 없습니다. 다시 인증해주세요.");
        }
       
        return googleCalendarService.getGoogleCalendarList(accessToken);
    }


    // 구글 캘린더에 일정 추가
    @PostMapping("/events")
    public Map<String, Object> addEvent(@RequestHeader(value = "X-Google-Access-Token", required = false) String accessToken,
                                       @RequestBody Map<String, Object> eventData) throws Exception {
        // TODO: DB에서 토큰 조회 (DB 컬럼 추가 후 구현)
        // String accessToken = userGoogleTokenService.getAccessToken(userId);
       
        if (accessToken == null || accessToken.trim().isEmpty()) {
            throw new IllegalStateException("Google Access Token이 없습니다. 다시 인증해주세요.");
        }
       
        return googleCalendarService.addGoogleEvent(accessToken, eventData);
    }


    // ScheduleDTO를 받아서 구글 캘린더에 일정 추가
    @PostMapping("/schedule")
    public Map<String, Object> addScheduleToGoogle(@RequestHeader(value = "X-Google-Access-Token", required = false) String accessToken,
                                                  @RequestBody ScheduleDTO scheduleDTO) throws Exception {
        // TODO: DB에서 토큰 조회 (DB 컬럼 추가 후 구현)
        // String accessToken = userGoogleTokenService.getAccessToken(userId);
       
        if (accessToken == null || accessToken.trim().isEmpty()) {
            throw new IllegalStateException("Google Access Token이 없습니다. 다시 인증해주세요.");
        }
       
        return googleCalendarService.addScheduleToGoogleCalendar(accessToken, scheduleDTO);
    }


}

 

2.5. 구글 Calendar API 응답 예시

본인 DB 테이블 구조대로 파싱할 때 참고할 것!!

Google Calendar API 응답: {
 "kind": "calendar#events",
 "etag": "\"{}\"",
 "summary": "dddzr@dddzr.co.kr",
 "description": "",
 "updated": "2025-07-16T05:30:50.133Z",
 "timeZone": "Asia/Seoul",
 "accessRole": "owner",
 "defaultReminders": [
  {
   "method": "popup",
   "minutes": 30
  }
 ],
 "items": [
  {
   "kind": "calendar#event",
   "etag": "\"{}\"",
   "id": "{}",
   "status": "confirmed",
   "htmlLink": "https://www.google.com/calendar/event?eid={}",       
   "created": "2025-07-16T00:20:05.000Z",
   "updated": "2025-07-16T05:28:49.919Z",
   "summary": "일정 제목",
   "description": "일정 설명",
   "creator": {
    "email": "dddzr@dddzr.co.kr",
    "self": true
   },
   "organizer": {
    "email": "dddzr@dddzr.co.kr",
    "self": true
   },
   "start": {
    "dateTime": "2025-07-16T09:30:00+09:00",
    "timeZone": "Asia/Seoul"
   },
   "end": {
    "dateTime": "2025-07-16T10:30:00+09:00",
    "timeZone": "Asia/Seoul"
   },
   "iCalUID": "{}@google.com",
   "sequence": 0,
   "reminders": {
    "useDefault": true
   },
   "eventType": "default"
  }
 ]
}

'Backend > API' 카테고리의 다른 글

ICal을 이용한 외부 캘린더 연동  (0) 2025.07.26
[Google API] 구글 앱 인증 받기  (0) 2025.07.26