# InstagramClient 사용 매뉴얼 Instagram Graph API를 사용한 콘텐츠 게시 및 조회를 위한 비동기 클라이언트입니다. --- ## 목차 1. [개요](#개요) 2. [클래스 구조](#클래스-구조) 3. [초기화 및 설정](#초기화-및-설정) 4. [메서드 상세](#메서드-상세) 5. [예외 처리](#예외-처리) 6. [데이터 모델](#데이터-모델) 7. [사용 예제](#사용-예제) 8. [내부 동작 원리](#내부-동작-원리) --- ## 개요 ### 주요 특징 - **비동기 지원**: `asyncio` 기반의 비동기 HTTP 클라이언트 - **멀티테넌트**: 각 사용자가 자신의 `access_token`으로 독립적인 인스턴스 생성 - **자동 재시도**: Rate Limit 및 서버 에러 시 지수 백오프 재시도 - **컨텍스트 매니저**: `async with` 패턴으로 리소스 자동 관리 - **타입 힌트**: 완전한 타입 힌트 지원 ### 지원 기능 | 기능 | 메서드 | 설명 | |------|--------|------| | 미디어 목록 조회 | `get_media_list()` | 계정의 게시물 목록 조회 | | 미디어 상세 조회 | `get_media()` | 특정 게시물 상세 정보 | | 이미지 게시 | `publish_image()` | 단일 이미지 게시 | | 비디오/릴스 게시 | `publish_video()` | 비디오 또는 릴스 게시 | | 캐러셀 게시 | `publish_carousel()` | 2-10개 이미지 게시 | --- ## 클래스 구조 ### 파일 구조 ``` poc/instagram/ ├── __init__.py # 패키지 초기화 및 export ├── client.py # InstagramClient 클래스 ├── exceptions.py # 커스텀 예외 클래스 ├── models.py # Pydantic 데이터 모델 ├── main.py # 테스트 실행 파일 └── manual.md # 본 문서 ``` ### 클래스 다이어그램 ``` InstagramClient ├── __init__(access_token, ...) # 초기화 ├── __aenter__() # 컨텍스트 진입 ├── __aexit__() # 컨텍스트 종료 │ ├── get_media_list() # 미디어 목록 조회 ├── get_media() # 미디어 상세 조회 ├── publish_image() # 이미지 게시 ├── publish_video() # 비디오 게시 ├── publish_carousel() # 캐러셀 게시 │ ├── _request() # (내부) HTTP 요청 처리 ├── _wait_for_container() # (내부) 컨테이너 대기 ├── _get_account_id() # (내부) 계정 ID 조회 ├── _get_client() # (내부) HTTP 클라이언트 반환 └── _build_url() # (내부) URL 생성 ``` --- ## 초기화 및 설정 ### 생성자 파라미터 ```python InstagramClient( access_token: str, # (필수) Instagram 액세스 토큰 *, base_url: str = None, # API 기본 URL (기본값: https://graph.instagram.com/v21.0) timeout: float = 30.0, # HTTP 요청 타임아웃 (초) max_retries: int = 3, # 최대 재시도 횟수 container_timeout: float = 300.0, # 컨테이너 처리 대기 타임아웃 (초) container_poll_interval: float = 5.0, # 컨테이너 상태 확인 간격 (초) ) ``` ### 파라미터 상세 설명 | 파라미터 | 타입 | 기본값 | 설명 | |----------|------|--------|------| | `access_token` | `str` | (필수) | Instagram Graph API 액세스 토큰 | | `base_url` | `str` | `https://graph.instagram.com/v21.0` | API 엔드포인트 기본 URL | | `timeout` | `float` | `30.0` | 개별 HTTP 요청 타임아웃 (초) | | `max_retries` | `int` | `3` | Rate Limit/서버 에러 시 재시도 횟수 | | `container_timeout` | `float` | `300.0` | 미디어 컨테이너 처리 대기 최대 시간 (초) | | `container_poll_interval` | `float` | `5.0` | 컨테이너 상태 확인 폴링 간격 (초) | ### 기본 사용법 ```python from poc.instagram import InstagramClient async with InstagramClient(access_token="YOUR_TOKEN") as client: # API 호출 media_list = await client.get_media_list() ``` ### 커스텀 설정 사용 ```python async with InstagramClient( access_token="YOUR_TOKEN", timeout=60.0, # 타임아웃 60초 max_retries=5, # 최대 5회 재시도 container_timeout=600.0, # 컨테이너 대기 10분 ) as client: # 대용량 비디오 업로드 등에 적합 await client.publish_video(video_url="...", caption="...") ``` --- ## 메서드 상세 ### get_media_list() 계정의 미디어 목록을 조회합니다. ```python async def get_media_list( self, limit: int = 25, # 조회할 미디어 수 (최대 100) after: Optional[str] = None # 페이지네이션 커서 ) -> MediaList ``` **파라미터:** | 파라미터 | 타입 | 기본값 | 설명 | |----------|------|--------|------| | `limit` | `int` | `25` | 조회할 미디어 수 (최대 100) | | `after` | `str` | `None` | 다음 페이지 커서 (페이지네이션) | **반환값:** `MediaList` - 미디어 목록 **예외:** - `InstagramAPIError` - API 에러 발생 시 - `AuthenticationError` - 인증 실패 시 - `RateLimitError` - Rate Limit 초과 시 **사용 예제:** ```python # 기본 조회 media_list = await client.get_media_list() # 10개만 조회 media_list = await client.get_media_list(limit=10) # 페이지네이션 media_list = await client.get_media_list(limit=25) if media_list.next_cursor: next_page = await client.get_media_list(limit=25, after=media_list.next_cursor) ``` --- ### get_media() 특정 미디어의 상세 정보를 조회합니다. ```python async def get_media( self, media_id: str # 미디어 ID ) -> Media ``` **파라미터:** | 파라미터 | 타입 | 설명 | |----------|------|------| | `media_id` | `str` | 조회할 미디어 ID | **반환값:** `Media` - 미디어 상세 정보 **조회되는 필드:** - `id`, `media_type`, `media_url`, `thumbnail_url` - `caption`, `timestamp`, `permalink` - `like_count`, `comments_count` - `children` (캐러셀인 경우 하위 미디어) **사용 예제:** ```python media = await client.get_media("17895695668004550") print(f"타입: {media.media_type}") print(f"좋아요: {media.like_count}") print(f"링크: {media.permalink}") ``` --- ### publish_image() 단일 이미지를 게시합니다. ```python async def publish_image( self, image_url: str, # 이미지 URL (공개 접근 가능) caption: Optional[str] = None # 게시물 캡션 ) -> Media ``` **파라미터:** | 파라미터 | 타입 | 설명 | |----------|------|------| | `image_url` | `str` | 공개 접근 가능한 이미지 URL (JPEG 권장) | | `caption` | `str` | 게시물 캡션 (해시태그, 멘션 포함 가능) | **반환값:** `Media` - 게시된 미디어 정보 **이미지 요구사항:** - 형식: JPEG 권장 - 최소 크기: 320x320 픽셀 - 비율: 4:5 ~ 1.91:1 - URL: 공개 접근 가능 (인증 없이) **사용 예제:** ```python media = await client.publish_image( image_url="https://cdn.example.com/photo.jpg", caption="오늘의 사진 #photography #daily" ) print(f"게시 완료: {media.permalink}") ``` --- ### publish_video() 비디오 또는 릴스를 게시합니다. ```python async def publish_video( self, video_url: str, # 비디오 URL (공개 접근 가능) caption: Optional[str] = None, # 게시물 캡션 share_to_feed: bool = True # 피드 공유 여부 ) -> Media ``` **파라미터:** | 파라미터 | 타입 | 기본값 | 설명 | |----------|------|--------|------| | `video_url` | `str` | (필수) | 공개 접근 가능한 비디오 URL (MP4 권장) | | `caption` | `str` | `None` | 게시물 캡션 | | `share_to_feed` | `bool` | `True` | 피드에 공유 여부 | **반환값:** `Media` - 게시된 미디어 정보 **비디오 요구사항:** - 형식: MP4 (H.264 코덱) - 길이: 3초 ~ 60분 (릴스) - 해상도: 최소 720p - 비율: 9:16 (세로), 16:9 (가로), 1:1 (정사각형) **참고:** - 비디오 처리 시간이 이미지보다 오래 걸립니다 - 내부적으로 `container_timeout * 2` 시간까지 대기합니다 **사용 예제:** ```python media = await client.publish_video( video_url="https://cdn.example.com/video.mp4", caption="새로운 릴스! #reels #trending", share_to_feed=True ) print(f"게시 완료: {media.permalink}") ``` --- ### publish_carousel() 캐러셀(멀티 이미지)을 게시합니다. ```python async def publish_carousel( self, media_urls: list[str], # 이미지 URL 목록 (2-10개) caption: Optional[str] = None # 게시물 캡션 ) -> Media ``` **파라미터:** | 파라미터 | 타입 | 설명 | |----------|------|------| | `media_urls` | `list[str]` | 이미지 URL 목록 (2-10개 필수) | | `caption` | `str` | 게시물 캡션 | **반환값:** `Media` - 게시된 미디어 정보 **예외:** - `ValueError` - 이미지 수가 2-10개가 아닌 경우 **특징:** - 각 이미지의 컨테이너가 **병렬로** 생성됩니다 (성능 최적화) - 모든 이미지가 동일한 요구사항을 충족해야 합니다 **사용 예제:** ```python media = await client.publish_carousel( media_urls=[ "https://cdn.example.com/img1.jpg", "https://cdn.example.com/img2.jpg", "https://cdn.example.com/img3.jpg", ], caption="여행 사진 모음 #travel #photos" ) print(f"게시 완료: {media.permalink}") ``` --- ## 예외 처리 ### 예외 계층 구조 ``` Exception └── InstagramAPIError # 기본 예외 ├── AuthenticationError # 인증 오류 (code=190) ├── RateLimitError # Rate Limit (code=4, 17, 341) ├── ContainerStatusError # 컨테이너 ERROR 상태 └── ContainerTimeoutError # 컨테이너 타임아웃 ``` ### 예외 클래스 상세 #### InstagramAPIError 모든 Instagram API 예외의 기본 클래스입니다. ```python class InstagramAPIError(Exception): message: str # 에러 메시지 code: Optional[int] # API 에러 코드 subcode: Optional[int] # API 서브코드 fbtrace_id: Optional[str] # Facebook 트레이스 ID (디버깅용) ``` #### AuthenticationError 인증 관련 에러입니다. - 토큰 만료 - 유효하지 않은 토큰 - 앱 권한 부족 ```python try: await client.get_media_list() except AuthenticationError as e: print(f"인증 실패: {e.message}") print(f"에러 코드: {e.code}") # 보통 190 ``` #### RateLimitError API 호출 제한 초과 에러입니다. ```python class RateLimitError(InstagramAPIError): retry_after: Optional[int] # 재시도까지 대기 시간 (초) ``` ```python try: await client.get_media_list() except RateLimitError as e: print(f"Rate Limit 초과: {e.message}") if e.retry_after: print(f"{e.retry_after}초 후 재시도") await asyncio.sleep(e.retry_after) ``` #### ContainerStatusError 미디어 컨테이너가 ERROR 상태가 된 경우 발생합니다. - 잘못된 미디어 형식 - 지원하지 않는 코덱 - 미디어 URL 접근 불가 #### ContainerTimeoutError 컨테이너가 지정된 시간 내에 처리되지 않은 경우 발생합니다. ```python try: await client.publish_video(video_url="...", caption="...") except ContainerTimeoutError as e: print(f"타임아웃: {e}") ``` ### 에러 코드 매핑 | 에러 코드 | 예외 클래스 | 설명 | |-----------|-------------|------| | 4 | `RateLimitError` | API 호출 제한 | | 17 | `RateLimitError` | 사용자별 호출 제한 | | 190 | `AuthenticationError` | 인증 실패 | | 341 | `RateLimitError` | 앱 호출 제한 | ### 종합 예외 처리 예제 ```python from poc.instagram import ( InstagramClient, AuthenticationError, RateLimitError, ContainerStatusError, ContainerTimeoutError, InstagramAPIError, ) async with InstagramClient(access_token="YOUR_TOKEN") as client: try: media = await client.publish_image( image_url="https://example.com/image.jpg", caption="테스트" ) print(f"성공: {media.permalink}") except AuthenticationError as e: print(f"인증 오류: {e}") # 토큰 갱신 로직 실행 except RateLimitError as e: print(f"Rate Limit: {e}") if e.retry_after: await asyncio.sleep(e.retry_after) # 재시도 except ContainerStatusError as e: print(f"미디어 처리 실패: {e}") # 미디어 형식 확인 except ContainerTimeoutError as e: print(f"처리 시간 초과: {e}") # 더 긴 타임아웃으로 재시도 except InstagramAPIError as e: print(f"API 에러: {e}") print(f"코드: {e.code}, 서브코드: {e.subcode}") except Exception as e: print(f"예상치 못한 에러: {e}") ``` --- ## 데이터 모델 ### Media 미디어 정보를 담는 Pydantic 모델입니다. ```python class Media(BaseModel): id: str # 미디어 ID media_type: Optional[str] # IMAGE, VIDEO, CAROUSEL_ALBUM media_url: Optional[str] # 미디어 URL thumbnail_url: Optional[str] # 썸네일 URL (비디오) caption: Optional[str] # 캡션 timestamp: Optional[datetime] # 게시 시간 permalink: Optional[str] # 퍼머링크 like_count: int = 0 # 좋아요 수 comments_count: int = 0 # 댓글 수 children: Optional[list[Media]] # 캐러셀 하위 미디어 ``` ### MediaList 미디어 목록 응답 모델입니다. ```python class MediaList(BaseModel): data: list[Media] # 미디어 목록 paging: Optional[dict[str, Any]] # 페이지네이션 정보 @property def next_cursor(self) -> Optional[str]: """다음 페이지 커서""" ``` ### MediaContainer 미디어 컨테이너 상태 모델입니다. ```python class MediaContainer(BaseModel): id: str # 컨테이너 ID status_code: Optional[str] # IN_PROGRESS, FINISHED, ERROR status: Optional[str] # 상태 메시지 @property def is_finished(self) -> bool: ... @property def is_error(self) -> bool: ... @property def is_in_progress(self) -> bool: ... ``` --- ## 사용 예제 ### 미디어 목록 조회 및 출력 ```python import asyncio from poc.instagram import InstagramClient async def main(): async with InstagramClient(access_token="YOUR_TOKEN") as client: media_list = await client.get_media_list(limit=10) for media in media_list.data: print(f"[{media.media_type}] {media.caption[:30] if media.caption else '(캡션 없음)'}") print(f" 좋아요: {media.like_count:,} | 댓글: {media.comments_count:,}") print(f" 링크: {media.permalink}") print() asyncio.run(main()) ``` ### 이미지 게시 ```python async def post_image(): async with InstagramClient(access_token="YOUR_TOKEN") as client: media = await client.publish_image( image_url="https://cdn.example.com/photo.jpg", caption="오늘의 사진 #photography" ) return media.permalink permalink = asyncio.run(post_image()) print(f"게시됨: {permalink}") ``` ### 멀티테넌트 병렬 게시 여러 사용자가 동시에 게시물을 올리는 예제입니다. ```python import asyncio from poc.instagram import InstagramClient async def post_for_user(user_id: str, token: str, image_url: str, caption: str): """특정 사용자의 계정에 게시""" async with InstagramClient(access_token=token) as client: media = await client.publish_image(image_url=image_url, caption=caption) return {"user_id": user_id, "permalink": media.permalink} async def main(): users = [ {"user_id": "user1", "token": "TOKEN1", "image": "https://...", "caption": "User1 post"}, {"user_id": "user2", "token": "TOKEN2", "image": "https://...", "caption": "User2 post"}, {"user_id": "user3", "token": "TOKEN3", "image": "https://...", "caption": "User3 post"}, ] # 병렬 실행 tasks = [ post_for_user(u["user_id"], u["token"], u["image"], u["caption"]) for u in users ] results = await asyncio.gather(*tasks, return_exceptions=True) for result in results: if isinstance(result, Exception): print(f"실패: {result}") else: print(f"성공: {result['user_id']} -> {result['permalink']}") asyncio.run(main()) ``` ### 페이지네이션으로 전체 미디어 조회 ```python async def get_all_media(client: InstagramClient, max_items: int = 100): """전체 미디어 조회 (페이지네이션)""" all_media = [] cursor = None while len(all_media) < max_items: media_list = await client.get_media_list(limit=25, after=cursor) all_media.extend(media_list.data) if not media_list.next_cursor: break cursor = media_list.next_cursor return all_media[:max_items] ``` --- ## 내부 동작 원리 ### HTTP 클라이언트 생명주기 ``` async with InstagramClient(...) as client: │ ├── __aenter__() │ └── httpx.AsyncClient 생성 │ ├── API 호출들... │ └── 동일한 HTTP 클라이언트 재사용 (연결 풀링) │ └── __aexit__() └── httpx.AsyncClient.aclose() ``` ### 미디어 게시 프로세스 Instagram API의 미디어 게시는 3단계로 진행됩니다: ``` ┌─────────────────────────────────────────────────────────┐ │ 미디어 게시 프로세스 │ ├─────────────────────────────────────────────────────────┤ │ │ │ Step 1: Container 생성 │ │ POST /{account_id}/media │ │ ├── image_url / video_url 전달 │ │ └── Container ID 반환 │ │ │ │ Step 2: Container 상태 대기 (폴링) │ │ GET /{container_id}?fields=status_code │ │ ├── IN_PROGRESS: 계속 대기 │ │ ├── FINISHED: 다음 단계로 │ │ └── ERROR: ContainerStatusError 발생 │ │ │ │ Step 3: 게시 │ │ POST /{account_id}/media_publish │ │ └── Media ID 반환 │ │ │ └─────────────────────────────────────────────────────────┘ ``` ### 캐러셀 게시 프로세스 ``` ┌─────────────────────────────────────────────────────────┐ │ 캐러셀 게시 프로세스 │ ├─────────────────────────────────────────────────────────┤ │ │ │ Step 1: 각 이미지 Container 병렬 생성 │ │ ├── asyncio.gather()로 동시 실행 │ │ └── children_ids = [id1, id2, id3, ...] │ │ │ │ Step 2: 캐러셀 Container 생성 │ │ POST /{account_id}/media │ │ ├── media_type: "CAROUSEL" │ │ └── children: "id1,id2,id3" │ │ │ │ Step 3: Container 상태 대기 │ │ │ │ Step 4: 게시 │ │ │ └─────────────────────────────────────────────────────────┘ ``` ### 자동 재시도 로직 ```python retry_base_delay = 1.0 for attempt in range(max_retries + 1): try: response = await client.request(...) if response.status_code == 429: # Rate Limit wait_time = max(retry_base_delay * (2 ** attempt), retry_after) await asyncio.sleep(wait_time) continue if response.status_code >= 500: # 서버 에러 wait_time = retry_base_delay * (2 ** attempt) await asyncio.sleep(wait_time) continue return response.json() except httpx.HTTPError: wait_time = retry_base_delay * (2 ** attempt) await asyncio.sleep(wait_time) continue ``` ### 계정 ID 캐싱 계정 ID는 첫 조회 후 캐시됩니다: ```python async def _get_account_id(self) -> str: if self._account_id: return self._account_id # 캐시 반환 async with self._account_id_lock: # 동시성 안전 if self._account_id: return self._account_id response = await self._request("GET", "me", {"fields": "id"}) self._account_id = response["id"] return self._account_id ``` --- ## API 제한사항 ### Rate Limits | 제한 | 값 | 설명 | |------|-----|------| | 시간당 요청 | 200회 | 사용자 토큰당 | | 일일 게시 | 25개 | 계정당 (공식 문서 확인 필요) | ### 미디어 요구사항 **이미지:** - 형식: JPEG 권장 - 최소 크기: 320x320 픽셀 - 비율: 4:5 ~ 1.91:1 **비디오:** - 형식: MP4 (H.264) - 길이: 3초 ~ 60분 (릴스) - 해상도: 최소 720p - 비율: 9:16 (세로), 16:9 (가로), 1:1 (정사각형) **캐러셀:** - 이미지 수: 2-10개 - 각 이미지는 위 요구사항 충족 필요 ### URL 요구사항 게시할 미디어 URL은: - HTTPS 프로토콜 권장 - 공개적으로 접근 가능 (인증 없이) - CDN 또는 S3 등의 공개 URL 사용 --- ## 참고 문서 - [Instagram Graph API 공식 문서](https://developers.facebook.com/docs/instagram-platform) - [Content Publishing API](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing) - [Graph API Explorer](https://developers.facebook.com/tools/explorer/)