Uniswap V4 Deep Dive | 1 — 코드를 통해 알아보는 V4
서울대학교 블록체인 학회 디사이퍼(Decipher)에서 Uniswap V4에 대한 글을 시리즈로 연재합니다. 본 시리즈는 Uniswap V4에 대한 전체적인 코드 분석으로부터 시작하여, V4의 전망과 한계점에 대한 분석까지 V4 전반을 모두 다루고…
Uniswap V4 Deep Dive | 1 — 코드를 통해 알아보는 V4
서울대학교 블록체인 학회 디사이퍼(Decipher)에서 Uniswap V4에 대한 글을 시리즈로 연재합니다. 본 시리즈는 Uniswap V4에 대한 전체적인 코드 분석으로부터 시작하여, V4의 전망과 한계점에 대한 분석까지 V4 전반을 모두 다루고 있습니다. 글에 들어가기 앞서서, 필자는 Uniswap와 아무런 연관이 없음을 밝힙니다.

Author
전승민(@organ_mo) Seoul Nat’l Univ. Blockchain Academy Decipher(@decipher-media) Reviewed By Decipher Media Team 임요한, 박찬우
**Uniswap V4 Series
- **Uniswap V4 Deep Dive | 1 — 코드를 통해 알아보는 V4
- Uniswap V4 Deep Dive | 2 — Uniswap V4의 현 상황과 전망
1. Background - Uniswap의 변천사
2017년 출시된 Uniswap은 V1부터 현재 V3까지 약 6년에 걸쳐 프로토콜을 발전시켜왔다. 그 변천 과정을 정리하면 다음과 같다.
처음 등장하였던 Uniswap V1은 아주 단순한 AMM(Automated Market Maker)이었고, ETH와 토큰 간의 페어만 지원하여 A 토큰과 B 토큰 간 거래를 하려면 ETH를 거쳐야 하는 비효율이 발생하였다.
Uniswap V2는 토큰과 토큰 간 거래가 가능하도록 풀을 만들었고, ETH를 ERC20 토큰으로 랩핑한 WETH로 풀을 구성하여 V1의 문제를 해결하였다. 그러나, 모든 가격대에 유동성이 균등하게 분배되어, 풀 규모에 비해 스왑에 쓰이는 유동성은 극히 일부라는 게으른 유동성(Lazy Liquidity) 문제가 존재하였다.
Uniswap V3는 집중화된 유동성을 사용하여, 유동성 공급자들이 각각 유동성이 적용될 범위를 결정할 수 있게 하여 현재 가격 중심으로 유동성을 모으고 자본 효율성을 높이는 성과를 달성하였다.

(Concentrated Liquidity in Uniswap V3 | 출처: Uniswap V3 whitepaper)
한편 이를 위해 V3는 틱(Tick)을 중심으로 하는 복잡한 메커니즘이 도입되었다. 이는 자본 효율성을 높인다는 장점이 존재하였지만, 가스 수수료를 증가시키는 부작용을 낳았다.
그리고 올해 6월, Uniswap은 V4를 발표하였다. 과연 Uniswap V4는 어떤 문제를 해결하고 있을까?Uniswap V4가 가져올 효용이나 부작용, 그리고 앞으로 해결해야 하는 문제들에는 무엇이 있을까?
이 글은 이러한 의문점들에 대해, 공개된 코드를 기반으로 자세히 분석하여 답을 내리는 것을 목표로 하여, 최대한 깊고 기술적인 분석을 수행한다. 총 두 편으로 구성되어, 첫 번째 편은 공개된 코드를 기반으로 Uniswap V4의 기능에 대한 분석을 진행하고, 두 번째 편은 이를 기반으로 Uniswap V4를 둘러싼 논란, 전망 등에 대해 이야기한다. 만약 Solidity에 익숙하지 않다면, 다음 링크에 있는 2편으로 넘어가기 바란다.
2. V4 Architecture
2.1. Prerequisite : 기본 용어 정의
우선 V4 메커니즘 분석으로 들어가기 전에, V4에서 사용되는 용어들을 기반으로 이해에 필요한 개념을 먼저 알아보자.
- Tick
Tick은 Uniswap V3의 집중화된 유동성에서 가격을 표현하는 단위를 의미한다. Uniswap V3에서는 다음과 같이 가격을 표현한다.

여기서 밑으로 사용되는 1.0001은 금융에서의 Basis point를 나타낸 것으로, 한 틱만큼 움직일 때마다 1 Basis Point만큼 가격이 움직이도록 설계한 것이다. Solidity에서는 소숫점 이하의 값을 표현하지 못하기 떄문에, Uniswap V3에서는 앞 64비트에 정수부, 뒤 96비트에 소수부를 저장하는 Q64.96 자료형을 사용한다. Tick의 연산에 대한 자세한 내용은 Uniswap V3 Book을 참고하기 바란다.

(Tick Bitmap Indexing | 출처: Uniswap V3 Book)
Tick은 가격을 표현할 뿐만 아니라, 수수료 및 유동성 계산의 기준점이 된다. Uniswap V3에서는 틱으로 하여금 해당 틱 외부에서 계산된 수수료와 틱 내부의 유동성을 트래킹하도록 하고, 이 구조는 V4에서도 동일하게 이어진다.
- Singleton
Singleton은 코드 디자인 패턴 중 하나로, 특정 클래스에 오직 하나의 인스턴스만 존재하는 형태를 의미한다. 원래 유니스왑은 V3까지 Factory 패턴을 사용하여, 페어를 만들고 싶으면 Factory 컨트랙트를 통해 새로운 풀 컨트랙트를 배포하게끔 했다. 그러나, 여기에는 풀 배포와 스왑 Routing에 많은 가스비가 든다는 문제점이 존재하였다.
현재 Uniswap V4의 코드는 모든 풀을 PoolManager 컨트랙트 안에 담는다. 이러한 Singleton 아키텍처를 통해, 풀 배포와 스왑 Routing 작업에 있어 많은 가스비를 아낄 수 있게 된다.
- Hook
훅Hook은 풀에 추가될 수 있는 일종의 플러그인이다. Hook은 일반적으로 특정 이벤트가 발생했을 때 실행되는 로직을 담은 함수를 의미한다. 유니스왑에서는 Hook을 트리거하는 이벤트를 풀 배포 / 유동성 공급 및 회수 / 스왑 세 가지로 나누고, 이에 따른 8가지의 Hook을 정의하고 있다.

