AWS IAM AssumeRole 사용법 완벽 가이드 — 계정 간 역할 전환과 임시 자격증명

목차

AWS IAM AssumeRole 사용법은 멀티 계정 환경에서 권한 위임을 다룰 때 가장 먼저 익혀야 하는 메커니즘이다. 정확히 말하면 sts:AssumeRole API를 호출해서 특정 IAM Role의 임시 자격증명(Access Key, Secret Key, Session Token)을 발급받는 절차다. 한 계정의 사용자가 다른 계정의 리소스를 만지거나, EC2 인스턴스가 운영 계정의 S3 버킷에 쓰기 권한을 얻거나, 외부 SaaS가 우리 계정에 제한된 접근 권한을 가질 때 전부 이 API를 거친다.

프론트엔드에서 백엔드로 옮겨온 뒤 한동안 이 부분이 가장 헷갈렸다. OAuth의 토큰 발급과 구조가 비슷해 보이지만, 실제로는 훨씬 더 깊고 위험한 메커니즘이다. 토큰 하나가 잘못 새면 운영 DB가 통째로 노출될 수 있는 종류의 위험이다. 그래서 이 글에서는 AssumeRole을 단순히 "어떻게 호출하는가"가 아니라 "왜 이 구조로 설계됐는가"에서 시작해서, CLI와 boto3에서 실제로 호출하는 패턴, 그리고 운영하면서 마주친 한계까지 단계별로 풀어본다.

왜 정적 액세스 키 대신 AssumeRole인가

AWS 공식 문서(IAM User Guide, 2024 개정판 기준)에서 정적 액세스 키를 가능하면 쓰지 말라고 권고한 지 꽤 됐다. 이유는 세 가지로 정리된다.

그러나, 첫째, 액세스 키는 발급된 순간부터 폐기 전까지 유효하다. 누군가 실수로 깃허브에 푸시하면, 봇이 분 단위로 스캔해서 채굴 인스턴스를 띄운다. Trufflehog 같은 도구가 흔히 잡아내는 케이스다. 영구 키는 한번 새어나가면 회수가 사실상 불가능하다.

둘째, IAM User 하나에 모든 권한을 몰아두면 권한 분리가 불가능하다. 개발용과 운영용을 같은 키로 쓰면 운영 사고 시 책임 추적이 어렵다. CloudTrail에 남는 userIdentity가 동일하기 때문이다. 누가 무엇을 했는지 구분이 안 된다.

셋째, 멀티 계정 환경에서는 키 개수가 계정 × 사용자만큼 폭증한다. 계정 10개에 개발자 20명이면 단순 계산으로 200개의 키가 돈다. 회전(rotation) 주기를 90일로 잡아도 매주 누군가의 키를 갈아야 한다. 운영 부담이 선형으로 증가한다.

AssumeRole은 이 세 가지를 한 번에 해결하는 구조다. 영구 키는 IAM User 하나만 두거나 아예 SSO(IAM Identity Center)로 대체하고, 실제 작업은 Role을 통해 임시 자격증명을 받아 수행한다. 임시 자격증명은 기본 1시간, 최대 12시간이면 자동 폐기된다. 새어나가도 시간 안에 만료된다.

AssumeRole의 두 정책 구조 — 신뢰와 권한

예를 들어, 이 부분이 처음에 가장 헷갈렸다. Role에는 두 개의 정책이 붙는다.

정책 종류 답하는 질문 누가 작성하는가
신뢰 정책 (Trust Policy) 누가 이 Role을 가져갈 수 있는가 Role을 소유한 계정
권한 정책 (Permissions Policy) Role을 가져간 주체가 무엇을 할 수 있는가 Role을 소유한 계정

신뢰 정책 없이는 AssumeRole이 호출되지 않는다. 권한 정책 없이는 Role을 가져가도 아무것도 못 한다. 두 정책의 교집합이 실제 가능한 동작이다.

신뢰 정책의 전형적인 형태는 다음과 같다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:role/DeveloperRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "shared-secret-abc123"
        }
      }
    }
  ]
}

Principal이 호출 주체를 지정한다. 계정 전체(arn:aws:iam::111122223333:root)를 허용할 수도 있고, 특정 사용자나 Role만 허용할 수도 있다. 권장은 가능한 좁히는 쪽이다. root를 쓰면 해당 계정의 누구든 IAM 정책만 있으면 호출할 수 있어서 사실상 위임 범위가 통제되지 않는다.

Condition 블록은 추가 제약이다. ExternalId, MFA, IP 대역, 시간대 등을 걸 수 있다. 서드파티가 우리 계정의 Role을 가져가는 경우 ExternalId는 사실상 필수다. 이유는 뒤에서 다룬다.

