우선 구글 캘린더 API 연동 흐름은 아래와 같이 진행되고 있다.
- Google API 관련 설정 (HTTP_TRANSPORT, JSON_FACTORY, SCOPES 등)
- 사용자 인증 수행 (authorize())
- client_secret.json 로드
- OAuth 2.0 인증
- 인증된 Credential 반환
- Google Calendar API 서비스 객체 생성 (getCalendarService())
- 인증된 Credential을 사용해 Calendar 객체 생성
- 생성된 Calendar 객체를 통해 API 호출 수행 (이벤트 조회, 추가, 삭제 등)
저번에 local 서버에서는 정상작동이 되었지만 운영서버에 배포를 하니 오류메시지가 나오면서 API연동이 정상적으로 이루어지지않았다. 이유를 살펴보니
// Credential 객체 생성
Credential credential = new AuthorizationCodeInstalledApp(flow, new LocalServerReceiver()).authorize("user");
LocalServerReceiver()
부분이 문제가 생겼었다. 이 함수는 임시 로컬 웹 서버를 생성하는 함수인데 운영서버에서는 리다이렉션 URI부분이 운영서버에 맞게 들어가야 하는데 localhost가 강제적으로 주입이 되었다. 또한 LocalServerReceiver()
함수에는 Callback
함수를 자동으로 처리해주는 기능이 있어 로컬에서는 발견을 못하였지만 운영서버에서는 Callback.do
라는 로직을 추가해주어야한다...
따라서 해당 부분을 운영서버와, 로컬서버를 구분을 지어야 하며 Callback.do
를 추가해주었다.
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.List;
import javax.annotation.Resource;
import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl;
import org.springframework.stereotype.Service;
import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.auth.oauth2.TokenResponse;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.DateTime;
import com.google.api.client.util.store.FileDataStoreFactory;
import com.google.api.services.calendar.Calendar;
import com.google.api.services.calendar.CalendarScopes;
import com.google.api.services.calendar.model.Event;
import com.google.api.services.calendar.model.EventDateTime;
@SuppressWarnings("unused")
private static Credential authorize() throws IOException, GeneralSecurityException {
// client_secret.json 파일을 클래스 경로에서 읽기
try (InputStream in = GoogleCalendarServiceImpl.class.getResourceAsStream("/client_secret.json")) {
if (in == null) {
throw new FileNotFoundException("Resource '/client_secret.json' not found. Ensure the file exists in the classpath.");
}
// GoogleClientSecrets 객체 생성
GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));
// GoogleAuthorizationCodeFlow 객체 생성
GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
clientSecrets,
SCOPES
)
.setDataStoreFactory(DATA_STORE_FACTORY)
.setAccessType("offline")
.build();
String redirectUri = "https://intra.k2web.co.kr/Callback.do"; //운영서버용 URI
// 기존 Credential 불러오기 (refresh_token 체크)
Credential credential = flow.loadCredential("user");
System.out.println("Credential이 저장된 경로: " + DATA_STORE_DIR.getAbsolutePath());
if (credential != null) {
if (credential.getRefreshToken() == null) { // refresh_token이 없으면 다시 인증 필요
System.err.println("Warning: refresh_token이 없습니다. 기존 인증 정보를 삭제하고 다시 로그인해야 합니다.");
// 기존 인증 정보 삭제
DATA_STORE_FACTORY.getDataStore("user").clear();
// 새로운 OAuth 인증 필요
AuthorizationCodeRequestUrl authorizationUrl = flow.newAuthorizationUrl()
.setRedirectUri(redirectUri)
.set("approval_prompt", "force"); // 항상 refresh_token 받기
System.out.println("OAuth 인증 필요! 브라우저에서 다음 URL을 열어 로그인하세요: " + authorizationUrl.build());
return null;
}
System.out.println("기존 인증 정보를 사용하여 로그인합니다.");
return credential;
} else {
// refresh_token 없음 → 새로운 OAuth 인증 필요
AuthorizationCodeRequestUrl authorizationUrl = flow.newAuthorizationUrl()
.setRedirectUri(redirectUri)
.set("approval_prompt", "force"); // 항상 refresh_token 받기
System.out.println("OAuth 인증 필요! 브라우저에서 다음 URL을 열어 로그인하세요: " + authorizationUrl.build());
return null; // 사용자가 인증 완료 후 다시 실행해야 함
}
} catch (IOException e) {
// JSON 파일을 읽거나 GoogleClientSecrets 로드 중 문제가 발생했을 때 예외 처리
System.err.println("Error reading client_secret.json: " + e.getMessage());
throw e;
}
}
@RestController
public class OAuthCallbackController {
@GetMapping("/Callback.do")
public ResponseEntity<String> handleOAuthCallback(@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "error", required = false) String error) {
if (error != null) {
return ResponseEntity.badRequest().body("OAuth 인증 실패: " + error);
}
if (code == null) {
return ResponseEntity.badRequest().body("인증 코드가 없습니다.");
}
try {
//받은 코드로 Access Token 요청 및 저장
Credential credential = GoogleCalendarServiceImpl.exchangeAuthorizationCodeForToken(code);
return ResponseEntity.ok("OAuth 인증 성공! Access Token: " + credential.getAccessToken());
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.SC_INTERNAL_SERVER_ERROR)
.body("토큰 교환 실패: " + e.getMessage());
}
}
}
public static Credential exchangeAuthorizationCodeForToken(String code) throws IOException {
try (InputStream in = GoogleCalendarServiceImpl.class.getResourceAsStream("/client_secret.json")) {
if (in == null) {
throw new FileNotFoundException("Resource '/client_secret.json' not found. Ensure the file exists in the classpath.");
}
// GoogleClientSecrets 객체 생성
GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));
// GoogleAuthorizationCodeFlow 객체 생성
GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
clientSecrets,
SCOPES
)
.setDataStoreFactory(DATA_STORE_FACTORY)
.setAccessType("offline")
.build();
// Authorization Code로 Access Token 요청
TokenResponse response = flow.newTokenRequest(code)
.setRedirectUri("https://intra.k2web.co.kr/Callback.do")
.execute();
// Access Token을 저장하고 Credential 반환
Credential credential = flow.createAndStoreCredential(response, "user");
// refresh_token이 있는지 로그 출력 (디버깅용)
System.out.println("Access Token: " + credential.getAccessToken());
System.out.println("Refresh Token: " + credential.getRefreshToken());
if (credential.getRefreshToken() == null) {
System.err.println("Warning: refresh_token이 없습니다. 다시 로그인해야 할 수도 있습니다.");
}
return credential;
}
}
public static Credential exchangeAuthorizationCodeForToken(String code) throws IOException {
try (InputStream in = GoogleCalendarServiceImpl.class.getResourceAsStream("/client_secret.json")) {
if (in == null) {
throw new FileNotFoundException("Resource '/client_secret.json' not found. Ensure the file exists in the classpath.");
}
// GoogleClientSecrets 객체 생성
GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));
// GoogleAuthorizationCodeFlow 객체 생성
GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
clientSecrets,
SCOPES
)
.setDataStoreFactory(DATA_STORE_FACTORY)
.setAccessType("offline")
.build();
// Authorization Code로 Access Token 요청
TokenResponse response = flow.newTokenRequest(code)
.setRedirectUri("https://intra.k2web.co.kr/Callback.do")
.execute();
// Access Token을 저장하고 Credential 반환
Credential credential = flow.createAndStoreCredential(response, "user");
// refresh_token이 있는지 로그 출력 (디버깅용)
System.out.println("Access Token: " + credential.getAccessToken());
System.out.println("Refresh Token: " + credential.getRefreshToken());
if (credential.getRefreshToken() == null) {
System.err.println("Warning: refresh_token이 없습니다. 다시 로그인해야 할 수도 있습니다.");
}
return credential;
}
}
이제 운영서버에서 실행시켜보면 초기에는 Credential
파일이 없으므로 log에 URL을 입력하라고 나오는데 해당 URL을 입력하면 access token
과 refresh token
을 가지고있는 Credential
을 생성해 줄 것이다. 여기서 refresh token
을 만들어 주는 것이 중요하다.
access token
은 1시간이 지나면 만료가 되기때문에 이걸 자동으로 갱신시켜주는 것이 refresh token
이다.
- Access Token (response.getAccessToken())
- API에 접근할 때 사용
- 짧은 유효기간
- 매 요청 시 포함되어 사용됨
- Refresh Token (response.getRefreshToken())
- 만료된 access token을 갱신하는 데 사용
- 긴 유효기간
- 사용자 재인증 없이 토큰 갱신 가능
이렇게 로컬서버에서 작동이 되었지만 운영서버에 배포했을 경우 소스가 완전 달라지고 신경써주어야 할 부분이 많을 걸 볼 수 있다. 나도 구글캘린더API 연동을 해보면서 멘붕이 일어나고 또한 개발서버가 없어 불편한 점이 많았다. 로컬에서 된다고 끝난게 아니라는 걸 다시 한번 느꼈다. 어쨌든 이번 구글 캘린더 API 연동을 하며 배운점도 많았고 배치 작업도 스스로 해볼 수 있어서 좋았던 것 같다. 끝