[KR] New transaction fee mechanism: 동적 가스비 조절 제안

Background

안녕하세요, Klaytn 팀의 Jared 입니다. Klaytn’s Gas Price Adjustment Plan and Future Direction 글의 ’ We want a dynamic and deterministic fee policy in the long term’ 섹션에서 간략히 설명 드렸던 바와 같이, Klaytn team은 현재의 고정 가격 가스비를 네트워크 상황에 따라 동적으로 조절할 수 있는 메커니즘을 개발 중에 있습니다. 아직까지 많은 사안이 확정된 것은 아니지만, 생태계에 미치는 영향이 큰 사안인 만큼 개발 이전 논의 과정을 투명하게 공개하고 커뮤니티와 같이 논의하기 위해 이 글을 작성합니다. 동적 가스비 메커니즘에 대해 좋은 의견이나, 아이디어가 있다면 언제나 환영합니다.

Motivation

현재까지 Klaytn의 가스비 메커니즘은 ‘고정 가격제’로, 가스비(gas price)는 특정 값(e.g. 25 ston, 750 ston)으로 고정되는 형태를 가집니다. 개발 초기 이 메커니즘은 사용자가 직접 가스비를 입력할 필요 없이 쉽게 거래를 보내고, 거래 비용(transaction fee)의 변동성을 최소화하기 위해 채택되었습니다. 하지만 가스비 인상 전 최근 Klaytn에 많은 거래가 일어나며, 다음과 같은 문제점들이 발견되었습니다:

  • 거래(transaction) 처리 지연 : 많은 거래가 동시에 발생하여, 사용자가 자신의 거래를 처리하는데 이전보다 많은 시간이 걸리는 사례들이 빈번하게 발생하고 있습니다. 이는 순간적인 거래량 급증이 근본적인 원인입니다. 하지만, Klaytn 팀에서는 자신의 거래를 처리하기 위해 수천건 이상의 거래를 한번에 발생시키는 사례가 빈번함을 확인하였습니다. 그리고 해당 행위가 지속적으로 발생하는 원인은 Klaytn에 거래의 우선순위를 결정하는 알고리즘이 부재한 동시에, 적절한 가스비 가격 정책을 가지고 있지 않았기 때문입니다.
  • 스토리지 부하 : 앞서 언급한 수천건의 폭발적인 거래들은 대부분 봇에 의해 이뤄지는데, 대부분이 처리 되지 않는(revert) 형태로 발생합니다. 이러한 거래들은 Klaytn의 스토리지에도 장기적으로 부담을 주기 때문에, Klaytn이 빠른 거래 확정과 안정적 네트워크를 지속적으로 제공하는데 큰 장애가 될 수 있습니다.
  • 합리적 가격 산정의 어려움으로 인한 비효율적 자원 배분 : 가스비는 무조건적으로 높아도, 반대로 낮아도 문제입니다. 너무 낮을 경우 DDOS 공격 등에 취약할 수 있으며, 많은 사용자들의 거래가 지연될 수 있습니다. 반대로 너무 높을 경우 거래 비용이 지나치게 상승해 사용자들이 원하는 만큼 네트워크를 사용하기 힘듭니다. 하지만 현재의 고정 가스비 정책으로는 어느 정도의 가격이 적절한 가격인지 발견하기 쉽지 않습니다. Klaytn의 네트워크 상황이 시시각각 변하기 때문입니다.
  • 빠르고 유연한 대처 불가 : 현재와 같이 거래가 급증하는 경우에는 가스비를 높히는 방향으로, 반대로 네트워크가 원활할때는 낮추는 방향으로 조절하는 것이 필요합니다. 하지만 현재의 고정 가스비 정책은 적절한 가격대에 대해 같이 논의하고, 실제 적용하기 까지 많은 시간이 걸립니다.

위의 문제들을 해결하는 동시에, Klaytn에 소각 매커니즘을 도입하여 생태계의 성장과 KLAY의 가치를 연동시키고자 합니다.

