Data Science를 학습하면서, 전처리에 많이 사용되는 groupby를 정리해본다. 예제 데이터는 아래를 가정한다.

import pandas as pd

df  # columns: SEASON, A, B, C  (SEASON은 반복되는 범주값)

1. 개요 — Series가 모여 DataFrame이 된다

1.1 Series = 값 배열 + 인덱스

Series는 1차원이고, 값과 라벨(인덱스)이 짝지어진 구조다.

s = pd.Series([10, 20, 30], index=['x', 'y', 'z'], name='A')
x    10
y    20
z    30
Name: A, dtype: int64
  • s.values → array([10, 20, 30])
  • s.index → Index(['x','y','z'])
  • s.name → 'A' (나중에 DataFrame의 열 이름이 된다)

1.2 DataFrame = 인덱스를 공유하는 Series들의 모음

df = pd.DataFrame({
    'A': pd.Series([10, 20, 30], index=['x','y','z']),
    'B': pd.Series([1.0, 2.0, 3.0], index=['x','y','z']),
})
     A    B
x   10  1.0
y   20  2.0
z   30  3.0

핵심은 축이 두 개라는 점이다.

구성요소 의미
df.index 행 라벨 (axis=0)
df.columns 열 라벨 (axis=1)
df['A'] 열 하나 → Series
df[['A','B']] 열 여러 개 → DataFrame
df.loc['x'] 행 하나 → Series (인덱스가 열 이름이 된다)

대괄호 하나(df['A'])와 둘(df[['A']])의 차이가 이후 전부에 영향을 준다. 하나는 Series, 둘은 DataFrame이다.

1.3 MultiIndex = 라벨이 여러 층

인덱스의 한 칸이 단일 값이 아니라 튜플인 경우다.

mi = pd.MultiIndex.from_tuples([('2022','A'), ('2022','B'), ('2023','A')],
                               names=['SEASON', 'VAR'])
SEASON  VAR
2022    A
        B
2023    A
  • mi.nlevels → 2
  • level=0 → 바깥층(SEASON), level=1 → 안쪽층(VAR)
  • 이름이 있으면 숫자 대신 이름 사용 가능: level='SEASON'

groupby의 결과나 corr(), describe(), pivot 계열은 MultiIndex를 자주 만들어낸다. 그래서 "층을 어떻게 골라내고 없애느냐"가 실무의 대부분이다.


2. groupby 개요

2.1 split - apply - combine

df.groupby('SEASON')['A'].mean()
  1. split — SEASON 값이 같은 행끼리 묶는다
  2. apply — 각 묶음에 mean()을 적용한다
  3. combine — 결과를 다시 하나로 합친다. 그룹 키가 결과의 인덱스가 된다
SEASON
2022    18.3
2023    21.7
Name: A, dtype: float64

2.2 groupby 객체 자체는 계산을 하지 않는다

g = df.groupby('SEASON')
g                       # <DataFrameGroupBy object ...>  ← 아직 아무것도 안 함
g.ngroups               # 그룹 개수
g.size()                # 그룹별 행 수
g.groups.keys()         # 그룹 키 목록
g.get_group('2022')     # 특정 그룹의 원본 행들
list(g)[0]              # ('2022', DataFrame) 튜플

집계 함수(mean, agg, sum 등)를 붙이는 순간 실제로 계산된다. 디버깅할 때 get_group으로 한 그룹만 꺼내 확인하는 습관이 유용하다.

2.3 반환 형태 규칙

df.groupby('SEASON')['A'].mean()          # Series      (열 하나 → 1차원)
df.groupby('SEASON')[['A']].mean()        # DataFrame   (대괄호 둘)
df.groupby('SEASON')[['A','B']].mean()    # DataFrame
df.groupby(['SEASON','TEAM'])['A'].mean() # Series, 인덱스는 MultiIndex

2.4 자주 쓰는 옵션

df.groupby('SEASON', as_index=False)   # 그룹 키를 인덱스가 아닌 일반 열로
df.groupby('SEASON', sort=False)       # 그룹 키 정렬 안 함 (등장 순서 유지, 약간 빠름)
df.groupby('SEASON', dropna=False)     # 키가 NaN인 행도 하나의 그룹으로
df.groupby('SEASON', observed=True)    # Categorical 키에서 실제 등장한 조합만

as_index=False를 안 썼다면 나중에 .reset_index()로 같은 효과를 낼 수 있다.

2.5 agg / transform / apply 구분

셋의 차이를 헷갈리면 이후가 전부 꼬인다.

메서드 그룹당 반환 결과 행 수 용도
agg 스칼라 1개 그룹 수 평균, 합, max-min
transform 원본과 같은 길이 원본 행 수 그룹평균 붙이기, 정규화
apply 자유 (스칼라/Series/DataFrame) 상황에 따라 복잡한 그룹 로직
# agg: 시즌별 평균 (행 수 = 시즌 수)
df.groupby('SEASON')['A'].mean()

# transform: 각 행에 자기 시즌 평균을 붙인다 (행 수 = 원본)
df['A_season_mean'] = df.groupby('SEASON')['A'].transform('mean')
df['A_z'] = df.groupby('SEASON')['A'].transform(lambda s: (s - s.mean()) / s.std())

# apply: 여러 열을 동시에 봐야 할 때
df.groupby('SEASON').apply(
    lambda g: g['A'].corr(g['B']),
    include_groups=False,      # pandas 2.2+ 경고 방지
)