즉, 권한 정책은 일반 IAM Policy와 동일하다. 익숙한 JSON 구조 그대로 Action, Resource, Condition을 조합해서 작성한다. Role에 인라인으로 붙이거나, 관리형 정책을 attach하면 된다.

세션 정책은 별도다

이처럼, 여기서 한 단계가 더 있다. AssumeRole 호출 시점에 Policy 파라미터로 추가 정책을 전달할 수 있다. 이게 세션 정책이다.

세션 정책은 권한을 확장하지 못한다. 권한 정책과 세션 정책의 교집합만 허용된다. 즉, 권한을 더 좁히는 용도다. 예를 들어 운영 Role을 가져가되 이번 세션에서는 특정 버킷 하나에만 접근하고 싶을 때 쓴다. 임시로 권한을 줄여 실수를 막는 방어 패턴이다.

그런데, 이 구조를 이해하지 못한 채로 "Role에 권한을 줬는데 왜 안 되지" 또는 "권한을 더 좁히고 싶은데 어떻게 하지" 하면서 헤맨 시간이 꽤 됐다. AWS 콘솔의 IAM Policy Simulator가 신뢰 정책까지 같이 점검해주지 않는 점도 한몫했다. 시뮬레이터는 권한 정책의 정합성만 본다.

CLI에서 호출하는 가장 단순한 형태

aws sts assume-role 명령이 기본이다.

aws sts assume-role \
  --role-arn arn:aws:iam::444455556666:role/CrossAccountReadRole \
  --role-session-name dev-session-jhkim \
  --duration-seconds 3600

그래서, 성공하면 JSON으로 임시 자격증명이 떨어진다. AccessKeyIdASIA로 시작한다는 점이 중요하다. 영구 키는 AKIA, 임시 키는 ASIA. CloudTrail이나 S3 액세스 로그에서 이 두 글자만 봐도 정적/임시 구분이 된다. 침해 조사 때 의외로 자주 쓰는 단서다.

실무에서는 이 출력을 매번 환경변수에 박지 않는다. ~/.aws/config의 프로파일을 쓰는 게 표준이다.

[profile prod-read]
role_arn = arn:aws:iam::444455556666:role/CrossAccountReadRole
source_profile = default
role_session_name = jhkim-cli
duration_seconds = 3600
external_id = shared-secret-abc123

또한, 이렇게 두면 aws s3 ls --profile prod-read 한 번으로 AssumeRole이 자동 실행되고 결과가 메모리에 캐시된다. CLI는 만료 시간 직전에 알아서 재호출한다. 일일이 sts 명령을 칠 일이 없다. 여러 계정을 옮겨다닌다면 프로파일을 계정 수만큼 정의해두는 게 정신 건강에 좋다.

MFA를 강제하는 흔한 패턴

또한, 운영 계정 Role의 신뢰 정책에 MFA 조건을 추가하면 인증 없이는 AssumeRole이 거부된다. 핵심은 aws:MultiFactorAuthPresentaws:MultiFactorAuthAge 두 컨디션 키다.

"Condition": {
  "Bool": {"aws:MultiFactorAuthPresent": "true"},
  "NumericLessThan": {"aws:MultiFactorAuthAge": "3600"}
}

CLI 사용 시에는 --serial-number(MFA 디바이스 ARN)와 --token-code(6자리 OTP)를 같이 보낸다. MultiFactorAuthAge를 3600초로 잡으면 MFA 인증 후 1시간 안에만 Role 전환이 가능하다. 자리를 비웠을 때 세션을 누군가 가로채도 한 시간 뒤에는 새로 인증해야 한다. 운영 계정 보호에 효과가 크다.

boto3로 호출하고 캐싱하기

Python에서는 boto3.client('sts').assume_role(...)이 직접 호출이다. 다만 운영 코드에서 이걸 그대로 쓰는 건 안티패턴에 가깝다. 만료 직전에 자동으로 갱신해주는 자격증명 공급자(credential provider)를 거치는 게 정석이다.

게다가, botocore는 RefreshableCredentials라는 내부 클래스를 제공한다. 직접 쓸 일은 드물지만, 구조를 알면 의도가 보인다.

import boto3
from botocore.credentials import RefreshableCredentials
from botocore.session import get_session

def _refresh():
    # 만료 직전에 botocore가 이 콜백을 호출해서 새 토큰을 받아온다
    sts = boto3.client("sts")
    resp = sts.assume_role(
        RoleArn="arn:aws:iam::444455556666:role/AppRole",
        RoleSessionName="app-runtime",
        DurationSeconds=3600,
    )
    c = resp["Credentials"]
    return {
        "access_key": c["AccessKeyId"],
        "secret_key": c["SecretAccessKey"],
        "token": c["SessionToken"],
        "expiry_time": c["Expiration"].isoformat(),
    }

creds = RefreshableCredentials.create_from_metadata(
    metadata=_refresh(),
    refresh_using=_refresh,
    method="sts-assume-role",
)

