
Google Meet 링크를 생성하기 위해서는 먼저 구글 로그인 여부를 확인해야 한다. 이것을 확인하는 방법이 바로 OAuth2 액세스 토큰이 있는지 여부를 확인하는 것이다. 기본적으로 액세스 토큰은 1시간이면 만료되므로, 같이 발급되는 리프레쉬 토큰을 통해 만료 10분 전 자동으로 새 액세스 토큰을 발급받는 로직 또한 만들어놨다.
public MeetingResponse createMeeting(String userId, LocalDateTime startTime, Designer designer) {
String accessToken = googleTokenService.getValidAccessToken(userId);
String meetingTitle = generateMeetingTitle(designer.getName(), startTime);
try {
ConferenceResponse response = googleMeetClient.createMeeting(
accessToken,
createConferenceRequest(meetingTitle, startTime, startTime.plusHours(1))
);
Meeting meeting = createMeeting2(startTime, meetingTitle, response.hangoutLink());
meetingRepository.save(meeting);
return new MeetingResponse(meetingTitle, response.hangoutLink(), meeting.getId());
} catch (Exception e) {
throw new RuntimeException("Google Meet 링크 생성에 실패했습니다." + e);
}
}
따라서 필자는 이렇게 getValidAccessToken 메서드로 토큰 유효성을 확인한 후에 미팅을 생성할 수 있게 했다.
public String getValidAccessToken(String userId) {
GoogleJsonWebToken token = googleTokenRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("Google 토큰을 찾을 수 없습니다."));
if (token.getExpiresIn() == null || token.getExpiresIn().minusMinutes(10).isBefore(LocalDateTime.now())) {
OAuth2TokenResponse newToken = tokenService.refreshAccessToken(token.getRefreshToken());
// 새 토큰 저장
GoogleJsonWebToken updatedToken = GoogleJsonWebToken.builder()
.userId(userId)
.accessToken(newToken.accessToken())
.refreshToken(token.getRefreshToken()) // refresh token은 유지
.expiresIn(LocalDateTime.now().plusHours(1))
.build();
googleTokenRepository.save(updatedToken);
log.info("리프레쉬 토큰 재발급");
return "Bearer " + newToken.accessToken();
}
return "Bearer " + token.getAccessToken();
}
getValidAccessToken은 이런 식으로 구성했다. 기본적으로 Id로 로그인한 유저의 토큰을 찾고, 만료 기간이 null이거나 10분 전이면 자동으로 새 토큰을 발급받은 후 리턴하고, 그렇지 않은 경우 기존 토큰을 반환한다.
필자는 이번에 FeignClient를 활용하여 API 호출을 구현했다.
(FeignClient의 개념과 활용 관련해서도 포스팅 할 예정)
@FeignClient(
name = "googleMeetClient",
url = "https://www.googleapis.com/calendar/v3",
configuration = GoogleMeetFeignConfig.class
)
public interface GoogleMeetClient {
@PostMapping("/calendars/primary/events?conferenceDataVersion=1")
ConferenceResponse createMeeting(
@RequestHeader("Authorization") String accessToken,
@RequestBody ConferenceRequest request);
}
여기서 "conferenceDataVersion=1" 이란 부분이 있는데, 여기를 1로 설정해줘야 API 호출 시 이를 인식해서 Google Meet 링크를 생성할 수 있다. 또 "primary"는 유저의 기본 캘린더에 이벤트를 추가하는 엔드포인트이다.
DTO를 두 개 설정해줘야 하는데, 하나는 Google과 API 호출 관련한 DTO이고, 다른 하나는 Meeting 엔티티 생성에 관여하는 DTO이다.