apply는 유연하지만 느리다. agg 또는 transform으로 표현 가능하면 그쪽을 쓴다.


3. groupby + corr 결과 인덱싱

3.0 만들어지는 구조 확인

c = df.groupby('SEASON')[['A', 'B', 'C']].corr()

문법 주의: df.groupby('SEASON').['A','B','C'] 는 오류. 대괄호를 두 번 쓴다 → [['A','B','C']]

                   A      B      C
SEASON  VAR
2022    A       1.00   0.35  -0.12
        B       0.35   1.00   0.48
        C      -0.12   0.48   1.00
2023    A       1.00   0.71   0.05
        B       0.71   1.00   0.22
        C       0.05   0.22   1.00
2024    A       1.00   0.19   0.60
        B       0.19   1.00  -0.33
        C       0.60  -0.33   1.00
  • 행 인덱스: (SEASON, VAR) 2층
  • 열: A, B, C
  • 시즌마다 3×3 대칭행렬이 세로로 쌓인 모양
  • 대각선은 항상 1.0, 상삼각과 하삼각은 중복. 실제 정보는 쌍 3개(A-B, A-C, B-C)뿐이다
c.index.names    # ['SEASON', None]
c.index[0]       # ('2022', 'A')

3.1 인덱스로 직접 접근 (loc)

스칼라 하나

c.loc[('2022', 'A'), 'B']      # 2022의 corr(A, B) → 0.35

행 라벨이 튜플이므로 ('2022','A')를 통째로 넘긴다.

한 시즌의 행렬 전체

c.loc['2022']
        A      B      C
VAR
A    1.00   0.35  -0.12
B    0.35   1.00   0.48
C   -0.12   0.48   1.00

바깥층은 전부, 안쪽층은 특정 값 — slice(None) 사용

c.loc[(slice(None), 'A'), :]     # 모든 시즌의 A행
c.loc[(slice(None), 'A'), 'B']   # 모든 시즌의 corr(A,B)

slice(None)이 눈에 거슬리면 IndexSlice가 같은 뜻이다.

idx = pd.IndexSlice
c.loc[idx[:, 'A'], 'B']
c.loc[idx['2022':'2023', 'A'], :]    # 시즌 범위 + 안쪽층 지정

이 방식의 결과는 인덱스가 여전히 2층이다.

c.loc[idx[:, 'A'], 'B'].idxmax()     # ('2023', 'A')  ← 튜플이 나온다

시즌 라벨만 원하면 [0]을 떼거나, 아래 xs를 쓴다.

MultiIndex 정렬 경고 loc에 슬라이스를 쓰다가 UnsortedIndexError가 나면 먼저 정렬한다.

c = c.sort_index()

3.2 xs로 단면 자르기

xs = cross-section. 지정한 층의 라벨이 일치하는 행만 골라내고, 그 층을 인덱스에서 제거한다.

c.xs('A', level=1)
          A      B      C
SEASON
2022   1.00   0.35  -0.12
2023   1.00   0.71   0.05
2024   1.00   0.19   0.60

동작을 두 단계로 풀면:

  1. VAR == 'A' 인 행만 남긴다 → 시즌당 1행
  2. VAR 층은 전부 'A'라 정보가 없으므로 인덱스에서 떨어뜨린다

읽는 법: "각 시즌에서 A가 다른 변수와 갖는 상관계수". A 열은 자기 자신이므로 항상 1.00이고, 쓸모 있는 건 B, C 열이다.

인덱스가 1층이 됐으므로 이후는 평범한 DataFrame이다.

c.xs('A', level=1)['B']
SEASON
2022    0.35
2023    0.71
2024    0.19
Name: B, dtype: float64

시즌별 corr(A,B) Series. idxmax, max, sort_values, plot 전부 그대로 동작한다.

c.xs('A', level=1)['B'].idxmax()    # '2023'  ← 튜플이 아니라 라벨

level을 생략하면 뜻이 달라진다

c.xs('A', level=1)   # 안쪽층(VAR)에서 'A' 찾기       ← 의도한 것
c.xs('2022')         # 바깥층(level=0 기본)에서 찾기   → 한 시즌 행렬
c.xs('A')            # 바깥층에서 'A'를 찾다가 KeyError

기본값이 level=0이므로 안쪽층을 노릴 때 level=1은 필수다.

층을 남기고 싶으면

c.xs('A', level=1, drop_level=False)

대칭성 상관행렬은 대칭이므로 어느 방향으로 뽑아도 값이 같다.

c.xs('A', level=1)['B']    # A행의 B열
c.xs('B', level=1)['A']    # B행의 A열 → 동일한 값

실전 조회 패턴

ab = c.xs('A', level=1)['B']   # 시즌별 corr(A, B)
ac = c.xs('A', level=1)['C']   # 시즌별 corr(A, C)
bc = c.xs('B', level=1)['C']   # 시즌별 corr(B, C)

s_best = ab.idxmax()           # corr(A,B) 최대 시즌
ab.max()                       # 그때의 corr(A,B) 값
ac.loc[s_best]                 # 그 시즌의 corr(A,C)
bc.loc[s_best]                 # 그 시즌의 corr(B,C)

bc.loc[ac.idxmax()]            # corr(A,C) 최대 시즌의 corr(B,C)
ab.nlargest(3)                 # corr(A,B) 상위 3시즌

3.3 unstack으로 평탄화 (flat)

무슨 일이 일어나는가

