[IoT ๐ /Homebridge] - [JS] Homebridge Smartthings ์ฐ๋ ํ๋ฌ๊ทธ์ธ ์ ์, OAuth ์ธ์ฆ - 1
์ ๋ฒ ๊ฒ์๊ธ์์, ํ๋ก์ ์๋ฒ์ OAuth ๋ฐฉ์์ ์ด์ฉํ์ฌ
์๊ตฌ์ ์ผ๋ก ํ ํฐ์ ์๋์ผ๋ก ๋ฐ๊ธ/๊ฐฑ์ ํ์ฌ ๊ถํ์ ์ ์งํ๋ ๋ก์ง์ ๊ตฌํํ๋ค.
์ด๋ฒ์๋ ์ค์ ๋ฐ๊ธ๋ ํ ํฐ์ ๊ฐ์ง๊ณ Smartthings API๋ฅผ ์ด์ฉํด,
๋๋ฐ์ด์ค๋ฅผ ์กฐํํ๊ณ , ํด๋น ๊ธฐ๊ธฐ๋ค์ Homebridge ์ ์ธ์๋ฆฌ์ ๋งคํํ์ฌ
์ค์ ๋ก Smartthings์ Device๋ค์ ์ฐ๋ํด๋ณด๋๋ก ํ์.
Smartthings API
์ญ์ ์ด๋ฒ์๋, API ๋ฌธ์๋ฅผ ํ์ธํด์
Device๋ฅผ ์กฐํํ๋ ๋ช ๋ น๊ณผ, ๋์์ํค๋ ๋ช ๋ น์ด๋ฅผ ์ฐพ์ ๋ณด์๋ค.
https://developer.smartthings.com/docs/api/public
API | Developer Documentation | SmartThings
SmartThings Public API
developer.smartthings.com
๋จผ์ Postman์ ์ฌ์ฉํด, ํค๋์ ํ ํฐ์ ๋ฃ์ด ๋๋ฐ์ด์ค ์กฐํ ์์ฒญ์ ๋ฃ์ด๋ณด์๋ค.
https://api.smartthings.com/v1/devices
์์ฒญ ๊ฒฐ๊ณผ 200, ์ ์์ ์ผ๋ก ๋๋ฐ์ด์ค ๋ชฉ๋ก์ด ๋์๋ค.
ํ์๋ ์ง์ TV์ Set-Top, ์์ด์ปจ์ ์ง์ Smartthings์ ์ฐ๊ฒฐ์ด ์๋
๊ฐค๋ญ์ ํ ๋ฏธ๋์ IR ๋ช ๋ น์ผ๋ก ์ฌ์ฉ์ค์ด๊ธฐ ๋๋ฌธ์,
capabilites์ "stateless-" ๋ก ์์ํ๋ ๋ช ๋ น์ด ๋ง์ด ๋์๋ค.

