보다 더 생생한 기록

[Solidity][공부 마라톤] 솔리디티로 디자인 패턴 알아보고 적용하기 (上) 본문

블록체인

[Solidity][공부 마라톤] 솔리디티로 디자인 패턴 알아보고 적용하기 (上)

viviviviviid 2024. 6. 27. 00:17
3번째 공부 마라톤 주제로 디자인 패턴을 선정했습니다.
디자인 패턴에 대해 알아보고, 솔리디티에 적용하여 이야기를 해볼까 합니다.
총 두 편으로 나눠 포스팅됩니다.

목차
1. 프록시 패턴
2. 팩토리 패턴
3. 싱글톤 패턴
4. 옵저버 패턴
5. 스테이트 머신 패턴

 

1. 프록시 패턴 (Proxy Pattern)

프록시 패턴을 이용한 업그레이더블 컨트랙트 구현은 불변성을 지닌 컨트랙트 생태계에서 유명한 내용입니다.

이 내용은 따로 포스팅해 뒀으니, 이번 글에서는 넘어가겠습니다.

 

1편 : 업그레이더블 컨트랙트 (이론)

2편 : 업그레이더블 컨트랙트 + 하드햇 테스트 (실전)

 

2. 팩토리 패턴 (Factory Pattern) 

디자인 패턴을 이용한 스마트 컨트랙트 개발의 두 번째 주제로 팩토리 패턴을 소개합니다. 팩토리 패턴은 객체 생성 로직을 캡슐화하여 코드를 보다 유연하고 재사용 가능하게 만드는 패턴입니다. 이번 내용에서는 팩토리 패턴의 개념과 이를 Solidity에서 어떻게 적용할 수 있는지 알아보겠습니다.

 

팩토리 패턴의 구성 요소

  1. Factory: 객체 생성 로직을 담당하는 클래스입니다.
  2. Product: 팩토리가 생성하는 객체입니다.

Solidity에서는 팩토리 패턴을 사용하여 스마트 컨트랙트의 인스턴스를 생성하고 관리할 수 있습니다. 예를 들어, 여러 종류의 토큰 컨트랙트를 생성하는 팩토리 컨트랙트를 만들어보겠습니다.

 

Token.sol

contract Token {
    string public name;
    address public owner;

    constructor(string memory _name, address _owner) {
        name = _name;
        owner = _owner;
    }
}

 

TokenFactory.sol

import "./SimpleToken.sol";

contract TokenFactory {
    address[] public tokens;

    event TokenCreated(address tokenAddress, string name, address owner);

    function createToken(string memory _name) public {
        SimpleToken newToken = new SimpleToken(_name, msg.sender);
        tokens.push(address(newToken));
        emit TokenCreated(address(newToken), _name, msg.sender);
    }

    function getTokens() public view returns (address[] memory) {
        return tokens;
    }
}


createToken 함수를 사용해서 새로운 Token 인스턴스를 생성합니다. 생성된 토큰의 주소를 tokens 배열에 추가하고, TokenCreated 이벤트를 발생시킵니다. 

이런 식으로 사용되는 팩토리패턴은 다음과 같은 곳에 쓰일 수 있습니다.

 

  • 토큰 생성: 다양한 종류의 토큰을 생성하고 관리할 때 유용합니다.
  • 다양한 스마트 컨트랙트 인스턴스 생성: 특정 기능을 수행하는 다양한 컨트랙트를 생성하고 관리할 때 사용합니다.
  • 업그레이드 가능한 컨트랙트: 새로운 버전의 컨트랙트를 생성하고 기존 데이터를 마이그레이션 할 때 유용합니다.

 

3. 싱글톤 패턴 (Singleton Pattern) 

싱글톤 패턴은 특정 컨트랙트가 단 하나의 인스턴스만 가지도록 보장하는 패턴입니다. 이를 통해 전역적으로 접근 가능한 인스턴스를 생성할 수 있으며, 특정 상태나 자원을 공유할 때 유용합니다. 

 