먼저 API 통신용 DTO이다. 현재는 record를 활용하여 리팩토링한 상태인데, 설명을 위해 잠깐 다시 합쳐보자면,
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
public class GoogleMeetDto {
// ✅ 요청 DTO
public static class Request {
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ConferenceRequest {
private String summary; // 미팅 제목
private EventDateTime start; // 시작 시간
private EventDateTime end; // 종료 시간
private ConferenceData conferenceData; // Google Meet 회의 정보
private int version = 1; // 요청 버전
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class EventDateTime {
private String dateTime; // ISO 8601 형식 ("2024-02-16T10:00:00Z")
private String timeZone; // 예: "Asia/Seoul"
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ConferenceData {
private CreateConferenceRequest createRequest;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class CreateConferenceRequest {
private String requestId; // 요청을 구분하는 UUID
private ConferenceSolutionKey conferenceSolutionKey;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ConferenceSolutionKey {
private String type = "hangoutsMeet"; // Google Meet 사용
}
}
}
// ✅ 응답 DTO
public static class Response {
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ConferenceResponse {
private String id;
private String hangoutLink; // Google Meet 링크
private ConferenceData conferenceData;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ConferenceData {
private ConferenceSolution conferenceSolution;
private CreateConferenceRequest createRequest;
private EntryPoints[] entryPoints;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ConferenceSolution {
private ConferenceSolutionKey key;
private String name;
private String iconUri;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ConferenceSolutionKey {
private String type;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class CreateConferenceRequest {
private String requestId;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class EntryPoints {
private String entryPointType;
private String uri;
private String label;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class Key {
private String type;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class Status {
private String statusCode;
}
}
}
이런 식으로 구현해주면 된다. 주의할 점은 request.ConferenceSolutionKey의 기본값을 레거시인 "hangoutsMeet"로, request.ConferenceRequest의 conferenceDataVersion을 "1"로 지정해주어야 Google에서 이를 제대로 인식해서 API를 불러올 수 있다. 또 requestId 같은 경우도 동적으로 설정해주어야 한다.
// Meet 링크 생성에 필요한 데이터 설정
ConferenceSolutionKey solutionKey = new ConferenceSolutionKey();
CreateConferenceRequest createRequest = new CreateConferenceRequest(
UUID.randomUUID().toString(),
solutionKey
);
ConferenceData conferenceData = new ConferenceData(createRequest);
필자의 경우 서비스 레이어에서 처리해주었다. 나중에 DTO 자체에서 디폴트 값을 넣는 식으로 리팩토링하면 더 깔끔한 코드가 되시겠다.
다음으로는 내부 통신용 DTO이다.
package blaybus.domain.meeting.presentation.dto.request;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
public record MeetingCreateRequest(
@NotNull
String title,
@NotNull
LocalDateTime startTime,
@NotNull
LocalDateTime endTime,
@NotNull
String designerName
) {}
package blaybus.domain.meeting.presentation.dto.response;
public record MeetingResponse(
String title,
String hangoutLink,
Long id
) {}
미팅 title을 생성할 때 코드가 다음과 같았기 때문에,
public String generateMeetingTitle(String designerName, LocalDateTime startTime) {
return String.format("%s님과의 상담 예약 - %s",
designerName,
startTime.format(DateTimeFormatter.ofPattern("M월 d일 a h시 mm분")));
}
Request에 startTime과 designerName 필드를 추가했다. 지금 보니까 endTime은 필요 없었군...
테스트 순서는 Google 소셜 로그인 -> 액세스 토큰을 Headers에 추가 -> API 호출이다.
@Test
void Google_Meet_생성_테스트() {
// given
String accessToken = "your-access-token";
String token = "Bearer " + accessToken;
String title = "test-title";
LocalDateTime startTime = LocalDateTime.now().plusMinutes(5);
LocalDateTime endTime = startTime.plusHours(1);
// Create EventDateTime objects
EventDateTime start = new EventDateTime(
startTime.toString(),
"Asia/Seoul"
);
EventDateTime end = new EventDateTime(
endTime.toString(),
"Asia/Seoul"
);
// Create ConferenceData
ConferenceSolutionKey solutionKey = new ConferenceSolutionKey("hangoutsMeet");
CreateConferenceRequest createRequest = new CreateConferenceRequest(
UUID.randomUUID().toString(),
solutionKey
);
ConferenceData conferenceData = new ConferenceData(createRequest);
// Create final ConferenceRequest
ConferenceRequest request = new ConferenceRequest(
title,
start,
end,
conferenceData,
1
);
// when
ConferenceResponse response = googleMeetClient.createMeeting(token, request);
// then
System.out.println(response);
System.out.println("생성된 링크: " + response.hangoutLink());
}
필자가 작성한 테스트 코드이다. 액세스 토큰을 하드코딩으로 넣게 되어 있는데 실제 서비스 로직에서는 위에서 보여줬다시피 userId를 통해 토큰 존재 여부를 확인하게 된다.

링크가 잘 생성되는 걸 볼 수 있다 ㅎㅎ
FE단에서 미팅 생성 요청을 보낼 때, 분명히 DTO 형식이 다 잘 맞는데도 자꾸 API 호출 실패가 뜨는 오류가 발생했었다.


포스트맨으로 테스트하던 도중, startTime의 밀리초 부분이 0인 경우에만 호출 오류가 발생한다는 것을 알게 되었고,

프론트단에서 밀리초 부분에 1을 채우는 식으로 디버깅을 성공했다.
아마 정확한 이유는 모르겠지만, Google API 내부적으로 밀리초 부분의 000000을 null로 처리했던 게 아닐까 싶다!