(다만 이 제안에서는 Dynamic fee policy에 대해서만 다루며, 거래의 우선순위 알고리즘의 경우 이미 FIFO(First In First Out) 방식의 우선순위 알고리즘이 개발되어 GitHub - klaytn/klaytn: Official Go implementation of the Klaytn protocol 에서 직접 확인하실 수 있습니다.)

Dynamic Fee Policy

동적 가스비 정책에서의 거래(transaction)는 네트워크의 포화 상태에 따라 동적으로 조절되는 base_fee 로 구성됩니다. 네트워크의 포화도는 Klaytn에서 생성되는 블록(Block)의 사용량인 gas_used 를 통해 측정됩니다. base_fee 는 매 블록 변경됩니다.

합의 노드가 블록을 생성할 때, 이전 블록의 gas_usedgas_target 보다 많다면, base_fee 를 증가시킵니다. 반대로, gas_usedgas_target 보다 낮다면 base_fee 를 감소시킵니다. 이와 같은 과정은 base_feelower_bound 혹은 upper_bound 를 넘지 않는 선까지 반복적으로 수행됩니다. 블록 생성자는 블록에 담긴 거래 수수료중 일부를 수취하며, 나머지는 소각됩니다.

만약 다음 테이블과 같은 블록들이 있고, 최신 블록에서 base_fee 를 결정하고자 할 때의 예시는 다음 설명과 같습니다. 하지만, 밑의 예시는 실제 구현되는 파라메터 값이 변함에 따라 변경될 수 있으므로, 밑에 작성한 reference implementation을 참고하는 것을 부탁드립니다. 이 예시에서, gas_target 값은 30 이라 가정합니다.

Block number 100 101
Gas used 50 70
Base fee 100 101.04
  1. 100번째 블록의 gas used를 계산 = 50
  2. gas_target 과 해당 값의 차이를 계산 = (50-30) = 20
  3. base_fee 를 기존 대비 얼마나 올릴 것인지 계산
  4. 기존 base fee * ( gas_target - 이전 블록의 gas_used 차이) / gas_target 값 / 상수(여기에서는 64라 가정)
  5. (100 * 20 / 30) / 64 = 1.04
  6. base_fee 증가 = 100 + 1.04 = 101.04

*구체적인 방법은, 이글 하단에 reference implementation을 참조하시기 바랍니다.

Expected Effect

제안된 동적 가스비 조절 메커니즘은 다음과 같은 문제를 해결할 것으로 예상합니다.

  • 거래 처리 지연 및 스토리지 부하 정도 경감
  • 네트워크 상황에 따른 유연한 가스비 조절
  • 소각 메커니즘 도입

하지만, 다음과 같은 변화들이 일어날 수 있습니다.

  • 생태계 지갑 및 기타 툴들의 가스비 관련 코드 수정
  • 가스비 예측 가능성 저하

위와 같은 메커니즘을 실제 클레이튼 데이터를 통해 검증해 보았을 때, 다음과 같은 base_fee 변동이 예상되었습니다.

일반적인 상황일 경우


Congestion시


Reference Implementation: EIP-1559 Specification을 참조하였습니다. 또한, 구체적인 구현 내용 등은 변경될 수 있습니다.

from asyncio.windows_events import NULL
from typing import Union, Dict, Sequence, List, Tuple, Literal
from dataclasses import dataclass, field
from abc import ABC, abstractmethod


# Since Klaytn has multiple transaction types,
# only 3 transaction types are defined here for sake of simplicity.
@dataclass
class TxTypeLegacyTransaction:
	value: int = 0
    to: int = 0
    input: bytes = bytes()
	v: int = 0
	r: int = 0
	s: int = 0
    nonce: int = 0
	gas: int = 0
    gas_price: int = 0

@dataclass
class TxTypeFeeDelegatedSmartContractExecution:
    type: int = 0x09
	nonce: int = 0
	gas_price: int = 0
	gas: int = 0
	to: int = 0
	value: int = 0
	from: int = 0
	input: bytes = bytes()
	tx_signatures: List[int] = field(default_factory=list)
	fee_payer: int = 0 
	fee_payer_signatures: List[int] = field(default_factory=list)