이를 통해 유동성 풀은 단순히 스왑만 제공하는 것이 아니라, 지정가 주문 / MEV 수익 공유 / LP 수익 극대화 등 다양한 기능을 제공할 수 있게 되고, V4는 이를 기반으로 일종의 생태계를 구축할 수 있게 된다.
- Flash Accounting
Flash Accounting은 EIP-1153을 통해 값싸고 안전한 방식으로 풀 관련 작업을 수행하는 것을 의미한다. 다음 이더리움 하드포크인 Cancun-Deneb(DenCun) 업데이트에서 적용될 예정인 EIP-1153은 transient storage(임시 스토리지)를 도입하기 위한 제안이다. Transient storage는 기존 스토리지와 똑같은 방식으로 동작하지만, 트랜잭션이 끝나는 동시에 저장된 데이터가 모두 초기화되는 스토리지이다. 매 트랜잭션이 끝나면 데이터가 삭제되는 것과 같기 때문에 해당 스토리지는 결과적으로 이더리움 클라이언트의 저장 용량을 증가시키지 않는 상태에서 컴퓨팅 리소스만 소모하고, 일반 스토리지에 대한 opcode보다 최대 20배 적은 가스(100 GAS)를 소모한다. Uniswap V4는 이를 통해 트랜잭션 중 연산 및 확인 작업을 값싸고 안전하게 수행할 수 있다.
이전에는 매 풀 작업마다 풀 간 토큰을 주고 받는 방식이었지만, V4에서는 singleton 아키텍처와 flash accounting을 기반으로 내부 잔고만을 조정하여 마지막에 한꺼번에 전송하는 방식을 택한다. 이 방식은 가스비를 굉장히 많이 줄여주고, Routing이나 아토믹 스왑 등의 작업을 훨씬 쉽게 만든다.
- Callback
콜백 함수는 외부에서 로직의 일부분을 받아와서 실행하는 함수이다. 이는 주로 코드 가변성과 추상화를 위해서 사용된다.
Uniswap에서는 풀 내 스왑 함수를 외부의 다양한 컨트랙트들이 호출할 수 있다. 대표적으로 Uniswap V3의 유동성 포지션을 자동으로 조정해주는 Arrakis Finance, Gamma, Bunni 등과 같은 디파이 프로토콜들을 예시로 들 수 있다. 여기서 Uniswap은 콜백 함수를 구현해 놓고, 해당 프로토콜들이 각자만의 로직을 담은 콜백 컨트랙트를 알아서 구현할 수 있게끔 해서 컨트랙트의 활용성을 높인다. 즉, 누구든 Uniswap의 코어 로직을 레버리지할 수 있게 하여 모듈성을 높이는 형태이다.
2.2. Data Structures used in Uniswap V4
Uniswap V4는 많은 자료구조들이 존재하지만, 이 글에서는 꼭 알아야 할 struct 세 가지만 뽑아 분석하였다.
- PoolKey struct (PoolManager.sol)

PoolManager 컨트랙트 내에서 각 풀을 식별하는 데에 쓰이는 struct이다. 풀에 어떤 토큰이 들어가는지, 스왑 수수료는 얼마인지, 틱 간격은 얼마인지, 그리고 어떤 Hook을 쓰는지가 들어간다. PoolKey struct를 해시해서 풀의 ID가 결정되고, 이는 각 풀을 구분하는 데에 쓰인다.
여기서 주의할 점은 풀 식별자에 Hook의 인터페이스(IHooks)가 들어간다는 점이다. 즉 Uniswap V4의 각 풀은 정확히 한 개의 Hook만을 가질 수 있다. 또한, 구성이 완벽히 같지만 서로 다른 Hook을 가진 여러 개의 풀이 존재할 수 있다. 예를 들어, 온체인 오라클 Hook을 가진 USDC-ETH 풀과 지정가 주문 Hook을 가지는 USDC-ETH 풀이 공존할 수 있다.
또한 기존 Uniswap V3에서 풀 수수료를 0.05%, 0.3%, 1%로 제한하였던 것과 달리, Uniswap V4에서는 수수료에 제한이 없다. 따라서 구성이 완벽히 같지만 서로 다른 수수료를 가진 풀이 무수히 많이 존재할 수 있다.
- Slot0 struct (Pool.sol)

풀의 상태를 나타내는 struct이다. V3와 비교했을 때, 오라클 관련 정보와 재진입 공격을 방지하는 unlocked flag가 사라지고, 수수료 관련 정보들이 포함되었다. 이 struct는 풀이 배포될 때(initialize) 초기화된다. 수수료 관련 변수들은 분모에 들어갈 값을 나타낸다(ex. protocolSwapFee = 20이라면 1/20 = 5%가 프로토콜 스왑 수수료로 책정된다).
주목할 점은 두 가지이다. 우선 기존 V3에서 없었던 protocolWithdrawFee가 새로 등장하였다. Uniswap DAO는 작년 12월과 올해 5월 두 번에 걸쳐 프로토콜 수수료를 걷자는 Fee Switch에 대해 투표를 진행하였고, 법적인 이슈를 걱정한 일부 투표자들에 의해 두 번 모두 거절되었다. V4에서 새로 등장한protocolWithdrawFee 로 인해, 해당 논의가 지속될지 지켜보아야 할 것이다.
두 번째는 Hook에도 동일하게 스왑 혹은 유동성 회수 시 수수료를 걷는 기능이 존재한다는 것이다. 이는 2부에서 자세히 다뤄보도록 하겠다.
- LockState struct (PoolManager.sol)

