카테고리: 업무 자동화

파이썬 업무 자동화#12구글 시트 연동하기! 팀원과 같이 보는 자동 시트 (gspread) 📗

목차14개 항목

안녕하세요! Mojangcam입니다 😎

지난 편(#11 개인화 대량 메일 자동 발송)에서는 명단을 읽어서 사람마다 다른 메일을 쏘는 법을 배웠어요. 그런데 그 명단, 아직도 엑셀 파일로 주고받고 계시죠? 😅

엑셀 파일은 치명적인 약점이 하나 있어요. 나만 볼 수 있다는 거예요. 파일을 메신저로 보내는 순간 최종.xlsx, 최종_진짜최종.xlsx, 최종_이번엔진짜.xlsx 지옥이 시작됩니다.

오늘은 파이썬이 만든 결과를 구글 시트에 바로 써넣는 법을 배워볼게요. 스크립트가 새벽에 혼자 돌아서 시트를 갱신하면, 팀원들은 아침에 브라우저만 열면 최신 데이터를 봅니다. 파일 첨부도, 버전 관리도 필요 없어요. 자동화 파이프라인의 결과 공유 창구를 만드는 편이에요! 🙌

📌 준비물

  • Python 3.8+ (gspread 6.x 요구 사양)
  • gspread 6.2.1 — 구글 시트를 파이썬 객체처럼 다루게 해주는 라이브러리
  • google-auth — 구글 인증 담당 (gspread 설치 시 같이 따라옵니다)
  • python-dotenv — 인증 정보 경로를 .env로 분리
  • 구글 계정 1개 (개인 계정이면 충분해요)
터미널
pip install gspread python-dotenv

버전 확인은 이렇게 해요. 6.x가 맞는지 꼭 확인하세요. 5.x와 6.x는 update() 인자 순서가 달라서, 옛날 블로그 코드를 그대로 쓰면 값이 엉뚱한 곳에 들어갈 수 있어요(아래 "자주 나는 에러"에서 다룰게요).

터미널
python -c "import gspread; print(gspread.__version__)"

🧠 왜 "서비스 계정"을 쓰나요?

구글 시트에 접근하는 방법은 크게 3가지예요.

방식 언제 쓰나 자동화에 적합?
API 키 공개(링크 공유된) 시트만 읽기 ❌ 비공개 시트 불가
OAuth 클라이언트 ID 내 계정으로 브라우저 로그인 ❌ 실행할 때마다 사람이 필요
서비스 계정 봇 전용 구글 계정 ✅ 무인 실행 OK

서비스 계정(Service Account)은 사람이 아닌 프로그램을 위한 구글 계정이에요. 자기만의 이메일 주소(뭐시기@프로젝트.iam.gserviceaccount.com)를 가지고 있어서, 그 주소를 시트에 공유 걸어주면 팀원 한 명 추가하듯이 접근 권한이 생깁니다.

새벽 3시에 스케줄러가 돌아야 하는 우리 파이프라인에는 이 방식밖에 답이 없어요. 브라우저 로그인 창이 뜨면 자동화가 아니니까요 😅

📌 1단계 - 서비스 계정 만들고 키 받기

딱 한 번만 하면 되는 세팅이에요. 천천히 따라와 주세요!

  1. Google Cloud Console에 접속해서 새 프로젝트를 만들어요 (이름은 아무거나, 예: my-automation).
  2. 검색창에 Google Sheets API를 검색 → 사용 설정.
  3. 같은 방법으로 Google Drive API도 사용 설정. (시트를 이름으로 찾거나 새로 만들려면 드라이브 권한이 필요해요)
  4. 왼쪽 메뉴 API 및 서비스 > 사용자 인증 정보 → 사용자 인증 정보 만들기 > 서비스 계정.
  5. 이름 입력 후 만들기 → 완료.
  6. 만들어진 서비스 계정을 클릭 → 키 탭 → 키 추가 > 새 키 만들기 → JSON 선택 → 만들기.
  7. JSON 파일이 자동으로 다운로드돼요. 이게 비밀번호나 다름없는 파일입니다.

다운로드된 JSON을 열면 이런 내용이 들어 있어요.

JSON
{
  "type": "service_account",
  "project_id": "my-automation-xxxxx",
  "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
  "client_email": "my-bot@my-automation-xxxxx.iam.gserviceaccount.com",
  ...
}

여기서 client_email 값을 복사해두세요. 다음 단계에서 바로 씁니다!

💡 이 JSON 파일은 절대 깃허브에 올리면 안 돼요. .gitignore에 *.json이나 service_account.json을 꼭 추가하세요. 유출되면 남이 내 구글 API 할당량을 마음껏 쓰게 됩니다.

📌 2단계 - 시트를 서비스 계정에 공유하기 (제일 많이 빼먹는 단계!)

서비스 계정은 별개의 구글 계정이에요. 내가 만든 시트라도, 공유를 안 하면 못 봅니다.

  1. 구글 드라이브에서 새 스프레드시트를 만들어요 (예: 자동화 리포트).
  2. 우측 상단 공유 버튼 클릭.
  3. 아까 복사한 client_email 주소를 붙여넣고 "편집자" 권한으로 추가 → 전송.
  4. 주소창에서 시트 키를 복사해 둡니다. URL https://docs.google.com/spreadsheets/d/**1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms**/edit 에서 가운데 긴 문자열이 시트 키(spreadsheet key)예요.

이 단계를 건너뛰면 SpreadsheetNotFound 에러가 납니다. 시트가 없는 게 아니라 봇 눈에 안 보이는 것뿐이에요!

📌 3단계 - .env로 인증 정보 분리하기

우리 시리즈의 철칙이죠. 경로·키는 코드에 박지 않습니다. 프로젝트 폴더에 .env 파일을 만들어요.

터미널
# .env  (절대 깃에 올리지 마세요!)
GOOGLE_SA_FILE=./secrets/service_account.json
SHEET_KEY=1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
WORKSHEET_NAME=일일리포트
터미널
# .gitignore
.env
secrets/

이제 파이썬에서 불러와서 연결해 볼게요.

Python
# gspread 6.2.1 / Python 3.8+
import os
import gspread
from dotenv import load_dotenv

load_dotenv()

SA_FILE = os.getenv("GOOGLE_SA_FILE")
SHEET_KEY = os.getenv("SHEET_KEY")

# 서비스 계정으로 인증
gc = gspread.service_account(filename=SA_FILE)

# 시트 키로 열기 (이름보다 키가 안전하고 빨라요)
sh = gc.open_by_key(SHEET_KEY)
print("연결 성공:", sh.title)

ws = sh.sheet1
print(ws.acell("A1").value)

하나씩 뜯어볼게요 💡

  • gspread.service_account(filename=...) : JSON 키 파일을 읽어 인증을 끝내고 클라이언트 객체를 돌려줘요. 파일을 ~/.config/gspread/service_account.json(윈도우는 %APPDATA%\gspread\service_account.json)에 두면 filename 없이 gspread.service_account()만 써도 됩니다.
  • open_by_key(키) : 시트 키로 여는 방식. gc.open("이름")도 되지만 이름은 바뀔 수 있고, 내부적으로 드라이브 전체를 검색해서 느려요. 키 방식을 권장합니다.
  • sh vs ws : sh는 스프레드시트 파일 전체, ws는 그 안의 탭 하나(워크시트)예요. 헷갈리기 쉬우니 변수명을 이렇게 나눠 쓰면 편해요.
  • acell("A1").value : 셀 하나 읽기. 여러 개를 읽을 땐 아래의 get_all_records()를 쓰세요.

📌 4단계 - 읽기: 시트를 파이썬 데이터로

Python
# 1) 전체를 2차원 리스트로
rows = ws.get_all_values()
print(rows[:3])
# [['날짜', '방문자', '매출'], ['2026-09-10', '1200', '350000'], ...]

# 2) 첫 줄을 헤더로 삼아 딕셔너리 리스트로 (pandas 바로 투입 가능!)
records = ws.get_all_records()
print(records[0])
# {'날짜': '2026-09-10', '방문자': 1200, '매출': 350000}

# 3) 특정 범위만
print(ws.get("A1:C10"))
  • get_all_values() : 모든 값을 문자열 2차원 리스트로. 헤더가 없거나 표 모양이 아닐 때 좋아요.
  • get_all_records() : 1행을 키로 써서 [{...}, {...}] 형태로 변환. 숫자는 숫자 타입으로 알아서 바뀝니다. pd.DataFrame(records) 한 줄이면 pandas 편에서 배운 분석으로 바로 넘어가요.
  • ⚠️ 헤더에 중복된 이름이 있으면 get_all_records()가 에러를 던져요. 빈 칸도 두 개 이상이면 "빈 문자열이 중복"으로 잡힙니다. 1행은 항상 깔끔하게 채워두세요.

📌 5단계 - 쓰기: 결과를 시트에 밀어넣기

여기가 오늘의 핵심이에요. gspread 6.x 기준 update(값, 범위) 순서에 주의하세요!

Python
# 셀 하나
ws.update_acell("E1", "마지막 갱신")

# 범위 통째로 (6.x: values가 먼저, range_name이 나중)
values = [
    ["날짜", "방문자", "매출"],
    ["2026-09-12", 1450, 412000],
]
ws.update(values, "A1")

# 순서가 헷갈리면 명명 인자가 제일 안전해요 (버전 상관없이 동작)
ws.update(values=values, range_name="A1")

# 맨 아래에 한 줄 추가 (로그 쌓기용으로 최고!)
ws.append_row(
    ["2026-09-13", 1510, 430000],
    value_input_option="USER_ENTERED",
)

하나씩 뜯어볼게요 💡

  • update(values, "A1") : A1을 왼쪽 위 기준점으로 잡고 2차원 리스트를 통째로 붙여넣어요. 범위를 "A1:C2"처럼 정확히 쓸 수도 있지만, 시작 셀만 줘도 알아서 확장됩니다.
  • value_input_option : "RAW"는 입력한 문자열 그대로, "USER_ENTERED"는 사람이 타이핑한 것처럼 해석해요. =SUM(B2:B10) 같은 수식이나 2026-09-13 같은 날짜를 제대로 넣으려면 USER_ENTERED를 쓰세요. (append_row()의 기본값은 RAW라 명시해 주는 게 좋아요)
  • append_row() : 값이 있는 마지막 행 다음에 추가. 매일 실행되는 스크립트가 한 줄씩 기록을 쌓는 데 딱이에요.

🧩 셀 하나씩 쓰지 마세요 (API 할당량 이야기)

구글 시트 API는 사용자당 분당 60회 읽기 / 60회 쓰기 정도로 호출 횟수가 제한돼 있어요. 그래서 이런 코드는 최악입니다.

Python
# ❌ 절대 이렇게 하지 마세요 - 100줄이면 API 호출 100번
for i, row in enumerate(data, start=2):
    ws.update_acell(f"A{i}", row["날짜"])

한 번에 모아서 보내는 게 정답이에요.

Python
# ✅ 리스트로 모아서 한 번에 (호출 1번)
body = [[r["날짜"], r["방문자"], r["매출"]] for r in data]
ws.update(body, "A2")

# ✅ 서로 떨어진 범위를 동시에 갱신하려면 batch_update
ws.batch_update([
    {"range": "A2", "values": body},
    {"range": "F1", "values": [["갱신: 2026-09-12 06:00"]]},
])

💡 보너스 - 서식까지 자동으로 입히기

데이터만 덜렁 있으면 팀원이 안 봐요. 헤더는 굵게, 배경색도 넣어줍시다.

Python
ws.format("A1:C1", {
    "textFormat": {"bold": True},
    "backgroundColor": {"red": 0.85, "green": 0.89, "blue": 0.98},
    "horizontalAlignment": "CENTER",
})

# 시트가 없으면 새로 만들기 (날짜별 탭 자동 생성)
try:
    ws = sh.worksheet("2026-09")
except gspread.exceptions.WorksheetNotFound:
    ws = sh.add_worksheet(title="2026-09", rows=200, cols=10)

색상은 0~1 사이 실수예요. 255 같은 값을 넣으면 무시되니 주의하세요!

📌 전체 코드 (복붙 OK)

Python
# gspread 6.2.1 / python-dotenv 1.x / Python 3.8+
# 매일 실행되면 구글 시트에 일일 지표 한 줄을 추가하는 스크립트
import os
from datetime import date

import gspread
from dotenv import load_dotenv

load_dotenv()

SA_FILE = os.getenv("GOOGLE_SA_FILE")
SHEET_KEY = os.getenv("SHEET_KEY")
WORKSHEET_NAME = os.getenv("WORKSHEET_NAME", "일일리포트")

HEADER = ["날짜", "방문자", "매출", "비고"]

def get_worksheet():
    """서비스 계정으로 인증하고 워크시트를 가져옵니다. 없으면 새로 만들어요."""
    gc = gspread.service_account(filename=SA_FILE)
    sh = gc.open_by_key(SHEET_KEY)

    try:
        ws = sh.worksheet(WORKSHEET_NAME)
    except gspread.exceptions.WorksheetNotFound:
        ws = sh.add_worksheet(title=WORKSHEET_NAME, rows=500, cols=10)

    # 헤더가 비어 있으면 채우고 서식 적용
    if not ws.acell("A1").value:
        ws.update(values=[HEADER], range_name="A1")
        ws.format("A1:D1", {
            "textFormat": {"bold": True},
            "backgroundColor": {"red": 0.85, "green": 0.89, "blue": 0.98},
            "horizontalAlignment": "CENTER",
        })
    return ws

def collect_today():
    """실제로는 크롤링·DB 조회 결과가 들어올 자리예요. (#2 크롤링 편 참고)"""
    return {"방문자": 1450, "매출": 412000, "비고": "정상"}

def upsert_today(ws, data):
    """오늘 날짜 행이 이미 있으면 덮어쓰고, 없으면 새 줄로 추가합니다."""
    today = date.today().isoformat()
    row = [today, data["방문자"], data["매출"], data["비고"]]

    dates = ws.col_values(1)  # A열 전체
    if today in dates:
        idx = dates.index(today) + 1  # 시트 행 번호는 1부터
        ws.update(values=[row], range_name=f"A{idx}")
        print(f"{today} 행을 갱신했어요 (행 {idx})")
    else:
        ws.append_row(row, value_input_option="USER_ENTERED")
        print(f"{today} 행을 추가했어요")

def main():
    if not SA_FILE or not SHEET_KEY:
        raise SystemExit(".env에 GOOGLE_SA_FILE과 SHEET_KEY를 먼저 채워주세요!")

    try:
        ws = get_worksheet()
        upsert_today(ws, collect_today())
    except gspread.exceptions.SpreadsheetNotFound:
        print("시트를 못 찾았어요. 서비스 계정 이메일로 시트를 공유했는지 확인하세요!")
    except gspread.exceptions.APIError as e:
        print(f"구글 API 에러: {e}")

if __name__ == "__main__":
    main()

📌 실행 결과

터미널
$ python sheet_report.py
2026-09-12 행을 추가했어요

브라우저로 시트를 열어보면 헤더가 파란 배경에 굵은 글씨로 깔려 있고, 그 아래에 오늘 날짜 행이 딱 들어가 있어요. 같은 날 두 번 실행하면 줄이 늘어나는 게 아니라 기존 행을 덮어쓴다는 것도 확인해 보세요 (upsert_today가 하는 일이에요).

이 스크립트를 #6 예약 실행 편의 스케줄러에 물리면, 매일 아침 팀 시트가 알아서 최신 상태가 됩니다 ✨

📌 자주 나는 에러

1. gspread.exceptions.SpreadsheetNotFound

  • 원인: 시트를 서비스 계정 이메일로 공유하지 않았음. (90%가 이 경우예요)
  • 해결: JSON의 client_email 값을 복사 → 시트 공유 > 편집자로 추가. 시트 키를 잘못 복사한 것도 의심해 보세요.

2. 값이 엉뚱한 곳에 들어가거나 DeprecationWarning: The order of arguments in worksheet.update() has changed

  • 원인: gspread 5.x와 6.x의 인자 순서 차이. 5.x는 update("A1", values), 6.x는 update(values, "A1")이에요.
  • 해결: 최신 6.x는 순서가 뒤집힌 걸 눈치채면 경고만 띄우고 알아서 바로잡아 주지만, 구버전이 섞이면 값이 엉뚱한 칸에 박힙니다. ws.update(values=..., range_name=...)처럼 명명 인자로 쓰면 버전에 상관없이 안전해요.

3. APIError: [429] Quota exceeded for quota metric 'Read requests'

  • 원인: 분당 호출 한도 초과. 반복문 안에서 update_acell이나 row_values를 부르고 있을 거예요.
  • 해결: 값을 리스트로 모아 update() / batch_update()로 한 번에 보내기. 정 안 되면 time.sleep(1)로 간격을 두세요.

4. APIError: [403] Google Drive API has not been used in project ...

  • 원인: Drive API를 사용 설정하지 않음. gc.open("이름")이나 새 시트 생성은 드라이브 권한이 필요해요.
  • 해결: Cloud Console에서 Google Drive API 사용 설정. 또는 open_by_key()만 쓰도록 코드를 바꾸세요.

5. GSpreadException: the header row in the worksheet contains duplicates: [...]

  • 원인: get_all_records() 호출 시 1행에 중복된 헤더가 있음. 빈 칸이 두 개 이상이어도 중복으로 잡혀요.
  • 해결: 1행 정리. 또는 ws.get_all_values()로 받아서 직접 가공하거나, get_all_records(expected_headers=[...])로 쓸 헤더를 직접 지정하세요.

📌 마무리

오늘은 파이썬 자동화의 결과를 팀 전체가 보는 구글 시트로 내보내는 법을 배웠어요.

  • 서비스 계정 = 봇 전용 구글 계정, 시트에 공유를 걸어줘야 보입니다
  • 인증 파일 경로·시트 키는 .env로 분리, JSON 키는 .gitignore 필수
  • 읽기는 get_all_records(), 쓰기는 update(values, 범위) / append_row()
  • API 할당량 때문에 셀 하나씩 쓰지 말고 모아서 한 번에

다음 편(#13)에서는 자동화가 조용히 죽지 않게 만드는 법을 다룰게요. 로그 남기기, 실패했을 때 재시도(retry), 그리고 에러가 나면 디스코드로 바로 알림 쏘기까지요. 새벽에 스크립트가 멈췄는데 아무도 모르는 상황, 이제 그만 만들어야죠 😅 기대해주세요!

궁금한 점은 댓글로 남겨주세요 💬 다음 편에서 만나요! 🙌

📚 파이썬 업무 자동화 시리즈

  1. #1 엑셀 자동화 입문 (openpyxl)
  2. #2 웹 크롤링 입문 (requests + BeautifulSoup)
  3. #3 파일 정리 자동화 (이름 일괄 변경)
  4. #4 디스코드 웹훅으로 알림 보내기
  5. #5 로컬 LLM(LM Studio)으로 문의 분류·요약
  6. #6 예약 실행 자동화 (schedule·APScheduler)
  7. #7 동적 페이지·로그인 자동화 (Selenium)
  8. #8 PDF 자동 처리 (pypdf)
  9. #9 마우스·키보드 GUI 자동화 (PyAutoGUI)
  10. #10 워드 문서 자동 생성 (python-docx)
  11. #11 개인화 대량 메일 자동 발송 (smtplib 실전편)
  12. #12 구글 시트 연동하기 (gspread) ← 지금 이 글!
  13. #13 로그·예외처리·실패 알림 체계 (예정)
  14. #14 🏆 [최종 프로젝트] 매일 아침 오는 업무 리포트 시스템 (예정)

연재 · 14편 중 12번째

파이썬 업무 자동화

  1. #1엑셀 자동화 입문 (openpyxl)!
  2. #2웹 크롤링 입문 (requests + BeautifulSoup)!
  3. #3파일 정리 자동화 (이름 일괄 변경)!
  4. #4디스코드 웹훅(Webhook)으로 알림 보내기 (requests)!
  5. #5로컬 LLM(LM Studio)으로 고객 문의 자동 분류·요약하기! (무료 AI)
  6. #6예약 실행 자동화! 매일 정해진 시간에 스크립트 돌리기 (schedule·APScheduler)
  7. #7동적 페이지·로그인 자동화! requests로 안 되는 사이트 긁기 (Selenium)
  8. #8PDF 자동 처리! 여러 파일 병합·분할부터 비밀번호까지 (pypdf) 📄
  9. #9마우스·키보드 GUI 자동화! 클릭·타이핑을 파이썬이 대신하게 하기 (PyAutoGUI)
  10. #10워드(Word) 문서 자동 생성! 엑셀 명단으로 계약서·안내문 100장 뽑기 (python-docx) 📄
  11. #11개인화 대량 메일 자동 발송! 명단별 첨부파일·발송 로그·재시도까지 (smtplib 실전편) 📬
  12. #12구글 시트 연동하기! 팀원과 같이 보는 자동 시트 (gspread) 📗지금 읽는 글
  13. #13파이썬 자동화 로그·재시도·디스코드 실패 알림 만들기 - 조용히 멈추는 스크립트 잡기
  14. #14파워포인트(PowerPoint) 보고서 자동 생성! 엑셀 숫자만 바꾸면 발표자료 완성 (python-pptx) 📊
전체 14편 보기접기

글쓴이

Mojangcam

직접 코드를 짜고, 서버를 돌리고, 막힌 에러를 해결하며 배운 것들을 기록하는 개발·IT 실전 기록 블로그