즉, 싱글톤 패턴을 구현하기 위해서는,

  1. 하나의 인스턴스 외에 추가적으로 생성되지 않게 막아야 합니다.
  2. 인스턴스가 이미 생성되었는지 조회하는 기능이 있어야 합니다.
  3. 추가적으로, RBAC(Role-Based Access Control) 디자인 패턴을 사용하여 관리자 역할을 부여받은 사람만이 인스턴스 관리에 접근 가능하도록 제한할 수 있습니다. 이는 우리가 흔히 아는 RBAC의 간단한 형태인 Ownable 등을 사용하여 구현할 수 있습니다.

두 가지 접근법이 있는데, 이를 솔리디티로 구현해 보겠습니다.

A. 싱글톤 인스턴스가 배포되었는지 확인하는 flag 또는 bool 변수를 이용하는 것

contract Singleton {
    bool private deployed;

    constructor() private {
        require(!deployed, "싱글톤 인스턴스가 이미 배포되었습니다.");
        deployed = true;
	// logic
    }
}

deployed 플래그는 싱글톤 인스턴스가 이미 배포되었는지 확인합니다. 이 플래그를 통해 추가 인스턴스 생성을 방지할 수 있습니다.

B. Factory 패턴을 이용하는 것

contract Singleton {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function do() public view returns (string memory) {
        require(msg.sender == owner, "owner만 이 함수를 이용할 수 있습니다.");
        return "You can do it.";
    }
}
import "./Singleton.sol";

contract SingletonFactory {
    Singleton private singletonInstance;

    function createSingleton() public returns (address) {
        require(address(singletonInstance) == address(0), "싱글톤 인스턴스가 이미 존재합니다.");
        singletonInstance = new Singleton();
        return address(singletonInstance);
    }

    function getSingletonInstance() public view returns (address) {
        return address(singletonInstance);
    }
}

 

바로 위, 2번에서 소개한 팩토리 패턴입니다. 이 패턴을 이용하여 구현한 컨트랙트는 싱글톤에 대한 참조를 유지하고, 하나의 인스턴스만 생성되도록 보장할 수 있습니다.

  1. singletonInstance 변수를 통해 Singleton 인스턴스를 저장합니다.
  2. createSingleton 함수는 Singleton 인스턴스가 아직 생성되지 않았을 때만 새로운 인스턴스를 생성합니다.
  3. getSingletonInstance 함수는 현재 Singleton 인스턴스의 주소를 반환합니다.

이렇게 구현된 싱글톤 패턴은 아래와 같은 상황에서 적용 가능합니다.

  • 전역 상태 관리: 시스템 전체에서 공통된 상태나 설정을 유지할 때.
  • 로그 관리: 애플리케이션 전체에서 로그를 중앙에서 기록하고 관리할 때.
  • 캐시 시스템: 자주 사용되는 데이터를 캐시하여 시스템 전반에서 사용할 때.

4. 옵저버 패턴 (Observer Pattern) 

옵저버 패턴은 이벤트를 발행(publish)하고 이를 구독(subscribe)하는 방식으로 동작합니다. 주로 상태 변화나 특정 이벤트 발생 시 여러 컴포넌트나 컨트랙트가 이를 인지하고 대응하도록 하는 데 사용됩니다.

 

옵저버 패턴의 구성 요소

  1. Subject: 상태 변화를 감지하고 옵저버들에게 알림을 보냅니다. 옵저버 등록, 제거, 알림 기능을 포함합니다.
  2. Observer: 상태 변화 알림을 받아서 처리하는 객체입니다. Subject에 등록되고 상태 변화 알림을 받습니다.

Subject.sol

contract Subject {
    event StateChanged(uint256 newState);
    
    uint256 private state;
    address[] private observers;

    // 상태 변경 함수
    function setState(uint256 _newState) public {
        state = _newState;
        emit StateChanged(_newState); 
        notifyObservers();
    }

    function registerObserver(address _observer) public {
        observers.push(_observer);
    }

    function removeObserver(address _observer) public {
        for (uint i = 0; i < observers.length; i++) {
            if (observers[i] == _observer) {
                observers[i] = observers[observers.length - 1];
                observers.pop();
                break;
            }
        }
    }

    // 알림 함수
    function notifyObservers() private {
        for (uint i = 0; i < observers.length; i++) {
            (bool success, ) = observers[i].call(abi.encodeWithSignature("update(uint256)", state));
            require(success, "Observer update failed");
        }
    }
}

 