unstack()은 행 인덱스의 가장 안쪽 층을 뽑아 열로 올려붙인다. 시즌마다 3행이었던 것이 시즌마다 1행이 된다.

flat = c.unstack()
            A                     B                     C
VAR         A      B      C       A      B      C       A      B      C
SEASON
2022     1.00   0.35  -0.12    0.35   1.00   0.48   -0.12   0.48   1.00
2023     1.00   0.71   0.05    0.71   1.00   0.22    0.05   0.22   1.00
2024     1.00   0.19   0.60    0.19   1.00  -0.33    0.60  -0.33   1.00
  • 행 인덱스: SEASON 1층으로 줄었다
  • 열 인덱스: 2층으로 늘었다 (3 × 3 = 9개 열)
  • 층의 총 개수는 보존된다. 행에서 뺀 만큼 열로 옮겨갔을 뿐이다

열 튜플 읽는 규칙 (가장 헷갈리는 지점)

flat.columns
# MultiIndex([('A','A'), ('A','B'), ('A','C'),
#             ('B','A'), ('B','B'), ('B','C'),
#             ('C','A'), ('C','B'), ('C','C')])

(원래_열이름, 올라온_VAR값) — 기존 열이 겉층, 새로 올라온 층이 안층.

따라서 ('B','A') = "원래 표에서 VAR='A' 행의 B 열" = corr(A, B).

flat[('B', 'A')]     # 시즌별 corr(A, B)
flat['B']['A']       # 같은 것, 두 단계 접근
flat['B']            # B 겉층 전체 묶음

사람이 읽기 좋게 이름 붙이기

flat.columns = [f'{b}_{a}' for a, b in flat.columns]

a = 겉층(원래 열), b = 안층(VAR). 순서를 뒤집어 ('B','A') → 'A_B' 로 만든다.

A_A  B_A  C_A   A_B  B_B  C_B   A_C  B_C  C_C
 ↑                ↑                     ↑
1.0            corr(A,B)            corr(B,C)

대각선(A_A, B_B, C_C)은 1.0이고 B_A는 A_B의 중복이므로, 의미 있는 3개만 남긴다.

flat = flat[['A_B', 'A_C', 'B_C']]
        A_B    A_C    B_C
SEASON
2022   0.35  -0.12   0.48
2023   0.71   0.05   0.22
2024   0.19   0.60  -0.33

시즌이 행, 상관쌍이 열인 평범한 표가 됐다. 조회가 한 줄로 끝난다.

flat.loc[flat['A_B'].idxmax(), 'A_C']    # corr(A,B) 최대 시즌의 corr(A,C)
flat.loc[flat['A_C'].idxmax(), 'A_B']
flat.nlargest(3, 'A_B')                   # 상위 3시즌 전체 비교
flat.sort_values('B_C', ascending=False)
flat.corr()                               # 쌍끼리의 관계
flat.plot()                               # 시즌 추이 그래프

unstack(level=0) — 방향 반대

c.unstack(level=0)      # == c.unstack('SEASON')
          A                    B                 ...
SEASON  2022   2023   2024   2022   2023   2024
VAR
A       1.00   1.00   1.00   0.35   0.71   0.19
B       0.35   0.71   0.19   1.00   1.00   1.00
C      -0.12   0.05   0.60   0.48   0.22  -0.33

변수가 행, 시즌이 열. 시즌 수가 많으면 열이 폭발하므로 보통 기본값(VAR을 올리는 쪽)이 실용적이다.

역연산 stack

flat.stack()     # 열 층을 행으로 내린다 → 원래 c 모양에 가깝게 복귀

3.4 행렬을 거치지 않는 대안

corr()은 대각선과 중복을 모두 계산한다. 쌍 값만 필요하면 처음부터 직접 만드는 게 가볍고 명확하다.

pairs = df.groupby('SEASON').apply(
    lambda g: pd.Series({
        'A_B': g['A'].corr(g['B']),
        'A_C': g['A'].corr(g['C']),
        'B_C': g['B'].corr(g['C']),
    }),
    include_groups=False,
)
pairs.loc[pairs['A_B'].idxmax()]

또는 reset_index로 아예 긴 형태(long format)로 펴는 방법.

t = c.reset_index().rename(columns={'level_1': 'VAR'})
t.loc[t['VAR'] == 'A', ['SEASON', 'B', 'C']]

3.5 세 방식 비교

방식 결과 인덱스 idxmax() 반환 언제
loc / IndexSlice 2층 유지 튜플 ('2023','A') 스칼라 하나, 범위 슬라이싱
xs(..., level=1) 1층으로 축소 라벨 '2023' 특정 변수 기준 조회 (가장 흔함)
unstack + 이름정리 1층 + 열 평탄 라벨 '2023' 여러 쌍을 나란히 비교/정렬/시각화

4. groupby().agg() 사용법

4.1 named aggregation — 새컬럼명=(원본컬럼, 함수)

권장 기본형이다. 결과 열이 평평한 단일 레벨로 나와 후처리가 없다.

out = df.groupby('SEASON').agg(
    A_mean=('A', 'mean'),
    B_range=('B', lambda s: s.max() - s.min()),
)
        A_mean  B_range
SEASON
2022      18.3      7.2
2023      21.7      5.4

lambda 대신 이름 있는 함수를 쓰면 재사용과 가독성이 좋다.

def rng(s):
    return s.max() - s.min()

df.groupby('SEASON').agg(A_mean=('A', 'mean'), B_range=('B', rng))

