SW 개발/Spring
Spring Boot, Kotlin, Google Drive/Sheets API로 공유 파일 제어하기
지단로보트
2021. 3. 29. 01:53
개요
- Google Drive는 구글의 클라우드 파일 저장소 상품이며, Google Sheets는 클라우드 스프레드시트 상품이다. 이번 글에서는 Google Drive/Google Sheets의 특정 계정에 업로드된 파일/시트를 UI 없이 완전히 프로그램으로 다운로드/제어할 수 있는 방법을 소개하고자 한다.
구현 시나리오
- 개발자는 구글이 제공하는 웹 콘솔에서
서비스 계정(Service Account)
를 생성한다. 생성 후 .json 파일을 다운로드하여 프로젝트에 사용하기 위해 추가한다. - 사용자는 자신의 구글 드라이브에서 애플리케이션에 공유할 파일에 위 개발자가 생성한 서비스 계정을 공유 계정을 추가한다. (공유할 계정 이름은 앞서 다운로드한 .json 파일의
client_email
값이다.) - 개발자는 Google Drive API에 서비스 계정으로 인증 후, 접근 권한을 가진 해당 파일을 다운로드한다.
- 또한 개발자는 Google Sheets API에 서비스 계정으로 인증 후, 접근 권한을 가진 해당 시트를 획득하거나 편집할 수 있다.
서비스 계정 생성
- 가장 먼저 서비스 계정을 생성할 차례이다. 아래 순서대로 진행한다.
Google 클라우드 플랫폼 접속 (https://console.cloud.google.com/apis/dashboard)
→ [사용자 인증 정보] 클릭
→ [서비스 계정 관리] 클릭
→ [+ 서비스 계정 만들기] 클릭
→ 서비스 계정 이름: company-foobar (입력)
→ [완료] 클릭
- 이제 JSON 형식의 비공개 키를 생성하고 다운로드할 차례이다.
Google 클라우드 플랫폼 접속 (https://console.cloud.google.com/apis/dashboard)
→ [서비스 계정] 클릭
→ (서비스 계정 클릭)
→ [키] 클릭
→ [키 추가] 클릭
→ [새 키 만들기] 클릭
→ [JSON] 선택
→ [만들기] 클릭 (다운로드된 JSON 파일을 잘 보관하고 프로젝트에 추가)
공유 파일/시트 링크 주소
- 파일 또는 시트를 서비스 계정에 공유하고 링크 주소를 획득하면 아래와 같은 문자열이 얻어진다. 이 중 API 제어에 필요한 것은 파일의 경우 fileId, 시트의 경우 sheetId이다.
# 파일 링크 주소
https://drive.google.com/file/d/{fileId}/view?usp=sharing
# 시트 링크 주소
https://docs.google.com/spreadsheets/d/{sheetId}/edit?usp=sharing
build.gradle.kts
- 프로젝트 루트의 build.gradle.kts 파일에 아래 내용을 추가한다.
dependencies {
implementation("com.google.api-client:google-api-client:1.31.3")
implementation("com.google.apis:google-api-services-drive:v3-rev197-1.25.0")
implementation("com.google.apis:google-api-services-sheets:v4-rev612-1.25.0")
implementation("com.google.auth:google-auth-library-oauth2-http:0.25.2")
implementation("com.google.http-client:google-http-client-jackson2:1.39.1")
implementation("org.apache.poi:poi:5.0.0")
}
공유 파일 다운로드
- 아래는 공유 파일을 다운로드하는 예이다.
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
import com.google.auth.http.HttpCredentialsAdapter
import com.google.auth.oauth2.ServiceAccountCredentials
import org.springframework.stereotype.Service
import java.io.ByteArrayOutputStream
import java.io.FileInputStream
import java.io.OutputStream
@Service
class GoogleDriveService {
fun download(fileId: String) {
// 서비스 계정 인증과 권한 부여 후 DriveService 객체 획득
val driveService = Drive.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory.getDefaultInstance(),
HttpCredentialsAdapter(
ServiceAccountCredentials.fromStream(FileInputStream("/{service-account-file}.json"))
.createScoped(listOf(DriveScopes.DRIVE_READONLY))
.createDelegated("{service-account-client-email}")
)
).setApplicationName("{app-name}").build()
// 파일을 다운로드할 방법을 명시
// 메모리에 다운로드할 경우 ByteArrayOutputStream를 사용
// 로컬 파일로 다운로드할 경우 FileOutputStream을 사용
val outputStream: OutputStream = ByteArrayOutputStream()
// 파일 다운로드 실행
driveService.files()[fileId].executeMediaAndDownloadTo(outputStream)
}
}
공유 시트 데이터 획득
- 아래는 공유 시트를 획득하는 예이다.
import com.google.api.services.sheets.v4.Sheets
import com.google.api.services.sheets.v4.SheetsScopes
...
fun getSheet(sheetId: String) {
// 서비스 계정 인증과 권한 부여 후 SheetService 객체 획득
val service = Sheets.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory.getDefaultInstance(),
HttpCredentialsAdapter(
ServiceAccountCredentials.fromStream(FileInputStream("/{service-account-file}.json"))
// 시트의 편집 권한도 부여
.createScoped(listOf(SheetsScopes.SPREADSHEETS))
.createDelegated("{service-account-client-email}")
)
).setApplicationName("{app-name}").build()
// 시트 데이터 조회
// {"majorDimension":"ROWS","range":"'시트1'!A1:C4","values":[["a","b","c"],["1","2","3"],["4","5","6"],["7","8","9"]]}
val valueRange: ValueRange = try {
service
.spreadsheets()
.values()
.get(sheetId, "시트1!A1:C4")
.execute()
} catch (ex: GoogleJsonResponseException) {
println(ex.statusCode) // 403
println(ex.statusMessage) // Forbidden
println(ex.details.message) // The caller does not have permission
}
// [[a, b, c], [1, 2, 3], [4, 5, 6], [7, 8, 9]]
@Suppress("UNCHECKED_CAST") val values = valueRange.getValues() as List<List<String>>
}
- 레인지 문자열 전달시 셀 범위를 제외하고 시트의 이름만 명시하면 자동으로 해당 시트에서 유효한 최대 데이터를 조회한다. (ex: “시트1”) [관련 링크]
공유 시트 정보 획득
- 앞서 특정 시트의 데이터 자체를 획득하는 방법을 설명했다. 아래는 특정 스프레드시트의 요약 정보를 획득하는 방법이다. (아래 설명할 배치 업데이트를 실행하려면 각 시트(스프레드시트가 아닌 스프레드시트 내의 탭 단위를 의미)에 대한 sheetId가 요구된다. 아래와 같이 시트 정보를 획득하면 해당 정보를 획득할 수 있다.)
// 원하는 스프레드시트 ID와 시트 이름을 지정
val spreadsheetId = "{spreadsheetId}"
val sheetName = "{sheetName}"
// 스프레드시트 정보 획득
val response = service.spreadsheets().get(sheetId).execute()
// 시트 이름과 일치하는 시트 정보 획득
// {"gridProperties":{"columnCount":256,"rowCount":2159},"index":0,"sheetId":0,"sheetType":"GRID","title":"시트1"}
val sheet: Sheet? = response.sheets.firstOrNull { it.properties.title == sheetName }
공유 시트 데이터 편집
- 아래와 같이 공유 시트의 데이터를 편집할 수 있다. [관련 링크]
// {"spreadsheetId":"{spreadsheetId}","updatedCells":42,"updatedColumns":3,"updatedRange":"'시트1'!A1:C14","updatedRows":14}
val result = service
.spreadsheets()
.values()
.update(
"{spreadsheetId}",
"시트1",
ValueRange().setValues(newValues as List<List<String>>)
)
.setValueInputOption("RAW")
.execute()
- .setValueInputOption() 옵션은 입력한 셀 값을 구글 시트에서 해석할지 여부를 결정할 수 있다.
RAW
를 전달하면 어떤 해석도 하지 않고 원본 값 그대로 출력하고,USER_ENTERED
를 전달하면 구글 시트가 해당 값의 형식을 자동으로 판단하여 화면에 출력한다.
공유 시트 헤더 포맷팅
- 앞서 공유 시트의 데이터 자체를 편집하는 방법을 설명했다. 아래는 특정 시트의 헤더 배경색을 지정하는 방법이다. (배경색 지정에 있어 RGB를 0~1 크기로 지정해야 함에 유의핸다. [관련 링크])
val requests: List < Request > = listOf(
Request()
.setUpdateCells(
UpdateCellsRequest()
.setRange(
GridRange()
.setSheetId({sheetId})
// 시작 로우를 지정
.setStartRowIndex(0)
// 종료 로우를 지정
.setEndRowIndex(1)
)
.setRows(
listOf(
RowData().setValues(
listOf(
CellData()
.setUserEnteredFormat(
// 첫번째 셀의 배경색을 지정
CellFormat().setBackgroundColor(
Color()
.setRed(1 / 255 F)
.setGreen(191 / 255 F)
.setBlue(255 / 255 F)
)
),
CellData()
.setUserEnteredFormat(
// 두번째 셀의 배경색을 지정
CellFormat().setBackgroundColor(
Color()
.setRed(18 / 255 F)
.setGreen(119 / 255 F)
.setBlue(221 / 255 F)
)
)
)
)
)
)
.setFields("userEnteredFormat(backgroundColor)")
)
)
val requestBody = BatchUpdateSpreadsheetRequest()
requestBody.requests = requests
val request = service.spreadsheets().batchUpdate(sheetId, requestBody)
val response = request.execute()
컬럼 번호-문자열 상호 변환
Apache POI
를 이용하여 컬럼 번호와 문자열을 상화 변환할 수 있다. 사용 예는 아래와 같다.
// 컬럼 번호를 문자열로 변환
CellReference.convertNumToColString(0) // A
CellReference.convertNumToColString(1) // B
CellReference.convertNumToColString(28) // AC
// 컬럼 문자열을 번호로 변환
CellReference.convertColStringToIndex("A") // 0
CellReference.convertColStringToIndex("B") // 1
CellReference.convertColStringToIndex("AC") // 28
트러블슈팅: java.io.FileNotFoundException: {service-account-file}.json
- 서비스 계정을 인증해주는 키 파일 경로가 유효하지 않아 발생하는 예외이다. 정상적인 키 파일 경로를 명시하면 해결된다.
트러블슈팅: java.io.IOException: Error reading service account credential from JSON
- 서비스 계정 인증 키 파일 내용이 유효하지 않아 발생하는 예외이다. 구글 API 콘솔에서 정상적으로 다운로드한 파일인지 확인한다.
트러블슈팅: java.io.IOException: Error getting access token for service account
- 서비스 계정 인증이 유효하지 않아 액세스 토큰이 발급되지 않아 발생하는 예외이다. .createDelegated()의 아큐먼트에 유효한 client_email 값을 전달하면 해결된다. (ex: {app-name}@{service-account-name}.iam.gserviceaccount.com)
Google Sheets API 트러블슈팅: GoogleJsonResponseException: 404 Not Found
- 스프레드시트의 ID가 유효하지 않을 경우 발생하는 예외이다. 구체적으로는 아래 오류를 응답한다.
{
"code": 404,
"errors": [
{
"domain": "global",
"message": "Requested entity was not found.",
"reason": "notFound"
}
],
"message": "Requested entity was not found.",
"status": "NOT_FOUND"
}
Google Sheets API 트러블슈팅: GoogleJsonResponseException: 403 Forbidden
- 스프레드시트에 서비스 계정 공유가 되지 않아 발생한 예외이다. 서비스 계정이 공유 추가되면 해결된다.
{
"code": 403,
"errors": [
{
"domain": "global",
"message": "The caller does not have permission",
"reason": "forbidden"
}
],
"message": "The caller does not have permission",
"status": "PERMISSION_DENIED"
}
Google Sheets API 트러블슈팅: GoogleJsonResponseException: 400 Bad Request
- 스프레드시트의 레인지 문자열이 유효하지 않을 경우 발생하는 예외이다. (시트의 이름이 존재하지 않거나, 레인지의 범위가 비정상적으로 큰 경우에 발생한다.) 구체적으로는 아래 오류를 응답한다.
{
"code": 400,
"errors": [
{
"domain": "global",
"message": "Unable to parse range: {range}",
"reason": "badRequest"
}
],
"message": "Unable to parse range: {range}",
"status": "INVALID_ARGUMENT"
}
트러블슈팅: 다운로드 제한 정책 적용
- Google Drive에는 다양한 다운로드/업로드 제한 정책이 존재한다. 특정 제한 정책이 적용되면 24시간이 경과되어야 해제된다.
- 전체 다운로드 용량은 일 최대 10TB, 전체 업로드 용량은 일 최대 750GB로 제한된다. [관련 링크]
- 2020-07 부터 특정 파일에 대해 일 최대 다운로드 개수 제한 정책이 개시되었다. 정확한 제한 개수는 공개되지 않았다. [관련 링크]
Error 403: The download quota for this file has been exceeded., downloadQuotaExceeded
- 서비스 계정에 대해서 API 이용 제한이 존재한다. [관련 링크]
참고 글
- Stack Overflow - Google Drive API, Oauth and service account
- Stack Overflow - How can I get the file “service_account.json” for Google Translate API?
- Stack Overflow - I can’t see the files and folders created via code in my Google Drive
- Stack Overflow - Google Drive API read access not granted
- Stack Overflow - Upload Files to specified folder in Google Drive
- Google Cloud - 서비스 계정으로 인증
- Interact with Google Sheets from Java