LockState는 사용자가 풀에 얼만큼 빚졌는지(Owed)를 나타내는 struct이다. 여기서 빚졌다는 것의 의미는 V4의 핵심 메커니즘인 flash accounting과 관련이 있다.
Uniswap V4는 swap, modifyPosition 등의 함수에서 직접 토큰 전송을 수행하지 않고, 상태변수를 업데이트하여 결과를 반환하는 ‘계산만’ 수행한다. 모든 토큰 전송은 lockAcquired 콜백 함수 내에서 별도로 정해둔 로직을 따라 처리된다.
따라서 swap, modifyPosition 등의 함수를 실행하고 나면 ‘풀로 보내야 할’ 또는 ‘풀에게 받아야 할’ 금액이 존재하게 된다. 이를 LockState 내에 currencyDelta에 저장하여, 나중에 **take**와 **settle** 함수를 통해 정산하게 된다.
여기서 nonzeroDeltaCount는 정산되지 않은 토큰의 종류가 총 몇 개인지를 의미하고, currencyDelta는 정산되지 않은 각 토큰이 몇 개 남았는지를 의미한다.
2.3. Architecture & Function Analysis
2.3.1. PoolManager

(Overview of V4 PoolManager)
PoolManager.sol의 목적을 한 마디로 요약하자면, ‘작업이 끝났을 때 풀과 사용자 모두 서로에게 빚진 토큰이 없도록 함’이다. 이를 위해 PoolManager 컨트랙트는 작업 과정을 1) 계산 2) 빚 청산 의 두 부분으로 나눈다. 이에 따라 각각의 메서드도 아래와 같이 목적이 나뉘어져 있다.
- 핵심 계산 로직을 담은 메서드 -
initialize,swap,modifyPosition,donate - 계산 결과를 정산하고 실제로 토큰을 주고받도록 하는 메서드 -
settle,take,lock
계산 로직이 담긴 메서드
이 메서드들은 Pool 라이브러리에 대한 호출을 통해 대부분의 작업이 이루어진다. 이는 핵심 계산 로직을 단일 인스턴스로 캡슐화 하기 위함이다. 라이브러리는 컨트랙트와 유사하지만, 특정 주소에 한 번만 배포되어서 DELEGATECALL 을 통해 계속 재사용된다. Uniswap V4는 계산 과정에서 복잡한 로직을 포함하게 되는데, 이를 별도의 Pool 라이브러리에 넣어 코드 가독성을 높이고 로직을 일원화하겠다는 의도이다.
**initialize**
function initialize(PoolKey memory key, uint160 sqrtPriceX96) external override returns (int24 tick) {
...
PoolId id = key.toId();
(uint8 protocolSwapFee, uint8 protocolWithdrawFee) = _fetchProtocolFees(key);
(uint8 hookSwapFee, uint8 hookWithdrawFee) = _fetchHookFees(key);
tick = pools[id].initialize(sqrtPriceX96, protocolSwapFee, hookSwapFee, protocolWithdrawFee, hookWithdrawFee);
...
}
풀을 배포할 때 호출하는 메서드로, 위에서 설명한 PoolKey struct를 입력값으로 받아 풀의 State를 불러온 후, 풀을 초기화한다. 풀 초기화는 입력값으로 받은 가격 값에 해당하는 Tick을 초기화하는 작업을 의미한다. 해당 작업은 Pool 라이브러리 내 initialize 함수에 의해 이루어진다.
function initialize(
State storage self,
uint160 sqrtPriceX96,
uint8 protocolSwapFee,
uint8 hookSwapFee,
uint8 protocolWithdrawFee,
uint8 hookWithdrawFee
) internal returns (int24 tick) {
if (self.slot0.sqrtPriceX96 != 0) revert PoolAlreadyInitialized();
tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96);
self.slot0 = Slot0({
sqrtPriceX96: sqrtPriceX96,
tick: tick,
protocolSwapFee: protocolSwapFee,
hookSwapFee: hookSwapFee,
protocolWithdrawFee: protocolWithdrawFee,
hookWithdrawFee: hookWithdrawFee
});
}
위 코드에서는 풀 배포 시 결정되는 가격과 수수료 정보를 기반으로 Slot0 정보를 채워넣어 풀을 초기화하는 모습을 확인할 수 있다. 새로 풀 컨트랙트를 배포해야 했던 V3와는 다르게, 풀 배포의 로직이 매우 단순하게 이루어져 있다.
**swap**
Pool 라이브러리의 swap 함수를 호출해서 핵심 로직을 실행하는 함수이다. Pool 라이브러리 내의 swap 함수를 호출하여 계산을 수행한다.
function swap(PoolKey memory key, IPoolManager.SwapParams memory params)
external
override
noDelegateCall
onlyByLocker
returns (BalanceDelta delta)
{
...
Pool.SwapState memory state;
PoolId id = key.toId();
(delta, feeForProtocol, feeForHook, state) = pools[id].swap(
Pool.SwapParams({
fee: totalSwapFee,
tickSpacing: key.tickSpacing,
zeroForOne: params.zeroForOne,
amountSpecified: params.amountSpecified,
sqrtPriceLimitX96: params.sqrtPriceLimitX96
})
);
_accountPoolBalanceDelta(key, delta);
// the fee is on the input currency
...
}
V3에서와 마찬가지로, Pool 라이브러리의 swap 함수는 스왑이 끝날 때까지 계산을 수행한다(while 문). 스왑이 끝나는 조건은 다음 두 가지이다.
- 스왑 input amount을 다 소진(스왑 완료)
- 정해놓은 가격 한도(Slippage limit)에 도달
V4에서 달라지는 점은, 스왑이 종료되면 delta 값을 반환한다는 것이다. delta는 풀에서 스왑, 유동성 공급 등의 작업을 수행했을 때 풀 내 잔고의 변화량을 의미한다. 이는 다음과 같이 구성되어 있다.
function toBalanceDelta(int128 _amount0, int128 _amount1) pure returns (BalanceDelta balanceDelta) {
/// @solidity memory-safe-assembly
assembly {
balanceDelta :=
or(shl(128, _amount0), and(0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff, _amount1))
}
}
이는 아래 그림과 같이, 작업으로 인해 조정되어야 할 amount0와 amount1을 int256 자료형에 채워 넣는다.

