1. 서론
이전 실습에서는 Foundry 기본 예제인 Counter 컨트랙트를 수정하고 테스트해보았다.
Counter는 컨트랙트 전체에서 하나의 숫자 값을 저장하고, 그 값을 증가시키는 단순한 구조였다.
이번 실습에선 한 단계 더 나아가 사용자 별로 값을 따로 저장하는 스마트 컨트랙트를 만들어보고자 한다.
실제 dApp에서는 사용자마다 포인트, 예치금, 투표 여부, 권한 등이 다르게 관리되어야 하기 때문에, 이번 실습은 실제 스마트 컨트랙트 구조에 조금 더 가까운 내용이다.
2. 실습 순서
- 서론
- 실습 순서
- 실제 스마트 컨트랙트에서의 활용
- src/UserStorage.sol 파일 생성
- 코드 이해
- mapping
- address
- msg.sender - 함수 설명
- setMyValue()
- getMyValue()
- getValueOf() - test/UserStorage.t.sol 테스트 파일 생성
- 테스트 코드 이해
- 테스트 결과
- 실제 서비스로 확장한다면?
- 마무리
3. 실제 스마트 컨트랙트에서의 활용
이해를 위해 오늘 실습하는 주제가 실생활에서 어떻게 활용되는지 설명을 하겠다.
예를 들어 블록체인 기반 출석 체크 서비스를 만든다고 해보자.
사용자가 출석하면 포인트를 지급하려 한다.
- 이런 식으로 사용자 별로 포인트를 다르게 저장하려 한다.
성재 지갑 주소 → 100 포인트
민수 지갑 주소 → 50 포인트
지수 지갑 주소 → 200 포인트
- 블록체인에서는 wallet_address를 사용하면 된다.
wallet_address | point
-------------- | -----
0xAAA... | 100
0xBBB... | 50
0xCCC... | 200
- 따라서 Solidity에선 이런 구조를 mapping으로 표현할 수 있다.
mapping(address => uint256) public points;
- 즉, 이번 실습에서 만드는 UserStorage는 실제 서비스에서 다음과 같은 기능으로 확장될 수 있다
지갑 주소 → 포인트
지갑 주소 → 예치금
지갑 주소 → 투표 여부
지갑 주소 → NFT 보유 여부
지갑 주소 → 관리자 권한 여부
4. src/UserStorage.sol 파일 생성
- src폴더에 아래와 같은 코드를 새로 생성한다.
- 이 컨트랙트는 사용자의 지갑 주소를 기준으로 숫자 값을 저장하고 조회하는 기능을 가진다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract UserStorage {
mapping(address => unit256) private values;
function setMyValue(uint256 _value) public{
values[msg.sender] = _value;
}
function getMyValue() public view returns (uint256) {
return values[msg.sender];
}
function getValueOf(address _user) public view returns (uint256) {
return values[_user];
}
}
5. 코드 분석
5-1. 기본 선언부
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
- // SPDX-License-Identifier: UNLICENSED
- 이 Solidity 코드의 라이선스를 아직 공개 라이선스로 지정하지 않았다는 뜻이다.
- 개인이나 비공개 코드의 경우에 해당한다.
- 만약 오픈소스로 공개할 코드라면 보통 다음과 같이 적는다.
- // SPDX-License-Identifier: MIT
- pragma solidity ^0.8.13;
- Solidity 컴파일러 버전을 지정한다.
- Solidity 0.8.13 이상 버전에서 컴파일하되, 0.9.0 미만 버전에서만 컴파일하라는 뜻이다
- 여기서 ^ 가 중요한데, 0.8.13, 0.8.20, 0.8.25 버전은 가능하지만 0.9.0이나 1.0.0 같은 버전은 문법이나 동작 방식이 달라지기 때문에 되지 않는다.
5-2. Contract 선언
contract UserStorage {
이건 UserStorage라는 스마트 컨트랙트를 만든다는 뜻이다.
5-3. Mapping 선언
mapping(address => uint256) private values;
Mapping은 쉽게 말해 사용자 지갑의 주소값을 넣었을 때 그 주소에 저장된 숫자 값을 가져오는 역할이다.
Mapping 이해를 위한 비유
예를 들어서 JavaScript에서 아래와 같은 객체를 만들 수 있다.
const values = {
"성재": 10,
"민수": 50
};
그러면 아래와 같이 값을 찾을 수 있다.
values["성재"] // 10
values["민수"] // 50
Solidity의 mapping도 비슷하다. 다만 사용자의 이름 대신 지갑 주소를 key로 쓰는 것이다.
즉, 이런 식으로 저장된다는 뜻이다.
0xAAA... 주소 → 10
0xBBB... 주소 → 50
0xCCC... 주소 → 100
5-4. private이란?
private은 이 변수를 컨트랙트 외부에서 직접 읽을 수 없게 한다는 뜻이다.
private values;
외부에서 아래처럼 접근할 수 없다.
values[userAddress]
그래서 값을 읽고 싶다면 아래 함수처럼 읽어야 한다.
getMyValue()
getValueOf(address _user)
6. 함수 기능 및 설명
현재 UserStorage 컨트랙트에는 아래와 같이 함수가 3개 있다.
setMyValue(uint256 _value)
getMyValue()
getValueOf(address _user)
6-1. setMyValue 함수
이 함수는 내 주소에 값을 저장하는 함수이다.
function setMyValue(uint256 _value) public {
values[msg.sender] = _value;
}
예를 들어 사용자 A가 다음과 같이 호출을 했다고 해보자
setMyValue(0xAAA...)
그러면 컨트랙트는 이 함수를 호출한 사람의 지갑 주소를 확인한다.
그 역할을 하는 것이 msg.sender다.
따라서 정리하면 아래와 같이 된다.
msg.sender = 사용자의 지갑 주소
_value = 0xAAA...
6-2. getMyValue() 함수
이 함수는 내 지갑 주소에 저장된 값을 조회하는 함수다.
사용자A 주소에 0xAAA... 이 저장되어 있다면, 사용자A가 getMyValue()를 호출했을 때 0xAAA에 저장되어 있는 10이 반환된다.
function getMyValue() public view returns (uint256) {
return values[msg.sender];
}
따라서 사용자 별로 다른 값이 반환된다.
A가 호출 → A 주소에 저장된 값 반환
B가 호출 → B 주소에 저장된 값 반환
이 함수에는 view가 붙어 있는데, view는 상태를 변경하지 않고 읽기만 한다는 뜻이다.
즉, 이 함수는 블록체인에 저장된 값을 바꾸지 않고 조회만 한다.
public view returns (uint256)
6-3. getValueOf()
이 함수는 특정 지갑 주소에 저장된 값을 조회한다.
즉, 다른 사람의 지갑 주소를 알고 있다면 해당 주소의 값을 조회할 수 있는 기능이다.
function getValueOf(address _user) public view returns (uint256) {
return values[_user];
}
6-4. 세 함수 비교
사실 가장 기본적인 기능만 놓고 보면 setMyValue()와 getMyValue()만 있어도 된다.
하지만 getValueOf()가 있으면 특정 주소의 값을 확인할 수 있어서 테스트와 디버깅에 편리하다.
| 함수 | 역할 | 필요한 이유 |
| setMyValue() | 내 값 저장 | 사용자가 자기 주소에 값을 저장 |
| getMyValue() | 내 값 조회 | 사용자가 자신의 값을 쉽게 확인 |
| getValueOf() | 특정 주소 값 조회 | 다른 주소의 값이나 테스트 결과를 확인 |
7. 테스트 파일 생성
test 폴더 안에 다음 파일을 생성한다.
test/UserStorage.t.sol
그리고 아래 코드를 작성한다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {UserStorage} from "../src/UserStorage.sol";
contract UserStorageTest is Test {
UserStorage public userStorage;
address public userA = address(1);
address public userB = address(2);
function setUp() public {
userStorage = new UserStorage();
}
function test_SetMyValue() public {
userStorage.setMyValue(10);
assertEq(userStorage.getMyValue(), 10);
}
function test_DifferentUsersHaveDifferentValues() public {
vm.prank(userA);
userStorage.setMyValue(10);
vm.prank(userB);
userStorage.setMyValue(50);
assertEq(userStorage.getValueOf(userA), 10);
assertEq(userStorage.getValueOf(userB), 50);
}
function test_DefaultValueIsZero() public view {
assertEq(userStorage.getValueOf(userA), 0);
}
}
8. 테스트 파일 분석
8-1. import
Foundry에서 제공하는 테스트 도구를 가져오는 코드이다.
여기서 forge-std는 표준 라이브러리로 그 안에 있는 Test.sol에는 테스트에 필요한 여러 기능들이 들어 있다.
import {Test} from "forge-std/Test.sol";
그리고 테스트 코드에서 사용한 다른 기능들도 이 라이브러리에서 나온다.
assertEq(...)
vm.prank(...)
8-2. 테스트 컨트랙트 선언
UserStorageTest라는 테스트용 컨트랙트를 만드는 코드이다.
contract UserStorageTest is Test {
여기서 is Test란, UserStorageTest가 Foundry의 Test 기능을 상속 받는 것을 의미 한다.
is Test
따라서 테스트 컨트랙트 안에서 Foundry가 제공하는 여러 테스트 도구들을 사용할 수 있는 것이다.
- assertEq()
- vm.prank()
8-3. 가짜 사용자 주소 생성
이 줄은 테스트에서 사용할 가짜 사용자 주소를 만든 것이다.
테스트용 주소를 만들어서 이 주소가 userA고 이 주소가 userB라고 가정하는 것이다.
address public userA = address(1);
address public userB = address(2);
실제 이더리움 주소는 보통 20바이트, 즉 40개의 16진수 문자로 이루어져 있다.
address public userA = 0x0000000000000000000000000000000000000aAa;
8-4. setUp()
setUP()은 각 테스트 함수가 실행 되기 전 자동으로 먼저 실행되는 함수이다.
지금 내 코드에는 아래와 같이 3개의 테스트 함수가 있다.
test_SetMyValue()
test_DifferentUsersHaveDifferentValues()
test_DefaultValueIsZero()
그러면 Foundry는 대략 이렇게 실행한다.
테스트마다 새롭게 준비하는 것이다.
setUp() 실행
test_SetMyValue() 실행
setUp() 실행
test_DifferentUsersHaveDifferentValues() 실행
setUp() 실행
test_DefaultValueIsZero() 실행
이게 필요한 이유는, 테스트끼리 서로 영향을 주면 안 되기 때문이다.
예를 들어 첫 번째 테스트에서 값을 저장했는데 그 상태가 두 번째 테스트에 그대로 남아있다면 결과가 꼬일 수 있다.
그래서 각 테스트마다 새로운 UserStorage 컨트랙트를 만든다.
userStorage = new UserStorage();
8-5. 첫번째 테스트: test_SetMyValue()
이 테스트는 가장 기본적인 저장/조회를 한다.
검증 내용: 현재 호출자가 10을 저장하면, 다시 조회했을 때 10이 나오는가?
function test_SetMyValue() public {
userStorage.setMyValue(10);
assertEq(userStorage.getMyValue(), 10);
}
흐름은 아래와 같다.
- userStorage.setMyValue(10) 실행
- 현재 호출자의 주소에 10 저장
- userStorage.getMyValue() 실행
- 반환값이 10인지 확인
assertEq()는 두 값이 같은지 확인하는 함수이다.
뜻은, userStorage.getMyValue()의 결과가 10과 같은가? 이다.
같으면 통과, 다르면 실패
8-6. 두번째 테스트: test_DifferentUsersHaveDifferentValues()
이 테스트는 userA와 userB가 서로 다른 값을 가질 수 있는지 확인하는 것이다.
검증 내용: userA는 10, userB는 50을 각각 따로 저장할 수 있는가?
function test_DifferentUsersHaveDifferentValues() public {
vm.prank(userA);
userStorage.setMyValue(10);
vm.prank(userB);
userStorage.setMyValue(50);
assertEq(userStorage.getValueOf(userA), 10);
assertEq(userStorage.getValueOf(userB), 50);
}
vm.prank()는 Foundry 테스트에서 제공하는 기능이다.
뜻은, 다음 함수 호출 한 번은 userA가 호출한 것처럼 실행하라는 뜻이다.
이렇게 해석하면 된다. userA가 setMyValue(10)을 호출한 것처럼 실행하라
vm.prank(userA);
userStorage.setMyValue(10);
마지막으로 이제 값이 잘 저장됐는지를 확인한다.
assertEq(userStorage.getValueOf(userA), 10);
assertEq(userStorage.getValueOf(userB), 50);
8-7. 세번째 테스트: testDefaultValueIsZero()
이 테스트는 아직 값을 저장하지 않은 주소의 기본값이 0인지를 확인한다.
검증 내용: 아직 값을 저장하지 않은 주소의 기본값은 0인가?
Solidity에선 mapping(address => uint256)은 특정 주소에 값을 저장하지 않으면 기본값을 반환한다.
uint256의 기본값은 0이다.
function test_DefaultValueIsZero() public view {
assertEq(userStorage.getValueOf(userA), 0);
}
즉, 아무 값도 넣지 않은 상태에서 아래를 호출하면 0이 나와야 한다.
따라서 이 테스트는 userA가 아직 아무 값도 저장하지 않았을 때 0이 나오는가? 를 확인한다.
userStorage.getValueOf(userA)
8.8 테스트 파일 한 눈에 정리
- import {Test} from "forge-std/Test.sol";
→ Foundry 테스트 기능 가져오기 - import {UserStorage} from "../src/UserStorage.sol";
→ 테스트할 컨트랙트 가져오기 - contract UserStorageTest is Test
→ Foundry 테스트 기능을 상속받는 테스트 컨트랙트 만들기 - UserStorage public userStorage;
→ 테스트할 컨트랙트 변수 선언 - address(1), address(2)
→ 테스트용 가짜 사용자 주소 만들기 - setUp()
→ 각 테스트 전에 새 UserStorage 컨트랙트 생성 - test_SetMyValue()
→ 내 주소에 값 저장/조회 테스트 - test_DifferentUsersHaveDifferentValues()
→ userA, userB가 서로 다른 값을 가질 수 있는지 테스트 - test_DefaultValueIsZero()
→ 저장하지 않은 주소의 기본값이 0인지 테스트
9. 테스트 결과
테스트를 해본 결과 처음에 컴파일 오류가 났는데, 내가 uint256이라고 적여야 할 것을 unit256이라고 철자를 잘못 적어서 오류가 발생했다.
수정 후에 다시 컴파일 하니 모두 PASS가 된 것을 확인할 수 있다.

10. 실제 서비스로 확장한다면?
이번 UserStorage 코드는 단순히 숫자 값을 저장하지만, 실제 서비스에서는 의미 있는 데이터로 확장할 수 있다.
10-1. 투표 시스템
한 사람이 한 번만 투표하게 하는 시스템이라면 다음과 같이 사용할 수 있다.
- mapping(address => bool) public hasVoted; 사용자 별로 투표했는지 여부를 저장하는 공간
- 지갑 주소 -> 투표 여부
- 예를 들면,
A 주소 -> true
B 주소 -> false
C 주소 -> true
mapping(address => bool) public hasVoted;
function vote() public {
require(hasVoted[msg.sender] == false, "Already voted");
hasVoted[msg.sender] = true;
}
- function vote() public 는 투표 함수이다.
누군가 이 함수를 호출하면, "투표한다"고 생각하면 된다.
public이므로 외부에서 호출이 가능하며, 사용자가 지갑을 연결하고 프론트엔드에서 투표 버튼을 누르면 이 함수가 실행되는 구조다 - require는 조건이 참이면 에러 메시지가 실행되는 게 아니라, 조건이 거짓일 때 에러 메시지가 실행된다
현재 호출한 사람이 아직 투표하지 않았으면 계속 진행하고, 이미 투표했다면 에러를 발생시키고 함수를 중단한다.- 예를 들어 내가 투표 버튼을 누른다면 => msg.sender = 내 지갑 주소
그래서 내 지갑 주소의 투표 여부가 false인지 확인하라는 뜻이 된다.
만약 아직 안했다면 hasVoted[내 주소] == false가 참이므로 다음 줄로 넘어간다.
그러나, 이미 투표했다면 true가 되어서 조건이 거짓이 되고 함수가 중단되어 "Already voted"가 뜬다.
- 예를 들어 내가 투표 버튼을 누른다면 => msg.sender = 내 지갑 주소
11. 마무리
이번 실습에서는 UserStoragae 컨트랙트를 만들고, 사용자 별로 값을 저장하는 방법을 학습했다.
핵심은 다음과 같다.
- Solidity에선 사용자를 지갑 주소로 구분한다.
- msg.sender는 현재 함수를 호출한 사람의 주소이다.
- mapping(address => uint256)은 지갑 주소별 숫자 저장소이다.
'블록체인' 카테고리의 다른 글
| Hash란 (0) | 2026.05.26 |
|---|---|
| Gas fee란 (0) | 2026.05.25 |
| Solidity 실습: Foundry (0) | 2026.05.22 |
| Solidity 실습 준비: MacOS에서 Foundry 개발 환경 설치 (0) | 2026.05.20 |
| Solidity 알아보기 ( 공식 문서 참고 ) (0) | 2026.05.19 |