Observer.sol

contract Observer {
    uint256 public observedState;

    // 상태 업데이트 함수
    function update(uint256 _newState) public {
        observedState = _newState;
    }
}

코드 동작

  1. Subject 컨트랙트의 상태가 변경되면 setState 함수가 호출되고, 상태 변화 이벤트가 발생합니다.
  2. notifyObservers 함수가 호출되어 모든 등록된 옵서버들에게 상태 변화 알림을 보냅니다.
  3. 옵서버들은 update 함수를 통해 새로운 상태를 업데이트합니다.

이렇게 구현된 옵저버 패턴은 아래와 같은 상황에 적용 가능합니다.

  • 상태 변화 관리: 객체의 상태 변화를 효율적으로 관리하고, 여러 객체들이 그 변화를 쉽게 감지할 수 있습니다.
  • 이벤트 기반 시스템: 이벤트 중심의 프로그래밍 모델을 구현할 수 있습니다.

5. 스테이트 머신 패턴 (State Machine Pattern)

스테이트 머신 패턴은 컨트랙트가 특정 상태를 가지며 상태에 따른 행동을 정의하는 패턴입니다. 상태 전환 규칙을 정의하고, 각 상태에서 가능한 행동들을 명확히 함으로써 복잡한 비즈니스 로직을 관리할 때 유용합니다.

 

쿠팡의 주문배송을 예로 들겠습니다. 위치정보는 아래와 같이 5단계로 나뉩니다.

쿠팡의 배송단계

이 '인수', '이동'. '배송지', '배송중', '배송완료'는 쿠팡에서 물건을 구매하고 물건의 현재 위치 상태를 알려줍니다. 즉 스테이트 머신 패턴이 적용되어 있는 내용입니다.

 

이때 배송 완료 버튼을 누르게 된다면 어떤일이 벌어지게 될까요?

배송원에 의해 배송 전이므로 취소가 가능합니다 / 배송 중이므로 취소가 불가능합니다 과 같은 조건부가 붙을 겁니다.

조건에 부합한다면 취소가 승인될 것이고, 스테이트 머신 패턴의 상태전환규칙에 의해 다음 상태인 '취소' 상태로 변하게 됩니다.

 

이 내용들을 Soldity로 구현해 봅시다.

contract Delivery {
    enum State { 주문됨, 인수됨, 배송중, 배송됨, 취소됨 }
    State public currentState;

    event StateChanged(State newState);

    constructor() {
        buyer = msg.sender;
        currentState = State.주문됨;
    }

    // 배송 상태 변경 함수
    function setState(State _newState) public {
		currentState = _newState;
        emit StateChanged(_newState);
    }

    // 배송 취소 함수
    function cancelDelivery() public {
        require(msg.sender == buyer, "구매자만이 취소할 수 있습니다.");
        require(currentState == State.주문됨 || currentState == State.인수됨, "배송전인 상태에서만 취소가 가능합니다.");

        currentState = State.취소됨;
        emit StateChanged(State.취소됨);
    }
}

배송 취소 함수를 보면, currentState인 현재상태가 '주문됨'과 '인수됨' 상태에서만 되는 것을 확인할 수 있습니다.

 

이 구현을 통해 배송 상태를 관리하고, 각 상태에서 유효한 상태 전환을 보장할 수 있습니다. 또한, 조건에 따라 배송을 취소하거나 완료할 수 있도록 하여, 실제 배송 프로세스를 모델링할 수 있습니다.

참고 자료

Solidity Academy Medium : https://medium.com/@solidity101

Solidity Documentation : https://docs.soliditylang.org/en/v0.8.26/
Openzepplin : https://www.openzeppelin.com/