@dataclass
class TxTypeFeeDelegatedSmartContractExecutionWithRatio:
    type: int = 0x32
	nonce: int = 0
	gas_price: int = 0
	gas: int = 0
	to: int = 0
	value: int = 0
	from: int = 0
	input: bytes = bytes()
	fee_ratio: int = 1
	tx_signatures: List[int] = field(default_factory=list)
	fee_payer: int = 0 
	fee_payer_signatures: List[int] = field(default_factory=list)


# In Klaytn, Ethereum transaction types are supported by adding EthereumTxTypeEnvelope(0x78).
# RawTransaction : EthereumTxTypeEnvelope || EthereumTransactionType || TransactionPayload
# || is the byte/byte-array concatenation operator.
# In this proposal, those concatenation is intentionally ommited for simplification.

# Transaction2930 in Ethereum. 
@dataclass
class TxTypeEthereumAccessList:
	type: int = 0x7801
	chain_id: int = 0
	nonce: int = 0
	gas_price: int = 0
	gas: int = 0
	to: int = 0
	value: int = 0
	data: bytes = bytes()
	access_list: List[Tuple[int, List[int]]] = field(default_factory=list)
	v: int = 0
	r: int = 0
	s: int = 0

# Transaction1559 in Ethereum
@dataclass
class TxTypeEthereumDynamicFee:
	type: int = 0x7802
	chain_id: int = 0
	nonce: int = 0
	gas_tip_cap: int = 0
	gas_fee_cap: int = 0
	gas: int = 0
	to: int = 0
	value: int = 0
	data: bytes = bytes()
	access_list: List[Tuple[int, List[int]]] = field(default_factory=list)
	v: int = 0
	r: int = 0
	s: int = 0


EthereumTransactions = Union[TxTypeEthereumDynamicFee, TxTypeEthereumAccessList]

Transaction = Union[TxTypeLegacyTransaction, TxTypeFeeDelegatedSmartContractExecution, TxTypeFeeDelegatedSmartContractExecutionWithRatio, EthereumTransactions]

# TODO: transaction accounting part
@dataclass
class NormalizedTransaction:
	signer_address: int = 0
	signer_nonce: int = 0
	max_fee_per_gas: int = 0
	gas_limit: int = 0
	to: int = 0
	value: int = 0
	data: bytes = bytes()
	fee_payer_address: int = 0
	fee_ratio: int = 1

@dataclass
class Block:
	hash: int = 0
	parent_hash: int = 0
	base_fee_per_gas: int = 0
	block_score: int = 0
	extra_data: bytes = bytes()
	gas_used: int = 0 
	governance_data: bytes = bytes()
	logs_bloom: int = 0
	number: int = 0
	transaction_receipt_root: int = 0
	reward: int = 0
	state_root: int = 0
	timestamp: int = 0
	timestamp_FoS: int = 0
	transaction_root: int = 0
	nonce: int = 0
	committee: List[int] = field(default_factory=list)
	proposer: int = 0 
	size: int = 0 


@dataclass
class Account:
	type: int = 0
	nonce: int = 0
	humanReadable: bool = False 
	address: int = 0
	key: int = 0 
	balance: int = 0
	storage_root: int = 0
	code_hash: int = 0
	code_format: int = 0
	vm_version: int = 0

INITIAL_FORK_BLOCK_NUMBER = 107806544 # TBD
BASE_FEE_DELTA_REDUCING_DENOMINATOR = 64 # TBD
LOWER_BOUND_BASE_FEE = 2000000000# TBD, 20ston
UPPER_BOUND_BASE_FEE = 1000000000000 # TBD, 2000ston
GAS_TARGET = 30000000 # TBD
BURN_RATIO = 0.5 # TBD