session = get_session()
session._credentials = creds
boto3_session = boto3.Session(botocore_session=session)
s3 = boto3_session.client("s3")

반면, 이 패턴은 장기 실행 프로세스(예: 배치 워커, 큐 컨슈머)에서 12시간짜리 세션을 쓰면서 만료 직전에 알아서 갱신해야 할 때 쓴다. 매 호출마다 sts를 부르면 sts API 한도에 부딪힐 위험이 있다. sts는 일반 API 호출과 별도로 카운트되는데, 공식 문서상 명시된 한도는 리전 엔드포인트 기준 계정당 초당 수천 회 수준이다(정확한 수치는 리전·계정 등급에 따라 다르다).

EC2·ECS·Lambda는 다르다

특히, 위 패턴이 필요한 건 컨테이너가 아닌 환경이거나, 한 프로세스가 여러 Role을 번갈아 써야 할 때다. EC2 인스턴스 프로파일, ECS 태스크 Role, Lambda 실행 Role을 쓰는 경우 boto3가 알아서 임시 자격증명을 메타데이터 서비스(IMDSv2)에서 받아온다. 코드에서 별도 처리가 필요 없다.

특히, 문제는 "EC2에서 다른 계정의 Role을 가져갈 때"다. 이 경우 EC2 인스턴스 프로파일이 1차 Role, 거기서 AssumeRole로 2차 Role을 가져온다. 이걸 Role Chaining이라고 부른다.

Role Chaining에는 한 가지 제약이 있다. 체인된 세션의 최대 지속시간은 1시간으로 고정이다. Role에 12시간 MaxSessionDuration을 줘도 1시간만 유효하다. AWS 공식 문서(IAM User Guide, "roles-terms-and-concepts" 섹션)에 명시돼 있다. 이걸 모르고 DurationSeconds=43200을 줬다가 ValidationError: The requested DurationSeconds exceeds the MaxSessionDuration set for this role. 를 만나는 경우가 흔하다. 에러 메시지가 MaxSessionDuration 설정을 의심하게 만들지만, 실제 원인은 체이닝 제약인 경우가 많다.

ExternalId — 외부 제공자에게 Role을 줄 때

서드파티 SaaS(예: Datadog, Snyk, Wiz)에게 우리 계정의 읽기 권한을 줄 때, 그들은 우리에게 자신의 Role ARN을 알려준다. 우리는 그 ARN을 Principal에 박는다. 그런데 이게 위험하다.

대표적인 공격이 혼동된 대리자(Confused Deputy) 문제다. 같은 SaaS를 쓰는 다른 고객이 우리 Role ARN을 추측해서 AssumeRole을 호출하면, 신뢰 정책상으로는 막을 방법이 없다. SaaS 측 Role이 우리 신뢰 정책에 들어있기 때문에, 그 SaaS가 어느 고객의 요청으로 호출하든 SaaS Role이라는 사실은 동일하기 때문이다.

한편, 해결책이 ExternalId다. 신뢰 정책에 sts:ExternalId 조건을 걸고, SaaS에는 그 값을 알려준다. SaaS가 우리 Role을 호출할 때 ExternalId를 같이 보내지 않으면 거부된다. 다른 고객은 우리 ExternalId를 모르므로 호출 자체가 안 된다. AWS의 보안 모범 사례 문서(AWS Security Blog, "How to use external ID" 포스트)가 이 패턴을 표준으로 권장한다.

그런데, ExternalId는 GUID나 충분히 긴 랜덤 문자열로 써야 한다. 사용자가 추측 가능한 값(회사명, 도메인 등)은 의미가 없다. 보안 감사 시 ExternalId를 정기적으로 회전시키는지도 자주 점검된다. 내부 계정 간(같은 조직 내)에는 ExternalId가 불필요하다. 외부 신뢰 경계를 넘을 때만 의미가 있다.

한편, 여담으로, ExternalId를 콘솔에 그대로 노출하는 SaaS도 많은데 이건 좀 부주의한 설계 같다. 환경변수나 시크릿 저장소에 두고 마스킹하는 게 맞다.

세션 토큰 만료와 클럭 드리프트

그런데, 운영에서 마주친 까다로운 이슈 하나가 클럭 드리프트(시스템 시간 어긋남)였다. 자체 호스팅 노드의 NTP가 죽어서 시계가 어긋나면, 그 노드에서 받은 토큰을 다른 곳에서 사용할 때 ExpiredTokenException이 떨어질 수 있다. 토큰 자체는 만료 전이지만, sts가 보는 시간과 검증 시점의 시간이 어긋나기 때문이다.