이 값을 바탕으로, _accountPoolBalanceDelta를 호출한다. 이 함수는 쌍을 이루는 두 토큰에 대해 각 각 delta 값(작업으로 인해 풀에게 갚아야 할 & 받아야 할 토큰의 개수)을 기록하는 함수이다.
/// @dev Accumulates a balance change to a map of currency to balance changes
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta) internal {
_accountDelta(key.currency0, delta.amount0());
_accountDelta(key.currency1, delta.amount1());
}
function _accountDelta(Currency currency, int128 delta) internal {
if (delta == 0) return;
LockState storage lockState = lockStates[lockedBy.length - 1];
int256 current = lockState.currencyDelta[currency];
int256 next = current + delta;
unchecked {
if (next == 0) {
lockState.nonzeroDeltaCount--;
} else if (current == 0) {
lockState.nonzeroDeltaCount++;
}
}
lockState.currencyDelta[currency] = next;
}
_accountDelta 함수에서는 입력받은 delta 값을 기반으로 lockState struct를 조정한다. 스왑 후 delta 는 0이 아닐 것이기 때문에, 여기서는 nonzeroDeltaCount에 1이 더해지고 currencyDelta에 delta 만큼이 더해질 것이다.
여기서 기록된 nonzeroDeltaCount와 currencyDelta 값은 향후 settle, take 함수에서 정산된다.
**modifyPosition**
유동성 공급/수정/회수 등의 핵심 로직을 실행하는 함수이다. swap 함수와 마찬가지로 Pool 라이브러리의 modifyPosition ****함수를 호출해 delta 값을 반환하고, 풀 정보에 반영한다.
Pool 라이브러리 내 modifyPosiition 함수는 지정된 틱을 초기화하고, 수수료 정보를 업데이트 한 후, 풀의 delta 값을 반환하는 세 가지 작업으로 구성된다.
- 지정된 틱 활성화
사용자가 유동성을 공급/회수하고자 하는 범위를 확인해서 해당 틱들을 활성화(
flipTick)한다. - 수수료 정보 업데이트 해당 LP 포지션 내에 축적된 수수료 정보를 불러온다. 이는 유동성 회수나 범위 수정 등의 작업이 이루어질 때 필요한 수수료 정보를 가져온다.
- 풀의
delta값 계산 그런 다음 풀의delta값을 계산하여 반환값인 result에 더해준다.
반환된 delta 값은 swap 함수에서와 마찬가지로 _amountPoolBalanceDelta 함수를 통해 lockState struct에 기록되고, 이후 settle 및 take 함수를 통해 정산이 이루어진다.
**donate**
이 함수는 V4에서 새롭게 추가된 기능으로, 풀로 토큰을 기부하여 LP에게 인센티브를 주는 역할을 수행한다. swap, modifyPosition 과 마찬가지로 delta 값을 구하는 데에 초점을 맞추고, Pool 라이브러리 내 donate 함수 내에서 아래와 같이 핵심 로직이 실행된다.
function donate(State storage state, uint256 amount0, uint256 amount1) internal returns (BalanceDelta delta) {
if (state.liquidity == 0) revert NoLiquidityToReceiveFees();
delta = toBalanceDelta(amount0.toInt128(), amount1.toInt128());
unchecked {
if (amount0 > 0) {
state.feeGrowthGlobal0X128 += FullMath.mulDiv(amount0, FixedPoint128.Q128, state.liquidity);
}
if (amount1 > 0) {
state.feeGrowthGlobal1X128 += FullMath.mulDiv(amount1, FixedPoint128.Q128, state.liquidity);
}
}
}
기부자가 보낸 금액은 축적된 풀 수수료에 더해진다. 이는 향후 TWAMM에 필요한 유동성을 공급하는 LP들에게 팁을 주거나, 새로운 수수료 체계를 만드는 등의 사례에 활용될 수 있다.
swap 과 modifyPosition, donate 함수는 모두 실제 토큰 전송을 수행하지 않고 작업으로 인해 변해야 할 토큰의 수인 delta 값을 계산하는 기능만을 수행한다. 계산된 delta 값이 어떠한 과정을 거쳐 정산되는지 알아보자.
계산 결과를 정산하고 토큰을 주고 받는 메서드
**settle**
function settle(Currency currency) external payable override noDelegateCall onlyByLocker returns (uint256 paid) {
uint256 reservesBefore = reservesOf[currency];
reservesOf[currency] = currency.balanceOfSelf();
paid = reservesOf[currency] - reservesBefore;
// subtraction must be safe
_accountDelta(currency, -(paid.toInt128()));
}
settle은 사용자가 빚진 금액을 갚는 함수이다. settle 함수는 사용자가 PoolManager 컨트랙트로 토큰을 보내는 작업이 수행된 이후에 호출된다. 위 함수의 계산 과정을 예시로 표현하면 다음과 같다.
사용자가 1 ETH를 PoolManager에 빚진 상황이고, 풀의 현재 ETH 잔고(reserveOf[currency])는 5 ETH라고 생각해보자. 사용자는 1 ETH를 PoolManager로 보내면서 settle 함수를 호출한다. 그러면 다음과 같은 계산 작업이 이루어진다.
첫 번째 줄: 아직 reserveOf[currency] 값은 업데이트되기 전이기 때문에 5 ETH일 것이므로, reserveBefore = 5 이다.
두 번째 줄: PoolManager의 잔고 값을 업데이트하는 balanceOfSelf() 함수를 호출한다. 이때 reserveOf[currency]의 값은 6 ETH로 업데이트 될 것이다.
세 번째 줄: paid = 6 - 5 = 1이다.
네 번째 줄: currency = ETH, paid = 1을 입력값으로 전달하여 _accountDelta를 호출한다.
여기서 호출되는 _accountDelta는 swap이나 modifyPosition으로 인해 lockState에 기록되었던 delta 값을 정산하는 역할을 수행한다.
function _accountDelta(Currency currency, int128 delta) internal {
if (delta == 0) return;
LockState storage lockState = lockStates[lockedBy.length - 1];
int256 current = lockState.currencyDelta[currency];
int256 next = current + delta;
unchecked {
if (next == 0) {
lockState.nonzeroDeltaCount--;
} else if (current == 0) {
lockState.nonzeroDeltaCount++;
}
}
lockState.currencyDelta[currency] = next;
}
이 함수가 호출되면, 풀과 사용자 모두 서로에게 빚진 토큰이 없는 (delta = 0) 상태가 되고, 풀 작업이 정상 종료될 수 있는 조건이 맞추어진다.
**take**
function take(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
_accountDelta(currency, amount.toInt128());
reservesOf[currency] -= amount;
currency.transfer(to, amount);
}
이는 사용자가 풀로부터 받아야 할 토큰을 정산 및 전송하는 함수이다. settle과 마찬가지로 _accountDelta 함수를 통해 토큰을 정산하고, 풀에서 사용자로 토큰을 전송하는 기능을 수행한다.
**lock & `lockAcquired`**
/// @notice All operations go through this function
/// @param data Any data to pass to the callback, via `ILockCallback(msg.sender).lockCallback(data)`
/// @return The data returned by the call to `ILockCallback(msg.sender).lockCallback(data)`
function lock(bytes calldata data) external override returns (bytes memory result) {
uint256 id = lockedBy.length;
lockedBy.push(msg.sender);
// the caller does everything in this callback, including paying what they owe via calls to settle
result = ILockCallback(msg.sender).lockAcquired(id, data);
...
}
lock 함수는 Uniswap V4의 모든 동작이 이루어지는 시발점이다. 이 함수는 호출자를 lockedBy array에 추가한다. lockedBy 는 풀과의 ‘채무’ 관계가 있는 사용자들의 목록을 의미하며, 트랜잭션이 정상적으로 끝나면 호출자는 이 array에서 삭제된다.
그런 다음, ILockCallback에 있는 lockAcquired 함수를 호출한다.
lockAcquired 함수에서 사용자와 상호작용하는 핵심 로직이 실행되며, 이 함수는 콜백 컨트랙트에서 구현된다. 현재는 표준으로 구현된 콜백 컨트랙트가 없기에, Uniswap V4 Github에 있는 테스트 코드를 기반으로 알아보자.
function lockAcquired(uint256, bytes calldata rawData) external returns (bytes memory) {
require(msg.sender == address(manager));
CallbackData memory data = abi.decode(rawData, (CallbackData));
BalanceDelta delta = manager.swap(data.key, data.params);
if (data.params.zeroForOne) {
if (delta.amount0() > 0) {
if (data.testSettings.settleUsingTransfer) {
if (data.key.currency0.isNative()) {
manager.settle{value: uint128(delta.amount0())}(data.key.currency0);
} else {
IERC20Minimal(Currency.unwrap(data.key.currency0)).transferFrom(
data.sender, address(manager), uint128(delta.amount0())
);
manager.settle(data.key.currency0);
}
} else {
// the received hook on this transfer will burn the tokens
manager.safeTransferFrom(
data.sender,
address(manager),
uint256(uint160(Currency.unwrap(data.key.currency0))),
uint128(delta.amount0()),
""
);
}
}
if (delta.amount1() < 0) {
if (data.testSettings.withdrawTokens) {
manager.take(data.key.currency1, data.sender, uint128(-delta.amount1()));
} else {
manager.mint(data.key.currency1, data.sender, uint128(-delta.amount1()));
}
}
...
위 코드는 콜백 컨트랙트 역할을 하는 PoolSwapTest 컨트랙트 내 lockAcquired 함수이다. 여기서 lockAcquired 함수는 우선 PoolManager 컨트랙트 내 swap 함수를 호출한다. 위에서 보았듯이, swap 함수는 잔고의 변화인 delta 값을 반환하게 된다.
예를 들어 ETH-USDC 풀이 있고 여기서 1개의 ETH를 주고 2000개의 USDC를 받는 스왑을 실행한다고 해 보자. 설정한 슬리피지 제한에 걸리지 않았다면, swap 함수를 통해 계산된 delta 값은 다음과 같을 것이다(부호는 풀에게 빚졌다면 +, 풀로부터 받아야 한다면 -이다).

그런 다음 아래 작업을 차례로 수행한다.
- 1 ETH가
IERC20.transferFrom함수를 통해 PoolManager로 이동한다. delta.amount0가 settle 함수를 통해 0으로 정산된다.take함수를 통해 2000 USDC가 사용자에게 전달되고,delta.amount1이 0으로 정산된다.
정산된 delta는 0일 것이고, lockAcquired 함수는 이 값을 다시 lock 함수 내로 반환한다. lockAcquired 콜백이 끝나 result를 반환하면, 아래 코드를 마저 실행한다.
// function lock()
unchecked {
LockState storage lockState = lockStates[id];
if (lockState.nonzeroDeltaCount != 0) revert CurrencyNotSettled();
}
lockedBy.pop();
}
여기서는 lockState 내 nonzeroDeltaCount가 0이 아니면 트랜잭션을 revert하는 일종의 안전 장치가 존재한다. 이는 아직 정산되지 않고 남은 토큰이 존재하고, lockAcquired 실행 과정에서 오류 혹은 악의적인 공격이 있었다는 것이기 때문이다. 위 예시에서는 정상적으로 스왑이 이루어졌기 때문에, lockState 내 nonzeroDeltaCount와 currencyDelta는 모두 0이다.
마지막으로 lockedBy array에서 호출자를 다시 빼내며 트랜잭션이 마무리된다.
콜백 컨트랙트가 Uniswap V4 Github 내 테스트 코드인 PoolSwapTest.sol이라고 가정하고, 사용자가 풀에 스왑 요청을 했을 때부터 스왑이 종료되었을 때까지의 전체적인 Call Flow를 정리하면 아래와 같다.

(Swap Call Flow at Uniswap V4)
- 사용자가 1 ETH를 2000 USDC로 스왑하고자 한다. 사용자는 콜백 컨트랙트로 1 ETH를 보내며, 콜백 컨트랙트 내
swap함수를 호출한다. - 이 함수는 PoolManager 컨트랙트 내
lock함수를 호출한다. lock함수는 다시 콜백 컨트랙트 내lockAcquired함수를 호출한다.lockAcquired내에서는 PoolManager 컨트랙트 내 세 개의 함수를 호출한다. 우선,swap함수를 호출한다. 이 함수는 스왑으로 인해 변한delta값(+1 ETH, -2000 USDC)을 저장한다.- 그 다음, PoolManager 컨트랙트 내로 1 ETH를 보내며
settle함수를 호출한다. 이 함수는 ETH의delta값을 0으로 정산한다. - 마지막으로
take함수를 호출한다. 이 함수는 USDC의delta값을 0으로 정산한 후, 2000 USDC를 사용자에게 전송한다. - 사용자는 USDC를 받고,
lock함수에서 lockState 내 변수가 모두 0인지 확인이 끝나면 스왑이 완료된다.
위 Call Flow에서 알 수 있듯이, 작업의 실행을 지시하는 곳은 lockAcquired 함수이다. Uniswap V4에서는 누구나 이 함수를 작성하여 콜백 컨트랙트의 형태로 배포하고 커스텀할 수 있고, 이는 Uniswap V4를 기반으로 다양한 프로젝트들이 출시될 수 있다는 것을 의미한다.
2.3.2 Hook
Hook은 V4에서 풀 생성, 스왑, 유동성 공급 및 회수 등의 이벤트가 발생한 전후로 수행되는 로직에 대해 정의한다. Hook은 PoolManager 컨트랙트 내 initialize, swap, modifyPosition 함수 내에서 실행된다. 각 함수의 실행 직후, 종료 직전의 코드를 보면 다음과 같이 Hook을 실행하는 파트가 존재한다.
// In swap() method from PoolManager.sol
if (key.hooks.shouldCallBeforeSwap()) {
if (key.hooks.beforeSwap(msg.sender, key, params) != IHooks.beforeSwap.selector) {
revert Hooks.InvalidHookResponse();
}
}
// Swap Logic
if (key.hooks.shouldCallAfterSwap()) {
if (key.hooks.afterSwap(msg.sender, key, params, delta) != IHooks.afterSwap.selector) {
revert Hooks.InvalidHookResponse();
}
}
위에서 이야기했던 대로, PoolKey struct에는 Hook의 인터페이스가 들어간다. 해당 인터페이스에서 beforeSwap, afterSwap 등 Hook이 가진 로직을 찾아 실행하는 방식이다.
Uniswap V4에는 각각의 Hook이 가진 기능을 구별하고 찾는 과정을 보다 쉽게 하기 위해, Hook 컨트랙트의 주소 앞 두 자리를 통해 기능을 특정하는 기발한 방식을 사용한다. 아래와 같이 Hook이 포함하는 기능별로 flag를 지정한 뒤, Hook 주소 맨 앞 두 자리에 flag 정보를 저장하는 것이다.

(Uniswap Hook Address Identification | 출처: Uniswap Github)
만약 Hook 컨트랙트의 주소가 0x9000000000000000000000000000000000000000 이라면, 앞 두자리는 0x90 이므로 이진법으로 1001000 이 되고, 해당 Hook은 beforeInitialize 와 afterModifiyPosition 두 가지 기능을 가지고 있다는 것을 알 수 있다. 이를 통해 Uniswap V4는 Hook 검색 및 스왑 Routing의 효율을 높일 수 있다.
Hook에 대한 이해를 높이기 위해, Uniswap V4 Github에 있는 LimitOrder (지정가 주문) Hook을 분석해보자. V4에서 지정가 주문은 매우 좁은 범위(Tick 한 단위)에 유동성을 공급하는 방식으로 동작한다. 지정가 주문 제출은 place 메서드를 통해 이루어진다.
function place(PoolKey calldata key, int24 tickLower, bool zeroForOne, uint128 liquidity)
external
onlyValidPools(key.hooks)
{
if (liquidity == 0) revert ZeroLiquidity();
poolManager.lock(
abi.encodeCall(this.lockAcquiredPlace, (key, tickLower, zeroForOne, int256(uint256(liquidity)), msg.sender))
);
...
지정가 주문을 하는 모든 주소들은 각각 본인의 주문 정보가 담긴 EpochInfo struct를 가진다. 입력받은 인자 값을 바탕으로, 각자의 EpochInfo가 저장되며 place 함수가 종료된다.
EpochInfo storage epochInfo;
Epoch epoch = getEpoch(key, tickLower, zeroForOne);
epochInfo = epochInfos[epoch];
unchecked {
epochInfo.liquidityTotal += liquidity;
epochInfo.liquidity[msg.sender] += liquidity;
}
emit Place(msg.sender, epoch, key, tickLower, zeroForOne, liquidity);
아래과 같은 ETH-USDC 풀에서 현재 ETH의 가격이 Index 0에 있고, 내 지정가 주문은 각각 Index 2와 4에 존재한다고 가정해보자.

그런 다음, 만약 큰 규모의 스왑이 발생해서 ETH의 가격이 Index 9로 상승했다고 가정해보자. 그러면 내 주문 두 개는 체결되어 공급한 ETH가 모두 USDC로 바뀔 것이고, 유동성을 모두 회수해서 주문을 마무리해야 할 것이다. 따라서 스왑 연산이 끝나면 LimitOrder 컨트랙트의 afterSwap 함수가 호출된다.
function afterSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata params, BalanceDelta)
external
override
poolManagerOnly
returns (bytes4)
{
(int24 tickLower, int24 lower, int24 upper) = _getCrossedTicks(key.toId(), key.tickSpacing);
if (lower > upper) return LimitOrder.afterSwap.selector;
bool zeroForOne = !params.zeroForOne;
for (; lower <= upper; lower += key.tickSpacing) {
Epoch epoch = getEpoch(key, lower, zeroForOne);
if (!epoch.equals(EPOCH_DEFAULT)) {
EpochInfo storage epochInfo = epochInfos[epoch];
epochInfo.filled = true;
(uint256 amount0, uint256 amount1) = abi.decode(
poolManager.lock(
abi.encodeCall(this.lockAcquiredFill, (key, lower, -int256(uint256(epochInfo.liquidityTotal))))
),
(uint256, uint256)
);
unchecked {
epochInfo.token0Total += amount0;
epochInfo.token1Total += amount1;
}
setEpoch(key, lower, zeroForOne, EPOCH_DEFAULT);
emit Fill(epoch, key, lower, zeroForOne);
}
}
이 함수는 스왑으로 인해 바뀐 가격과 현재 가격 사이에 있는 모든 지정가 주문이 처리되었음을 알리고 유동성을 회수하는 역할을 수행한다. 회수는 PoolManager의 lock 함수를 호출하여 실행된다.

기존 V3에서도 이러한 형태의 지정가 주문이 가능하였지만, 유동성 공급과 회수를 사용자가 직접 실행해야 한다는 불편함이 존재하였다. V4에서는 LimitOrder Hook을 통해 이 과정을 자동화함으로써, 사용자의 UX를 개선하고 지정가 주문을 위한 인프라를 제공할 수 있다.
이 뿐만 아니라, Hook에는 매우 다양한 기능이 들어갈 수 있다. LimitOrder 외에도 Uniswap V4 Github에서 공식 예시로 작성되어 있는 Hook은 다음과 같다.
- Geomean Oracle: 시간에 따른 가격의 기하평균을 반환하는 오라클
- TWAMM: 설정한 기간 내에 대규모의 스왑을 여러 개로 작게 나누어 실행하는 매매 방식
- Volatililty Oracle: 가격의 변동성을 반환하는 오라클(아직 구현이 완료되지 않음)
이외에도 MEV 수익을 돌려주는 기능을 제공하거나, 범위 밖으로 벗어난 유동성을 렌딩 프로토콜에 예치하여 LP의 수익을 극대화하는 기능을 제공하는 Hook 등 매우 다양한 형태의 Hook이 존재할 수 있다.
3. Feature of Uniswap V4
위 코드 분석을 기반으로 Uniswap V4의 기능을 총 네 가지로 나누어 정리할 수 있다.
3.1. Flash Accounting
Uniswap V4의 핵심 기능 중 하나는 Flash Accounting이다. 풀에 대해 특정 작업을 수행하면, 풀 내 자산의 잔고 변화가 Lockstate라는 struct 내 nonzeroDeltaCount와 currencyDelta에 저장된다. 이 데이터들은 작업이 수행되기 전과 후에 모두 반드시 0으로 정산되어야 한다. 즉, Lockstate는 작업 경로에 의존하지 않고(path-indepedent), 작업 전후로 같은 값을 가지는 struct이다.
이러한 자료형을 도입한 이유는 EIP-1153을 기반으로 Flash accounting을 적용하기 위함이다. 지금은 아직 EIP-1153이 구현되지 않아 Lockstate가 스토리지에 저장되지만, Uniswap은 향후 Dencun 업데이트 이후 Lockstate struct와 lockedBy array를 transient storage에 저장되는 변수로 선언할 예정이다. 그렇게 되면 계산 과정에서 드는 가스비를 아낄 수 있게 되는 동시에, 트랜잭션 전후로 해당 변수들의 값이 0으로 유지되기 때문에 스왑 및 유동성 공급 실행의 완결성도 확보할 수 있게 된다.
이는 기존 V3와 비교했을 때 보안 비용이 더욱 저렴해진다는 의의를 가진다.
V3에서는 스왑, 유동성 회수 등에 대한 재진입 공격을 막기 위해 아래와 같은 Reentrancy Guard를 사용하였다.
modifier lock() {
require(slot0.unlocked, 'LOK');
slot0.unlocked = false;
_;
slot0.unlocked = true;
}
여기서 slot0.unlocked 는 스토리지에 저장된 변수로, 업데이트할 때마다 스토리지를 수정하기 때문에 최대 22,100 GAS를 소모한다. 한편 transient storage를 수정하는 비용은 100 GAS로, 매우 저렴하게 책정되었다. 즉, LockState와 lockedBy를 transient storage에 선언함으로써 보안에 대한 비용을 95% 이상 절감할 수 있는 것이다.
Transient storage와 EIP-1153에 대해서는 이 글에서 더 자세히 분석하였으니, 참고하기 바란다.
3.2. Singleton Architecture
이전의 Uniswap 코드가 Factory / Pool 두 개로 나누어져 풀마다 새로운 컨트랙트를 생성했던 것과 달리, V4에서는 PoolManager 컨트랙트 하나로 모든 풀을 관리한다. 이에 따라, 풀 간의 Routing이 훨씬 저렴해졌다.
예를 들어 ETH를 USDC로 바꾸고, USDC를 STG 토큰으로 바꾸는 Routing 작업을 수행한다고 생각해보자. V4 이전에는 ETH-USDC풀에 ETH를 넣고 USDC를 받아, 이를 다시 USDC-STG 풀로 전송하여 STG를 받는 방식이었다. 그러나 V4에서는 단순히 각 풀에 대해 내부 잔고 변화 정보인 delta 값만을 바꿔 기록해주는 방식으로 이를 수행할 수 있다. 중간 중간에 토큰을 전송하지 않고 처음과 마지막에만 스토리지를 수정하므로, Routing 프로세스가 매우 단순해지고 저렴해졌다.
또한 풀 배포 비용도 매우 저렴해졌다. 이전에 풀을 배포하려면 별도의 풀 컨트랙트를 배포해야 했지만, V4에서는 PoolManager 컨트랙트 내 initialize 함수를 호출하면 풀이 배포되고, 배포 비용을 약 99% 이상 아낄 수 있다.
3.3. Hook
V4의 각 풀은 Hook을 통해 사용자에게 다양한 기능을 제공할 수 있다. 위의 지정가 주문 예시에서 보았듯이 Hook은 사용자의 UX를 증진시키는 기능을 할 수도 있지만, 다음과 같은 기능들도 할 수 있다.
- 유동성 공급자의 수익 개선 (ex. 범위를 벗어난 유동성에 대한 렌딩 기능)
- 동적 수수료
- MEV Protection
- LP 수수료 auto-compounding
- 오라클 없는 렌딩 프로토콜
이러한 Hook들을 기반으로 Uniswap V4 풀은 트레이더 및 유동성 공급자 모두에게 추가 수익 및 기능을 제공할 수 있다.
한편 어떤 Hook을 선택했냐에 따라 풀이 구분되기 때문에, 유동성 파편화가 더욱 심해질 수 있다는 우려가 존재한다. 이에 대해서는 2부에서 자세히 다루어보도록 하겠다.
3.4. Gas Optimization

(Gas Used in Pool Actions of Uniswap V3 & V4)
Uniswap V3와 V4의 코드를 기반으로 가스 테스트를 한 결과값은 다음과 같다. 그런데 위에서 말했던 것과 반대로, 풀 배포 비용을 제외하고 스왑과 유동성 공급에 대해서는 V4가 V3보다 더 높은 가스비를 소모하거나, 유사한 수준의 가스비를 보여주는 것을 확인할 수 있다.
이는 EIP-1153이 아직 적용되지 않았기 때문이다. lock 함수 내 lockedBy array와 Lockstate를 계산하는 과정에서 스토리지에 값을 저장하는 SSTORE opcode가 매번 포함되는데, 이 연산의 비용이 현재는 매우 크다. SSTORE 는 저장하려고 하는 값이 0인지 아닌지, 처음 접근되는지 아닌지에 따라 비용이 아래와 같이 책정되고 있다.

현재 V4는 Lockstate struct와 lockedBy array가 스토리지에 선언되어 있다. 스왑 과정에서 lock 과 lockAcquired 함수 내 이에 대한 SSTORE 연산 횟수는 총 12번으로, 약 138,500 GAS를 소모한다.
그런데 해당 변수들에 대해 SSTORE 가 아니라 transient storage에 대한 opcode TSTORE 가 사용된다면, 소모되는 가스비는 어떻게 변할까? TSTORE는 한 번 연산에 필요한 가스가 100 GAS로 고정되어 있으므로, 소모되는 가스는 138,500 GAS에서 1,200 GAS으로 감소하게 된다. 이는 변수 저장에 대한 비용이 99% 이상 줄어드는 것을 의미하고, 총 스왑 비용은 V3에서보다 최대 52% 저렴해진다. 즉, EIP-1153이 이더리움 클라이언트에 구현된다면 V3보다 V4에서 훨씬 저렴한 가스비로 스왑 및 유동성 공급 / 회수를 실행할 수 있을 것으로 예상된다.
3.5. Conclusion
Uniswap V4를 세줄로 요약하면 다음과 같다.
- 핵심 로직은 Flash Accounting을 기반으로 하는 풀과 사용자 간 ‘채무’ 관리(
lock,lockAcquired)이다. - 빌더가 Hook과 콜백 컨트랙트를 직접 제작 및 선택할 수 있어, V4를 기반으로 하는 프로토콜을 빌딩하기 더욱 쉬운 환경을 구축하였다.
- 동시에, 아키텍처와 데이터 저장 방식을 개선하여 사용자에게 더 저렴한 거래 환경을 제공한다.
그러나 이에 대해 이러한 기능들이 타 프로토콜에 이미 존재하고, 유동성 파편화가 더욱 심각해질 것이라는 비판의 목소리도 존재한다. 다음 글에서는 V4를 둘러싼 여러 논란에 대해 알아보고, 향후 V4를 둘러싼 생태계는 어떻게 발전할지에 대해 알아보도록 하겠다.
Reference
메타데이터
- post_id
- 2881fa0358aa
- slug
- uniswap-v4-deep-dive-1-코드를-통해-알아보는-v4-2881fa0358aa
- url
- https://medium.com/decipher-media/uniswap-v4-deep-dive-1-%EC%BD%94%EB%93%9C%EB%A5%BC-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-v4-2881fa0358aa
- canonical_url
- https://medium.com/decipher-media/uniswap-v4-deep-dive-1-%EC%BD%94%EB%93%9C%EB%A5%BC-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-v4-2881fa0358aa
- author_url
- https://medium.com/@organmo
- status
- ok
- fetched_at
- 2026-08-02 03:19:08