API ๋ฌธ์์์ ํ์ธ ๊ฒฐ๊ณผ, deprecated ๋ ๋ช ๋ น๋ค์ด์ด์
์ง์ github๋ฌธ์๋ฅผ ์ฐพ์, ํด๋น capablities id๊ฐ ์ฌ์ฉ๋๋ command๋ค์ ํ๋ํ๋ ์ฐพ์์ผ ํ๋ค.
https://github.com/hongtat/smartthings-capabilities/tree/master
GitHub - hongtat/smartthings-capabilities: SmartThings Capabilities
SmartThings Capabilities. Contribute to hongtat/smartthings-capabilities development by creating an account on GitHub.
github.com
ํ์ํ ์ ๋ณด๋ ์ผ์ถ ์ฐพ์์ผ๋ ์ด์ ํ๋ฌ๊ทธ์ธ์ ๋ง๋ค์ด๋ณด์
ํ๋ฌ๊ทธ์ธ ์ ์ (๋๋ฐ์ด์ค ์ ์ด)
์ ๋ฒ์ ๋ง๋ค๋ ํ๋ฌ๊ทธ์ธ์์ ์ด์ ๋๋ฐ์ด์ค ์กฐํ์,
๊ฐ๊ฐ์ ๋๋ฐ์ด์ค๋ค์ ์ ํ ํ ํ๊ฒฝ์ ๋ง๊ฒ ์ ์ธ์๋ฆฌ๋ฅผ ๋งคํํด์ค ๊ฒ์ด๋ค.
index.js ํ์ผ์ ๋๋ฐ์ด์ค๋ฅผ ์ฐพ๋ ๋ฉ์๋๋ฅผ ์ถ๊ฐํ๊ณ ,
postman์์ ์์ฒญ์ ๋ฆฌํด๋ฐ์ json ๊ฐ์ ๋ง๊ฒ ์ํ๋ ๋๋ฐ์ด์ค๋ง ๋งคํํ๋๋ก ์ถ์ถํ๋ค.
์ด๋, Homekit ๊ท๊ฒฉ์ ๋ง๊ฒ TV์ SetTop ์ ์ธ์๋ฆฌ๋ ๋ธ๋ฆฟ์ง ํํ๊ฐ ์๋ ์ธ๋ถ๊ธฐ๊ธฐ๋ก ๋์ํ๊ฒ ๊ตฌํํ๋ค.
async discoverDevices() {
const url = 'https://api.smartthings.com/v1/devices';
try {
const response = await axios.get(url, {
headers: { 'Authorization': `Bearer ${this.accessToken}` }
});
const devices = response.data.items;
for (const device of devices) {
const caps = device.components?.[0]?.capabilities?.map(c => c.id) || [];
const cats = device.components?.[0]?.categories?.map(c => c.name) || [];
let AccessoryClass = null;
if (cats.includes('Television')) AccessoryClass = TVAccessory;
else if (cats.includes('SetTop')) AccessoryClass = SetTopAccessory;
else if (caps.includes('airConditionerMode')) AccessoryClass = AirConAccessory;
else if (caps.includes('switch') && caps.includes('powerMeter')) AccessoryClass = PlugAccessory;
if (!AccessoryClass) continue; // ์ฌ์ฉ ์ํ ๊ธฐ๊ธฐ Skip
const isExternal = ['TVAccessory', 'SetTopAccessory'].includes(AccessoryClass.name);
if (isExternal) {
new AccessoryClass(this, device);
} else {
const uuid = this.api.hap.uuid.generate(device.deviceId);
let existingAccessory = this.accessories.find(acc => acc.UUID === uuid);
if (existingAccessory) {
new AccessoryClass(this, existingAccessory, device);
} else {
const accessory = new this.api.platformAccessory(device.label, uuid);
new AccessoryClass(this, accessory, device);
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.accessories.push(accessory);
}
}
}
} catch (err) {
this.log.error('๊ธฐ๊ธฐ ๋ชฉ๋ก ๋ก๋ ์คํจ:', err.message);
}
}
์ด์ BaseAccessory ํด๋์ค๋ฅผ ๋ง๋ค์ด
๊ฐ๊ฐ์ ์ ์ธ์๋ฆฌ ์ ์ด ์์ฒญ์ ํ๋๋ก ์ฒ๋ฆฌํ๊ฒ ๋ง๋ค์ด๋ณด์
๊ตฌ์กฐ์ฒด ์์, ๊ฐ ๊ธฐ๊ธฐ์์ ์ ์ด์ ์ฌ์ฉํ api ์์ฒญ ๋ก์ง์ ๋ง๋ค๊ณ
this.client = axios.create({
baseURL: `https://api.smartthings.com/v1/devices/${this.deviceId}`,
headers: { 'Authorization': `Bearer ${this.platform.accessToken}` }
});
์ ๋ฒ ๊ฒ์๊ธ์์ ๋ง๋ฃ๋ ํ ํฐ์ด ์์์, ํ ํฐ์ ์ฌ๋ฐ๊ธ ํด์ฃผ๋ ๋ฉ์๋๋ฅผ ๋ง๋ค์๋๋ฐ
401 ์๋ฌ ๋ฐ์์(ํ ํฐ๋ง๋ฃ), ํด๋น ๋ฉ์๋๋ฅผ ๋์์ํฌ ์ธํฐ์ ํฐ๋ ๊ตฌ์กฐ์ฒด์ ๊ตฌํํ์๋ค.
this.client.interceptors.response.use((response) => response,
async (err) => {
const originalRequest = error.config;
if (error.response && error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newToken = await this.platform.refreshAccessToken();
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
this.client.defaults.headers['Authorization'] = `Bearer ${newToken}`;
return this.client(originalRequest);
} catch (err) {
this.log.error('ํ ํฐ ๊ฐฑ์ ์คํจ. ์๋ ์ธ์ฆ์ด ํ์ํ ์ ์์ต๋๋ค.');
}
}
return Promise.reject(err);
}
);
๋ง์ง๋ง์ผ๋ก ๊ฐ๊ฐ์ ์ ์ธ์๋ฆฌ์์ ๋ช ๋ น์ ๋ณด๋ด๋ ๋์๋ ๊ตฌํ
async executeCommand(capability, command, args = []) {
try {
await this.client.post('/commands', {
commands: [{ component: 'main', capability, command, arguments: args }]
});
} catch (err) {
this.log.error(`[${this.name}] ๋ช
๋ น ์คํจ: ${err.message}`);
}
}
ํ๋ฌ๊ทธ์ธ ์ ์ (์ ์ธ์๋ฆฌ ๋งคํ)
์ด์ ์ ์ด ๋์์ ์๋ฃํ์ผ๋,
ํ์ํ ์ ์ธ์๋ฆฌ๋ฅผ Homebridge์ ๋ง๊ฒ ๋งคํํด๋ณด์
์ฌ์ฉํ ๊ธฐ๊ธฐ๋ TV, Set-Top, ์์ด์ปจ, ํ๋ฌ๊ทธ 4๊ฐ์ด๋ฏ๋ก ๊ฐ๊ฐ ํด๋์ค๋ฅผ ๋ง๋ค์ด ์ฃผ์๋ค.
๋จผ์ TV ์ ์ธ์๋ฆฌ, ์ด์ ๋ถํฐ TV ์ ์ธ์๋ฆฌ๋ ํญ์ ๋ง๋ค๋๋ง๋ค ์ค๋ฅ๊ฐ ๋ฌ๋๋ฐ,
API ๋ฌธ์๋ฅผ ์ฌ๋ฌ๋ฒ ์ ๋ ํด๋ณธ ๊ฒฐ๊ณผ, ์๋ ๋ฌด์กฐ๊ฑด ๊ฐ๋ณ ์ ์ธ์๋ฆฌ๋ก ๋ฑ๋กํด์ผ ๋๋ค๋ ๊ฒ์ด์๋ค.
๊น๋ค๋กญ๊ฒ๋ ๋งคํ๋ ํ๋ฆผ ์์ด ์ฌ๋ฐ๋ฅด๊ฒ ํด์ค์ผ ํ๋ฏ๋ก, ์ฐจ๊ทผ์ฐจ๊ทผ ํด๋ณด๋ ค๊ณ ํ๋ค.
https://developers.homebridge.io/#/service/Television
Homebridge Plugin Developer Documentation
Homebridge plugin developer documentation and API reference.
developers.homebridge.io
๋ถ๋ชจ ํด๋์ค์ธ BaseAccessory ํด๋์ค๋ฅผ ์์๋ฐ๊ณ ,
์ ์ธ์๋ฆฌ ํ์ ์ Television, ๊ทธ๋ฆฌ๊ณ ๊ตฌ์กฐ์ฒด์ ๋์๋ค์ ๊ฐ๊ฐ ๋งคํํ๋ค.
์๊น Smartthings API์์ ํ์ธํ ์ ๋ณด์ ๋ง๊ฒ
stateless ์ ์์๋ "setButton", command์๋ 'powerToggle'์ด ๋ค์ด๊ฐ์ผ ํ๋ค.
๊ทธ๋ฆฌ๊ณ ํํท์ ๋ฆฌ๋ชจ์ปจ ์ ์ธ์๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ ค๋ฉด ์ ์ธ์๋ฆฌ๊ฐ ์ผ์ง์ํ์ด์ฌ์ผ ํ์ง๋ง,
IR ๋ช ๋ น์ ์ํ๊ฐ ์๋ ๋ฐฉ์์ด๋ฏ๋ก ๋ฒํผ์ด ๋๋ ค๋ ํญ์ ์ผ์ง์ํ๋ฅผ ์ ์งํ์๋ค.
this.accessory = new this.platform.api.platformAccessory(this.name, uuid);
this.accessory.category = Categories.TELEVISION;
this.tvService.getCharacteristic(Characteristic.Active)
.onGet(() => Characteristic.Active.ACTIVE)
.onSet(async (value) => {
await this.executeCommand('statelessPowerToggleButton', 'setButton', ['powerToggle']);
if (value === Characteristic.Active.INACTIVE) {
setTimeout(() => {
this.tvService.updateCharacteristic(Characteristic.Active, Characteristic.Active.ACTIVE);
}, 1000);
}
});
๊ทธ๋ฆฌ๊ณ ํํท์ ๋ฆฌ๋ชจ์ปจ ์ ์ธ์๋ฆฌ์ ์ญ์ ๋ชจํ์ ๊ฐ๊ฐ ์ฑ๋ ๋ณ๊ฒฝ๊ณผ, ๋ณผ๋ฅจ ์กฐ์ ์ ๋งคํํ๋ค.
this.tvService.getCharacteristic(Characteristic.RemoteKey)
.onSet(async (value) => {
const cmdMap = {
[Characteristic.RemoteKey.ARROW_UP]: ['statelessAudioVolumeButton', 'volumeUp'],
[Characteristic.RemoteKey.ARROW_DOWN]: ['statelessAudioVolumeButton', 'volumeDown'],
[Characteristic.RemoteKey.ARROW_LEFT]: ['statelessChannelButton', 'channelDown'],
[Characteristic.RemoteKey.ARROW_RIGHT]: ['statelessChannelButton', 'channelUp'],
};
if (cmdMap[value]) {
await this.executeCommand(cmdMap[value][0], 'setButton', [cmdMap[value][1]]);
}
});
๊ทธ๋ฆฌ๊ณ TV ์ ์ธ์๋ฆฌ์๋ ์คํผ์ปค ์ ์ธ์๋ฆฌ๋ฅผ ํ์์ ๋ถ์ผ ์ ์์ผ๋ฏ๋ก
์ญ์ ๊ท๊ฒฉ์ ๋ง๊ฒ ์์๊ฑฐ ๋ฒํผ๋ ๋ง๋ค์ด ์ฃผ์๋ค.
this.speakerService = this.accessory.getService(Service.TelevisionSpeaker);
this.speakerService.getCharacteristic(Characteristic.Mute)
.onGet(() => false)
.onSet(async () => {
await this.executeCommand('statelessAudioMuteButton', 'setButton', ['muteToggle']);
});
๋ค์์ผ๋ก๋ SetTopAccessory
์ ํฑ๋ฐ์ค๋ ์ฌ์ค TV์ ๊ธฐ๋ฅ์ด ์์ ๊ฐ์ผ๋ฏ๋ก, ์ค๋ช ์ ์๋ตํ๊ฒ ๋ค.
์ด๋ ์นดํ ๊ณ ๋ฆฌ๋ฅผ TV_SET_TOP_BOX๋ก ์ง์ ํด๋๋ฉด, TV์๋ ์กฐ๊ธ ๋ค๋ฅธ ์์ด์ฝ์ผ๋ก ๋งคํ๋๋ค.
this.accessory.category = Categories.TV_SET_TOP_BOX;
์ด์ ์์ด์ปจ์ ๋งคํํด๋ณด์
๊ธฐ์กด์ ์ฐ๋ ํ๋ฌ๊ทธ์ธ์ Fan ์ ์ธ์๋ฆฌ ํํ๋ก ์ผ๊ณ ๋๊ณ ๋ง ๊ฐ๋ฅํ์ง๋ง,
HeaterCooler๋ฅผ ์ด์ฉํด์ ์จ๋๋ฅผ ์ง๊ด์ ์ผ๋ก ํ์ธํ๊ณ , ๋ชจ๋์กฐ์ ๋ ํ ์ ์๊ฒ ๋ง๋ค์ด ๋ณผ๊ฒ์ด๋ค.
https://developers.homebridge.io/#/service/HeaterCooler
Homebridge Plugin Developer Documentation
Homebridge plugin developer documentation and API reference.
developers.homebridge.io
์ญ์ ์ ์ธ์๋ฆฌ๋ HeaterCooler๋ก ๋งคํ ํ,
Smartthings ๋ช ๋ น ํ์ ๋ง๊ฒ on/off ๋ฅผ ๊ตฌํ
this.service = this.accessory.getService(Service.HeaterCooler)
this.service.getCharacteristic(Characteristic.Active)
.onGet(() => this.state.active)
.onSet(async (value) => {
this.state.active = value;
const command = (value === Characteristic.Active.ACTIVE) ? 'on' : 'off';
await this.executeCommand('switch', command);
});
๊ทธ๋ฆฌ๊ณ , ์ฌ์ฉ์ค์ธ ์์ด์ปจ์ ๋๋ฐฉ ๋ชจ๋๊ฐ ์์ผ๋ฏ๋ก,
์ฌ๋ฆ์ ์์ฃผ ์ฌ์ฉํ๋ ์ ์ต ์ด์ ๋์์ ๋งคํํ์ฌ ๋๋ฐฉ/์ ์ต/์๋์ ๊ตฌํํ์๋ค.
this.service.getCharacteristic(Characteristic.TargetHeaterCoolerState)
.setProps({
validValues: [
Characteristic.TargetHeaterCoolerState.AUTO,
Characteristic.TargetHeaterCoolerState.HEAT,
Characteristic.TargetHeaterCoolerState.COOL
]
})
.onGet(() => this.state.mode)
.onSet(async (value) => {
this.state.mode = value;
let mode = 'cool';
if (value === Characteristic.TargetHeaterCoolerState.HEAT) mode = 'dry';
else if (value === Characteristic.TargetHeaterCoolerState.AUTO) mode = 'auto';
await this.executeCommand('airConditionerMode', 'setAirConditionerMode', [mode]);
});
๋ง์ง๋ง์ผ๋ก, ์ฝ/์ค/๊ฐ ๋ฐ๋์ธ๊ธฐ ์กฐ์ ๋์์
์ ์ธ์๋ฆฌ ํ์์ Fan ํํ๋ก ๋งคํํ์ฌ ์ฌ์ฉํ ์ ์๊ฒ ํ์๋ค.
( ์ฝ: 15, ์ค: 50, ๊ฐ: 85 )
this.service.getCharacteristic(Characteristic.RotationSpeed)
.setProps({ minStep: 35, minValue: 15, maxValue: 85 })
.onGet(() => this.state.fanSpeed)
.onSet(async (value) => {
this.state.fanSpeed = value;
let fanMode = 'low';
if (value > 75) fanMode = 'high';
else if (value > 40) fanMode = 'medium';
await this.executeCommand('airConditionerFanMode', 'setFanMode', [fanMode]);
});
์ด์ ๋ง์ง๋ง ์ ์ธ์๋ฆฌ์ธ ํค์ดํ ์ค๋งํธ ํ๋ฌ๊ทธ
์์ผ๋ ์ ๋ฌผ ๋ฐ์๋๊ฑด๋ฐ, Smartthings์๋ง ๋ถ์ด์
์ด๋ฒ์์ผ๋ง๋ก ์ฐ๋ํด์ ์ ๋๋ก ์จ๋ณด๋ ค๊ณ ํ๋ค.
์ ์ธ์๋ฆฌ ๋งคํ์ Outlet
https://developers.homebridge.io/#/service/Outlet
Homebridge Plugin Developer Documentation
Homebridge plugin developer documentation and API reference.
developers.homebridge.io
Outlet ์ ์ธ์๋ฆฌ๋ ๋ณต์กํ ์์ ์ ์์๊ณ ,
์๋ ๊ฐ์ง๊ฒ์ค ์ ์ผํ๊ฒ, ํ์ฌ ์ํ๋ฅผ ์กฐํํ ์ ์์ผ๋ฏ๋ก
๊ธฐ๋ณธ๋์๊ณผ, ์ ์ธ์๋ฆฌ ํ์ผ๊ณผ ๋๊ธฐํ ํ์ฌ ์ค์๊ฐ ์ํ๋ฅผ ๋ฐ์ํ๊ฒ ๊ตฌํํ๋ค.
this.service = this.accessory.getService(Service.Outlet);
this.service.getCharacteristic(Characteristic.On)
.onGet(async () => {
try {
const response = await this.client.get('/status');
const state = response.data.components.main.switch.switch.value;
return state === 'on';
} catch (e) return false;
})
.onSet(async (value) => {
const command = value ? 'on' : 'off';
await this.executeCommand('switch', command);
this.service.updateCharacteristic(Characteristic.On, value);
});
this.service.getCharacteristic(Characteristic.OutletInUse)
.onGet(async () => {
try {
const response = await this.client.get('/status');
return response.data.components.main.switch.switch.value === 'on';
} catch (e) return false;
});
์ฝ๋ ์์ ์๋ฃ.
๊ฝค ์ฝ๋๊ฐ ๊ธธ์ด์ ธ, ๊ฒ์๊ธ์๋ ์ฝ๋๋ฅผ ๋ง์ด ์๋ตํ๋๋ฐ,
์ด ์ธ์ ์๋ต๋ ๋ฉ์๋๋ค์ ์๋ github ์ฝ๋๋ฅผ ๋ณด๋ฉด ๋๊ฒ ๋ค.
https://github.com/101Sean/homebridge-smartthings-device
GitHub - 101Sean/homebridge-smartthings-device: Smartthings Device ์ฐ๋ (Home mini IR ๋์)
Smartthings Device ์ฐ๋ (Home mini IR ๋์). Contribute to 101Sean/homebridge-smartthings-device development by creating an account on GitHub.
github.com
ํ ์คํธ
์ ๋ฒ ๊ฒ์๊ธ์์ ์ ์์ ์ผ๋ก ํ ํฐ์ด ๋ฐ๊ธ๋๋ ๊ฑธ ํ์ธํ์ผ๋,
์ด์ ๋๋ฐ์ด์ค๊ฐ ์ ์์ ์ผ๋ก ๋ถ๋ฌ์์ง๋์ง ๋ด์ผํ๋ค.
ํ๋ฌ๊ทธ์ธ ์ ์ฉ ํ, ์ ์์ ์ผ๋ก ๋๋ฐ์ด์ค๋ค์ด ์์ฒญ๋๋ ๊ฒ์ ๋ณผ ์ ์๋ค.