한 열에 여러 집계, 서로 다른 열을 자유롭게 섞을 수 있다.

df.groupby('SEASON').agg(
    A_mean=('A', 'mean'),
    A_std=('A', 'std'),
    B_max=('B', 'max'),
    B_min=('B', 'min'),
    B_range=('B', rng),
    C_median=('C', 'median'),
    N=('A', 'size'),                       # 행 수 (NaN 포함)
    N_valid=('A', 'count'),                # 결측 제외 개수
    A_p90=('A', lambda s: s.quantile(0.9)),
)

제약: 키워드 인자 이름이므로 파이썬 식별자여야 한다. SALES 2023=(...) 같은 건 불가.

4.2 dict 방식 — agg({열: 함수})

df.groupby('SEASON').agg({'A': 'mean', 'B': rng})              # 단일 → 열이 평평
df.groupby('SEASON').agg({'A': ['mean', 'std'], 'B': [rng]})   # 다중 → 열이 MultiIndex

다중일 때 결과:

            A               B
         mean    std      rng
SEASON
2022     18.3    3.1      7.2

평탄화는 습관적으로 붙여두면 편하다.

g = df.groupby('SEASON').agg({'A': ['mean','std'], 'B': [rng]})
g.columns = ['_'.join(col) for col in g.columns]    # A_mean, A_std, B_rng
g = g.reset_index()

dict가 유리한 경우

1) 열이 많고 집계가 반복적일 때

num_cols = df.select_dtypes('number').columns
df.groupby('SEASON').agg({c: 'mean' for c in num_cols})

named agg로는 **{f'{c}_mean': (c,'mean') for c in num_cols} 언패킹이 필요해 장황하다.

2) 집계 스펙을 외부(설정/인자)에서 주입할 때

spec = {'A': 'mean', 'B': rng, 'C': ['min', 'max']}
df.groupby('SEASON').agg(spec)

3) MultiIndex 열을 그대로 활용할 때

g = df.groupby('SEASON').agg({'A': ['mean','std'], 'B': ['mean','std']})
g.stack(level=0)      # 변수 × 통계량 형태로 재배치
g['A']['mean']        # 계층 접근

named agg는 항상 1층이라 이런 재배치가 안 된다.

4) 열 이름이 파이썬 식별자가 아닐 때 — 'SALES 2023', '매출/원' 등

5) pivot_table의 aggfunc — named agg 문법 자체가 없다

df.pivot_table(index='SEASON', aggfunc={'A': 'mean', 'B': rng})

주의: dict 안에서 이름 바꾸기는 제거됐다

pandas 0.20 deprecated → 2.0 제거. 아래는 모두 에러다.

df.groupby('SEASON').agg({'A': {'평균': 'mean'}})     # ✗
df.groupby('SEASON')['A'].agg({'평균': 'mean'})       # ✗

이름을 정하려면 named agg를 쓰거나, dict로 집계한 뒤 rename한다.

4.3 Series에 대한 agg

열 하나만 다룰 때는 리스트로 넘기면 된다.

df.groupby('SEASON')['A'].agg(['mean', 'std', 'max', rng])
df.groupby('SEASON')['A'].agg(평균='mean', 범위=rng)    # Series named agg (가능)

4.4 자주 쓰는 집계 문자열

'mean' 'median' 'sum' 'min' 'max' 'std' 'var' 'count' 'size' 'first' 'last' 'nunique' 'idxmax' 'idxmin' 'any' 'all' 'sem' 'skew' 'prod'

문자열은 내부 최적화 경로를 타서 lambda보다 훨씬 빠르다. 가능하면 문자열을 쓴다.

  • count vs size: count는 NaN 제외, size는 포함
  • idxmax: 그룹 내 최대값의 인덱스 라벨을 돌려준다

4.5 두 방식 선택 기준

상황 선택
열 몇 개, 결과 이름을 명시하고 싶다 named agg
열을 동적으로 생성 / 스펙 주입 dict
MultiIndex 열로 후처리 (stack 등) dict
열 이름이 파이썬 식별자가 아니다 dict
pivot_table dict

한 agg 호출 안에서 두 방식을 섞을 수는 없다. 하나만 고른다.


5. 자주 밟는 함정

증상 원인 / 해결
df.groupby('S').['A','B'] 문법 오류 [['A','B']] 대괄호 두 번
xs('A')에서 KeyError level 기본값이 0. 안쪽층은 level=1 명시
idxmax()가 튜플을 반환 loc/IndexSlice는 층을 남긴다. xs나 unstack을 쓰거나 [0]으로 꺼낸다
UnsortedIndexError c = c.sort_index() 후 슬라이싱
unstack 후 열 접근 실패 튜플 순서는 (원래열, 올라온층). 이름 붙일 때 뒤집는다
agg 결과 열이 2층 dict + 다중 함수. '_'.join으로 평탄화
agg({'A': {'평균':'mean'}}) 에러 제거된 문법. named agg 사용
apply에서 그룹 키 관련 FutureWarning include_groups=False 추가 (pandas 2.2+)
np.ptp 사용 시 경고 직접 lambda s: s.max()-s.min() 정의
결과 행 수가 예상과 다름 agg(그룹 수) vs transform(원본 수) 혼동
그룹 키가 인덱스로 들어가 불편 as_index=False 또는 .reset_index()
키가 NaN인 행이 사라짐 기본 dropna=True. dropna=False

6. 한눈 치트시트