그런데, 이 문제는 AWS SDK가 직접 잡아주지 않는다. 운영 측에서 NTP를 강제하는 게 답이다. AWS 공식 권장은 chrony 또는 systemd-timesyncd로 Amazon Time Sync Service(169.254.169.123)를 가리키는 것이다. EC2 환경에서는 기본값이지만, 온프레미스나 다른 클라우드에서 AssumeRole을 쓸 때는 한 번 점검해볼 가치가 있다.

리전별 sts 엔드포인트 얘기를 잠깐 보태자면, 기본은 sts.amazonaws.com(글로벌)이지만 2019년 이후로 리전 엔드포인트(sts.ap-northeast-2.amazonaws.com)를 권장한다. 글로벌 엔드포인트는 미국 동부 한 곳에서 처리되므로 지연이 크고 장애 영향도 크다. 한국에서 운영하는 서비스라면 boto3 설정에 region_name='ap-northeast-2'를 명시하거나 환경변수 AWS_STS_REGIONAL_ENDPOINTS=regional을 세팅하는 쪽이 낫다. 평균 지연이 수십 ms 단위로 줄어든다.

운영에서 자주 빠뜨리는 디테일

그런데, 이 섹션은 짧다. 자주 빠뜨리는 것 세 가지만.

그런데, 첫째, CloudTrail의 userIdentity.sessionContext.sessionIssuer를 봐야 진짜 호출자가 보인다. AssumeRole로 들어온 호출은 userIdentity.arn이 가짜 Role 세션 ARN으로 찍히기 때문에, 누가 그 Role을 가져갔는지는 sessionIssuer를 따로 봐야 한다.

물론, 둘째, MaxSessionDuration은 Role 단위 설정이다. AssumeRole 호출 시 DurationSeconds가 이 값을 초과하면 거부된다. 운영 Role에 12시간을 줘놓고 호출 시 1시간만 요청해도 되지만, 반대로 1시간짜리 Role에 12시간을 요청하면 실패한다.

셋째, IAM 변경의 글로벌 전파에는 보통 수 초가 걸리지만 드물게 1분 이상 걸린다. 신뢰 정책을 수정한 직후 호출이 거부되면 잠시 기다려보는 게 답인 경우가 종종 있다.

한계 — AssumeRole이 풀지 못하는 것

특히, 이 메커니즘이 모든 권한 문제를 해결하지는 못한다. 운영하며 마주친 한계 세 가지가 있다.

첫째, IAM Identity Center(구 SSO)가 등장한 이후 사람 사용자의 일상 워크플로에서는 직접 AssumeRole을 호출할 일이 줄었다. SSO가 Permission Set이라는 추상으로 한 단계 더 감싸서 사용자 경험을 단순화했다. 콘솔에서 계정과 역할을 클릭만 하면 임시 자격증명이 발급된다. 내부적으로는 여전히 AssumeRoleWithSAML이 도는 구조다. 새로 멀티 계정을 시작한다면 SSO부터 깔고 가는 흐름이 자연스럽다.

둘째, EKS의 IRSA(IAM Roles for Service Accounts)나 GitHub Actions의 OIDC 연동은 AssumeRoleWithWebIdentity라는 변형을 쓴다. 신뢰 정책 작성법이 조금 다르고, Principal에 OIDC 공급자 ARN이 들어간다. 일반 AssumeRole만 알고 이쪽을 처음 마주치면 한 번 더 학습 곡선이 생긴다. 신뢰 조건의 sub, aud 클레임을 어떻게 좁히느냐가 핵심이고, 이걸 느슨하게 두면 OIDC 토큰 발급 권한을 가진 누구나 Role을 가져갈 위험이 있다.

셋째, 권한 위임이 깊어질수록 CloudTrail 추적이 복잡해진다. Role A → Role B → Role C로 이어지는 체이닝에서 마지막 호출의 sessionIssuer만으로는 원천 사용자를 알기 어렵다. sourceIdentity 파라미터를 명시적으로 전달해서 추적성을 확보하는 패턴이 있는데, IAM 정책에서 강제해야 실효가 있다. 아직 우리 조직에서는 일부 Role만 적용 중이고 전면 적용은 검토 단계다.

당장 적용할 수 있는 액션 세 가지를 짚자면, 이렇다. 첫째, 기존에 발급해둔 IAM User 액세스 키를 목록으로 뽑아서 30일 이상 안 쓴 것은 비활성화해라. 둘째, 외부 SaaS에 준 Role이 있다면 신뢰 정책에 sts:ExternalId 조건이 걸려 있는지 확인해라. 셋째, boto3로 장기 실행 프로세스를 돌린다면 RefreshableCredentials 패턴으로 옮겨가는 PR을 하나 올려둬라.

게다가, 개인적으로는 새로 시작하는 멀티 계정 환경이라면 IAM Identity Center를 먼저 깔고, 거기서 못 풀리는 케이스(자동화, 서드파티 연동)만 직접 AssumeRole로 푸는 쪽이 더 깔끔한 것 같다.

관련 글