์ด์ ์ง์ ํ ์ฑ์ ๋ธ๋ฆฟ์ง์, ์ธ๋ถ ์ ์ธ์๋ฆฌ๋ฅผ ๋ฑ๋ก ํ๊ณ ,
๋ณดํต, TV์ Set-Top์ ๋์์ ํค๋ ๋์์ ์์ฃผ ์ฐ๋ฏ๋ก
ํด๋น Scene์ ๋ง๋ค์ด์ค์ ์ํฐ์น ๋ฒํผ์ผ๋ก ์ฐ๊ฒ ๋ง๋ค์๋ค.


์ฃผ๋ก ์ฐ๋ ๊ธฐ๊ธฐ๋ Set-Top์ด๋ฏ๋ก, Set-Top์ ํ ์คํธ ํด๋ณด์
๋งคํํ ๋ฆฌ๋ชจ์ปจ์ ์๋ง๊ฒ ๋ชจ๋ ๋์์ด ๋๋๊ฒ์ ๋ณผ ์ ์๋ค.
https://youtube.com/shorts/Yc2hrBpyyG0?feature=share
์ด๋ก์จ Smartthings ์ํ๊ณ์ ์ ํ ํ์ ์์ ํ ํตํฉ์ ์ด๋ค๋ค.
์์ง ์ ํ ํ์ฑ์์ ์ ๊ณตํ๋ ์ ์ธ์๋ฆฌ ํ์ ์ด ๋ค์ํ์ง ์์ง๋ง,
์ฐจ์ฐจ ์๋ก์ด ์ ์ธ์๋ฆฌ ํ์ ๋ ์ง์ํด์ค๊ฑฐ๋ผ ๋ฏฟ๋๋ค.
์๋ณธ ๊ฒ์๊ธ
https://blog.naver.com/101artspace/224122553178
Homebridge Smartthings ์ฐ๋ ํ๋ฌ๊ทธ์ธ ์ ์, ์ ์ธ์๋ฆฌ ๋งคํ - 2
์ ๋ฒ ๊ฒ์๊ธ์์, ํ๋ก์ ์๋ฒ์ OAuth ๋ฐฉ์์ ์ด์ฉํ์ฌ ์๊ตฌ์ ์ผ๋ก ํ ํฐ์ ์๋์ผ๋ก ๋ฐ๊ธ/๊ฐฑ์ ํ์ฌ ๊ถํ...
blog.naver.com