# 구조 확인
c.index.names / c.index.nlevels / c.columns

# 기본 집계
df.groupby('SEASON')['A'].mean()                       # Series
df.groupby('SEASON')[['A','B']].mean()                 # DataFrame
df.groupby('SEASON', as_index=False)['A'].mean()       # 키를 열로

# corr 결과 인덱싱
c = df.groupby('SEASON')[['A','B','C']].corr()
c.loc[('2022','A'), 'B']                               # 스칼라
c.loc['2022']                                          # 한 시즌 행렬
c.xs('A', level=1)                                     # A행만, 층 제거
c.xs('A', level=1)['B']                                # 시즌별 corr(A,B)
flat = c.unstack()
flat.columns = [f'{b}_{a}' for a, b in flat.columns]
flat = flat[['A_B','A_C','B_C']]

# 조회
ab, ac = flat['A_B'], flat['A_C']
ac.loc[ab.idxmax()]                                    # A_B 최대 시즌의 A_C
flat.nlargest(3, 'A_B')

# agg
df.groupby('SEASON').agg(A_mean=('A','mean'), B_range=('B', rng))
df.groupby('SEASON').agg({'A': ['mean','std'], 'B': [rng]})

# transform (원본 행 수 유지)
df['A_dev'] = df['A'] - df.groupby('SEASON')['A'].transform('mean')

핵심 주장

기업의 AI 운영에는 두 극단이 있다. 하나는 100x 엔지니어를 지향하며 가능한 한 많은 모델 호출과 에이전트를 사용하게 하는 접근이다. 다른 하나는 비용을 최소화하기 위해 사용량을 강하게 제한하는 접근이다.

실용적인 해법은 둘 중 하나를 택하는 것이 아니다. 사용자와 업무 채택은 넓히되, 문맥·모델·호출·재시도·캐시를 시스템적으로 최적화하고, 최종 평가는 토큰이 아니라 완료된 업무의 가치로 하는 것이다.

Uber의 최근 변화는 이 중간 지점을 보여주는 사례다.

1. 문제 제기: AI를 많이 쓰는 것이 생산성인가

Uber는 2026년 초 개발자들의 Claude Code 등 에이전트 사용을 빠르게 확대했다. 그 결과 연간 AI 코딩 예산을 1분기 말부터 약 4개월 안에 소진했다는 보도가 나왔다.

중요한 점은 AI 도입이 실패해서 비용이 발생한 것이 아니라는 점이다. 사용자가 빠르게 늘고, 에이전트가 여러 작업을 병렬로 수행하면서 사용량이 예상을 넘어섰다. 다시 말해 성공적인 채택이 비용 문제를 일으킨 사례에 가깝다.

그러나 높은 토큰 사용량이 곧 고객 가치나 제품 출시 증가를 의미하지는 않는다. 따라서 질문은 “AI를 얼마나 많이 사용하는가?”에서 다음으로 바뀌어야 한다.

AI가 실제로 완료한 업무 하나의 비용은 얼마인가?

2. 최근 트렌드: 무제한 사용과 무조건 제한 사이

극단 1: 100x 엔지니어와 token-maxxing

고성능 모델과 다중 에이전트를 최대한 사용하면 개인의 처리량을 크게 높일 수 있다. 탐색, 프로토타이핑, 대규모 마이그레이션처럼 실패 비용이 낮거나 속도가 중요한 업무에는 이 접근이 합리적일 수 있다.

하지만 다음 문제가 생긴다.

  • 에이전트가 같은 문맥을 반복해서 읽음
  • 단순 업무에도 프런티어 모델을 사용함
  • 병렬 에이전트가 중복 결과를 만듦
  • 긴 추론과 재시도가 비용을 키움
  • AI가 만든 코드·테스트·리뷰를 사람이 다시 검토해야 함

극단 2: 사용량 제한 중심의 비용 통제

사용자별 월 한도나 고정 예산은 급격한 비용 폭증을 막는 데 유용하다. 하지만 한도 자체가 목표가 되면 중요한 작업까지 막고, 비용은 줄어도 생산성·학습·실험 기회를 잃을 수 있다.

현실적인 중간 지점

실용적인 운영은 채택은 확대하고, 낭비만 줄이는 것이다.

  • 사용자는 자유롭게 AI를 활용
  • 시스템은 업무별로 적절한 모델을 선택
  • 문맥과 도구 호출을 최소화
  • 세션 비용을 사용자에게 공개
  • 결과 품질과 완료 업무를 함께 측정

3. Uber의 비용 방정식

Uber는 에이전트 세션 비용을 다음처럼 분해한다.

사용자 수 × 사용자당 세션 수 × 세션당 턴 수 × 턴당 요청 수 × 요청당 토큰 수 × 토큰 가격

이 식의 해석은 중요하다.

확대해야 하는 항목

  • 사용자 수
  • 사용자당 유효 세션 수

AI가 조직에 실제로 확산되려면 이 두 항목은 반드시 커져야 한다. Uber도 2026년 2월부터 8월 사이 주간 활성 사용자가 7배, 주간 에이전트 요청이 9.4배 증가했다고 설명한다.

최적화해야 하는 항목

  • 세션당 턴 수
  • 턴당 요청 수
  • 요청당 입력·출력 토큰
  • 토큰 가격

이 항목들은 사용자의 AI 접근을 막지 않고도 줄일 수 있다. Uber는 같은 모델을 고정해 비교했을 때 요청 1,000건당 비용을 약 34%, 세션당 비용을 약 52% 낮췄다고 밝혔다.