class World(ABC):
	def validate_block(self, block: Block) -> None:
		
		# check if the base fee is correct
		if INITIAL_FORK_BLOCK_NUMBER == block.number:
			expected_base_fee_per_gas = LOWER_BOUND_BASE_FEE

		else: 
			parent_base_fee_per_gas = self.parent(block).base_fee_per_gas
			parent_gas_used = self.parent(block).gas_used
			transactions = self.transactions(block)

			# check if the base fee is in the range
			if parent_base_fee_per_gas < LOWER_BOUND_BASE_FEE:
				expected_base_fee_per_gas = LOWER_BOUND_BASE_FEE

			elif parent_base_fee_per_gas > UPPER_BOUND_BASE_FEE:
				expected_base_fee_per_gas = UPPER_BOUND_BASE_FEE

			else: 
				# check if the base fee is correct
				if parent_gas_used == GAS_TARGET:
					expected_base_fee_per_gas = parent_base_fee_per_gas
				elif parent_gas_used > GAS_TARGET:
					gas_used_delta = parent_gas_used - GAS_TARGET
					base_fee_per_gas_delta = max(parent_base_fee_per_gas * gas_used_delta // GAS_TARGET // BASE_FEE_DELTA_REDUCING_DENOMINATOR, 1)
					expected_base_fee_per_gas = parent_base_fee_per_gas + base_fee_per_gas_delta
				else:
					gas_used_delta = GAS_TARGET - parent_gas_used
					base_fee_per_gas_delta = parent_base_fee_per_gas * gas_used_delta // GAS_TARGET // BASE_FEE_DELTA_REDUCING_DENOMINATOR
					expected_base_fee_per_gas = parent_base_fee_per_gas - base_fee_per_gas_delta
		
		assert expected_base_fee_per_gas == block.base_fee_per_gas, 'invalid block: base fee not correct'

		# execute transactions and do gas accounting
		cumulative_transaction_gas_used = 0
		for unnormalized_transaction in transactions:
			# Note: this validates transaction signature and chain ID which must happen before we normalize below since normalized transactions don't include signature or chain ID
			signer_address = self.validate_and_recover_signer_address(unnormalized_transaction)
			transaction = self.normalize_transaction(unnormalized_transaction, signer_address)

			signer = self.account(signer_address)

			
			signer.balance -= transaction.amount
			assert signer.balance >= 0, 'invalid transaction: signer does not have enough ETH to cover attached value'
			
			# TODO: fee delegation transaction accounting
			# the signer must be able to afford the transaction
			assert signer.balance >= transaction.gas_limit * transaction.max_fee_per_gas

			# ensure that the user was willing to at least pay the base fee
			assert transaction.max_fee_per_gas >= block.base_fee_per_gas

			# Prevent impossibly large numbers
			assert transaction.max_fee_per_gas < 2**256
			

			signer.balance -= transaction.gas_limit * block.base_fee_per_gas
			assert signer.balance >= 0, 'invalid transaction: signer does not have enough ETH to cover gas'

			gas_used = self.execute_transaction(transaction, block.base_fee_per_gas)
			gas_refund = transaction.gas_limit - gas_used
			cumulative_transaction_gas_used += gas_used
			# signer gets refunded for unused gas
			signer.balance += gas_refund * block.base_fee_per_gas

			# miner only receives some propotion of the base fee(basefee burned)
			self.account(block.proposer).balance += gas_used * block.base_fee_per_gas * BURN_RATIO

		# check if the block spent too much gas transactions
		assert cumulative_transaction_gas_used == block.gas_used, 'invalid block: gas_used does not equal total gas used in all transactions'

		# TODO: verify account balances match block's account balances (via state root comparison)
		# TODO: validate the rest of the block

	def normalize_transaction(self, transaction: Transaction, signer_address: int) -> NormalizedTransaction:
		# legacy transactions
		if isinstance(transaction, TxTypeLegacyTransaction):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_price,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.input,
				fee_payer_address = None,
				fee_ratio = None
			)

		elif isinstance(transaction, TxTypeFeeDelegatedSmartContractExecution):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_price,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.input,
				fee_payer_address = transaction.fee_payer,
				fee_ratio = None
			)
		
		elif isinstance(transaction, TxTypeFeeDelegatedSmartContractExecutionWithRatio):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_price,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.input,
				fee_payer_address = transaction.fee_payer,
				fee_ratio = transaction.fee_ratio
			)
		elif isinstance(transaction, TxTypeEthereumAccessList):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_price,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.data,
				fee_payer_address = None,
				fee_ratio = None
			)
		elif isinstance(transaction, TxTypeEthereumDynamicFee):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_fee_cap,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.data,
				fee_payer_address = None,
				fee_ratio = None
			)
		else:
			raise Exception('invalid transaction: unexpected number of items')

	@abstractmethod
	def parent(self, block: Block) -> Block: pass

	@abstractmethod
	def block_hash(self, block: Block) -> int: pass

	@abstractmethod
	def transactions(self, block: Block) -> Sequence[Transaction]: pass

	# effective_gas_price is the value returned by the GASPRICE (0x3a) opcode
	@abstractmethod
	def execute_transaction(self, transaction: NormalizedTransaction, effective_gas_price: int) -> int: pass

	@abstractmethod
	def validate_and_recover_signer_address(self, transaction: Transaction) -> int: pass

	@abstractmethod
	def account(self, address: int) -> Account: pass

