Ver3.0 ๐ ๋ผ๋ผ๋ฒจ๋ก ๋ง๋๋ ๋ธ๋ก์ฒด์ธ ๊ตฌ๋ ๊ฒฐ์ ์์คํ : Web3 ์๋์ ์๋ก์ด ์์ต ๋ชจ๋ธ ๊ตฌ์ถํ๊ธฐ

๐ ๋ผ๋ผ๋ฒจ๋ก ๋ง๋๋ ๋ธ๋ก์ฒด์ธ ๊ตฌ๋ ๊ฒฐ์ ์์คํ : Web3 ์๋์ ์๋ก์ด ์์ต ๋ชจ๋ธ ๊ตฌ์ถํ๊ธฐ
์ค๋งํธ ์ปจํธ๋ํธ์ PHP์ ๋ง๋จ, ํ์ค์ํ ๊ตฌ๋ ์๋น์ค์ ๋ชจ๋ ๊ฒ
๋ธ๋ก์ฒด์ธ ๊ธฐ์ ๊ณผ ๋ผ๋ผ๋ฒจ ํ๋ ์์ํฌ๋ฅผ ๊ฒฐํฉํ์ฌ ์์ ํ ์๋ก์ด ํํ์ ๊ตฌ๋ ๊ฒฐ์ ์์คํ ์ ๊ตฌํํ๋ ๋ฐฉ๋ฒ์ ์์๋ด ๋๋ค. ์ ํต์ ์ธ ์ ์ฉ์นด๋ ๊ฒฐ์ ๊ฐ ์๋, ์ํธํํ ๊ธฐ๋ฐ์ ์๋ ๊ตฌ๋ ์์คํ ์ ๋ง๋ค์ด๋ณด๋ฉด์ Web3 ์๋์ ๋น์ฆ๋์ค ๋ชจ๋ธ์ ์ง์ ๊ฒฝํํด๋ณผ ์ ์์ต๋๋ค. ๐
๐ ์ ๋ธ๋ก์ฒด์ธ ๊ตฌ๋ ๊ฒฐ์ ์ธ๊ฐ?
์ฌ๋ฌ๋ถ, ๋ทํ๋ฆญ์ค๋ ์คํฌํฐํ์ด ๊ฐ์ ๊ตฌ๋
์๋น์ค ์ฐ์์ฃ ? ๐บ ๋งค๋ฌ ์๋์ผ๋ก ๊ฒฐ์ ๋๋ ๊ทธ ํธ๋ฆฌํจ! ๊ทผ๋ฐ ์ด๊ฒ ๋ธ๋ก์ฒด์ธ์ผ๋ก ๊ฐ๋ฅํ๋ค๋ฉด ์ด๋จ๊น์? ๋ ๋ฎ์ ์์๋ฃ, ๊ตญ๊ฒฝ ์๋ ๊ฒฐ์ , ๊ทธ๋ฆฌ๊ณ ๋ฌด์๋ณด๋ค ์ค๊ฐ์ ์๋ ์ง์ ๊ฑฐ๋๊ฐ ๊ฐ๋ฅํด์ง๋๋ค.
์ ํต์ ์ธ ๊ฒฐ์ ์์คํ
์ ์ ์ฉ์นด๋์ฌ, ๊ฒฐ์ ๋ํ์ฌ(PG์ฌ) ๋ฑ ์ฌ๋ฌ ์ค๊ฐ์๋ฅผ ๊ฑฐ์น๋ฉด์ ์์๋ฃ๊ฐ 3~5%์ฉ ๋ถ์ต๋๋ค. ํ์ง๋ง ๋ธ๋ก์ฒด์ธ ๊ธฐ๋ฐ ๊ตฌ๋
๊ฒฐ์ ๋ ์ค๋งํธ ์ปจํธ๋ํธ๋ฅผ ํตํด ์๋ํ๋์ด ์์๋ฃ๋ฅผ 1% ๋ฏธ๋ง์ผ๋ก ์ค์ผ ์ ์์ฃ . ๐ฐ
ํนํ ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ๊ธ๋ก๋ฒ ํฌ๋ฆฌ์์ดํฐ๋ค์ด ์์ ์ ์ฌ๋ฅ์ ๊ตฌ๋
ํํ๋ก ํ๋งคํ๋ค๋ฉด? ๊ตญ๊ฐ ๊ฐ ์ก๊ธ ์ ์ฝ ์์ด, ์ฆ์ ์ ์ฐ๋ฐ์ ์ ์๋ ์์คํ
์ด ๊ฐ๋ฅํด์ง๋๋ค!
- โ ๋ฎ์ ์์๋ฃ (0.1~1% ์์ค)
- โ ๊ตญ๊ฒฝ ์๋ ๊ธ๋ก๋ฒ ๊ฒฐ์
- โ ์ฆ์ ์ ์ฐ (T+0)
- โ ํฌ๋ช ํ ๊ฑฐ๋ ๋ด์ญ
- โ ์ค๊ฐ์ ์๋ P2P ๊ฑฐ๋
- โ ํ๋ก๊ทธ๋๋จธ๋ธํ ์๋ํ (์ค๋งํธ ์ปจํธ๋ํธ)
๐ ๏ธ ๊ฐ๋ฐ ํ๊ฒฝ ์ค๋นํ๊ธฐ
์, ์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ๊ฐ๋ฐ์ ์์ํด๋ณผ๊น์? ๐จโ๐ป ๋จผ์ ํ์ํ ๋๊ตฌ๋ค์ ์ค๋นํด์ผ ํฉ๋๋ค. ์๊ฐ๋ณด๋ค ๋ณต์กํ์ง ์์ผ๋ ๊ฑฑ์ ๋ง์ธ์!
1. PHP 8.1 ์ด์ - ๋ผ๋ผ๋ฒจ 10.x๋ฅผ ์ฌ์ฉํ๋ ค๋ฉด ํ์์ ๋๋ค
2. Composer - PHP ํจํค์ง ๊ด๋ฆฌ์
3. Node.js & NPM - ํ๋ก ํธ์๋ ๋น๋ ๋๊ตฌ
4. MySQL ๋๋ PostgreSQL - ๋ฐ์ดํฐ๋ฒ ์ด์ค
5. MetaMask - ํ ์คํธ์ฉ ์ง๊ฐ
6. Infura ๋๋ Alchemy ๊ณ์ - ๋ธ๋ก์ฒด์ธ ๋ ธ๋ ์ ๊ทผ
7. Remix IDE - ์ค๋งํธ ์ปจํธ๋ํธ ๊ฐ๋ฐ (์ ํ์ฌํญ)
๋ผ๋ผ๋ฒจ ํ๋ก์ ํธ๋ฅผ ์๋ก ์์ฑํ๋ ๊ฒ๋ถํฐ ์์ํฉ๋๋ค:
# ๋ผ๋ผ๋ฒจ ํ๋ก์ ํธ ์์ฑ
composer create-project laravel/laravel blockchain-subscription
cd blockchain-subscription
# ํ์ํ ํจํค์ง ์ค์น
composer require web3p/web3.php
composer require simplito/elliptic-php
composer require kornrunner/keccak
# ํ๋ก ํธ์๋ ํจํค์ง
npm install ethers@5.7.2
npm install @metamask/detect-provider
web3.php๋ PHP์์ ์ด๋๋ฆฌ์ ๋ธ๋ก์ฒด์ธ๊ณผ ํต์ ํ ์ ์๊ฒ ํด์ฃผ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ๋๋ค. ํ๋ก ํธ์๋์์๋ ethers.js๋ฅผ ์ฌ์ฉํ๊ณ , ๋ฐฑ์๋์์๋ web3.php๋ฅผ ์ฌ์ฉํ๋ ํ์ด๋ธ๋ฆฌ๋ ๊ตฌ์กฐ๊ฐ ๊ฐ์ฅ ์์ ์ ์ ๋๋ค. ๐ง
๐ ์ค๋งํธ ์ปจํธ๋ํธ ์์ฑํ๊ธฐ
๋ธ๋ก์ฒด์ธ ๊ตฌ๋ ์์คํ ์ ํต์ฌ์ ๋ฐ๋ก ์ค๋งํธ ์ปจํธ๋ํธ์ ๋๋ค. Solidity ์ธ์ด๋ก ์์ฑํ๋ฉฐ, ์ด๋๋ฆฌ์ ๋คํธ์ํฌ์ ๋ฐฐํฌ๋ฉ๋๋ค. ๊ตฌ๋ ๋ก์ง์ ์ฝ๋๋ก ๊ตฌํํ๋ฉด ๋๊ตฌ๋ ์กฐ์ํ ์ ์๋ ํฌ๋ช ํ ์์คํ ์ด ์์ฑ๋์ฃ ! โก
๋จผ์ ๊ธฐ๋ณธ์ ์ธ ๊ตฌ๋ ์ปจํธ๋ํธ ๊ตฌ์กฐ๋ฅผ ์ดํด๋ณด๊ฒ ์ต๋๋ค:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract SubscriptionService is ReentrancyGuard, Ownable {
// ๊ตฌ๋
ํ๋ ๊ตฌ์กฐ์ฒด
struct Plan {
uint256 price; // ์ ๊ตฌ๋
๋ฃ (ํ ํฐ ๋จ์)
uint256 duration; // ๊ตฌ๋
๊ธฐ๊ฐ (์ด ๋จ์)
bool active; // ํ๋ ํ์ฑํ ์ฌ๋ถ
string name; // ํ๋ ์ด๋ฆ
}
// ์ฌ์ฉ์ ๊ตฌ๋
์ ๋ณด
struct Subscription {
uint256 planId; // ๊ตฌ๋
์ค์ธ ํ๋ ID
uint256 startTime; // ๊ตฌ๋
์์ ์๊ฐ
uint256 endTime; // ๊ตฌ๋
์ข
๋ฃ ์๊ฐ
bool autoRenew; // ์๋ ๊ฐฑ์ ์ฌ๋ถ
bool active; // ๊ตฌ๋
ํ์ฑํ ์ฌ๋ถ
}
// ์ํ ๋ณ์
IERC20 public paymentToken; // ๊ฒฐ์ ํ ํฐ (USDT, USDC ๋ฑ)
mapping(uint256 => Plan) public plans; // ํ๋ ID => ํ๋ ์ ๋ณด
mapping(address => Subscription) public subscriptions; // ์ฌ์ฉ์ ์ฃผ์ => ๊ตฌ๋
์ ๋ณด
uint256 public planCount; // ์ด ํ๋ ๊ฐ์
// ์ด๋ฒคํธ
event PlanCreated(uint256 indexed planId, string name, uint256 price);
event Subscribed(address indexed user, uint256 indexed planId, uint256 endTime);
event Renewed(address indexed user, uint256 indexed planId, uint256 newEndTime);
event Cancelled(address indexed user, uint256 indexed planId);
event AutoRenewToggled(address indexed user, bool autoRenew);
constructor(address _paymentToken) {
paymentToken = IERC20(_paymentToken);
}
// ํ๋ ์์ฑ (๊ด๋ฆฌ์๋ง)
function createPlan(
string memory _name,
uint256 _price,
uint256 _duration
) external onlyOwner {
planCount++;
plans[planCount] = Plan({
name: _name,
price: _price,
duration: _duration,
active: true
});
emit PlanCreated(planCount, _name, _price);
}
// ๊ตฌ๋
์์
function subscribe(uint256 _planId) external nonReentrant {
require(plans[_planId].active, "Plan not active");
require(!subscriptions[msg.sender].active, "Already subscribed");
Plan memory plan = plans[_planId];
// ํ ํฐ ์ ์ก
require(
paymentToken.transferFrom(msg.sender, address(this), plan.price),
"Payment failed"
);
// ๊ตฌ๋
์ ๋ณด ์ ์ฅ
uint256 endTime = block.timestamp + plan.duration;
subscriptions[msg.sender] = Subscription({
planId: _planId,
startTime: block.timestamp,
endTime: endTime,
autoRenew: true,
active: true
});
emit Subscribed(msg.sender, _planId, endTime);
}
// ๊ตฌ๋
๊ฐฑ์
function renew() external nonReentrant {
Subscription storage sub = subscriptions[msg.sender];
require(sub.active, "No active subscription");
require(block.timestamp >= sub.endTime, "Subscription still valid");
require(sub.autoRenew, "Auto-renew disabled");
Plan memory plan = plans[sub.planId];
require(plan.active, "Plan no longer active");
// ํ ํฐ ์ ์ก
require(
paymentToken.transferFrom(msg.sender, address(this), plan.price),
"Payment failed"
);
// ๊ตฌ๋
๊ธฐ๊ฐ ์ฐ์ฅ
sub.endTime = block.timestamp + plan.duration;
emit Renewed(msg.sender, sub.planId, sub.endTime);
}
// ๊ตฌ๋
์ทจ์
function cancel() external {
Subscription storage sub = subscriptions[msg.sender];
require(sub.active, "No active subscription");
sub.active = false;
sub.autoRenew = false;
emit Cancelled(msg.sender, sub.planId);
}
// ์๋ ๊ฐฑ์ ํ ๊ธ
function toggleAutoRenew() external {
Subscription storage sub = subscriptions[msg.sender];
require(sub.active, "No active subscription");
sub.autoRenew = !sub.autoRenew;
emit AutoRenewToggled(msg.sender, sub.autoRenew);
}
// ๊ตฌ๋
์ํ ํ์ธ
function isSubscriptionValid(address _user) external view returns (bool) {
Subscription memory sub = subscriptions[_user];
return sub.active && block.timestamp < sub.endTime;
}
// ๋จ์ ๊ตฌ๋
๊ธฐ๊ฐ ์กฐํ
function getRemainingTime(address _user) external view returns (uint256) {
Subscription memory sub = subscriptions[_user];
if (!sub.active || block.timestamp >= sub.endTime) {
return 0;
}
return sub.endTime - block.timestamp;
}
// ์์ต ์ธ์ถ (๊ด๋ฆฌ์๋ง)
function withdraw(uint256 _amount) external onlyOwner {
require(
paymentToken.transfer(owner(), _amount),
"Withdrawal failed"
);
}
}
์ค๋งํธ ์ปจํธ๋ํธ๋ ๋ฐฐํฌ ํ ์์ ์ด ๋ถ๊ฐ๋ฅํฉ๋๋ค! ๋ฐ๋์ ํ ์คํธ๋ท(Goerli, Sepolia ๋ฑ)์์ ์ถฉ๋ถํ ํ ์คํธํ ํ ๋ฉ์ธ๋ท์ ๋ฐฐํฌํ์ธ์. OpenZeppelin์ ๊ฒ์ฆ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ด ์์ ํฉ๋๋ค. ๐
์ ์ปจํธ๋ํธ์ ํต์ฌ ๊ธฐ๋ฅ๋ค์ ์ ๋ฆฌํ๋ฉด:
| ํจ์๋ช | ๊ธฐ๋ฅ | ๊ถํ |
|---|---|---|
| createPlan | ์๋ก์ด ๊ตฌ๋ ํ๋ ์์ฑ | ๊ด๋ฆฌ์ |
| subscribe | ๊ตฌ๋ ์์ ๋ฐ ๊ฒฐ์ | ๋ชจ๋ ์ฌ์ฉ์ |
| renew | ๊ตฌ๋ ๊ฐฑ์ (์๋/์๋) | ๊ตฌ๋ ์ |
| cancel | ๊ตฌ๋ ์ทจ์ | ๊ตฌ๋ ์ |
| toggleAutoRenew | ์๋ ๊ฐฑ์ ์ค์ ๋ณ๊ฒฝ | ๊ตฌ๋ ์ |
| isSubscriptionValid | ๊ตฌ๋ ์ ํจ์ฑ ํ์ธ | ์ฝ๊ธฐ ์ ์ฉ |
| withdraw | ์์ต ์ธ์ถ | ๊ด๋ฆฌ์ |
๐ ์ค๋งํธ ์ปจํธ๋ํธ ๋ฐฐํฌํ๊ธฐ
์ปจํธ๋ํธ๋ฅผ ์์ฑํ๋ค๋ฉด ์ด์ ๋ธ๋ก์ฒด์ธ์ ๋ฐฐํฌํด์ผ๊ฒ ์ฃ ? ๐ Hardhat์ ์ฌ์ฉํ๋ฉด ๋ฐฐํฌ ๊ณผ์ ์ ์๋ํํ ์ ์์ต๋๋ค.
# Hardhat ํ๋ก์ ํธ ์ด๊ธฐํ
npm install --save-dev hardhat
npx hardhat
# ํ์ํ ํ๋ฌ๊ทธ์ธ ์ค์น
npm install --save-dev @nomiclabs/hardhat-ethers ethers
npm install --save-dev @openzeppelin/contracts
๋ฐฐํฌ ์คํฌ๋ฆฝํธ๋ฅผ ์์ฑํฉ๋๋ค (scripts/deploy.js):
const hre = require("hardhat");
async function main() {
// USDT ํ ํฐ ์ฃผ์ (ํ
์คํธ๋ท)
const USDT_ADDRESS = "0x..."; // Goerli USDT ์ฃผ์
console.log("Deploying SubscriptionService...");
const SubscriptionService = await hre.ethers.getContractFactory("SubscriptionService");
const subscription = await SubscriptionService.deploy(USDT_ADDRESS);
await subscription.deployed();
console.log("SubscriptionService deployed to:", subscription.address);
// ๊ธฐ๋ณธ ํ๋ ์์ฑ
console.log("Creating default plans...");
// ๋ฒ ์ด์ง ํ๋: ์ $9.99 (30์ผ)
await subscription.createPlan(
"Basic Plan",
hre.ethers.utils.parseUnits("9.99", 6), // USDT๋ 6 decimals
30 * 24 * 60 * 60 // 30์ผ
);
// ํ๋ก ํ๋: ์ $29.99 (30์ผ)
await subscription.createPlan(
"Pro Plan",
hre.ethers.utils.parseUnits("29.99", 6),
30 * 24 * 60 * 60
);
// ์ํฐํ๋ผ์ด์ฆ ํ๋: ์ $99.99 (30์ผ)
await subscription.createPlan(
"Enterprise Plan",
hre.ethers.utils.parseUnits("99.99", 6),
30 * 24 * 60 * 60
);
console.log("Plans created successfully!");
// ์ปจํธ๋ํธ ์ ๋ณด ์ ์ฅ
const fs = require('fs');
const contractInfo = {
address: subscription.address,
network: hre.network.name,
deployedAt: new Date().toISOString()
};
fs.writeFileSync(
'./contract-info.json',
JSON.stringify(contractInfo, null, 2)
);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
hardhat.config.js ์ค์ :
require("@nomiclabs/hardhat-ethers");
require('dotenv').config();
module.exports = {
solidity: {
version: "0.8.19",
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
},
networks: {
goerli: {
url: process.env.GOERLI_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
chainId: 5
},
sepolia: {
url: process.env.SEPOLIA_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
chainId: 11155111
},
mainnet: {
url: process.env.MAINNET_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
chainId: 1
}
}
};
๋ฐฐํฌ ์คํ:
# ํ
์คํธ๋ท ๋ฐฐํฌ
npx hardhat run scripts/deploy.js --network goerli
# ๋ฉ์ธ๋ท ๋ฐฐํฌ (์ถฉ๋ถํ ํ
์คํธ ํ!)
npx hardhat run scripts/deploy.js --network mainnet
๐ ๋ผ๋ผ๋ฒจ๊ณผ ๋ธ๋ก์ฒด์ธ ์ฐ๋ํ๊ธฐ
์ด์ ๋ผ๋ผ๋ฒจ ๋ฐฑ์๋์์ ์ค๋งํธ ์ปจํธ๋ํธ์ ํต์ ํ๋ ๋ถ๋ถ์ ๊ตฌํํด๋ด ์๋ค. ๐ ๋จผ์ Web3 ์๋น์ค ํด๋์ค๋ฅผ ๋ง๋ญ๋๋ค.
// app/Services/Web3Service.php
<?php
namespace App\Services;
use Web3\Web3;
use Web3\Contract;
use Web3\Utils;
class Web3Service
{
private $web3;
private $contract;
private $contractAddress;
private $abi;
public function __construct()
{
// Infura ๋๋ Alchemy RPC URL
$rpcUrl = config('web3.rpc_url');
$this->web3 = new Web3($rpcUrl);
// ์ปจํธ๋ํธ ์ฃผ์์ ABI ๋ก๋
$this->contractAddress = config('web3.contract_address');
$this->abi = json_decode(file_get_contents(storage_path('app/contract-abi.json')), true);
$this->contract = new Contract($rpcUrl, $this->abi);
}
/**
* ์ฌ์ฉ์์ ๊ตฌ๋
์ํ ํ์ธ
*/
public function checkSubscription(string $userAddress): array
{
$subscription = null;
$this->contract->at($this->contractAddress)->call('subscriptions', $userAddress, function ($err, $result) use (&$subscription) {
if ($err !== null) {
throw new \Exception('Failed to fetch subscription: ' . $err->getMessage());
}
$subscription = $result;
});
return [
'plan_id' => $subscription['planId']->toString(),
'start_time' => $subscription['startTime']->toString(),
'end_time' => $subscription['endTime']->toString(),
'auto_renew' => $subscription['autoRenew'],
'active' => $subscription['active']
];
}
/**
* ๊ตฌ๋
์ ํจ์ฑ ํ์ธ
*/
public function isSubscriptionValid(string $userAddress): bool
{
$isValid = false;
$this->contract->at($this->contractAddress)->call('isSubscriptionValid', $userAddress, function ($err, $result) use (&$isValid) {
if ($err !== null) {
throw new \Exception('Failed to check validity: ' . $err->getMessage());
}
$isValid = $result[0];
});
return $isValid;
}
/**
* ๋จ์ ๊ตฌ๋
๊ธฐ๊ฐ ์กฐํ (์ด ๋จ์)
*/
public function getRemainingTime(string $userAddress): int
{
$remainingTime = 0;
$this->contract->at($this->contractAddress)->call('getRemainingTime', $userAddress, function ($err, $result) use (&$remainingTime) {
if ($err !== null) {
throw new \Exception('Failed to get remaining time: ' . $err->getMessage());
}
$remainingTime = (int) $result[0]->toString();
});
return $remainingTime;
}
/**
* ํ๋ ์ ๋ณด ์กฐํ
*/
public function getPlan(int $planId): array
{
$plan = null;
$this->contract->at($this->contractAddress)->call('plans', $planId, function ($err, $result) use (&$plan) {
if ($err !== null) {
throw new \Exception('Failed to fetch plan: ' . $err->getMessage());
}
$plan = $result;
});
return [
'price' => $plan['price']->toString(),
'duration' => $plan['duration']->toString(),
'active' => $plan['active'],
'name' => $plan['name']
];
}
/**
* ํธ๋์ญ์
ํ์ธ
*/
public function getTransactionReceipt(string $txHash): ?array
{
$receipt = null;
$this->web3->eth->getTransactionReceipt($txHash, function ($err, $result) use (&$receipt) {
if ($err !== null) {
return null;
}
$receipt = $result;
});
return $receipt;
}
/**
* ์ด๋ฒคํธ ๋ก๊ทธ ํ์ฑ
*/
public function parseSubscribedEvent(array $log): array
{
// ์ด๋ฒคํธ ์๊ทธ๋์ฒ: Subscribed(address,uint256,uint256)
$eventSignature = '0x' . hash('sha3-256', 'Subscribed(address,uint256,uint256)');
if ($log['topics'][0] !== $eventSignature) {
throw new \Exception('Invalid event signature');
}
return [
'user' => '0x' . substr($log['topics'][1], 26),
'plan_id' => hexdec($log['topics'][2]),
'end_time' => hexdec($log['data'])
];
}
}
์ค์ ํ์ผ ์์ฑ (config/web3.php):
<?php
return [
'rpc_url' => env('WEB3_RPC_URL', 'https://goerli.infura.io/v3/YOUR_PROJECT_ID'),
'contract_address' => env('WEB3_CONTRACT_ADDRESS'),
'chain_id' => env('WEB3_CHAIN_ID', 5),
'payment_token' => env('WEB3_PAYMENT_TOKEN'),
];
๐พ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค๊ณ
๋ธ๋ก์ฒด์ธ ๋ฐ์ดํฐ๋ฅผ ๋ก์ปฌ DB์ ๋๊ธฐํํ์ฌ ๋น ๋ฅธ ์กฐํ์ ๋ถ์์ ๊ฐ๋ฅํ๊ฒ ํฉ๋๋ค. ๐
// database/migrations/2024_01_01_000001_create_subscriptions_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('wallet_address', 42)->index();
$table->unsignedBigInteger('plan_id');
$table->timestamp('start_time');
$table->timestamp('end_time');
$table->boolean('auto_renew')->default(true);
$table->boolean('active')->default(true);
$table->string('tx_hash', 66)->nullable();
$table->timestamps();
$table->index(['wallet_address', 'active']);
$table->index('end_time');
});
Schema::create('subscription_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id')->constrained()->onDelete('cascade');
$table->string('tx_hash', 66)->unique();
$table->string('type', 20); // subscribe, renew, cancel
$table->string('amount', 78); // BigNumber string
$table->unsignedBigInteger('block_number');
$table->timestamp('block_timestamp');
$table->string('status', 20)->default('pending'); // pending, confirmed, failed
$table->timestamps();
$table->index('tx_hash');
$table->index(['subscription_id', 'type']);
});
Schema::create('subscription_plans', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('chain_plan_id')->unique();
$table->string('name');
$table->string('price', 78);
$table->unsignedInteger('duration'); // seconds
$table->boolean('active')->default(true);
$table->text('description')->nullable();
$table->json('features')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('subscription_transactions');
Schema::dropIfExists('subscriptions');
Schema::dropIfExists('subscription_plans');
}
};
Eloquent ๋ชจ๋ธ ์์ฑ:
// app/Models/Subscription.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Carbon\Carbon;
class Subscription extends Model
{
protected $fillable = [
'user_id',
'wallet_address',
'plan_id',
'start_time',
'end_time',
'auto_renew',
'active',
'tx_hash'
];
protected $casts = [
'start_time' => 'datetime',
'end_time' => 'datetime',
'auto_renew' => 'boolean',
'active' => 'boolean'
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(SubscriptionPlan::class, 'plan_id', 'chain_plan_id');
}
public function transactions(): HasMany
{
return $this->hasMany(SubscriptionTransaction::class);
}
/**
* ๊ตฌ๋
์ด ์ ํจํ์ง ํ์ธ
*/
public function isValid(): bool
{
return $this->active && $this->end_time->isFuture();
}
/**
* ๋จ์ ์ผ์ ๊ณ์ฐ
*/
public function daysRemaining(): int
{
if (!$this->isValid()) {
return 0;
}
return Carbon::now()->diffInDays($this->end_time, false);
}
/**
* ๋ง๋ฃ ์๋ฐ ์ฌ๋ถ (3์ผ ์ด๋ด)
*/
public function isExpiringSoon(): bool
{
$days = $this->daysRemaining();
return $days > 0 && $days <= 3;
}
/**
* ์ค์ฝํ: ํ์ฑ ๊ตฌ๋
๋ง
*/
public function scopeActive($query)
{
return $query->where('active', true)
->where('end_time', '>', now());
}
/**
* ์ค์ฝํ: ๋ง๋ฃ๋ ๊ตฌ๋
*/
public function scopeExpired($query)
{
return $query->where('end_time', '<=', now());
}
}
๐จ ํ๋ก ํธ์๋ ๊ตฌํ (Vue.js + Ethers.js)
์ฌ์ฉ์๊ฐ MetaMask๋ก ์ง๊ฐ์ ์ฐ๊ฒฐํ๊ณ ๊ตฌ๋ ํ ์ ์๋ ์ธํฐํ์ด์ค๋ฅผ ๋ง๋ค์ด๋ด ์๋ค. ๐ญ
// resources/js/components/SubscriptionManager.vue
<template>
<div class="subscription-manager">
<div v-if="!walletConnected" class="connect-wallet">
<h2>์ง๊ฐ ์ฐ๊ฒฐ์ด ํ์ํฉ๋๋ค</h2>
<button @click="connectWallet" class="btn-primary">
MetaMask ์ฐ๊ฒฐ
</button>
</div>
<div v-else>
<div class="wallet-info">
<p>์ฐ๊ฒฐ๋ ์ง๊ฐ: {{ shortenAddress(account) }}</p>
<p>์์ก: {{ balance }} USDT</p>
</div>
<div v-if="!hasSubscription" class="plans">
<h2>๊ตฌ๋
ํ๋ ์ ํ</h2>
<div class="plan-grid">
<div
v-for="plan in plans"
:key="plan.id"
class="plan-card"
:class="{ selected: selectedPlan === plan.id }"
@click="selectPlan(plan.id)"
>
<h3>{{ plan.name }}</h3>
<div class="price">${{ plan.price }}</div>
<div class="duration">{{ plan.durationDays }}์ผ</div>
<ul class="features">
<li v-for="feature in plan.features" :key="feature">
โ {{ feature }}
</li>
</ul>
</div>
</div>
<button
@click="subscribe"
:disabled="!selectedPlan || subscribing"
class="btn-subscribe"
>
{{ subscribing ? '์ฒ๋ฆฌ ์ค...' : '๊ตฌ๋
ํ๊ธฐ' }}
</button>
</div>
<div v-else class="current-subscription">
<h2>ํ์ฌ ๊ตฌ๋
์ ๋ณด</h2>
<div class="sub-info">
<p><strong>ํ๋:</strong> {{ currentSubscription.planName }}</p>
<p><strong>์์์ผ:</strong> {{ formatDate(currentSubscription.startTime) }}</p>
<p><strong>์ข
๋ฃ์ผ:</strong> {{ formatDate(currentSubscription.endTime) }}</p>
<p><strong>๋จ์ ๊ธฐ๊ฐ:</strong> {{ daysRemaining }}์ผ</p>
<p>
<strong>์๋ ๊ฐฑ์ :</strong>
<span :class="currentSubscription.autoRenew ? 'text-success' : 'text-danger'">
{{ currentSubscription.autoRenew ? 'ํ์ฑํ' : '๋นํ์ฑํ' }}
</span>
</p>
</div>
<div class="actions">
<button @click="toggleAutoRenew" class="btn-secondary">
์๋ ๊ฐฑ์ {{ currentSubscription.autoRenew ? '๋๊ธฐ' : '์ผ๊ธฐ' }}
</button>
<button @click="cancelSubscription" class="btn-danger">
๊ตฌ๋
์ทจ์
</button>
</div>
</div>
</div>
<div v-if="txHash" class="transaction-status">
<p>ํธ๋์ญ์
ํด์: {{ txHash }}</p>
<a :href="getEtherscanUrl(txHash)" target="_blank">
Etherscan์์ ํ์ธ
</a>
</div>
</div>
</template>
<script>
import { ethers } from 'ethers';
import detectEthereumProvider from '@metamask/detect-provider';
import contractABI from '../contracts/SubscriptionService.json';
export default {
name: 'SubscriptionManager',
data() {
return {
provider: null,
signer: null,
contract: null,
account: null,
balance: '0',
walletConnected: false,
plans: [],
selectedPlan: null,
subscribing: false,
hasSubscription: false,
currentSubscription: null,
txHash: null,
contractAddress: process.env.MIX_CONTRACT_ADDRESS,
usdtAddress: process.env.MIX_USDT_ADDRESS
};
},
computed: {
daysRemaining() {
if (!this.currentSubscription) return 0;
const now = Math.floor(Date.now() / 1000);
const remaining = this.currentSubscription.endTime - now;
return Math.floor(remaining / 86400);
}
},
async mounted() {
await this.initWeb3();
await this.loadPlans();
},
methods: {
async initWeb3() {
const ethereumProvider = await detectEthereumProvider();
if (!ethereumProvider) {
alert('MetaMask๋ฅผ ์ค์นํด์ฃผ์ธ์!');
return;
}
this.provider = new ethers.providers.Web3Provider(window.ethereum);
},
async connectWallet() {
try {
const accounts = await this.provider.send('eth_requestAccounts', []);
this.account = accounts[0];
this.signer = this.provider.getSigner();
this.walletConnected = true;
// ์ปจํธ๋ํธ ์ธ์คํด์ค ์์ฑ
this.contract = new ethers.Contract(
this.contractAddress,
contractABI.abi,
this.signer
);
await this.loadBalance();
await this.checkSubscription();
// ๊ณ์ ๋ณ๊ฒฝ ๊ฐ์ง
window.ethereum.on('accountsChanged', (accounts) => {
this.account = accounts[0];
this.checkSubscription();
});
} catch (error) {
console.error('์ง๊ฐ ์ฐ๊ฒฐ ์คํจ:', error);
alert('์ง๊ฐ ์ฐ๊ฒฐ์ ์คํจํ์ต๋๋ค.');
}
},
async loadBalance() {
const usdtContract = new ethers.Contract(
this.usdtAddress,
['function balanceOf(address) view returns (uint256)'],
this.provider
);
const balance = await usdtContract.balanceOf(this.account);
this.balance = ethers.utils.formatUnits(balance, 6); // USDT๋ 6 decimals
},
async loadPlans() {
try {
const response = await axios.get('/api/subscription/plans');
this.plans = response.data;
} catch (error) {
console.error('ํ๋ ๋ก๋ ์คํจ:', error);
}
},
async checkSubscription() {
try {
const sub = await this.contract.subscriptions(this.account);
if (sub.active) {
this.hasSubscription = true;
this.currentSubscription = {
planId: sub.planId.toNumber(),
planName: await this.getPlanName(sub.planId.toNumber()),
startTime: sub.startTime.toNumber(),
endTime: sub.endTime.toNumber(),
autoRenew: sub.autoRenew,
active: sub.active
};
}
} catch (error) {
console.error('๊ตฌ๋
์ ๋ณด ์กฐํ ์คํจ:', error);
}
},
async getPlanName(planId) {
const plan = await this.contract.plans(planId);
return plan.name;
},
selectPlan(planId) {
this.selectedPlan = planId;
},
async subscribe() {
if (!this.selectedPlan) return;
this.subscribing = true;
try {
// 1. USDT approve
const plan = this.plans.find(p => p.id === this.selectedPlan);
const amount = ethers.utils.parseUnits(plan.price.toString(), 6);
const usdtContract = new ethers.Contract(
this.usdtAddress,
[
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)'
],
this.signer
);
// ํ์ฌ allowance ํ์ธ
const currentAllowance = await usdtContract.allowance(
this.account,
this.contractAddress
);
if (currentAllowance.lt(amount)) {
console.log('USDT approve ์งํ ์ค...');
const approveTx = await usdtContract.approve(
this.contractAddress,
amount
);
await approveTx.wait();
console.log('USDT approve ์๋ฃ');
}
// 2. ๊ตฌ๋
์คํ
console.log('๊ตฌ๋
์งํ ์ค...');
const tx = await this.contract.subscribe(this.selectedPlan);
this.txHash = tx.hash;
// ํธ๋์ญ์
๋๊ธฐ
const receipt = await tx.wait();
console.log('๊ตฌ๋
์๋ฃ:', receipt);
// ์๋ฒ์ ๋๊ธฐํ
await axios.post('/api/subscription/sync', {
tx_hash: tx.hash,
wallet_address: this.account
});
// ๊ตฌ๋
์ ๋ณด ์๋ก๊ณ ์นจ
await this.checkSubscription();
alert('๊ตฌ๋
์ด ์๋ฃ๋์์ต๋๋ค!');
} catch (error) {
console.error('๊ตฌ๋
์คํจ:', error);
alert('๊ตฌ๋
์ ์คํจํ์ต๋๋ค: ' + error.message);
} finally {
this.subscribing = false;
}
},
async toggleAutoRenew() {
try {
const tx = await this.contract.toggleAutoRenew();
await tx.wait();
await this.checkSubscription();
alert('์๋ ๊ฐฑ์ ์ค์ ์ด ๋ณ๊ฒฝ๋์์ต๋๋ค.');
} catch (error) {
console.error('์ค์ ๋ณ๊ฒฝ ์คํจ:', error);
alert('์ค์ ๋ณ๊ฒฝ์ ์คํจํ์ต๋๋ค.');
}
},
async cancelSubscription() {
if (!confirm('์ ๋ง ๊ตฌ๋
์ ์ทจ์ํ์๊ฒ ์ต๋๊น?')) return;
try {
const tx = await this.contract.cancel();
await tx.wait();
this.hasSubscription = false;
this.currentSubscription = null;
alert('๊ตฌ๋
์ด ์ทจ์๋์์ต๋๋ค.');
} catch (error) {
console.error('๊ตฌ๋
์ทจ์ ์คํจ:', error);
alert('๊ตฌ๋
์ทจ์์ ์คํจํ์ต๋๋ค.');
}
},
shortenAddress(address) {
if (!address) return '';
return `${address.slice(0, 6)}...${address.slice(-4)}`;
},
formatDate(timestamp) {
return new Date(timestamp * 1000).toLocaleDateString('ko-KR');
},
getEtherscanUrl(txHash) {
const network = process.env.MIX_NETWORK === 'mainnet' ? '' : 'goerli.';
return `https://${network}etherscan.io/tx/${txHash}`;
}
}
};
</script>
<style>.tx-header{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);
color:#fff;
padding:40px 30px;
border-radius:16px;
text-align:center;
margin-bottom:40px;
box-shadow:0 8px 24px rgba(102,126,234,0.25);}
.tx-intro-box{background:#f8f9fa;
border-left:5px solid #667eea;
padding:20px;
margin:30px 0;
border-radius:8px;}
.tx-section{margin:50px 0;}
.tx-highlight{background:linear-gradient(120deg,#fef3c7 0%,#fde68a 100%);
padding:3px 8px;
border-radius:4px;
font-weight:600;}
.tx-tip-box{background:#ecfdf5;
border:2px solid #10b981;
padding:18px;
border-radius:12px;
margin:25px 0;}
.tx-warning-box{background:#fef2f2;
border:2px solid #ef4444;
padding:18px;
border-radius:12px;
margin:25px 0;}
.tx-info-box{background:#eff6ff;
border:2px solid #3b82f6;
padding:18px;
border-radius:12px;
margin:25px 0;}
.tx-step-box{background:#fff;
border:2px solid #e5e7eb;
padding:20px;
border-radius:12px;
margin:20px 0;
box-shadow:0 2px 8px rgba(0,0,0,0.05);}
.tx-table-wrap{overflow-x:auto;
margin:25px 0;}
.tx-table{width:100%;
border-collapse:collapse;
background:#fff;
border-radius:8px;
overflow:hidden;
box-shadow:0 2px 8px rgba(0,0,0,0.05);}
.tx-table th{background:#667eea;
color:#fff;
padding:12px;
text-align:left;
font-weight:600;}
.tx-table td{padding:12px;
border-bottom:1px solid #e5e7eb;}
.tx-table tr:last-child td{border-bottom:none;}
.tx-subtitle{color:#667eea;
font-weight:700;
font-size:1.3em;
margin:35px 0 20px 0;
padding-bottom:10px;
border-bottom:3px solid #667eea;}
.tx-point{color:#764ba2;
font-weight:600;}
.tx-emoji{font-size:1.3em;
margin:0 5px;}</style>
๐ ๋ธ๋ก์ฒด์ธ ์ด๋ฒคํธ ๋ฆฌ์ค๋ ๋ฐ ๋๊ธฐํ
๋ธ๋ก์ฒด์ธ์์ ๋ฐ์ํ๋ ์ด๋ฒคํธ๋ฅผ ์ค์๊ฐ์ผ๋ก ๊ฐ์งํ๊ณ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋๊ธฐํํ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค. โก Laravel์ Job๊ณผ Queue๋ฅผ ํ์ฉํ๋ฉด ํจ์จ์ ์ผ๋ก ์ฒ๋ฆฌํ ์ ์์ต๋๋ค.
// app/Jobs/SyncBlockchainEvents.php
<?php
namespace App\Jobs;
use App\Models\Subscription;
use App\Models\SubscriptionTransaction;
use App\Services\Web3Service;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SyncBlockchainEvents implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private $web3Service;
public function __construct()
{
$this->web3Service = new Web3Service();
}
public function handle()
{
try {
// ๋ง์ง๋ง ๋๊ธฐํ๋ ๋ธ๋ก ๋ฒํธ ๊ฐ์ ธ์ค๊ธฐ
$lastBlock = cache('last_synced_block', 0);
$currentBlock = $this->web3Service->getCurrentBlockNumber();
Log::info("Syncing blocks from {$lastBlock} to {$currentBlock}");
// ์ด๋ฒคํธ ๋ก๊ทธ ๊ฐ์ ธ์ค๊ธฐ
$events = $this->web3Service->getContractEvents($lastBlock + 1, $currentBlock);
foreach ($events as $event) {
$this->processEvent($event);
}
// ๋ง์ง๋ง ๋ธ๋ก ๋ฒํธ ์บ์ ์
๋ฐ์ดํธ
cache(['last_synced_block' => $currentBlock], now()->addDays(7));
Log::info("Sync completed. Processed " . count($events) . " events");
} catch (\Exception $e) {
Log::error("Blockchain sync failed: " . $e->getMessage());
throw $e;
}
}
private function processEvent(array $event)
{
$eventName = $event['event'];
switch ($eventName) {
case 'Subscribed':
$this->handleSubscribed($event);
break;
case 'Renewed':
$this->handleRenewed($event);
break;
case 'Cancelled':
$this->handleCancelled($event);
break;
case 'AutoRenewToggled':
$this->handleAutoRenewToggled($event);
break;
}
}
private function handleSubscribed(array $event)
{
$userAddress = $event['args']['user'];
$planId = $event['args']['planId'];
$endTime = $event['args']['endTime'];
// ์ฌ์ฉ์ ์ฐพ๊ธฐ ๋๋ ์์ฑ
$user = User::firstOrCreate(
['wallet_address' => strtolower($userAddress)],
['name' => 'User ' . substr($userAddress, 0, 8)]
);
// ๊ตฌ๋
์์ฑ
$subscription = Subscription::create([
'user_id' => $user->id,
'wallet_address' => strtolower($userAddress),
'plan_id' => $planId,
'start_time' => now(),
'end_time' => \Carbon\Carbon::createFromTimestamp($endTime),
'auto_renew' => true,
'active' => true,
'tx_hash' => $event['transactionHash']
]);
// ํธ๋์ญ์
๊ธฐ๋ก
SubscriptionTransaction::create([
'subscription_id' => $subscription->id,
'tx_hash' => $event['transactionHash'],
'type' => 'subscribe',
'amount' => $this->getPlanPrice($planId),
'block_number' => $event['blockNumber'],
'block_timestamp' => now(),
'status' => 'confirmed'
]);
Log::info("New subscription created for {$userAddress}");
}
private function handleRenewed(array $event)
{
$userAddress = $event['args']['user'];
$planId = $event['args']['planId'];
$newEndTime = $event['args']['newEndTime'];
$subscription = Subscription::where('wallet_address', strtolower($userAddress))
->where('active', true)
->first();
if ($subscription) {
$subscription->update([
'end_time' => \Carbon\Carbon::createFromTimestamp($newEndTime)
]);
SubscriptionTransaction::create([
'subscription_id' => $subscription->id,
'tx_hash' => $event['transactionHash'],
'type' => 'renew',
'amount' => $this->getPlanPrice($planId),
'block_number' => $event['blockNumber'],
'block_timestamp' => now(),
'status' => 'confirmed'
]);
Log::info("Subscription renewed for {$userAddress}");
}
}
private function handleCancelled(array $event)
{
$userAddress = $event['args']['user'];
$subscription = Subscription::where('wallet_address', strtolower($userAddress))
->where('active', true)
->first();
if ($subscription) {
$subscription->update([
'active' => false,
'auto_renew' => false
]);
SubscriptionTransaction::create([
'subscription_id' => $subscription->id,
'tx_hash' => $event['transactionHash'],
'type' => 'cancel',
'amount' => '0',
'block_number' => $event['blockNumber'],
'block_timestamp' => now(),
'status' => 'confirmed'
]);
Log::info("Subscription cancelled for {$userAddress}");
}
}
private function handleAutoRenewToggled(array $event)
{
$userAddress = $event['args']['user'];
$autoRenew = $event['args']['autoRenew'];
$subscription = Subscription::where('wallet_address', strtolower($userAddress))
->where('active', true)
->first();
if ($subscription) {
$subscription->update(['auto_renew' => $autoRenew]);
Log::info("Auto-renew toggled for {$userAddress}: " . ($autoRenew ? 'ON' : 'OFF'));
}
}
private function getPlanPrice(int $planId): string
{
$plan = $this->web3Service->getPlan($planId);
return $plan['price'];
}
}
์ค์ผ์ค๋ฌ์ ๋ฑ๋กํ์ฌ ์ฃผ๊ธฐ์ ์ผ๋ก ์คํ:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// 5๋ถ๋ง๋ค ๋ธ๋ก์ฒด์ธ ์ด๋ฒคํธ ๋๊ธฐํ
$schedule->job(new SyncBlockchainEvents())
->everyFiveMinutes()
->withoutOverlapping();
// ๋งค์ผ ์์ ์ ๋ง๋ฃ๋ ๊ตฌ๋
์ ๋ฆฌ
$schedule->call(function () {
Subscription::expired()
->where('active', true)
->update(['active' => false]);
})->daily();
// ๋ง๋ฃ 3์ผ ์ ์๋ฆผ
$schedule->call(function () {
$expiringSoon = Subscription::active()
->whereBetween('end_time', [now(), now()->addDays(3)])
->get();
foreach ($expiringSoon as $subscription) {
// ์ด๋ฉ์ผ ๋๋ ํธ์ ์๋ฆผ ๋ฐ์ก
Mail::to($subscription->user->email)
->send(new SubscriptionExpiringNotification($subscription));
}
})->daily();
}
๐ก๏ธ ๋ณด์ ๊ณ ๋ ค์ฌํญ
๋ธ๋ก์ฒด์ธ ๊ฒฐ์ ์์คํ ์ ๊ตฌ์ถํ ๋ ๋ฐ๋์ ๊ณ ๋ คํด์ผ ํ ๋ณด์ ์ฌํญ๋ค์ด ์์ต๋๋ค. ๐
1. ์ค๋งํธ ์ปจํธ๋ํธ ๋ณด์
โข Reentrancy ๊ณต๊ฒฉ ๋ฐฉ์ง (ReentrancyGuard ์ฌ์ฉ)
โข Integer overflow/underflow ๋ฐฉ์ง (Solidity 0.8+ ์๋ ์ฒดํฌ)
โข Access control ์ฒ ์ ํ (Ownable, Role-based)
โข ์ธ๋ถ ๊ฐ์ฌ ๋ฐ๊ธฐ (CertiK, OpenZeppelin ๋ฑ)
2. ํ๋ผ์ด๋น ํค ๊ด๋ฆฌ
โข ์ ๋ ์ฝ๋์ ํ๋์ฝ๋ฉ ๊ธ์ง
โข ํ๊ฒฝ ๋ณ์ ์ฌ์ฉ (.env)
โข AWS KMS, HashiCorp Vault ๊ฐ์ ํค ๊ด๋ฆฌ ์๋น์ค ํ์ฉ
โข ์๋ฒ ์ง๊ฐ์ ์ต์ ๊ธ์ก๋ง ๋ณด์
3. ํ๋ก ํธ์๋ ๋ณด์
โข ์ฌ์ฉ์ ์๋ช ๊ฒ์ฆ
โข ํธ๋์ญ์ ํ๋ผ๋ฏธํฐ ๊ฒ์ฆ
โข Phishing ๋ฐฉ์ง (๋๋ฉ์ธ ํ์ธ)
โข Rate limiting ์ ์ฉ
4. ๋ฐฑ์๋ ๋ณด์
โข API ์ธ์ฆ/์ธ๊ฐ (JWT, OAuth)
โข CSRF ํ ํฐ ์ฌ์ฉ
โข SQL Injection ๋ฐฉ์ง
โข ๋ฏผ๊ฐ ๋ฐ์ดํฐ ์ํธํ
์ง๊ฐ ์๋ช ๊ฒ์ฆ ๋ฏธ๋ค์จ์ด ์์:
// app/Http/Middleware/VerifyWalletSignature.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Elliptic\EC;
use kornrunner\Keccak;
class VerifyWalletSignature
{
public function handle(Request $request, Closure $next)
{
$signature = $request->header('X-Wallet-Signature');
$message = $request->header('X-Wallet-Message');
$address = $request->header('X-Wallet-Address');
if (!$signature || !$message || !$address) {
return response()->json(['error' => 'Missing signature headers'], 401);
}
// ์๋ช
๊ฒ์ฆ
if (!$this->verifySignature($message, $signature, $address)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// ํ์์คํฌํ ๊ฒ์ฆ (์ฌ์ ๊ณต๊ฒฉ ๋ฐฉ์ง)
$timestamp = json_decode($message, true)['timestamp'] ?? 0;
if (abs(time() - $timestamp) > 300) { // 5๋ถ ์ด๋ด
return response()->json(['error' => 'Signature expired'], 401);
}
// ์์ฒญ์ ์ง๊ฐ ์ฃผ์ ์ถ๊ฐ
$request->merge(['verified_wallet' => strtolower($address)]);
return $next($request);
}
private function verifySignature(string $message, string $signature, string $address): bool
{
// ์ด๋๋ฆฌ์ ์๋ช
๋ฉ์์ง ํ์
$hash = Keccak::hash("\x19Ethereum Signed Message:\n" . strlen($message) . $message, 256);
// ์๋ช
์์ r, s, v ์ถ์ถ
$r = substr($signature, 2, 64);
$s = substr($signature, 66, 64);
$v = hexdec(substr($signature, 130, 2));
if ($v < 27) {
$v += 27;
}
$ec = new EC('secp256k1');
$pubkey = $ec->recoverPubKey($hash, [
'r' => $r,
's' => $s
], $v - 27);
$recoveredAddress = '0x' . substr(Keccak::hash(substr(hex2bin($pubkey->encode('hex')), 1), 256), 24);
return strtolower($recoveredAddress) === strtolower($address);
}
}
๐ ๋์๋ณด๋ ๋ฐ ๋ถ์
๊ด๋ฆฌ์์ ์ฌ์ฉ์๋ฅผ ์ํ ๋์๋ณด๋๋ฅผ ๋ง๋ค์ด๋ด ์๋ค. ๐ ๊ตฌ๋ ํํฉ, ์์ต, ์ฌ์ฉ์ ํต๊ณ ๋ฑ์ ํ๋์ ๋ณผ ์ ์์ด์ผ ํฉ๋๋ค.
// app/Http/Controllers/DashboardController.php
<?php
namespace App\Http\Controllers;
use App\Models\Subscription;
use App\Models\SubscriptionTransaction;
use App\Models\SubscriptionPlan;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DashboardController extends Controller
{
/**
* ๊ด๋ฆฌ์ ๋์๋ณด๋
*/
public function admin()
{
$stats = [
'total_subscriptions' => Subscription::count(),
'active_subscriptions' => Subscription::active()->count(),
'total_revenue' => $this->calculateTotalRevenue(),
'monthly_revenue' => $this->calculateMonthlyRevenue(),
'churn_rate' => $this->calculateChurnRate(),
'mrr' => $this->calculateMRR(), // Monthly Recurring Revenue
'arr' => $this->calculateARR(), // Annual Recurring Revenue
];
// ํ๋๋ณ ๊ตฌ๋
๋ถํฌ
$planDistribution = Subscription::active()
->select('plan_id', DB::raw('count(*) as count'))
->groupBy('plan_id')
->with('plan')
->get();
// ์๋ณ ์ ๊ท ๊ตฌ๋
์ถ์ด
$monthlyGrowth = Subscription::select(
DB::raw('DATE_FORMAT(created_at, "%Y-%m") as month'),
DB::raw('count(*) as count')
)
->where('created_at', '>=', now()->subMonths(12))
->groupBy('month')
->orderBy('month')
->get();
// ์ต๊ทผ ํธ๋์ญ์
$recentTransactions = SubscriptionTransaction::with(['subscription.user', 'subscription.plan'])
->orderBy('created_at', 'desc')
->limit(10)
->get();
return view('dashboard.admin', compact(
'stats',
'planDistribution',
'monthlyGrowth',
'recentTransactions'
));
}
/**
* ์ฌ์ฉ์ ๋์๋ณด๋
*/
public function user(Request $request)
{
$user = $request->user();
$subscription = Subscription::where('user_id', $user->id)
->where('active', true)
->with('plan')
->first();
if (!$subscription) {
return view('dashboard.user', [
'has_subscription' => false
]);
}
$transactions = SubscriptionTransaction::where('subscription_id', $subscription->id)
->orderBy('created_at', 'desc')
->get();
$stats = [
'days_remaining' => $subscription->daysRemaining(),
'total_spent' => $transactions->where('type', '!=', 'cancel')->sum('amount'),
'renewal_count' => $transactions->where('type', 'renew')->count(),
'is_expiring_soon' => $subscription->isExpiringSoon(),
];
return view('dashboard.user', compact(
'subscription',
'transactions',
'stats'
));
}
/**
* ์ด ์์ต ๊ณ์ฐ
*/
private function calculateTotalRevenue(): float
{
return SubscriptionTransaction::where('status', 'confirmed')
->where('type', '!=', 'cancel')
->sum(DB::raw('CAST(amount AS DECIMAL(20,6))'));
}
/**
* ์๊ฐ ์์ต ๊ณ์ฐ
*/
private function calculateMonthlyRevenue(): float
{
return SubscriptionTransaction::where('status', 'confirmed')
->where('type', '!=', 'cancel')
->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->sum(DB::raw('CAST(amount AS DECIMAL(20,6))'));
}
/**
* ์ดํ๋ฅ ๊ณ์ฐ
*/
private function calculateChurnRate(): float
{
$startOfMonth = now()->startOfMonth();
$endOfMonth = now()->endOfMonth();
$activeAtStart = Subscription::where('active', true)
->where('created_at', '<', $startOfMonth)
->count();
if ($activeAtStart === 0) {
return 0;
}
$cancelled = Subscription::where('active', false)
->whereBetween('updated_at', [$startOfMonth, $endOfMonth])
->count();
return ($cancelled / $activeAtStart) * 100;
}
/**
* MRR (Monthly Recurring Revenue) ๊ณ์ฐ
*/
private function calculateMRR(): float
{
$activeSubscriptions = Subscription::active()->with('plan')->get();
$mrr = 0;
foreach ($activeSubscriptions as $sub) {
$plan = $sub->plan;
if ($plan) {
// ํ๋ ๊ฐ๊ฒฉ์ ์ ๋จ์๋ก ์ ๊ทํ
$monthlyPrice = $plan->price * (30 * 24 * 60 * 60 / $plan->duration);
$mrr += $monthlyPrice;
}
}
return $mrr;
}
/**
* ARR (Annual Recurring Revenue) ๊ณ์ฐ
*/
private function calculateARR(): float
{
return $this->calculateMRR() * 12;
}
/**
* API: ๊ตฌ๋
ํต๊ณ
*/
public function stats(Request $request)
{
$period = $request->input('period', '30d'); // 30d, 90d, 1y
$startDate = match($period) {
'30d' => now()->subDays(30),
'90d' => now()->subDays(90),
'1y' => now()->subYear(),
default => now()->subDays(30)
};
$data = [
'new_subscriptions' => Subscription::where('created_at', '>=', $startDate)->count(),
'renewals' => SubscriptionTransaction::where('type', 'renew')
->where('created_at', '>=', $startDate)
->count(),
'cancellations' => SubscriptionTransaction::where('type', 'cancel')
->where('created_at', '>=', $startDate)
->count(),
'revenue' => SubscriptionTransaction::where('status', 'confirmed')
->where('type', '!=', 'cancel')
->where('created_at', '>=', $startDate)
->sum(DB::raw('CAST(amount AS DECIMAL(20,6))')),
];
return response()->json($data);
}
}
๐ง ์๋ ๊ฐฑ์ ์์คํ ๊ตฌํ
๊ตฌ๋ ์๋น์ค์ ํต์ฌ์ ์๋ ๊ฐฑ์ ์ ๋๋ค. ๐ ์ฌ์ฉ์๊ฐ ๋ณ๋ ์ก์ ์์ด๋ ๊ตฌ๋ ์ด ์๋์ผ๋ก ์ฐ์ฅ๋์ด์ผ ํ์ฃ . ํ์ง๋ง ๋ธ๋ก์ฒด์ธ์์๋ ์๋ ์คํ์ด ๋ถ๊ฐ๋ฅํ๋ฏ๋ก, ๋ฐฑ์๋์์ ํธ๋ฆฌ๊ฑฐํด์ผ ํฉ๋๋ค.
๋ธ๋ก์ฒด์ธ์ ์ธ๋ถ์์ ํธ๋ฆฌ๊ฑฐํ์ง ์์ผ๋ฉด ์ฝ๋๊ฐ ์คํ๋์ง ์์ต๋๋ค. ๋ฐ๋ผ์ ๋ค์๊ณผ ๊ฐ์ ๋ฐฉ์์ผ๋ก ๊ตฌํํฉ๋๋ค:
1. ์ฌ์ฉ์๊ฐ ๊ตฌ๋ ์ ํ ํฐ approve๋ฅผ ์ถฉ๋ถํ ํด๋
2. ๋ฐฑ์๋ ํฌ๋ก ์ก์ด ๋ง๋ฃ ์๋ฐ ๊ตฌ๋ ์ ๊ฐ์ง
3. ๋ฐฑ์๋๊ฐ ์ฌ์ฉ์ ๋์ renew() ํจ์ ํธ์ถ
4. ๊ฐ์ค๋น๋ ์๋น์ค ์ ๊ณต์๊ฐ ๋ถ๋ด (๋๋ ๊ตฌ๋ ๋ฃ์ ํฌํจ)
// app/Jobs/ProcessAutoRenewals.php
<?php
namespace App\Jobs;
use App\Models\Subscription;
use App\Services\Web3Service;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Web3\Contract;
class ProcessAutoRenewals implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 300; // 5๋ถ
public function handle()
{
// 24์๊ฐ ์ด๋ด ๋ง๋ฃ ์์ ์ด๊ณ ์๋ ๊ฐฑ์ ์ด ์ผ์ง ๊ตฌ๋
์ฐพ๊ธฐ
$expiringSubscriptions = Subscription::active()
->where('auto_renew', true)
->whereBetween('end_time', [now(), now()->addDay()])
->get();
Log::info("Found {$expiringSubscriptions->count()} subscriptions to renew");
foreach ($expiringSubscriptions as $subscription) {
try {
$this->renewSubscription($subscription);
} catch (\Exception $e) {
Log::error("Failed to renew subscription {$subscription->id}: " . $e->getMessage());
// ์คํจ ์๋ฆผ ๋ฐ์ก
$this->notifyRenewalFailure($subscription, $e->getMessage());
}
}
}
private function renewSubscription(Subscription $subscription)
{
$web3Service = new Web3Service();
// 1. ์ฌ์ฉ์์ ํ ํฐ allowance ํ์ธ
$allowance = $web3Service->checkAllowance(
$subscription->wallet_address,
config('web3.contract_address')
);
$planPrice = $web3Service->getPlan($subscription->plan_id)['price'];
if (bccomp($allowance, $planPrice) < 0) {
throw new \Exception("Insufficient allowance. User needs to approve more tokens.");
}
// 2. ์๋ฒ ์ง๊ฐ์ผ๋ก renew ํธ๋์ญ์
์ ์ก
$txHash = $web3Service->renewSubscriptionForUser($subscription->wallet_address);
Log::info("Renewal transaction sent: {$txHash}");
// 3. ํธ๋์ญ์
๊ธฐ๋ก
SubscriptionTransaction::create([
'subscription_id' => $subscription->id,
'tx_hash' => $txHash,
'type' => 'renew',
'amount' => $planPrice,
'block_number' => 0, // ๋์ค์ ์
๋ฐ์ดํธ
'block_timestamp' => now(),
'status' => 'pending'
]);
// 4. ํธ๋์ญ์
ํ์ธ Job ํ์ ์ถ๊ฐ
ConfirmTransaction::dispatch($txHash, $subscription->id)
->delay(now()->addMinutes(2));
}
private function notifyRenewalFailure(Subscription $subscription, string $reason)
{
// ์ฌ์ฉ์์๊ฒ ์ด๋ฉ์ผ ๋ฐ์ก
Mail::to($subscription->user->email)
->send(new RenewalFailedNotification($subscription, $reason));
// ์๋ ๊ฐฑ์ ๋นํ์ฑํ
$subscription->update(['auto_renew' => false]);
}
}
Web3Service์ ๊ฐฑ์ ๋ฉ์๋ ์ถ๊ฐ:
// app/Services/Web3Service.php ์ ์ถ๊ฐ
use Web3\Personal;
/**
* ์๋ฒ ์ง๊ฐ์ผ๋ก ์ฌ์ฉ์ ๊ตฌ๋
๊ฐฑ์
*/
public function renewSubscriptionForUser(string $userAddress): string
{
$serverAddress = config('web3.server_wallet_address');
$privateKey = config('web3.server_private_key');
// ํธ๋์ญ์
๋ฐ์ดํฐ ์์ฑ
$contract = new Contract($this->web3->provider, $this->abi);
$contract->at($this->contractAddress);
// renew() ํจ์ ์ธ์ฝ๋ฉ
$data = $contract->getData('renew');
// ๊ฐ์ค ์ถ์
$gasEstimate = null;
$this->web3->eth->estimateGas([
'from' => $serverAddress,
'to' => $this->contractAddress,
'data' => $data
], function ($err, $gas) use (&$gasEstimate) {
if ($err !== null) {
throw new \Exception('Gas estimation failed: ' . $err->getMessage());
}
$gasEstimate = $gas;
});
// Nonce ๊ฐ์ ธ์ค๊ธฐ
$nonce = null;
$this->web3->eth->getTransactionCount($serverAddress, 'pending', function ($err, $count) use (&$nonce) {
if ($err !== null) {
throw new \Exception('Failed to get nonce: ' . $err->getMessage());
}
$nonce = $count;
});
// ํธ๋์ญ์
์๋ช
๋ฐ ์ ์ก
$transaction = [
'from' => $serverAddress,
'to' => $this->contractAddress,
'gas' => '0x' . dechex($gasEstimate->toHex()),
'gasPrice' => '0x' . dechex(20000000000), // 20 Gwei
'nonce' => '0x' . dechex($nonce->toHex()),
'data' => $data,
'chainId' => config('web3.chain_id')
];
// ์๋ช
$signedTx = $this->signTransaction($transaction, $privateKey);
// ์ ์ก
$txHash = null;
$this->web3->eth->sendRawTransaction('0x' . $signedTx, function ($err, $hash) use (&$txHash) {
if ($err !== null) {
throw new \Exception('Transaction failed: ' . $err->getMessage());
}
$txHash = $hash;
});
return $txHash;
}
/**
* ํธ๋์ญ์
์๋ช
*/
private function signTransaction(array $transaction, string $privateKey): string
{
// Ethereum ํธ๋์ญ์
์๋ช
๋ก์ง
// elliptic-php ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ
$ec = new \Elliptic\EC('secp256k1');
$key = $ec->keyFromPrivate($privateKey);
// RLP ์ธ์ฝ๋ฉ ๋ฐ ์๋ช
// ... (๋ณต์กํ ์๋ช
๋ก์ง)
return $signedTransaction;
}
์๋ ๊ฐฑ์ ์ ์๋น์ค ์ ๊ณต์๊ฐ ๊ฐ์ค๋น๋ฅผ ๋ถ๋ดํ๋ฏ๋ก, ์ต์ ํ๊ฐ ์ค์ํฉ๋๋ค:
โข ๊ฐ์ค ๊ฐ๊ฒฉ์ด ๋ฎ์ ์๊ฐ๋์ ๋ฐฐ์น ์ฒ๋ฆฌ (์ฃผ๋ง, ์๋ฒฝ)
โข ์ฌ๋ฌ ๊ฐฑ์ ์ ํ๋์ ํธ๋์ญ์ ์ผ๋ก ๋ฌถ๊ธฐ (batch renewal)
โข Layer 2 ์๋ฃจ์ ํ์ฉ (Polygon, Arbitrum ๋ฑ)
โข EIP-1559 ํ์ฉํ์ฌ ์ ์ ๊ฐ์ค๋น ์ค์
๐ ๋ฉํฐ์ฒด์ธ ์ง์
์ด๋๋ฆฌ์ ๋ฉ์ธ๋ท๋ง ์ฌ์ฉํ๋ฉด ๊ฐ์ค๋น๊ฐ ๋๋ฌด ๋น์๋๋ค. ๐ธ ์ฌ๋ฌ ์ฒด์ธ์ ์ง์ํ๋ฉด ์ฌ์ฉ์๊ฐ ์ ํํ ์ ์์ด ์ข์ต๋๋ค!
| ์ฒด์ธ | ์ฅ์ | ํ๊ท ๊ฐ์ค๋น | TPS |
|---|---|---|---|
| Ethereum | ๊ฐ์ฅ ์์ ํ๊ณ ํ์ค์ํ | $5-50 | 15-30 |
| Polygon | ์ ๋ ดํ๊ณ ๋น ๋ฆ, EVM ํธํ | $0.01-0.1 | 7,000+ |
| BSC | ์ ๋ ด, ํฐ ์ํ๊ณ | $0.1-0.5 | 160 |
| Arbitrum | ์ด๋๋ฆฌ์ L2, ์ ๋ ด | $0.1-1 | 4,000+ |
| Optimism | ์ด๋๋ฆฌ์ L2, ์์ ์ | $0.1-1 | 2,000+ |
๋ฉํฐ์ฒด์ธ ์ค์ :
// config/chains.php
<?php
return [
'ethereum' => [
'chain_id' => 1,
'name' => 'Ethereum Mainnet',
'rpc_url' => env('ETHEREUM_RPC_URL'),
'explorer' => 'https://etherscan.io',
'contract_address' => env('ETHEREUM_CONTRACT_ADDRESS'),
'usdt_address' => '0xdAC17F958D2ee523a2206206994597C13D831ec7',
],
'polygon' => [
'chain_id' => 137,
'name' => 'Polygon',
'rpc_url' => env('POLYGON_RPC_URL'),
'explorer' => 'https://polygonscan.com',
'contract_address' => env('POLYGON_CONTRACT_ADDRESS'),
'usdt_address' => '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
],
'bsc' => [
'chain_id' => 56,
'name' => 'BNB Smart Chain',
'rpc_url' => env('BSC_RPC_URL'),
'explorer' => 'https://bscscan.com',
'contract_address' => env('BSC_CONTRACT_ADDRESS'),
'usdt_address' => '0x55d398326f99059fF775485246999027B3197955',
],
'arbitrum' => [
'chain_id' => 42161,
'name' => 'Arbitrum One',
'rpc_url' => env('ARBITRUM_RPC_URL'),
'explorer' => 'https://arbiscan.io',
'contract_address' => env('ARBITRUM_CONTRACT_ADDRESS'),
'usdt_address' => '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9',
],
];
์ฒด์ธ๋ณ Web3Service ํฉํ ๋ฆฌ:
// app/Services/Web3ServiceFactory.php
<?php
namespace App\Services;
class Web3ServiceFactory
{
public static function create(string $chain): Web3Service
{
$config = config("chains.{$chain}");
if (!$config) {
throw new \InvalidArgumentException("Unsupported chain: {$chain}");
}
return new Web3Service(
$config['rpc_url'],
$config['contract_address'],
$config['chain_id']
);
}
public static function getSupportedChains(): array
{
return array_keys(config('chains'));
}
}
๐งช ํ ์คํธ ์์ฑ
๋ธ๋ก์ฒด์ธ ์ ํ๋ฆฌ์ผ์ด์ ์ ํ ์คํธ๊ฐ ๋งค์ฐ ์ค์ํฉ๋๋ค. ๐ฌ ํ ๋ฒ ๋ฐฐํฌํ๋ฉด ์์ ์ด ๋ถ๊ฐ๋ฅํ๋๊น์!
// tests/Feature/SubscriptionTest.php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Subscription;
use App\Models\SubscriptionPlan;
use App\Services\Web3Service;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
class SubscriptionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// ํ
์คํธ ํ๋ ์์ฑ
SubscriptionPlan::create([
'chain_plan_id' => 1,
'name' => 'Basic Plan',
'price' => '9990000', // 9.99 USDT (6 decimals)
'duration' => 2592000, // 30 days
'active' => true,
'features' => json_encode(['Feature 1', 'Feature 2'])
]);
}
/** @test */
public function user_can_subscribe_to_plan()
{
$user = User::factory()->create([
'wallet_address' => '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'
]);
// Web3Service ๋ชจํน
$web3Mock = Mockery::mock(Web3Service::class);
$web3Mock->shouldReceive('checkSubscription')
->andReturn([
'plan_id' => '1',
'start_time' => time(),
'end_time' => time() + 2592000,
'auto_renew' => true,
'active' => true
]);
$this->app->instance(Web3Service::class, $web3Mock);
// API ํธ์ถ
$response = $this->actingAs($user)
->postJson('/api/subscription/sync', [
'tx_hash' => '0xabc123...',
'wallet_address' => $user->wallet_address
]);
$response->assertStatus(200);
// DB ํ์ธ
$this->assertDatabaseHas('subscriptions', [
'user_id' => $user->id,
'wallet_address' => strtolower($user->wallet_address),
'plan_id' => 1,
'active' => true
]);
}
/** @test */
public function subscription_expires_after_end_time()
{
$user = User::factory()->create();
$subscription = Subscription::create([
'user_id' => $user->id,
'wallet_address' => '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
'plan_id' => 1,
'start_time' => now()->subDays(31),
'end_time' => now()->subDay(),
'auto_renew' => false,
'active' => true
]);
$this->assertFalse($subscription->isValid());
$this->assertEquals(0, $subscription->daysRemaining());
}
/** @test */
public function auto_renewal_processes_expiring_subscriptions()
{
$subscription = Subscription::create([
'user_id' => User::factory()->create()->id,
'wallet_address' => '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
'plan_id' => 1,
'start_time' => now()->subDays(29),
'end_time' => now()->addHours(12), // 12์๊ฐ ํ ๋ง๋ฃ
'auto_renew' => true,
'active' => true
]);
// Web3Service ๋ชจํน
$web3Mock = Mockery::mock(Web3Service::class);
$web3Mock->shouldReceive('checkAllowance')
->andReturn('100000000'); // ์ถฉ๋ถํ allowance
$web3Mock->shouldReceive('renewSubscriptionForUser')
->andReturn('0xtxhash123');
$this->app->instance(Web3Service::class, $web3Mock);
// Job ์คํ
$job = new \App\Jobs\ProcessAutoRenewals();
$job->handle();
// ํธ๋์ญ์
๊ธฐ๋ก ํ์ธ
$this->assertDatabaseHas('subscription_transactions', [
'subscription_id' => $subscription->id,
'type' => 'renew',
'status' => 'pending'
]);
}
/** @test */
public function dashboard_shows_correct_statistics()
{
$admin = User::factory()->create(['is_admin' => true]);
// ํ
์คํธ ๋ฐ์ดํฐ ์์ฑ
Subscription::factory()->count(10)->create(['active' => true]);
Subscription::factory()->count(3)->create(['active' => false]);
$response = $this->actingAs($admin)
->get('/dashboard/admin');
$response->assertStatus(200);
$response->assertViewHas('stats');
$stats = $response->viewData('stats');
$this->assertEquals(13, $stats['total_subscriptions']);
$this->assertEquals(10, $stats['active_subscriptions']);
}
}
๐ ๋ฐฐํฌ ๋ฐ ์ด์
๊ฐ๋ฐ์ด ์๋ฃ๋์๋ค๋ฉด ์ด์ ์ค์ ์๋น์ค๋ฅผ ๋ฐฐํฌํ ์ฐจ๋ก์ ๋๋ค! ๐
1. ์ค๋งํธ ์ปจํธ๋ํธ
โ ํ ์คํธ๋ท์์ ์ถฉ๋ถํ ํ ์คํธ
โ ๋ณด์ ๊ฐ์ฌ ์๋ฃ
โ ๊ฐ์ค ์ต์ ํ ํ์ธ
โ ๋ฉ์ธ๋ท ๋ฐฐํฌ
โ Etherscan์์ ์ปจํธ๋ํธ ๊ฒ์ฆ
2. ๋ฐฑ์๋
โ ํ๊ฒฝ ๋ณ์ ์ค์ (.env)
โ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์
โ ํ ์์ปค ์ค์ (Supervisor)
โ ํฌ๋ก ์ก ์ค์
โ ๋ก๊ทธ ๋ชจ๋ํฐ๋ง ์ค์
โ ๋ฐฑ์ ์์คํ ๊ตฌ์ถ
3. ํ๋ก ํธ์๋
โ ํ๋ก๋์ ๋น๋
โ CDN ์ค์
โ SSL ์ธ์ฆ์ ์ค์น
โ ๋ฉํ๋ง์คํฌ ์ฐ๋ ํ ์คํธ
4. ๋ชจ๋ํฐ๋ง
โ ์๋ฒ ๋ชจ๋ํฐ๋ง (CPU, ๋ฉ๋ชจ๋ฆฌ, ๋์คํฌ)
โ ์ ํ๋ฆฌ์ผ์ด์ ๋ชจ๋ํฐ๋ง (Sentry, New Relic)
โ ๋ธ๋ก์ฒด์ธ ์ด๋ฒคํธ ๋ชจ๋ํฐ๋ง
โ ์๋ฆผ ์ค์ (Slack, ์ด๋ฉ์ผ)
Supervisor ์ค์ ์์ (ํ ์์ปค):
# /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker.log
stopwaitsecs=3600
Nginx ์ค์ :
server {
listen 80;
listen [::]:80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name your-domain.com;
root /var/www/html/public;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
๐ ์ฑ์ฅ ์ ๋ต ๋ฐ ๋ง์ผํ
๊ธฐ์ ์ ๊ตฌํ๋ ์ค์ํ์ง๋ง, ์ค์ ์ฌ์ฉ์๋ฅผ ํ๋ณดํ๋ ๊ฒ์ด ๋ ์ค์ํฉ๋๋ค! ๐ฏ
1. ์ด๊ธฐ ์ฌ์ฉ์ ํ๋ณด
โข ์์ด๋๋ ์บ ํ์ธ (NFT ๋๋ ํ ํฐ)
โข ์ผ๋ฆฌ์ด๋ตํฐ ํ ์ธ (์ฒซ 100๋ช 50% ํ ์ธ)
โข ์ถ์ฒ ํ๋ก๊ทธ๋จ (์ถ์ฒ์ธ์๊ฒ ๋ฆฌ์๋)
โข Discord/Telegram ์ปค๋ฎค๋ํฐ ๊ตฌ์ถ
2. ํํธ๋์ญ
โข DeFi ํ๋กํ ์ฝ๊ณผ ํ์
โข NFT ํ๋ก์ ํธ์ ๋ฒ๋ค ์ ๊ณต
โข DAO์ ํ๋ ฅ
โข ์ธํ๋ฃจ์ธ์ ๋ง์ผํ
3. ํ ํฐ ์ด์ฝ๋ ธ๋ฏธ
โข ๊ฑฐ๋ฒ๋์ค ํ ํฐ ๋ฐํ
โข ์คํ ์ดํน ๋ฆฌ์๋
โข ๋ก์ดํฐ ํ๋ก๊ทธ๋จ
โข DAO ์ ํ ๋ก๋๋งต
4. ํฌ๋ก์ค์ฒด์ธ ํ์ฅ
โข ์ฌ๋ฌ ์ฒด์ธ ์ง์์ผ๋ก ์ฌ์ฉ์์ธต ํ๋
โข ๋ธ๋ฆฟ์ง ํตํฉ
โข ๋ฉํฐ์ฒด์ธ ์ง๊ฐ ์ง์
์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ์ด๋ฐ ๋ธ๋ก์ฒด์ธ ๊ตฌ๋ ์์คํ ์ ๋์ ํ๋ฉด, ๊ธ๋ก๋ฒ ํฌ๋ฆฌ์์ดํฐ๋ค์ด ๊ตญ๊ฒฝ ์์ด ์์ ์ ์ฌ๋ฅ์ ๊ตฌ๋ ํํ๋ก ํ๋งคํ ์ ์์ต๋๋ค. ์๋ฅผ ๋ค์ด ํ๊ตญ์ ๋์์ด๋๊ฐ ๋ฏธ๊ตญ ๊ณ ๊ฐ์๊ฒ ์ ๊ตฌ๋ ๋์์ธ ์๋น์ค๋ฅผ ์ ๊ณตํ๋ฉด์, ์ฆ์ ์ํธํํ๋ก ์ ์ฐ๋ฐ์ ์ ์์ฃ ! ๐
๐ ํ์ต ๋ฆฌ์์ค ๋ฐ ์ปค๋ฎค๋ํฐ
๋ธ๋ก์ฒด์ธ ๊ฐ๋ฐ์ ๊ณ์ ์งํํ๊ณ ์์ต๋๋ค. ์ต์ ์ ๋ณด๋ฅผ ๋ฐ๋ผ๊ฐ๋ ๊ฒ์ด ์ค์ํด์! ๐
| ๋ฆฌ์์ค | ์ค๋ช | ๋งํฌ |
|---|---|---|
| Solidity ๊ณต์ ๋ฌธ์ | ์ค๋งํธ ์ปจํธ๋ํธ ์ธ์ด ๋ ํผ๋ฐ์ค | docs.soliditylang.org |
| OpenZeppelin | ๊ฒ์ฆ๋ ์ค๋งํธ ์ปจํธ๋ํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ | openzeppelin.com |
| Ethers.js ๋ฌธ์ | JavaScript ์ด๋๋ฆฌ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ | docs.ethers.org |
| Laravel ๊ณต์ ๋ฌธ์ | PHP ํ๋ ์์ํฌ ๊ฐ์ด๋ | laravel.com/docs |
| CryptoZombies | ๊ฒ์์ผ๋ก ๋ฐฐ์ฐ๋ Solidity | cryptozombies.io |
| Alchemy University | ๋ฌด๋ฃ Web3 ๊ฐ๋ฐ ๊ฐ์ | university.alchemy.com |
๐ฎ ๋ฏธ๋ ์ ๋ง ๋ฐ ๋ฐ์ ๋ฐฉํฅ
๋ธ๋ก์ฒด์ธ ๊ตฌ๋ ๊ฒฐ์ ๋ ์์ง ์ด๊ธฐ ๋จ๊ณ์ง๋ง, ์์ฒญ๋ ์ ์ฌ๋ ฅ์ ๊ฐ์ง๊ณ ์์ต๋๋ค. ๐
1. Account Abstraction (ERC-4337)
์ฌ์ฉ์๊ฐ ๊ฐ์ค๋น๋ฅผ ์ง์ ๋ด์ง ์์๋ ๋๋ "๊ฐ์ค๋ฆฌ์ค" ํธ๋์ญ์ ์ด ๊ฐ๋ฅํด์ง๋๋ค. ์ด๋ Web2 ์์ค์ UX๋ฅผ ์ ๊ณตํ ์ ์์ด ๋์คํ์ ํฐ ๋์์ด ๋ ๊ฒ์ ๋๋ค.
2. ์์ ๋ก๊ทธ์ธ ํตํฉ
Magic Link, Web3Auth ๊ฐ์ ์๋น์ค๋ฅผ ํตํด ์ด๋ฉ์ผ์ด๋ ์์ ๊ณ์ ์ผ๋ก ์ง๊ฐ์ ์์ฑํ ์ ์์ต๋๋ค. ๋ฉํ๋ง์คํฌ ์ค์น ์์ด๋ ๋ธ๋ก์ฒด์ธ ์๋น์ค๋ฅผ ์ด์ฉํ ์ ์์ฃ !
3. AI์์ ๊ฒฐํฉ
AI๊ฐ ์ฌ์ฉ์์ ๊ตฌ๋ ํจํด์ ๋ถ์ํ์ฌ ์ต์ ์ ํ๋์ ์ถ์ฒํ๊ฑฐ๋, ์๋์ผ๋ก ๊ตฌ๋ ์ ๊ด๋ฆฌํด์ค ์ ์์ต๋๋ค.
4. ํฌ๋ก์ค์ฒด์ธ ์ํธ์ด์ฉ์ฑ
LayerZero, Axelar ๊ฐ์ ํ๋กํ ์ฝ์ ํตํด ์ฌ๋ฌ ์ฒด์ธ ๊ฐ ์ํํ ๊ตฌ๋ ์ด๋์ด ๊ฐ๋ฅํด์ง ๊ฒ์ ๋๋ค.
5. ํ์ค์ํ ์ ์ (DID)
์ฌ์ฉ์๊ฐ ์์ ์ ๊ตฌ๋ ์ด๋ ฅ๊ณผ ํํ์ ๋ธ๋ก์ฒด์ธ์ ์ ์ฅํ๊ณ , ๋ค๋ฅธ ์๋น์ค์์๋ ํ์ฉํ ์ ์๊ฒ ๋ฉ๋๋ค.
๐ฌ ๋ง์น๋ฉฐ
์, ์ฌ๊ธฐ๊น์ง ๋ผ๋ผ๋ฒจ ๊ธฐ๋ฐ ๋ธ๋ก์ฒด์ธ ๊ตฌ๋
๊ฒฐ์ ์์คํ
์ ๊ตฌ์ถํ๋ ์ ๊ณผ์ ์ ์ดํด๋ดค์ต๋๋ค! ๐
์ฒ์์๋ ๋ณต์กํด ๋ณด์ผ ์ ์์ง๋ง, ํ๋์ฉ ๋ฐ๋ผํ๋ค ๋ณด๋ฉด ์๊ฐ๋ณด๋ค ์ด๋ ต์ง ์๋ค๋ ๊ฑธ ๋๋ผ์ค ๊ฑฐ์์. ์ค๋งํธ ์ปจํธ๋ํธ๋ก ๋น์ฆ๋์ค ๋ก์ง์ ๊ตฌํํ๊ณ , ๋ผ๋ผ๋ฒจ๋ก ์ฌ์ฉ์ ์นํ์ ์ธ ์ธํฐํ์ด์ค๋ฅผ ๋ง๋ค๊ณ , ๋ธ๋ก์ฒด์ธ๊ณผ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ๋๊ธฐํํ๋ ์ด ๋ชจ๋ ๊ณผ์ ์ด Web3 ์๋์ ์๋ก์ด ๋น์ฆ๋์ค ๋ชจ๋ธ์ ๋ง๋๋ ๊ธฐ์ด๊ฐ ๋ฉ๋๋ค.
ํนํ ๊ตฌ๋
๊ฒฝ์ ๋ ์์ ์ ์ธ ์์ต ๋ชจ๋ธ๋ก ๋ง์ ๊ธฐ์
๋ค์ด ์ ํธํ๋ ๋ฐฉ์์ธ๋ฐ, ์ฌ๊ธฐ์ ๋ธ๋ก์ฒด์ธ์ ํฌ๋ช
์ฑ๊ณผ ํจ์จ์ฑ์ ๋ํ๋ฉด ์ ๋ง ๊ฐ๋ ฅํ ์์คํ
์ด ์์ฑ๋ฉ๋๋ค. ์ค๊ฐ์ ์์ด ์ง์ ๊ฑฐ๋ํ๊ณ , ๊ตญ๊ฒฝ ์์ด ๊ธ๋ก๋ฒ ๊ณ ๊ฐ์ ํ๋ณดํ๊ณ , ์ฆ์ ์ ์ฐ๋ฐ์ ์ ์๋ค๋ ๊ฒ์ ํฌ๋ฆฌ์์ดํฐ์ ์๋น์ค ์ ๊ณต์์๊ฒ ์์ฒญ๋ ๊ธฐํ์
๋๋ค. ๐
๋ฌผ๋ก ์์ง ํด๊ฒฐํด์ผ ํ ๊ณผ์ ๋ค๋ ๋ง์ต๋๋ค. ๊ฐ์ค๋น ๋ฌธ์ , ์ฌ์ฉ์ ๊ฒฝํ(UX) ๊ฐ์ , ๊ท์ ๋์ ๋ฑ์ด ๊ทธ๊ฒ์ด์ฃ . ํ์ง๋ง ๊ธฐ์ ์ ๊ณ์ ๋ฐ์ ํ๊ณ ์๊ณ , Account Abstraction, Layer 2 ์๋ฃจ์
, ํฌ๋ก์ค์ฒด์ธ ๋ธ๋ฆฟ์ง ๋ฑ ๋ค์ํ ํด๊ฒฐ์ฑ
๋ค์ด ๋์ค๊ณ ์์ต๋๋ค.
์ฌ๋ฌ๋ถ๋ ์ด ๊ธ์ ์ฐธ๊ณ ํ์ฌ ์์ ๋ง์ ๋ธ๋ก์ฒด์ธ ๊ตฌ๋
์๋น์ค๋ฅผ ๋ง๋ค์ด๋ณด์ธ์! ์๊ฒ ์์ํด์ ์ ์ง์ ์ผ๋ก ํ์ฅํด๋๊ฐ๋ฉด ๋ฉ๋๋ค. ํ
์คํธ๋ท์์ ์ถฉ๋ถํ ์คํํ๊ณ , ์ปค๋ฎค๋ํฐ์ ํผ๋๋ฐฑ์ ๋ฐ์ผ๋ฉด์ ๊ฐ์ ํด๋๊ฐ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค.
๊ทธ๋ฆฌ๊ณ ํผ์ ํ๊ธฐ ์ด๋ ต๋ค๋ฉด ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ๋ธ๋ก์ฒด์ธ ๊ฐ๋ฐ์๋ ์ค๋งํธ ์ปจํธ๋ํธ ์ ๋ฌธ๊ฐ์ ๋์์ ๋ฐ๋ ๊ฒ๋ ์ข์ ๋ฐฉ๋ฒ์
๋๋ค. ํจ๊ป ํ๋ ฅํ๋ฉด ๋ ๋น ๋ฅด๊ณ ์์ ํ๊ฒ ํ๋ก์ ํธ๋ฅผ ์์ฑํ ์ ์์ผ๋๊น์! ๐ค
Web3๋ ์ด์ ์ ํ์ด ์๋ ํ์๊ฐ ๋์ด๊ฐ๊ณ ์์ต๋๋ค. ์ง๊ธ ์์ํ๋ ๊ฒ์ด ๋ฆ์ง ์์์ด์. ์คํ๋ ค ์ง๊ธ์ด ์ ๊ธฐ์
๋๋ค! ์ฌ๋ฌ๋ถ์ ์์ด๋์ด์ ์ด์ , ๊ทธ๋ฆฌ๊ณ ์ด ๊ฐ์ด๋๊ฐ ๋ง๋ ๋ฉ์ง ํ๋ก์ ํธ๊ฐ ํ์ํ๊ธฐ๋ฅผ ๊ธฐ๋ํฉ๋๋ค. ๐
ํ์ด์ ๋น๋๋ค! ํ์ดํ
! ๐ช
โ ์ค๋งํธ ์ปจํธ๋ํธ๋ก ์๋ํ๋ ๊ตฌ๋ ์์คํ ๊ตฌํ
โ ๋ผ๋ผ๋ฒจ๋ก ์ฌ์ฉ์ ์นํ์ ์ธ ๋ฐฑ์๋ ๊ตฌ์ถ
โ Ethers.js๋ก ํ๋ก ํธ์๋ ์ง๊ฐ ์ฐ๋
โ ๋ธ๋ก์ฒด์ธ ์ด๋ฒคํธ์ DB ์ค์๊ฐ ๋๊ธฐํ
โ ์๋ ๊ฐฑ์ ์์คํ ์ผ๋ก ํธ๋ฆฌํ ๊ตฌ๋ ๊ด๋ฆฌ
โ ๋ฉํฐ์ฒด์ธ ์ง์์ผ๋ก ๊ฐ์ค๋น ์ต์ ํ
โ ๋ณด์๊ณผ ํ ์คํธ๋ฅผ ์ต์ฐ์ ์ผ๋ก!
๐ Web3์ ๋ฏธ๋๋ฅผ ํจ๊ป ๋ง๋ค์ด๊ฐ์! ๐
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.