4. Uber가 최적화한 것

4.1 불필요한 대화 재전송

에이전트는 매 턴마다 대화 이력, 프로젝트 컨텍스트, 도구 결과를 다시 입력으로 보낼 수 있다. 세션이 길어질수록 같은 정보가 반복 청구된다.

Uber는 자동 압축 기준을 40만 토큰으로 설정하고, 100만 토큰 컨텍스트를 지원하는 모델이라도 무조건 끝까지 문맥을 유지하지 않는다. 긴 문맥이 항상 높은 품질을 보장하지 않는다는 판단이다.

4.2 MCP 도구와 컨텍스트 최적화

Uber는 1,000개 이상의 MCP 서버를 단일 게이트웨이로 관리한다. 모든 도구의 스키마를 매 세션에 미리 넣으면 초기 프롬프트에 약 5만~7만 토큰이 추가될 수 있다.

대응 방식은 다음과 같다.

  • 필요한 도구만 검색해 동적으로 로딩
  • MCP 호출을 CLI 방식으로 지연 실행
  • 여러 도구 호출을 코드 모드에서 묶어 처리
  • 중간 polling 결과를 모델 문맥에 계속 넣지 않음

핵심은 MCP 서버를 줄이는 것이 아니라 모든 MCP 서버의 설명을 모든 세션에 싣지 않는 것이다.

4.3 프롬프트 캐시의 운영

반복되는 긴 문맥은 캐시하면 입력 비용과 지연 시간을 줄일 수 있다. Uber는 개발자가 세션을 5분 이상 중단하는 경우가 많다는 관찰에 따라 대화형 세션의 캐시 유지 시간을 5분에서 1시간으로 바꿨다.

반면 짧게 끝나는 하위 에이전트에는 짧은 캐시 시간을 적용한다. 즉, 캐시도 일괄 설정이 아니라 세션의 수명과 작업 패턴에 맞춰 운영한다.

4.4 적절한 모델 배치

모든 작업에 가장 비싼 모델을 쓰지 않는다.

  • 계획 수립·복잡한 판단: 고성능 모델
  • 분류·검색·반복 실행: 저비용 모델
  • 특화 업무: 오픈웨이트 모델
  • 하위 에이전트: 주 모델보다 저렴한 모델

Uber는 실제 업무로 자체 벤치마크를 만들고, 모델별 비용·정확도·신뢰성의 Pareto frontier를 비교한다. 단순히 “가장 싼 모델”이 아니라 완료된 업무의 비용과 품질이 함께 좋은 모델을 고르는 방식이다.

4.5 비용을 사용자에게 보이기

개발자 터미널에 세션 비용을 표시하고, 대시보드가 다음과 같은 낭비 패턴을 알려준다.

  • 단순 업무에 고성능 모델 사용
  • 긴 MCP 응답을 계속 문맥에 유지
  • 캐시 만료 후 세션 재개
  • 과도한 시스템 지침과 도구 정의 사전 로딩

비용 관리는 재무팀만의 업무가 아니라, 개발자의 일상적인 설계 선택이 된다.

5. 다른 사례: 비용 최적화가 제품 기능으로 들어가는 중

Uber만의 특수한 대응은 아니다.

GitHub Copilot: 하니스가 모델만큼 중요해짐

GitHub는 같은 모델과 같은 작업을 고정해 비교했을 때, Copilot의 에이전트 하니스가 다른 하니스보다 적은 토큰으로 비슷한 작업 완료율을 달성한다고 발표했다. 이는 비용 경쟁의 단위가 모델 자체에서 모델을 운용하는 하니스와 오케스트레이션 계층으로 확장되고 있다는 신호다. GitHub 공식 글

VS Code: 캐시 적중률을 핵심 효율 지표로 관리

VS Code 팀도 반복되는 프롬프트 접두부를 재사용하면 비용과 지연 시간을 함께 줄일 수 있다고 설명한다. 대화형 코딩 도구의 성능은 모델 품질뿐 아니라 컨텍스트를 얼마나 잘 재사용하는지에 좌우된다. VS Code 공식 글

Snowflake: 동적 모델 라우팅을 게이트웨이화

Snowflake는 품질과 비용을 기준으로 작업별 모델을 자동 선택하는 Cortex AI Gateway를 내세우고 있다. 복잡한 작업은 프런티어 모델로, 반복·저난도 작업은 더 효율적인 모델로 보내는 방식이다. Snowflake의 내부 평가에서는 프런티어 모델 단일 경로보다 최대 3배 높은 토큰 효율을 보고했다. 다만 이는 Snowflake 내부 테스트이므로 일반적인 ROI로 확대 해석해서는 안 된다. 이는 기업의 모델 선택이 개인의 프롬프트 습관이 아니라 중앙화된 라우팅 정책으로 이동하는 사례다. Snowflake 공식 글

이 사례들은 Uber의 방향과 같은 축에 있다. 사용량을 단순히 억제하는 것이 아니라, 하니스·캐시·게이트웨이·모델 라우팅을 통해 같은 업무를 더 적은 비용으로 수행한다.

6. 살펴봐야 할 부분: 비용 효율이 ROI인가

Uber의 요청당 비용과 세션당 비용이 낮아졌다는 사실은 좋은 신호지만, 그것만으로 사업 ROI가 증명되지는 않는다.