FAQ

  • 변경 후 base_fee 는 얼마나 될 것으로 예측하나요?
    • 아직 이를 미리 예측하기는 매우 힘듭니다. base_fee 자체의 변동은 네트워크의 활용도에 따라 변하기 때문입니다. 다만, Klaytn 팀은 일 평균 base_fee 는 약 50~500 ston 내외가 될 것으로 예상합니다. 각 블록별로는 일시적으로 이보다 더 낮거나(e.g. 20 ston) 높은(e.g. 2000 ston) 가격이 될 수 있습니다.
  • 개발자 입장에서 뭘 바꿔야 하나요?
    • 기존 가스비 관련 로직이 변경되기 때문에, Metamask 와 같이 가스비 관련 로직을 추가해야 할 수 있습니다.
  • Klaytn 사용성이 나빠지진 않을까요?
    • 거래 처리 지연 측면에서는 오히려 기존보다 나아질 가능성이 높습니다. 다만, Kaikas 등 월렛 측면에서 기존과 다르게 가스비를 예상해 거래를 보내야 하는 측면이 있을 수 있습니다. 하지만 해당 가스비 관련 로직이 추가된다면, (현재 메타마스크 등의 지갑을 참고했을때) 단순 사용성 측면에서 큰 허들이 생긴다고 보기는 힘듭니다.
  • 도입한다면, 언제쯤 도입을 예상하나요?
    • 현재 구체적인 날짜는 정해지지 않았습니다. 다만, 2022년 하반기 초입에 새로운 변동 가스비 정책을 도입하는 것을 목표로 하고 있습니다.
  • EIP 1559와 다른점이 뭔가요?
    • EIP 1559와 달리, Tip이 존재하지 않습니다.
    • base_fee 에 대한 최저값과 최대값이 존재합니다.
  • 왜 Tip이 없나요?
    • 기존 토큰 전송 등의 UX 저하를 최소화 하면서도, 가스비의 예측 가능성을 일부분 담보 하고자 했습니다. 다만, 추후 네트워크의 상황 등을 고려해 Tip의 도입이 고려될 수 있습니다.
5 Likes

KIP proposal link: [KIP-71] Dynamic gas fee pricing mechanism by lwj010 · Pull Request #72 · klaytn/kips · GitHub

1 Like

Base fee 만 Dynamic하게 결정되고 tip은 없는구조군요

그럼 Fee에대해 가격 우선순위가아닌, Tx처리순서는 FCFS인가요?

1 Like

네, tip을 먼저 빠르게 도입하는 것도 본문에 언급한 부작용을 불러올 수 있기 때문에, 먼저 FCFS 형태를 도입해 보려고 합니다.

2 Likes

안녕하세요. 블록체인에 관심많은 개발자입니다. 포럼의 여러 분들과 동적 가스비 메커니즘의 사용성(UX) 문제 가능성과 솔루션을 논의해보고자 댓글 남깁니다. 여러 의견을 나누길 바라며, 혹시 부정확한 이해 및 정보로 인해 논리 진행 과정에서 잘못된 정보가 있다면 댓글로 정정 부탁 드립니다!

