7일간의 DeFi Lending Protocol 스터디 시리즈. Day 2에서는 Compound V2의 내부 구조를 코드 레벨로 파고들고, 렌딩 프로토콜의 3가지 유형을 분류한 뒤, Aave V3의 아키텍처 진화를 분석한다.
Repository: jeongseup/lending-protocol-study
Day 2 목표
- Compound V2의 CToken 상속 구조와 “Fresh” 패턴을 코드 레벨로 이해한다
- Mantissa 기반 고정소수점 연산과 Lazy Interest Accrual 메커니즘을 파악한다
- 렌딩 프로토콜을 3가지 유형(Pool-based, CDP-based, Fixed-rate)으로 분류한다
- Aave V3의 비트맵 스토리지 최적화와 아키텍처 진화를 분석한다
Compound V2 Deep Dive: CToken 상속 구조
Day 1에서 Compound V2의 핵심 파일 3개를 언급했다. Day 2에서는 CToken의 상속 구조부터 파고든다.
| |
CErc20과 CEther의 차이는 단순하다. ETH는 ERC20이 아니라 msg.value로 받아야 하므로, 자금 수수 로직만 다르고 핵심 렌딩 로직은 CToken에 모두 있다.
중요한 건 CToken이 풀 역할까지 겸한다는 점이다. mint(), borrow(), repay(), liquidate() 모두 CToken 컨트랙트에 있다. 하나의 컨트랙트가 ERC20 토큰이면서 동시에 렌딩 풀인 Monolithic 구조다.
Fresh 패턴: “이자부터 정산하고 본론으로”
Compound V2의 모든 핵심 함수 이름에 Fresh가 붙어 있다.
| |
왜 Fresh일까? **“이자를 최신 상태로 정산한 뒤에 핵심 로직을 실행한다”**는 패턴이다.
| |
모든 external 함수가 이 패턴을 따른다:
| |
이렇게 하는 이유는 간단하다. 이자 계산은 마지막 업데이트 이후의 블록 수에 기반한다. 이자를 정산하지 않고 mint()을 실행하면, 기존 예치자들의 교환비율이 부정확한 상태에서 새로운 지분이 발행되어 기존 예치자가 손해를 본다.
Mantissa: Solidity의 소수점 해법
Solidity에는 부동소수점이 없다. Compound V2는 이를 Mantissa 방식으로 해결한다.
$$\text{Mantissa} = \text{실제값} \times 10^{18}$$예를 들어:
- 이자율 5% →
0.05 × 10^18 = 50000000000000000 - 교환비율 0.02 →
0.02 × 10^18 = 20000000000000000
곱셈과 나눗셈 시 스케일링을 맞춰야 한다:
| |
이 패턴은 Compound만의 것이 아니다. Aave의 RAY(10^27), Uniswap V3의 Q96(2^96), MakerDAO의 WAD(10^18) 모두 같은 원리다. 정수로 소수점을 표현하기 위한 고정소수점 연산.
Lazy Interest Accrual: 이자는 “필요할 때만” 계산한다
accrueInterest()는 매 블록마다 자동으로 실행되는 게 아니다. 누군가 트랜잭션을 보낼 때만 실행된다. 이것이 Lazy Evaluation이다.
| |
200 블록 동안 아무도 상호작용하지 않으면, 200 블록치의 이자가 한번에 계산된다. 가스비는 트랜잭션 수가 아니라 상호작용 빈도에 비례한다.
accrueInterest()가 업데이트하는 상태 변수는 딱 4개다:
| |
4개의 SSTORE = 20,000 gas. 사용자 수가 아무리 많아도 이 비용은 고정이다. 이것이 Day 1에서 설명한 Scaled Balance 패턴의 실제 구현이다.
이자 시나리오 시뮬레이션
구체적인 숫자로 따라가보자.
초기 상태
| |
Alice가 1,000 USDC 대출
| |
100블록 후 이자 정산
| |
Alice의 실제 대출 잔액은?
$$\text{Alice 대출잔액} = \frac{\text{principal} \times \text{현재 borrowIndex}}{\text{Alice의 interestIndex}} = \frac{1{,}000 \times 1.01}{1.0} = 1{,}010 \text{ USDC}$$Alice의 Storage를 건드리지 않고도 전역 borrowIndex 하나만으로 정확한 잔액을 계산했다.
렌딩 프로토콜의 3가지 유형
Day 1에서는 Pool-based 모델만 다뤘다. 실제로 렌딩 프로토콜은 자금 조달 방식에 따라 3가지로 분류된다.
1. Pool-based (Compound, Aave)
| |
- 기존 예치금에서 대출
- 예치자가 없으면 빌릴 수 없다
- 이자율은 Utilization Rate에 연동
2. CDP-based (MakerDAO)
| |
- 프로토콜이 새 토큰을 발행 (mint)
- 예치자 없이도 대출 가능
- Stability Fee(이자)는 거버넌스가 결정
- CDP = Collateralized Debt Position
3. Fixed-rate (Yield Protocol)
| |
- 할인된 미래 가치 토큰 매매
- 0.95 fyUSDC를 사면, 만기에 1 USDC → 약 5% 고정 금리
- 이자율이 트레이딩으로 결정
비교 정리
| 특성 | Pool-based | CDP-based | Fixed-rate |
|---|---|---|---|
| 자금 출처 | 예치자 | 프로토콜 발행 | 시장 매매 |
| 이자율 결정 | 알고리즘 (사용률) | 거버넌스 투표 | 시장 수급 |
| 변동/고정 | 변동 | 반고정 | 고정 |
| 대표 | Compound, Aave | MakerDAO | Yield, Notional |
| 필요 조건 | 유동성 공급자 | 없음 (mint) | 만기 매칭 |
대부분의 메이저 프로토콜이 Pool-based인 이유: 구현이 단순하고, 유동성이 자연스럽게 형성되기 때문이다. CDP는 토큰 경제 설계가 복잡하고, Fixed-rate는 만기 매칭이 어렵다.
Aave V3 아키텍처: Compound에서 무엇이 진화했나
Compound V2는 CToken이 모든 걸 한다. Aave V3는 이를 분리했다.
| |
delegatecall + Library 패턴
Aave V3의 Pool.sol은 얇은 프록시에 가깝다. 실제 로직은 Library 컨트랙트에 있다.
| |
Library를 쓰면:
- 코드 크기 제한(24KB) 회피 - Pool의 로직이 아무리 커도 Library에 분산
- 관심사 분리 - Supply, Borrow, Liquidation 로직이 독립적으로 관리
- 단, Library는 독립 배포 후 업그레이드 불가 - Proxy 패턴과 조합하여 해결
Debt Tokenization
Compound V2에서 대출은 단순 매핑이다:
| |
Aave V3는 대출도 토큰화했다:
| |
부채를 토큰화하면 무엇이 좋은가?
balanceOf()로 대출 잔액 조회 가능 (표준 인터페이스)- Transfer 제한으로 부채 이전 방지
- 이벤트 표준화 (Transfer 이벤트로 대출/상환 추적 가능)
Aave V3.2부터 Stable Rate이 제거되었다. 변동금리만 남았으므로
stableDebtToken은 더 이상 사용되지 않는다.
비트맵 스토리지 최적화
Day 2의 가장 중요한 기술적 발견이다. Compound V2와 Aave V3의 사용자 상태 관리 방식이 근본적으로 다르다.
Compound V2: 배열 순회
| |
문제: 사용자가 참여한 마켓이 많을수록 가스비가 선형으로 증가한다.
Aave V3: 비트맵
| |
비트맵의 구조:
| |
Health Factor 계산 시:
| |
성능 비교
| |
비트 연산(AND, SHIFT)은 3 gas다. 128개 자산을 확인해도 2,100 + 128 × 3 = 2,484 gas. 배열 방식 대비 약 20배 절약이다.
이 차이가 결정적인 이유: Health Factor 계산은 모든 트랜잭션에서 발생한다. supply, borrow, repay, liquidate 전부. 가장 빈번하게 호출되는 로직의 가스를 20배 줄이면 프로토콜 전체의 가스 효율이 극적으로 개선된다.
LendingPool 리팩토링: 배운 것을 적용하기
스터디 프로젝트의 LendingPool.sol에 Aave V3의 PoolStorage 패턴을 적용했다.
Before: 단순 매핑
| |
After: 구조화된 스토리지
| |
핵심 변경점:
_reserves: 자산 주소 → ReserveData 매핑 (모든 자산 상태를 하나의 구조체로)_reservesList: reserveId → 자산 주소 매핑 (비트맵 인덱싱용)_usersConfig: 사용자별 비트맵 (담보/대출 상태)
비트맵 헬퍼도 구현했다:
| |
리팩토링 후 모든 62개 테스트(기존 61 + 비트맵 테스트 1)가 통과했다.
JumpRateModel 테스트: Fuzz Testing까지
Day 1에서 이론으로 다룬 JumpRateModel을 실제로 구현하고 테스트했다.
Unit Test: 18개 케이스
| |
Fuzz Test: 6개 케이스
| |
Fuzz Testing으로 검증한 불변 조건:
- 단조 증가: 사용률이 올라가면 이자율도 올라간다
- Supply ≤ Borrow: Supply Rate은 항상 Borrow Rate 이하
- Kink에서의 연속성: kink 전후로 이자율이 비연속적으로 뛰지 않는다
전체 24개 테스트 (18 unit + 6 fuzz) 모두 통과.
Day 2 핵심 인사이트 정리
Fresh 패턴은 렌딩의 정합성을 보장한다 - 이자를 먼저 정산하지 않으면 교환비율이 부정확해져서 기존 사용자가 손해본다. “이자부터, 그 다음 본론"이 원칙.
Lazy Interest Accrual은 가스 효율의 핵심 - 매 블록이 아니라 트랜잭션이 발생할 때만 이자를 계산한다. 4개의 SSTORE로 전체 사용자의 이자를 한번에 정산.
렌딩은 3가지 유형 - Pool-based(예치금에서 빌림), CDP-based(새 토큰 발행), Fixed-rate(할인 토큰). 대부분이 Pool-based인 이유는 구현 단순성과 유동성 형성의 용이함.
비트맵이 아키텍처를 결정한다 - Compound의 배열 vs Aave의 비트맵. 한 번의 SLOAD로 128개 자산 상태를 확인하는 것은 단순한 최적화가 아니라, Health Factor 계산이 모든 트랜잭션에서 발생하기 때문에 프로토콜 전체 가스 효율을 좌우한다.
Storage 레이아웃이 아키텍처를 결정한다 - Compound는 배열 중심이라 Monolithic이 자연스럽고, Aave는 비트맵 + 구조체 중심이라 Modular가 자연스럽다. 데이터 구조가 코드 구조를 결정한다.
Next: Day 3
내일은 청산(Liquidation) 메커니즘을 파고든다.
- 청산 트리거 조건과
liquidateBorrowFresh()코드 리딩 - 청산 보너스(Liquidation Incentive) 경제학
- Foundry Fork Testing으로 Aave V3 메인넷 청산 시뮬레이션
- 고급 Foundry 테스팅 패턴 (invariant, fork)
이 시리즈의 전체 코드와 학습 자료는 lending-protocol-study 레포에서 확인할 수 있다.