반드시 함께 봐야 할 지표는 다음과 같다.

  • 병합된 PR당 AI 비용
  • 실제 출시된 기능당 AI 비용
  • AI 생성 코드의 되돌림률과 장애율
  • 사람의 검토·수정 시간
  • 버그 해결 시간과 MTTR
  • 고객 지원·매출·운영비에 미친 영향
  • AI 사용이 없었을 때와 비교한 순수한 시간 절감

특히 비용/요청은 좋아졌지만 비용/고객 가치는 그대로일 수 있다. 요청 수가 늘면서 단위 비용이 내려가는 규모의 경제와, 실제 가치가 증가하는 생산성 향상은 구분해야 한다.

7. 우리가 배울 수 있는 운영 원칙

  1. AI 사용을 무조건 제한하지 말고, 먼저 비용의 발생 구조를 분해한다.
  2. 모델 가격보다 문맥 중복·재시도·도구 로딩·캐시 만료를 점검한다.
  3. 단순 작업과 복잡한 작업에 같은 모델을 배치하지 않는다.
  4. 실제 업무에서 만든 평가 세트로 비용과 품질을 함께 비교한다.
  5. 사용자·세션 비용을 숨기지 않고 실시간으로 보여준다.
  6. 토큰당 비용에서 완료된 업무당 비용으로 측정 단위를 바꾼다.
  7. 비용 절감 결과가 고객 가치와 연결되는지 별도 검증한다.

결론

AI 운영의 목표는 최대 사용량도, 최소 비용도 아니다. 조직이 필요한 만큼 충분히 사용하면서, 낭비되는 토큰과 낮은 가치의 호출을 줄이고, 결과 단위의 성과를 높이는 것이다.

Uber 사례가 보여주는 변화는 다음과 같다.

1단계: AI를 조직에 확산한다.
2단계: 사용량의 비용 구조를 이해한다.
3단계: 업무 단위의 품질과 ROI를 최적화한다.

 

따라서 앞으로의 AI 경쟁력은 “누가 가장 강한 모델을 쓰는가”보다 누가 같은 모델 호출로 더 많은 유효 업무를 완료하는가에 가까워질 가능성이 높다.

참고 자료 및 근거 상태

작성 메모

초기 보도에서 Uber의 연간 AI 예산 소진 시점은 “1분기”와 “약 4개월”로 혼용된다. 본문에서는 단정 대신 “1분기 말부터 약 4개월 내”로 표현하는 것이 안전하다.

내가 OpenClaw/Hermes를 사용하는 사람들이 모여서 여러 이야기는 나누는 채널에 들어가 있다. 9월 한 달의 대화는 “무엇을 자동화할 수 있는가”에서 “업데이트·권한·비용·상태·복구를 어떻게 통제할 것인가”로 관심이 이동한 기록이라고 할 수 있겠다.

왜 이 주제가 중요한가

OpenClaw는 콘텐츠 자동화와 문서 작성만을 위한 도구로 소비되지 않았다. 대화방에서는 개발, 교육, 병원 업무, IoT 제어, 원격 운영까지 활용 범위가 넓어졌다. 그러나 에이전트가 실제 업무와 외부 시스템에 연결될수록 성능보다 운영 경계가 먼저 문제가 된다.

이번 기록에서 반복된 질문은 다음과 같다.

  • 업데이트 뒤 Gateway와 기억·스킬이 정상적으로 남아 있는가?
  • 구독 OAuth와 API는 무엇이 다르고, 비용과 계정 제재 위험은 누가 부담하는가?
  • 여러 에이전트를 어떻게 병렬 협업시키되 중복 응답과 상태 충돌을 막는가?
  • Slack·Discord·Telegram·KakaoTalk 중 어디에 어떤 업무를 배치할 것인가?
  • 에이전트에게 파일 쓰기·브라우저·외부 발송 권한을 어디까지 줄 것인가?

1. 활용 범위는 빠르게 넓어졌다

대화에서 확인되는 활용 사례는 다음과 같다.

  • SNS·유튜브·홈페이지·상품화 콘텐츠 자동화
  • 문서 작성, Markdown-to-Word 변환, 메일 처리
  • 코드 작성·리뷰·업데이트·장애 복구

여기까지는 기존의 사용성으로 보인다. 흥미로운 것은 다음의 것들이다.

  • 교육 자료·웨비나 녹취·Q&A 정리
  • 병원·연구 업무 보조
  • IoT 조명·커튼 등 물리 환경 제어
  • 여러 모델과 여러 봇을 이용한 역할 분담

캐릭터와 반려동물 세계관은 기술 진입장벽을 낮추는 장치로 작동했다. 사용자는 봇을 단순한 명령 인터페이스가 아니라 “키우는 작업 파트너”로 받아들였고, 그 결과 비개발자도 세팅·권한·스킬·메모리 개념을 학습하기 시작했다.

다만 활용 범위가 넓어질수록 실패의 외부 영향도 커진다. 게시·삭제·결제·IoT 제어 같은 작업은 자동 실행보다 승인 게이트가 필요하다.

2. 업데이트는 기능 추가가 아니라 복구 훈련이었다

9월 초 업데이트 경험담에서 다음 문제가 연속적으로 등장했다.

  • Gateway가 멈추거나 봇이 응답하지 않음
  • 초기화 뒤 이전 기억과 memory 경로가 달라짐
  • SQLite 전환 과정에서 일부 봇과 OAuth 목록이 끊김
  • 사용자 스킬과 대시보드 설정이 사라짐
  • 롤백과 재시작만으로 해결되지 않아 다른 에이전트로 복구를 시도함
  • API 모델을 복구 작업에 장시간 사용해 비용이 커짐