배경
고정 가스비는 그 가격에 따라 트랜잭션 혼잡 상황이 발생되거나 블록이 낭비되는 상황이 발생할 수 있어 동적 가스비가 제안되는 것은 의미있다고 생각됩니다. 또한 수수료 소각은 네트워크 전체에 좋은 영향을 줄 것입니다.

다만, 클레이튼은 공식 문서에 사용자 친화적으로 서비스 중심의 엔터프라이즈급 블록체인이라고 서술되어 있습니다. 따라서 사용성을 중요하게 다루어야 하기 때문에 면밀하게 사용성 저해 요인을 찾아 솔루션을 제시해야한다고 생각합니다.

사용성 저해 문제 상황 가정
유저는 트랜잭션 전송 시 네트워크 혼잡 상황을 예상해야 합니다. 만약, 트랜잭션이 몰려 트랜잭션이 pending되면, pending 시간 동안 base fee가 높아져 유저가 전송한 트랜잭션이 demoted되어 base fee가 낮아질 때까지 기다려야 할 수 있기 때문입니다.
예를 들어 유저는 월렛(Kaikas 등)에서 매 트랜잭션마다 [네트워크 안정 / 네트워크 혼잡 / 네트워크 매우 혼잡] 혹은 [천천히 처리 / 빠르게 처리 / 매우 빠르게 처리] 등을 선택하여 가스비를 지불하고 트랜잭션을 전송합니다.

사용성 저해 문제 가능성

  • 서비스에서 매 트랜잭션마다 네트워크 혼잡 정도 및 트랜잭션 속도를 선택하도록 팝업이 나온다면 사용자 경험은 매우 저해될 것 입니다.
  • 서비스 설정에서 트랜잭션 속도를 정해두어 가스비에 대한 물리적 선택의 단계가 줄어들더라도, 유저는 매번 달라지는 가스비 영수증을 보고 트랜잭션의 즉시성과 수수료 가중에 대한 고민이 계속될 것이라 예상됩니다.

사용성 저해 문제 솔루션 후보

단기적 솔루션 :

  1. 트랜잭션이 Tx Pool에 추가되는 시점에 'base fee 이상’이란 조건을 충족하면 demoted는 되지 않음 → 단순 FCFS만으로 트랜잭션 우선순위를 가질 수 있으면서, 토큰 이코노미에 기반한 네트워크 혼잡도 조절이란 목표를 달성할 수 있을 것으로 예상
  2. 추가 예정

장기적 솔루션 :

  1. 개별적 동적 가스비 조절 메커니즘 : 일부 네트워크 사용량이 높은 컨트랙트 및 주소 계정에만 할증된 base fee를 부여, 나머지 주소에는 최소 base fee만 부여 / 소수 주소의 독점 없이 네트워크 혼잡 상황 변동 시 전체 base fee 조정 → 디테일은 추가 연구가 필요
  2. 추가 예정

긴 글 읽어주셔서 감사합니다.

2 Likes

안녕하세요. 좋은 제안 주셔서 감사합니다.

저도 해당 부분이 굉장히 걱정 되었던 부분이라, 지금은 크게 두가지 방식으로 개발중인 것으로 알고 있습니다.

  1. 지갑상에서 가스비에 대한 사용자 경험 저하: 애초에 현재 가스비를 받아오는 API에 더해, 적정 가스비를 직접 계산할 필요 없도록 (현재 가스비 * 2) 를 리턴해주는 API 개발
  2. 말씀해 주신 것처럼 tx 가 tx pool에 추가되는 시점에 base fee 이상이란 조건을 충족하면 demoted 되지 않고, queue에 일정 시간 남아 있음

장기적 솔루션에 대해서도 좋은 아이디어인거 같아서, 추후 네트워크 혼잡 상태가 예상되면 충분히 고민 후 시도해 볼 수 있는 방안인 것 같습니다. 다만, 이렇게 했을 경우 일반 서비스가 아닌 트레이딩용 컨트랙트 등의 경우 컨트랙트를 지속적으로 재배포해 다른 주소로 tx를 발생시키는 경우도 있을수 있어서, 해당 부분에 대해서도 추가 고려가 필요할 것 같습니다.

또 좋은 솔루션이 있다면 언제나 제안 부탁 드립니다!

2 Likes