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 이용 제한이 존재한다. [관련 링크]

참고 글