일부 사용자는 openclaw doctor --fix를 여러 번 실행해야 했고, Hermes도 정식 버전 배포와 업데이트 안정성에 의문이 남아 있다고 보고했다. 이 문장은 이승범님의 운영 경험과 커뮤니티 사례를 분리해 다뤄야 한다. 현재 후보 단계에서 “OpenClaw 전체의 일반적 결함”으로 확대 해석하지 않는다.

업데이트 전 최소 백업 대상

  • memory/와 장기 메모리
  • skills와 사용자 정의 설정
  • OAuth·provider 목록
  • Gateway·cron·자동화 설정
  • 채널 연결과 권한 설정

업데이트 후에는 기본 봇 하나로 Gateway, 인증, 파일 읽기·쓰기, 채널 응답, 예약 작업을 순서대로 확인하는 smoke test가 필요하다. 실패하면 추가 변경보다 로그 확인과 롤백이 먼저다.

3. 구독 OAuth와 API를 둘러싼 비용·정책 혼동

대화에서 Claude, GPT, Gemini의 구독 플랜과 API, OAuth 연결을 둘러싼 질문이 반복됐다. 참여자들은 다음을 서로 다른 방식으로 설명했다.

  • 구독 사용량과 API 토큰 비용은 별도라는 설명
  • 구독 OAuth를 OpenClaw에 연결하면 편하지만 약관·제재 리스크가 있다는 우려
  • Gemini OAuth 연결 뒤 계정이 차단됐다는 개인 경험
  • 모델 사용량 리셋, 5시간 한도, 플랜별 소모량에 대한 체감 보고
  • 비용을 줄이기 위한 fallback과 로컬 모델 라우팅

이 대목은 커뮤니티 경험의 가치와 한계를 동시에 보여준다. 실제 사용량과 차단 경험은 중요한 운영 신호지만, 특정 provider의 약관 위반 여부나 가격·리셋 정책을 증명하지는 않는다.

글에서는 provider마다 다음 다섯 항목을 별도 표로 제시하는 것이 좋다.

  1. 공식 지원되는 인증 방식
  2. 과금 주체와 비용 단위
  3. 사용량·리셋·동시성 한도
  4. 계정 제재 또는 서비스 차단 가능성
  5. 장애 시 대체 provider와 중단 조건

4. 에이전트 협업의 핵심은 ‘대화’가 아니라 ‘공유 상태’다

여러 에이전트를 같은 채널에 넣는 것만으로 협업이 만들어지지는 않았다. 실제 사례에서는 다음 문제가 발생했다.

  • 두 봇이 서로 대화하지 못함
  • 같은 메시지를 모두 읽고 모두 답함
  • 공유 DB를 붙인 뒤 둘 다 반응하는 듀얼 모드가 됨
  • sessions_send나 Allow 설정을 몰라 에이전트 간 통신이 막힘
  • 어디서 어떤 작업을 시켰는지 사용자가 잊어버림

따라서 협업 구조는 다음처럼 설계해야 한다.

사람 요청
  → 역할이 정해진 coordinator
  → 작업 큐와 공유 상태
  → 전문 agent 실행
  → 결과·로그·실패 사유 기록
  → 외부 발송/변경 전 사람 승인

역할, 트리거, 입력 채널, 출력 채널, 공유 상태, 권한, owner를 먼저 정하지 않으면 에이전트 수를 늘릴수록 통제 비용이 커진다.

5. 채널 선택은 기능보다 기록성과 책임 추적의 문제다

커뮤니티에서는 채널별 장단점이 다음처럼 논의됐다.

  • Slack: 채널·스레드 단위 병렬 업무에 유리하다는 평가
  • Discord: 스레드와 커뮤니티 접근성이 좋지만 숨김·파일 제한이 문제
  • Telegram: 토픽으로 분리할 수 있지만 Slack식 구조와는 다름
  • KakaoTalk: 진입장벽이 낮고 참여가 활발하지만 지식 관리와 백업이 별도 필요

선택 기준은 “어디가 더 편한가”보다 다음 질문이어야 한다.

  • 검색이 가능한가?
  • 보존 기간과 export 정책은 무엇인가?
  • 봇 대화와 사람 대화를 어떻게 백업하는가?
  • 스레드·토픽별 권한을 나눌 수 있는가?
  • 파일과 링크의 감사 기록이 남는가?
  • 외부 발송·삭제·게시 전 승인을 넣을 수 있는가?

채널은 대화 UI가 아니라 에이전트 운영의 관찰·기록 계층으로 봐야 한다.

잠정 결론

9월의 OpenClaw 반려봇 대화는 에이전트의 대중화가 모델 성능만으로 일어나지 않는다는 사실을 보여준다. 실제 병목은 다음 네 가지다.

  • 업데이트 가능한 상태를 안전하게 보존하는 것
  • 인증·비용·약관 경계를 이해하는 것
  • 여러 에이전트의 역할과 공유 상태를 설계하는 것
  • 채널에서 작업과 책임의 기록을 남기는 것

OpenClaw를 잘 쓰는 사람은 봇을 많이 만든 사람이 아니라, 실패해도 복구할 수 있고 어떤 권한으로 어떤 채널에서 무슨 일을 했는지 설명할 수 있는 사람에 가깝다.

+ Recent posts