캘린더 시스템에서 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 |