[{"data":1,"prerenderedAt":1201},["ShallowReactive",2],{"article:/websocket-vs-socket-io":3,"related:/websocket-vs-socket-io":1093},{"id":4,"title":5,"body":6,"category":1069,"created":1070,"description":1071,"extension":1072,"image":1073,"meta":1074,"navigation":216,"path":1088,"published":216,"rawbody":1089,"seo":1090,"stem":1091,"updated":1070,"__hash__":1092},"content/websocket-vs-socket-io.md","웹소켓과 socket.io",{"type":7,"value":8,"toc":1061},"minimark",[9,24,32,35,38,43,51,54,57,60,65,78,83,94,98,101,104,107,110,113,120,124,127,138,167,170,413,416,563,567,570,585,588,768,775,921,924,927,943,946,1037,1041,1057],[10,11,12,13,23],"p",{},"예전에 회사 프로젝트를 진행할 때, 지도에 실시간으로 사용자의 위치를 보여주는 기능이 필요해서 ",[14,15,19],"a",{"href":16,"rel":17},"https://socket.io",[18],"nofollow",[20,21,22],"code",{},"socket.io"," 를 사용해서 구현했던 적이 있습니다.",[10,25,26,27,31],{},"여태까지 저는 그냥 무지성으로 실시간이면 무조건 socket.io 써야지~ 했었는데, 이번에 ",[14,28,30],{"href":29},"/cryptocurrency-price-in-a-second","코인 시세 모니터링 앱","을 만들 때 소켓 기술을 이용해서 실시간으로 데이터를 받아와야 할 일이 있었습니다.",[10,33,34],{},"근데 이번에는 회사에서 소켓을 사용할 때와는 연결 방식이 달라서 조금 이상하다고 생각했습니다. 그래서 열심히 구글링을 해보니 둘이 아예 다르더군요.",[10,36,37],{},"그리 많은 내용은 아니지만, 저처럼 두 개의 차이를 모르셨던 분들을 위해 혼자 정리한 내용을 공유할까 합니다.",[39,40,42],"h2",{"id":41},"websocket-vs-socketio","WebSocket vs socket.io",[10,44,45,46,50],{},"사실 애초에 둘은 다른 개념입니다. 웹소켓은 양방향 소통을 위한 프로토콜입니다. 프로토콜은 쉽게 말하자면 ",[47,48,49],"strong",{},"서로 다른 컴퓨터끼리 소통하기 위한 약속"," 정도로 이해하면 됩니다.",[10,52,53],{},"반면에, socket.io는 양방햔 통신을 하기위해 웹소켓 기술을 활용하는 라이브러리입니다. 어찌보면 자바스크립트와 jQuery의 관계와 비슷하다고 할 수 있겠습니다.",[10,55,56],{},"그렇기 때문에 socket.io가 같은 기능을 구현하더라도 약간 느리지만, 많은 편의성을 제공합니다. 또한 Java, C++, Python 등 여러 언어들의 라이브러리 또한 지원됩니다.",[10,58,59],{},"그렇다면 둘 사이에 기술적으로 어떤 차이점이 있는지 짧게 정리했습니다.",[10,61,62],{},[47,63,64],{},"WebSocket",[66,67,68,72,75],"ul",{},[69,70,71],"li",{},"HTML5 웹 표준 기술",[69,73,74],{},"매우 빠르게 작동하며 통신할 때 아주 적은 데이터를 이용함",[69,76,77],{},"이벤트를 단순히 듣고, 보내는 것만 가능함",[10,79,80],{},[47,81,82],{},"Socket.io",[66,84,85,88,91],{},[69,86,87],{},"표준 기술이 아니며, 라이브러리임",[69,89,90],{},"소켓 연결 실패 시 fallback을 통해 다른 방식으로 알아서 해당 클라이언트와 연결을 시도함",[69,92,93],{},"방 개념을 이용해 일부 클라이언트에게만 데이터를 전송하는 브로드캐스팅이 가능함",[39,95,97],{"id":96},"그래서-어떤-걸-써야하는데","그래서 어떤 걸 써야하는데?",[10,99,100],{},"짧게 정리했지만 사실, 이 정도는 다른 블로그나 문서에도 이미 잘 설명되어 있는 내용입니다.",[10,102,103],{},"그렇다면 우리에게 정말 중요한 것은 대체 언제 WebSocket을 사용하고, 언제 socket.io를 사용해야할 지 기준을 정해야 하는 것이겠죠.",[10,105,106],{},"개인적으로 이렇습니다. 서버에서 연결된 소켓(사용자)들을 세밀하게 관리해야하는 서비스인 경우에는 Broadcasting 기능이 있는 socket.io을 쓰는게 유지보수 측면에서 훨씬 이점이 많습니다.",[10,108,109],{},"반면 가상화폐 거래소같이 데이터 전송이 많은 경우에는 빠르고 비용이 적은 표준 WebSocket을 이용하는게 바람직하겠죠. 실제로 업비트나 바이낸스 소켓 API를 사용해보면 정말 엄청나게 많은 데이터가 들어옵니다.",[10,111,112],{},"결국 선택의 몫은 어떤 서비스를 제공할 것인가에 따라 달려있네요. (진리의 케바케)",[10,114,115,116,119],{},"아, 추가로 여러분들이 알아두셔야 할 내용이 있습니다. socket.io로 구성된 서버에게 소켓 연결을 하기 위해서는 클라이언트측에서 반드시 ",[20,117,118],{},"socket.io-client"," 라이브러리를 이용해야합니다. 꼭 짝을 맞춰주세요.",[39,121,123],{"id":122},"웹소켓-websocket-구현-예제","웹소켓 (WebSocket) 구현 예제",[10,125,126],{},"소켓은 양방향 연결이기 때문에 서버와 클라이언트측에서 같이 구현을 해야합니다. 이번 예제는 Node.js를 이용해 서버를 구성하겠습니다.",[10,128,129,130,137],{},"먼저 Node.js에서 표준 웹소켓을 구성하려면 ",[14,131,134],{"href":132,"rel":133},"https://www.npmjs.com/package/ws",[18],[20,135,136],{},"ws"," 패키지를 사용하면 됩니다.",[139,140,146],"pre",{"className":141,"code":142,"filename":143,"language":144,"meta":145,"style":145},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","$ npm install ws\n","shell","bash","",[20,147,148],{"__ignoreMap":145},[149,150,153,157,161,164],"span",{"class":151,"line":152},"line",1,[149,154,156],{"class":155},"sBMFI","$",[149,158,160],{"class":159},"sfazB"," npm",[149,162,163],{"class":159}," install",[149,165,166],{"class":159}," ws\n",[10,168,169],{},"다음은 간단한 서버측 예제입니다.",[139,171,175],{"className":172,"code":173,"language":174,"meta":145,"style":145},"language-js shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","const WebSocket = require(\"ws\");\n\nconst wss = new WebSocket.Server({ port: 3000 });\n\nwss.on(\"connection\", (ws) => {\n  ws.on(\"message\", (message) => {\n    console.log(\"received: %s\", message);\n  });\n\n  ws.send(\"something\");\n});\n","js",[20,176,177,211,218,263,268,305,336,365,375,380,403],{"__ignoreMap":145},[149,178,179,183,187,191,195,198,201,203,205,208],{"class":151,"line":152},[149,180,182],{"class":181},"spNyl","const",[149,184,186],{"class":185},"sTEyZ"," WebSocket ",[149,188,190],{"class":189},"sMK4o","=",[149,192,194],{"class":193},"s2Zo4"," require",[149,196,197],{"class":185},"(",[149,199,200],{"class":189},"\"",[149,202,136],{"class":159},[149,204,200],{"class":189},[149,206,207],{"class":185},")",[149,209,210],{"class":189},";\n",[149,212,214],{"class":151,"line":213},2,[149,215,217],{"emptyLinePlaceholder":216},true,"\n",[149,219,221,223,226,228,231,234,237,240,242,245,249,252,256,259,261],{"class":151,"line":220},3,[149,222,182],{"class":181},[149,224,225],{"class":185}," wss ",[149,227,190],{"class":189},[149,229,230],{"class":189}," new",[149,232,233],{"class":185}," WebSocket",[149,235,236],{"class":189},".",[149,238,239],{"class":193},"Server",[149,241,197],{"class":185},[149,243,244],{"class":189},"{",[149,246,248],{"class":247},"swJcz"," port",[149,250,251],{"class":189},":",[149,253,255],{"class":254},"sbssI"," 3000",[149,257,258],{"class":189}," }",[149,260,207],{"class":185},[149,262,210],{"class":189},[149,264,266],{"class":151,"line":265},4,[149,267,217],{"emptyLinePlaceholder":216},[149,269,271,274,276,279,281,283,286,288,291,294,297,299,302],{"class":151,"line":270},5,[149,272,273],{"class":185},"wss",[149,275,236],{"class":189},[149,277,278],{"class":193},"on",[149,280,197],{"class":185},[149,282,200],{"class":189},[149,284,285],{"class":159},"connection",[149,287,200],{"class":189},[149,289,290],{"class":189},",",[149,292,293],{"class":189}," (",[149,295,136],{"class":296},"sHdIc",[149,298,207],{"class":189},[149,300,301],{"class":181}," =>",[149,303,304],{"class":189}," {\n",[149,306,308,311,313,315,317,319,322,324,326,328,330,332,334],{"class":151,"line":307},6,[149,309,310],{"class":185},"  ws",[149,312,236],{"class":189},[149,314,278],{"class":193},[149,316,197],{"class":247},[149,318,200],{"class":189},[149,320,321],{"class":159},"message",[149,323,200],{"class":189},[149,325,290],{"class":189},[149,327,293],{"class":189},[149,329,321],{"class":296},[149,331,207],{"class":189},[149,333,301],{"class":181},[149,335,304],{"class":189},[149,337,339,342,344,347,349,351,354,356,358,361,363],{"class":151,"line":338},7,[149,340,341],{"class":185},"    console",[149,343,236],{"class":189},[149,345,346],{"class":193},"log",[149,348,197],{"class":247},[149,350,200],{"class":189},[149,352,353],{"class":159},"received: %s",[149,355,200],{"class":189},[149,357,290],{"class":189},[149,359,360],{"class":185}," message",[149,362,207],{"class":247},[149,364,210],{"class":189},[149,366,368,371,373],{"class":151,"line":367},8,[149,369,370],{"class":189},"  }",[149,372,207],{"class":247},[149,374,210],{"class":189},[149,376,378],{"class":151,"line":377},9,[149,379,217],{"emptyLinePlaceholder":216},[149,381,383,385,387,390,392,394,397,399,401],{"class":151,"line":382},10,[149,384,310],{"class":185},[149,386,236],{"class":189},[149,388,389],{"class":193},"send",[149,391,197],{"class":247},[149,393,200],{"class":189},[149,395,396],{"class":159},"something",[149,398,200],{"class":189},[149,400,207],{"class":247},[149,402,210],{"class":189},[149,404,406,409,411],{"class":151,"line":405},11,[149,407,408],{"class":189},"}",[149,410,207],{"class":185},[149,412,210],{"class":189},[10,414,415],{},"다음은 클라이언트측 입니다. 웹소켓은 HTML5 모듈이기 때문에 클라이언트 측에서는 따로 모듈을 설치할 필요가 없습니다.",[139,417,419],{"className":172,"code":418,"language":174,"meta":145,"style":145},"const ws = new WebSocket(\"ws://localhost:3000\");\n\nws.on(\"open\", () => {\n  ws.send(\"something\");\n});\n\nws.on(\"message\", (data) => {\n  console.log(data);\n});\n",[20,420,421,447,451,477,497,505,509,538,555],{"__ignoreMap":145},[149,422,423,425,428,430,432,434,436,438,441,443,445],{"class":151,"line":152},[149,424,182],{"class":181},[149,426,427],{"class":185}," ws ",[149,429,190],{"class":189},[149,431,230],{"class":189},[149,433,233],{"class":193},[149,435,197],{"class":185},[149,437,200],{"class":189},[149,439,440],{"class":159},"ws://localhost:3000",[149,442,200],{"class":189},[149,444,207],{"class":185},[149,446,210],{"class":189},[149,448,449],{"class":151,"line":213},[149,450,217],{"emptyLinePlaceholder":216},[149,452,453,455,457,459,461,463,466,468,470,473,475],{"class":151,"line":220},[149,454,136],{"class":185},[149,456,236],{"class":189},[149,458,278],{"class":193},[149,460,197],{"class":185},[149,462,200],{"class":189},[149,464,465],{"class":159},"open",[149,467,200],{"class":189},[149,469,290],{"class":189},[149,471,472],{"class":189}," ()",[149,474,301],{"class":181},[149,476,304],{"class":189},[149,478,479,481,483,485,487,489,491,493,495],{"class":151,"line":265},[149,480,310],{"class":185},[149,482,236],{"class":189},[149,484,389],{"class":193},[149,486,197],{"class":247},[149,488,200],{"class":189},[149,490,396],{"class":159},[149,492,200],{"class":189},[149,494,207],{"class":247},[149,496,210],{"class":189},[149,498,499,501,503],{"class":151,"line":270},[149,500,408],{"class":189},[149,502,207],{"class":185},[149,504,210],{"class":189},[149,506,507],{"class":151,"line":307},[149,508,217],{"emptyLinePlaceholder":216},[149,510,511,513,515,517,519,521,523,525,527,529,532,534,536],{"class":151,"line":338},[149,512,136],{"class":185},[149,514,236],{"class":189},[149,516,278],{"class":193},[149,518,197],{"class":185},[149,520,200],{"class":189},[149,522,321],{"class":159},[149,524,200],{"class":189},[149,526,290],{"class":189},[149,528,293],{"class":189},[149,530,531],{"class":296},"data",[149,533,207],{"class":189},[149,535,301],{"class":181},[149,537,304],{"class":189},[149,539,540,543,545,547,549,551,553],{"class":151,"line":367},[149,541,542],{"class":185},"  console",[149,544,236],{"class":189},[149,546,346],{"class":193},[149,548,197],{"class":247},[149,550,531],{"class":185},[149,552,207],{"class":247},[149,554,210],{"class":189},[149,556,557,559,561],{"class":151,"line":377},[149,558,408],{"class":189},[149,560,207],{"class":185},[149,562,210],{"class":189},[39,564,566],{"id":565},"socketio-구현-예제","Socket.io 구현 예제",[10,568,569],{},"먼저 Node.js에서 socket.io를 사용하기 위해 패키지를 설치해줍니다.",[139,571,573],{"className":141,"code":572,"filename":143,"language":144,"meta":145,"style":145},"npm install socket.io\n",[20,574,575],{"__ignoreMap":145},[149,576,577,580,582],{"class":151,"line":152},[149,578,579],{"class":155},"npm",[149,581,163],{"class":159},[149,583,584],{"class":159}," socket.io\n",[10,586,587],{},"다음은 서버 측 예제입니다.",[139,589,591],{"className":172,"code":590,"language":174,"meta":145,"style":145},"const server = require(\"http\").createServer();\n\nconst io = require(\"socket.io\")(server);\nio.on(\"connection\", (socket) => {\n  socket.on(\"message\", (msg) => {\n    console.log(msg);\n  });\n});\n\nserver.listen(3000);\n",[20,592,593,625,629,653,683,713,729,737,745,749],{"__ignoreMap":145},[149,594,595,597,600,602,604,606,608,611,613,615,617,620,623],{"class":151,"line":152},[149,596,182],{"class":181},[149,598,599],{"class":185}," server ",[149,601,190],{"class":189},[149,603,194],{"class":193},[149,605,197],{"class":185},[149,607,200],{"class":189},[149,609,610],{"class":159},"http",[149,612,200],{"class":189},[149,614,207],{"class":185},[149,616,236],{"class":189},[149,618,619],{"class":193},"createServer",[149,621,622],{"class":185},"()",[149,624,210],{"class":189},[149,626,627],{"class":151,"line":213},[149,628,217],{"emptyLinePlaceholder":216},[149,630,631,633,636,638,640,642,644,646,648,651],{"class":151,"line":220},[149,632,182],{"class":181},[149,634,635],{"class":185}," io ",[149,637,190],{"class":189},[149,639,194],{"class":193},[149,641,197],{"class":185},[149,643,200],{"class":189},[149,645,22],{"class":159},[149,647,200],{"class":189},[149,649,650],{"class":185},")(server)",[149,652,210],{"class":189},[149,654,655,658,660,662,664,666,668,670,672,674,677,679,681],{"class":151,"line":265},[149,656,657],{"class":185},"io",[149,659,236],{"class":189},[149,661,278],{"class":193},[149,663,197],{"class":185},[149,665,200],{"class":189},[149,667,285],{"class":159},[149,669,200],{"class":189},[149,671,290],{"class":189},[149,673,293],{"class":189},[149,675,676],{"class":296},"socket",[149,678,207],{"class":189},[149,680,301],{"class":181},[149,682,304],{"class":189},[149,684,685,688,690,692,694,696,698,700,702,704,707,709,711],{"class":151,"line":270},[149,686,687],{"class":185},"  socket",[149,689,236],{"class":189},[149,691,278],{"class":193},[149,693,197],{"class":247},[149,695,200],{"class":189},[149,697,321],{"class":159},[149,699,200],{"class":189},[149,701,290],{"class":189},[149,703,293],{"class":189},[149,705,706],{"class":296},"msg",[149,708,207],{"class":189},[149,710,301],{"class":181},[149,712,304],{"class":189},[149,714,715,717,719,721,723,725,727],{"class":151,"line":307},[149,716,341],{"class":185},[149,718,236],{"class":189},[149,720,346],{"class":193},[149,722,197],{"class":247},[149,724,706],{"class":185},[149,726,207],{"class":247},[149,728,210],{"class":189},[149,730,731,733,735],{"class":151,"line":338},[149,732,370],{"class":189},[149,734,207],{"class":247},[149,736,210],{"class":189},[149,738,739,741,743],{"class":151,"line":367},[149,740,408],{"class":189},[149,742,207],{"class":185},[149,744,210],{"class":189},[149,746,747],{"class":151,"line":377},[149,748,217],{"emptyLinePlaceholder":216},[149,750,751,754,756,759,761,764,766],{"class":151,"line":382},[149,752,753],{"class":185},"server",[149,755,236],{"class":189},[149,757,758],{"class":193},"listen",[149,760,197],{"class":185},[149,762,763],{"class":254},"3000",[149,765,207],{"class":185},[149,767,210],{"class":189},[10,769,770,771,774],{},"만약 서버에서 ",[20,772,773],{},"express","를 사용하고 있다면 이렇게 사용하면 됩니다.",[139,776,778],{"className":172,"code":777,"language":174,"meta":145,"style":145},"const app = require(\"express\")();\nconst server = require(\"http\").createServer(app);\nconst io = require(\"socket.io\")(server);\n\nio.on(\"connection\", (socket) => {\n  /* … */\n});\n\nserver.listen(3000);\n",[20,779,780,804,833,855,859,887,893,901,905],{"__ignoreMap":145},[149,781,782,784,787,789,791,793,795,797,799,802],{"class":151,"line":152},[149,783,182],{"class":181},[149,785,786],{"class":185}," app ",[149,788,190],{"class":189},[149,790,194],{"class":193},[149,792,197],{"class":185},[149,794,200],{"class":189},[149,796,773],{"class":159},[149,798,200],{"class":189},[149,800,801],{"class":185},")()",[149,803,210],{"class":189},[149,805,806,808,810,812,814,816,818,820,822,824,826,828,831],{"class":151,"line":213},[149,807,182],{"class":181},[149,809,599],{"class":185},[149,811,190],{"class":189},[149,813,194],{"class":193},[149,815,197],{"class":185},[149,817,200],{"class":189},[149,819,610],{"class":159},[149,821,200],{"class":189},[149,823,207],{"class":185},[149,825,236],{"class":189},[149,827,619],{"class":193},[149,829,830],{"class":185},"(app)",[149,832,210],{"class":189},[149,834,835,837,839,841,843,845,847,849,851,853],{"class":151,"line":220},[149,836,182],{"class":181},[149,838,635],{"class":185},[149,840,190],{"class":189},[149,842,194],{"class":193},[149,844,197],{"class":185},[149,846,200],{"class":189},[149,848,22],{"class":159},[149,850,200],{"class":189},[149,852,650],{"class":185},[149,854,210],{"class":189},[149,856,857],{"class":151,"line":265},[149,858,217],{"emptyLinePlaceholder":216},[149,860,861,863,865,867,869,871,873,875,877,879,881,883,885],{"class":151,"line":270},[149,862,657],{"class":185},[149,864,236],{"class":189},[149,866,278],{"class":193},[149,868,197],{"class":185},[149,870,200],{"class":189},[149,872,285],{"class":159},[149,874,200],{"class":189},[149,876,290],{"class":189},[149,878,293],{"class":189},[149,880,676],{"class":296},[149,882,207],{"class":189},[149,884,301],{"class":181},[149,886,304],{"class":189},[149,888,889],{"class":151,"line":307},[149,890,892],{"class":891},"sHwdD","  /* … */\n",[149,894,895,897,899],{"class":151,"line":338},[149,896,408],{"class":189},[149,898,207],{"class":185},[149,900,210],{"class":189},[149,902,903],{"class":151,"line":367},[149,904,217],{"emptyLinePlaceholder":216},[149,906,907,909,911,913,915,917,919],{"class":151,"line":377},[149,908,753],{"class":185},[149,910,236],{"class":189},[149,912,758],{"class":193},[149,914,197],{"class":185},[149,916,763],{"class":254},[149,918,207],{"class":185},[149,920,210],{"class":189},[10,922,923],{},"다음은 클라이언트 측 예제입니다. 아까 말했듯이 socket.io로 구성된 서버에겐 반드시 socket.io-client 패키지로 연결을 시도해야합니다.",[10,925,926],{},"패키지를 설치합니다.",[139,928,930],{"className":141,"code":929,"filename":143,"language":144,"meta":145,"style":145},"$ npm install socket.io-client\n",[20,931,932],{"__ignoreMap":145},[149,933,934,936,938,940],{"class":151,"line":152},[149,935,156],{"class":155},[149,937,160],{"class":159},[149,939,163],{"class":159},[149,941,942],{"class":159}," socket.io-client\n",[10,944,945],{},"다음은 자바스크립트 예제입니다.",[139,947,949],{"className":172,"code":948,"language":174,"meta":145,"style":145},"const io = require(\"socket.io-client\");\n\nconst socket = io(\"http://localhost:3000\");\n\nsocket.emit(\"message\", \"hello world!\");\n",[20,950,951,973,977,1002,1006],{"__ignoreMap":145},[149,952,953,955,957,959,961,963,965,967,969,971],{"class":151,"line":152},[149,954,182],{"class":181},[149,956,635],{"class":185},[149,958,190],{"class":189},[149,960,194],{"class":193},[149,962,197],{"class":185},[149,964,200],{"class":189},[149,966,118],{"class":159},[149,968,200],{"class":189},[149,970,207],{"class":185},[149,972,210],{"class":189},[149,974,975],{"class":151,"line":213},[149,976,217],{"emptyLinePlaceholder":216},[149,978,979,981,984,986,989,991,993,996,998,1000],{"class":151,"line":220},[149,980,182],{"class":181},[149,982,983],{"class":185}," socket ",[149,985,190],{"class":189},[149,987,988],{"class":193}," io",[149,990,197],{"class":185},[149,992,200],{"class":189},[149,994,995],{"class":159},"http://localhost:3000",[149,997,200],{"class":189},[149,999,207],{"class":185},[149,1001,210],{"class":189},[149,1003,1004],{"class":151,"line":265},[149,1005,217],{"emptyLinePlaceholder":216},[149,1007,1008,1010,1012,1015,1017,1019,1021,1023,1025,1028,1031,1033,1035],{"class":151,"line":270},[149,1009,676],{"class":185},[149,1011,236],{"class":189},[149,1013,1014],{"class":193},"emit",[149,1016,197],{"class":185},[149,1018,200],{"class":189},[149,1020,321],{"class":159},[149,1022,200],{"class":189},[149,1024,290],{"class":189},[149,1026,1027],{"class":189}," \"",[149,1029,1030],{"class":159},"hello world!",[149,1032,200],{"class":189},[149,1034,207],{"class":185},[149,1036,210],{"class":189},[1038,1039,1040],"h3",{"id":1040},"참고",[66,1042,1043,1050],{},[69,1044,1045],{},[14,1046,1049],{"href":1047,"rel":1048},"https://www.educba.com/websocket-vs-socket-io/",[18],"Difference Between WebSocket and Socket.io - Educba",[69,1051,1052],{},[14,1053,1056],{"href":1054,"rel":1055},"https://stackoverflow.com/questions/10112178/differences-between-socket-io-and-websockets",[18],"Differences between socket.io and websockets - Stackoverflow",[1058,1059,1060],"style",{},"html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}",{"title":145,"searchDepth":220,"depth":220,"links":1062},[1063,1064,1065,1066],{"id":41,"depth":213,"text":42},{"id":96,"depth":213,"text":97},{"id":122,"depth":213,"text":123},{"id":565,"depth":213,"text":566,"children":1067},[1068],{"id":1040,"depth":220,"text":1040},"tech","2021-06-02","예전에 회사 프로젝트를 진행할 때, 지도에 실시간으로 사용자의 위치를 보여주는 기능이 필요해서 socket.io 를 사용해서 구현했던 적이 있습니다.","md",null,{"excerpt":1075},{"type":7,"value":1076},[1077,1084],[10,1078,12,1079,23],{},[14,1080,1082],{"href":16,"rel":1081},[18],[20,1083,22],{},[10,1085,26,1086,31],{},[14,1087,30],{"href":29},"/websocket-vs-socket-io","---\ncategory: tech\ntitle: 웹소켓과 socket.io\nupdated: 2021-06-02\ncreated: 2021-06-02\npublished: true\n---\n\n예전에 회사 프로젝트를 진행할 때, 지도에 실시간으로 사용자의 위치를 보여주는 기능이 필요해서 [`socket.io`](https://socket.io) 를 사용해서 구현했던 적이 있습니다.\n\n여태까지 저는 그냥 무지성으로 실시간이면 무조건 socket.io 써야지~ 했었는데, 이번에 [코인 시세 모니터링 앱](/cryptocurrency-price-in-a-second)을 만들 때 소켓 기술을 이용해서 실시간으로 데이터를 받아와야 할 일이 있었습니다.\n\n\u003C!--more-->\n\n근데 이번에는 회사에서 소켓을 사용할 때와는 연결 방식이 달라서 조금 이상하다고 생각했습니다. 그래서 열심히 구글링을 해보니 둘이 아예 다르더군요.\n\n그리 많은 내용은 아니지만, 저처럼 두 개의 차이를 모르셨던 분들을 위해 혼자 정리한 내용을 공유할까 합니다.\n\n## WebSocket vs socket.io\n\n사실 애초에 둘은 다른 개념입니다. 웹소켓은 양방향 소통을 위한 프로토콜입니다. 프로토콜은 쉽게 말하자면 **서로 다른 컴퓨터끼리 소통하기 위한 약속** 정도로 이해하면 됩니다.\n\n반면에, socket.io는 양방햔 통신을 하기위해 웹소켓 기술을 활용하는 라이브러리입니다. 어찌보면 자바스크립트와 jQuery의 관계와 비슷하다고 할 수 있겠습니다.\n\n그렇기 때문에 socket.io가 같은 기능을 구현하더라도 약간 느리지만, 많은 편의성을 제공합니다. 또한 Java, C++, Python 등 여러 언어들의 라이브러리 또한 지원됩니다.\n\n그렇다면 둘 사이에 기술적으로 어떤 차이점이 있는지 짧게 정리했습니다.\n\n**WebSocket**\n\n- HTML5 웹 표준 기술\n- 매우 빠르게 작동하며 통신할 때 아주 적은 데이터를 이용함\n- 이벤트를 단순히 듣고, 보내는 것만 가능함\n\n**Socket.io**\n\n- 표준 기술이 아니며, 라이브러리임\n- 소켓 연결 실패 시 fallback을 통해 다른 방식으로 알아서 해당 클라이언트와 연결을 시도함\n- 방 개념을 이용해 일부 클라이언트에게만 데이터를 전송하는 브로드캐스팅이 가능함\n\n## 그래서 어떤 걸 써야하는데?\n\n짧게 정리했지만 사실, 이 정도는 다른 블로그나 문서에도 이미 잘 설명되어 있는 내용입니다.\n\n그렇다면 우리에게 정말 중요한 것은 대체 언제 WebSocket을 사용하고, 언제 socket.io를 사용해야할 지 기준을 정해야 하는 것이겠죠.\n\n개인적으로 이렇습니다. 서버에서 연결된 소켓(사용자)들을 세밀하게 관리해야하는 서비스인 경우에는 Broadcasting 기능이 있는 socket.io을 쓰는게 유지보수 측면에서 훨씬 이점이 많습니다.\n\n반면 가상화폐 거래소같이 데이터 전송이 많은 경우에는 빠르고 비용이 적은 표준 WebSocket을 이용하는게 바람직하겠죠. 실제로 업비트나 바이낸스 소켓 API를 사용해보면 정말 엄청나게 많은 데이터가 들어옵니다.\n\n결국 선택의 몫은 어떤 서비스를 제공할 것인가에 따라 달려있네요. (진리의 케바케)\n\n아, 추가로 여러분들이 알아두셔야 할 내용이 있습니다. socket.io로 구성된 서버에게 소켓 연결을 하기 위해서는 클라이언트측에서 반드시 `socket.io-client` 라이브러리를 이용해야합니다. 꼭 짝을 맞춰주세요.\n\n## 웹소켓 (WebSocket) 구현 예제\n\n소켓은 양방향 연결이기 때문에 서버와 클라이언트측에서 같이 구현을 해야합니다. 이번 예제는 Node.js를 이용해 서버를 구성하겠습니다.\n\n먼저 Node.js에서 표준 웹소켓을 구성하려면 [`ws`](https://www.npmjs.com/package/ws) 패키지를 사용하면 됩니다.\n\n```bash[shell]\n$ npm install ws\n```\n\n다음은 간단한 서버측 예제입니다.\n\n```js\nconst WebSocket = require(\"ws\");\n\nconst wss = new WebSocket.Server({ port: 3000 });\n\nwss.on(\"connection\", (ws) => {\n  ws.on(\"message\", (message) => {\n    console.log(\"received: %s\", message);\n  });\n\n  ws.send(\"something\");\n});\n```\n\n다음은 클라이언트측 입니다. 웹소켓은 HTML5 모듈이기 때문에 클라이언트 측에서는 따로 모듈을 설치할 필요가 없습니다.\n\n```js\nconst ws = new WebSocket(\"ws://localhost:3000\");\n\nws.on(\"open\", () => {\n  ws.send(\"something\");\n});\n\nws.on(\"message\", (data) => {\n  console.log(data);\n});\n```\n\n## Socket.io 구현 예제\n\n먼저 Node.js에서 socket.io를 사용하기 위해 패키지를 설치해줍니다.\n\n```bash[shell]\nnpm install socket.io\n```\n\n다음은 서버 측 예제입니다.\n\n```js\nconst server = require(\"http\").createServer();\n\nconst io = require(\"socket.io\")(server);\nio.on(\"connection\", (socket) => {\n  socket.on(\"message\", (msg) => {\n    console.log(msg);\n  });\n});\n\nserver.listen(3000);\n```\n\n만약 서버에서 `express`를 사용하고 있다면 이렇게 사용하면 됩니다.\n\n```js\nconst app = require(\"express\")();\nconst server = require(\"http\").createServer(app);\nconst io = require(\"socket.io\")(server);\n\nio.on(\"connection\", (socket) => {\n  /* … */\n});\n\nserver.listen(3000);\n```\n\n다음은 클라이언트 측 예제입니다. 아까 말했듯이 socket.io로 구성된 서버에겐 반드시 socket.io-client 패키지로 연결을 시도해야합니다.\n\n패키지를 설치합니다.\n\n```bash[shell]\n$ npm install socket.io-client\n```\n\n다음은 자바스크립트 예제입니다.\n\n```js\nconst io = require(\"socket.io-client\");\n\nconst socket = io(\"http://localhost:3000\");\n\nsocket.emit(\"message\", \"hello world!\");\n```\n\n### 참고\n\n- [Difference Between WebSocket and Socket.io - Educba](https://www.educba.com/websocket-vs-socket-io/)\n- [Differences between socket.io and websockets - Stackoverflow](https://stackoverflow.com/questions/10112178/differences-between-socket-io-and-websockets)\n",{"title":5,"description":1071},"websocket-vs-socket-io","a9MIVtNaOi8CAkeOkXDhnJklnVFL2keTMLVdPABiGEw",[1094,1100,1106,1112,1118,1124,1130,1136,1142,1148,1154,1160,1166,1172,1178,1184,1189,1195],{"path":1095,"title":1096,"description":1097,"created":1098,"category":1069,"rawbody":1099},"/nuxt3-auto-scrolling-with-composition-api","[Nuxt 3] Composition API로 자동 스크롤링 기능 구현하기","이번 포스팅에서는 실시간 채팅 서비스에서 새로운 대화 내용이 추가되었을 때 자동으로 스크롤이 계속해서 아래로 내려가면서, 스크롤을 조작함에 따라 자동 스크롤이 활성화/비활성화되는 기능을 Vue 3에서 새로 추가된 Composition API를 통해 만들어볼겁니다.","2023-07-24","---\ncategory: tech\ntitle: \"[Nuxt 3] Composition API로 자동 스크롤링 기능 구현하기\"\nupdated: 2023-07-24\ncreated: 2023-07-24\nimage: https://user-images.githubusercontent.com/20244536/136804762-1e64b59c-e60e-462b-99f8-a39131f4c507.png\npublished: true\n---\n\n이번 포스팅에서는 실시간 채팅 서비스에서 새로운 대화 내용이 추가되었을 때 자동으로 스크롤이 계속해서 아래로 내려가면서, 스크롤을 조작함에 따라 자동 스크롤이 활성화/비활성화되는 기능을 Vue 3에서 새로 추가된 [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html#what-is-composition-api)를 통해 만들어볼겁니다.\n\n\u003C!--more-->\n\n## Composition API\n\n이제는 공개된지 꽤 지나서 대부분 Composition API에 대해 익숙하실겁니다. 그래도 이 코드 작성 방법이 기존 방식에 비해 뭐가 더 좋고, 어떻게 사용하는건지 간단하게 먼저 알아보도록 하겠습니다.\n\n```vue [Options API]\n\u003Ctemplate>\n  \u003Cdiv>count: {{ count }}\u003C/div>\n  \u003Cdiv>double count: {{ getDoubleCount }}\u003C/div>\n  \u003Cbutton @click=\"addCount\">Add count\u003C/button>\n\u003C/template>\n\n\u003Cscript>\nexport default {\n  data() {\n    return {\n      count: 1,\n    };\n  },\n  computed: {\n    getDoubleCount() {\n      return this.count * 2;\n    },\n  },\n  methods: {\n    addCount() {\n      this.count++;\n    },\n  },\n  mounted() {\n    console.log(\"mounted\");\n  },\n};\n\u003C/script>\n```\n\n먼저 Vue 2의 Options API입니다. 이 방식은 Vue의 빌트인 기능들을 사용할 때 `count` 같은 반응형 데이터와 `computed`,`mounted` 같은 라이프사이클 함수들을 호출하는 `.vue` 파일 안에 위치시켜야 했습니다. 그리고 특히 이 요소들을 밖으로 빼내서 재사용하기가 어려웠습니다.\n\n그리고 지금은 코드가 짧아서 보기에 문제가 없어보이지만, 코드가 많아졌을 때 원하는 코드를 찾기가 매우 힘들었습니다.\n\n자 그럼 이제 Composition API를 활용해 재사용성을 높인 코드를 봅시다.\n\n```vue [Composition API]\n\u003Ctemplate>\n  \u003Cdiv>count: {{ count }}\u003C/div>\n  \u003Cdiv>double count: {{ getDoubleCount }}\u003C/div>\n  \u003Cbutton @click=\"addCount\">Add count\u003C/button>\n\u003C/template>\n\n\u003Cscript setup>\nimport { useCount } from \"./useCount\";\n\nconst { count, getDoubleCount, addCount } = useCount();\n\u003C/script>\n```\n\n```ts [useCount.ts]\nimport { ref, computed, onMounted } from \"vue\";\n\nexport function useCount() {\n  const count = ref(1);\n  const getDoubleCount = computed(() => count.value);\n\n  function addCount() {\n    count.value++;\n  }\n\n  onMounted(() => {\n    console.log(\"useCount is mounted.\");\n  });\n\n  return { count, getDoubleCount, addCount };\n}\n```\n\n차이점이 보이시나요? Vue 빌트인 기능들을 외부로 빼두고 필요한 곳에서 불러다 쓰니, 호출하는 곳에선 내부 구현에 대해 전혀 알 필요가 없어졌습니다. 확실하게 관심사를 분리할 수 있게 되었습니다.\n\n아 참고로 Vue 파일 `\u003Cscript>` 태그에 `setup` 키워드가 들어가있어야 위 코드가 동작합니다.\n\n## 자동 스크롤링 기능 만들기\n\n최근 회사에서 실시간 채팅 비슷한 UI/UX가 필요한 서비스를 만들었고, 이 때 Composition API를 활용해 나름 보기 좋은 코드를 작성했습니다. 여러분들도 읽어보시고 작업 중인 프로젝트에 응용해보세요.\n\n먼저 해당 기능을 구현하기 위한 설계를 먼저 해봅시다.\n\n1. 가로가 좁고 세로가 긴 화면으로 만든다.\n2. 유튜브/트위치 실시간 채팅창처럼 주기적으로 채팅이 생성된다.\n3. 채팅은 화면 상단부터 시작해 화면 하단까지 순서대로 쌓인다.\n4. 화면이 꽉 찬 상태에서 채팅이 생성되면 스크롤 바가 생성되며 화면 가장 아래로 이동한다.\n\n간단하게 이렇게 설계하고, 이걸 해당 API 사용부에서는 최소한의 정보만을 넘기면서 해당 기능을 재사용할 수 있는 형태가 나오면 되겠습니다.\n\n## 프로젝트 시작하기\n\n일단 프로젝트를 새로 만들어주세요. Node 16버전 이상 설치되어 있어야 합니다.\n\n```bash[bash]\nnpx nuxi@latest init auto-scrolling-project\ncd auto-scrolling-project\nyarn && yarn dev\n```\n\n정상적으로 설치가 되었다면 [`http://localhost:3000`](http://localhost:3000) 주소로 로컬 서버가 열리게 됩니다.\n\n마지막으로 `css` 적용을 위해 [`Tailwind CSS`](https://tailwindcss.com/)를 설치해주겠습니다.\n\n```bash [bash]\nyarn i -D @nuxtjs/tailwindcss\n```\n\n패키지 설치 후 사용을 위해 `nuxt.config.ts` 파일을 수정해주세요.\n\n```ts [nuxt.config.ts]\nexport default defineNuxtConfig({\n  modules: [\"@nuxtjs/tailwindcss\"],\n});\n```\n\n잘 적용이 됐는지 확인을 위해 HTML 코드를 수정해주겠습니다.\n\n```vue [app.vue]\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003Cdiv class=\"text-2xl font-bold\">Hi!\u003C/div>\n  \u003C/div>\n\u003C/template>\n```\n\n![](https://github.com/peterkimzz/blog/assets/20244536/fe39f827-7238-463a-9b1c-fe687972d298)\n\n## 화면 구성하기\n\n아까 설계했던 대로, 채팅방 형태의 화면이어야 하기 때문에 화면 좌우가 너무 넓을 필요는 없습니다. 그리고 화면 세로가 꽉 차면 좋겠네요. 아 그리고 채팅이 채워지는 공간과 바깥 배경이 시각적으로 분리되면 좋을 것 같으니 배경색이 구분되어 보이면 되겠습니다.\n\n```vue [app.vue]\n\u003Ctemplate>\n  \u003Cdiv class=\"h-[100dvh]\">\n    \u003Cdiv class=\"mx-auto flex h-full max-w-sm flex-col\">\n      \u003Cheader class=\"flex-1 border-b py-3\">\n        \u003Ch1 class=\"text-lg font-bold\">Chat Room with Auto Scrolling\u003C/h1>\n      \u003C/header>\n\n      \u003Cmain class=\"h-full bg-gray-100/50 py-2\">\n        Lorem ipsum dolor sit, amet consectetur adipisicing elit. Itaque\n        excepturi facilis suscipit, nihil dolorem porro accusamus sapiente\n        veritatis atque nesciunt placeat! Dolore ab aut error rerum beatae\n        aliquid nulla obcaecati?\n      \u003C/main>\n\n      \u003Cfooter class=\"flex-1 border-t py-2\">\n        Nuxt 3 + Composition API + TailwindCSS\n      \u003C/footer>\n    \u003C/div>\n  \u003C/div>\n\u003C/template>\n```\n\n말한대로 화면이 구성되게끔 `app.vue`의 내용을 수정해주었습니다. 이렇게 하면 채팅이 들어가는 영역이 차지할 수 있는 만큼 모든 높이를 차지하게 됩니다.\n\n![](https://github.com/peterkimzz/blog/assets/20244536/fab48203-d2e4-47f8-b2da-0be9afd649db)\n\n## 채팅 데이터를 주기적으로 만들기\n\n다음으로는 `faker` 패키지를 이용해 더미 채팅 데이터를 계속해서 만들어보고, 그 데이터를 화면에 그려보도록 하겠습니다.\n\n먼저 더미 데이터를 만들기 위해 패키지를 설치해주세요.\n\n```bash [bash]\nyarn add @faker-js/faker\n```\n\n일단은 채팅 데이터 하나를 응답할 함수 하나를 작성합시다.\n\n```vue [app.vue]\n\u003Cscript lang=\"ts\" setup>\nimport { fakerDE as faker } from \"@faker-js/faker\";\n\ntype Chat = { id: string; name: string; message: string };\n\nfunction getChat(): Promise\u003CChat> {\n  return new Promise((resolve) => {\n    setTimeout(\n      () =>\n        resolve({\n          id: faker.string.uuid(),\n          name: faker.person.fullName(),\n          message: faker.lorem.sentence(),\n        }),\n      500 // 0.5 seconds to respond\n    );\n  });\n}\n\u003C/script>\n```\n\n물론 실제 상황이라면 소켓 연결을 통해 실시간으로 채팅 데이터를 받아오겠지만, 지금은 거기까지 가면 너무 복잡해지니까 지속적으로 이 함수를 호출하는 방식으로 갑시다.\n\n다음은 `setInterval` 함수를 이용해 주기적으로 채팅 데이터를 배열에 넣는 역할을 하는 녀석을 만들어보겠습니다.\n\n```vue [app.vue]\n\u003Cscript lang=\"ts\" setup>\nconst chats = ref\u003CChat[]>([]);\n\nfunction pollingChats() {\n  const INTERVAL = 2000; // 2 seconds\n  setInterval(async () => {\n    const chat = await getChat();\n    chats.value.push(chat);\n  }, INTERVAL);\n}\n\npollingChats();\n\u003C/script>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"h-[100dvh]\">\n    \u003Cdiv class=\"mx-auto flex h-full max-w-sm flex-col px-5\">\n      \u003Cheader class=\"flex-1 border-b py-3\">\n        \u003Ch1 class=\"text-lg font-bold\">Chat Room with Auto Scrolling\u003C/h1>\n      \u003C/header>\n\n      \u003Cmain class=\"h-full bg-gray-100/50 py-2\">\n        \u003Cul v-if=\"chats.length\" class=\"space-y-5\">\n          \u003Cli v-for=\"chat in chats\" :key=\"chat.id\">\n            \u003Cdiv>{{ chat.name }}\u003C/div>\n            \u003Cp>{{ chat.message }}\u003C/p>\n          \u003C/li>\n        \u003C/ul>\n      \u003C/main>\n\n      \u003Cfooter class=\"flex-1 border-t py-2\">\n        Nuxt 3 + Composition API + TailwindCSS\n      \u003C/footer>\n    \u003C/div>\n  \u003C/div>\n\u003C/template>\n```\n\n이렇게하면 대충 채팅이 쌓이는 것 처럼은 보이게 됐습니다.\n\n![Jul-18-2023 01-54-02](https://github.com/peterkimzz/blog/assets/20244536/c6c4864f-202e-4f87-900d-779b9b71124e)\n\n근데 화면이 꽉 차면 Footer의 위치는 그대로인데, 채팅은 계속 아래로 쌓여서 글자가 겹쳐보이는 현상이 있습니다. 간단하게 CSS를 수정해봅시다.\n\n```vue [app.vue]\n\u003Ctemplate>\n  \u003Cdiv class=\"h-[100dvh]\">\n    \u003Cdiv class=\"mx-auto flex h-full max-w-sm flex-col overflow-hidden px-5\">\n      \u003Cheader class=\"flex-1 border-b py-3\">\n        \u003Ch1 class=\"text-lg font-bold\">Chat Room with Auto Scrolling\u003C/h1>\n      \u003C/header>\n\n      \u003Cmain class=\"h-full overflow-y-scroll bg-gray-100/50 py-2\">\n        \u003Cul v-if=\"chats.length\" class=\"space-y-5\">\n          \u003Cli v-for=\"chat in chats\" :key=\"chat.id\">\n            \u003Cdiv>{{ chat.name }}\u003C/div>\n            \u003Cp>{{ chat.message }}\u003C/p>\n          \u003C/li>\n        \u003C/ul>\n      \u003C/main>\n\n      \u003Cfooter class=\"flex-1 border-t py-2\">\n        Nuxt 3 + Composition API + TailwindCSS\n      \u003C/footer>\n    \u003C/div>\n  \u003C/div>\n\u003C/template>\n```\n\n![Jul-18-2023 01-57-06](https://github.com/peterkimzz/blog/assets/20244536/937d1b27-90ff-4e96-a61d-60d09fcc4192)\n\ncss의 `overflow` 속성을 이용해 아주 쉽게 Header와 Footer는 고정시키면서 가운데 영역의 콘텐츠가 꽉 찼을 때만 스크롤이 되게끔 동작하게 됐습니다.\n\n## 자동 스크롤 구현하기\n\n다음으로는 채팅이 쌓여 스크롤이 길어질 때 마다 가장 아래로 화면을 스크롤시키면 됩니다. 근데 어떻게 할까요?\n\n채팅이 새로 추가가 됐을때 가운데 영역이 차지하는 영역 높이를 계산 후 스크롤을 아래로 보내면 됩니다. 말로는 약간 복잡한데 코드로 보면 쉽습니다.\n\n채팅을 감싸는 HTML 태그에 접근할 수 있도록 변수에 담고, 아까 만든 `pollingChats` 함수를 약간 수정합시다.\n\n```ts\nfunction pollingChats() {\n  const INTERVAL = 2000; // 2 seconds\n  setInterval(async () => {\n    const chat = await getChat();\n    chats.value.push(chat);\n\n    if (chatContainer.value) {\n      chatContainer.value.scrollTo(0, chatContainer.value.scrollHeight);\n    }\n  }, INTERVAL);\n}\n```\n\n![Jul-18-2023 02-08-17](https://github.com/peterkimzz/blog/assets/20244536/e3abbba8-1cb6-43f5-b695-ea68a84660ee)\n\n채팅이 새로 생겼을 때 자동으로 밑으로 내려가기는 하지만 뭔가 이상합니다. 마지막으로 생성된 채팅으로 이동하는 게 아니라, 그 위에까지만 이동하고 있습니다.\n\n이유는 채팅이 실제로 그려진 이후 영역이 늘어난 다음에 스크롤이 이동해야하기 때문입니다. 현재 코드는 화면이 새로 업데이트가 되기도 전에 스크롤이 이동하기 때문에 마지막 위치로 이동하지 못하는 것이죠.\n\n여기서 꿀팁은 Vue의 `nextTick` 함수를 이용하면 아주 쉽게 구현이 됩니다. 이 녀석의 역할을 간단하게 설명드리면 데이터 변화로 인한 화면 업데이트 이후 콜백이 실행되는 함수입니다. 다시 코드를 조금만 수정해주겠습니다.\n\n```ts\nfunction pollingChats() {\n  const INTERVAL = 2000; // 2 seconds\n  setInterval(async () => {\n    const chat = await getChat();\n    chats.value.push(chat);\n\n    nextTick(() => {\n      if (chatContainer.value) {\n        chatContainer.value.scrollTo(0, chatContainer.value.scrollHeight);\n      }\n    });\n  }, INTERVAL);\n}\n```\n\n![Jul-18-2023 02-13-04](https://github.com/peterkimzz/blog/assets/20244536/3392318a-beb7-497e-aaf0-5ae981c8eeed)\n\n마지막 채팅이 추가되는 순간에 그 위치로 스크롤이 이동하고 있습니다.\n\n## 역할 분리하기\n\n지금까지 크게 2개의 역할을 수행하는 함수를 만들어습니다. 하나는 채팅 데이터를 관리하는 녀석, 나머지 하나는 스크롤링을 하는 녀석입니다.\n\n이렇게 분리하는 이유는 채팅 데이터를 관리하는 녀석은 다른 페이지에서 그대로 다시 보여줄 가능성이 높고, 스크롤링을 하는 함수는 이 역할만 다른 곳에서 또 재사용해서 사용할 가능성이 높기 때문입니다. 그리고 초반부에 말씀드렸다시피 `app.vue`의 입장에서 굳이 채팅을 가져오는 부분과 스크롤링을 하는 내부 구현을 알 필요가 없기 때문입니다.\n\nNuxt 3에는 최상위 디렉토리에 `composables` 라는 폴더가 있으면서 그 아래에 파일이 있으면 자동으로 함수를 전역으로 주입해줍니다. 먼저 채팅을 관리하는 녀석부터 분리해봅시다.\n\n프로젝트 최상위 디렉토리에 `composables` 폴더를 만들고 그 안에 `useChat.ts` 파일을 만들어주세요.\n\n```ts [composables/useChat.ts]\nimport { fakerDE as faker } from \"@faker-js/faker\";\n\ntype Chat = { id: string; name: string; message: string };\n\nexport function useChat() {\n  const chats = ref\u003CChat[]>([]);\n\n  function getChat(): Promise\u003CChat> {\n    return new Promise((resolve) => {\n      setTimeout(\n        () =>\n          resolve({\n            id: faker.string.uuid(),\n            name: faker.person.fullName(),\n            message: faker.lorem.sentence(),\n          }),\n        500 // 0.5 seconds to respond\n      );\n    });\n  }\n\n  function pollingChats() {\n    const INTERVAL = 2000; // 2 seconds\n    setInterval(async () => {\n      const chat = await getChat();\n      chats.value.push(chat);\n    }, INTERVAL);\n  }\n\n  return { chats, getChat, pollingChats };\n}\n```\n\n그리고 `app.vue` 파일의 `script` 부분을 수정해주세요.\n\n```vue [app.vue]\n\u003Cscript lang=\"ts\" setup>\nconst { chats, pollingChats } = useChat();\n\nconst chatContainer = ref\u003CHTMLElement>();\n\npollingChats();\n\u003C/script>\n```\n\n근데 여기서 잠깐, 채팅과 관련된 녀석의 역할을 분리함으로써 오히려 자동 스크롤을 구현하기가 어려워졌습니다. `pollingChats` 함수 마지막 부분에서 `chatContainer.scrollTo`를 호출하고 있었는데 역할 분리에 따라 자동 스크롤과 관련된 코드를 넣기가 어려워졌기 때문입니다.\n\n저는 2가지 해결 방법이 떠오르는데요, `pollingChats()`의 파라미터로 `setInterval` 호출 이후 콜백을 받을 수 있게 하든가, 아니면 Vue의 빌트인 함수인 `watch`를 사용하는 방법이 생각났습니다.\n\n저는 좀 더 Vue스럽게 문제를 해결하기 위해 두 번째 방법을 시도하겠습니다. 일단 자동 스크롤과 관련된 녀석을 분리하기 위해 `useAutoScroll.ts` 파일을 `composables` 폴더 아래에 만들어줍시다.\n\n```ts [composables/useAutoScroll.ts]\nexport function useAutoScroll(container: Ref\u003CHTMLElement | undefined>) {\n  function scrollToBottom() {\n    nextTick(() => {\n      if (!container.value) {\n        return;\n      }\n\n      container.value.scrollTo(0, container.value.scrollHeight);\n    });\n  }\n\n  return { scrollToBottom };\n}\n```\n\n여기서 언급할만한 부분은 이 함수의 인자로 자동 스크롤을 적용하고 싶은 Vue의 반응형 HTML Element를 옵셔널로 받게끔 해주는 겁니다. 옵셔널로 해주는 이유는 `app.vue` 즉 이 함수를 호출하는 곳에서 옵셔널을 신경쓰지 않게끔 해주기 위함입니다. 이건 아래 코드를 보면 더 이해가 빠릅니다. 이제 `app.vue` 파일을 수정해줍시다.\n\n```vue [app.vue]\n\u003Cscript lang=\"ts\" setup>\nconst { chats, pollingChats } = useChat();\n\nconst chatContainer = ref\u003CHTMLElement>();\nconst { scrollToBottom } = useAutoScroll(chatContainer);\n\nwatch(\n  () => chats.value.length,\n  () => {\n    scrollToBottom();\n  }\n);\n\npollingChats();\n\u003C/script>\n```\n\n`watch` 함수에 대한 설명으로는, 첫 번째 반환값에 데이터 변화를 감지하고 싶은 녀석을 넣어주면 두 번째 응답 콜백에서는 데이터 변화가 일어났을 때 이 콜백이 실행됩니다.\n\n즉 위 코드는 채팅 데이터를 담고 있는 녀석의 배열 갯수가 달라질때마다 스크롤을 화면 밑으로 내려라가 됩니다.\n\n이렇게 하면 역할을 분리하기 전과 동일하게 작동하고, 관심사가 분리되어 훨씬 더 보기 좋은 코드가됐습니다.\n\n## UX 개선하기\n\n블로그를 마무리하기 전에 조금 신경쓰이는 부분이 있습니다.\n\n보통 우리가 지금까지 만든 실시간 채팅 형태의 UX에선 지나간 채팅을 보기 위해 스크롤을 올리면 자동 스크롤이 멈춥니다. 그리고 다시 스크롤을 가장 아래로 내리면 알아서 다시 자동 스크롤이 시작됩니다.\n\n마지막으로 이걸 개선해봅시다.\n\n일단 채팅과는 관련이 없기 때문에 이건 `useAutoScroll.ts` 파일로 가서 수정합시다.\n\n```ts [composables/useAutoScroll.ts]\nexport function useAutoScroll(container: Ref\u003CHTMLElement | undefined>) {\n  const isAutoScrolling = ref\u003Cboolean>(true);\n\n  watch(\n    () => container.value,\n    () => {\n      if (container.value) {\n        container.value.addEventListener(\"scroll\", () => {\n          const scrollTop = container.value!.scrollTop;\n          const scrollHeight = container.value!.scrollHeight;\n          const clientHeight = container.value!.clientHeight;\n\n          const reachBottom = scrollTop + clientHeight >= scrollHeight;\n\n          if (reachBottom) {\n            isAutoScrolling.value = true;\n          } else {\n            isAutoScrolling.value = false;\n          }\n        });\n      }\n    }\n  );\n\n  function scrollToBottom() {\n    nextTick(() => {\n      if (!container.value || !isAutoScrolling.value) {\n        return;\n      }\n\n      container.value.scrollTo(0, container.value.scrollHeight);\n    });\n  }\n\n  return { scrollToBottom };\n}\n```\n\n코드는 간단합니다.\n\n이 `useAutoScroll` 내부에 아까처럼 `watch` 를 이용해 `container` 변화를 감지해서 실제로 `container` 가 생겼을 때 `addEventListener` 를 붙여주고, 스크롤할 때 마다 높이를 감지해서 바닥에 스크롤이 닿았으면 `boolean` 값을 `true`으로 만들고, 아니면 `false` 로 만들어주면 됩니다.\n\n![Jul-24-2023 21-00-15](https://github.com/peterkimzz/blog/assets/20244536/5141b946-3ac3-419d-a2d7-24d9d6cf14a1)\n\n잘 되네요!\n\n## 마무리\n\nVue 3 Composition API 에서 새로 추가된 몇 가지 기능들과 Nuxt 3에서 새로 추가된 `composables` 을 활용해 간단한 예제와 함께 알아보았습니다. Composition API를 사용하면 훨씬 더 가독성 좋은 코드를 작성할 수 있고, 장기적으로 유지보수하기에 좋아지기 때문에 익숙하게 사용하신다면 매우 도움이 될 것 같습니다.\n\nNuxt 3에 대해 조금 더 자세히 알고 싶으시다면 [[Nuxt 3] 사이드 프로젝트 만들기 - 개발 환경 설정편](/nuxt3-sideproject-2)를 읽어주세요.\n",{"path":1101,"title":1102,"description":1103,"created":1104,"category":1069,"rawbody":1105},"/phone-validation-service-twilio-in-5-minutes","Twilio 번호 구매 없이 연락처 인증 서비스 5분만에 구현하기","이번 포스팅에선 Twilio를 이용해 Node.js에서 개인 번호를 발급받지 않고, 핸드폰 번호 인증을 매우 간단하게 구현하는 방법에 대해 소개해드리겠습니다.","2023-02-04","---\ncategory: tech\ntitle: Twilio 번호 구매 없이 연락처 인증 서비스 5분만에 구현하기\nimage: https://user-images.githubusercontent.com/20244536/217119347-a66255f7-f5cd-436c-ae11-63bed3bff016.png\nupdated: 2023-02-19\ncreated: 2023-02-04\npublished: true\n---\n\n이번 포스팅에선 `Twilio`를 이용해 `Node.js`에서 개인 번호를 발급받지 않고, 핸드폰 번호 인증을 매우 간단하게 구현하는 방법에 대해 소개해드리겠습니다.\n\n\u003C!--more-->\n\n온라인 서비스를 확장하다보면 개인화된 경험을 위해 반드시 인증 서비스를 구현해야합니다. 구글,카톡으로 로그인하기 같은 OAuth도 있을거고, 이메일/패스워드 로그인도 있고, 아니면 연락처만 넣고 인증할 수도 있습니다. 운영하는 서비스에 맞게끔 잘 선택하면 되겠습니다.\n\n저는 요즘 비밀번호를 수집하지 않는 것을 좋아합니다. 데이터베이스 유저 테이블에 `password`, `password_salt` 같은 항목을 넣기도 싫고, 보안에 더 신경을 써야할 것만 같은 느낌이 들기 때문입니다. 어렵진 않지만 굳이 암호화 로직도 추가해야하구요.\n\n그리고 로그인 가능한 방법을 여러개 넣는 것도 좋아하지 않습니다. 구글로 로그인하기, 카톡으로 로그인하기, 이메일로 로그인하기, 연락처로 로그인하기.. 이런거 다 때려박는 서비스들이 가끔 있는데, 이러면 유저가 오랜만에 로그인하려고 하면 뭘로 했는지 기억이 안나서 로그인 시도를 여러번 해야하기 때문입니다. 일단 저부터가 뭘로 가입했었는지 기억이 안나서 중간에 앱 꺼버린 적이 생각보다 많았습니다.\n\n:Serieis{:type=\"in-n-minutes\"}\n\n## 시작하기\n\n저희가 사용하려는 서비스는 [Twilio Verify](https://www.twilio.com/verify)라는 기능입니다. 인증 1개당 $0.05입니다. 그리고 가입시 약 $15정도를 무료로 사용할 수 있도록 제공해줍니다. 한국어 번역은 지원되지 않습니다만 매우 쉽습니다.\n\n[회원가입 페이지](https://www.twilio.com/try-twilio)로 접속하신 뒤 가입해주세요.\n\n![Twilio-Sign-Up](https://user-images.githubusercontent.com/20244536/216743616-6635afdd-d823-49eb-98e0-bfa6fbd7e384.png)\n\n그 다음 입력한 이메일로 온 인증 메일을 통해 가입을 완료해주세요.\n\n![Twilio-Email-Received](https://user-images.githubusercontent.com/20244536/216743650-5ec01f83-e692-42a0-a503-e8d30f6b18b1.png)\n\n이메일 안의 링크를 눌렀다면, 다음은 연락처 인증입니다. 이 기능은 저희가 곧 이용할 기능이기도 합니다.\n\n![Twilio-Phone-Validation](https://user-images.githubusercontent.com/20244536/216743695-2245f96a-e68b-4674-ba65-7600d787c74d.png)\n\n연락처는 `1011112222` 처럼 맨 앞 0은 빼고 넣으셔도 됩니다.\n\n![Twilio-Phone-Code](https://user-images.githubusercontent.com/20244536/216743851-f0ae90ea-37d8-4f51-8bc3-0a8014923c05.PNG)\n\n그러면 이렇게 인증 코드를 받을 수 있습니다. 근데 기존 기록을 보니 Mailchimp와 SendGrid도 Twilio를 통해 연락처 인증을 구현하고 있다는 사실을 알게 되었습니다.\n\n![Twilio-Registered](https://user-images.githubusercontent.com/20244536/216743955-878328c6-0630-4cdd-b12e-54c0e7e8e380.png)\n\n가입을 완료했습니다.\n\n![Twilio-Required-Forms](https://user-images.githubusercontent.com/20244536/216744012-ad02f243-55da-4084-9d83-6479a43b55a0.png)\n\n서비스 이용을 위해 필수로 작성해야하는 설문조사를 해야합니다. 아무렇게나 작성하셔도 됩니다.\n\n## 인증 시스템 구현하기\n\n자 가입을 완료했으면 자동으로 대시보드로 이동하는데요, 왼쪽 메뉴 바에서 `Verify` -> `Try it out` 으로 이동합시다.\n\n![Twilio-Verify-Try-it-Out](https://user-images.githubusercontent.com/20244536/216744201-32283da1-d075-4b77-bde7-c707e3a177ce.png)\n\n이 인증 서비스의 이름을 지어주세요. 이 이름은 나중에 인증 코드를 보낼 때 **서비스의 이름**으로 보여지게 됩니다. 인증 코드를 받는 유저가 헷갈리지 않도록 애플리케이션 이름과 일치시켜주시면 좋겠네요.\n\n![Twilio-Verify-Try-it-Out](https://user-images.githubusercontent.com/20244536/216745258-594dfc3a-bdcc-4f1e-bffa-f6868948a45f.png)\n\n서비스까지 생성하셨다면 이제 거의 다 끝났습니다. 이제 서버 측 코드에서 API를 호출하기 위한 3개의 키를 가져오면 됩니다. 일단 왼쪽 상단 `My First Twilio Account`을 눌러 콘솔 메인 화면으로 이동해주세요.\n\n![Twilio-Verify-Try-it-Out](https://user-images.githubusercontent.com/20244536/216745698-c91d628c-45a9-4926-80a3-285ef419242b.png)\n\n저희에게 필요한 건 3가지 키입니다.\n\n- `Account SID`\n- `Auth Token`\n- `Service SID`\n\nAccount SID와 Auth Token은 방금 방문한 콘솔 메인화면 하단에 있습니다.\n\n![Twilio-Verify-Try-it-Out](https://user-images.githubusercontent.com/20244536/216745760-0927d1e8-d156-4b98-be50-baf0a9b4ed68.png)\n\n마지막 Service SID는 `Verify` -> `Services`에 가면 가져올 수 있습니다.\n\n![Twilio-Verify-Try-it-Out](https://user-images.githubusercontent.com/20244536/216745796-f71f5af1-e03c-4024-9b77-6befce7f9a80.png)\n\n## 서버측 코드 작성하기\n\n코드를 작성하기 전에 먼저 필요한 npm 패키지들을 설치해주세요. 둘 다 타입스크립트 지원합니다.\n\n- [`twilio`](https://www.npmjs.com/package/twilio)\n- [`phone`](https://www.npmjs.com/package/phone)\n\n```bash\nyarn add twilio phone\n# npm install twilio phone\n```\n\n저는 타입스크립트로 작성하겠습니다. 따로 모듈로 작성해두었구요, 이거 그냥 복붙해서 Secret 키만 바꾼다음 import 해서 사용하시면 바로 잘 작동할겁니다. 뭐 사실 너무 간단해서 코드 설명할 필요가 없습니다.\n\n```ts\nimport twilio from \"twilio\";\n\nexport class Twilio {\n  private client: twilio.Twilio;\n  private accountSid = \"AC9400af563ea46b42b3255f287abXXXXX\";\n  private authToken = \"65406c430c90d00268ef9bf0720XXXXX\";\n  private verifyServiceSid = \"VAaa47973652ccaabfc582ed8c1afXXXXX\";\n\n  constructor() {\n    this.client = twilio(this.accountSid, this.authToken);\n  }\n\n  sendVerificationCode(options: { to: string }) {\n    return this.client.verify.v2\n      .services(this.verifyServiceSid)\n      .verifications.create({ to: options.to, channel: \"sms\" });\n  }\n  checkVerificationCode(options: { to: string; code: string }) {\n    return this.client.verify.v2\n      .services(this.verifyServiceSid)\n      .verificationChecks.create({\n        to: options.to,\n        code: options.code,\n      });\n  }\n}\n```\n\n호출은 정말 너무나 간단합니다.\n\n```ts\nimport express from \"express\";\nimport phone from \"phone\";\nimport { Twilio } from \"./utils/sms\";\n\nconst app = express();\napp.use(express.json());\n\napp.post(\"/send\", async (req, res) => {\n  const body = req.body as { phone?: string };\n  if (!body.phone) {\n    throw new Error(\"400\");\n  }\n\n  const phoneValidation = phone(body.phone, { country: \"KOR\" });\n  if (!phoneValidation.isValid) {\n    throw new Error(\"invalid format of the phone.\");\n  }\n\n  const twilio = new Twilio();\n\n  const result = await twilio.sendVerificationCode({\n    to: phoneValidation.phoneNumber,\n  });\n\n  res.json({\n    success: true,\n    data: { result },\n  });\n});\n\napp.listen(3000, () => {\n  console.log(\"http://localhost:3000\");\n});\n```\n\n하나 짚어둘 부분이 있는데요, `twilio` 패키지에 연락처 정보를 넘길 때 항상 하이픈 없이 국가 코드를 같이 보내주어야 합니다. 예를들어 `010-1111-2222` 라는 한국 연락처가 있다고 했을 때 저 패키지 `to` 값에는 반드시 `+821011112222` 라고 넣어주어야 한다는 뜻입니다.\n\n하지만 한국에서 일반적으로 연락처를 넣을 땐 `01011112222` 이렇게 11자리만 넣는게 모든 사람에게 익숙합니다. 아무도 `+821011112222` 이렇게 넣진 않습니다.\n\n그래서 아까 `phone` 패키지를 같이 설치한건데요, 유저가 하이픈을 넣거나 국가코드를 넣지 않더라도 반드시 twilio가 원하는 포맷으로 만들어줍니다. 아예 안맞는 포맷이면 `isValid` 가 `false`가 되면서 유저에게 미리 에러 응답을 줄 수 있겠죠.\n\n그래서 저 `sendVerificationCode` 함수에 대한 성공 응답은 이렇습니다.\n\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"result\": {\n      \"sid\": \"VEb7fbe1cfddfb5809d5e08748816XXXXX\",\n      \"serviceSid\": \"VAaa47973652ccaabfc582ed8c1afXXXXX\",\n      \"accountSid\": \"AC9400af563ea46b42b3255f287abXXXXX\",\n      \"to\": \"+821042730000\",\n      \"channel\": \"sms\",\n      \"status\": \"pending\",\n      \"valid\": false,\n      \"lookup\": {\n        \"carrier\": null\n      },\n      \"amount\": null,\n      \"payee\": null,\n      \"sendCodeAttempts\": [\n        {\n          \"attempt_sid\": \"VLb759dde6804ba896b9cdb44748dXXXXX\",\n          \"channel\": \"sms\",\n          \"time\": \"2023-02-04T03:36:03.000Z\"\n        },\n        {\n          \"attempt_sid\": \"VL6100bc660d2e825e3b8cfaacc15XXXXX\",\n          \"channel\": \"sms\",\n          \"time\": \"2023-02-04T03:38:05.798Z\"\n        }\n      ],\n      \"dateCreated\": \"2023-02-04T03:36:03.000Z\",\n      \"dateUpdated\": \"2023-02-04T03:38:05.000Z\",\n      \"url\": \"https://verify.twilio.com/v2/Services/VAaa47973652ccaabfc582ed8c1af463ad/Verifications/VEb7fbe1cfddfb5809d5e0874881669f6a\"\n    }\n  }\n}\n```\n\n주목할 부분은 `data.result.valid` 값이 아직 인증 전이라 `false` 라는 것 말고는 없습니다.\n\n아 그리고 `data.result.sendCodeAttempts` 부분을 보니 같은 번호에 대해 기록을 쌓아주는 것 같으니 너무 자주 요청을 보내지 못하도록 저 항목을 비교해서 좀 이따가 다시 시도하라는 응답을 우리 서버가 주도록 구현할 수도 있겠네요. 근데 구현하지 않더라도 아마 Twilio 내부적으로 자주 보내지 못하도록 Rate Limit이 있기는 할겁니다.\n\n어쨌든 성공 응답을 받는다면 입력받은 연락처로 6자리 인증 코드가 발송됩니다.\n\n![ㅁ](https://user-images.githubusercontent.com/20244536/216746989-ff7f664f-85ff-461b-af5e-906e405d9a13.jpeg)\n\n아까 이야기했던 것 처럼 인증 코드를 보냈을 때 미리 지정해주었던 서비스 이름인 `Phone Validation Code` 가 잘 표시됩니다.\n\n인증 쪽 코드도 똑같습니다. 연락처랑 코드 넣어주면 됩니다.\n\n```ts\napp.post(\"/verify\", async (req, res) => {\n  const body = req.body as { phone?: string; code?: string };\n  if (!body.phone || !body.code) {\n    throw new Error(\"400\");\n  }\n\n  const phoneValidation = phone(body.phone, { country: \"KOR\" });\n  if (!phoneValidation.isValid) {\n    throw new Error(\"invalid format of the phone.\");\n  }\n\n  const twilio = new Twilio();\n\n  const result = await twilio.checkVerificationCode({\n    to: phoneValidation.phoneNumber,\n    code: body.code,\n  });\n\n  res.json({\n    success: true,\n    data: { result },\n  });\n});\n```\n\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"result\": {\n      \"sid\": \"VEb7fbe1cfddfb5809d5e08748816XXXXX\",\n      \"serviceSid\": \"VAaa47973652ccaabfc582ed8c1afXXXXX\",\n      \"accountSid\": \"AC9400af563ea46b42b3255f287abXXXXX\",\n      \"to\": \"+821042730000\",\n      \"channel\": \"sms\",\n      \"status\": \"approved\",\n      \"valid\": true,\n      \"amount\": null,\n      \"payee\": null,\n      \"dateCreated\": \"2023-02-04T03:36:03.000Z\",\n      \"dateUpdated\": \"2023-02-04T03:39:00.000Z\"\n    }\n  }\n}\n```\n\n`data.valid`는 `true`, `data.status`는 `approved`가 되면서 인증은 끝이 납니다. 직접 운영하시는 데이터베이스가 있다면 성공 요청 이후에 해당 유저 레코드를 업데이트 해주면 되겠네요.\n\n## 마무리\n\n외부 서비스를 이용하다보니 사진이 많았는데 사전 작업과 코드 정말 뭐 별거 없습니다. 키 받아와서 API 2개 사용한 거 그게 전부입니다.\n\n이제 비밀번호 수집 없이 연락처만 받고 최대한 가볍게 인증을 구현하는 것도 가능하겠죠?\n\n우리 모두 인증 때문에 스트레스 받지 맙시다.\n",{"path":1107,"title":1108,"description":1109,"created":1110,"category":1069,"rawbody":1111},"/nuxt3-sideproject-2","[Nuxt 3] 사이드 프로젝트 만들기 - 개발 환경 설정편","저번 사이드 프로젝트 만들기 - 기획편의 다음 편입니다. 이번엔 nuxt3의 주요 변경사항 일부를 알아보고, 쾌적한 개발 환경을 위해 몇 가지 세팅을 해보도록 하겠습니다.","2022-03-12","---\ncategory: tech\ntitle: \"[Nuxt 3] 사이드 프로젝트 만들기 - 개발 환경 설정편\"\nupdated: 2022-03-12\ncreated: 2022-03-12\nimage: https://user-images.githubusercontent.com/20244536/158020130-9fbf9873-9bdf-43ca-81a8-45cbe5ac900b.png\npublished: true\n---\n\n저번 [사이드 프로젝트 만들기 - 기획편](/nuxt3-sideproject-1/)의 다음 편입니다. 이번엔 `nuxt3`의 주요 변경사항 일부를 알아보고, 쾌적한 개발 환경을 위해 몇 가지 세팅을 해보도록 하겠습니다.\n\n일단 새로운 `Nuxt3` 프로젝트를 생성합시다. 터미널을 열고 아래 명령어를 입력합시다.\n\n저는 웹 애플리케이션이면 프로젝트 이름을 보통 도메인 이름과 매칭해서 만듭니다. `www` 도메인은 이미 사용 중이니, `app.drawbeat.com` 이라는 이름으로 만들겠습니다. 여러분들은 아무거나 하셔도 됩니다.\n\n```bash [bash]\nnpx nuxi init app.drawbeat.com\ncd app.drawbeat.com\nyarn && yarn dev -o\n```\n\n![image](https://user-images.githubusercontent.com/20244536/153166157-fa49c657-fcc2-43da-bec4-9145a3fb1f92.png)\n\n이렇게 하면 개발 서버가 열리게 되고, `nuxt3`의 첫 화면이 보이게 됩니다.\n\n\u003C!--more-->\n\n![image](https://user-images.githubusercontent.com/20244536/153166558-00d0a21a-9d5d-4382-8730-ede6adfb369a.png)\n\n폴더 구조를 살펴볼까요? `nuxt2` 와는 다르게 파일이 몇 개 없습니다. 그리고 페이지 렌더링을 위해 필수였던 `pages/` 폴더가 사라졌고, `app.vue` 파일만 있습니다.\n\n`nuxt3`의 주요 변경점 중 하나는 `pages/` 폴더가 **옵션**이라는 점입니다.\n\n왜 옵션으로 바뀌었을까요? 프로젝트에 따라 페이지 라우팅이 필요 없는 경우, `pages/` 폴더를 만들지 않으면 `vue-router` 패키지를 결과물에 포함시키지 않게되서 용량을 줄일 수 있기 때문입니다. 이제 랜딩 페이지만 필요한 경우엔 굳이 `pages/` 폴더를 만들지 않아도 되겠네요.\n\n그리고 `tsconfig.json` 파일이 있는걸로 봐선 `typescript`가 이제 기본 언어입니다. 이 기회에 타입스크립트를 사용하지 않으셨던 분들이라면 꼭 도입해보세요. 개발할 때 도움이 많이 됩니다.\n\n근데 저희는 페이지를 여러개 만들거라 라우팅이 꼭 필요합니다. `pages/index.vue` 파일을 만들어주고, `app.vue` 파일을 수정해줍시다.\n\n```vue [app.vue]\n\u003Ctemplate>\n  \u003Cdiv class=\"text-lg\">\n    \u003CNuxtLayout>\n      \u003CNuxtPage />\n    \u003C/NuxtLayout>\n  \u003C/div>\n\u003C/template>\n```\n\n```vue [pages/index.vue]\n\u003Ctemplate>\n  \u003Cdiv>index\u003C/div>\n\u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/153169723-be2cc4b6-a1c4-4bb6-aac9-7f381e53fd1a.png)\n\n웰컴 페이지가 사라지고, `pages/index.vue` 파일을 잘 읽고 있네요.\n\n`\u003CNuxtPage>` 태그는 `pages/` 폴더를 가져와서 렌더링한다는 뜻이겠고, `\u003CNuxtLayout>`은 나중에 `layouts/` 폴더를 만들면 그 때 자세히 설명하겠습니다.\n\n여기서 또 하나 추가 변경사항이 있다면 `app.vue`가 모든 페이지의 진입점이 되는 **컴포넌트** 역할을 하기 때문에 전역적으로 `js` 파일이나 `css` 파일을 적용하고 싶다면 `app.vue` 에 적용해도 됩니다.\n\n기존에는 전역으로 적용되는 코드를 넣기 위해 `nuxt.config` 파일에 객체 형식으로 CDN 링크 등을 넣어주거나 `plugin` 을 사용하기도 했었는데, 이 부분은 `.vue` 파일을 이용해 컴포넌트를 활용한다는 점에서 기존보다 조금 더 일관적으로 프로젝트를 관리할 수 있어서 좋아진 것 같습니다.\n\n그러면 라우팅이 잘 되는지 확인을 위해 로그인 페이지도 만들어보고, 라우팅을 위한 `a` 태그도 만들어보겠습니다.\n\n```diff [pages/login/index.vue]\n   \u003Ctemplate>\n     \u003Cdiv>\n       \u003Cnav>\n+        \u003CNuxtLink to=\"/\">Go to index\u003C/NuxtLink>\n       \u003C/nav>\n\n       \u003Cdiv>login\u003C/div>\n     \u003C/div>\n   \u003C/template>\n```\n\n```diff [pages/index.vue]\n   \u003Ctemplate>\n     \u003Cdiv>\n       \u003Cnav>\n+        \u003CNuxtLink to=\"/login\">Go to login\u003C/NuxtLink>\n       \u003C/nav>\n\n       \u003Cdiv>index\u003C/div>\n     \u003C/div>\n   \u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/153176122-6f3d47f8-0246-44a8-bec8-4b9c06473d0f.png)\n![image](https://user-images.githubusercontent.com/20244536/153176179-649f294d-c033-4221-96c7-af35ca38cf69.png)\n\n링크를 눌러보면 페이지 이동도 잘 되고, `/login` URL에서 새로고침을 하더라도 페이지가 잘 렌더링됩니다.\n\n`\u003CNuxtLink>`는 `nuxt` 에서 기본으로 내장되어있는 페이지 라우팅용 컴포넌트입니다. 웹사이트 **내부** 페이지 이동을 위해 `\u003Ca>` 태그 대신 사용하시면 됩니다.\n\n참고로 페이지 내부 이동을 `\u003Ca>` 태그 대신 `\u003CNuxtLink>` 컴포넌트를 사용하는 이유는 라우팅이 훨씬 빠르기 때문입니다. 페이지 전체를 다시 불러오지 않고, 바뀐 부분만 렌더링하기 때문입니다.\n\n그럼 동적 페이지를 만드려면 어떻게 하면 될까요? 여기서도 `nuxt3`의 변경사항이 있습니다.\n\n`/blog/1`, `/blog/2` ... 같이 동적 URL 파라미터 값을 사용하려면 기존에는 폴더명 맨 앞에 `_` 기호를 이용했었습니다. 예를 들어 이렇게요.\n\n```bash [nuxt2]\nproejct/\n└── pages/\n    └── _blogId/\n        └── index.vue\n```\n\n하지만 `nuxt3`에서는 이렇게 사용합니다.\n\n```[nuxt3]\nproejct/\n└── pages/\n    └── [blogId]/\n        └── index.vue\n```\n\n`nuxt3` 에서만 사용가능한 방법도 새로 추가됐는데요, `[blogId]` 말고 `blog-[id]` 이런식으로 파라미터의 일부 텍스트만 동적으로 받을 수도 있습니다.\n\n동적 `path` 파라미터 값을 가져올 땐 기존과 똑같습니다.\n\n```vue [vue]\n\u003Ctemplate>\n  \u003C!-- [blogId] 인 경우 -->\n  {{ $route.params.blogId }}\n\n  \u003C!-- blog-[id] 인 경우 -->\n  {{ $route.params.id }}\n\u003C/template>\n```\n\n쉽죠? `nuxt3`쪽이 훨씬 더 가독성이 좋습니다. 아 참고로 이제는 `\u003Ctemplate>`쪽에서 `$route`를 사용하는 건 자제하는게 좋습니다. 이유는 나중에 `Vue3`에서 새롭게 추가된 `Composition API`를 사용할 때 알려드릴게요.\n\n## Navigation Bar\n\n![image](https://user-images.githubusercontent.com/20244536/153179255-d77b4acf-c7f4-4b75-b28d-7a3bd5172710.png)\n![image](https://user-images.githubusercontent.com/20244536/153179042-5a5603a5-0b1f-4731-b674-3b1282a0085e.png)\n![image](https://user-images.githubusercontent.com/20244536/153179146-3d657f72-a0a8-4343-9f8a-c034b1ddf718.png)\n\n거의 대부분 웹사이트가 가장 상단에 주요 페이지들로 이동할 수 있는 링크들을 배치하고 있습니다. 국룰인 것 같으니 따라하면 됩니다.\n\n크게 나누면 왼쪽엔 서비스 로고, 오른쪽엔 링크를 배치하네요. 보통 이 네비게이션 바는 모든 페이지에서 동일하게 보여지니까 컴포넌트로 만들면 좋겠다는 생각이 듭니다.\n\n`nuxt`는 `components/` 폴더 아래에 존재하는 모든 폴더, 파일을 자동으로 `import`합니다. 사실 이게 좋은건지는 의문이긴 한데, 실제로 사용해보면 편하기는 합니다. 왜 좋은지 의문이라고 하냐면 이런식으로 프레임워크가 다른 코드를 불러오는 과정을 자동으로 처리해버리면 나중에 프레임워크 이해도가 없는 사람이 코드를 봤을 때 왜 `import` 없이 렌더링되지? 이런식으로 직관적으로 이해가 안갈 수 있기 때문입니다. 이거 하나는 괜찮을 수 있지만 다른 부분을 계속해서 프레임워크가 자동으로 처리해주는 부분이 많아지면 프로젝트 유지보수가 힘들어지기 때문입니다.\n\n`components/` 폴더를 만들고, `NavigationBar` 라는 이름의 폴더도 만들어주겠습니다.\n\n```vue [components/NavigationBar/index.vue]\n\u003Ctemplate>\n  \u003Cnav>\n    \u003CNuxtLink to=\"/\">Home\u003C/NuxtLink>\n    \u003CNuxtLink to=\"/login\">Login\u003C/NuxtLink>\n  \u003C/nav>\n\u003C/template>\n```\n\n그리고 랜딩 페이지랑 로그인 페이지에서 방금 만든 네비게이션 바 컴포넌트를 불러주세요.\n\n```diff [pages/index.vue]\n   \u003Ctemplate>\n     \u003Cdiv>\n+      \u003CNavigationBar />\n\n       \u003Cdiv>index\u003C/div>\n     \u003C/div>\n   \u003C/template>\n```\n\n```diff [pages/login/index.vue]\n   \u003Ctemplate>\n     \u003Cdiv>\n+     \u003CNavigationBar />\n\n       \u003Cdiv>login\u003C/div>\n     \u003C/div>\n   \u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/153180466-d80909a3-8e56-43f0-8e04-8f6f6d6e7e14.png)\n![image](https://user-images.githubusercontent.com/20244536/153180615-4ce58c75-2be2-44ea-bb0b-2aa38266991c.png)\n\n`NavigationBar`가 잘 렌더링 됐습니다.\n\n근데 한 가지 문제가 있습니다. 지금대로라면 새로운 페이지가 늘어날 때 마다 `\u003CNavigationBar/>` 를 계속해서 페이지 상단에 불러야합니다. 어떻게 하면 코드 중복을 피할 수 있을까요?\n\n이건 `nuxt`의 `layouts` 폴더를 활용하면 쉽게 해결 가능합니다. `layouts/` 폴더를 만들고, `default.vue` 파일을 만들어주세요.\n\n```vue [layouts/default.vue]\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003CNavigationBar />\n\n    \u003Cslot />\n  \u003C/div>\n\u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/153181266-477c8008-9195-4356-a74c-7ecefa2c073d.png)\n\n이렇게 `layouts` 폴더를 만드는 것만으로도 `default` 레이아웃이 전역으로 적용됩니다. 사실 이건 `app.vue` 에서 `\u003CNuxtLayout>` 태그를 이미 감싸줘서 그렇습니다.\n\n`\u003Cslot>` 태그는 일단 `\u003CNuxtPage>` 태그랑 역할이 비슷하다고 생각하시면 됩니다.\n\n`layouts/` 폴더에서 네비게이션 바를 불러오고 있으니 이제 `pages/` 폴더 밑에 있는 파일에선 `\u003CNavigationBar/>` 를 지워주세요.\n\n```diff [pages/index.vue]\n  \u003Ctemplate>\n    \u003Cdiv>\n-     \u003CNavigationBar />\n\n      \u003Cdiv>index\u003C/div>\n    \u003C/div>\n  \u003C/template>\n```\n\n```diff [pages/login/index.vue]\n  \u003Ctemplate>\n    \u003Cdiv>\n-     \u003CNavigationBar />\n\n      \u003Cdiv>login\u003C/div>\n    \u003C/div>\n  \u003C/template>\n```\n\n## Tailwind CSS\n\n본격적으로 `css` 를 사용하기 전에 스타일링을 더 편하고 이쁘게 만들어 줄 패키지인 `tailwindcss`를 먼저 설치하도록 하겠습니다.\n\n```bash [bash]\nyarn add -D tailwindcss postcss@latest autoprefixer@latest @nuxt/postcss8\nnpx tailwindcss init\n```\n\n이러면 `tailwind.config.js` 파일이 생기게 됩니다. 그 다음 `tailwindcss` 를 사용하기위해 `assets/` 폴더 아래 `css/` 폴더 아래 `tailwind.css` 파일까지 만들어주세요.\n\n```css [assets/css/tailwind.css]\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n```\n\n다음은 `nuxt.config.ts` 파일을 수정해줄게요.\n\n```ts [nuxt.config.ts]\nimport { defineNuxtConfig } from \"nuxt3\";\n\nexport default defineNuxtConfig({\n  css: [\"~/assets/css/tailwind.css\"],\n  build: {\n    postcss: {\n      postcssOptions: {\n        plugins: {\n          tailwindcss: {},\n          autoprefixer: {},\n        },\n      },\n    },\n  },\n});\n```\n\n그리고 마지막으로 `tailwind.config.js` 파일을 수정해주면 끝입니다.\n\n```js [tailwind.config.js]\nmodule.exports = {\n  content: [\n    \"./components/**/*.{js,vue,ts}\",\n    \"./layouts/**/*.vue\",\n    \"./pages/**/*.vue\",\n    \"./plugins/**/*.{js,ts}\",\n  ],\n  theme: {\n    extend: {},\n  },\n  plugins: [],\n};\n```\n\n잘 적용됐는지 확인하기위해 `pages/index.vue` 를 조금 수정합시다.\n\n```vue [pages/index.vue]\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003C!-- font-weight: bold; font-size: 1.875rem; -->\n    \u003Ch1 class=\"**font-bold text-3xl**\">Home\u003C/h1>\n  \u003C/div>\n\u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/153184535-3619e9ea-4a9b-47a8-b788-225e5bed46ce.png)\n\n`tailwindcss` 가 잘 적용됐네요! `tailwindcss` 는 이런식으로 미리 만들어진 클래스를 이용해 `css` 작성없이 스타일링을 적용하는 라이브러리입니다.\n\n그럼 네비게이션 바를 스타일링 하겠습니다.\n\n```vue [components/NavigationBar/index.vue]\n\u003Ctemplate>\n  \u003Cnav>\n    \u003Cdiv class=\"flex justify-between\">\n      \u003CNuxtLink to=\"/\">\n        \u003Cimg\n          src=\"https://pbsmtipexzqvbentyzuw.supabase.co/storage/v1/object/public/drawbeat.com/public/logo1.svg\"\n          alt=\"app.drawbeat.com\"\n          class=\"**w-[180px]**\"\n        />\n      \u003C/NuxtLink>\n\n      \u003Cul>\n        \u003Cli>\n          \u003CNuxtLink to=\"/login\">Login\u003C/NuxtLink>\n        \u003C/li>\n      \u003C/ul>\n    \u003C/div>\n  \u003C/nav>\n\u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/153410898-ededa981-3600-4225-b56d-58fb4353cb02.png)\n\n참고로 `tailwind` 에서 인라인 커스텀 스타일링을 사용하고 싶으면 `w-[180px]` 처럼 대괄호로 감싸서 사용할 수도 있습니다.\n\n로고를 누르면 홈으로 가도록 `\u003CNuxtLink>` 태그로 감싸주었고, 로그인 페이지로 가는 메뉴는 오른쪽에 붙여서 배치했습니다. 아까 참고한거랑 구조는 비슷해졌죠?\n\n## 통신\n\n다음으로 설정해볼 건 HTTP Request 입니다. 보통 다른 서버에 데이터나 작업을 요청하기 위해 사용합니다. 보통은 [`axios`](https://github.com/axios/axios)를 사용하는데, `nuxt3`는 내장된 [`useFetch()`](https://v3.nuxtjs.org/docs/usage/data-fetching#usefetch) 함수가 있습니다.\n\n문서를 살펴보니 이 `fetch` 함수는 [`ohmyfetch`](https://github.com/unjs/ohmyfetch)를 사용하더라구요.\n\n```ts [ohmyfetch]\n// ESM / Typescript\nimport { $fetch } from \"ohmyfetch\";\n\n// CommonJS\nconst { $fetch } = require(\"ohmyfetch\");\n```\n\n저도 처음봤습니다. 브라우저랑 노드에서 둘 다 사용 가능하다고 합니다. `axios`에 비해 어떤 이점이 있는지 문서를 읽어봤는데 크게 어떤 이점이 있는지는 잘 모르겠습니다. 하나 꼽자면 타입스크립트 친화적이라는 점 정도 있을 것 같습니다.\n\n`nuxt3` 에서는 이렇게 사용하면 됩니다.\n\n```vue [nuxt3]\n\u003Cscript setup lang=\"ts\">\nconst { data, error, pending, refresh } = await useFetch(\"https://...')\n\u003C/script>\n```\n\n역시 프레임워크답게 여러가지 편의 기능을 많이 제공하고 있습니다.\n\n- `data`: HTTP 응답 데이터\n- `error?` HTTP 요청 에러 데이터\n- `pending: Boolean`: 요청에 대한 응답을 기다리고 있는지 여부를 가지고 있습니다.\n- `refresh: (force?: Boolean) => Promise\u003Cvoid>`: 같은 요청을 새로 보내고 싶을 때 컴포넌트 내에서 `refresh()` 하면 요청을 또 보낼 수 있습니다.\n\n`pending` 은 생각보다 엄청 코드량을 줄여줍니다.\n\n```vue [nuxt3]\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003Ch1 class=\"text-3xl font-bold\">Home\u003C/h1>\n    \u003Cdiv v-if=\"pending\">Loading..\u003C/div>\n    \u003Ctemplate v-else>\n      \u003Cdiv v-if=\"error\">Sorry, error occured.\u003C/div>\n      \u003Cdiv v-else>{{ data }}\u003C/div>\n    \u003C/template>\n\n    \u003Cbutton @click=\"refresh()\">Refresh\u003C/button>\n  \u003C/div>\n\u003C/template>\n\n\u003Cscript setup lang=\"ts\">\nconst { data, pending, error, refresh } = await useFetch(\n  \"https://jsonplaceholder.typicode.com/todos/1'\n)\n\u003C/script>\n```\n\n프론트엔드에서 HTTP 요청을 보낼 땐 각 상황에 맞는 올바른 UI가 필요합니다. 응답 대기 중에는 로딩이라고 표시하고, 응답이 왔지만 에러가 발생했다면 에러라고 보여줘야하겠죠. 에러가 없다면 그 때서야 원하는 데이터를 보여줄 수 있습니다.\n\n이렇게 `nuxt3` 에서는 스크립트 한 줄로 응답 대기, 에러와 재요청을 모두 처리 가능합니다. 참 편합니다.\n\n근데 이 `useFetch` 함수의 설정을 전역으로 설정하고 싶을 수 있습니다.\n\n그 전에 알아야 할 개념이 있는데요, 일단 `use-` 어쩌구로 시작하는거는 전부 `vue3`의 `composition API` 입니다. `React`의 `hooks`와 같은 개념입니다. 이 둘의 핵심은 프레임워크 기능을 모듈화가 가능하다는 점입니다.\n\n뭐 예를들면 `vue`의 `mounted()` 같은 건 컴포넌트 내에서만 사용 가능했는데, 일부 컴포넌트가 마운트 때 마다 로그를 찍어주고 싶으면 매 컴포넌트마다 `mounted` 훅에 로그를 찍었어야 했습니다. 하지만 `composition` 이라는 이름의 기능이 생기면서 이런 `mounted` 같은 함수를 모듈화해서 재사용이 가능하도록 만들어줬습니다.\n\n어쨌든 `useFetch`도 `use-`로 시작하는걸로 봐선 `composition`이고, `nuxt3` 에서는 이런 커스텀 `composition`을 만드려면 `composables/` 폴더 아래에 파일을 생성하면 자동으로 만들어주도록 되어있습니다. 프로젝트 루트에 `composables/` 폴더를 만들고, `useApi.ts` 파일을 만들어주세요. 이 역시 컴포넌트 폴더처럼 파일 이름을 읽어서 자동으로 전역으로 `import` 해줍니다.\n\n```ts [composables/useApi.ts]\nexport default (url: string) => {\n  return useFetch(url, {\n    baseURL: \"https://api.example.com',\n    onRequest: (context) => {\n      const isDev = process.env.NODE_ENV === 'development'\n      if (isDev) {\n        // 이 부분은 왜 이렇게밖에 못하는지 모르겠는데, 차후 개선이 되면 좋겠네요.\n        // 참고: https://github.com/nuxt/framework/issues/2557#issuecomment-1003865620\n        context.options.headers = new Headers(context.options.headers)\n        context.options.headers.append('Authrization', 'Bearer TOKEN_FOR_DEV')\n      }\n\n      return null\n    },\n  })\n}\n```\n\n이렇게 해주고 프로젝트를 재시작하면 아래처럼 커스텀 `composition`을 전역에서 사용이 가능합니다.\n\n```diff [nuxt3]\n   \u003Cscript setup lang=\"ts\">\n+  const { data, pending, error, refresh } = await useApi('/todos/1')\n-  const { data, pending, error, refresh } = await useFetch(\n     \"https://jsonplaceholder.typicode.com/todos/1'\n   )\n   \u003C/script>\n```\n\n아까 컴포넌트쪽에서 말했듯이 `useFetch` 같은건 `nuxt` 프레임워크에서 전역으로 사용 가능하니까 이대로도 `import` 오류가 발생하진 않습니다.\n\n## 상태 관리\n\n모든 페이지, 컴포넌트에서 같은 데이터를 참조하고 싶을 수 있습니다. 대표적으로 로그인을 한 유저의 정보를 어디서든 가져오고 싶은 경우죠. 기존에는 `Vuex`를 공식 라이브러리로 사용했지만, 이제는 레거시가 되었습니다.\n\n새로운 뷰 코어 팀이 준비한 상태 관리 라이브러리는 바로 [`Pinia`](https://pinia.vuejs.org/) 입니다. `Vuex`도 이미 너무 잘 만든 상태 관리 라이브러리지만 새로 만든 이유는 공식 문서에 따르면 다음과 같습니다.\n\n> Compared to Vuex, Pinia provides a simpler API with less ceremony, offers Composition-API-style APIs, and most importantly, has solid type inference support when used with TypeScript.\n\n요약하자면 컴포지션 API와 타입스크립트를 더 잘 지원하기 위함입니다. 직접 사용해보니까 더 사용하기 편리한 것도 맞고, 타입스크립트 친화적인 것도 맞습니다. 기존에도 좋았는데 지금은 더 좋아졌습니다. 도입하지 않을 이유가 없습니다. 간단하게 사용하기 위해 프로젝트 루트 폴더에 `stores/` 폴더를 만들고 `user.ts` 파일을 만들어줍니다.\n\n`Pinia`의 주요 변경 사항 중 하나는 기존에는 데이터에 변화를 줄 때 비동기 여부에 따라 `actions`와 `commit` 함수를 나누어 사용했는데, 이제는 `actions`에 모두 통합되었습니다. 그거 말고는 똑같습니다.\n\n```ts [stores/user.ts]\nimport { defineStore } from \"pinia\";\n\nexport const useUserStore = defineStore(\"user\", {\n  state: () => ({\n    user: null,\n  }),\n  getters: {\n    doubleCount: (state) => state.user,\n  },\n  actions: {\n    save(user?: any) {\n      this.user = user;\n    },\n  },\n});\n```\n\n사용하는 방법도 조금 다른데요, 기존에는 최상위 `store` 객체를 가져와서 미리 정의된 고유한 문자열을 키로 삼아 전역 상태 값을 가져오거나 변경했었습니다.\n\n이제는 타입스크립트를 통해 완벽한 자동완성을 지원받을 수 있고, 각 모듈화된 `store`를 개별적으로 가져오면 됩니다. 이건 정말 좋아진 것 같습니다.\n\n```diff [pages/index.vue]\n   \u003Ctemplate>\n     \u003Cdiv>\n       \u003Ch1 class=\"font-bold text-3xl\">Home\u003C/h1>\n       \u003Cdiv v-if=\"pending\">Loading..\u003C/div>\n       \u003Ctemplate v-else>\n         \u003Cdiv v-if=\"error\">Sorry, error occured.\u003C/div>\n         \u003Cdiv v-else>{{ data }}\u003C/div>\n       \u003C/template>\n\n+      \u003Cdiv>{{ userStore.user }}\u003C/div>\n\n       \u003Cbutton @click=\"refresh()\">Refresh\u003C/button>\n+      \u003Cbutton @click=\"userStore.save({ email: 'peterkimzz69@gmail.com' })\">Increment\u003C/button>\n     \u003C/div>\n   \u003C/template>\n```\n\n```diff [pages/index.vue]\n   \u003Cscript setup lang=\"ts\">\n+  import { useUserStore } from '~~/stores/users'\n\n   const { data, pending, error, refresh } = await useApi('/todos/1')\n\n+  const userStore = useUserStore()\n   \u003C/script>\n```\n\n## 마무리\n\n이번 글에서는 스타일링 도구인 `tailwindcss`와 HTTP 통신을 위한 `ohmyfetch`에 대해 알아봤습니다. 그리고 `nuxt3`의 주요 변경점도 알아봤습니다.\n\n이 정도면 저희가 하고싶은 개발 환경 설정은 끝이 났습니다. 이것만으로도 적당히 작동하는 앱을 충분히 만들 수 있습니다. 별거 없죠? 요샌 프레임워크들이 편의성을 너무 잘 제공해주고 있어서 창작자들이 개발에 쓰는 시간을 줄여줘서 너무 좋은 것 같습니다. 그 시간 아껴서 고객들이 겪는 문제점을 해결하는데 시간을 더 쓰면 좋겠네요.\n",{"path":1113,"title":1114,"description":1115,"created":1116,"category":1069,"rawbody":1117},"/nuxt3-sideproject-1","[Nuxt 3] 사이드 프로젝트 만들기 - 기획편","올해 첫 개발 관련 주제를 뭘로할까 고민하다가 사이드 프로젝트 아이디어가 떠올라서 그걸 같이 만들어볼까 합니다. 하지만 이미 잘 알고 있던 기술을 사용해서 만들면 재미없겠죠. 사이드 프로젝트는 역시 신기술을 이용해서 만드는게 가장 좋습니다. 나만의 프로젝트를 만들면서 최신 기술도 마음껏 써볼 수 있으니까요.","2022-02-07","---\ncategory: tech\ntitle: \"[Nuxt 3] 사이드 프로젝트 만들기 - 기획편\"\nupdated: 2022-02-07\ncreated: 2022-02-07\nimage: https://user-images.githubusercontent.com/20244536/152959346-43168905-d155-4e46-b812-9dc5eed12951.png\npublished: true\n---\n\n올해 첫 개발 관련 주제를 뭘로할까 고민하다가 사이드 프로젝트 아이디어가 떠올라서 그걸 같이 만들어볼까 합니다. 하지만 이미 잘 알고 있던 기술을 사용해서 만들면 재미없겠죠. 사이드 프로젝트는 역시 신기술을 이용해서 만드는게 가장 좋습니다. 나만의 프로젝트를 만들면서 최신 기술도 마음껏 써볼 수 있으니까요.\n\n신기술로 알아볼 것은 바로 `Nuxt 3`입니다. 아직 베타 버전이기는 하지만, 클라우드 플랫폼인 `Vercel`과 조합해서 사용해보니 아주 쉽게 서버 없이 서버 사이드 렌더링을 구현할 수가 있더라구요. 서버 없이 서버 사이드 렌더링을 한다는 게 이해가 안가실 수도 있어서 간단히 설명해드리면, 그냥 서버리스 함수 1개로 기존 서버를 대체한다는 말입니다. 뭐 이해 안가셔도 괜찮습니다.\n\n\u003C!--more-->\n\n저는 개발자로 일하면서 AWS를 꽤 오래 사용했습니다. 특히 EC2, RDS같은 서버 컴퓨팅을 주로 사용했는데, 서버를 관리한다는 건 정말 짜증나는 일입니다. 왜냐면 서버를 임대하는 순간부터 24시간 스트레스에 시달려야 하니까요.\n\n그동안 서비스를 많이 만들고, 없애고 하다보니 **서버 없이 사이드 프로젝트를 운영할 수만 있다면 진짜 너무 좋겠다** 라는 고민을 항상 했고 계속해서 그것에 대한 답을 찾고 있습니다. 어쨌든 꽤 저렴한 비용으로 그것을 가능하게 해주는 착한 플랫폼 서비스들이 많이 생겨나고 있어서, 저만의 노하우를 공유하면서 새로운 기술도 알아보자 뭐 이런 취지의 시리즈입니다.\n\n## 기획하기\n\n그래서 제가 여러분들과 같이 만들어 볼 사이드 프로젝트는 바로 **경품 이벤트 빌더**입니다.\n\n사실 혼자 [드로우비트](https://drawbeat.com)이라는 경품 홍보 플랫폼을 사이드 프로젝트를 운영 중입니다. 단순히 네이버, 인스타와 페이스북 등 여러 SNS에 흩어져있는 기업 경품 이벤트를 모아주는 서비스입니다.\n\n현재 회원가입한 유저는 300명정도 되는데, 그 동안 무수히 많은 경품 이벤트를 수집하고 데이터를 가공해본 결과 나름 경품 시장에 대한 인사이트가 생겼고 그 인사이트와 사이드 프로젝트의 개발 방향성을 공유해드리려고 합니다.\n\n일단 응모하는 사람들은 꽤 쉽게 회원가입을 했습니다. 아시다시피 소비자층은 그냥 회원가입하라고 하면 절대 안합니다. 그래서 응모자가 실제 응모하는 페이지로 이동하기 위해선 회원가입을 하도록 강제시켰습니다. 대신 회원가입을 쉽게 할 수 있도록 카카오톡으로만 인증이 가능하게끔 UX를 쉽게 설계했습니다. 소셜 로그인 많으면 나중에 뭘로 로그인했는지 헷갈리니까요.\n\n![image](https://user-images.githubusercontent.com/20244536/152776980-9b1ac5ab-0ffd-4527-a85e-ebb0dd488d93.png)\n_로그인/회원가입 페이지를 1개로 처리했다_\n\n어쨌든 응모자들은 이 서비스를 사용하는 명확한 목적이 있고 나름 유용하게 사용하고 있어서 당장은 크게 더 개선할 부분은 없습니다.\n\n문제는 이벤트를 진행하는 기업측입니다. 경품 이벤트를 수집하면서 우리 플랫폼에 올려도 되냐고 DM을 많이 보냈는데 응답해준 기업들에게 열심히 설문조사한 결과, 이 서비스의 장점과 개선점을 파악하게 됐습니다.\n\n먼저 장점은 회사측 이벤트를 보기 좋게 정리할 수 있고, 자체 SNS 채널에 경품 이벤트를 한다는 게시글을 작성할 필요가 없다는 점입니다. 그리고 이벤트 페이지가 생기는 것도 좋아하셨구요. SNS에 게시글 올리는 부분은 호불호가 좀 갈리긴 했는데 이건 채널 성향에 따라 조금 갈린다고 생각하고 있습니다. 피드 콘텐츠를 빡세게 관리하는 기업이라면 경품 이벤트 게시글을 작성하긴 어려우니까요.\n\n![image](https://user-images.githubusercontent.com/20244536/152777922-8abc8849-d521-4c6c-8131-ecdd0ff7b1d4.png)\n_경품 상세 페이지. 로그인을 해야지만 응모하러 갈 수 있다_\n\n가장 많이 받았던 개선점은 당연히 더 많은 홍보가 됐으면 좋겠다는 점이었고, 기업측 대시보드가 필요하다는 것과 당첨자 선별과 경품 발송을 도와주면 좋겠다는 의견이 꽤 있었습니다. 사실 홍보는 유저가 늘어나면 자연스럽게 해결될 문제입니다.\n\n그래서 저는 기업측이 사용할 대시보드, 빌더를 만들면 좋겠네 싶어서 이걸 같이 만들어볼까 합니다.\n\n서론이 참 길었네요. 😅\n\n## 기술 스택\n\n일단 프론트엔드에선 [`Nuxt3`](https://v3.nuxtjs.org/)와, `Vue`의 차세대 상태 관리 라이브러리인 [`Pinia`](https://pinia.vuejs.org/)를 중점적으로 사용할 생각입니다. `Vue3`가 나오고나서 다들 `TypeScript`와 `Composition API`를 사용하다보니 기존 `Options API` 위주로 개발됐던 라이브러리들이 대체되고 있는 것 같습니다. (`Vuex`야 그 동안 고마웠어)\n\n근데 서버는 그냥 [`Express.js`](https://expressjs.com/ko/)를 쓰겠습니다. 이유는 기존 API 서버를 `Express.js`로 만들었는데 이거 API 서버 분리하면서 새로 만들긴 좀 그러니까요..\n\n데이터베이스는 [`Supabase`](https://supabase.com/)를 사용하겠습니다. 예전같으면 RDS로 사용했을텐데, Supabase를 사용해보고 나선 이거만 씁니다. 참고로 이건 [`PostgresDB`](https://www.postgresql.org/) 기반입니다.\n\n마지막으로 프로젝트를 배포할 클라우드 플랫폼은 [`Vercel`](https://vercel.com/)입니다.\n\n사이드 프로젝트 주제에 뭐 기술이 엄청 들어가는데, 웹 서비스를 운영한다는 게 이렇게 힘듭니다. 근데 이것도 제가 진짜 시간과 비용을 엄청 줄일 수 있게끔 고민한겁니다. 전통적인 방법으로 서비스하려고 하면 진짜 몇 배는 더 수고스럽습니다. 그리고 Vue랑 React의 발전도 한 몫합니다.\n\n## 필요한 기능\n\n가장 제품을 단순화시키면 기업측에게 필요한 건 다음과 같습니다.\n\n1. 인증\n2. 경품 이벤트 만들기\n3. 당첨자 선별하기\n\n**1. 인증**\n\n일단 대시보드니까 **로그인**과 **회원가입**이 필요하겠죠.\n\n이것도 그냥 카카오톡 로그인으로 만들까했는데 생각해보니 기업측은 좀 무리가 있는게, 카톡은 다들 개인용으로 사용하는데 개인 계정을 업무용 계정으로 사용하라고 할 순 없습니다. 그래서 업무용으로 쓸 수 있는 이메일 로그인만 지원하면 됩니다.\n\n이건 `Supabase`의 이메일 인증 기능을 사용하면 쉽게 해결됩니다.\n\n**2. 경품 이벤트 만들기**\n\n가장 핵심 기능입니다. 근데 사실 구글 폼을 만드는 것과 크게 구조가 다를 건 없습니다.\n\n보통 경품 이벤트는 설문 조사나 퀴즈 맞추기가 가장 많고, 거기에 SNS 계정 팔로우를 늘리고 싶은 기업들은 검증을 위해 SNS 계정도 양식에 포함시킵니다.\n\n그리고 양식에 반드시 포함시켜야 하는 **응모자 개인정보**입니다. 보통 이름과 핸드폰 번호를 받습니다. 경품을 발송하기 위해서죠. 거의 대부분 기프티콘으로 보낼 수 있는 상품을 보내기 때문에 이름, 핸드폰 번호로 충분합니다. 기프티콘 이외의 상품에 대해선 지금은 고려하지 않겠습니다.\n\n![image](https://user-images.githubusercontent.com/20244536/152785049-771a2747-9f03-4828-9c5d-6ba1fad49a31.png)\n_UI가 대충 이런 형태면 될 것 같다_\n\n결과적으로 커스텀 필드를 계속해서 붙여나갈 수 있는 그런 모양새면 됩니다. 여기서 저는 이 툴의 포지션이 **경품 이벤트를 위한 구글 폼** 정도로 떠올랐습니다. (더 좋은 아이디어 있으면 댓글 부탁드려요!)\n\n**3. 당첨자 선별하기**\n\n필수적으로 들어가야할 기능은 당연히 알림입니다. 당신이 당첨됐으니 기뻐하세요 같은 알림을 대신 보내주면 기업측이 편하겠죠. 선별은 수동 선정을 기본 옵션으로 두고, 선택적으로 자동으로 추첨하기 기능 같은걸 나중에 만들어도 좋을 것 같습니다.\n\n기프티콘을 대신 보내주면 가장 베스트지만 그건 일단 제외하겠습니다. 왜냐면 기프티콘 보내주는 외부 서비스에서 API를 제공받으면 되는데, 결제가 들어가야해서 복잡해지니까요.\n\n## 마무리\n\n기능을 최대한 줄였는데도 각 기능마다 고려해야할 게 많죠? 하지만 사소한 기능 하나라도 **사용자 입장**에서 생각하면 꽤 쉽게 방향을 잡을 수 있습니다. 항상 (개발 역량이 가능한 선에서) 유저 입장을 고려하는 시각을 가져보도록 노력해보세요.\n\n어쩌면 코딩을 안하고도 문제를 해결할 수도 있습니다. 심지어 기존 코드를 지울 수도 있어요. 그러면 장기적으로 유지보수가 편해지니까 좋겠죠.\n",{"path":1119,"title":1120,"description":1121,"created":1122,"category":1069,"rawbody":1123},"/introduce-free-responsive-email-template-mjml","평생 무료로 반응형 이메일 템플릿 무한대로 만들기 - mjml.io","저는 이메일을 데스크톱과 모바일 환경에서 매일매일 확인합니다. 그런데 아직도 모바일 디스플레이에 최적화되지 않은 이메일을 받을 때가 많습니다.","2022-01-07","---\ncategory: tech\ntitle: 평생 무료로 반응형 이메일 템플릿 무한대로 만들기 - mjml.io\nupdated: 2022-01-07\ncreated: 2022-01-07\nimage:\npublished: true\n---\n\n저는 이메일을 데스크톱과 모바일 환경에서 매일매일 확인합니다. 그런데 아직도 모바일 디스플레이에 최적화되지 않은 이메일을 받을 때가 많습니다.\n\n어차피 확대해서 보면 되니까 크게 상관 없긴하지만, 누군가는 이메일 열었는데 잘 안보이면 짜증나서 이메일을 바로 닫아버릴 수도 있겠죠. 안그래도 내가 보낸 이메일 열게 하는 것도 힘든데 말이죠.\n\n그래서 준비했습니다. 이메일을 좀 더 있어보이게 만들고 싶은 분들을 위해 이번엔 **무료로 반응형 이메일 템플릿을 무한으로 즐기는 방법**을 소개해드리려고 합니다. (명륜진사메일)\n\n\u003C!--more-->\n\n저번에 작성한 [평생 무료로 커스텀 이메일 사용하기](/custom-email-service-for-free-forever) 포스팅에서는 `Node.js` 에서 프로그래밍적으로 이메일을 보내는 방법을 알아보았습니다. 저번 포스팅과 시리즈처럼 이어지는 내용입니다.\n\n:Serieis{:type=\"forever\"}\n\n## [mjml.io](https://mjml.io)\n\n> The only framework that makes responsive email easy\n\n공식 홈페이지에 들어가면 가장 먼저 나오는 문구입니다. 멋지네요.\n\n`mjml.io` 을 사용하는 방법은 2가지가 있습니다. 아 근데 `HTML` 을 어느정도 작성할 줄 알아야 합니다.\n\n1. `npm install mjml`\n\n2. 온라인 에디터\n\n사실 제대로 사용하려면 2번은 필수고, 1번은 옵션입니다. 온라인 에디터에서 템플릿 작성하면서 미리보기 화면으로 이메일이 어떻게 보여질지 실시간으로 봐야하니까요.\n\n`mjml`이 반응형 이메일을 만들어주는 건 내부 원리는 간단합니다. `mjml` 문법에 맞춰 콘텐츠를 작성하면, 반응형 이메일이 되도록 `HTML`으로 바꿔주는게 전부입니다.\n\n아 그리고 `mjml` 의 장점 하나 더 있습니다. 이메일을 읽어들이는 클라이언트 앱에 상관없이 같은 스타일링을 보장해줍니다.\n\n무슨 말이냐면 이메일을 읽어주는 클라이언트 앱이 사실 엄청 많습니다. Outlook, Office 365, Gmail, iOS 메일 앱 등등 엄청 많죠. 근데 그게 크롬이랑 파이어폭스가 자바스크립트, 보안 룰이랑 스타일링 조금씩 다르게 적용하는 것 처럼, 이메일 클라이언트들도 각자 조금씩 다릅니다. 근데 `mjml` 은 (어느정도는) 똑같이 보이도록 대응해줍니다. 이거 하나하나 대응하는 거 못할 짓입니다.\n\n호환성 문서는 [여기](https://mjml.io/compatibility/mj-section)를 참고해주세요.\n\n## 시작하기\n\n일단 [온라인 에디터](https://mjml.io/try-it-live)에 접속합니다. 기본 템플릿이 미리 작성되어있습니다.\n\n```mjml [mjml]\n\u003Cmjml>\n  \u003Cmj-body>\n    \u003Cmj-section>\n      \u003Cmj-column>\n        \u003Cmj-image width=\"100px\" src=\"/assets/img/logo-small.png\">\u003C/mj-image>\n\n        \u003Cmj-divider border-color=\"#F45E43\">\u003C/mj-divider>\n\n        \u003Cmj-text font-size=\"20px\" color=\"#F45E43\" font-family=\"helvetica\"\n          >Hello World\u003C/mj-text\n        >\n      \u003C/mj-column>\n    \u003C/mj-section>\n  \u003C/mj-body>\n\u003C/mjml>\n```\n\n`HTML` 이랑 코드 작성하는게 완전히 똑같죠? 엄청 쉽습니다. 사용 가능한 모든 태그는 [여기](https://documentation.mjml.io/#components)에서 확인 가능합니다.\n\n근데 일단 코드 작성하기 전에 이메일을 어떻게 구성할지 기획부터 합시다.\n\n보통 일반적인 이메일의 구성은 상단에 기업 로고가 있고 바로 본문, 그리고 하단에는 서비스 관련 링크나 수신 거부, SNS 링크 등이 있죠.\n\n아직 제 블로그의 뉴스레터 구독 기능은 없지만, 구독 신청을 해준 사람들에게 바로 감사 이메일을 작성하는 이메일을 작성한다고 생각하고 이메일을 만들어보도록 하겠습니다.\n\n```mjml [mjml]\n\u003Cmjml>\n  \u003Cmj-body>\n    \u003Cmj-section>\n      \u003Cmj-column>\n        \u003C!-- Header -->\n        \u003Cmj-image\n          width=\"150px\"\n          src=\"https://user-images.githubusercontent.com/20244536/148511699-e4d03a86-7b71-40c4-9cda-975e64687ff0.png\"\n        >\u003C/mj-image>\n        \u003Cmj-spacer height=\"20px\">\u003C/mj-spacer>\n\n        \u003C!-- Main -->\n        \u003Cmj-text font-size=\"16px\" line-height=\"1.5\">\n          안녕하세요 **님! 제 개인 블로그 peterkimzz.com 을 구독해주셔서\n          감사합니다.\n        \u003C/mj-text>\n\n        \u003Cmj-text font-size=\"16px\" line-height=\"1.5\">\n          최소 2주에 1번 정도는 도움이 될만한 글들을 꼭 포스팅 해보도록\n          하겠습니다. 구독해주셔서 정말 감사합니다!\n        \u003C/mj-text>\n\n        \u003Cmj-text font-size=\"16px\" line-height=\"1.5\"> 김동현 드림 \u003C/mj-text>\n\n        \u003C!-- Footer -->\n      \u003C/mj-column>\n    \u003C/mj-section>\n  \u003C/mj-body>\n\u003C/mjml>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/148514261-2496b382-82a4-427e-9cbc-1837e6fcef5d.png)\n\n먼저 헤더측과 본문 측을 나누고, 헤더엔 로고를 넣고 본문에 전달 사항을 작성했습니다. 저 `**님` 부분은 나중에 스크립트로 이름 대치해서 넣어주면 받는 사람이 좀 더 기분 좋겠네요.\n\n여기서 한가지 꿀팁은 로고 주소를 원격 저장소에 저장된 링크를 사용하는 게 좋습니다.\n\n이유는 이메일은 한 번 발송되면 내용을 수정할 수가 없기 때문입니다. 하지만 링크는 주소는 유지하면서 콘텐츠는 수정할 수 있죠. 이걸 활용하면 이미 보낸 수 많은 이메일의 기업 로고를 리뉴얼된 새 로고로 전부 바꾼다거나 하는 일이 가능해집니다. 또, [bit.ly](https://bit.ly/) 같은 링크 리디렉션 서비스를 이용해서 이미 보낸 이메일도 링크 주소를 다르게 변경할 수도 있겠습니다.\n\n이렇게만 해도\n\n```mjml [mjml]\n\u003C!-- Footer -->\n\u003Cmj-divider border-color=\"#E5E7EB\" border-width=\"1px\">\u003C/mj-divider>\n\n\u003Cmj-text\n  align=\"center\"\n  font-family=\"Pretendard, Arial\"\n  font-size=\"12px\"\n  line-height=\"1.75\"\n>\n  \u003Ca\n    href=\"https://www.peterkimzz.com\"\n    target=\"_blank\"\n    rel=\"noopener noreferrer nofollow\"\n    >웹사이트\u003C/a\n  >\n  \u003Cspan>·\u003C/span>\n  \u003Ca\n    href=\"https://www.instagram.com/peterkimzz\"\n    target=\"_blank\"\n    rel=\"noopener noreferrer nofollow\"\n    >인스타그램\u003C/a\n  >\n\u003C/mj-text>\n\n\u003Cmj-text\n  align=\"center\"\n  font-family=\"Pretendard, Arial\"\n  font-size=\"12px\"\n  line-height=\"1\"\n>\n  © Peter Kim. All Rights Reserved.\n\u003C/mj-text>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/148514316-3c2e04fd-0598-41f0-9499-d1649892f469.png)\n\n내용이 길어져서 Footer 쪽만 작성했습니다. 얇은 선으로 Footer 영역을 구분하고, 제 블로그 링크와 개인 인스타그램 주소를 넣었습니다. 회사 메일이라면 홈페이지, 이용약관, 개인정보처리방침 등을 넣으면 되겠죠.\n\n이렇게만 해도 그냥 텍스트만 보내는 것 보다 훨씬 낫죠? 생각보다 이메일 작성하는 거 어렵지 않습니다.\n\n## 스타일링 개선\n\n하지만 몇 가지 아쉬운 부분은 있습니다. 본문 첫 문단의 **감사합니다** 부분이 공간이 모자라서 중간에 글자가 잘렸습니다. 그리고 폰트도 좀 더 깔끔한 걸로 바꿔주면 좋을 것 같고, 채도도 조금 낮춰주면 더 읽기 편할 것 같습니다. 링크도 브라우저 기본 색 보다는 좀 더 예쁜 파란색으로 바꿔주겠습니다.\n\n1. 문장 잘리는 문제\n\ncss 속성 중 `word-break: keep-all` 속성이 있습니다. 이걸 사용하면 공간이 모자라도 반드시 단어가 다음 줄로 넘어가지 않게 해줍니다.\n\n이건 본문 전체에 적용되야 하니 전역으로 적용하면 좋겠네요.\n\n```mjml [mjml]\n\u003Cmjml>\n  \u003Cmj-head>\n    \u003Cmj-style> * { word-break: keep-all; } \u003C/mj-style>\n  \u003C/mj-head>\n\u003C/mjml>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/148515567-b06a90a5-a5de-49b8-be7d-00526ea80847.png)\n\n`\u003Cmjml>` 태그 바로 밑에 `\u003Cmj-head>` 태그를 만들고, 그 안에 `\u003Cmj-style>` 태그에 원래 작성하던 css를 작성하면 됩니다. 콘텐츠 전체에 `word-break` 속성을 적용시켜야 하니 `*` 선택자를 사용했습니다.\n\n\"감사합니다와\" \"구독해주셔서\" 부분이 개선됐습니다.\n\n2. 폰트\n\n폰트는 원하시는 거 사용하시면 됩니다. 저는 [`Pretendard`](https://github.com/orioncactus/pretendard) 라는 한/영 지원이 모두 가능한 폰트를 사용하겠습니다.\n\n```mjml [mjml]\n\u003Cmj-head>\n  \u003Cmj-font\n    name=\"Pretendard\"\n    href=\"https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css\"\n  />\n\n  \u003Cmj-attributes>\n    \u003Cmj-all font-family=\"Pretendard, Arial\" />\n  \u003C/mj-attributes>\n\n  \u003Cmj-style> * { word-break: keep-all; } \u003C/mj-style>\n\u003C/mj-head>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/148516068-c1f23242-4976-4f42-8315-f4174cb99116.png)\n![image](https://user-images.githubusercontent.com/20244536/148516244-5a32389c-e8c6-497d-8e2a-c2c658932e2d.png)\n\n`\u003Cmj-font>` 태그로 폰트를 불러주고, 여기서는 버그인지 모르겠는데 `\u003Cmj-style>` 안에 넣으면 스타일 적용이 안되서 mjml 컴포넌트 태그인 `\u003Cmj-attributes>`를 활용해서 폰트를 적용시켰습니다. 훨씬 깔끔합니다.\n\n3. 색깔\n\n본문 색이 `#000` 이라서 너무 까맣습니다. 약간 연하게 하면 훨씬 읽기 좋습니다. 전 `#404040` 를 사용하겠습니다.\n\n그리고 링크도 기본 색깔도 예쁜 파란색으로 바꿔주겠습니다.\n\n```mjml [mjml]\n\u003Cmj-attributes>\n  \u003Cmj-all font-family=\"Pretendard, Arial\" color=\"#404040\" />\n\u003C/mj-attributes>\n\n\u003Cmj-style>\n  * { word-break: keep-all; } a { color: #2563eb !important; }\n\u003C/mj-style>\n```\n\n\u003Cdiv class=\"grid grid-cols-2 gap-2\">\n \u003Cimg src=\"https://user-images.githubusercontent.com/20244536/148514316-3c2e04fd-0598-41f0-9499-d1649892f469.png\">\n \u003Cimg src=\"https://user-images.githubusercontent.com/20244536/148516791-fa711694-330f-4e17-90ac-d030aa3c2fbc.png\">\n\u003C/div>\n\n완성입니다. 처음과 비교해봐도 바뀐 게 훨씬 깔끔하죠? 아래 첨부한 전체 mjml 코드를 참고해서 원하는 내용으로 바꿔보세요.\n\n```mjml [mjml]\n\u003Cmjml>\n  \u003Cmj-head>\n    \u003Cmj-font\n      name=\"Pretendard\"\n      href=\"https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css\"\n    />\n\n    \u003Cmj-attributes>\n      \u003Cmj-all font-family=\"Pretendard, Arial\" color=\"#404040\" />\n    \u003C/mj-attributes>\n\n    \u003Cmj-style>\n      * { word-break: keep-all; } a { color: #2563eb !important; }\n    \u003C/mj-style>\n  \u003C/mj-head>\n  \u003Cmj-body>\n    \u003Cmj-section>\n      \u003Cmj-column>\n        \u003C!-- Header -->\n        \u003Cmj-image\n          width=\"150px\"\n          src=\"https://user-images.githubusercontent.com/20244536/148511699-e4d03a86-7b71-40c4-9cda-975e64687ff0.png\"\n        >\u003C/mj-image>\n        \u003Cmj-spacer height=\"20px\">\u003C/mj-spacer>\n\n        \u003C!-- Main -->\n        \u003Cmj-text font-size=\"16px\" line-height=\"1.5\">\n          안녕하세요 **님! 제 개인 블로그 peterkimzz.com 을 구독해주셔서\n          감사합니다.\n        \u003C/mj-text>\n\n        \u003Cmj-text font-size=\"16px\" line-height=\"1.5\">\n          최소 2주에 1번 정도는 도움이 될만한 글들을 꼭 포스팅 해보도록\n          하겠습니다. 구독해주셔서 정말 감사합니다!\n        \u003C/mj-text>\n\n        \u003Cmj-text font-size=\"16px\" line-height=\"1.5\"> 김동현 드림 \u003C/mj-text>\n\n        \u003C!-- Footer -->\n        \u003Cmj-divider border-color=\"#E5E7EB\" border-width=\"1px\">\u003C/mj-divider>\n\n        \u003Cmj-text align=\"center\" font-size=\"12px\" line-height=\"1.75\">\n          \u003Ca\n            href=\"https://www.peterkimzz.com\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer nofollow\"\n            >웹사이트\u003C/a\n          >\n          \u003Cspan>·\u003C/span>\n          \u003Ca\n            href=\"https://www.instagram.com/peterkimzz\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer nofollow\"\n            >인스타그램\u003C/a\n          >\n        \u003C/mj-text>\n\n        \u003Cmj-text\n          align=\"center\"\n          font-family=\"Pretendard, Arial\"\n          font-size=\"12px\"\n          line-height=\"1\"\n        >\n          © Peter Kim. All Rights Reserved.\n        \u003C/mj-text>\n      \u003C/mj-column>\n    \u003C/mj-section>\n  \u003C/mj-body>\n\u003C/mjml>\n```\n\n## Nodemailer로 메일 보내기\n\n저번에 작성했던 [평생 무료로 커스텀 이메일 사용하기](/custom-email-service-for-free-forever) 를 보셨다면 엄청 쉽습니다.\n\n일단 mjml을 서버측에서 바로 HTML로 바꿔주는 `mjml` 패키지를 설치해주세요.\n\n```bash [bash]\n$ yarn add mjml\n$ yarn add -D @types/mjml # typescript인 경우\n```\n\n사실 이러면 끝입니다.\n\n```ts [typescript]\nimport mjml2html from \"mjml\";\n\nconst { html } = mjml2html(`아까 작성했던 mjml코드`);\n```\n\n이러면 `html` 변수 안에 반응형으로 변경된 순수 HTML이 담기게 됩니다. 엄청 좋죠? 중간에 대치할 내용이 있다면 함수로 빼서 템플릿 리터럴을 사용하면 되겠습니다.\n\n`Nodemailer`를 활용하면 아래처럼 작성 가능합니다.\n\n```ts [typescript]\nimport nodemailer from \"nodemailer\";\nimport mjml2html from \"mjml\";\n\nconst { html } = mjml2html(`아까 작성했던 mjml코드`);\n\nconst transporter = nodemailer.createTransport({\n  /** */\n});\n\ntransporter.sendMail({\n  sender: \"petekimzz.com\",\n  from: \"peterkimzz69@gmail.com\",\n  to: \"받는사람 이메일\",\n  html: html,\n});\n```\n\n자 완성입니다! 이러면 우리가 만든 반응형 이메일이 받는 사람에게 잘 전달됩니다.\n\n## 마무리\n\n반응형 이메일을 만드는 게 생각보다 어렵지 않았죠?\n\n근데 사실 시간과 돈이 없는 1인 기업, 프리랜서 개발자나 소규모 기업에선 반응형 이메일 만드는 게 사치인 것 같기도 합니다. 텍스트로만 보내도 전달하고 싶은 말은 충분히 전달할 수 있거든요.\n\nAWS 같은 큰 서비스도 어떤 메일은 아직도 그냥 텍스트로만 보내기도 합니다. 그러니까 이메일을 예쁘게 만드는 것에 대해 너무 부담갖지 마시길 바랍니다!\n\n### 참고\n\n- [mjml Github](https://github.com/mjmlio/mjml)\n",{"path":1125,"title":1126,"description":1127,"created":1128,"category":1069,"rawbody":1129},"/custom-email-service-for-free-forever","평생 무료로 커스텀 이메일 사용하기","안녕하세요. 또 다시 찾아온 평생 무료 시리즈입니다. 저는 틈만나면 1인 사이드 프로젝트를 진행하기 때문에, 어떻게든 공짜로 서버를 돌리기 위해 온갖 노력을 하고 있습니다. 그래서 무료로 이용하는 방법에 관한 글을 몇 개 올렸는데 GA를 살펴보니 다른 주제보다 조회수가 높더군요. 역시 공짜가 좋네요.","2021-09-30","---\ncategory: tech\ntitle: 평생 무료로 커스텀 이메일 사용하기\nupdated: 2021-09-30\ncreated: 2021-09-30\nimage: https://user-images.githubusercontent.com/20244536/135378482-9ac193f6-a6dd-42c1-91be-ebc83c8953dc.png\npublished: true\n---\n\n안녕하세요. 또 다시 찾아온 **평생 무료** 시리즈입니다. 저는 틈만나면 1인 사이드 프로젝트를 진행하기 때문에, 어떻게든 공짜로 서버를 돌리기 위해 온갖 노력을 하고 있습니다. 그래서 무료로 이용하는 방법에 관한 글을 몇 개 올렸는데 GA를 살펴보니 다른 주제보다 조회수가 높더군요. 역시 공짜가 좋네요.\n\n\u003C!--more-->\n\n그런 관계로 제가 알고 있는 무료로 서비스를 운영하는 방법들을 시간이 날 때마다 종종 올려보도록 하겠습니다.\nㄱ\n:Serieis{:type=\"forever\"}\n\n## 이메일 서비스\n\n이번엔 이메일입니다. 온라인 서비스 운영자에겐 필수라고 할 수 있겠습니다.\n\n이유는 서비스 관련 각종 알림, 광고 발송과 계정 인증까지 모두 저비용으로 커버가 가능한 고마운 전달 매체이기 때문이죠.\n\n그런데 이 이메일 서비스는 제대로 구현해서 안정적으로 서비스하기는 생각보다 꽤 까다롭습니다. 커스텀 이메일을 보내기 위해선 도메인 관련 지식도 필요하고, 자동화 및 개인화 하려면 서버도 돌려야 하고, 요샌 모바일로도 메일을 많이 읽어서 반응형으로 템플릿 만드는 등 이메일 하나에 정말 많은 노력이 들어갑니다. 운영하는 서비스에 따라서 전담 인력이 필요할 수도 있습니다.\n\n그래서 [스티비](https://stibee.com), [Mailchimp](https://mailchimp.com) 같은 이메일 솔루션을 사용하는 게 맘 편하기는 합니다.\n\n하지만 문제는 **가격**입니다. 스티비의 경우 무제한으로 이메일을 보내기 위해선 최소 월 29,000원을 지불해야합니다. 솔직히 사용 만족도 대비 절대 아깝지 않습니다만, 사이드 프로젝트인데 이메일 서비스에만 월 3만원 가량을 지불한다는 건 무일푼으로 프로젝트하는 우리에게 있을 수 없는 일입니다.\n\n그리고 사실 스티비는 자체 서버에서 100% 커스텀해서 통합하긴 어렵습니다. 이메일 구독시키기, 그룹화하기나 웹훅 API 정도만 지원하기 때문입니다.\n\n## 다음 스마트워크\n\n그래서 평생 무료로, 100% 원하는대로 커스텀 이메일을 서비스하는 방법을 준비했습니다. 바로 **다음 스마트워크**를 이용하는 방법입니다.\n\n지금은 카카오랑 통합했지만, 다음도 네이버와 마찬가지로 오래전부터 메일 서비스를 제공 중입니다. 다만 이 방법은 다음 계정 1개당 커스텀 도메인 1개만 가능합니다.\n\n그리고 개인 계정이랑 커스텀 도메인이 완벽히 분리되는 방법은 아닙니다. 개인 계정을 커스텀 도메인처럼 사용하는 방식이기 때문입니다. 뭐 크게 신경쓸 부분은 아닙니다.\n\n그리고 가끔 이메일 발송이 늦는 경우가 있습니다. 그래도 보통 1분 이내로 발송됩니다.\n\n카카오도 최근 메일 서비스를 베타로 출시했는데, UI가 기존 다음 이랑 거의 비슷합니다. 다만 스마트워크처럼 커스텀 도메인을 만드는 기능은 없습니다. 정식 버전에선 지원해주길 기도하겠습니다.\n\n![image](https://user-images.githubusercontent.com/20244536/135370985-2cee0aff-da43-4008-86e3-fcd34826d953.png)\n_카카오 메일 BETA. 스마트워크 유지해주세요. 제발_\n\n## 시작하기\n\n먼저 [다음](https://www.daum.net) 사이트에 접속해서 로그인 후, 메일로 이동합니다. 기존 다음 계정이나, 통합된 카카오 계정이라도 상관 없습니다. 저는 기존 다음 계정과 카카오 계정을 통합시킨 계정으로 사용 중입니다.\n\n![image](https://user-images.githubusercontent.com/20244536/135372107-526bb7ca-6880-47d3-9f20-a947bde73b72.png)\n\n그리고 왼쪽 사이드 메뉴 하단에 **Daum 스마트워크**로 이동해주세요.\n\n![image](https://user-images.githubusercontent.com/20244536/135372169-c0afba98-5421-4799-b752-127317f4e255.png)\n\n이미 등록한 개인 도메인이 없다면, 도메인을 입력하는 공간이 있을 겁니다. 저는 지금은 취소를 할 수가 없어서 사진을 첨부하지 못했는데, 그냥 사용할 도메인 주소를 넣어주기만 하면 됩니다.\n\n그리고 커스텀 도메인을 사용하기 위해 도메인을 구입한 호스팅 업체에 가서 `MX` 레코드를 추가해주셔야 합니다. 저는 [Godaddy](https://godaddy.com)를 사용 중입니다. 다른 업체를 사용하고 계셔도 작동 원리는 모두 같습니다.\n\nDNS 설정하는 곳으로 가셔서 `MX` 레코드 2개 추가해주세요.\n\n1. aspmx.daum.net (우선순위: 10)\n2. alt.aspmx.daum.net (우선순위: 20)\n\n![image](https://user-images.githubusercontent.com/20244536/135372996-bb7f2384-3b9f-4937-a53f-ced8e3271cfb.png)\n\n친절하게 설정 방법까지 메일로 보내주네요.\n\n이러면 커스텀 도메인을 사용하기 위한 준비가 끝났습니다. 쉽죠?\n\n![image](https://user-images.githubusercontent.com/20244536/135373246-cc81e429-90bc-4f11-b6f4-dbfaafa2c7b4.png)\n_다음에서 정상적으로 도메인 `MX` 레코드를 확인한 상태_\n\n## 서버에 통합하기\n\n여기까지 설정을 마치셨다면, 지금부터 개인 도메인 주소로 메일을 발송할 수 있습니다. 서버 통합 필요없이 그냥 웹 메일만 이용하실 분들은 지금 상태로도 이용하는데 문제 없습니다.\n\n하지만 자체 서버에서 메일을 보내길 원하시는 분들은 아래 예제를 참고해주시면 되겠습니다.\n\n저는 `Node.js`를 사용했고, 메일 발송을 위해 [`Nodemailer`](https://nodemailer.com/) 라이브러리를 사용했습니다.\n\n```bash [bash]\n# yarn\n$ yarn add nodemailer\n$ yarn add -D @types/nodemailer # typescript 사용하는 경우\n\n# npm\n$ npm i nodemailer\n$ npm i -D @types/nodemailer # typescript 사용하는 경우\n```\n\n패키지 설치 후, 코드 재사용성을 위해 아래와 같이 모듈화 해줍니다.\n\n```ts [typescript]\nimport nodemailer from \"nodemailer\";\n\nexport type mailOptions = {\n  to: string | string[];\n  subject: string;\n  html: string;\n};\n\nexport class Nodemailer {\n  private transporter;\n\n  constructor() {\n    this.transporter = nodemailer.createTransport({\n      host: \"smtp.daum.net\",\n      port: 465,\n      secure: true,\n      auth: {\n        user: \"다음 계정 아이디\",\n        pass: \"다음 계정 비밀번호\",\n      },\n    });\n  }\n\n  public sendMail(options: mailOptions) {\n    return this.transporter.sendMail({\n      priority: \"normal\",\n      sender: \"브리아나랩스\",\n      from: \"브리아나랩스 \u003Ccontact@brianalabs.com>\",\n      to: \"contact@brianalabs.com\",\n      bcc: options.to,\n      subject: options.subject,\n      html: options.html,\n    });\n  }\n}\n```\n\n`nodemailer`의 인터페이스는 매우 간단합니다. 이메일 서비스 업체의 정보를 담은 `transport` 객체를 만들고, 이 객체에 내장된 `sendMail` 함수를 통해 메일을 발송하면 끝입니다.\n\n여기서 한 가지 짚어드릴 부분이 있다면, `sendMail` 함수의 `to` 옵션을 수정하지 못하도록 작성했습니다. 이유는 같은 메일을 여러 명에게 발송할 때 `to` 객체에 다 넣게 되면 메일을 받는 사람에게 다른 사람들의 메일까지 전부 노출되게 됩니다.\n\n수신자 입장에선 내 메일 주소가 다른 사람에게 노출됐기 때문에 상당히 불쾌할 수 있습니다.\n\n그런 불상사를 막기 위해 받는 사람을 메일 발송자의 주소와 동일하게 해두고, 숨은 참조로만 메일을 보내도록 설계해주세요. 이건 이메일 예절이기도 합니다.\n\n숨은 참조로 보내면 메일을 여러 명에게 발송해도 개인에게만 발송된 것 처럼 보입니다. 그리고 관리자 계정으로 메일을 보내기 때문에, 우리 메일이 잘 발송됐는지 확인도 가능합니다.\n\n사용 예제는 이렇습니다.\n\n```ts [typescript]\nconst mailer = new Nodemailer();\nconst info = await mailer.sendMail({\n  to: \"tmna1234@naver.com\",\n  subject: \"이메일 인증 코드를 보내드립니다.\",\n  html: \"인증번호는 [000000] 입니다.\",\n});\n```\n\n![image](https://user-images.githubusercontent.com/20244536/135374752-bcb31d0b-11db-4fd5-b561-4b4ba6ede6f6.png)\n\n![image](https://user-images.githubusercontent.com/20244536/135374879-86121a4a-47ab-4e33-9f99-0df87efa1b1e.png)\n\n위 사진은 메일함에서 받았을 때 모습이고, 아래는 아이폰 기본 메일 앱에서 받았을 때 보이는 모습입니다. 우리가 흔히 받는 메일의 포맷이죠? 이제 옵션을 조절해서 원하는 포맷으로 메일을 마음껏 발송하세요.\n\n## 마무리\n\n다음 스마트워크는 도메인 당 계정 `500`개까지 등록 가능하고, 계정 당 `20GB`의 용량을 무료로 제공합니다. 이 혜택은 정말 미친겁니다. 웬만한 규모에선 사실상 이메일 관련해서 지출 없이 서비스를 운영할 수 있습니다.\n\n여기서 추가로 이메일 UI/UX 개선을 위해 반응형 이메일 템플릿까지 제공하면 완벽하겠죠. 다만 분량 관계로 무료로 반응형 이메일 템플릿을 작성 및 유지보수하는 방법은 다음 포스팅에서 다루도록 하겠습니다.\n\n잘 안되거나, 모르는 부분은 아래 댓글로 남겨주세요.\n\n### 참고\n\n- [Daum 스마트 워크 도움말](https://cs.daum.net/faq/43/13114.html)\n- [nodemailer.com](https://nodemailer.com/)\n",{"path":1131,"title":1132,"description":1133,"created":1134,"category":1069,"rawbody":1135},"/clipboard-to-url","클립보드 이미지를 1초만에 링크로 만드는 툴 개발하기","저는 이 블로그를 운영하면서 가장 귀찮은 일이 하나 있습니다. 바로 이미지 주소를 만드는 일인데요, 저는 @nuxt/content 모듈을 이용해 마크다운 포맷을 이용하는 정적 블로그를 운영 중이라 글 작성 중에 원격 이미지 주소를 삽입하는 기능을 사용하지 않습니다.","2021-09-14","---\ncategory: tech\ntitle: 클립보드 이미지를 1초만에 링크로 만드는 툴 개발하기\nupdated: 2021-09-14\ncreated: 2021-09-14\nimage: https://user-images.githubusercontent.com/20244536/133209740-1982eec8-1c1a-4dc9-8c37-ee5970d6ee0a.png\npublished: true\n---\n\n저는 이 블로그를 운영하면서 가장 귀찮은 일이 하나 있습니다. 바로 **이미지 주소**를 만드는 일인데요, 저는 `@nuxt/content` 모듈을 이용해 마크다운 포맷을 이용하는 정적 블로그를 운영 중이라 글 작성 중에 원격 이미지 주소를 삽입하는 기능을 사용하지 않습니다.\n\n\u003C!--more-->\n\n그래서 이미지를 삽입할 땐 클립보드에 저장된 이미지나 가지고 있는 이미지를 제 `GitHub Issue` 아무거나 골라 댓글에 붙여넣기해서 만들어진 URL을 사용하고 있습니다.\n\n![clipboard_to_link](https://user-images.githubusercontent.com/20244536/132941829-34dac179-1ce1-44cf-a683-dd2bca87c4e1.gif)\n_이런 식으로 블로그에 삽입할 이미지 주소를 무료로 만들어서 사용 중입니다_\n\n이렇게 하는 이유는 이 블로그 리파지토리에 이미지 리소스를 저장하기 싫고, 외부 저장소를 사용하는 비용을 지불하고 싶지 않기 때문입니다. (참고로 깃허브 저장소는 용량이 무제한이 아니다)\n\n## 좀 더 편하게 링크를 만들어보자\n\n사실 지금도 그렇게 불편하지는 않습니다. 그저 깃허브에 로그인하고 이슈 페이지까지 가는게 너무 귀찮을 뿐이죠..\n\n근데 **클립보드로 영역을 캡쳐하고, 필요할 때 원격 이미지 주소가 생기면** 너무 좋지 않을까? 라는 생각이 들었습니다. 당연히 확장 프로그램이 좀 더 편한 UX가 될 것 같습니다.\n\n클립보드 캡쳐해두고 확장 프로그램 실행하면 바로 원격 저장소에 저장한 뒤, URL이 복사되게끔 하는거죠.\n\n수익화나 PMF 이런 건 일단 제쳐두고, 일단 만들어보도록 하겠습니다.\n\n## 프로젝트 셋업\n\n저는 `Vue.js` 를 좋아하기 때문에 `Vite`과 `Vue`를 이용해 프로젝트를 만들도록 하겠습니다.\n\n```bash [bash]\n$ yarn create vite clipboard-to-url --template vue\n```\n\n프로젝트 이름은 `Clipboard to URL` 입니다. 클립보드에 저장된 이미지를 URL로 만들기라는 뜻입니다.\n\n그리고 이미지를 저장하기 위한 저장소로 [`supabase.io`](https://supabase.io/) 를 사용하도록 하겠습니다.\n\n이 툴에 대해선 [Firebase를 대체할 오픈소스 프로젝트, Supabase](/supasbase-overview) 포스팅에서 다루었던 적이 있으니 궁금하신 분들은 참고해주세요.\n\n```bash [bash]\n$ yarn add @supabase/supabase-js\n```\n\n`supabase` 의 공식 라이브러리도 설치해줍시다.\n\n## UI 만들기\n\n일단 기존 컴포넌트는 전부 지워주시구요, 전 [`tailwindcss`](https://tailwindcss.com) 를 이용해 디자인하는 걸 좋아하기 때문에 이것도 설치하도록 하겠습니다.\n\n```bash [bash]\n$ yarn add -D tailwindcss@latest postcss@latest autoprefixer@latest\n\n$ npx tailwindcss init -p\n```\n\n생성된 `tailwind.config.js` 의 파일을 수정해주세요.\n\n```diff [tailwind.config.js]\nmodule.exports = {\n- purge: [],\n+ purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],\n  darkMode: false,\n  theme: {\n    extend: {},\n  },\n  variants: {\n    extend: {},\n  },\n  plugins: [],\n}\n```\n\n마지막으로 `src` 폴더에 `index.css` 파일을 만들어 `tailwindcss` 를 불러온 뒤, `Vue`의 진입점에 이 `css` 파일을 읽도록 하면 되겠습니다.\n\n```css [index.css]\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  html {\n    @apply text-gray-700;\n    @apply bg-white;\n    @apply leading-6;\n    @apply antialiased;\n  }\n}\n```\n\n```js [main.js]\nimport { createApp } from \"vue\";\nimport App from \"./App.vue\";\nimport \"./index.css\";\n\ncreateApp(App).mount(\"#app\");\n```\n\n`tailwindcss` 설정은 이걸로 끝입니다.\n\n먼저 상단 네비게이션 바 부터 만들어보도록 하겠습니다.\n\n`src` 폴더에 `NavigationBar` 폴더를 만들고, 그 안에 `index.vue` 파일을 만들어주세요.\n\n```zsh\nclipboard-to-url\n|- src/\n|-- components/\n|--- NavigationBar/\n|---- index.vue\n```\n\n여기서 `NavigationBar.vue` 로 만들지 않는 이유는, 혹시나 나중에 네비게이션 바에서 파생되는 컴포넌트를 또 만들어야 할 가능성이 있기 때문입니다.\n\n크게 상관은 없지만 나중에 불필요하게 리팩토링하는 데 시간쓰지 않도록 하기 위함입니다.\n\n```vue [NavigationBar/index.vue]\n\u003Ctemplate>\n  \u003Cnav class=\"border-b border-gray-200 shadow-sm\">\n    \u003Cdiv class=\"mx-auto max-w-4xl px-4\">\n      \u003Cdiv class=\"flex items-center justify-between\">\n        \u003Ch1 class=\"text-2xl font-bold text-black\">\n          \u003Ca href=\"/\" class=\"block py-3\">Clipboard to URL\u003C/a>\n        \u003C/h1>\n      \u003C/div>\n    \u003C/div>\n  \u003C/nav>\n\u003C/template>\n```\n\n네비게이션 바 컴포넌트를 만들었습니다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003CNavigationBar />\n  \u003C/div>\n\u003C/template>\n\n\u003Cscript setup>\nimport NavigationBar from \"~/components/NavigationBar.vue\";\n\u003C/script>\n```\n\n여기서 `import` 하는 파일의 경로를 보면 `~` 표시가 있습니다. 이는 현재 파일의 위치에 상관없이 파일을 불러오고 싶을 때 사용하는 `alias` 라는 개념입니다. `vite.config.js` 파일로 가서 설정해주도록 합니다.\n\n```js [vite.config.js]\nimport path from \"path\";\nimport vue from \"@vitejs/plugin-vue\";\nimport { defineConfig } from \"vite\";\n\nexport default defineConfig({\n  resolve: {\n    alias: {\n      \"~\": path.resolve(__dirname, \"src\"),\n    },\n  },\n  plugins: [vue()],\n});\n```\n\n![image](https://user-images.githubusercontent.com/20244536/133067193-c315f86d-5f43-4d46-bea7-013611e30b09.png)\n\n상단 바는 만들었으니, 클립보드에 저장된 이미지를 붙여넣기 하라는 인터페이스가 있으면 좋겠네요.\n\n`components` 폴더 아래 `Image` 폴더를 만들고, `UploadZone.vue` 파일을 만들어주세요.\n\n```vue [UploadZone.vue]\n\u003Ctemplate>\n  \u003Cdiv\n    class=\"\n      flex\n      justify-center\n      rounded-md\n      border\n      border-dashed\n      border-gray-400 bg-white px-6\n      pt-5\n      pb-6\n      transition-colors\n      hover:border-gray-600\n    \"\n  >\n    \u003Cdiv class=\"space-y-1 text-center\">\n      \u003Csvg\n        class=\"mx-auto h-12 w-12 text-gray-400\"\n        stroke=\"currentColor\"\n        fill=\"none\"\n        viewBox=\"0 0 48 48\"\n        aria-hidden=\"true\"\n      >\n        \u003Cpath\n          d=\"M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n        />\n      \u003C/svg>\n      \u003Cdiv class=\"flex text-sm text-gray-600\">\n        \u003Cspan class=\"font-semibold text-indigo-600\"\n          >Paste a Clipboard Image\u003C/span\n        >\n      \u003C/div>\n    \u003C/div>\n  \u003C/div>\n\u003C/template>\n```\n\n`App.vue` 파일에 방금 만든 컴포넌트를 불러와줍니다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003CNavigationBar />\n\n    \u003CImageUploadZone />\n  \u003C/div>\n\u003C/template>\n\n\u003Cscript setup>\nimport NavigationBar from \"~/components/NavigationBar/index.vue\";\nimport ImageUploadZone from \"~/components/Image/UploadZone.vue\";\n\u003C/script>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/133197396-ffad0a54-7ed1-41ce-8c9f-7a879c9eb684.png)\n\n인터페이스는 그럭저럭 괜찮아 보이지만, 한가지 문제는 좌우 넓이 제한이 없어서 화면에 꽉차보이는 게 영 마음에 들지 않네요.\n\n모든 화면에서 좌우 넓이를 균일하게 맞추기 위해 `Container` 라는 컴포넌트들 만들도록 하겠습니다.\n\n```vue [components/Container/index.vue]\n\u003Ctemplate>\n  \u003Cdiv class=\"mx-auto max-w-4xl px-4\">\n    \u003Cslot />\n  \u003C/div>\n\u003C/template>\n```\n\n사실 이 스타일은 `NavigationBar` 에도 이미 적용이 되어 있었습니다. 같이 수정해주도록 합시다.\n\n```diff [NavigationBar/index.vue]\n \u003Ctemplate>\n  \u003Cnav class=\"border-b border-gray-200 shadow-sm\">\n-    \u003Cdiv class=\"mx-auto max-w-4xl px-4\">\n+    \u003CContainer>\n      \u003Cdiv class=\"flex items-center justify-between\">\n        \u003Ch1 class=\"font-bold text-2xl text-black\">\n          \u003Ca href=\"/\" class=\"block py-3\">Clipboard to URL\u003C/a>\n        \u003C/h1>\n      \u003C/div>\n+    \u003C/Container>\n-    \u003C/div>\n  \u003C/nav>\n \u003C/template>\n\n+ \u003Cscript setup>\n+ import Container from \"~/components/Container/index.vue\";\n+ \u003C/script>\n```\n\n```diff [App.vue]\n \u003Ctemplate>\n  \u003Cdiv>\n    \u003CNavigationBar />\n\n+   \u003CContainer>\n+     \u003CImageUploadZone class=\"mt-6\" />\n+   \u003C/Container>\n  \u003C/div>\n \u003C/template>\n\n \u003Cscript setup>\n   import NavigationBar from '~/components/NavigationBar/index.vue'\n+  import Container from '~/components/Container/index.vue'\n   import ImageUploadZone from '~/components/Image/UploadZone.vue'\n \u003C/script>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/133198202-01e35f86-a636-439a-ac80-0d862a516013.png)\n\n전보다 조금 더 낫네요.\n\n## 클립보드 저장 후 붙여넣기\n\n핵심 로직이 될 자바스크립트는 이렇습니다.\n\n자바스크립트 이벤트인 `onpaste` 를 이용해서 붙여넣기를 감지하고, `Blob` 형태의 파일을 `supabase`의 저장소로 바로 업로드할겁니다.\n\n저는 클립보드에 저장된 이미지를 혹시나 여러번 저장하더라도 모두 다른 URL이 나오게 하기 위해, 타임스탬프를 기반으로 고유한 문자열을 생성해주는 `uuid` 를 사용하도록 하겠습니다.\n\n```bash [bash]\n$ yarn add uuid\n```\n\n`uuid` 패키지를 설치해주고, `supabase` 에 업로드하는 로직까지 작성해보도록 하겠습니다.\n\n먼저 `src` 폴더 아래 `utils` 폴더를 만들고, `supabase.js` 파일을 만들어줍니다.\n\n```js [utils/supabase.js]\nimport { createClient } from \"@supabase/supabase-js\";\n\nconst supabaseUrl = import.meta.env.VITE_SUPABASE_URL;\nconst supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;\n\nexport const supabase = createClient(supabaseUrl, supabaseAnonKey);\n```\n\n그리고 프로젝트 루트 폴더에 `.env` 파일도 만들고 `supabase` 의 API 주소와, `ANON_KEY` 를 환경 변수로 저장합니다. 여기에는 자기 프로젝트에 해당하는 값을 넣어주면 됩니다.\n\n```[.env]\nVITE_SUPABASE_URL=https://yourprojectid.supabase.co\nVITE_SUPABASE_ANON_KEY=ey...\n```\n\n여기까지 작성했다면 `supabase` 를 클라이언트 측에서 사용할 준비가 됐으니, 클립보드 이미지를 원격 저장소에 저장하는 로직을 작성해보도록 합시다.\n\n```vue [App.vue]\n\u003Cscript setup>\nimport { v4 as uuidv4 } from \"uuid\";\nimport { supabase } from \"./utils/supabase\";\n\ndocument.onpaste = async (event) => {\n  try {\n    const items = event.clipboardData.items;\n    const blob = items?.[0]?.getAsFile();\n\n    if (!blob) {\n      return;\n    }\n\n    const key = uuidv4();\n    const bucket = \"images\"; // supabase 에 미리 만들어둔 public 버킷 이름\n\n    await supabase.storage.from(bucket).upload(key, blob, {\n      cacheControl: \"3600\",\n    });\n\n    const { publicURL } = await supabase.storage.from(bucket).getPublicUrl(key);\n    console.log(publicURL);\n  } catch (err) {\n    console.log(err);\n  }\n};\n\u003C/script>\n```\n\n![ani](https://user-images.githubusercontent.com/20244536/133199963-e79ea6f8-93a6-4b29-8595-2c468f32e489.gif)\n\ngif가 잘 안보이긴 하지만, 화면 캡쳐 후 붙여넣기하면 정상적으로 저장소에 저장된 URL을 받았고 접속해보니 사진도 캡쳐한 영역만큼 잘 저장됐다는 걸 확인할 수 있습니다.\n\n여기서 링크를 누르지 않더라도 잘 저장이 됐다는 걸 바로 인지하기 위해 조금만 더 인터페이스를 개선해봅시다.\n\n```diff [App.vue]\n \u003Ctemplate>\n  \u003Cdiv>\n    \u003CNavigationBar />\n\n    \u003CContainer>\n+      \u003Cdiv v-if=\"url\" class=\"mt-6\">\n+        \u003Cp class=\"font-semibold text-sm uppercase text-gray-400\">\n+          Image preview\n+        \u003C/p>\n+        \u003Cimg\n+          :src=\"url\"\n+          class=\"mt-2 w-full shadow border border-gray-200 rounded-lg\"\n+        />\n+        \u003Ca\n+          :href=\"url\"\n+          target=\"_blank\"\n+          class=\"block mt-2 text-gray-700 font-semibold\"\n+        >\n+          {{ url }}\n+        \u003C/a>\n+      \u003C/div>\n\n      \u003CImageUploadZone class=\"mt-6\" />\n    \u003C/Container>\n  \u003C/div>\n \u003C/template>\n\n \u003Cscript setup>\n  import { supabase } from './utils/supabase'\n+ import { ref } from '@vue/reactivity'\n  import { v4 as uuidv4 } from 'uuid'\n\n  import Container from '~/components/Container/index.vue'\n  import NavigationBar from '~/components/NavigationBar/index.vue'\n  import ImageUploadZone from '~/components/Image/UploadZone.vue'\n+ const url = ref(null)\n\n  document.onpaste = async (event) => {\n    try {\n      const items = event.clipboardData.items\n      const blob = items?.[0]?.getAsFile()\n\n      if (!blob) {\n        return\n      }\n\n      const key = uuidv4()\n      const bucket = 'images'\n\n      await supabase.storage.from(bucket).upload(key, blob, {\n        cacheControl: '3600',\n      })\n\n      const { publicURL } = await supabase.storage.from(bucket).getPublicUrl(key)\n+     url.value = publicURL\n    } catch (err) {\n      console.log(err)\n    }\n  }\n \u003C/script>\n```\n\n![2222](https://user-images.githubusercontent.com/20244536/133201523-ce63197b-28b0-43fb-9834-eceba9a63997.gif)\n\n이제 붙여넣기를 하자마자 저장소에 잘 저장됐다는 걸 확인할 수 있습니다.\n\n## 확장 프로그램\n\n잘 작동하는 걸 확인했으니, 더 편리하기 사용하기 위해 확장 프로그램으로 만들어볼겁니다.\n\n```bash [bash]\n$ yarn add vite-plugin-chrome-extension\n```\n\n크롬 확장 프로그램은 `manifest.json` 파일만 있으면 작동합니다. 관련해서 개발을 좀 더 수월하게 도와주는 [`vite-plugin-chrome-extension`](https://www.npmjs.com/package/vite-plugin-chrome-extension) 패키지를 설치합시다.\n\n```diff [vite.config.js]\n  import path from 'path'\n  import vue from '@vitejs/plugin-vue'\n  import { defineConfig } from 'vite'\n+ import { chromeExtension } from 'vite-plugin-chrome-extension'\n\n  export default defineConfig({\n    resolve: {\n      alias: {\n        '~': path.resolve(__dirname, 'src'),\n      },\n    },\n+  build: {\n+    rollupOptions: {\n+      input: 'src/manifest.json',\n+    },\n+  },\n    plugins: [vue(), chromeExtension()],\n  })\n```\n\n확장 프로그램으로 만들기 위해 약간의 파일 정리가 필요합니다.\n\n`manifest.json` 파일을 `src` 폴더 아래에 만들어주고, 루트 폴더에 있던 `index.html` 파일을 `src` 폴더 아래로 옮겨주세요. 변경된 폴더 구조는 아래와 같아야 합니다.\n\n```zsh\nclipboard-to-url/\n|- src/\n|-- components/\n|-- utils/\n|-- App.vue\n|-- index.css\n|-- index.html\n|-- main.js\n|-- manifest.json\n```\n\n마지막으로 `package.json` 파일의 `scripts` 부분도 수정해줍시다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"dev\": \"vite build --watch --mode=development\",\n    \"build\": \"vite build\",\n    \"serve\": \"vite preview\"\n  }\n}\n```\n\n이 상태로 `yarn dev` 를 이용해 `dist` 폴더를 만들면 이 폴더가 확장 프로그램이 되는 겁니다.\n\n근데 하.. 작성하면서 보니까 현재 `vite-plugin-chrome-extension` 플러그인이 프로젝트 빌드시에 `tailwindcss` 를 인식하지 못하는 이슈가 있습니다.\n\n그래서 개발자 모드 켜서 확장 프로그램을 로드해보면 스타일링 적용되지 않는 현상이 있습니다. 이 허탈함..\n\n다시 스타일링을 적용하기엔 먼 길을 와버렸으니 그냥 진행하도록 하겠습니다.\n\n```diff [App.vue]\n  \u003Ctemplate>\n-   \u003Cdiv>\n+   \u003Cdiv style=\"width: 15rem; height: 15rem\">\n      \u003CNavigationBar />\n\n      \u003CContainer>\n        \u003Cdiv v-if=\"url\" class=\"mt-6\">\n          \u003Cp class=\"font-semibold text-sm uppercase text-gray-400\">\n            Image preview\n          \u003C/p>\n          \u003Cimg\n            :src=\"url\"\n            class=\"mt-2 w-full shadow border border-gray-200 rounded-lg\"\n+           style=\"width: 100%\"\n          />\n          \u003Ca\n            :href=\"url\"\n            target=\"_blank\"\n            class=\"block mt-2 text-gray-700 font-semibold\"\n          >\n            {{ url }}\n          \u003C/a>\n        \u003C/div>\n\n        \u003CImageUploadZone class=\"mt-6\" />\n      \u003C/Container>\n    \u003C/div>\n  \u003C/template>\n```\n\n일단 확장 프로그램 최소 사이즈를 맞추기 위해 최상단 `div` 태그에 높낮이를 적용합니다.\n\n![3333](https://user-images.githubusercontent.com/20244536/133208249-570abac2-ef8a-4ea3-aed6-23dd0f2f98d5.gif)\n\n## 마무리\n\n짝짝짝! 여기까지 하셨으면 알파 버전 정도의 툴을 완성한겁니다. `css` 적용 안되는 문제만 없다면 참 좋았을텐데요..\n\n추가로 이건 선택 사항인데, 붙여넣는 것도 귀찮다면 확장 프로그램을 누르는 순간 이미지를 만들어줘도 됩니다.\n\n그리고 앱을 삭제하지 않는다면 기존 캡쳐 이미지 링크를 `localStorage` 를 이용해 저장해두고 언제든지 다시 링크를 가져올 수 있게 할 수도 있습니다. 이건 여러분들에게 과제로 남기겠습니다. 😄\n",{"path":1137,"title":1138,"description":1139,"created":1140,"category":1069,"rawbody":1141},"/monitoring-tool-in-10-minutes","평생 무료인 모니터링 도구 10분만에 만들기","서버를 운영하다보면 예상치 못한 서버 다운이나 응답 속도 저하를 반드시 겪게 됩니다. 원인은 둘째 치구요. 근데 문제는 서버 장애를 원천 차단할 방법이 사실상 없기 때문에, 우리 개발자들이 24시간 눈을 뜨고 지켜볼 수 밖에 없겠습니다.","2021-09-03","---\ncategory: tech\ntitle: 평생 무료인 모니터링 도구 10분만에 만들기\nupdated: 2021-09-14\ncreated: 2021-09-03\nimage: https://user-images.githubusercontent.com/20244536/132088930-d2a8a3a0-8772-46c5-9815-87e671a20eae.png\npublished: true\n---\n\n서버를 운영하다보면 예상치 못한 서버 다운이나 응답 속도 저하를 반드시 겪게 됩니다. 원인은 둘째 치구요. 근데 문제는 서버 장애를 원천 차단할 방법이 사실상 없기 때문에, 우리 개발자들이 24시간 눈을 뜨고 지켜볼 수 밖에 없겠습니다.\n\n\u003C!--more-->\n\n![1611196673948-3fc16ccbe0](https://user-images.githubusercontent.com/20244536/131988431-ebd95bbc-ad29-4ea3-a22f-30f3287faddc.jpg)\n\n울지 마시구요.. 그래도 개발자들은 어떻게든 (반)자동화를 하며 버텨온 사람들입니다.\n\n근데 정말 슬프게도 서버 장애를 원천 차단할 수 있는 방법은 존재하지 않습니다. 다만 24시간 서버를 쳐다보는게 아니라, 장애가 발생했을 때만 알림을 받아서 최대한 빠르게 장애를 해결하면 됩니다.\n\n이런 행위를 **모니터링** 이라고 합니다. 이번 포스팅에서 어떤 서비스든 10분만에 평생 무료로 모니터링 도구를 배포하는 방법을 알아보겠습니다.\n\n:Serieis{:type=\"forever\"}\n\n## 작동 원리\n\n근데 본격적으로 모니터링을 하기 전에, 이 도구 원리를 잠깐만 생각해봅시다.\n\n모니터링 도구가 없다면 개발자가 계속해서 서버 상태를 확인하면 됩니다. 그리고 혹시나 장애가 발생했을 때 바로 대응하면 되겠죠.\n\n이 모니터링 행위를 자동화 한다고 가정해봅시다. 웹사이트 혹은 API 서버의 상태가 정상적인지 주기적으로 파악하기위해 계속해서 서비스에 HTTP 요청을 하면 되겠죠. 그리고 오류가 발생했을 땐 어딘가로 알림을 보내는거죠.\n\n간단하죠?\n\n## Upptime.js\n\n제가 이번 포스팅에서 소개할 툴은 [`Upptime.js`](https://github.com/upptime/upptime) 이라는 오픈소스 프로젝트입니다. 오로지 `GitHub` 생태계에만 의존하는 아주 바람직한 프로젝트입니다.\n\n이 툴의 원리는 다음과 같습니다.\n\n1. `Github Actions`의 스케줄링 기능을 이용해 미리 정의된 엔드포인트에 주기적으로 HTTP/TCP 요청을 날립니다 (기본 값: 5분).\n\n2. `Upptime`이 체크하는 여러가지 사항 중 응답이 저하되거나 다운되는 경우, 새로운 `Github Issue`를 발행합니다.\n\n3. 이후 서버 장애가 해결되면 해당 `Github Issue`는 Close 됩니다.\n\n4. 이 리포트 결과를 `Github Pages`로 만들어진 웹 대시보드를 통해 언제든지 확인할 수 있습니다.\n\n모든 리소스를 오로지 `Github` 에만 의존하고 있다는 것 자체가 엄청난 장점입니다.\n\n그럼 여기까지 간단하게 작동 원리를 알아보았으니, 우리만의 모니터링 웹 대시보드를 만들어보겠습니다.\n\n## 시작하기\n\n저는 이 블로그의 상태를 확인하기 위한 모니터링 페이지를 만들어보도록 하겠습니다.\n\n먼저 [https://github.com/upptime/upptime](https://github.com/upptime/upptime) 이 링크를 눌러 리파지토리를 클론합시다.\n\n![image](https://user-images.githubusercontent.com/20244536/132086435-d2fcedd9-49ef-4834-9ec7-6601823bec59.png)\n\n이 프로젝트는 템플릿화된 리파지토리입니다. 그래서 위 이미지의 우측 상단 **Use This Template** 버튼을 눌러 내 계정 혹은 조직으로 리파지토리를 클론할 수 있습니다.\n\n![image](https://user-images.githubusercontent.com/20244536/132086490-e2354789-a6ef-407e-b595-a1d526fda9cc.png)\n\n프로젝트를 클론하실 때 프로젝트 이름은 아무거나 넣어도 상관 없지만, 아래 두 가지 사항은 지켜주세요.\n\n1. 공개 여부를 `Public` 으로 설정해주세요.\n\n2. 제일 하단 `Include all branches` 를 체크해주세요.\n\n프로젝트를 공개 여부는 사실 필수 사항은 아니지만, 비공개로 설정하면 웹 페이지를 호스팅할 때 API 프록시 설정을 해줘야 합니다. 웹 페이지가 굳이 필요하지 않다면 비공개로 설정하셔도 무방합니다.\n\n하지만 `GitHub Actions`의 빌드 타임이 공개 프로젝트는 무제한인 반면, 비공개 프로젝트에 대해서는 한 달에 2,000분 밖에 제공되지 않습니다. (Free tier 기준)\n\n`Upptime.js` 특성상 빌드 타임이 기본 값인 5분으로 설정했을 때 약 3,000분에 가깝게 소요되기 때문에 무조건 비용이 발생하게 됩니다. 그러니 굳이 비공개로 해야할 필요가 없다면 공개 하시는 걸 추천드리겠습니다.\n\n그리고 모든 브랜치를 포함해야하는 이유는, 이미 포함되어 있는 `gh-pages` 브랜치를 가져와야 이후 단계에서 설정을 마치고 `GitHub Pages` 를 통해 정상적으로 웹 페이지를 호스팅할 수 있기 때문입니다.\n\n## GitHub Pages\n\n프로젝트를 잘 클론했다면 `Settings` -> `Pages` 탭으로 이동해 `Source` 부분의 브랜치가 `gh-pages` 의 루트 폴더를 가리키고 있는지 확인해줍시다.\n\n![image](https://user-images.githubusercontent.com/20244536/132087081-80c519d6-68d7-474c-89f6-92b1048bdf96.png)\n\n저의 경우 `https://peterkimzz.github.io/upptime` 이라는 공개 링크가 생성되었습니다. 접속해보면 설정을 모두 마친 게 아니라서 정상적으로 작동하진 않을겁니다.\n\n## PAT & Secret\n\nPAT는 `Personal Access Token`의 약자입니다. 그냥 `GitHub` 리소스를 사용할 때 필요한 권한을 지닌 토큰이라고 생각하면 됩니다.\n\n내 권한을 동일하게 가진 토큰이기 때문에, 공개 페이지에 저장하지 않도록 보안에 신경 써주세요.\n\n발급 방법은 다음과 같습니다.\n\n![image](https://user-images.githubusercontent.com/20244536/132087358-ab43e744-b27f-4881-991d-743025bb347a.png)\n\n대시보드 우측 상단 내 프로필을 눌러 `Settings`로 이동합니다.\n\n![image](https://user-images.githubusercontent.com/20244536/132087375-5b8b5a4d-25c5-4f97-acca-1486913efbac.png)\n\n그리고 왼쪽 메뉴의 `Developer Settings`로 이동한 다음, 다시 `Personal access tokens` 탭으로 이동해주세요.\n\n![image](https://user-images.githubusercontent.com/20244536/132087389-642e03de-a011-4494-96e7-a3d51d45765b.png)\n\n토큰을 생성하기 위해 우측 상단 `Generate new token` 버튼을 눌러주세요.\n\n우리는 오로지 `Upptime.js`을 정상적으로 작동시키기 위한 `Upptime.js`전용 토큰을 발행할겁니다. 불필요한 권한을 주지 않는 것도 보안을 강화하는 방법입니다.\n\n![image](https://user-images.githubusercontent.com/20244536/132087443-b1834635-8966-4dd0-8e48-3bb0f0851966.png)\n\n`Note` 칸에는 이 토큰이 어떤 목적을 가진 토큰인지 자유롭게 적어주면 됩니다.\n\n그리고 토큰 만료 기간은 설정하는 게 보안상 좋지만, 솔직히 갱신시켜주는 거 너무 귀찮기 때문에 저는 만료 없이 발행했습니다.\n\n필요한 권한은 `repo` 와 `workflow` 입니다.\n\n![image](https://user-images.githubusercontent.com/20244536/132087531-c20f082f-d837-4e2b-9b71-8c3c725bfced.png)\n\n이 키는 생성 후 딱 한 번만 볼수 있으니까 저장해서 어디 노트에 저장해두시길 바랍니다.\n\n자 이제 `PAT` 를 만들었으니 이 토큰을 `Upptime.js` 가 사용할 수 있게 리파지토리에 저장해줍시다.\n\n![image](https://user-images.githubusercontent.com/20244536/132087647-4437bc4d-99ac-427d-a273-c8b856a7d0ec.png)\n\n다시 아까 만든 리파지토리의 `Settings` -> `Secrets` 탭으로 이동해서, 우측 상단 `New repository secret` 버튼을 눌러주세요.\n\n![image](https://user-images.githubusercontent.com/20244536/132087672-2c5e1dad-4a8e-4e93-8029-0598d6e79e06.png)\n\n`Name` 은 꼭 `GH_PAT` 로 설정해주세요.\n\n`Value` 에는 방금 만든 토큰을 넣어주면 됩니다.\n\n![image](https://user-images.githubusercontent.com/20244536/132087703-5360c4de-ef16-45be-aaf8-c105be977b7b.png)\n_토큰이 잘 생성되었다_\n\n이 토큰을 `Secret` 으로 저장하는 이유는, `GitHub Actions` 가 작업 중일 때 비공개로 전달해야하는 값들을 안전하게 저장하기 위함입니다.\n\n## 설정하기\n\n자, 마지막 단계입니다. 프로젝트를 열어 `.upptimerc.yml` 파일만 수정하면 됩니다.\n\n아래는 초기 설정 값입니다.\n\n```yml [.upptimerc.yml]\n# Change these first\nowner: upptime # Your GitHub organization or username, where this repository lives\nrepo: upptime # The name of this repository\n\nsites:\n  - name: Google\n    url: https://www.google.com\n  - name: Wikipedia\n    url: https://en.wikipedia.org\n  - name: Hacker News\n    url: https://news.ycombinator.com\n  - name: Test Broken Site\n    url: https://thissitedoesnotexist.koj.co\n\nstatus-website:\n  # Add your custom domain name, or remove the `cname` line if you don't have a domain\n  # Uncomment the `baseUrl` line if you don't have a custom domain and add your repo name there\n  cname: demo.upptime.js.org\n  # baseUrl: /your-repo-name\n  logoUrl: https://raw.githubusercontent.com/upptime/upptime.js.org/master/static/img/icon.svg\n  name: Upptime\n  introTitle: \"**Upptime** is the open-source uptime monitor and status page, powered entirely by GitHub.\"\n  introMessage: This is a sample status page which uses **real-time** data from our [GitHub repository](https://github.com/upptime/upptime). No server required — just GitHub Actions, Issues, and Pages. [**Get your own for free**](https://github.com/upptime/upptime)\n  navbar:\n    - title: Status\n      href: /\n    - title: GitHub\n      href: https://github.com/$OWNER/$REPO\n# Upptime also supports notifications, assigning issues, and more\n# See https://upptime.js.org/docs/configuration\n```\n\n뭐 이것저것 텍스트가 많은데, 필수적인 것들만 짚고 넘어가도록 하겠습니다.\n\n- `owner` : 내 유저명 혹은 조직 명을 넣어주면 됩니다.\n- `repo` : 리파지토리 이름을 넣어주세요.\n- `sites` : 모니터링하고 싶은 링크 주소를 배열로 넣어주세요. 100개 이상도 가능합니다.\n- `status-website.name` : 웹사이트 이름을 넣어주세요.\n\n아래는 둘 중 하나만 설정하시면 됩니다.\n\n- `status-website.cname` : 커스텀 도메인을 사용하는 경우, 호스팅 할 도메인 명을 넣어주세요.\n- `status-website.baseUrl` : 커스텀 도메인을 사용하지 않는 경우에 리파지토리 명을 넣어주면 됩니다.\n\n나머지는 대시보드 꾸미기용이라, 나중에 문서를 읽어보며 설정해보시길 바랍니다.\n\n하나 언급할 내용은, `sites`에 엔드포인트를 넣어줄 때 다양한 방식으로 HTTP 요청 방식을 보내는 것도 가능합니다.\n\nHeader를 포함한 HTTP GET 요청\n\n```yml\n- name: API endpoint\n  url: https://example.com/get-user/3\n  headers:\n    - \"Authorization: Bearer $SECRET_SITE_2\" # Repository Secret을 이용하는 방법\n    - \"Content-Type: application/json\"\n```\n\nBody를 포함한 HTTP POST 요청\n\n```yml\n- name: API endpoint with data\n  method: POST\n  url: https://example.com/login\n  headers:\n    - \"Content-Type: application/json\"\n  body: '{ \"password\": \"hello\" }'\n```\n\n원하는 응답 코드만 필터링하는 방법도 있습니다.\n\n```yml\nsites:\n  - name: Google\n    url: https://www.google.com\n    expectedStatusCodes:\n      - 200\n      - 201\n      - 404\n```\n\n응답 시간을 제한하는 방법도 있습니다.\n\n```yml\n- name: Slow endpoint\n  url: https://example.com\n  maxResponseTime: 5000\n```\n\n공개 페이지를 만드는 경우, 웹 URL을 숨기고 싶을 수도 있습니다. 그런 경우에는 아까처럼 `Repository Secrets` 을 이용하면 됩니다.\n\n```yml\n- name: Secret Site\n  url: $SECRET_SITE\n```\n\n## 설정 마무리\n\n어쨌든 저는 다 필요 없고 제 블로그가 잘 접속이 되는지 확인하고, 커스텀 도메인을 사용할 예정이기 때문에 아래처럼 구성했습니다.\n\n```yml [.upptimerc.yml]\nowner: peterkimzz\nrepo: upptime\n\nsites:\n  - name: peterkimzz.com\n    url: http://peterkimzz.com\n\nstatus-website:\n  name: Upptime for peterkimzz.com\n  cname: upptime.peterkimzz.com\n```\n\n이 상태로 코드를 커밋하고 푸시하면 알아서 북치고 장구치고 다 해줍니다.\n\n참고로 커스텀 도메인을 붙이시는 분들은 도메인 네임 서버로 가셔서 `CNAME` 서브도메인 값에 `USERNAME.github.io` 를 넣어주시고 인증서가 발행될 때 까지 15분 정도 기다리셔야 합니다.\n\n이 내용은 예전 [Github Pages로 개인 블로그 평생 무료로 운영하기](/github-pages-nuxtjs) 에도 다루었던 내용이므로 참고해주세요.\n\n## 알림 설정하기\n\n알림을 따로 설정하지 않더라도 내 `GitHub` 계정에 등록된 이메일로 이슈를 발송해주긴 합니다. 하지만 대부분의 경우 알림을 바로 받아보고 싶으실테니 직접 설정해보도록 하겠습니다.\n\n현재 지원되는 제공 업체는 `Slack`, `Discord`, `Telegram`, `SMS` 와 `Email` 입니다.\n\n저는 제가 자주 사용하는 `Discord` 채널에 알림을 보내도록 설정하겠습니다.\n\n![image](https://user-images.githubusercontent.com/20244536/132088656-fabe6029-7932-49e1-9c4f-5133bd04a83b.png)\n\n먼저 알림을 받고 싶은 `Discord` 채널 우측 **채널 편집** 을 눌러 설정 페이지로 이동해주세요.\n\n![image](https://user-images.githubusercontent.com/20244536/132088693-da470e7f-7989-4e8a-920f-a43c70e657a1.png)\n\n왼쪽 탭의 **연동** 버튼을 누른 뒤, **웹후크** 를 선택해주세요.\n\n![image](https://user-images.githubusercontent.com/20244536/132088703-6096933f-f2bd-49a2-8e78-21346764a3ee.png)\n\n**새 웹후크** 버튼을 누르면 가장 하단 카드가 생기고, 웹 후크의 이름과 알림을 받을 채널을 선택할 수 있습니다. 이름은 아무거나 설정해주세요.\n\n그리고 **웹후크 URL 복사** 버튼을 눌러 잘 저장해두세요.\n\n![image](https://user-images.githubusercontent.com/20244536/132088785-4e4bd51a-9ee8-467c-bf85-e8aacac5d60b.png)\n\n마지막으로 아까 했던 `Repository Secrets` 로 이동해서 3개의 값을 추가해주면 됩니다.\n\n- `NOTIFICATION_DISCORD` : true\n- `NOTIFICATION_DISCORD_WEBHOOK` : true\n- `NOTIFICATION_DISCORD_WEBHOOK_URL` : 웹후크 URL 주소\n\n![image](https://user-images.githubusercontent.com/20244536/132088792-e37f235e-b293-4f53-a3c7-0476b9269c8f.png)\n\n알림 설정은 이걸로 끝입니다.\n\n우리가 모니터링 중인 엔드포인트에 문제가 생기면 바로 알림을 보내주고, 다시 복구가 됐을 때도 알림을 보내줄겁니다.\n\n![image](https://user-images.githubusercontent.com/20244536/132088842-2ebcf129-7708-4b2d-8e1d-86660de074b6.png)\n_실제 운영 중인 서버가 잠시 다운 됐다가, 다시 복구되었을 때 알림을 받은 모습_\n\n## 마무리\n\n![image](https://user-images.githubusercontent.com/20244536/132112607-ab361637-c15d-4583-a148-861dbf85db40.png)\n_대시보드 화면_\n\n이렇게 해서 저는 [https://upptime.peterkimzz.com](https://upptime.peterkimzz.com) 이라는 평생 무료인 모니터링 용 웹 대시보드를 10분만에 갖게 되었습니다.\n\n개인 프로젝트나 재직 중인 회사에서도 충분히 도입할만한 가치가 있는 프로젝트이므로, 무료로 무한으로 즐겨보세요.\n\n### 참고\n\n- [Upptime.js Official Website](https://upptime.js.org/)\n- [Upptime.js Official Documentation](https://upptime.js.org/docs/)\n",{"path":1143,"title":1144,"description":1145,"created":1146,"category":1069,"rawbody":1147},"/vuejs-chrome-extension-3","Vue.js로 크롬 확장 프로그램 만들기 강의 - 3부","이전 포스팅에서는 Vite을 이용해 크롬 확장 프로그램을 만들기 위한 기본적인 프로젝트 환경 설정까지 마쳤습니다. 본격적으로 Vue.js 코드를 작성해보도록 합시다.","2021-08-16","---\ncategory: tech\ntitle: Vue.js로 크롬 확장 프로그램 만들기 강의 - 3부\nupdated: 2021-08-16\ncreated: 2021-08-16\nimage: https://dynamisign.com/api/sign?d=peterkimzz.com&t=Vue.js%EB%A1%9C%20%ED%81%AC%EB%A1%AC%20%ED%99%95%EC%9E%A5%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EB%A7%8C%EB%93%A4%EA%B8%B0%20%EA%B0%95%EC%9D%98%20-%203%EB%B6%80\npublished: true\n---\n\n[이전 포스팅](/vuejs-chrome-extension-2)에서는 [Vite](https://vitejs.dev/)을 이용해 크롬 확장 프로그램을 만들기 위한 기본적인 프로젝트 환경 설정까지 마쳤습니다. 본격적으로 Vue.js 코드를 작성해보도록 합시다.\n\n\u003C!--more-->\n\n## 개발 시작하기\n\n일단 저는 개발 단계에서 크롬 확장 프로그램으로 올려서 개발하기 보다는, 그냥 브라우저에서 핫 리로드를 하며 빠르게 개발을 하도록 하겠습니다.\n\n일단 `package.json`의 scripts를 조금 수정해줍시다.\n\n```json [package.json]\n{\n  \"dev\": \"vite\",\n  \"dev:extension\": \"vite build --watch\",\n  \"build\": \"vite build\",\n  \"build:extension\": \"bestzip dist.zip dist/\"\n}\n```\n\n브라우저로 개발하고 싶을 땐 `npm run dev`를, 크롬 확장 프로그램에 올려서 하고 싶을 땐 `npm run dev:extension` 으로 하면 됩니다. `build`도 똑같습니다.\n\n개발 서버를 열어줍니다.\n\n```bash[bash]\nnpm run dev\n# http://localhost:3000\n```\n\n이런 화면이 보인다면 정상적으로 개발 서버가 열린겁니다.\n\n일단 시작 페이지를 깔끔하게 만들어주도록 하죠. `src/App.vue` 파일을 열어주세요.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cimg alt=\"Vue logo\" src=\"./assets/logo.png\" />\n  \u003CHelloWorld msg=\"Hello Vue 3 + Vite\" />\n\u003C/template>\n\n\u003Cscript setup>\nimport HelloWorld from \"./components/HelloWorld.vue\";\n\n// This starter template is using Vue 3 experimental \u003Cscript setup> SFCs\n// Check out https://github.com/vuejs/rfcs/blob/master/active-rfcs/0040-script-setup.md\n\u003C/script>\n\n\u003Cstyle>\n#app {\n  font-family: Avenir, Helvetica, Arial, sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-align: center;\n  color: #2c3e50;\n  margin-top: 60px;\n}\n\u003C/style>\n```\n\nVue는 `Single File Component (SFC)` 라는 기술로 한 파일에서 `HTML`, `Javascript`, `CSS`를 모두 정의합니다. 이 자체로 장점이 있다면 있을 수 있고, 코드가 길어진다면 단점이 될 수도 있겠죠.\n\n기존 코드를 아래처럼 정리해주고, `src/components` 폴더에 있는 `HelloWorld.vue` 파일도 지워주세요.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cdiv>Hello, world!\u003C/div>\n\u003C/template>\n\n\u003Cscript setup>\u003C/script>\n\n\u003Cstyle>\u003C/style>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/129522620-b5c4c974-3d97-42f7-9c06-ab7c010e9f03.png)\n\n## 레이아웃 디자인\n\n보통 웹사이트는 `Navigation Bar`, `Main Contents`, `Footer Bar` 크게 세 가지의 영역으로 나누어 디자인을 하게 됩니다. 이게 대부분의 유저들에게 가장 익숙합니다.\n\n`Navigation Bar` 에는 로고와 자주 사용하는 메뉴들을 넣게 됩니다.\n\n`Main Contents` 는 말 그대로 메인 콘텐츠가 보여집니다.\n\n`Footer bar` 영역엔 주로 회사 정보나, 사이트 내 접근 가능한 링크 혹은 외부 채널들을 넣어줍니다.\n\n![image](https://user-images.githubusercontent.com/20244536/129524227-83ddb4d9-fd9d-4310-a6a3-ca1575361ddf.png)\n\n간단하게 위의 구조로 HTML을 작성해줍시다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cnav>코인시세보기프로그램\u003C/nav>\n\n  \u003Cmain>Hello, world!\u003C/main>\n\n  \u003Cfooter>peterkimzz.com\u003C/footer>\n\u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/129525100-9a13e16f-5e16-45a1-b90d-b95397317f17.png)\n\n코드 상으로는 세 개의 영역이 확실히 구분되지만, 사람 눈에는 전혀 구분되어 보이지 않습니다. `css`를 활용해 지금보다 약간만 개선을 해봅시다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cnav>코인시세보기프로그램\u003C/nav>\n\n  \u003Cmain>Hello, world!\u003C/main>\n\n  \u003Cfooter>peterkimzz.com\u003C/footer>\n\u003C/template>\n\n\u003Cscript setup>\u003C/script>\n\n\u003Cstyle>\nnav {\n  font-size: 1.1rem;\n  font-weight: bold;\n  padding-bottom: 0.5rem;\n  border-bottom: 1px solid #e2e2e2;\n}\n\nmain {\n  padding-top: 1rem;\n  padding-bottom: 10rem;\n}\n\nfooter {\n  font-size: 0.9rem;\n  color: gray;\n  border-top: 1px solid #e2e2e2;\n  padding-top: 0.5rem;\n}\n\u003C/style>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/129525960-0010088b-6627-4ed8-a40b-0176bc277956.png)\n\n음.. 네 뭐 전보다는 낫네요. 예쁜게 좋은 디자인은 아닙니다. 익숙하고 편하면 좋은 겁니다.\n\n## 코인 거래소 UI 분석\n\n구조는 대충 잡았는데, 어디부터 어떻게 서비스를 만들어야 할지 막막합니다. 왜냐하면 거기까지는 생각을 안해봤거든요.\n\n그렇다면 실제 코인 거래소들은 어떤 UI를 구성했을까요? 저는 한국 거래소 1등인 [업비트](https://upbit.com/exchange?code=CRIX.UPBIT.KRW-BTC) 를 슬쩍 보도록 하겠습니다.\n\n![image](https://user-images.githubusercontent.com/20244536/129527876-f79dd52b-828e-49f5-a052-c8c73e57473f.png)\n\n우리가 만들어 볼 부분은 바로 여기입니다.\n\n간단히 UI를 분석해보자면, 최상단에는 **검색**과 **설정**이 있습니다.\n\n그 아래는 화폐에 따라 다른 시세를 볼 수 있는 **화폐 탭**이 있네요.\n\n그리고 아래에는 테이블 형식으로 된 **코인 시세**가 있습니다.\n\n근데 저는 개선해보고 싶은 부분이 하나 있습니다. 바로 테이블 쪽의 **거래대금** 입니다. `1,783,893백만` 이라는 숫자가 바로 읽히지 않기 때문입니다. 어차피 백만 아래 단위를 잘라서 보여줄 것 같으면 차라리 `1조 7838억` 정도로만 나와도 훨씬 이해하기 쉬울 것 같은데 말이죠.\n\n그래서 전 업비트의 UI를 참고하되, 저만의 차별점을 약간 줘보도록 하겠습니다.\n\n참고로 다른 서비스들을 최대한 많이 접해보는 게 좋습니다. 나중에 좋은 서비스를 만드는데 큰 밑거름이 됩니다. 자주 베끼고, 따라하세요. (그렇다고 아예 베끼라는 건 아닙니다)\n\n## UI 만들기\n\n일단 이 강의 시리즈에서는 검색이나 설정, 화폐 탭 바꾸기 기능 등 부가 기능들은 만들지 않도록 하겠습니다. 단순히 시세를 보여주기만 하는 것 까지만 하겠습니다. (너무 많아요)\n\n그렇다면 일단 테이블만 먼저 스타일링 없이 만들어보도록 하겠습니다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cnav>코인시세보기프로그램\u003C/nav>\n\n  \u003Ctable>\n    \u003Cthead>\n      \u003Ctr>\n        \u003Cth>한글명\u003C/th>\n        \u003Cth>현재가\u003C/th>\n        \u003Cth>전일대비\u003C/th>\n        \u003Cth>거래대금\u003C/th>\n      \u003C/tr>\n    \u003C/thead>\n    \u003Ctbody>\n      \u003Ctr>\n        \u003Ctd>\n          \u003Cdiv>도지코인\u003C/div>\n          \u003Cdiv>DOGE/KRW\u003C/div>\n        \u003C/td>\n        \u003Ctd>394\u003C/td>\n        \u003Ctd>\n          \u003Cdiv>-0.76%\u003C/div>\n          \u003Cdiv>-3.00\u003C/div>\n        \u003C/td>\n        \u003Ctd>1,783,893백만\u003C/td>\n      \u003C/tr>\n      \u003Ctr>\n        \u003Ctd>\n          \u003Cdiv>리플\u003C/div>\n          \u003Cdiv>XRP/KRW\u003C/div>\n        \u003C/td>\n        \u003Ctd>1500\u003C/td>\n        \u003Ctd>\n          \u003Cdiv>+0.67%\u003C/div>\n          \u003Cdiv>10.00\u003C/div>\n        \u003C/td>\n        \u003Ctd>1,159,312백만\u003C/td>\n      \u003C/tr>\n      \u003Ctr>\n        \u003Ctd>\n          \u003Cdiv>이더리움클래식\u003C/div>\n          \u003Cdiv>ETC/KRW\u003C/div>\n        \u003C/td>\n        \u003Ctd>85,340\u003C/td>\n        \u003Ctd>\n          \u003Cdiv>-2.33%\u003C/div>\n          \u003Cdiv>-2040\u003C/div>\n        \u003C/td>\n        \u003Ctd>933,497백만\u003C/td>\n      \u003C/tr>\n    \u003C/tbody>\n  \u003C/table>\n\n  \u003Cfooter>peterkimzz.com\u003C/footer>\n\u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/129530856-94661816-0592-45fb-a7b6-031175c33a1c.png)\n\n실제 데이터를 받아오기 전에, 3개의 코인 정보만 업비트랑 같은 레이아웃으로 작성했습니다.\n\n우리가 주목해야할 부분은, `\u003Ctr>` 태그 부분이 반복된다는 점입니다. 그렇다는 이야기는 자바스크립트를 이용해 좀 더 프로그램스럽게 만들 수 있다는 것이죠.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cnav>코인시세보기프로그램\u003C/nav>\n\n  \u003Ctable>\n    \u003Cthead>\n      \u003Ctr>\n        \u003Cth>한글명\u003C/th>\n        \u003Cth>현재가\u003C/th>\n        \u003Cth>전일대비\u003C/th>\n        \u003Cth>거래대금\u003C/th>\n      \u003C/tr>\n    \u003C/thead>\n    \u003Ctbody>\n      \u003Ctr v-for=\"coin in coins\" :key=\"coin.symbol\">\n        \u003Ctd>\n          \u003Cdiv>{{ coin.korean_name }}\u003C/div>\n          \u003Cdiv>{{ coin.symbol }}/KRW\u003C/div>\n        \u003C/td>\n        \u003Ctd>{{ coin.price }}\u003C/td>\n        \u003Ctd>\n          \u003Cdiv>{{ coin.change }}%\u003C/div>\n          \u003Cdiv>{{ coin.change_price }}\u003C/div>\n        \u003C/td>\n        \u003Ctd>{{ coin.volume }}백만\u003C/td>\n      \u003C/tr>\n    \u003C/tbody>\n  \u003C/table>\n\n  \u003Cfooter>peterkimzz.com\u003C/footer>\n\u003C/template>\n\n\u003Cscript setup>\nimport { ref } from \"vue\";\n\nconst coins = ref([\n  {\n    symbol: \"DOGE\",\n    korean_name: \"도지코인\",\n    price: 394,\n    change: -0.76,\n    change_price: -3.0,\n    volume: 1783783,\n  },\n  {\n    symbol: \"XRP\",\n    korean_name: \"리플\",\n    price: 1500,\n    change: 0.67,\n    change_price: 10.0,\n    volume: 1159312,\n  },\n  {\n    symbol: \"ETC\",\n    korean_name: \"이더리움클래식\",\n    price: 85340,\n    change: -2.33,\n    change_price: -2040,\n    volume: 933497,\n  },\n]);\n\u003C/script>\n```\n\n`script` 태그에 내부에 있는 변수들을, `\u003Ctemplate>` 영역에서 참조가 가능합니다.\n\n`coins` 변수를 Vue에서 반복문을 만들 때 사용하는 `v-for`를 이용해서 연결해두었으니, 나중에 `coins` 에 실제 코인 데이터를 넣어주면 업비트랑 똑같은 정보를 보여줄 수 있겠겠요. 그렇게 어렵지 않죠?\n\n숫자가 보여지는 부분이 아까랑은 약간 다르게 보이겠지만, 나중에 개선해보도록 합시다.\n\n## 업비트 API\n\n`API` 라는 말 들어보셨나요? `Application Programming Interface` 라는 뜻입니다. 뭔 소리냐구요? 그냥 프로그램이 사용하는 인터페이스라고 생각하시면 됩니다.\n\n인터페이스가 뭐냐구요? 그렇다면 `UI` 라는 말은 들어보셨겠죠. `User Interface` 라는 뜻입니다. 사람이 사용하는 인터페이스라는 뜻이겠죠.\n\n즉, 인터페이스는 진짜 간단하게 **사용법** 이라는 뜻입니다.\n\n그렇다면 업비트 API를 사용한다는 것은 업비트에서 제공하는 시세 데이터를 받아오거나, 매수/매도 주문을 넣거나 하는 행위를 업비트 사람이 웹사이트에서 하는 게 아니라 (이건 UI죠), **애플리케이션 끼리 하도록 하는 것**을 말합니다.\n\n[업비트 API 가이드](https://docs.upbit.com/docs)에는 개발자들이 쉽게 가상화폐 관련 데이터를 사용할 수 있도록 API 사용 문서가 제공되고 있습니다.\n\n일단 맛을 보기 위해 브라우저 주소창에 [https://api.upbit.com/v1/market/all](https://api.upbit.com/v1/market/all)를 입력해보세요.\n\n업비트에서 취급하는 모든 코인 목록이 보이실겁니다. 이 데이터를 이용하면 아까 우리가 만들었던 테이블을 좀 더 풍성하게 만들 수 있겠다는 생각이 드네요.\n\n## HTTP 요청\n\n아까 브라우저에 `api.upbit.com` 어쩌구를 입력했던 게 HTTP 요청이라는 거 알고 계셨나요? 우린 사실 이미 수 많은 HTTP 요청을 하고 살아왔습니다.\n\n근데 `naver.com`을 쳤을 때는 왜 웹사이트가 보일까요? 그건 네이버에서 `HTML`을 응답했기 때문입니다. 업비트는 그냥 단순 데이터를 응답한거구요. 별 거 아닙니다.\n\n이걸 브라우저에서 요청하는 게 아니라, 우리 웹사이트가 요청하도록 하기만 하면 아까 보였던 코인 목록들을 우리 웹사이트에서 활용할 수 있습니다.\n\n```bash\nnpm i axios\n```\n\n패키지 하나 설치해주세요. 자바스크립트에서 제일 많이 사용하는 HTTP 요청 라이브러리입니다.\n\n그리고 Vue script 코드를 수정해줍시다.\n\n```vue [App.vue]\n\u003Cscript setup>\nimport { ref } from \"vue\";\nimport axios from \"axios\";\n\nconst coins = ref([]);\n\nasync function GetSymbols() {\n  try {\n    const { data } = await axios.get(\"https://api.upbit.com/v1/market/all\");\n    console.log(data);\n\n    coins.value = data;\n  } catch (err) {\n    throw err;\n  }\n}\n\nGetSymbols();\n\u003C/script>\n```\n\n저장하고, 브라우저에서 F12를 눌러 개발자 콘솔을 열어보면 255개의 배열이 응답된 것을 확인할 수 있습니다. 그리고 우리 웹사이트에도 255개의 코인 목록이 보여지고 있네요.\n\n![image](https://user-images.githubusercontent.com/20244536/129536005-a8b0d29e-c2d0-42a8-a869-b7f658fea4e2.png)\n\n다만 단순 코인 목록만 요청했기 때문에, 시세 정보는 다른 API를 이용해 받아와야 합니다.\n\n![image](https://user-images.githubusercontent.com/20244536/129536612-e93e607f-48d1-4ee2-80c4-4df6f881860f.png)\n\n업비트 개발 문서에서 가져왔습니다. `/v1/ticker` 주소에 `markets` 라는 값과 함께 요청하면 된다는군요. 복수로 보내려면 컴마를 이용해서 가져올 수도 있다고 합니다.\n\n그럼 아까 불러왔던 코인 목록에서 `market` 값만 추출해서 배열로 이어주고, API 요청을 날리면 되겠다는 생각을 하면 되겠네요.\n\n코드도 조금 개선해보겠습니다.\n\n```vue [App.vue]\n\u003Cscript setup>\nimport { onMounted, ref } from \"vue\";\nimport axios from \"axios\";\n\nconst coins = ref([]);\n\nfunction GetSymbols() {\n  return axios.get(\"https://api.upbit.com/v1/market/all\");\n}\n\nfunction GetTickers(markets = []) {\n  return axios.get(\"https://api.upbit.com/v1/ticker\", {\n    params: { markets: markets.join(\",\") },\n  });\n}\n\n// 페이지가 로드되면 자동으로 호출되는 Vue.js의 사전 정의 함수\nonMounted(async () => {\n  try {\n    const { data: symbols } = await GetSymbols();\n\n    const markets = symbols.map((symbol) => symbol.market);\n    const { data: tickers } = await GetTickers(markets);\n\n    coins.value = tickers;\n  } catch (err) {}\n});\n\u003C/script>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/129537276-a3a206c6-61fd-4aae-ae9c-dad840f5ad55.png)\n\n콘솔을 확인해보니, 아까보단 각 코인들이 가지고 있는 데이터가 훨씬 더 많아졌습니다. 이것만 가지고도 아까 업비트에서 봤던 UI를 만들 수 있겠다는 생각이 드네요.\n\n근데 하나 문제가 있습니다. `ticker`를 받아오는 API에는 코인 한글명을 응답해주지 않는다는 점입니다.\n\n## 데이터 합치기\n\n그럼 심볼이랑 티커를 합치면 되겠네요.\n\n근데 잠깐, 데이터를 합치기 전에 잠깐 생각을 해봅시다. `symbols` 도 배열로 응답되고, `tickers` 도 배열로 응답됩니다.\n\n`tickers` 안에는 `market` 변수가 제공되니까 먼저 `symbols`을 반복문 돌리고, `symbols`도 반복문을 돌려서 `market` 값으로 찾은 다음에 둘을 합쳐야겠네?\n\n라고 생각하셨나요? 그럼 배열을 255번 x 255번을 돌아야 합니다. 총 `65,025` 번이나 돌게 생겼습니다.\n\n그리고 지금은 단 2번의 응답을 받아서 괜찮지만, 이따 실시간으로 거래되는 트래픽을 받을 때는 1초에 수십, 수백번의 응답을 받게 됩니다. 그 때 마다 계속 배열 돌면서 시세를 업데이트 해준다면 분명 앱이 느려서 제대로 작동하지 않을겁니다.\n\n그럼 어떡하죠? 방법은 간단합니다. 데이터를 배열이 아니라, **객체**로 저장하면 됩니다.\n\n객체는 `Key`, `Value`로 구성되어서, `Key`를 이용해 데이터를 접근한다는 특징이 있습니다. 데이터가 많아도 매우 빠른 속도로 데이터를 찾아낼 수 있죠.\n\n`onMounted` 를 아래처럼 수정해주세요.\n\n```js\nonMounted(async () => {\n  try {\n    const { data: symbols } = await GetSymbols();\n    symbols.forEach((symbol) => {\n      coins.value[symbol.market] = symbol;\n    });\n\n    const markets = symbols.map((symbol) => symbol.market);\n    const { data: tickers } = await GetTickers(markets);\n\n    tickers.forEach((ticker) => {\n      coins.value[ticker.market] = Object.assign(\n        coins.value[ticker.market],\n        ticker\n      );\n    });\n  } catch (err) {}\n});\n```\n\n근데 데이터를 살펴보니, 코인 데이터가 원화 마켓, BTC 마켓 등 여러 마켓이 짬뽕되어서 나오고 있습니다.\n\n![image](https://user-images.githubusercontent.com/20244536/129540617-8255955b-c783-4b6b-a77f-7396cba0df2d.png)\n\n마켓 구분은 심볼 앞의 접두어를 보면 됩니다. `KRW-` 은 원화 마켓, `BTC-` 는 비트코인 마켓, `USDT-` 는 달러 마켓입니다.\n\n우린 **원화** 마켓 데이터만 가져오고 싶기 때문에 `KRW-` 으로 시작하는 코인들만 필터링 해보도록 하겠습니다.\n\n변화된 데이터에 따라 `HTML`도 조금 수정했습니다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cnav>코인시세보기프로그램\u003C/nav>\n\n  \u003Ctable>\n    \u003Cthead>\n      \u003Ctr>\n        \u003Cth>한글명\u003C/th>\n        \u003Cth>현재가\u003C/th>\n        \u003Cth>전일대비\u003C/th>\n        \u003Cth>거래대금\u003C/th>\n      \u003C/tr>\n    \u003C/thead>\n    \u003Ctbody>\n      \u003Ctr v-for=\"(coin, key) in coins\" :key=\"key\">\n        \u003Ctd>\n          \u003Cdiv>{{ coin.korean_name }}\u003C/div>\n          \u003Cdiv>{{ coin.market }}/KRW\u003C/div>\n        \u003C/td>\n        \u003Ctd>{{ coin.trade_price }}\u003C/td>\n        \u003Ctd>\n          \u003Cdiv>{{ coin.change_rate * 100 }}%\u003C/div>\n          \u003Cdiv>{{ coin.change_price }}\u003C/div>\n        \u003C/td>\n        \u003Ctd>{{ coin.acc_trade_price_24h }}백만\u003C/td>\n      \u003C/tr>\n    \u003C/tbody>\n  \u003C/table>\n\n  \u003Cfooter>peterkimzz.com\u003C/footer>\n\u003C/template>\n\n\u003Cscript setup>\nimport { onMounted, ref } from \"vue\";\nimport axios from \"axios\";\n\nconst coins = ref({});\n\nfunction GetSymbols() {\n  return axios.get(\"https://api.upbit.com/v1/market/all\");\n}\n\nfunction GetTickers(markets = []) {\n  return axios.get(\"https://api.upbit.com/v1/ticker\", {\n    params: { markets: markets.join(\",\") },\n  });\n}\n\n// 페이지가 로드되면 자동으로 호출되는 Vue.js의 사전 정의 함수\nonMounted(async () => {\n  try {\n    const markets = [];\n\n    const { data: symbols } = await GetSymbols();\n\n    symbols.forEach((symbol) => {\n      /** `KRW-` 으로 시작하는 마켓만 핕터링 */\n      if (symbol.market.indexOf(\"KRW-\") === -1) {\n        return;\n      }\n\n      markets.push(symbol.market);\n      coins.value[symbol.market] = symbol;\n    });\n\n    const { data: tickers } = await GetTickers(markets);\n\n    tickers.forEach((ticker) => {\n      coins.value[ticker.market] = Object.assign(\n        coins.value[ticker.market],\n        ticker\n      );\n    });\n  } catch (err) {}\n});\n\u003C/script>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/129541687-e6d0cba6-53d9-47e1-8234-d3dfaa4fa7a9.png)\n\n실제 코인과 숫자가 채워지니까 아까보다 훨신 더 있어보이네요.\n\n## 스타일링\n\n여기까지 스크립트는 적당히 잘 작동하는 것 같습니다.\n\n헌데.. 솔직히 디자인이 너무 구립니다. 이대로 계속 진행하기엔 저의 내재된 디자인 감각이 허용하지 않네요. UI를 조금만 개선을 해보겠습니다.\n\n본격적으로 스타일링을 하기 전에, 라이브러리 하나 설치하고 가도록 하겠습니다.\n\n```bash [bash]\nnpm install -D tailwindcss@latest postcss@latest autoprefixer@latest\nnpx tailwindcss init -p\n```\n\n[`Tailwind CSS`](https://tailwindcss.com/) 라는 라이브러리입니다. 이걸 사용하면 매우 빠르게 스타일링을 할 수 있습니다. 저는 이 라이브러리의 도움으로, `\u003Cstyle>` 태그를 아예 사용하지 않고 스타일링을 하고 있습니다.\n\n위 두 줄을 입력하면 `tailwind.config.js` 파일과, `postcss.config.js` 파일이 생성됩니다.\n\n`tailwind.config.js` 파일만 조금 수정해줍시다.\n\n```js [tailwind.config.js]\nmodule.exports = {\n  mode: \"jit\",\n  purge: [\"./index.html\", \"./src/**/*.{vue,js,ts,jsx,tsx}\"],\n  darkMode: false,\n  theme: {\n    extend: {},\n  },\n  variants: {\n    extend: {},\n  },\n  plugins: [],\n};\n```\n\n그리고 `tailwindcss` 를 우리 프로젝트에서 사용할 수 있도록 전역 `.css` 파일을 만들고, import 해주도록 합시다.\n\n`src` 폴더 아래에 `index.css` 라는 파일을 만들어주세요.\n\n```css [index.css]\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n```\n\n```js [main.js]\nimport { createApp } from \"vue\";\nimport App from \"./App.vue\";\nimport \"./index.css\";\n\ncreateApp(App).mount(\"#app\");\n```\n\n이렇게 하면 구성이 끝났습니다.\n\n일단 기존 `\u003Cstyle>` 태그는 전부 지워주세요. 같은 스타일링을 `tailwindcss` 를 이용해 해보도록 하겠습니다. `HTML` 클래스를 주목해주세요.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cnav class=\"border-b border-gray-300 p-2 text-[1.1rem] font-bold\">\n    코인시세보기프로그램\n  \u003C/nav>\n\n  \u003Cdiv class=\"p-2\">// ...\u003C/div>\n\n  \u003Cfooter class=\"font-[0.9rem] border-t border-gray-300 p-2 text-gray-600\">\n    peterkimzz.com\n  \u003C/footer>\n\u003C/template>\n```\n\n대충 이해가 가시나요? 부가 설명을 드리자면 `p-` 는 `padding` 관련 옵션입니다. 그리고 색깔 부분은 미리 정의된 컬러 팔레트를 사용했습니다. 각 색깔마다 100 부터 900까지 다양한 채도로 구성되어있습니다. 자세한 내용이 궁금하신 분들은 [여기](https://tailwindcss.com/docs/customizing-colors)를 확인해보세요.\n\n전체적으로 스타일링을 조금 수정해보겠습니다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cnav class=\"p-4 text-[1.1rem] font-bold\">코인시세보기프로그램\u003C/nav>\n\n  \u003Cdiv>\n    \u003Ctable class=\"table w-full\">\n      \u003Cthead>\n        \u003Ctr class=\"border-b border-gray-100 text-sm text-gray-500\">\n          \u003Cth class=\"py-2 px-4 text-left\">한글명\u003C/th>\n          \u003Cth class=\"py-2 px-4 text-right\">현재가(원)\u003C/th>\n          \u003Cth class=\"py-2 px-4 text-right\">전일대비\u003C/th>\n          \u003Cth class=\"py-2 px-4 text-right\">거래대금\u003C/th>\n        \u003C/tr>\n      \u003C/thead>\n      \u003Ctbody class=\"text-gray-900\">\n        \u003Ctr v-for=\"(coin, key) in coins\" :key=\"key\">\n          \u003Ctd class=\"py-1 px-4 text-left\">\n            \u003Cdiv class=\"font-semibold text-gray-700\">\n              {{ coin.korean_name }}\n            \u003C/div>\n            \u003Cdiv class=\"text-sm text-gray-500\">{{ coin.market }}/KRW\u003C/div>\n          \u003C/td>\n          \u003Ctd class=\"py-1 px-4 text-right align-top font-semibold\">\n            {{ coin.trade_price }}\n          \u003C/td>\n          \u003Ctd class=\"py-1 px-4 text-right\">\n            \u003Cdiv class=\"font-semibold\">{{ coin.change_rate * 100 }}%\u003C/div>\n            \u003Cdiv class=\"text-sm text-gray-500\">{{ coin.change_price }}\u003C/div>\n          \u003C/td>\n          \u003Ctd class=\"py-1 px-4 text-right\">\n            {{ coin.acc_trade_price_24h }}백만\n          \u003C/td>\n        \u003C/tr>\n      \u003C/tbody>\n    \u003C/table>\n  \u003C/div>\n\n  \u003Cfooter class=\"font-[0.9rem] border-t border-gray-300 p-4 text-gray-600\">\n    peterkimzz.com\n  \u003C/footer>\n\u003C/template>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/129545644-32593360-8259-4ccf-9a48-e16c041af310.png)\n\n강조하고 싶은 부분을 좀 더 진한 색으로, 진한 폰트로. 덜 중요한 부분을 더 연한 색으로 변경해주었습니다.\n\n굳이 색을 넣지 않더라도, 보기 좋은 UI는 얼마든지 만들 수 있습니다.\n\n그리고 약간씩 간격을 조정했습니다. 숫자들만 정리되면 훨씬 더 직관적이고 깔끔해 보이겠네요.\n\n## 정리하기\n\n정리하고 싶은 점을 나열해봅시다.\n\n1. 현재가는 컴마 단위로 구분되서 보이면 좋겠네요. (10100원 -> 10,000원)\n\n2. 전일 대비는 소숫점 2번째 자리까지만 반올림해서 보이게 해줍시다. (1.508123% -> 1.51%)\n\n3. 현재가와 전일대비의 등락을 `+`, `-` 기호를 이용해 표시해주고 싶네요.\n\n4. 아까 말했던 전일대비의 xxx백만 단위를 xxx억 단위로 바꾸도록 하겠습니다.\n\n5. 마지막으로 마켓 심볼 부분의 KRW이 중복되지 않도록 정리해줍시다.\n\n1번은 간단합니다. 자바스크립트 숫자형 타입에 대해 기본적으로 제공되는 `toLocaleString()` 함수를 사용하면 됩니다.\n\n```js\nfunction GetCurrency(value) {\n  return Number(value).toLocaleString();\n}\n```\n\n2번 또한 간단합니다. 자바스크립트 숫자형 타입에 대해 기본적으로 제공되는 `toFixed()` 함수를 사용하면 됩니다. 파라미터는 소숫점을 제한하고 싶은 값을 넣어주면 됩니다. 이 경우에는 2를 넣어주면 되겠죠.\n\n```js\nfunction GetChangeRate(value) {\n  return Number(value).toFixed(2);\n}\n```\n\n3번의 경우에는 API를 받아서 데이터를 가공하는 부분을 조금 수정할 필요가 있습니다. 이유는 현재 등락을 `RISE`, `FALL` 이라는 문자열을 별개로 응답하고 있기 때문입니다. 이러저래 깔끔하게 하려고 시도를 많이 했는데, 음수일 땐 `-` 값이 붙어있는 게 조작하긴 더 좋더라구요.\n\n```js\nonMounted(async () => {\n  try {\n    const markets = [];\n\n    const { data: symbols } = await GetSymbols();\n\n    symbols.forEach((symbol) => {\n      if (symbol.market.indexOf(\"KRW-\") === -1) {\n        return;\n      }\n\n      markets.push(symbol.market);\n      coins.value[symbol.market] = symbol;\n    });\n\n    const { data: tickers } = await GetTickers(markets);\n\n    tickers.forEach((ticker) => {\n\n      /** 수정된 부분 */\n      if (ticker.change === \"FALL\") {\n        ticker.trade_price = -ticker.trade_price;\n        ticker.change_price = -ticker.change_price;\n      }\n\n      ticker.change_rate = ticker.change_rate * 100;\n      /** 수정된 부분 끝 */\n\n      coins.value[ticker.market] = Object.assign(\n        coins.value[ticker.market],\n        ticker\n      );\n    });\n  } catch (err) {}\n});\n\u003C/script>\n```\n\n4번은 [`num-to-korean`](https://www.npmjs.com/package/num-to-korean) 패키지의 도움을 받도록 하겠습니다.\n\n```bash [bash]\nnpm i num-to-korean\n```\n\n```js\nfunction getVolume(volume) {\n  return numToKorean(Math.floor(volume / 100000000) * 100000000, \"mixed\");\n}\n```\n\n억 단위 아래 숫자는 필요 없으니 나눴다 소숫점 버려주고, 다시 곱해주면 되겠죠.\n\n이 코드들을 참고해서 만든 전체 결과물입니다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cnav class=\"p-4 text-[1.1rem] font-bold\">코인시세보기프로그램\u003C/nav>\n\n  \u003Cdiv>\n    \u003Ctable class=\"table w-full\">\n      \u003Cthead>\n        \u003Ctr class=\"border-b border-gray-100 text-sm text-gray-500\">\n          \u003Cth class=\"py-2 px-4 text-left\">한글명\u003C/th>\n          \u003Cth class=\"py-2 px-4 text-right\">현재가(원)\u003C/th>\n          \u003Cth class=\"py-2 px-4 text-right\">전일대비\u003C/th>\n          \u003Cth class=\"py-2 px-4 text-right\">거래대금\u003C/th>\n        \u003C/tr>\n      \u003C/thead>\n      \u003Ctbody class=\"text-gray-900\">\n        \u003Ctr v-for=\"(coin, key) in coins\" :key=\"key\">\n          \u003Ctd class=\"py-1 px-4 text-left\">\n            \u003Cdiv class=\"font-semibold text-gray-700\">\n              {{ coin.korean_name }}\n            \u003C/div>\n            \u003Cdiv class=\"text-sm text-gray-500\">{{ coin.market }}\u003C/div>\n          \u003C/td>\n          \u003Ctd class=\"py-1 px-4 text-right font-semibold\">\n            {{ GetRatePrefix(coin) }}{{ GetCurrency(coin.trade_price) }}\n          \u003C/td>\n          \u003Ctd class=\"py-1 px-4 text-right\">\n            \u003Cdiv class=\"font-semibold\">\n              {{ GetRatePrefix(coin) }}{{ GetChangeRate(coin.change_rate) }}%\n            \u003C/div>\n            \u003Cdiv class=\"text-sm text-gray-500\">\n              {{ GetRatePrefix(coin) }}{{ GetCurrency(coin.change_price) }}\n            \u003C/div>\n          \u003C/td>\n          \u003Ctd class=\"py-1 px-4 text-right font-semibold text-gray-500\">\n            {{ getVolume(coin.acc_trade_price_24h) }}\n          \u003C/td>\n        \u003C/tr>\n      \u003C/tbody>\n    \u003C/table>\n  \u003C/div>\n\n  \u003Cfooter class=\"font-[0.9rem] border-t border-gray-300 p-4 text-gray-600\">\n    peterkimzz.com\n  \u003C/footer>\n\u003C/template>\n\n\u003Cscript setup>\nimport { onMounted, ref } from \"vue\";\nimport axios from \"axios\";\nimport { numToKorean } from \"num-to-korean\";\n\nconst coins = ref({});\n\n/** Utilities */\nfunction GetCurrency(price) {\n  return Number(price).toLocaleString();\n}\nfunction GetRatePrefix(coin) {\n  switch (coin.change) {\n    case \"RISE\":\n      return \"+\";\n    default:\n      return \"\";\n  }\n}\nfunction GetChangeRate(rate) {\n  return Number(rate).toFixed(2);\n}\nfunction getVolume(volume) {\n  return numToKorean(Math.floor(volume / 100000000) * 100000000, \"mixed\");\n}\n\n/** Upbit APIs */\nfunction GetSymbols() {\n  return axios.get(\"https://api.upbit.com/v1/market/all\");\n}\nfunction GetTickers(markets = []) {\n  return axios.get(\"https://api.upbit.com/v1/ticker\", {\n    params: { markets: markets.join(\",\") },\n  });\n}\n\n/**  페이지가 로드되면 자동으로 호출되는 Vue.js의 사전 정의 함수 */\nonMounted(async () => {\n  try {\n    const markets = [];\n\n    const { data: symbols } = await GetSymbols();\n\n    symbols.forEach((symbol) => {\n      /** `KRW-` 으로 시작하는 마켓만 핕터링 */\n      if (symbol.market.indexOf(\"KRW-\") === -1) {\n        return;\n      }\n\n      markets.push(symbol.market);\n      coins.value[symbol.market] = symbol;\n    });\n\n    const { data: tickers } = await GetTickers(markets);\n\n    tickers.forEach((ticker) => {\n      if (ticker.change === \"FALL\") {\n        ticker.trade_price = -ticker.trade_price;\n        ticker.change_price = -ticker.change_price;\n      }\n\n      ticker.change_rate = ticker.change_rate * 100;\n\n      coins.value[ticker.market] = Object.assign(\n        coins.value[ticker.market],\n        ticker\n      );\n    });\n  } catch (err) {}\n});\n\u003C/script>\n```\n\n![image](https://user-images.githubusercontent.com/20244536/129551073-407c021c-a542-49cd-9c17-f66dd3b42da9.png)\n\n아까랑 비교해보니 훨씬 낫네요.\n\n## 색 스타일링\n\n스타일 부분도 조금 더 개선하고 싶은 점이 있습니다. 등락을 색으로 표시해주면 더 직관적일 것 같습니다.\n\n```js\nfunction GetColor(change) {\n  switch (change) {\n    case \"RISE\":\n      return \"text-red-600\";\n    case \"FALL\":\n      return \"text-blue-600\";\n    default:\n      return \"text-gray-900\";\n  }\n}\n```\n\n```html\n\u003Ctd :class=\"[GetColor(coin), 'py-1 px-4 text-right font-semibold align-top']\">\n  {{ GetRatePrefix(coin) }}{{ GetCurrency(coin.trade_price) }}\n\u003C/td>\n\u003Ctd :class=\"[GetColor(coin), 'py-1 px-4 text-right']\">\n  \u003Cdiv class=\"font-semibold\">\n    {{ GetRatePrefix(coin) }}{{ GetChangeRate(coin.change_rate) }}%\n  \u003C/div>\n  \u003Cdiv class=\"text-sm text-gray-500\">\n    {{ GetRatePrefix(coin) }}{{ GetCurrency(coin.change_price) }}\n  \u003C/div>\n\u003C/td>\n```\n\n`v-bind` 를 이용해서 html `class` 태그에 js 함수를 적용하는 부분입니다. 이런 방식을 활용해 동적으로 클래스를 계속 바꿔줄 수도 있겠죠.\n\n![image](https://user-images.githubusercontent.com/20244536/129552175-d678c3cf-01d8-42ac-81b4-70444ff93dff.png)\n\n## 웹 소켓\n\n실시간 데이터를 받아올 때는 HTTP 요청으로는 사실 한계가 있습니다. 뭐 매 초 HTTP 요청을 날려서 데이터를 가져올 순 있겠지만, 주식이나 코인 거래처럼 파바바박 시세가 바뀌게끔 구현하긴 어렵습니다. 한 꺼번에 숫자들이 바뀔테니까요.\n\n그래서 **웹 소켓**이라는 기술이 필요합니다.\n\nHTTP는 요청, 응답 1개의 프로세스로 이루어지고 연결이 끊어집니다. 그래서 구조상 매 요청이 독립적이게 됩니다. 즉, 요청 10번이 필요할 때 무조건 10번의 호출이 필요하게 됩니다. 어떻게 보면 비효율적이죠.\n\n반면 웹 소켓은 최초 한 번 연결을 해두면, 그 이후에 연결이 끊어지기 전까지 서로 몇 번이고 요청/응답을 주고받을 수 있습니다. 실시간 데이터를 전송/수신하기엔 안성맞춤입니다.\n\n다행히 업비트에서도 웹소켓 API를 제공하고 있습니다.\n\n프로세스는 간단합니다.\n\n1. 소켓 연결 준비하기 (`Open`)\n2. 업비트 소켓 서버에 연결 요청 보내기 (`Send`)\n3. 연결 후 데이터 수신하기 (`OnMessage`)\n\n끝입니다. 간단하죠?\n\n일단 좀 더 깔끔한 코드 작성을 위해 기존 코드를 조금 수정해주세요.\n\n```vue\n\u003Cscript setup>\nimport { onMounted, ref } from \"vue\";\nimport axios from \"axios\";\nimport { numToKorean } from \"num-to-korean\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nconst coins = ref({});\n\n/** 이 부분 추가 */\nconst markets = ref([]);\n\nonMounted(async () => {\n  try {\n    const { data: symbols } = await GetSymbols();\n\n    symbols.forEach((symbol) => {\n      if (symbol.market.indexOf(\"KRW-\") === -1) {\n        return;\n      }\n\n      /** 이 부분 수정 */\n      markets.value.push(symbol.market);\n\n      coins.value[symbol.market] = symbol;\n    });\n  } catch (err) {}\n});\n\u003C/script>\n```\n\n`markets` 변수를 전역으로 사용할 수 있도록 밖에 선언해주고, `KRW-` 코인을 필터링해서 전역 `markets` 변수에 넣어주었습니다.\n\n자 다음은 웹 소켓 부분입니다.\n\n```vue\n\u003Cscript setup>\nimport { v4 as uuidv4 } from \"uuid\";\n\nonMounted(async () => {\n  try {\n    /** 생략 */\n\n    const ws = new WebSocket(\"wss://api.upbit.com/websocket/v1\");\n\n    ws.onopen = (e) => {\n      ws.send(\n        `${JSON.stringify([\n          { ticket: uuidv4() },\n          { type: \"ticker\", codes: markets.value },\n        ])}`\n      );\n    };\n\n    ws.onmessage = async (payload) => {\n      const ticker = await new Response(payload.data).json();\n\n      if (!coins.value[ticker.code]) {\n        return;\n      }\n\n      if (ticker.change === \"FALL\") {\n        coins.value[ticker.code].trade_price = -ticker.trade_price;\n        coins.value[ticker.code].change_rate = -ticker.change_rate;\n        coins.value[ticker.code].change_price = -ticker.change_price;\n      } else {\n        coins.value[ticker.code].trade_price = ticker.trade_price;\n        coins.value[ticker.code].change_rate = ticker.change_rate;\n        coins.value[ticker.code].change_price = ticker.change_price;\n      }\n    };\n  } catch (err) {}\n});\n\u003C/script>\n```\n\n브라우저 환경에서의 예제는 문서에도 없길래 직접 작성해보았습니다. 핵심은 응답 받는 데이터를 `Response` 객체로 받아서 `json` 포맷으로 만들어주는 부분입니다.\n\n아까 말한 것 처럼 소켓을 열고 요청을 보내고, 성공하면 `onMessage` 부분에 업비트 웹소켓 서버에서 보내는 데이터를 계속해서 수신하게 됩니다.\n\n자세한 API 사용법은 [여기](https://docs.upbit.com/docs/upbit-quotation-websocket)를 확인해주세요.\n\n![Animation](https://user-images.githubusercontent.com/20244536/129555612-e6ee2237-701e-492a-a528-ade9b17cc307.gif)\n\n와! 저희가 만든 앱이 100% 실시간 업비트 데이터를 보여주고 있네요.\n\n## 크롬 확장 프로그램에서 실행하기\n\n고생하셨습니다. 이제 크롬 확장 프로그램으로 동작하는지만 확인하면 끝입니다.\n\n다만, 지금 상태로 크롬 확장 프로그램으로 실행시키면 좌우 너비가 안맞을 가능성이 높습니다. 지정해주지 않았기 때문이죠. 그래서 마지막으로 좌우 넓이를 조정하고 테스트해보도록 하겠습니다.\n\n`App.vue`의 코드 전체를 `div`로 감싸고, `width`의 최소 너비를 `30rem`으로 주도록 하겠습니다.\n\n```vue [App.vue]\n\u003Ctemplate>\n  \u003Cdiv class=\"min-w-[30rem]\">// ...\u003C/div>\n\u003C/template>\n```\n\n이제 끝입니다. 빌드를 해줍시다.\n\n```bash bash\nnpm run build\n```\n\n다음은 브라우저의 확장 프로그램 관리 페이지로 이동해서 개발자 모드를 켜주시고, 우리가 빌드한 결과물인 `dist` 폴더를 `압축해제 된 확장앱 설치` 버튼을 이용해 로드해주고 실행시켜주세요.\n\n![Animation1](https://user-images.githubusercontent.com/20244536/129556666-875cf900-63e9-4489-a5f9-8a77614d9590.gif)\n\n## 마무리\n\n3부가 너무 길어서 4부까지 갈까 하다가 그냥 3부로 마무리를 했습니다.\n\n이 시리즈를 보면서 이해가 안되셨어도 괜찮습니다. 코드가 200줄도 안되는 간단한 앱이지만, 생각보다 어려운 개념들이 많이 들어가있습니다.\n\n그리고 약간의 버그도 있습니다. (+ 에서 -되면 색이 안변한다든지)\n\n그치만 진행 중 모르는 부분을 구글링하면서 조금씩이라도 이해하셨다면 굉장히 많은 부분을 경험하신겁니다. 진짜로요.\n\n하다가 잘 안되는 부분은 댓글 남겨주세요.\n\n찐막으로 제가 실제 배포해서 운영 중인 코인 시세 모니터링 프로그램인 `브리아나` 소개해드리면서 이번 시리즈 마무리하도록 하겠습니다.\n\n[브리아나 - 1초만에 코인 시세 확인하기](https://store.whale.naver.com/detail/hkipiplimgkfnhbimapmehjohnialiec)\n![image](https://user-images.githubusercontent.com/20244536/129557679-d4986353-e84f-425c-887a-a43e3734bfad.png)\n\n여러분들도 멋진 확장 프로그램 만드시길 바랍니다.\n",{"path":1149,"title":1150,"description":1151,"created":1152,"category":1069,"rawbody":1153},"/super-easy-docker","정말 너무 쉬운 Docker","우리가 Docker를 사용해야하는 가장 큰 이유는, 어떤 컴퓨터에서든 똑같은 개발 환경을 보장해주기 떄문입니다. 로컬 컴퓨터에서 열심히 개발하고 AWS에 코드를 올렸는데, 에러를 마주하며 스트레스를 받았던 경험이 한 번쯤은 있을겁니다. 내 컴퓨터랑 클라우드 컴퓨터의 환경이 100% 똑같지 않기 때문이죠. 근데 이 어려움을 한 번에 해결해준다? 쓰지 말아야 할 이유가 없습니다.","2021-06-09","---\ncategory: tech\ntitle: 정말 너무 쉬운 Docker\nupdated: 2021-06-09\ncreated: 2021-06-09\nimage: https://dynamisign.com/api/sign?t=%EC%A0%95%EB%A7%90%20%EB%84%88%EB%AC%B4%20%EC%89%AC%EC%9A%B4%20Docker&d=peterkimzz.com\npublished: true\n---\n\n우리가 [`Docker`](https://docker.com)를 사용해야하는 가장 큰 이유는, 어떤 컴퓨터에서든 똑같은 개발 환경을 보장해주기 떄문입니다. 로컬 컴퓨터에서 열심히 개발하고 `AWS`에 코드를 올렸는데, 에러를 마주하며 스트레스를 받았던 경험이 한 번쯤은 있을겁니다. 내 컴퓨터랑 클라우드 컴퓨터의 환경이 100% 똑같지 않기 때문이죠. 근데 이 어려움을 한 번에 해결해준다? 쓰지 말아야 할 이유가 없습니다.\n\n\u003C!--more-->\n\n이 뿐만이 아닙니다. 내 앱을 실행하기 위한 모든 과정을 미리 패키징해두면 몇 초만에 앱 수 십개를 구동할 수도 있습니다. 서비스를 확장할수록 더 많은 인스턴스를 운영해야하기 때문에 반드시 도커를 사용해야 할 시점이 오게 됩니다.\n\n이번 포스팅에서는 `node.js`와 `typescript`를 사용하는 아주 간단한 서버를 도커라이징하고, 도커를 더 쉽고 효율적으로 사용하는 몇 가지 팁들을 알려드리겠습니다.\n\n## Node.js 서버 만들기\n\n일단 프로젝트 폴더를 구성합니다.\n\n```bash [shell]\n$ mkdir node-docker\n$ cd node-docker\n\n$ yarn init -y # package.json 만들기\n```\n\n다음은 노드 서버를 돌리기 위한 패키지를 설치하고, 앱의 진입점이 될 타입스크립트 파일까지 만들어줍니다.\n\n```bash [shell]\n$ yarn add express\n$ yarn add -D typescript ts-node nodemon @types/node @types/express\n\n$ touch index.ts\n$ npx tsc --init # tsconfig.json 만들기\n```\n\n여기까지 하면 우리 프로젝트 폴더의 구조는 다음과 같습니다.\n\n```\nnode-docker/\n|-- node_modules\n|-- index.ts\n|-- package.json\n|-- tsconfig.json\n|-- yarn.lock\n```\n\n`express`는 노드에서 가장 많이 사용하는 서버 라이브러리입니다. 그럼 바로 스크립트를 작성하도록 합니다.\n\n```ts [index.ts]\nimport express from \"express\";\nconst app = express();\n\napp.get(\"/\", (req, res) => {\n  res.json({ message: \"Hello, Docker!\" });\n});\n\napp.listen(3000);\nconsole.log(\"http://localhost:3000..\");\n```\n\n서버에 요청할 때 마다 `Hello, Docker!`를 응답하는 간단한 서버입니다.\n\n서버 코드를 작성했으니 앱을 실행시켜야겠죠. `package.json` 파일을 수정합니다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"start\": \"node index.js\",\n    \"dev\": \"nodemon -L --exec ts-node index.ts\",\n    \"build\": \"tsc\"\n  }\n}\n```\n\n터미널에 `yarn dev`를 입력하고, 브라우저를 통해 `localhost:3000`으로 접속해보면 메세지를 응답 받을 수 있습니다.\n\n추가로 개발 환경 때 시간 단축을 위해 `nodemon` 패키지를 사용합니다. `-L` (Legacy Watch) 플래그를 넣는 이유는 WindowsOS에서 `ts` 파일을 수정할 때 인식이 안되는 버그가 있어서 그렇습니다.\n\n![image](https://user-images.githubusercontent.com/20244536/121014877-69af7500-c7d5-11eb-814c-dcbc963acdff.png)\n\n## Dockerfile\n\n도커를 사용하기 위해 사용자 컴퓨터에 도커를 설치해야하는데, [도커 홈페이지](https://www.docker.com/get-started)에서 `Docker Desktop`을 다운로드 해주세요.\n\n잘 설치했다면 터미널에서 `docker` 명령어를 사용할 수 있습니다.\n\n```\n$ docker -v\nDocker version 20.10.6, build 370c289\n```\n\n자 그럼 도커를 사용하기 위해 `Dockerfile` 을 작성해야 합니다. 별 거 없습니다. 그냥 어떤 순서로 앱을 패키징할지 나열하는 것 뿐입니다.\n\n프로젝트 루트 폴더에 도커파일을 만들어주세요. (대문자 `D` 오타 아닙니다)\n\n```bash\n$ touch Dockerfile\n```\n\n그럼 node 앱을 돌리기 위한 간단한 도커파일을 작성합니다.\n\n```dockerfile [Dockerfile]\n\n# 어떤 환경에서 도커 이미지를 만들지 결정하기.\nFROM node:14-slim\n\n# 도커 컨테이너 내부의 작업 디렉토리 결정하기. 원하는 대로 정하면 됩니다.\nWORKDIR /usr/src/app\n\n# 외부 패키지 설치를 위해 package.json과 yarn.lock 파일 복사\nCOPY package.json .\nCOPY yarn.lock .\n\n# 패키지 설치\nRUN yarn\n\n# 나머지 모두 복사\nCOPY . .\n\n# 도커 컨테이너에 접근할 수 있게 포트 열어주기\nEXPOSE 3000\n\n# 앱 실행시키기\nCMD [ \"yarn\", \"dev\" ]\n```\n\n참고로 도커 파일을 통해 패키징한 결과물을 **이미지**라고 합니다. 그리고 이 이미지를 저장하는 곳은 **레지스트리** 라고 합니다. 우리가 깃 프로젝트를 깃허브 리파지토리에 올리는 것과 비슷한 겁니다. 그래서 이 이미지들도 `Docker Hub`나 `AWS ECR` 같은 원격 레지스트리에 저장시켜서 사용합니다.\n\n아무튼 `Dockerfile` 작성에 대한 방법은, 그냥 받아들이면 됩니다. 한 가지 `COPY` 명령어가 직관적으로 이해가 안갈 수 있습니다.\n\n`COPY A B` 이런식으로 사용하면 되고, A가 내 컴퓨터 쪽, B가 도커 컨테이너 쪽입니다. A를 B로 복사한다는 뜻입니다.\n\n이제 이 도커파일을 이용해 이미지를 만들어봅시다. 터미널에 입력해주세요.\n\n```bash [shell]\n$ docker build . -t node_app\n\n[+] Building 11.6s (12/12) FINISHED\n => [internal] load build definition from Dockerfile                                                       0.0s\n => => transferring dockerfile: 187B                                                                       0.0s\n => [internal] load .dockerignore                                                                          0.0s\n => => transferring context: 2B                                                                            0.0s\n => [internal] load metadata for docker.io/library/node:14-slim                                            2.2s\n => [auth] library/node:pull token for registry-1.docker.io                                                0.0s\n => CACHED [1/6] FROM docker.io/library/node:14-slim@sha256:a3ff0656dfa88cc5c4092af3e18d16cbbbf50417ce4d0  0.0s\n => [internal] load build context                                                                          1.3s\n => => transferring context: 1.07MB                                                                        1.1s\n => [2/6] WORKDIR /usr/src/app                                                                             0.0s\n => [3/6] COPY package.json .                                                                              1.1s\n => [4/6] COPY yarn.lock .                                                                                 0.0s\n => [5/6] RUN yarn                                                                                         5.1s\n => [6/6] COPY . .                                                                                         0.9s\n => exporting to image                                                                                     0.9s\n => => exporting layers                                                                                    0.9s\n => => writing image sha256:33c768313fd785507812a137e90fdf97f629edd91d06851846ba416df6a62277               0.0s\n => => naming to docker.io/library/node_app                                                                0.0s\n```\n\n한 가지 알아둘 부분은, `-t` 는 태그를 지정한다는 뜻입니다. 지정하지 않으면 이름이 `NONE` 으로 지정되면서 사용하는 데 불편하므로 태깅을 잘 해주세요.\n\n아래는 빌드된 이미지를 확인하는 방법입니다.\n\n```bash [shell]\n$ docker images\n\nREPOSITORY   TAG       IMAGE ID       CREATED              SIZE\nnode_app     latest    33c768313fd7   About a minute ago   382MB\n```\n\n빌드된 이미지를 실행시켜봐야겠죠. 아래 명령어를 입력해주세요.\n\n```bash [shell]\n$ docker run -p 3000:3000 node_app\n\n# 이렇게 컨테이너를 여러 개 실행시킬 수도 있음. 포트 바꿔서 들어가보세요.\n$ docker run -p 3001:3000 node_app\n$ docker run -p 3002:3000 node_app\n```\n\n브라우저를 통해 접속이 된다면 성공입니다. 이렇게 빌드된 이미지를 실행시키면, 그걸 **컨테이너**라고 부릅니다.\n\n현재 실행 중인 모든 컨테이너 목록을 보고 싶으면 아래 명령어를 입력해주세요.\n\n```bash [shell]\n$ docker ps -a\n\nCONTAINER ID   IMAGE      COMMAND                  CREATED          STATUS          PORTS                                       NAMES\n36f74a13d90d   node_app   \"docker-entrypoint.s…\"   12 seconds ago   Up 10 seconds   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp   upbeat_blackburn\n```\n\n쓱 한 번 보고 넘어가세요. `STATUS` 부분이 Up이라고 되어있으면 앱이 돌아간다는 뜻이니까, 컨테이너를 삭제하고 싶다면 `CONTAINER ID`를 이용해 삭제해줍시다.\n\n```bash [shell]\n$ docker rm -f 36f74a13d90d\n```\n\n## Docker Compose\n\n지금까지 `Docker CLI`를 이용해서 이것 저것 해보았습니다만, 저는 CLI로 docker를 사용하는 것을 추천하지 않습니다. 도커 특성상 많은 옵션을 주어야 하는데 언제 일일히 치고 있나요. 명령어가 길어져서 보기 안좋습니다.\n\n그리고 지금은 이미지 1개만 띄우니까 괜찮지만, 나중에는 몇 개를 띄워야 할 지 모릅니다. 그 때 마다 일일히 이미지 하나씩 올리는 건 상당히 귀찮은 일이겠죠.\n\n그래서 여러 이미지를 한 번에 관리할 수 있게끔 개발된 게 `Docker Compose` 입니다. 바로 예제를 보도록 하죠.\n\n```yaml [docker-compose.yml]\nversion: \"3.9\"\n\nservices:\n  app: # 이미지 이름 (마음대로 설정해도 됩니다)\n    build: . # Dockerfile이 있는 경로를 넣어주기\n    ports:\n      - \"3000:3000\" # docker CLI의 \"-p 3000:3000\" 과 같은 표현\n```\n\n프로젝트 루트 디렉토리에 `docker-compose.yml` 파일을 만들어 주고 터미널에 `docker compose up` 을 입력하면 정상적으로 컨테이너가 생성되고, 로컬호스트로 접근이 가능해집니다.\n\n> docker compose CLI는 이제 docker CLI에서 제공됩니다. `docker-compose CMD` 대신 `docker compose CMD` 를 사용해주세요.\n\n그리고 여러 개의 컨테이너를 한 꺼번에 띄우고 싶다면 이런식으로 하면 됩니다.\n\n```yaml [docker-compose.yml]\nversion: \"3.9\"\n\nservices:\n  app1:\n    build: .\n    ports:\n      - \"3000:3000\"\n\n  app2:\n    build: .\n    ports:\n      - \"3001:3000\"\n\n  app3:\n    build: .\n    ports:\n      - \"3002:3000\"\n\n  app4:\n    build: .\n    ports:\n      - \"3003:3000\"\n```\n\n이러고 3000 ~ 3003 포트까지 들어가보면 잘 접속됩니다.\n\n![image](https://user-images.githubusercontent.com/20244536/121302145-359e9600-c934-11eb-8a32-2aa5c323945a.png)\n_이미지가 잘 안보이는데, 아무튼 4개 앱이 각자 다른 포트로 열렸다는 뜻_\n\n도커 컴포즈는 일반 도커 명령어와 다르게, 터미널에서 작업을 종료하면 그대로 컨테이너들이 모두 비활성화됩니다. 백그라운드에서도 계속 실행시키고 싶다면 `docker compose up -d` 명령어를 사용해주세요.\n\n그리고 백그라운드에서 실행된 컨테이너들을 한 번에 지우려면 `docker compose down`을 하면 됩니다. (다른 디렉토리에서 하면 당연히 안됩니다..)\n\n## 프로젝트 구조 개선하기\n\n여기까지 간단하게 `Docker` 에 대해 살펴봤습니다. 근데 사실 지금 상태로 앱을 개발하고, 배포하기엔 몇 가지 문제가 있습니다.\n\n첫 번째는 **핫 리로딩**입니다. 도커 컨테이너로 개발을 하는 경우, 코드를 수정할 때 마다 개발 서버가 다시 시작되지 않습니다. 이유는 우리가 수정하는 코드는 로컬 컴퓨터의 코드지, 컨테이너 안의 코드가 아니기 때문입니다.\n\n두 번째는 **배포**입니다. 일반적으로 배포용 앱은 `webpack` 같은 번들링 도구를 이용해 코드를 변형하거나 압축시키는 작업을 하게 됩니다. 그렇게 하기 위해서는 배포용 `Dockerfile` 이 필요합니다.\n\n### 핫 리로딩 개선하기\n\n해결 방법은 간단합니다. 내 로컬 컴퓨터와 컨테이너의 **저장 공간**을 공유하면 됩니다. 로컬 코드를 수정하면 바로 컨테이너 안의 코드도 같이 수정이 되는거죠.\n\n```yaml [docker-compose.yml]\nversion: \"3.9\"\n\nservices:\n  app:\n    build: .\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - \".:/usr/src/api\" # Dockerfile의 WORKDIR와 맞추기\n      - \"/usr/src/api/node_modules\" # 핫 리로드 성능 개선\n```\n\n이렇게 하고 `docker compose up --build` 명령어로 새로 빌드하면서 컨테이너를 띄워주고, 코드를 수정하면 바로 서버가 다시 시작하게 됩니다.\n\n### 개발용, 배포용 이미지 분리하기\n\n일단 기존 도커파일은 개발용이었으니 파일 이름을 `Dockerfile.dev` 로 변경해주고, 배포용 파일인 `Dockerfile` 을 새로 만들어주세요.\n\n배포용 도커 파일은 이렇게 작성합니다.\n\n```dockerfile [Dockerfile]\nFROM node:14-slim\n\nWORKDIR /usr/src/app\n\nCOPY package.json .\nCOPY yarn.lock .\n\nRUN yarn\n\nCOPY . .\n\nRUN yarn build # 빌드하는 부분 추가\n\nEXPOSE 3000\n\nCMD [ \"yarn\", \"start\" ] # `yarn dev`에서 `yarn start`로 변경\n```\n\n그리고 기존 `docker-compose.yml` 도 이름을 `docker-compose.dev.yml` 로 바꾸고, 새로운 컴포즈 파일을 만들고 아래 내용을 작성합니다.\n\n```yaml [docker-compose.yml]\nversion: \"3.9\"\n\nservices:\n  app:\n    build: .\n    ports:\n      - \"80:3000\"\n```\n\n물론 배포용 앱을 웹서버 없이 그냥 올리는 사람은 없을겁니다. `nginx` 같은 웹서버로 프록시를 해줘야 하지만, 여기서 다루면 또 내용이 비대해지기 때문에, 일단은 대충 이렇게 한다는 걸 알아두시면 되겠습니다.\n\n개발용인 `Dockerifle.dev` 와 `docker-compose.dev.yml` 은 이렇게 사용하면 됩니다.\n\n도커파일은 똑같고, 컴포즈 파일은 조금 수정이 필요합니다.\n\n```yaml [docker-compose.dev.yml]\nversion: \"3.9\"\n\nservices:\n  app:\n    build:\n      context: .\n      dockerfile: Dockerfile.dev\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - \".:/usr/src/app\"\n      - \"/usr/src/app/node_modules\"\n```\n\n`build` 부분이 조금 바뀌는데, 개발 때는 `Dockerfile.dev` 을 읽도록 바꿔주었습니다.\n\n`CLI`로 실행할 땐 `-f` 플래그를 이용하면 됩니다.\n\n```bash [shell]\n$ docker compose -f docker-compose.dev.yml up --build\n```\n\n### 참고\n\n- [Node.js 웹 앱의 도커라이징 - nodejs.org](https://nodejs.org/ko/docs/guides/nodejs-docker-webapp/)\n- [Compose file - docs.docker.com](https://docs.docker.com/compose/compose-file/)\n",{"path":1155,"title":1156,"description":1157,"created":1158,"category":1069,"rawbody":1159},"/vuejs-chrome-extension-2","Vue.js로 크롬 확장 프로그램 만들기 강의 - 2부","이전 포스팅에서 index.html과 manifest.json 파일을 이용해서 확장 프로그램을 개발자 모드로 실행시키는 것 까지 진행했습니다.","2021-05-13","---\ncategory: tech\ntitle: Vue.js로 크롬 확장 프로그램 만들기 강의 - 2부\nupdated: 2021-05-13\ncreated: 2021-05-13\nimage: https://dynamisign.com/api/sign?d=peterkimzz.com&t=Vue.js%EB%A1%9C%20%ED%81%AC%EB%A1%AC%20%ED%99%95%EC%9E%A5%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EB%A7%8C%EB%93%A4%EA%B8%B0%20%EA%B0%95%EC%9D%98%20-%202%EB%B6%80\npublished: true\n---\n\n[이전 포스팅](/vuejs-chrome-extension-1)에서 `index.html`과 `manifest.json` 파일을 이용해서 확장 프로그램을 개발자 모드로 실행시키는 것 까지 진행했습니다.\n\n이번 포스팅에서는 Vue.js와 Vite을 사용해서 프로젝트를 재설계해보도록 하겠습니다.\n\n## Vue.js\n\n[Vue.js](https://v3.vuejs.org/)는 웹사이트를 만들기 위해 고안된 프레임워크입니다. 그냥 순수 HTML을 작성하는 것 보다 개발자에게 훨씬 더 많은 이점을 가져다주기 때문에 사용합니다.\n\n\u003C!--more-->\n\n> Vue.js: The Progressive JavaScript Framework\n\n이 Vue.js를 프로젝트에 제대로 구현하려면 사실 프론트엔드 지식이 많이 필요합니다. `Webpack`이나 `Rollup`같은 번들러 사용법과, 자바스크립트 모듈 시스템에 대한 이해가 필요하기 때문입니다.\n\n하지만 걱정마세요. 우리는 남들이 만든 좋은 툴을 그저 가져다 사용하면 됩니다. 물론 나중에는 이게 왜 작동하는지 내부 구조나 기술들을 알고 계셔야 합니다만, 아마 아주 나중에 저절로 관심이 생기실테니 그 때 공부해보시길 바랍니다.\n\n제가 소개할 남들이 만든 툴은 바로 [Vite](https://vitejs.dev/) 입니다.\n\n> Vite: Next Generation Frontend Tooling\n\n문서에 따르면 빗 이라고 읽으면 된답니다. 불어로 `빠른` 이라는 뜻입니다.\n\nVite을 사용하면 복잡한 설정들을 직접 구현할 필요가 없습니다. 그리고 Vue 만을 위한 툴은 아니고, **React**나 **Svelte**같은 다른 프레임워크도 모두 제공합니다.\n\n## 프로젝트 새로 구성하기\n\n일단 기존에 만들었던 `vue-extension` 디렉토리는 지워주세요. vite를 이용해 새로 구성할겁니다.\n\n```bash\n$ npm init @vitejs/app vue-extension\n```\n\n터미널이 몇 가지 물어볼텐데, `vue`와 `javascript`를 선택해주세요.\n\n정상적으로 진행이 되었다면 해당 디렉토리로 이동 후, 패키지를 설치해줍니다.\n\n```bash\n$ cd vue-extension\n$ npm install\n$ npm run dev\n```\n\n이렇게 까지 하면 Vite이 `http://localhost:3000` 주소로 개발 서버를 열어줍겁니다. 브라우저를 통해 접속해보세요.\n\n![](https://user-images.githubusercontent.com/20244536/118081940-48867f00-b3f7-11eb-860e-f74ab318f7e5.png)\n\n## 프로젝트 구조\n\n자바스크립트 프로젝트라면 반드시 필요한 파일이 있습니다. 바로 `package.json` 입니다. 이 파일은 현재 이 디렉토리가 자바스크립트 프로젝트라는 걸 의미합니다. 또한 이 파일에 프로젝트 이름이나 버전, 외부 패키지 이름들을 적어줄 수 있습니다.\n\n그럼 Vite이 만들어 준 `package.json` 파일을 살펴보죠.\n\n```json[package.json]\n{\n  \"name\": \"vue-extension\",\n  \"version\": \"0.0.0\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"serve\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"vue\": \"^3.0.5\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-vue\": \"^1.2.2\",\n    \"@vue/compiler-sfc\": \"^3.0.5\",\n    \"vite\": \"^2.3.0\"\n  }\n}\n```\n\n이 `package.json` 파일을 제대로 설명하기 위해 또 다른 포스팅이 필요할 정도로 알아야 할 내용이 굉장히 많습니다만, 일단은 이 프로젝트를 진행하기 위해 알아야할 부분만 간단하게 설명하겠습니다.\n\n1. `name`: 프로젝트 이름입니다. 외부로 배포하지 않는다면 무시해도 됩니다.\n2. `version`: 프로젝트 버전입니다. 외부로 배포하지 않는다면 무시해도 됩니다.\n3. `scripts`: 커맨드라인 명령어를 정의할 수 있습니다. 예를 들어 `dev`의 경우 `vite`라는 값이 정의되어있는데, 터미널에 `npm run dev` 이라는 명령어를 입력하면 대신 `vite`를 실행하게 됩니다.\n4. `dependencies`: 외부 패키지들을 목록입니다. 여기에 패키지들을 적고 `npm install` 명령어를 입력하면 프로젝트 루트 디렉토리에 `node_modules` 폴더가 생기고, 이 밑에 적어둔 모든 패키지가 설치됩니다. `dependencies`와 `devDependencies` 와의 차이는 일단 무시하세요.\n\n우리가 좀 더 알아야 할 부분은 `scripts` 부분입니다. 아래 내용은 `vite` 프로젝트에 대해서만 유효합니다.\n\n1. `npm run dev`: 개발 서버를 열어줍니다. 기본값은 `http://localhost:3000` 입니다.\n2. `npm run build`: 프로덕션 (배포)용 프로젝트를 위해 코드를 정제합니다. 그리고 그 결과물을 `dist` 폴더로 내보냅니다. 보통 이 작업을 **build** 라고 합니다.\n3. `npm run serve`: 빌드된 프로젝트를 실행시킵니다. 역시 서버가 열립니다. 기본값은 `http://localhost:5000` 입니다.\n\n패키지 목록을 보니, 현재 Vue 최신 버전인 `Vue 3`가 설치되어있습니다. 2버전과 3버전은 차이가 꽤 크고, 3 버전에서 정말 많은 개선이 이루어졌으니 혹시라도 `2.x` 이하의 버전을 사용하고 계셨던 분들이라면, 이번 기회에 `3.x` 버전을 사용해보세요.\n\n## Vite을 확장 프로그램으로 만들기\n\n간단합니다. 이전에 만들어두었던 `manifest.json` 파일을 `public` 폴더에 넣어주고 빌드하면 됩니다.\n\n`public` 폴더에 넣는 이유는 vite가 프로젝트를 빌드할 때 `public` 폴더에 있는 파일들의 코드는 건들지 않고 바로 `dist` 폴더로 옮겨주기 때문입니다.\n\n```\n$ touch public/manifest.json\n$ npm run build\n```\n\n이렇게하면 `manifest.json` 파일이 포함된 결과물이 `dist` 폴더로 내보내집니다. 크롬 확장 프로그램 관리자 페이지에서 이 `dist`를 로드하면 정상적으로 확장 프로그램으로 작동하는 걸 확인할 수 있습니다.\n\n![](https://user-images.githubusercontent.com/20244536/118083782-7faa5f80-b3fa-11eb-944f-8c782d05a59f.png)\n\n하지만 여기서 큰 문제가 하나 있습니다.\n\n우리가 크롬 확장 프로그램을 개발할 때 계속해서 앱 화면을 확인해야하는데, 매번 `npm run build` 를 입력하면서 개발을 할 순 없겠죠. 엄청난 시간 낭비일겁니다.\n\n해결 방법은 간단합니다. `package.json`의 `script.build` 부분을 다음과 같이 수정해주세요.\n\n```json[package.json]\n{\n  \"scripts\": {\n    \"build\": \"vite build --watch\"\n  }\n}\n```\n\n이제 개발할 때 `npm run dev` 대신에 `npm run build` 를 실행하면, 개발 서버처럼 동작하되 코드를 수정할 때 마다 계속해서 빌드를 하게됩니다. 이렇게 하면 계속해서 build 명령어를 입력하지 않아도 되겠죠.\n\n근데 여기서 또 다른 문제가 하나 있다면, 일반 웹사이트 개발과는 다르게 크롬 확장 프로그램은 앱 특성상 코드 변경 후 화면을 확인 할 때 창을 껐다 켜야 변경사항을 확인할 수 있습니다.\n\n저는 그냥 껐다 켰다 하면서 개발하고 있지만, 이게 번거로우시다면 `npm run dev`를 이용해 `localhost`에서 개발하고 배포 전 테스트 할 때만 창으로 확인해도 상관없습니다. 편한 방법으로 개발하시면 되겠습니다.\n\n저는 브라우저에서 테스트하고 싶진 않으니 `scripts` 명령어를 크롬 확장 프로그램에 좀 더 맞게끔 수정하고, 결과물을 압축해주는 기능까지 추가해보록 하겠습니다.\n\n압축을 하기 위해서는 패키지를 하나 설치해야합니다. 설치 후 코드를 변경해주세요.\n\n```bash\n$ npm install -D bestzip\n```\n\n```json[package.json]\n{\n  \"scripts\": {\n    \"dev\": \"vite build --watch\"\n    \"build\": \"bestzip dist.zip dist/\"\n  }\n}\n```\n\n이제 평소 개발을 할 때는 `npm run dev`을 이용해 감시 모드로 빌드를 하고, 앱을 배포할 시기가 오면 `npm run build` 명령어를 이용해 `dist` 폴더를 `dist.zip` 파일로 만들어주면 됩니다.\n\n결과물을 압축하는 이유는, 이후 확장 프로그램을 스토어에 배포할 때 `.zip` 파일로 제출해야하기 때문입니다.\n\n## 2부 마무리\n\n2부 강의에서 `Vue`를 설치하긴 했지만, 막상 뷰 코드를 열어보지도 못했네요. 하지만 프로젝트 구조를 잘 잡아두었으니 이제 다음 강의에서는 코드 작성에 좀 더 치중해보도록 하겠습니다.\n",{"path":1161,"title":1162,"description":1163,"created":1164,"category":1069,"rawbody":1165},"/vuejs-chrome-extension-1","Vue.js로 크롬 확장 프로그램 만들기 강의 - 1부","제가 최근 우연히 크롬 확장 프로그램을 개발했는데, 이게 생각보다 꽤 괜찮은 시장이라는 걸 알게 되었습니다.","2021-03-28","---\ncategory: tech\ntitle: Vue.js로 크롬 확장 프로그램 만들기 강의 - 1부\nupdated: 2021-03-28\ncreated: 2021-03-28\nimage: https://dynamisign.com/api/sign?d=peterkimzz.com&t=Vue.js%EB%A1%9C%20%ED%81%AC%EB%A1%AC%20%ED%99%95%EC%9E%A5%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EB%A7%8C%EB%93%A4%EA%B8%B0%20%EA%B0%95%EC%9D%98%20-%201%EB%B6%80\npublished: true\n---\n\n제가 최근 우연히 크롬 확장 프로그램을 개발했는데, 이게 생각보다 꽤 괜찮은 시장이라는 걸 알게 되었습니다.\n\n크롬 확장 프로그램은 이미 여러분들에게 익숙한 웹 기술로 쉽게 개발할 수 있고, [React](https://reactjs.org/)와 [Vue](https://vuejs.org/)같은 SPA 방식의 프레임워크에 매우매우 잘 어울립니다. 심지어 최초 한 번만 5달러를 지불하면, 평생 무료로 앱 배포가 가능합니다.\n\n\u003C!--more-->\n\n때문에 이 강의를 계기로 더욱 많은 사람들이 확장 프로그램을 만들었으면 좋겠다는 취지로 이 강의를 만들게 되었습니다.\n\n저는 이 강의에서 등장하는 모든 기술들을 최대한 기초부터 다룰 예정입니다. 이 강의를 끝까지 잘 따라하신다면 이제 막 웹 개발을 시작하시는 분들에게 굉장히 많은 도움이 될겁니다.\n\n하지만 여기서 다루는 Vue나 [Webpack](https://webpack.js.org/) 같은 도구들이 완전 초보자용은 아니라서 많이 어려울 수 있습니다만, 최대한 쉽게 설명해보도록 하겠습니다.\n\n아 그리고 현재는 리눅스 명령어로만 강의를 진행하므로, WindowsOS 사용자 분들은 bash 커맨드를 입력할 수 있는 환경을 구성해주세요.\n\n## 프로젝트 기획하기\n\n본격적으로 코드를 작성하기에 앞서, 어떤 확장 프로그램을 만들지 기획을 먼저 해보도록 하겠습니다.\n\n만들고 싶은 앱을 정한 뒤에, 필요한 내용들을 하나씩 배워나가는 게 가장 학습 효과가 좋기 때문입니다. 뭐든지 목표가 있어야 열심히 하게 되니까요.\n\n그래서 뭘 만들어볼지 이것저것 고민을 많이 했는데, **가상화폐 시세 보는 앱**을 만들어볼까합니다.\n\n이유는 간단합니다. 무료로 데이터를 제공해주는 API가 있기 때문이죠. 공부할 땐 이게 최고입니다.\n\n## 사전 준비\n\n프로젝트를 구성하기 전에, 몇 가지 프로그램들을 여러분의 컴퓨터에 설치해야합니다.\n\n1. [Node.js](https://nodejs.org/ko/)가 설치되어 있어야 합니다. 현재 Long Term Support (LTS)인 14 버전 이상을 권장드립니다. Node.js를 정상적으로 설치하게 되면, 터미널에서 `node` 명령어와 `npm` 명령어를 사용할 수 있게 됩니다.\n\n2. 코드를 더욱 쉽게 작성하기 위한 코드 에디터가 필요합니다. 저는 [VSCode](https://code.visualstudio.com/)를 사용합니다.\n\n3. 확장 프로그램을 테스트 할 브라우저가 필요합니다. 저는 이 강의에서 [Chrome](https://www.google.com/intl/ko/chrome/) 브라우저를 사용하겠습니다.\n\n## 프로젝트 구성하기\n\n설치가 잘 되셨다면 프로젝트를 생성하고 싶은 곳에 폴더를 생성해줍시다. 저는 `vue-extension`으로 하겠습니다.\n\n`Terminal` 응용 프로그램을 켜서 아래 명령어를 입력해주세요.\n\n```bash[bash]\n$ mkdir vue-extension\n$ cd vue-extension\n$ npm init -y\n```\n\n> 참고로 각 줄 맨 앞에 `$` 기호는 실제로 입력하는 글자는 아닙니다. 그냥 해당 명령어가 bash 커맨드라는 걸 표기하기 위해 붙입니다.\n\n위 명령어들을 처음 보시더라도 당황하지 마세요. 설명해드리겠습니다.\n\n- `mkdir [폴더명]`: [폴더명]으로 폴더 생성\n- `cd [폴더명]`: [폴더명]으로 디렉토리 이동\n- `npm`: Node.js를 설치하면 자동으로 설치되는 노드 패키지 매니저입니다. 일단은 몰라도 됩니다.\n\n위 명령어들 모두 현재 디렉토리를 기준으로 작동하게 됩니다. 참고하세요.\n\n여기서 가장 마지막 `npm init`은 Node 프로젝트를 현재 디렉토리에 만들겠다는 뜻인데, 명령어를 입력해보면 몇 가지를 귀찮게 계속 물어보게 됩니다. 하지만 -y를 같이 넣어주면 그 질문들을 모두 yes로 하겠다는 뜻입니다.\n\n이렇게 구성하면 여러분들의 폴더 구조는 이렇습니다.\n\n```\n|- vue-extension/\n|-- package.json\n```\n\n일단 package.json에 대해 알아보기 전에, 우리에게 UI를 보여줄 `HTML` 파일을 먼저 만들어보겠습니다. 시작부터 뭐라도 눈에 보여야 더 재밌거든요.\n\n## 첫 화면 만들기\n\n```bash[bash]\n$ touch index.html\n```\n\n`touch`는 현재 디렉토리에 [파일명]으로 파일을 생성하겠다는 뜻입니다.\n\n자, 그러면 우리의 첫 화면을 보여줄 html 파일을 생성했습니다. 파일명을 index로 짓는 건, 우리 프로젝트 폴더를 배포할 때 컴퓨터가 index.html 이라는 파일을 가장 먼저 찾기 때문입니다.\n\n그럼 바로 `html` 코드를 작성하겠습니다.\n\n```html [index.html]\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"ko\">\n  \u003Chead>\n    \u003Cmeta charset=\"UTF-8\" />\n    \u003Cmeta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n    \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n\n    \u003Ctitle>Vue extension\u003C/title>\n  \u003C/head>\n\n  \u003Cbody>\n    \u003Cdiv>\n      \u003Ch1>Hello, world!\u003C/h1>\n    \u003C/div>\n  \u003C/body>\n\u003C/html>\n```\n\n`html` 별 거 없습니다. `\u003C>`으로 열어주면, `\u003C/>` 으로 닫는다, 이것만 기억하세요.\n\n아무튼 이 파일을 찾아서 실행시켜주면, 현재 사용하고 있는 기본 브라우저가 자동으로 이 `html` 파일을 읽어 화면에 보여주게 됩니다.\n\n![](https://user-images.githubusercontent.com/20244536/112429805-86b0dc00-8d80-11eb-8ae8-a1ebf622ef80.png)\n\n방금 작성한 HTML 파일의 `head` 안에 `title` 부분은 페이지의 제목이 됩니다. 보통 브라우저는 해당 페이지의 제목을 위 사진처럼 탭에 표시해줍니다.\n\n그리고 이 제목은 구글이나 네이버 등 검색 엔진이 검색 결과를 보여줄 때 가장 높은 점수를 주는 항목입니다. 알아두세요.\n\n## manifest.json\n\n우리가 만들고 싶은 건 확장 프로그램입니다. 웹사이트가 아닙니다.\n\n확장 프로그램을 만들기 위해선 프로젝트에 [`manifest.json`](https://developer.chrome.com/docs/extensions/mv3/getstarted/#manifest) 이라는 파일이 있어야 합니다. 크롬, 웨일, 엣지 등 크로미움 기반 브라우저라면 모두 이 파일 하나로 확장 프로그램을 실행시킬 수 있습니다.\n\n`json` 파일은 `{}` 안에 원하는 값들을 넣어주는 단순 데이터를 저장하는 포맷입니다. 예시를 보시죠.\n\n```json\n{\n  \"key1\": \"value1\",\n  \"key2\": {\n    \"key2-1\": \"value2-1\",\n    \"key2-2\": \"value2-2\"\n    // ...\n  }\n}\n```\n\n왼쪽에는 키를, 오른쪽엔 값을 넣으면 됩니다.\n\n이후 어떤 값을 찾으려고 할 때, 왼쪽에 있는 `key`를 이용해 원하는 `value`를 찾게 됩니다. `json` 파일은 이게 다입니다. 너무 쉽죠?\n\n현재 아주 많은 곳에서 쓰이고, 데이터를 저장하는 아주 중요한 포맷이니 기억해두세요.\n\njson에 대해 간략히 배웠으니, 아까 배운 `touch`를 이용해 파일을 만들어봅시다.\n\n```bash[bash]\ntouch manifest.json\n```\n\n물론 폴더에 마우스 우클릭해서 파일을 만들어 줄 수도 있지만, 검은 화면에 리눅스 명령어를 입력하는 것에 익숙해져보세요. 나중에 큰 도움이 됩니다.\n\n아무튼 확장 프로그램을 구성하겠다는 설정 파일인 이 `manifest.json` 파일은, **버전**이라는 게 있습니다.\n\n현재 최신 버전은 `3`이고, 이 버전으로 작성된 확장 프로그램은 올해 1월부터 크롬 웹스토어에 정식 등록이 가능합니다.\n\n지금 당장 자신이 만들 프로그램이 [크롬 웹스토어](https://chrome.google.com/webstore/category/extensions)에만 올라갈 예정이라면, 버전 `3`으로 작성해도 됩니다.\n\n하지만 웨일 등 다른 브라우저의 자체 스토어에도 올릴 예정이라면, 버전 `2`로 작성해야합니다.\n\n이유는 `manifest version 3 (MV3)`는 `Chrome 88` 버전 이상에서만 동작하기 때문입니다. 최신 버전의 웨일은 현재 88 버전 아래이기 때문에 `MV3`로 작성한 확장 프로그램은 인식하지 못합니다.\n\n> Manifest V3 is available beginning with Chrome 88, and the Chrome Web Store begins accepting MV3 extensions in January 2021.\n\n`MV2`로 작성한다고 해서 크롬 웹스토어에 올리지 못하는 건 아닙니다. 하지만 언제까지 `MV2` 앱 제출을 허용해줄지 모르기 때문에, 자신의 프로젝트 상황에 맞게 버전을 선택하세요.\n\n어쨌든 바로 `manifest.json`을 작성해보도록 하겠습니다.\n\n```json[manifest.json]\n{\n  \"name\": \"Vue extension\",\n  \"description\": \"My extension app made by Vue.js\",\n  \"version\": \"0.0.1\",\n  \"manifest_version\": 3,\n  \"action\": {\n    \"default_popup\": \"index.html\"\n  }\n}\n```\n\n`name`과 `description` 부분은 이후 앱 배포시 웹 스토어에 노출, 검색되는 가장 중요한 항목이므로, 앱을 잘 소개하는 문구로 작성하세요.\n\n`action.default_popup` 은 확장 프로그램을 눌렀을 때, 기본적으로 뜨게 될 팝업 창입니다. 우리는 아까 만들었던 `index.html` 파일을 지정하면 됩니다.\n\n자, 여기까지 하면 끝입니다. 이제 우리 프로젝트가 확장 프로그램으로써 역할을 하는 겁니다. 별 거 없죠?\n\n테스트를 위해 브라우저에 `chrome://extensions`를 복사, 붙여넣기 해서 확장 프로그램 관리 페이지로 이동합니다.\n\n![](https://user-images.githubusercontent.com/20244536/112747868-0a71ff00-8ff3-11eb-8f83-1be19507a58e.png)\n\n여기서 오른쪽 상단 **개발자 모드**를 활성화 하면 3개의 버튼이 생깁니다.\n\n여기에 **압축해제된 확장 프로그램을 로드합니다.** 버튼을 눌러 우리 프로젝트인 `vue-extension`을 선택해주세요.\n\n그럼 테스트 가능한 상태가 되며, 확장 프로그램을 눌러보면 우리가 작성한 `index.html` 페이지가 보입니다.\n\n![](https://user-images.githubusercontent.com/20244536/112747952-a7349c80-8ff3-11eb-9b43-2ccfe9e37c6e.png)\n\n다만 확장 프로그램은 브라우저에 띄우는 것과는 달리, 브라우저 위에 작게 뜨는 형태라 큰 사이즈로 보이진 않습니다.\n\n직접 설정하더라도, 최대로 설정 가능한 폭은 `width: 800px`, 높이는 `height: 600px` 입니다. 그 이상의 크기는 무시됩니다.\n\n## 1부 마무리\n\n여기까지 웹 페이지를 구성하는 `html`, 데이터를 저장하는 포맷인 `json`과 크롬 확장 프로그램을 구성하는 설정 파일인 `manifest.json` 대해 아주 간략하게 배웠습니다.\n",{"path":1167,"title":1168,"description":1169,"created":1170,"category":1069,"rawbody":1171},"/extremely-faster-esbuild-than-webpack","웹팩보다 100배 빠른 번들러, esbuild","이번 포스팅은 떠오르는 차세대 자바스크립트 번들러 esbuild에 대한 내용입니다. 작년 Github에서 떠오르는 번들링 프로젝트 중 1위를 차지했고, 오늘을 기준으로 20만개의 가까운 Github Star를 받았습니다.","2021-03-14","---\ncategory: tech\ntitle: 웹팩보다 100배 빠른 번들러, esbuild\nimage: https://user-images.githubusercontent.com/20244536/111092892-bc80e400-857a-11eb-9bb3-adce2c3c67d3.png\nupdated: 2021-03-14\ncreated: 2021-03-14\npublished: true\n---\n\n이번 포스팅은 떠오르는 차세대 자바스크립트 번들러 [`esbuild`](https://esbuild.github.io/)에 대한 내용입니다. 작년 Github에서 떠오르는 번들링 프로젝트 중 1위를 차지했고, 오늘을 기준으로 20만개의 가까운 Github Star를 받았습니다.\n\n\u003C!--more-->\n\n웹팩보다 100배 빠르다는 건 어그로가 아닙니다. 아래 그림을 봐주시죠.\n\n![](https://github.com/evanw/esbuild/raw/master/images/benchmark.svg)\n\n위 벤치마크는 메이저 자바스크립트 번들러들의 빌드 타임을 비교한 표입니다.\n\n아니 어떻게 이렇게 빠를 수가 있냐구요? 이유는 이러합니다.\n\n- `Go` 언어로 작성됨\n- 코드 파싱, 출력과 소스맵 생성을 모두 병렬로 처리함\n- 불필요한 데이터 변환과 할당 없음\n\n하지만 아직 1.0 버전 출시 전이기 때문에, 많은 기능을 제공하고 있지는 않습니다. 현재 지원되는 기능은 이렇습니다.\n\n- CommonJS, ES6\n- JSX\n- Typescript\n- Tree shaking\n- Source Map\n- Minification\n- 등등 더 많음\n\n훌륭합니다. 사실 이 정도만 지원되도 사용하기에 부족함이 없습니다. 오히려 빌드 타임이 너무 빨라서 프로젝트 규모가 커질수록 이득이죠.\n\n> esbuild는 es5 이하의 문법을 아직 100% 지원하지 않습니다. 즉 완벽한 인터넷 익스플로러 대응이 어렵습니다. IE 대응을 하려면 다른 대안을 찾는 것이 좋겠습니다.\n\n꼭 알아야 할 내용은, esbuild는 **자바스크립트를 위한 번들러**입니다. 타입스크립트의 타입 체킹이나 프론트엔드 언어 (Vue, Angular) 지원, 핫 모듈 리로딩을 포함한 개발 서버 오픈 등 번들링과 관계 없는 기능들은 일체 없습니다. 그래서 저는 이 툴이 마음에 듭니다.\n\n## esbuild 사용해보기\n\n먼저 프로젝트를 초기화 해줍시다.\n\n```bash\n$ mkdir esbuild\n$ cd esbuild\n$ yarn init -y\n$ yarn add -D esbuild\n```\n\n`esbuild`는 번들러이기 때문에 최초 진입할 파일 1개가 필요합니다. 그것도 같이 만들어주도록 하겠습니다.\n\n```bash\n$ mkdir src\n$ touch src/main.js\n```\n\n저는 이번 포스팅에서 동물들을 찍어내고, 동물들의 울음소리를 출력하는 그러한 클래스를 만들어보겠습니다.\n\n```js [src/main.js]\nclass Animal {\n  constructor(sound) {\n    this.sound = sound;\n  }\n\n  Bark() {\n    console.log(this.sound + \"!\");\n  }\n}\n```\n\n먼저 **동물**이라는 기본 베이스가 될 클래스를 만들고, 짖는 소리를 내는 Bark라는 함수를 만들었습니다.\n\n다음은 이 동물을 상속받는 **개**를 한 번 만들어보도록 하겠습니다.\n\n```js [src/main.js]\nclass Animal {\n  constructor(sound) {\n    this.sound = sound;\n  }\n\n  Bark() {\n    console.log(this.sound + \"!\");\n  }\n}\n\nclass Dog extends Animal {\n  constructor() {\n    super(\"멍멍\");\n  }\n}\n\nnew Dog().Bark();\n// 멍멍!\n```\n\n간단하게 개가 짖는 것 까지 만들었습니다. 그럼 바로 번들링을 해보도록 하겠습니다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"build\": \"esbuild src/main.js --bundle --outdir=dist\"\n  }\n}\n```\n\n기존 package.json에 `scripts` 키를 추가하고, `build`라는 명령어를 실행시키는 스크립트를 작성했습니다. `--bundle` 옵션은 번들링을 하겠다는 뜻이고, `--outdir=dist`는 최종 결과물 파일을 dist 폴더 아래에 넣겠다는 뜻입니다.\n\n그럼 빌드를 해봅시다.\n\n```bash\n$ yarn build # npm run build\n\n# Log\n$ esbuild src/main.js --bundle --outdir=dist\n✨  Done in 0.05s.\n```\n\n작성한 코드가 거의 없긴 하지만, 0.05초는 정말 빠르네요.\n\n결과물 파일은 이렇습니다.\n\n```js [dist/main.js]\n(() => {\n  // src/main.js\n  var Animal = class {\n    constructor(sound) {\n      this.sound = sound;\n    }\n    Bark() {\n      console.log(this.sound + \"!\");\n    }\n  };\n  var Dog = class extends Animal {\n    constructor() {\n      super(\"\\uBA4D\\uBA4D\");\n    }\n  };\n  new Dog().Bark();\n})();\n```\n\n애초에 ES6로 작성해서 그런지, 딱히 바뀐 건 크게 없어보입니다. 한글로 된 부분은 유니코드로 변환되었고, 상단에 코드의 출처를 주석으로 달아주었네요.\n\n## 번들링을 좀 더 자세히 알아보자\n\n용도에 맞게 번들링을 시작하기 전에 알아두면 좋은 개념이 있습니다. 바로 `format` 입니다. 이 포맷에 관한 내용은 esbuild 뿐만 아니라, 다른 번들러들에서도 사용되는 개념이니 알아두면 좋습니다.\n\n포맷은 용도에 따라 3가지로 나눌 수 있습니다.\n\n- `iife`\n- `cjs`\n- `esm`\n\n`iife` 는 immediately-invoked function expression의 약자이고, 브라우저에서 동작하는 포맷입니다.\n\n`cjs`는 CommonJS라는 뜻이고, Node에서 default로 동작하는 포맷입니다.\n\n마지막으로 `esm`은 ECMA Script라는 뜻으로, 브라우저와 노드 양쪽 모두에서 사용 가능한 포맷입니다.\n\n내 코드가 브라우저랑 노드 양쪽 다 지원하면 좋으니까 포맷을 무조건 esm으로 해야지~ 라고 생각할 수 있지만, 당연히 결과물의 코드 양이 많아집니다. 용도에 맞게 포맷을 지정해서, 불필요하게 최종 결과물의 크기를 커지지 않도록 합시다.\n\nesbuild에선 `platform` 이라는 옵션을 줘서 방금 소개한 `format`을 자동으로 지정해줍니다.\n\n기본적으로 브라우저에서 사용 가능하도록 번들링하는 `browser`가 기본이고, 이 경우 `iife`포맷으로 트랜스파일링합니다.\n\n만약 node에서만 사용 가능하도록 번들링하고 싶다면 platform 옵션을 `node`로 주면 됩니다. 이 경우 포맷은 `cjs`입니다.\n\n아니면 브라우저와 노드 양쪽 모두에서 사용하고 싶을 땐 platform 옵션을 `neutral`로 설정합시다. 이 경우 포맷은 `esm`입니다.\n\n저는 방금 만든 이 코드를 node에서만 사용할 예정이라, 아까 설정한 빌드 명령어를 바꾸도록 하겠습니다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"build\": \"esbuild src/main.js --bundle --outdir=dist --platform=node\"\n  }\n}\n```\n\n## 빌드 스크립트 작성하기\n\n벌써부터 빌드 명령어가 한 줄로 길어져서 보기가 안좋습니다. 앞으로 어떤 옵션이 더 추가될지 모르는데 이런 방식은 지양하는게 좋습니다.\n\n보통 다른 툴들은 `.js`나 `.json` 형태의 설정 파일을 만들면, 해당 파일을 자동으로 읽어서 빌드를 실행합니다. 하지만 esbuild는 자바스크립트 모듈을 직접 실행시키는 방법을 사용합니다.\n\n바로 스크립트를 작성해보도록 하겠습니다.\n\n```bash\n$ mkdir scripts\n$ touch scripts/build.js\n```\n\n```js [scripts/build.js]\nrequire(\"esbuild\")\n  .build({\n    entryPoints: [\"src/main.js\"],\n    outdir: \"dist\",\n    bundle: true,\n    platform: \"node\",\n  })\n  .catch(() => process.exit(1));\n```\n\n프로젝트에 scripts 폴더를 만들고, 그 아래 build.js 파일을 만들었습니다. 아까 명령어를 이해했으면, 이 설정 값도 직관적으로 바로 이해가 됩니다.\n\n다음은 package.json에서 esbuild CLI가 아닌, 방금 작성한 자바스크립트를 실행시키게끔 코드를 변경해줍니다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"build\": \"node scripts/build.js\"\n  }\n}\n```\n\n다시 `yarn build` 명령어로 빌드를 해보면, 같은 결과가 나옵니다.\n\n## 코드 모듈화하기\n\n우리가 만든 동물 클래스를 외부로 내보내진 않았기 때문에, 모듈은 아닙니다.\n\n모듈로 만들어야 하는 경우는, 다른 프로젝트에서 이 동물 클래스를 사용하게 만들고 싶을 때입니다. [`npmjs.com`](https://www.npmjs.com/)에 올라온 패키지들이 전부 모듈인 것이죠.\n\n모듈로 만드는 방법은 굉장히 간단합니다. 선언 시, `export`라는 접두어를 붙이면 됩니다.\n\n```js [src/main.js]\nexport class Dog extends Animal {\n  constructor() {\n    super(\"멍멍\");\n  }\n}\n```\n\nDog 클래스에 export 접두어를 붙여주었습니다. 이 상태에서 빌드를 하면 다른 노드 프로젝트에서 이런 식으로 불러서 쓸 수 있게 됩니다.\n\n```js [test.js]\nimport { Dog } from \"./dist/main\";\n\nnew Dog().Bark();\n```\n\n루트 디렉토리에 `test.js`를 만들었고, 잘 작동하는지 테스트하기 위해 실행시켜줍시다.\n\n```bash\n$ node test.js\n\nimport { Dog } from './dist/main'\n^^^^^^\n\nSyntaxError: Cannot use import statement outside a module\n```\n\n오류가 발생했습니다. 이 문제는 Node가 자바스크립트를 읽을 때 CommonJS 방식으로 해석하기 때문에 발생하는 문제입니다. 우리가 번들링한 방식은 ECMA Script 포맷이었죠.\n\n## 타입스크립트\n\n그렇다면 어떻게 테스트 하느냐?\n\n바로 자바스크립트 트랜스파일러인 [`Babel`](https://babeljs.io/)을 사용하면 됩니다. 하지만 타입스크립트를 도입한다면 바벨을 쓰지 않더라도 **어느정도**는 해결이 가능합니다.\n\n그래서 여기에서 `typescript`를 이용하면 바벨 없이 런타임에 Node를 실행시키면서, 다른 유저들을 위한 `.d.ts` 파일까지 지원 가능해집니다. 심지어 개발 단계에서 타입 체킹까지 가능하니 일석삼조입니다.\n\n고맙게도 esbuild는 확장자가 `.ts`인 파일에 대해 자동으로 처리해줍니다.\n\n그럼 아까 만든 파일의 확장자를 `.ts`로 바꿔줍시다.\n\n```ts [src/main.ts]\nexport interface IAnimal {\n  sound: string;\n}\n\nclass Animal implements IAnimal {\n  sound: string;\n\n  constructor(sound: string) {\n    this.sound = sound;\n  }\n\n  Bark() {\n    console.log(this.sound + \"!\");\n  }\n}\n\nexport class Dog extends Animal {\n  constructor() {\n    super(\"멍멍\");\n  }\n}\n```\n\n이렇게만 해도 번들링은 잘 되지만, esbuild는 타입스크립트의 타입 체킹을 빌드할 때 해주지는 않습니다. 단순히 코드를 읽어서 바꿔주기만 하는 것이죠. 또 `.d.ts` 파일을 만들어주지도 않습니다.\n\n공식 문서에선 esbuild가 번들링에만 치중하기 때문에, 앞으로도 지원할 가능성은 매우 낮다고 얘기합니다.\n\n제대로 타입스크립트를 활용하려면 몇 가지 패키지를 설치하고, 설정 파일인 `tsconfig.json`도 필요합니다.\n\n```bash\n$ yarn add -D typescript ts-node @types/node\n$ node_modules/.bin/tsc --init\n```\n\n두 번째 명령어를 실행하면 `tsconfig.json` 보일러 플레이트를 만들 수 있고, 저는 이렇게 사용하도록 하겠습니다.\n\n```json [tsconfig.js]\n{\n  \"compilerOptions\": {\n    \"target\": \"ES6\",\n    \"module\": \"commonjs\",\n    \"outDir\": \"dist\",\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"strict\": true,\n    \"esModuleInterop\": true\n  },\n  \"include\": [\"./src/**/*\"]\n}\n```\n\n여기선 두 가지가 중요합니다.\n\n`declaration` 옵션은 `d.ts` 파일을 만들겠다는 뜻이고, `emitDeclarationOnly`는 tsc 내장 번들링을 사용하지 않고, 단순 타입 체킹만 하겠다는 뜻입니다.\n\n그럼 다음으로 CLI를 실행할 빌드 스크립트로 수정해줍시다.\n\n```json [package.json]\n\"scripts\": {\n  \"build\": \"tsc && node scripts/build.js\"\n},\n```\n\n이제 명령어를 실행하면 tsc로 타입 체킹을 한뒤, 문제가 없다면 `d.ts` 파일을 만들고, 그 이후 esbuild를 이용해 빠르게 번들링하는 과정이 진행됩니다.\n\n## 마무리\n\n여기까지 간단하게 `esbuild`와 `typescript`를 이용해 아주 간단한 모듈을 만드는 것 까지 알아보았습니다.\n\n### 참고\n\n- https://esbuild.github.io/faq\n- https://news.hada.io/topic?id=3587\n- https://d2.naver.com/helloworld/12864\n- https://helloworldjavascript.net/pages/293-module.html\n",{"path":1173,"title":1174,"description":1175,"created":1176,"category":1069,"rawbody":1177},"/rollupjs-using-plugin","Rollup.js - 플러그인으로 완성도를 높이다","지난 포스팅에서 rollup.js 를 이용해 두 개의 자바스크립트 파일을 하나로 묶고, rollup.config.js 파일을 구성해서 CLI가 아닌 스크립트로 설정 파일을 관리하는 것 까지 진행했습니다.","2021-02-12","---\ncategory: tech\ntitle: Rollup.js - 플러그인으로 완성도를 높이다\nimage: https://user-images.githubusercontent.com/20244536/107738247-a4cef980-6d49-11eb-88a5-f7b8b6190a61.png\nupdated: 2021-02-12\ncreated: 2021-02-12\npublished: true\n---\n\n지난 포스팅에서 `rollup.js` 를 이용해 두 개의 자바스크립트 파일을 하나로 묶고, `rollup.config.js` 파일을 구성해서 CLI가 아닌 스크립트로 설정 파일을 관리하는 것 까지 진행했습니다.\n\n이번 시간에는 `rollup` 에 날개를 달아줄 플러그인들을 살펴보고 나아가 요즘 핫한 `typescript` 까지 적용해보도록 하겠습니다.\n\n\u003C!--more-->\n\n## 들어가기 전에\n\n사실 이전에 구성했던 프로젝트가 번들링이 잘 되고는 있었지만, 빌드 시에 경고 메세지가 출력되고 있었습니다.\n\n```bash\n$ yarn build\n\n(!) Unresolved dependencies\nhttps://rollupjs.org/guide/en/#warning-treating-module-as-external-dependency\nfaker (imported by src/faker.js)\n```\n\n해석하자면 우리의 `src/faker.js` 파일에서 가져온 `faker` 모듈에 대한 처리가 잘 되지 않았다는 얘기네요.\n\n지난 포스팅에 [`Tree shaking`](https://webpack.js.org/guides/tree-shaking/)에 대해 언급했었는데, `main.js` 에서 다른 모듈을 가져올 때 실제로 사용되는 함수만 번들링의 결과물에 포함시킨다는 내용이었습니다.\n\n여기서 추가적으로 `rollup`은 우리가 직접 작성한 모듈 말고, relative path를 가리키는 외부 모듈을 가져올 때 우리의 번들링 결과물에 포함시키지 않고, 단순히 가리키기만 합니다.\n\n즉 우리가 만든 이 프로젝트를 누군가 가져다 쓸 때는 `faker` 라는 모듈 없이 작동이 안된다는 뜻입니다. 당연한 얘기죠.\n\n사실 이 부분은 우리가 만든 패키지를 npm에 배포할 때 `faker` 모듈을 `devDepenency`가 아닌 `depenency`에 설치해두면 가져다 쓰는 사람이 우리 패키지를 설치할 때 직접 `yarn add faker` 를 하지 않더라도 알아서 같이 `node_modules` 안에 설치가 됩니다.\n\n결과적으로 현재 발생하는 경고 메세지를 무시해도 괜찮지만, 공식 문서에는 이 부분이 의도적이더라도 명시해주면 좋겠다고 기술하고 있습니다.\n\n경고 메세지를 없애는 방법은 간단합니다.\n\n```js [rollup.config.js]\nexport default {\n  input: 'src/main.js',\n  output: {\n    dir: 'dist',\n    format: 'cjs'\n  }\n  external: [ 'faker' ]\n};\n```\n\n외부 모듈에 대해 직접 이름을 명시하면 더 이상 오류 메세지는 출력되지 않습니다.\n\n## 외부 모듈도 포함시키고 싶다면?\n\n어떻게 보면 우리 프로젝트에서는 `faker` 의 `faker.name.findName()` 함수를 제외하면 아무것도 사용하지 않기 때문에 우리 모듈을 가져다 쓰는 사람이 `faker` 모듈을 전부 설치하는 게 좀 낭비같기도 합니다. 현재 `faker`는 **41MB**나 되거든요.\n\n그렇다면 외부 모듈에 대해서도 `Tree shaking`을 이용하면 크기를 많이 줄일 수 있겠다는 생각이 드네요.\n\n고맙게도 그런 역할을 하는 플러그인이 제공되고 있어서 아주 쉽게 구현할 수 있습니다.\n\n```\n$ yarn add -D @rollup/plugin-node-resolve\n```\n\n프로젝트에 플러그인 역할을 할 패키지를 설치 후, 설정 파일을 수정합시다.\n\n```js [rollup.config.js]\nimport { nodeResolve } from \"@rollup/plugin-node-resolve\";\n\nexport default {\n  input: \"src/main.js\",\n  output: {\n    dir: \"dist\",\n    format: \"cjs\",\n  },\n  plugins: [nodeResolve()],\n};\n```\n\n이렇게 설정하고 번들링을 하면 예상되는 결과는 우리의 결과물에 `faker`의 `findName()` 함수에 대한 코드가 포함되어 있어야겠죠.\n\n```bash\n$ yarn build\n\n[!] Error: 'default' is not exported by node_modules/faker/index.js, imported by src/faker.js\nhttps://rollupjs.org/guide/en/#error-name-is-not-exported-by-module\nsrc/faker.js (1:7)\n1: import faker from 'faker'\n          ^\n```\n\n오류가 발생했습니다. 이번엔 번들링 자체가 되지 않고 있습니다..\n\n문서에서는 이러한 문제는 `CommonJS` 로 작성된 모듈들을 번들링 결과물에 포함시키려고 할 때 문제가 발생한다고 설명합니다.\n\n당연하게도 우리가 어떤 파일에서 `import a from './a.js'` 라고 가져올 수 있는 건, `a.js` 파일에는 `export default` 이 있기 때문에 가능한 시나리오입니다.\n\n하지만 `faker` 모듈을 살펴보면 그런 부분이 없습니다. 사실 `faker` 뿐만 아니라 엄청나게 많은 모듈들이 `faker` 와 같은 포맷으로 작성되어 있습니다.\n\n그래서 여기서 또 다른 플러그인 하나가 등장합니다. `CommonJS` 로 작성된 모듈들을 `ES6` 바꾸어서 `rollup`이 해석할 수 있게 도와줍니다.\n\n```\n$ yarn add -D @rollup/plugin-commonjs\n```\n\n패키지를 설치하고 설정 파일을 다시 수정합시다.\n\n```js [rollup.config.js]\nimport { nodeResolve } from \"@rollup/plugin-node-resolve\";\nimport commonjs from \"@rollup/plugin-commonjs\";\n\nexport default {\n  input: \"src/main.js\",\n  output: {\n    dir: \"dist\",\n    format: \"cjs\",\n  },\n  plugins: [nodeResolve(), commonjs()],\n};\n```\n\n다시 빌드 해봅시다.\n\n```bash\n$ yarn build\n\ncreated dist in 5.2s\n✨  Done in 5.84s.\n```\n\n빌드 타임은 좀 많이 늘어났습니다. 그래도 빠른 편입니다.\n\n번들링 결과물이 길어서 첨부하지는 못하지만, `faker` 의 코드들이 번들링 결과물에 추가되어있는 걸 볼 수 있습니다. 아마 `faker` 내부에서 `findName()` 을 구현할 때 많은 단계를 거치는 것 같습니다.\n\n그래도 우리의 `main.js` 파일의 크기는 현재 **1.8MB**입니다. 우리 코드를 제외하더라도 거의 40MB가량 이득을 봤습니다.\n\n심지어 이 파일만 바깥으로 빼서 `node main.js` 로 실행시켜보면 다른 `node_modules` 이 없더라도 잘 실행됩니다. 필요한 부분이 전부 파일 안에 포함되었으니까요. 의존성이 없어지면서 파일 크기도 엄청나게 줄였습니다.\n\n이제 사용자가 불필요하게 `faker` 모듈을 설치하지 않아도 되겠습니다.\n\n## 타입스크립트\n\n이제는 타입스크립트가 거의 대세가 된 것 같습니다. 저 역시 타입스크립트를 도입하고 그 필요성을 절실히 느끼고 있기 때문에, `typescript`로 작성되어있지 않은 패키지를 설치할 때는 조금 꺼려지게 되더군요.\n\n먼저 우리 프로젝트에서 typescript를 사용하기 위해 패키지를 설치하도록 합시다.\n\n```bash\n$ yarn add -D typescript tslib\n```\n\n`tslib` 의 경우 `rollup` 이 typescript를 번들링할 때 필요해서 같이 설치해야합니다.\n\n타입스크립트를 사용하기 위해 `tsconfig.json` 파일을 만들어줍시다. 만드는 방법은 간단합니다. 타입스크립트를 설치하면 사용 가능한 CLI인 `tsc` 명령어를 이용하면 템플릿을 자동으로 만들 수 있습니다.\n\n```bash\n$ node_modules/.bin/tsc --init\n```\n\n이번에도 역시 typescript를 전역으로 설치하지 않고 로컬에 설치했기 때문에, CLI를 사용하고 싶다면 프로젝트 루트 디렉토리에서 `node_modules`에 들어있는 `tsc` 를 직접 사용하면 되겠습니다.\n\n```json [tsconfig.json]\n{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"module\": \"CommonJS\",\n    \"strict\": true,\n    \"moduleResolution\": \"node\",\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true\n  }\n}\n```\n\n저는 이정도로 간단하게만 사용하겠습니다.\n\n타입스크립트를 사용하기 위한 준비가 되었으니 `main.js` 와 `faker.js` 의 확장자를 `.ts`로 바꿔주시면 되겠습니다.\n\n이전에 js로 작성했던 파일의 확장자를 ts로 바꾸어도 변경할 부분은 없을 것 같습니다.\n\n이제 우리 프로젝트의 구조는 이러합니다.\n\n```\nproject/\n|- node_modules\n|- src\n  |- faker.ts\n  |- main.ts\n|- package.json\n|- rollup.config.js\n|- tsconfig.json\n|- yarn.lock\n```\n\n이제 `rollup` 이 typescript 파일을 읽어서 번들링을 할 수 있도록 도와주는 패키지를 설치합시다.\n\n```bash\n$ yarn add -D @rollup/plugin-typescript\n```\n\n다음 설정 파일을 수정합시다.\n\n```js [rollup.config.js]\nimport { nodeResolve } from \"@rollup/plugin-node-resolve\";\nimport commonjs from \"@rollup/plugin-commonjs\";\nimport typescript from \"@rollup/plugin-typescript\";\n\nexport default {\n  input: \"src/main.js\",\n  output: {\n    dir: \"dist\",\n    format: \"cjs\",\n  },\n  plugins: [\n    nodeResolve(),\n    commonjs({ extensions: [\".js\", \".ts\"] }),\n    typescript(),\n  ],\n};\n```\n\n플러그인에 `typescript` 를 추가하고, `commonjs`가 `.ts` 파일도 읽어들일 수 있도록 설정합니다.\n\n이 `typescript()` 플러그인 안에 타입스크립트에 대한 옵션을 넣어줄 수도 있지만, 없다면 자동으로 프로젝트 루트 디렉토리에 있는 `tsconfig.json` 파일을 찾아서 동기화를 해줍니다. 정말 간편하네요.\n\n```bash\n$ yarn build\n\n(!) Entry module \"src/main.ts\" is implicitly using \"default\" export mode, which means for CommonJS output that its default export is assigned to \"module.exports\". ... 이하 생략\n\ncreated dist in 5.6s\n✨  Done in 6.49s.\n```\n\n정상적으로 빌드가 되었습니다. 빌드 타임은 약간 더 길어졌지만, 결과물은 기존과 같습니다.\n\n다만 새로운 경고 메세지가 등장했습니다.\n\n기본적으로 `rollup` 은 우리의 모듈을 번들링할 때 단 하나의 객체를 내보내는 `export default` 인지, 아니면 `default` 없이 일일이 이름을 지어서 내보내는지 추측해서 번들링 하도록 되어있습니다.\n\n`output.exports` 의 옵션으로 `default`, `named` 아니면 `none` 3가지 옵션을 주고 있습니다.\n\n`rollup` 의 권장사항은 `named` 입니다. 애초에 코드를 작성할 때도 마지막에 `export default` 를 하지 않기를 권장합니다.\n\n```js [rollup.config.js]\nimport { nodeResolve } from \"@rollup/plugin-node-resolve\";\nimport commonjs from \"@rollup/plugin-commonjs\";\nimport typescript from \"@rollup/plugin-typescript\";\n\nexport default {\n  input: \"src/main.js\",\n  output: {\n    dir: \"dist\",\n    format: \"cjs\",\n    exports: \"named\",\n  },\n  plugins: [\n    nodeResolve(),\n    commonjs({ extensions: [\".js\", \".ts\"] }),\n    typescript(),\n  ],\n};\n```\n\n`exports` 를 추가하면 더 이상 경고 메시지는 나오지 않습니다.\n\n## 파일을 더 압축해보자\n\n사실 지금 번들링의 결과물은 불필요한 공백이 파일 크기를 많이 잡아먹고 있습니다. 이를 모두 제거해버립시다.\n\n```bash\n$ yarn add -D rollup-plugin-terser\n```\n\n다음은 설정 파일을 수정합시다.\n\n```js [rollup.config.js]\nimport { nodeResolve } from \"@rollup/plugin-node-resolve\";\nimport commonjs from \"@rollup/plugin-commonjs\";\nimport typescript from \"@rollup/plugin-typescript\";\nimport { terser } from \"rollup-plugin-terser\";\n\nexport default {\n  input: \"src/main.js\",\n  output: {\n    dir: \"dist\",\n    format: \"cjs\",\n    exports: \"named\",\n  },\n  plugins: [\n    nodeResolve(),\n    commonjs({ extensions: [\".js\", \".ts\"] }),\n    typescript(),\n    terser(),\n  ],\n};\n```\n\n빌드 후 파일을 열어보면 공백이 모두 제거 되었고, 파일이 **1.8MB**에서 **1.3MB**까지 줄어들었습니다.\n\n## 마무리\n\n이상으로 Rollup 번들러를 이용해서 간단하게 번들링에 대해서 알아보았습니다.\n\n사실 오픈 소스 프로젝트처럼 다른 사람들을 위해 라이브러리를 만드는 것이 아니라면, 번들링 하는 방법을 직접적으로 알 필요는 없습니다. 이미 대부분의 메이저 프레임워크들은 내부적으로 잘 구현이 되어있기 때문입니다.\n\n하지만 내가 만든 툴을 `npm`이나 `CDN` 같이 사람들에게 툴로써 제공하고 싶다면, 번들링은 선택이 아닌 필수라고 생각합니다.\n",{"path":1179,"title":1180,"description":1181,"created":1182,"category":1069,"rawbody":1183},"/rollupjs-lets-start-bundling","Rollup.js - 번들링, 파일을 하나로 합쳐보자","번들링 이라는 말을 프론트엔드 개발자라면 많이 들어보셨을겁니다. 번들링은, 파일을 하나로 묶는 것을 말합니다. 그럼 왜 굳이 파일을 하나로 묶어야 할까요? 바로 HTTP 통신의 특성 때문입니다.","2021-02-09","---\ncategory: tech\ntitle: Rollup.js - 번들링, 파일을 하나로 합쳐보자\nimage: https://user-images.githubusercontent.com/20244536/107738251-a6002680-6d49-11eb-8708-dbe40704924e.png\nupdated: 2021-02-09\ncreated: 2021-02-09\npublished: true\n---\n\n`번들링` 이라는 말을 프론트엔드 개발자라면 많이 들어보셨을겁니다. 번들링은, 파일을 하나로 묶는 것을 말합니다. 그럼 왜 굳이 파일을 하나로 묶어야 할까요? 바로 **HTTP 통신의 특성 때문입니다.**\n\n\u003C!--more-->\n\n단발성으로 리소스를 요청하는 HTTP 특성상, 요청할 파일이 많으면 그만큼 요청을 많이 보내야해서 비효율적입니다. 그래서 번들링해서 파일을 하나로 묶으면 요청 횟수가 적어지니 효율적이겠죠.\n\n그만큼 프론트엔드에서 번들링은 정말 중요합니다. 사용자가 우리의 웹사이트를 방문했을 때, 최대한 빠르게 웹 페이지를 보여줘야하기 때문입니다.\n\n그럼 번들링은 어떻게 해야할까요? 바로 **번들러**라는 녀석을 이용하면 됩니다. 대표적으로 [`Webpack`](https://webpack.js.org/)이 있습니다. 예전과 다르게 웹팩이 많이 개선되어서, 설정 없이도 간단한 번들링은 쉽게 가능합니다.\n\n![image](https://user-images.githubusercontent.com/20244536/107313452-e48dab00-6ad5-11eb-8a9b-223341b52217.png)\n\n위 그림은 웹팩이 가장 왼쪽 위에 위치한 최초 진입점인 `.js` 파일을 읽어서, 그 파일이 참조 하고 있는 다른 여러 형식들의 파일들을 하나로 묶어 최종적으로 js, css, jpg, png로 만든다는 과정을 설명한 그림입니다. 이해가 안가셔도 됩니다. 그냥 최초 진입점이 될 파일 1개를 선택한다는 것만 알아두시면 됩니다.\n\n그리고 번들러를 사용해야하는 큰 이유가 있습니다. 번들러는 이제 단순히 번들링만 하는 게 아니라, 용량 압축과 구형 브라우저 지원, Polyfill 등 하나로 묶는 것 등 굉장히 많은 이점과 편의성을 가져다줍니다.\n\n거의 대부분 `Webpack`을 많이 사용하지만, 더욱 빠른 성능이나 Zero configuration을 강조한 후발 주자들도 많이 있습니다.\n\n[`Rollup`](https://rollupjs.org/guide/en/), [`Parcel`](https://parceljs.org/), [`Esbuild`](https://esbuild.github.io/) 정도가 있습니다.\n\n이번 포스팅에서는 Rollup을 이용해 번들링에 대해 간단하게 알아보겠습니다.\n\n## 프로젝트 구성\n\n먼저 프로젝트를 생성하고, 초기화해줍니다.\n\n```bash\n$ mkdir project && cd $_\n$ yarn init -y\n```\n\nrollup을 사용하기 위해 패키지를 설치합니다. 이번 포스팅에선 `faker.js`도 같이 사용할 예정입니다.\n\n```bash\n$ yarn add -D rollup\n$ yarn add faker\n```\n\n프로젝트를 번들링하기 위해 최초 rollup이 읽어들어야 할 파일이 있어야겠죠. 저는 `src` 디렉토리 아래에 `main.js` 파일과 `faker.js` 파일 2개를 만들도록 하겠습니다.\n\n```bash\n$ mkdir src\n$ touch src/main.js\n$ touch src/faker.js\n```\n\n여기까지 구성하셨다면 프로젝트의 구조는 다음과 같습니다.\n\n```\nproject\n|- node_modules/\n|- src/\n    |-- main.js\n    |-- faker.js\n|- package.json\n|- yarn.lock\n```\n\n## 모듈 작성하기\n\n자 그러면 진입점이 될 파일인 `main.js`를 작성하기 전에, `faker.js`을 이용해서 랜덤한 이름을 만들어주는 우리의 모듈(함수)부터 만들어보도록 하겠습니다.\n\n```js [src/faker.js]\nimport faker from \"faker\";\n\nexport const GenerateName = () => {\n  return faker.name.findName();\n};\n```\n\n방금 만든 모듈을 이용해서 `main.js`를 구성해보도록 하겠습니다.\n\n```js [src/main.js]\nimport { GenerateName } from \"./faker\";\n\nfunction Init() {\n  const name = GenerateName();\n  console.log(`name: ${name}`);\n}\n\nInit();\n```\n\n이상으로 프로젝트를 실행시킬 때 마다 이름을 랜덤으로 출력해주는 간단한 모듈을 만들어보았습니다.\n\n## 번들링하기\n\n맨 처음 설명드린 것처럼, **번들러**는 다수의 파일을 하나의 파일로 묶어주는 역할을 한다고 했습니다. 이 말에 따르면 우리의 프로젝트는 현재 2개의 파일로 분리되어 있지만, 결과는 1개의 파일로 나와야합니다.\n\n커맨드라인 인터페이스를 이용하면 아주 간단하게 번들링을 할 수 있습니다. cli를 이용하기위해 `package.json` 내부에 `scripts`를 추가해주도록 합시다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"build\": \"rollup src/main.js --file dist/main.js\"\n  }\n}\n```\n\n옵션에 대한 설명은 두 번째 자리에 진입점이 될 파일을 넣고, --file 옵션에는 번들링된 결과 파일 명을 적어주면 되겠습니다.\n\n> 프로젝트 내부에만 rollup을 설치했기 때문에 명령어를 터미널에 직접치면 작동하지 않습니다. 터미널에서도 실행시키고 싶다면 `$ yarn add global rollup`으로 설치해주세요.\n\n그럼 이제 우리의 모듈을 번들링합시다.\n\n```bash\n$ yarn build\n\n# log\n$ rollup src/main.js --file dist/main.js\n\nsrc/main.js → dist/main.js...\n(!) Unresolved dependencies\nhttps://rollupjs.org/guide/en/#warning-treating-module-as-external-dependency\nfaker (imported by src/faker.js)\ncreated dist/main.js in 43ms\n✨  Done in 0.45s.\n```\n\n성공적으로 번들링되었습니다! 현재는 경고가 하나 뜨는걸로 보이는데, 일단은 결과 파일을 살펴보도록 하겠습니다.\n\n```js [dist/main.js]\nimport faker from \"faker\";\n\nconst GenerateName = () => {\n  return faker.name.findName();\n};\n\nfunction Init() {\n  const name = GenerateName();\n  console.log(`name: ${name}`);\n}\n\nInit();\n```\n\n분리되어있던 `faker.js` 파일의 코드들이 `main.js`에 합쳐져있음을 확인할 수 있습니다.\n\n우리의 모듈이 잘 동작하는지 테스트를 위해 `package.json`에 스크립트를 추가합시다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"build\": \"rollup src/main.js --file dist/main.js\",\n    \"start\": \"node dist/main.js\"\n  }\n}\n```\n\n```bash\n$ yarn start\n\n# log\nimport faker from 'faker';\n^^^^^^\n\nSyntaxError: Cannot use import statement outside a module\n```\n\nimport 구문을 사용할 수 없다는 오류가 출력되고 있습니다. 이 부분은 rollup의 문제는 아니고, node에서 최신 자바스크립트 문법을 해석할 수 없기 때문에 발생하는 오류입니다.\n\n감사하게도 번들링 과정에서 최신 자바스크립트 문법을 node에서 해석할 수 있도록 바꿔주는 옵션이 있습니다. `package.json`를 수정합시다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"build\": \"rollup src/main.js --file dist/main.js --format cjs\"\n  }\n}\n```\n\n수정한 뒤 결과 파일을 다시 살펴보도록 하겠습니다.\n\n```js\n// dist/main.js\n\n\"use strict\";\n\nvar faker = require(\"faker\");\n\nfunction _interopDefaultLegacy(e) {\n  return e && typeof e === \"object\" && \"default\" in e ? e : { default: e };\n}\n\nvar faker__default = /*#__PURE__*/ _interopDefaultLegacy(faker);\n\nconst GenerateName = () => {\n  return faker__default[\"default\"].name.findName();\n};\n\nfunction Init() {\n  const name = GenerateName();\n  console.log(`name: ${name}`);\n}\n\nInit();\n```\n\n이것 저것 코드가 많이 추가되었는데 다시 테스트 해보도록 하겠습니다.\n\n```bash\n$ yarn start\n\n# log\n$ node dist/main.js\nname: Cesar Greenholt\n✨  Done in 0.72s.\n```\n\n잘 작동하는걸 확인할 수 있습니다!\n\n그냥 넘어가기 전에 포맷에 대한 간략한 설명입니다. `--format` 옵션에는 몇 가지가 있는데, node에서 사용하는 모듈이라면 `cjs`를 사용하면 되고, 브라우저에서 사용한다면 `iife`, 둘 다 사용하고 싶다면 `umd` 옵션을 주면 됩니다.\n\n\"무조건 umd로 하면 되겠네\" 라고 생각하실 수 있지만, 당연히 번들링된 파일의 크기가 커집니다. 파일이 불필요하게 커지지 않도록 각 사용 환경에 맞게 구성하시면 되겠습니다.\n\n## Tree shaking\n\n번들링을 하면서 얻을 수 있는 큰 장점 중 하나는 내 프로젝트가 가져오는 외부 모듈들이 아주 많을텐데, 그 중에 실제로 사용되는 코드들만 번들링 결과물에 포함시켜준다는 점입니다.\n\n이를 [`Tree shaking`](https://webpack.js.org/guides/tree-shaking/)이라고 합니다. 예시를 보도록 합시다.\n\n```js\n// src/faker.js\n\nimport faker from \"faker\";\n\nexport const GenerateName = () => {\n  return faker.name.findName();\n};\n\nexport const Test = () => {\n  return \"Some string\";\n};\n```\n\n기존에 작성했던 `faker.js` 파일에 `Test()` 함수를 추가해서 내보내도록 했습니다.\n\n**main.js**\n\n```js\nimport { GenerateName, Test } from \"./faker\";\n\nfunction Init() {\n  const name = GenerateName();\n  console.log(`name: ${name}`);\n}\n\nInit();\n```\n\n그리고 `Test` 함수를 가져오도록 코드를 추가하고 번들링을 해보면 `Test` 함수가 포함되어 있어야 할 것 같지만, 모듈을 가져오는 부분에서 실제로 사용하진 않고 있기 때문에 결과물에 포함시키지 않습니다.\n\n이러한 부분은 기능이 많은 다른 모듈들을 가져올 때 굉장한 도움이 됩니다.\n\n## rollup.config.js\n\n번들러를 사용하다보면 CLI 설정 옵션이 너무 길어지는 상황이 발생합니다. 그리고 한 줄에 모두 작성해야하기 때문에 보기에도 좋지 않습니다. 또 번들링 환경에 따라 다른 결과 파일을 만들어내야할 수 도 있습니다.\n\n그런 몇몇 상황들을 충족시키기 위해 자바스크립트 파일로 번들링 옵션을 설정할 수 있도록 기능을 제공하고 있습니다.\n\n`package.json` 파일을 수정합시다.\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"build\": \"rollup -c\"\n  }\n}\n```\n\n코드가 많이 짧아졌습니다.\n\n-c 뒤에는 설정 파일 경로를 넣어주면 되는데, 설정하지 않는다면 프로젝트 루트 디렉토리의 `rollup.config.js` 파일을 찾도록 되어있습니다. 이것도 같이 구성하도록 하겠습니다.\n\n```bash\n$ touch rollup.config.js\n```\n\n```js [rollup.config.js]\nexport default {\n  input: \"src/main.js\",\n  output: {\n    dir: \"dist\",\n    format: \"cjs\",\n  },\n};\n```\n\n이렇게 구성해주면 원래 작성했던 cli 코드와 설정이 같아집니다. 테스트를 위해 `yarn build`를 이용해 번들링해보면 이전과 같은 결과가 나옵니다.\n\n## 마무리\n\n여기까지 다룰 수 있다면 다른 node 프로젝트에서 가져다 사용하기 위해 `npm`에 배포하는 데 큰 어려움은 없지만, rollup에 호환되지 않는 포맷으로 작성된 모듈들을 가져다가 사용하면서 생기는 문제가 있을 수 있습니다. 또 타입스크립트로 작성하고 싶거나, 공백까지도 모두 지워버려서 파일 크기를 최대한으로 줄이고 싶다거나 한다면 플러그인을 사용해야 합니다.\n\n2부에서는 이러한 문제를 아주 쉽게 해결할 수 있는 **플러그인**에 대해서 알아보도록 하겠습니다.\n",{"path":29,"title":1185,"description":1186,"created":1187,"category":1069,"rawbody":1188},"코인 시세 1초만에 보는 크롬 확장 프로그램 만들기","가상화폐 거래소 API를 활용해 브라우저에서 단축키로 빠르게 코인 시세를 확인할 수 있는 툴을 크롬 확장 프로그램으로 만들어보았다.","2021-02-05","---\ncategory: tech\ntitle: 코인 시세 1초만에 보는 크롬 확장 프로그램 만들기\ndescription: 가상화폐 거래소 API를 활용해 브라우저에서 단축키로 빠르게 코인 시세를 확인할 수 있는 툴을 크롬 확장 프로그램으로 만들어보았다.\nimage: https://user-images.githubusercontent.com/20244536/107003725-94aa9d80-67d0-11eb-8a27-9c68c45a3748.png\nupdated: 2021-02-05\ncreated: 2021-02-05\npublished: true\n---\n\n요즘 한국엔 주식, 코인 거래가 정말로 이슈이다. 그냥 이슈가 아니라, 주식 안하는 사람들을 바보 취급하는 그런 이상한 분위기가 형성됐다.\n\n나는 작년 4월부터 주식을 처음으로 시작했다. 그 때부터 한국에 삼성전자를 필두로 주식 열풍이 불기 시작했기 때문이다.\n\n개인적으로 1년 가까이 주식을 하면서 느낀 점은, 시드가 작으면 하나 마나라는 생각이 들었다. 등락폭 제한이 있는 한국 시장은 더더욱 그렇다.\n\n그래도 적금보다 낫지라고 생각할 수도 있지만, 어디까지나 내가 산 주식이 상승하고 있을 때 얘기이다. 죄다 파란 불이면 그 심리적 압박감으로 인한 스트레스는 정말 크다.\n\n그 때문인지 나는 자연스럽게 등락폭 제한이 없는 `미국 시장`과 `가상 화폐`쪽에 관심이 갔다. 시드가 적더라도 등락이 자유로워서 시장에 잘 적응한다면, 시드가 적어도 한국 시장보다는 좀 더 크게 시드를 불릴 수 있지 않을까 하는 생각 때문이었다. 물론 어디까지나 `매매를 잘 한다면`이다.\n\n## 근데 왜 크롬 확장 프로그램일까?\n\n아무튼 주식이나 코인을 사놓고 딱히 관심 없는 사람이 아니라면, 대부분 하루에 한 번 이상 내가 산 자산의 가격을 보게 되있다. 올랐는지 떨어졌는지 궁금하니까.\n\n심지어 코인은 더 자주 보게 된다. 코인은 장이 24시간 열려있고, 초 단위로 가격 변동이 심하기 때문이다.\n\n여기서 내가 `크롬 확장 프로그램`을 만들게 된 이유가 등장한다. **회사에서 핸드폰 켜서 시세 보는게 너무 번거롭다. 그렇다고 홈페이지 들어가서 차트 보는 것도 눈치 보이고. 근데 브라우저에서 단축키 눌러서 내가 보고 싶은 시세가 딱 뜨면 좋지 않을까?** 라는 생각이 들었다.\n\n아마 필요한 직장인들이 굉장히 많을 것이라고 감히 추측해본다.\n\n그래서 크롬 확장 프로그램 마켓에 대해 좀 알아봤는데, 생각보다 조건이 너무 괜찮다.\n\n일단 앱을 등록하려면 개발자 등록을 해야되는데, 등록비로 5불을 낸다. 최초 1번만 내면 된다. 정말 혜자스럽다. 한 번만 등록하면, 갱신없이 평생 무한으로 즐길 수 있다!\n\n그리고 웹 개발자들에게 익숙한 HTML, CSS, Javascript로 앱을 개발할 수 있다. 너무 좋다. 인 앱 결제 또한 외부 결제를 써도 상관없다.\n\n## 그렇다면 기획을 해보자\n\n일단 내가 당장 필요한 기능은 이러하다.\n\n- 브라우저 사용 중에 단축키를 누르면 바로 팝업이 떠서 코인 시세가 실시간으로 보인다.\n- 팝업이 굉장히 작아야 한다. 지나가다는 사람이 내 모니터를 봐도, 뭐 하는지 잘 모를 정도의 사이즈여야 한다.\n- 내 시스템 테마에 맞춰 다크 모드면 어둡게, 라이트면 밝은 테마로 나온다.\n\n당장은 아니지만 만약 이 앱을 사용하는 사람들이 많아진다면, 몇몇 거래소 API를 통합해서 거래까지 가능하도록 만들 수도 있다.\n\n아무튼 나는 이 앱을 트레이딩을 도와주는 인공지능(?) 느낌으로 발전시키고 싶었다. 아이언맨의 자비스처럼 말이다. 그래서 이름을 `브리아나`로 정했다. 그리고 조금 더 있어보이려면 기업명이 필요하기 때문에, 기업명은 `브리아나 랩스`로 정했다.\n\n\u003C!-- ## 먼저 확장 프로그램에 대해 알아보자\n\n확장 프로그램을 만드는 건 정말 별거 없다. 프로젝트 루트 폴더에 `manifest.json` 파일이 있다면 기존 운영 중인 웹사이트도 확장 프로그램으로 바로 사용이 가능하다.\n\n```json[manifest.json]\n{\n  \"manifest_version\": 2,\n  \"version\": \"0.0.1\",\n  \"name\": \"브리아나 - 회사에서도 코인 모니터링\",\n  \"description\": \"핸드폰으로 몰래 보지말고, 단축키로 코인 시세를 실시간으로 모니터링하자!\",\n  \"icons\": {\n    \"16\": \"icon16.png\",\n    \"32\": \"icon32.png\",\n    \"128\": \"icon128.png\"\n  },\n  \"browser_action\": {\n    \"default_popup\": \"popup.html\",\n    \"default_title\": \"Briana\"\n  },\n  \"background\": {\n    \"scripts\": [\"background.js\"],\n    \"persistent\": false\n  },\n  \"commands\": {\n    \"_execute_browser_action\": {\n      \"suggested_key\": {\n        \"default\": \"Ctrl+Shift+E\",\n        \"mac\": \"Cmd+Shift+E\",\n      }\n    }\n  }\n}\n```\n\n브리아나를 구성하기 위해 현재 사용 중인 manifest 파일의 내용이다. 뭐가 많아보이지만 진짜 별거 없다.\n\n**manifest_version**: 브라우저가 확장 프로그램을 어떤 버전으로 인식할건지 명시해주는 값이다. 지금은 2버전이 Stable 버전이고, 작년 말에 곧 3 버전이 나온다고 공시했는데 당장 나올 것 같진 않다. 3 버전으로 해보려고 했는데 문서대로 해도 잘 안되서 나는 2버전으로 했다.\n\n**version**: 마켓에 표시될 버전 정보이다. 일반적인 Sementic version을 따른다. 조금 주의해야할 부분인데, 앱 등록 후 버전 업데이트를 할 때 이 버전 숫자를 올리지 않고 그냥 올리면 심사가 거부될 수 있다. 한 번 거부되면 며칠씩 기다려야 하니 신경쓰자.\n\n**name**: 마켓에 표시될 앱의 이름이다.\n\n**description**: 앱에 대한 짧은 설명이다. SEO와 밀접한 연관이 있으니 앱과 관련된 키워드를 문장으로 잘 서술하자.\n\n**icons**: 앱 아이콘이다. 이것과 별개로 128x128 사이즈는 마켓에 등록할 용도로 필요하다.\n\n**browser_action**: 이 부분이 정말 확장 프로그램에 대한 내용이다. 보통 확장 프로그램은 앱을 눌렀을 때 브라우저 위에 작은 `팝업`이 뜨는 방식이다. 그래서 default_popup에 html 파일 경로를 적어주면 된다. default_title은 설치 후, 앱 위에 마우스를 올렸을 때 표시될 이름이다.\n\n**background**: 이건 앱이 켜졌을 때 실행되는 백그라운드 스크립트를 지정하는 곳이다. 나의 경우에는 크게 필요하지 않아서 제대로 써보진 않았는데, 뭔가 계속 무언가를 감시해야 할 때 사용하면 된다.\n\n**commands**: 말 그대로 단축키를 설정하는 곳이다. 윈도우, 맥, 리눅스 까지 설정 가능한 것 같다. 나는 윈도우와 맥만 설정해주었다.\n\n더 자세한 내용을 알고 싶다면, 글 맨 아래의 참고 부분에 걸어둔 공식 문서를 읽어보면 된다. -->\n\n## 디자인\n\n간단하게 기획은 마무리 했으니, 이번엔 디자인을 할 차례다.\n\n기획 단계에서 앱 이름을 정했고, 컨셉이 나왔으니 지금 단계에서는 두 가지만 준비하면 된다.\n\n1. **로고**\n2. **메인 컬러 (Primary color)**\n\n`로고`는 일반적인 경우라면 일러스트레이터에서 대충 볼드한 폰트 중에 마음에 드는거 골라서 BRIANA 이런식으로 글자로만 구성해도 무방하다. 하지만 확장 프로그램 앱들은 보통 심볼 형태의 로고를 가지고 있다. 이유는 브라우저 확장 프로그램 탭에서 4x4px 정도로 아주 작게 표시되기 때문에, 심볼 형태의 로고가 있는게 브랜드 노출 측면에서 더 유리하다.\n\n근데 나는 디자이너가 아니기 때문에 로고를 직접 만들긴 어렵다. 하지만 요즘은 머신러닝 기능을 활용해 로고를 만들어주는 서비스들이 정말 많다.\n\n나는 옛날부터 애용하고 있는 [Looka.com](https://looka.com)이라는 서비스를 이용해 로고를 만들었다. 마음에 드는 로고 레이아웃과 색, 브랜드 이름 등 몇 가지 input만 넣어주면, 인공지능이 무한으로 로고를 만들어준다. 무한으로 만드는 것 까지는 무료이고, 이후 구독제 혹은 1회성 결제로 구매하면 어디서든 상업적으로 이용 가능하다.\n\n비슷한 서비스들이 정말 많아서 그 동안 이것저것 다 사용해봤는데, 퀄리티는 여기가 가장 좋았다.\n\n`메인 컬러`는 파란색이나 빨간색 계통으로 하고 싶었다. 보통 파란색은 B2B 서비스에서 많이 쓰는 색이다. 신뢰도를 주는 색이기 때문이다.\n\n하지만 나는 최종적으로 주홍색을 선택했다. 이유는 브리아나가 인공지능의 느낌을 가지고 있고, 사람 이름을 쓰고 있기 때문에 파란색 계통을 쓰면 너무 차가운 인상을 줄 것 같았다.\n\n![image](https://user-images.githubusercontent.com/20244536/106980385-6fec0100-67a3-11eb-8fe0-f17da068c27b.png)\n\n`심볼`을 구성하는 데 시간을 꽤 많이 썼다. 브리아나라는 이름을 가진 인공지능을 심볼로 형상화 하기가 좀 많이 까다로웠기 때문이다.\n\n아까 자비스를 잠깐 언급했는데, 영화에서도 자비스는 목소리만 나오지 어떠한 시각화된 형태로써 등장하지 않기 때문이다. 물론 어벤져스2에서 잠깐 신경망처럼 시각화되어서 보이긴 하는데, 심볼을 만들 때 딱히 도움이 되진 않았다.\n\n결과적으로 내가 최종적인 심볼을 선택할 때, iOS의 `Siri`가 도움이 되었다. 아이폰 유저들은 알겠지만, 시리를 호출하면 화면 아래에 곡선 파동들이 요동친다.\n\n하지만 시리는 좀 형태가 복잡하다. 시리를 참고하되 좀 심플한 버전으로 만들어보고 싶었고, 이것저것 많이 구성해보다가 결과적으로 매우 마음에 드는 심볼을 찾았다. 그렇게 해서 나온 최종 결과물은 이렇다.\n\n![image](https://user-images.githubusercontent.com/20244536/106980937-7169f900-67a4-11eb-97e0-2824d70ca3f5.png)\n\n## 확장 프로그램 개발\n\n나는 이번 프로젝트의 프론트엔드를 `Vue.js` + `Typescript`로 구성했고, 스타일링은 `Tailwind CSS`를 사용했다. 그리고 마지막으로 개발을 위한 개발 서버 오픈과, 패키지 배포를 위한 번들링으로 `Parcel`을 이용했다.\n\n확장 프로그램은 기본적으로 HTML 페이지 1개만 필요하기 때문에, `React`나 `Vue`같은 `SPA` 구조를 가진 프레임워크에 매우 잘 어울린다. 이유는 SPA의 라우팅 시스템이 앱을 확장성있게 개발하기 매우 편하기 때문이다.\n\n프론트엔드에서 타입스크립트는 아직까지는 프로덕션 레벨의 Vue에는 적용하긴 이르다고 생각해서 사용하고 싶지 않았는데, 개발 단계에서 `chrome` 객체의 타입 체킹이 절실했기 때문에 어쩔 수 없이 사용했다. 타입스크립트와 함께 [`@types/chrome`](https://www.npmjs.com/package/@types/chrome) 패키지를 설치하면 개발 단계에서 많은 도움을 받을 수 있다.\n\n초기 버전의 브리아나는 확장 프로그램을 열었을 때 내가 미리 지정한 코인 목록이 보이고, 가격이 실시간으로 보여져야 하기 때문에 간단한 `데이터베이스`와 거래소에서 제공하는 `웹소켓 API`가 필요하다.\n\n확장 프로그램에서만 사용하는 `chrome` 객체에는 브라우저에 로그인한 사용자 계정마다 데이터를 저장시키는 [`storage`](https://developer.chrome.com/docs/extensions/reference/storage/) 기능이 있고, 실시간 가격 정보는 업비트에서 제공하는 웹소켓 API를 통해 가져올 수 있었다.\n\n스토리지는 클라우드에 저장되기 때문에 같은 계정으로만 로그인하면 어떤 디바이스에서도 완벽하게 동기화된다.\n\n이 앱을 개발하는데 이틀 정도 걸렸다. 확장 프로그램 자체가 딱 필요한 기능 1개만을 위해서 만들기 때문에 금방 개발할 수 있었다.\n\n![briana-hd-min](https://user-images.githubusercontent.com/20244536/106984834-a0d03400-67ab-11eb-82b6-5b6105849512.gif)\n\n## 마켓에 등록하기\n\n앱을 만들어 놓고 사용자의 입장에서 사용해보니까 이거 진짜 너무 유용한 툴이다. 내가 산 코인들 목록 넣어두고 그냥 보고싶을 때 단축키로 탁 보고 바로 끄면 되니까 세상 편할 수가 없었다. 눈치도 안보인다.\n\n하지만 이 좋은 툴을 나만 쓰긴 아깝기 때문에, 앱을 무료로 마켓에 배포하고 싶었다. 그래서 기꺼이 내 돈 5불을 내고 개발자 등록을 한 뒤 배포를 위한 준비를 시작했다.\n\n마켓에 등록하기 위해서 최소 이정도는 준비가 되어야 한다.\n\n1. 웹사이트\n2. 앱 로고\n3. 앱 스크린샷 최소 1장\n4. 민감함 정보를 다루는 기능을 쓸 때, 왜 써야 하는지 기술하기\n5. 개인정보처리방침 URL\n\n![image](https://user-images.githubusercontent.com/20244536/106993074-44761000-67bd-11eb-9d3e-56f13f11c1e8.png)\n\n`웹사이트`는 사실 Github 리파지토리 링크를 넣는걸로도 충분하지만, 나는 브리아나를 좀 있어보이게 하고 싶어서 브리아나를 소개하는 한 장 짜리 웹사이트를 만들었다.\n\nGodaddy에서 [brianalabs.com](https://www.brianalabs.com) 도메인을 구입했고, 이후 트래픽 분석을 위해 Google analytics 까지만 적용했다.\n\n이 웹사이트도 하루만에 만들었고, Github를 이용해 무료로 호스팅했다. [여기](https://www.brianalabs.com)을 눌러서 웹사이트를 볼 수 있다.\n\n`앱 로고`와 `스크린샷`도 이미 만들어두었기 때문에 어렵지 않게 해결했다.\n\n사실 심사를 한 번 넣었는데, `개인정보처리방침`이 없어서 빠꾸를 한 번 먹었다. 아직은 브리아나가 개인정보를 수집하진 않는데도 거절하더라. 앱 기능과 관계없이 그냥 무조건 있어야 하는 듯 싶다.\n\n2번 만에 심사가 통과됐고, 브리아나라고 검색하면 이제 누구든지 코인 시세를 빠르게 확인할 수 있다! 😎\n\n![image](https://user-images.githubusercontent.com/20244536/106993543-4391ae00-67be-11eb-8c38-4a54eec32e6c.png)\n\n\u003C!-- ![image](https://user-images.githubusercontent.com/20244536/106993498-2bba2a00-67be-11eb-9d3d-856ce7967cd5.png) -->\n\n설치를 하고 나면 브라우저 오른쪽 상단에 브리아나 로고가 보여지면서 단축키를 누르거나, 심볼을 누르면 시세를 확인할 수 있다. 그럼 다들 성투하시길!\n\n![image](https://user-images.githubusercontent.com/20244536/106871020-1f2dc700-6715-11eb-98b7-4e00080b6eb7.png)\n\n## 앞으로의 브리아나\n\n사실 지금도 시세를 알아보는 부분에선 충분히 가치있지만, 트레이딩에 더 도움이 되려면 차트가 보이는 기능도 있어야 할거고, 장기적으로 매매도 되야 할 것이다.\n\n그리고 사용자 풀로 봤을 땐 주식을 보고 싶어하는 사람들이 훨씬 많을 것이다. 주식까지 코인과 통합해서 보여줄 수 있다면, 엄청나게 많은 잠재 고객들이 존재한다.\n\n사용자에게 고려한 더 강력한 기능을 제공하려면 창과 텍스트 사이즈를 조절하는 기능도 필요할 거고, 거래량이나 호가를 보는 등 더 많은 데이터를 보여주는 기능도 필요하다.\n\n그리고 매매 기능이나 강력한 알림 기능을 만들어서 수익을 창출할 수 있다면 작은 팀을 꾸릴 수도 있다.\n\n아무튼 확실한 건 나는 지금 이 트레이딩 시장에 빠져들고 있다. 투기가 아닌 올바른 투자를 위해 앞으로 몇 년이고 계속 브리아나를 개발하며 이 시장을 공부할거고, 매매도 많이 할 것이다.\n\n### 참고\n\n- https://looka.com\n- https://godaddy.com\n- https://brianalabs.com\n",{"path":1190,"title":1191,"description":1192,"created":1193,"category":1069,"rawbody":1194},"/github-pages-nuxtjs","평생 무료로 개인 블로그 운영하기","거의 대부분의 개발자들이 개인 블로그를 운영하라고 얘기한다. 나도 그렇게 생각한다. 왜냐면 분명히 내가 작성했던 코드인데도, 일주일만 지나도 기억이 안나기 때문이다. 그리고 웬만하면 공개해서 작성하라고 하고 싶다. 이미 우리는 누군가가 옛날에 썼던 글을 보고, 문제를 해결한 경험히 굉장히 많기 때문이다. 나는 이런 개발자들의 문화가 너무 좋다. 이런 개발자들의 문화가 다른 업종에도 접목된다면 정말 좋으련만.","2020-12-17","---\ncategory: tech\ntitle: 평생 무료로 개인 블로그 운영하기\nimage: https://user-images.githubusercontent.com/20244536/102336973-3aae0880-3fd5-11eb-8fd1-e3c184d6ab7e.jpg\nupdated: 2020-12-17\ncreated: 2020-12-17\npublished: true\n---\n\n거의 대부분의 개발자들이 `개인 블로그`를 운영하라고 얘기한다. 나도 그렇게 생각한다. 왜냐면 분명히 내가 작성했던 코드인데도, 일주일만 지나도 기억이 안나기 때문이다. 그리고 웬만하면 공개해서 작성하라고 하고 싶다. 이미 우리는 누군가가 옛날에 썼던 글을 보고, 문제를 해결한 경험히 굉장히 많기 때문이다. 나는 이런 개발자들의 문화가 너무 좋다. 이런 개발자들의 문화가 다른 업종에도 접목된다면 정말 좋으련만.\n\n\u003C!--more-->\n\n아무튼 얼마 전 블로그를 새롭게 오픈했다. 사실 `미디엄`, `브런치`나 `Velog`같이 훌륭한 서비스들이 이미 많이 존재한다. 그럼에도 내가 개인 블로그를 오픈한 건, 개발자로써 개인 도메인을 가지고 싶기 때문이다. 무엇보다 디자인을 내 마음대로 꾸밀 수 있다.\n\n그럼 개인 블로그를 운영하기 위한 최고의 솔루션인 [Github Pages](https://pages.github.com/)를 이용해보자. (개인적인 생각임)\n\n먼저 내가 생각하는 Github Pages의 장점은 크게 세 가지이다.\n\n1. 평생 무료\n2. HTTPS 신경 안써도 됨\n3. Github 하나의 플랫폼 내에서 운영을 위한 모든 인프라를 쉽게 해결 가능\n\nGithub가 망하지 않는 이상 평생 무료이고, Github Pages는 정말 쉽게 커스텀 도메인과 HTTPS까지 연결할 수 있다. ~~평생 Github에 충성하자.~~\n\n:Serieis{:type=\"forever\"}\n\n## 리파지토리 만들기\n\n![](https://user-images.githubusercontent.com/20244536/102364149-7f4b9b00-3ff9-11eb-82bc-80751707887c.png)\n\n일단 먼저 Github Pages를 이용하기 위해서 Github Repository를 만든다.\n\n> 기본적으로 도메인 주소는 https://USERNAME.github.io/REPOSITORY 의 포맷으로 만들어진다. 예를 들어, 사진처럼 구성하면 https://peterkimzz.github.io/article 로 만들어진다.\n\n## Github Pages\n\n![](https://user-images.githubusercontent.com/20244536/102364247-95f1f200-3ff9-11eb-84e3-ca6c0836fdcd.png)\n\n리파지토리 생성 후 `Settings` 설정으로 이동한 뒤, 아래로 내리다보면 `Github Pages` 라는 섹션을 발견할 수 있다. Github Pages의 호스팅 원리는, 해당 리파지토리에 푸시된 `브랜치`를 루트 폴더로 삼아 호스팅을 하게 된다. 브랜치 이름은 아무거나 해도 상관없다.\n\n> Github Pages에 호스팅하는 브랜치 이름은 관습적으로 **gh-pages** 라는 이름을 쓴다. 다른 이름을 써도 무방하다.\n\n## index.html\n\n이번 포스팅에선 블로그가 동작하는지만 알아보기 위해 1개의 정적 HTML 파일만 업로드하겠다.\n\n먼저 소스 코드를 컴퓨터에 clone 하자.\n\n```bash\n$ git clone https://github.com/USERNAME/REPOSITORY.git\n```\n\n다음, 루트 폴더에 HTML 파일을 만든다.\n\n```html [index.html]\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"en\">\n  \u003Chead>\n    \u003Cmeta charset=\"UTF-8\" />\n    \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    \u003Ctitle>Blog by Github Pages\u003C/title>\n  \u003C/head>\n  \u003Cbody>\n    \u003Cdiv>\n      \u003Ch1>Hello, Pages!\u003C/h1>\n    \u003C/div>\n  \u003C/body>\n\u003C/html>\n```\n\n빠르게 만들어주고, 리파지토리에 푸시해주도록 한다.\n\n```bash\n$ git add .\n$ git commit -m 'Added index.html'\n$ git push\n```\n\n코드를 푸시하고 나서 다시 Settings의 Github Pages 섹션으로 이동하면 아까는 보이지 않았던 main 브랜치를 찾을 수 있고, main으로 설정한 뒤 `Save`하면 끝. 해당 링크에 배포되었으니 확인해보라는 문구도 나온다. 너무 간단하다.\n\n![](https://user-images.githubusercontent.com/20244536/102366831-701a1c80-3ffc-11eb-9b28-c8284df820a5.png)\n\n![](https://user-images.githubusercontent.com/20244536/102366952-8fb14500-3ffc-11eb-9fd3-d5ec231449e7.png)\n\n이 글을 보는 사람들은 `React`나 `Vue` 같은 모던 웹 프레임워크를 사용할 확률이 높다. 나는 웹 프론트엔드를 주로 Nuxt.js를 사용하기 때문에 Nuxt 프로젝트를 main 브랜치에 전부 올리고, `Github Actions`를 이용해 main 브랜치에 푸시되면 알아서 빌드해서 gh-pages 브랜치에 다시 배포하게끔 CI/CD를 구성했다.\n\n일반적인 설정이라면 React나 Vue는 SPA라서 HTML 파일이 하나밖에 나오지 않지만, 정적 배포를 위해 모던 프레임워크는 모두 `정적 빌드` 옵션을 제공한다. 그 기능을 이용하면 엄청난 생산성을 누릴 수 있다.\n\nGithub Actions가 익숙하지 않은 사람들이라면 npm에 등록된 `push-dir` 라이브러리를 이용하자. 로컬 컴퓨터에서 쉽게 gh-pages 브랜치에 배포할 수 있다. 문서가 잘 되어있으니 천천히 읽어보고 구현해보자.\n\n> push-dir: https://www.npmjs.com/package/push-dir\n\n## 커스텀 도메인 붙이기\n\n사실 여기까지만 알아도 블로그를 운영하는데 전혀 지장이 없다. 오히려 github.io 도메인을 사용하면 전문적인 느낌이 나서 좋아보일 때도 있다. 하지만 Pages는 개인 블로그로만 쓰는 기능은 아니기 때문에, 커스텀 도메인이 필요할 때가 있다. 걱정하지 않아도 된다. 너무 쉽다.\n\n![](https://user-images.githubusercontent.com/20244536/102369674-8f667900-3fff-11eb-995c-18ab079b8fc8.png)\n\nGithub Pages 섹션을 보면, `Custom domain`을 넣는 부분이 있다. 여기에 원하는 도메인을 넣으면 된다. 그리고 커스텀 도메인은 `루트 도메인`과 `서브 도메인` 모두 지원한다.\n\n하지만 문서에서는 Apex(Root) 도메인을 사용하는 것 대신, **항상 서브 도메인**을 사용하는 걸 권장한다. 명확한 이유는 나와있지는 않지만, 루트 도메인은 DNS 제공 업체에서 A 레코드나 ANAME 등 다른 속성들과 함께 쓰이기 때문에 혼란이 있을 수 있다는 것으로 추측해본다. 또, www를 사용하는 도메인에 한해서 사이트 로딩이 더 빠른 등 여러 이점이 있다고 한다. 뭐 이유가 중요할까, 지금은 그냥 하라는 대로 하자.\n\n아무튼 도메인이 `example.com` 이라면 무조건 `www.example.com`으로 설정하면 된다는 말이다. input에 www를 포함한 도메인을 넣어주고 Save하자.\n\nSave하면 네임 서버를 찾을 수 없다고 경고가 나올 것이다. 이제 여기서 도메인을 구입한 사이트로 이동해야 한다. 나는 GoDaddy에서 도메인을 구입했다. 다른 DNS 제공 업체여도 원리는 같다.\n\n![](https://user-images.githubusercontent.com/20244536/102372403-832feb00-4002-11eb-9600-5e9bdbf58194.png)\n\n요약하자면 2가지를 DNS에 설정하면 된다.\n\n1. **루트 도메인**에 대해, A 레코드에 Github 서버의 IP 주소 4개를 넣기\n2. 원하는 **서브 도메인**에 대해, CNAME 레코드에 자신의 github pages url 넣기\n\n이름 부분이 @인 업체도 있을 것이고 아닌 곳도 있을텐데, @는 루트 도메인을 가리키는 기호이다. 정상적으로 DNS 매핑되기 까지에는 시간이 조금 걸릴 수 있으니 기다리면서 **마지막 세팅**을 하러 다시 Github Pages로 돌아가자.\n\n![](https://user-images.githubusercontent.com/20244536/102373037-241ea600-4003-11eb-99fc-499c43bdcec8.png)\n\n커스텀 도메인을 넣으면 Enforce HTTPS 옵션이 해제되어있을텐데 이것을 켜주면 끝이다. 심지어 갱신도 알아서 해준다. 진짜 너무 쉽다.. 세상 좋다..\n\n![](https://user-images.githubusercontent.com/20244536/102373060-297bf080-4003-11eb-919a-a04651e24f70.png)\n\n정상적으로 설정했다면 이제 평생 무료로, 무한으로 즐기자. 😎\n\n### 참고\n\n- [Configuring a custom domain for your GitHub Pages site](https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/configuring-a-custom-domain-for-your-github-pages-site)\n- [Getting started with GitHub Pages](https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/getting-started-with-github-pages)\n",{"path":1196,"title":1197,"description":1198,"created":1199,"category":1069,"rawbody":1200},"/supasbase-overview","Firebase를 대체할 오픈소스 프로젝트, Supabase","Supabase는 구글 Firebase를 엔터프라이즈 레벨에서도 사용 가능하도록 만든 오픈소스 프로젝트이다. 현재는 베타 서비스이다.","2020-12-15","---\ncategory: tech\ntitle: Firebase를 대체할 오픈소스 프로젝트, Supabase\nimage: https://media.vlpt.us/images/peterkimzz/post/b2873856-20b6-49a4-a8f4-176a94c92d23/08f3d41684b91f7d68810459b2356ecb4819c382.png\nupdated: 2020-12-15\ncreated: 2020-12-15\npublished: true\n---\n\n[`Supabase`](https://supabase.io/)는 구글 Firebase를 엔터프라이즈 레벨에서도 사용 가능하도록 만든 오픈소스 프로젝트이다. 현재는 베타 서비스이다.\n\n컴퓨터에 직접 설치하는 방식은 아니고, Firebase처럼 클라우드로 제공되는 서비스이다.\n\n내가 Firebase를 사용하면서 아쉬웠던 건, 데이터가 많아졌을 때 인덱싱을 쉽게 적용시켜서 빠르게 레코드를 읽어오는 기능이 강력하진 않아서 데이터가 많은 앱에서는 사용하기 힘들다는 점이다.\n\n\u003C!--more-->\n\n## Supabase?\n\nSupabase 팀은 이 프로젝트를 이렇게 소개한다.\n\n> Create a backend in less than 2 minutes. Start your project with a Postgres Database, Authentication, instant APIs, and realtime subscriptions.\n\n2분 안에 Postgres, 인증과 API, 실시간 구독을 구현할 수 있다는 얘기이다. 사실이라면 엄청나게 편리할 것 같다.\n\n현재 회원가입은 Github OAuth로만 가능하고, 올해 12월까지 가입하는 유저들은 자동으로 2년 가량의 Base Tier 크레딧을 받을 수 있다.\n\n![](https://images.velog.io/images/peterkimzz/post/8723a9c7-db0f-4385-9575-ebbef1b79a4d/image.png)\n\n가입하고 Organization과 Database를 만들고 프로비저닝이 완료될 때 까지 기다리면 이런 대시보드가 나온다.\n\n데이터베이스에 대한 정보도 볼 수 있는데, 비밀번호를 제외하고 전부 자동으로 설정되고, 변경도 되지 않는다. 베타 버전이라 그런 것 같은데, 오히려 이름 짓느라 고민하지 않아도 되서 개인적으로는 좋았다.\n\n![](https://images.velog.io/images/peterkimzz/post/9fe72d58-cbde-4966-a1ba-632196d5cfaa/image.png)\n\n## Table 생성\n\n![](https://images.velog.io/images/peterkimzz/post/e062a75b-6dae-4b66-a556-12511cf2bf71/image.png)\n\n테이블명과 PK를 설정하는 부분인데, PK는 4가지 타입으로 제공된다. 나는 uuid로 선택했다.\n\n![](https://images.velog.io/images/peterkimzz/post/10b41a97-4401-4666-b20d-2344cd0eba37/image.png)\n\n테이블과 PK를 설정하면 칼럼도 추가할 수 있는데 UI가 상당히 직관적이다.\n\n아직은 베타 버전이라 현재는 웹사이트를 통해서만 Database, Table을 만들 수 있다.\n\n## API\n\n사실 데이터베이스는 서버쪽에서 데이터베이스 설정이나 스키마, 레코드 Read/Write를 코드로 관리하기 편해야한다. ORM을 사용하는 이유이기도 하다.\n\n클라이언트측 라이브러리는 현재 자바스크립트만 제공된다. 패키지 이름은 [`@supabase/supabase-js`](https://www.npmjs.com/package/@supabase/supabase-js)이고, 사용해보니 타입스크립트도 지원된다.\n\n### 설치\n\n```bash\n$ yarn add @supabase/supabase-js\n# npm i @supabase/supabase-js\n```\n\n### 사용법\n\nnpm으로 제공하는 라이브러리를 이용해 간단하게 인스턴스를 구현 가능하다.\n\n```ts\nimport { createClient } from \"@supabase/supabase-js\";\n\nconst supabaseUrl = \"https://siwwiuleewkpfbschahw.supabase.co\";\nconst supabaseKey = process.env.SUPABASE_KEY as string;\nconst supabase = createClient(supabaseUrl, supabaseKey);\n```\n\n**레코드 쓰기**\n\n```ts\nawait supabase\n  .from(\"User\")\n  .insert([{ some_column: \"someValue\", other_column: \"otherValue\" }]);\n```\n\n**레코드 읽기**\n\n```ts\nlet { data: User, error } = await supabase.from(\"User\").select(\"*\");\n\nlet { data: User, error } = await supabase\n  .from(\"User\")\n  .select(\"some_column, other_column\")\n  .range(0, 9);\n\nlet { data: User, error } = await supabase.from(\"User\").select(`\n    some_column,\n    other_table (\n      foreign_key\n    )\n  `);\n```\n\n**이벤트 훅**\n\n```ts\n// insert\nconst User = supabase\n  .from(\"User\")\n  .on(\"INSERT\", (payload) => {\n    console.log(\"Change received!\", payload);\n  })\n  .subscribe();\n\n// specific row\nconst User = supabase\n  .from(\"User\")\n  .eq(\"column_name\", \"someValue\")\n  .on(\"*\", (payload) => {\n    console.log(\"Change received!\", payload);\n  })\n  .subscribe();\n```\n\n기존 ORM에 익숙하다면 별 다른 문서없이 바로 이해가 될 정도로 직관적이다.\n\n읽기 쓰기 이외에도 필터링, 수정 삭제 등 필수적으로 제공되야 할 기능들이 제공된다.\n\n## GraphQL vs Supabase\n\nGraphQL에 익숙한 사람들을 위해 gql과 비슷하게 사용할 수 있는 인터페이스도 제공된다.\n\n현재는 GraphQL만큼 강력하진 않겠지만, 그에 준하는 기능들을 가지게 된다면 엄청난 장점이 될 것 같다.\n\n```ts\n// graphql\nconst { loading, error, data } = useQuery(gql`\n  query GetDogs {\n    dogs {\n      id\n      breed\n      owner {\n        id\n        name\n      }\n    }\n  }\n`);\n\n// supabase\nconst { data, error } = await supabase.from(\"dogs\").select(`\n      id, breed,\n      owner (id, name)\n  `);\n```\n\n## 회원 관리\n\nFirebase나 Supabase같은 툴들이 가진 가장 큰 장점 중 하나는 이 `회원 관리` 기능이다.\n\n**회원 가입**\n\n코드 3줄이면 비밀번호 암호화 알고리즘을 구현할 필요없이 회원을 가입시킬 수 있다.\n\n```ts\nlet { user, error } = await supabase.auth.signUp({\n  email: \"someone@email.com\",\n  password: \"nshOpnIDkwgnouwsVLnk\",\n});\n```\n\n**로그인**\n\nSupabase는 유저가 회원가입하면 고유한 id를 만들고, 서버가 켜져 있는 동안 어디서든 `supabase.auth.user()` 를 호출하는 것으로 유저 정보를 가져올 수 있다.\n\n물론 이 id를 다른 테이블에서 FK로 넣어줄 수 있다.\n\n```ts\nlet { user, error } = await supabase.auth.signIn({\n  email: \"someone@email.com\",\n});\n\n// OAuth\nlet { user, error } = await supabase.auth.signIn({\n  provider: \"github\",\n});\n```\n\n**기타 편의 기능**\n\n```ts\n// 비밀번호 재설정 이메일 보내기\nlet { data, error } = await supabase.auth.api.resetPasswordForEmail(email)\n\n// 현재 세션에 저장된 유저 정보 가져오기\nconst user = supabase.auth.user()\n\n// 초대 메일 보내기\nlet { user, error } = await supabase.auth.api.inviteUserByEmail(\n  email: 'someone@email.com'\n)\n```\n\n초대 메일을 보내는 기능을 써봤더니, 아래처럼 메일이 도착했다.\n\n![](https://images.velog.io/images/peterkimzz/post/5062b7ad-3a72-4ce6-8f0f-bf279419de4c/image.png)\n\n저 localhost로 보이는 부분은 웹 대시보드에서 변경 가능하다.\n\n추가로 Firebase와 동일하게 회원 관리를 위한 이메일 템플릿은 웹 대시보드에서 수정이 가능하다.\n\n## SQL\n\n내부적으로 PostgreSQL을 사용하기 때문에, 이렇게 웹 브라우저에서 SQL을 실행시키는 인터페이스도 제공된다.\n\n![](https://images.velog.io/images/peterkimzz/post/4741f89f-921f-4d89-b76b-5ef16d9a2836/image.png)\n\n**Quick start**\n\nSQL Editor 탭에서는 몇 가지 예제를 제공하고 있다.\n\n아래 예제는 Supabase에서 만들어 둔 Todo list 앱 개발을 위한 테이블이다. 테이블 읽기/쓰기 정책을 SQL로 설정할 수 있다.\n\n```sql\n--\n-- For use with https://github.com/supabase/supabase/tree/master/examples/todo-next-js\n--\n\ncreate table todos (\n  id bigint generated by default as identity primary key,\n  user_id uuid references auth.users not null,\n  task text check (char_length(task) > 3),\n  is_complete boolean default false,\n  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null\n);\n\nalter table todos enable row level security;\n\ncreate policy \"Individuals can create todos.\" on todos for\n    insert with check (auth.uid() = user_id);\n\ncreate policy \"Individuals can view their own todos. \" on todos for\n    select using (auth.uid() = user_id);\n\ncreate policy \"Individuals can update their own todos.\" on todos for\n    update using (auth.uid() = user_id);\n\ncreate policy \"Individuals can delete their own todos.\" on todos for\n    delete using (auth.uid() = user_id);\n```\n\n## 마무리\n\nSupabase는 런칭 후 9달 동안 약 3,000개 가량의 데이터베이스가 만들어졌다고 한다. 그리고 현재 YC와 Mozila에게 총 약 60억의 시드 투자를 받았다.\n\n직접 간단하게 훑어봤는데 아직은 기업에서 쓰긴 기능들이 약하지만, 10GB 용량이 무료로 제공되고 인터페이스가 간단하기 때문에 Firebase 대신에 공부나 토이 프로젝트로 쓰기는 좋을 것 같다.\n\n곧 Storage와 Function 기능을 출시할 예정이라고 한다. 앞으로 더 많은 기능이 제공된다면 정말 강력한 툴이 될 것 같다.\n\n### 참고\n\n- https://supabase.io/\n- https://github.com/supabase/supabase\n",1776591816600]