위대한 탄생
언젠가 맹실장님에게 물어본적이 있습니다. “좋은 코드를 어떻게 쓰나요?” 다짜고짜 좋은 코드를 어떻게 쓰냐니, 마치 “어떻게 하면 백만장자가 될 수 있을까요?”같은 질문이 아니었을까… 하여튼 그 때 맹실장님은 대답 대신 내가 쓰고 싶단 좋은 코드가 뭐냐고 반문하셨습니다. 그 질문을 받으니 솔직히 뭐가 좋은 코든진 잘 모르겠지만 그게 제 코드는 아니라 것은 잘 알겠다…였는데 이 질문을 하게 된 근본적인 이유는 다음과 같은 이유에서였습니다.
고수님들과 제 코드를 비교해보면 우선 생김새부터 달랐는데, 그것도 같은 기능을 하는 프로그램을 만들었을 때 조차그랬기에 그 이유가 너무 궁금했습니다. 그리고 그 이유에는 단순히 제가 JavaScript 언어 스펙을 몰라서라기 보단 말로 설명할 수 없는 제가 모르는 무언가가 더 있을 것이다라는 느낌이 들었습니다. 그래서 고수님들의 코드에서 풍겨지는 느낌적인 느낌의 이유가 궁금해 맹실장님에게 물어봤던 것! 하지만 그날 결국 얻은 답은 어떤 코드가 좋은 코드냐도, 어떻게 좋은 코드를 쓸 수 있냐도 아닌 “좋은 코드라는 것에는 다른 사람들이 합의하며 이뤄낸 기준이 있고, 그 합의를 이뤄내기 위해 체계적으로 정리된 학문이 있다는 것”정도였습니다.
그렇게 좋은 코드란 무엇인가에 대해 의문을 갖고 있던 중 지난 S70 스터디에서 함수와 객체를 배우면서 고수님들의 코드와 비슷해 보이는 코드들을 볼 수 있었고, 이어 진행되는 이번 스터디에서 드디어 좋은 코드의 비밀이 밝혀졌습니다! CodeSpitz라는 시리즈 아래 4단계로 진행되는 S71 스터디에서는 지난 스터디에서 배운 객체와 객체지향프로그램을 이어 디자인 패턴을 배우게 됩니다.
첫번째 시간에는 제가 그렇게 궁금해하던 “좋은 코드가 무엇인지”를 배우고, 그것을 구현하기 위해 “Json파일을 읽어와서 데이터를 html 테이블로 출력해주는 프로그램”을 만들어가면서 객체 협력 모델을 살펴보겠습니다.
무엇이 중헌디? 호스트 코드
우리가 만들고자 하는 것은 위에서 언급한대로 Json파일을 읽어와서 데이터를 html의 테이블로 출력해주는 프로그램입니다.
기본 인터페이스는 다음과 같습니다.
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>CodeSpitz 71-1</title> </head> <body> <section id="data"></section> <script> const Table = (_=>{ return class{ } })(); </script> </body> </html>
이제 내용을 채워보겠습니다.
…
…
…
….잉..?
참 이상하게도 강의를 들을 때에는 코드 내용이 너무 당연하다고 생각했는데, 교안이나 설명 없이 처음부터 코드를 짜려고 하니 턱 하고 막혀버렸습니다. 내용을 까먹은 것도 아니고 전 분명 테이블이 무슨일을 하는지도 알고 있었는데 말이죠. 문제는 제가 호스트 코드를 생각하지 않고 다 안다고 생각하며 클래스부터 코드를 채워나가려 했기 때문이었습니다.
호스트 코드는 클래스에 정해둔 액션이 실제로 행해지는 곳입니다. 실제로 일을 하는 건 인스턴스니 인스턴스가 어떤 모양으로 어떤 일을 할 지 부터 정했어야 했는데, 저는 클래스 자체에만 너무 집중한 나머지 이 클래스를 실제로 사용할지 생각도 하지 않고 무작정 클래스부터 코드를 써내려가려고 했습니다. 클래스가 어떤 모습으로 작동할 지를 모르니 코드를 써내려 갈 수 없던게 당연… 그래서 실제로 코드를 사용하게 될 쪽의 모습을 먼저 생각을 해봤습니다.
“클래스니까… 우선 new키워드로 Table클래스의 인스턴스를 만들겠지. 그리고 나서 load를 호출할거고. load를 호출할 때는 뭘 load할 지 모르니까 인자로 파일명이라든지 경로를 인자로 받아야겠네. 그리고 render를 호출해서 화면에 출력하면 되겠어. 어디에 랜더링 할 지 모르니까 render함수의 인자로 출력할 곳의 부모 셀렉터를 받아야겠다.”
const Table = (_=>{ return class{ constructor(){ ... } load(url){ ... } render(parent){ ... } } })(); const table = new Table(); table.load("71_1.json"); table.render("#data");
“근데 곰곰히 생각해보면 데이터 load되면 바로 render되는거 아닌가? 그럼 load와 render는 분리되어 따로 호출이 되는게 아니라, load가 render를 바로 호출하게 만들어야겠다. 내부함수니까 _로 고쳐주고… 근데 그러면 render를 어디에 할 지 모르잖아? load의 인자로 parent를 받는 것은 이상하고… 그렇다면 생성자로 인스턴스 만들 때 생성자 인자로 출력할 곳의 부모 셀렉터를 받게 해야겠다.
그럼 호스트 코드는 다음처럼 바뀌게 되고 Table클래스는 호스트 코드를 소화할 수 있는 모양으로 코드를 써내려가면 되겠군!”
const Table = (_=>{ return class{ //인자 추가 constructor(parent){ ... } load(url){ ... } //내부 함수로 변경 _render(){ ... } } })(); const table = new Table("#data"); table.load("71_1.json");
호스트 코드부터 생각하니 클래스의 모양을 정할 수 있었고, 클래스의 모양이 정해지니 어떤 내용의 코드를 클래스에 담아야할 지 알 수 있었습니다. 게다가 나중에 정해진 클래스의 모양을 보니 처음에 정한 모습과 달라져서 호스트 코드를 생각하지 않았다면 다시 만들어야 할 뻔했습니다. 호스트 코드의 중요성을 크게 느꼈던 순간이었습니다.
해치지 않아요~ ECMA Script 2015+
사실 저는 S71 스터디 첫번째 시간에는 참석을 하지 못했습니다. 동영상 강의를 기다리며 교안을 보는데…
으아니~~?!? 교안의 양이 어마어마해서 1차 멘붕…?
그리고 내용을 보니 오잉 이거슨무슨말인거신가…? ECMAScript 2015+이전 버전도 헉헉대는 저로서는 ES 2015+의 문법이 대거 등장해 2차 멘붕…?
두려움에 떨며 오늘의 강의를 소화할 수 있을까 걱정을 했지만 다행히도 ES 2015+를 전부 알아야 오늘의 강의 주제인 ‘객체 협력 모델’을 이해할 수 있는 것은 아니었습니다. (하지만 ES 2015+의 내용을 모르면 강의 코드의 내용을 온전히 이해하기 힘드므로 새로운 문법적인 표현이나 기능은 찾아가면서 함께 보도록 해요~^^;;;)
ES 2015+ 문법을 이용해 Table 클래스를 완성해보도록 하겠습니다.
constructor
Table 생성자 함수에서는 테이블을 출력할 부모 셀렉터 parent를 인자로 받아 내부 변수에 저장합니다. 내부 변수 Private은 ES 2015+의 새로운 데이터 타입인 Symbol로 만들었습니다. Symbol은 다른 어느 값과도 충돌하지 않는 일종의 문자열 값이라고 볼 수 있는데 직접 참조를 하지 않는 이상 Symbol의 값을 찾아내서 접근하는 것이 어렵기 때문에 Symbol을 이용하면 private 변수처럼 사용할 수 있다고 합니다.
const Table = (_=>{ //클로저 구간에 Symbol 생성 const Private = Symbol(); return class{ constructor(parent){ if(typeof parent != 'string' || !parent) throw "invalid param"; //Symbol에 parent키로 parent 저장 this[Private] = {parent}; } ... } })();
Load
Load메서드는 파일을 불러올 url을 인자로 받아 파일의 내용을 읽고 json함수를 이용해 json 형태로 파싱된 데이터를 얻습니다. 파일을 읽어오고 json으로 파싱하는 것은 비동기 작업인데 기존의 자바스크립트으로 비동기 함수를 작성하면 코드가 복잡해지고 예외처리라든지가 까다로워지는 경향이 있었습니다. ES 2015+부터는 Promise객체를 도입해 Promise의 then 메서드를 통해 비동기 작업을 더 간결하고 효과적으로 처리할 수 있게 되었습니다. 한편, then 메서드는 async와 await의 조합으로 더 간결하게 표현할 수 있는데 이 방식으로 Load함수를 작성해보도록 하겠습니다.
//async로 비동기 처리 async load(url){ //await으로 Promise의 then을 대체 const response = await fetch(url); //받아온 데이터를 json으로 파싱 const json = await response.json(); const {title, header, items} = json; //밸리데이션 if(!title || !header || !items.length) throw 'invalid data'; //심볼 Private에 json 데이터 객체 복사 Object.assign(this[Private], {title, header, items}); this._render(); }
_render
_render에서는 불러온 데이터를 실제로 화면에 출력하는 기능을 수행합니다. HTML에 Table로 데이터를 그리기 위해 DOM Element를 만들고 내용을 집어넣는 작업들이 반복됩니다.
이 작업을 위해 저는 반복문을 썼지만 반복문 대신 reduce를 사용할 수 있습니다. reduce는 여러개의 원소를 가진 배열을 한가지 값으로 수렴하게 해주는 메서드로, 두개의 인자를 받습니다. 첫번째 인자는 콜백함수고, 두번째 인자는 초기값인데 초기값 인자를 넘겨주지 않으면 배열의 첫번째 인덱스 값을 사용합니다. 콜백함수는 콜백함수가 리턴한 값, 현재 인덱스의 배열 값, 현재 인덱스를 3가지를 인자로 받습니다.
처음에는 reduce를 이해하기가 어려워서 그냥 반복문 쓰면 안되나 싶었는데, 제어문을 잘 못쓰는 저로서는 제어 로직을 직접 기술하기 보다 JavaScript에서 템플릿으로 제공하는 reduce를 사용해 반복 알고리즘을 구현하는 편이 나을 것 입니다. ES 2015+이후에 굉장히 많이 사용하는 함수라고 하니 제가 익숙해지는 수 밖에 없는 것 같습니다^^;;;
//for문으로 Table의 header을 그린다면... const thead = document.createElement("thead"); for(let i = 0; i < fields.header.length; i++){ const th = document.createElement("th"); th.innerHTML = fields.header[i]; thead.appendChild(th); } table.appendChild(thead); parent.appendChild(table); //reduce를 이용한다면... table.appendChild( //reduce가 for를 대체 fields.header.reduce((thead, data) => { const th = document.createElement("th"); th.innerHTML = data; thead.appendChild(th); return thead; }, document.createElement("thead")) ); parent.appendChild(table);
완성코드(VER 1)
<!doctype html> <html> <head> <meta charset="utf-8"> <title>CodeSpitz71-1(VER#1)</title> </head> <body> <section id="data"></section> <script> const Table = (_=>{ const Private = Symbol(); return class{ constructor(parent){ if(!parent) throw 'invalid parent'; this[Private] = {parent}; } async load(url){ const response = await fetch(url); if(!response.ok) throw 'Data Load Failed'; const json = await response.json(); if(!json || !json.title || !json.header || !json.items) throw 'Invalid data'; const {title, header, items} = json; Object.assign(this[Private], {title, header, items}); this._render(); } _render(){ const fields = this[Private]; const parent = document.querySelector(fields.parent); if(!parent) throw 'Invalid Parent'; parent.innerHTML = ''; const table = document.createElement('table'); const caption = document.createElement('caption'); caption.innerHTML = fields.title; table.appendChild(caption); table.appendChild( fields.header.reduce((thead, data)=>{ const th = document.createElement('th'); th.innerHTML = data; thead.appendChild(th); return thead; }, document.createElement('thead')) ); parent.appendChild(table); parent.appendChild( fields.items.reduce((table, row)=>{ table.appendChild( row.reduce((tr, data)=>{ const td = document.createElement('td'); td.innerHTML = data; tr.appendChild(td); return tr; }, document.createElement('tr')) ); return table; }, table) ); } } })(); const table = new Table("#data"); table.load("71_1.json"); </script> </body> </html>
좋은 코드의 비밀
우리가 오늘 만들고 싶었던 프로그램이 완성되었습니다. 보통의 저 같으면 완성을 기뻐하며 원래 코드 파일을 닫았을 것입니다. 프로그램이 잘 돌아가니까요^.^….. 하지만 여기서 우린 멈출 수 없습니다. 왜냐하면 보통의 프로그래머들에게 일어나는 일들은 다음과 같거든요.
동…동공지진… 하지만 클라이언트님의 요청이라면 해드려야죠~_~ 이전에 작성했던 코드를 오랜만에 열어 봅니다. 잘 기억도 안나는데… 수정을 하다보니 에러가 나네요. 디버깅을 하며 또 수정이 일어납니다. 수정의 늪에 빠져버렸습니다…ㅠㅠ
이처럼 “프로그램은 변한다”라는 사실은 프로그래밍의 세계에서 유일하게 변하지 않는 법칙입니다. 따라서 좋은 코드는 이 “프로그램은 변한다”라는 사실에 잘 대응할 수 있는 코드겠죠. 즉 코드가 수정되었을 때 변화의 여파가 아예 없거나 혹여 있더라도 최소한의 영향만 미쳐 관련된 부분에 수정을 덜 해도 되는 코드가 좋은 코드인 것입니다.
그럼 수정의 영향을 덜 받게 하려면 어떻게 해야할까요? 프로그래밍에서는 이를 위해 격리(isolation)이라는 방법을 취합니다. 격리의 사전적인 의미는 ‘다른 것과 통하지 못하도록 사이를 막아두거나 떼는 것’을 의미합니다. 코드를 분리하라는 의미인 것 같은데 어떤 기준으로 어떻게 나누어야 할까요? 여러가지 기준이 있겠지만 크게 ‘역할’과 ‘변화율’에 따라 격리 할 수 있습니다.
역할로 격리하는 것은 책임이 누구에게 있는지에 집중해 분리한 반면, 변화율은 변화의 원인이나 주기에 따라 자주 변하는 것은 자주 변하는 것끼리 묶어두고 덜 변하는 것은 덜 변하는 것끼리 분리해두는 것을 의미합니다. 그래서 강한 응집성, 약한 의존성을 구현하도록 한다는데…
크항항 무슨말인지 모르겠습니다! 코드를 통해 구체적인 예로 살펴보겠습니다.
역할에 따른 격리 : Loader와 Renderer
우리가 위에서 짠 Table 클래스는 크게 2가지 기능이 있는 것을 알 수 있습니다.
- 데이터를 불러온다.
- 불러온 데이터를 화면에 그린다.
위 ver 1 코드에서는 두가지 있음에도 불구하고 하나의 Table 클래스에 함께 구현되어 있었습니다.
이제는 기능을 역할로 나눠 데이터를 불러오는 역할은 Loader객체가, 불러온 데이터를 화면에 그리는 역할은 Renderer객체가 맡도록 합니다.
Host
(까먹지 말고) 호스트 코드부터 작성합니다.
const loader = new Loader("71_1.json"); loader.load((json)=>{ const renderer = new Renderer(“#data”); renderer.setData(json); renderer.render(); });
호스트 코드의 내용을 바탕으로 클래스를 작성합니다.
Loader
Loader에서는 json을 가지고 와서 내부 Symbol에 불러온 데이터를 저장하고 콜백함수에 데이터를 넘겨줍니다.
const Loader = class{ constructor(url){ if(!url || typeof url !== 'string') throw 'Invalid url'; this._url = url; } async load(end){ const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; const json = await response.json(); if(!json.title || !json.header || !json.items) throw 'Invalid data'; end(json); } }
Renderer
Renderer에서는 setData메서드를 통해 json 데이터를 받고 받은 내용을 render메서드로 화면에 출력합니다.
const Renderer = (_=>{ const Private = Symbol(); return class{ constructor(parent){ if(!parent) throw 'Invalid parent'; this[Private] = {parent}; } setData(data){ if(!data || !data.title || !data.header || !data.items) throw 'Invalid data'; Object.assign(this[Private], data); } render(){ //... 테이블 그리는 로직은 동일 } } })();
이렇게 ‘역할’을 기준으로 Table 객체를 Loader, Renderer객체로 분리했습니다.
객체 협력 모델
변화율로 나누는 것은 무엇인지 알아보기 전에 한가지 짚고 가야 할 것이 있습니다. 바로 Loader와 Renderer의 관계입니다. 객체 지향 프로그래밍은 단순히 객체를 만들어서 사용하면 끄읏-!이 아닙니다. 역할별로 나눠진 객체들이 문제를 해결하기 위해 서로 협력 하는 ‘객체 협력 모델’이 되어야 합니다.
현재 만들어진 코드에서는 Loader와 Renderer가 Json 데이터를 화면에 출력하는 문제를 해결하기 위해 각자의 역할에 따라 협력하고 있을까요?
답은 ‘아니요’ 입니다.
저는 이 부분이 굉장히 헷갈렸습니다. Loader도 클래스로 만들어서 사용하고 있고, 자신의 역할인 json 파일로부터 데이터를 잘 불러오고 있으니까 객체 협력 모델링이 구현된 것 아닌가?라고 생각했으니까요.
하지만 만약에 json이 아니라 csv 파일을 읽어들여야 된다면 어떻게 해야 할까요? 그 때에는 Renderer가 csv를 읽을 수 있는 어떤 Loader를 찾아서 데이터를 달라고 해야 할 것입니다. 데이터의 종류나 공급 방법이 바뀔 때 그 변화에 따른 대응을 화면 출력의 역할을 맡은 Renderer가 해야하는 것입니다.
게다가 코드를 보더라도 Renderer가 함께 협력하고 있는 것은 Loader가 반환한 json값입니다. Loader는 그 json을 얻기 위한 도구와 같은 것이었죠. 마치 내가 sin값이 필요하면 어디에서나 호출해서 사용하는 범용 함수 Math.sin처럼 json 파일의 내용을 얻기 위해 누구든 사용해 호출할 수 있는 유틸리티(도우미 객체)였습니다.
객체 협력 모델을 구축하기 위해서는 유틸리티가 아니라 Render와 함께 협업하여 문제를 해결해나갈 구성원으로서의 Loader가 필요합니다. Loader를 data를 어디선가 읽어와서 render에게 공급하는 책임을 맡은 구성원으로 변경하고 그러한 역할이 잘 드러나도록 이름부터 변경합니다. Loader는 Data로, load라는 메서드는 데이터를 공급한다는 의미에서 getData로 바꾸겠습니다.
//Loader에서 Data로 이름 변경 const Data = class{ constructor(url){ if(!url || typeof url !== 'string') throw 'Invalid url'; this._url = url; } //load에서 getData로 이름 변경 async getData(){ const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; return await response.json(); } };
Data 객체로 바뀌었으니 Renderer의 코드도 바뀌어야 합니다. Renderer는 render메서드 호출 시 받게 되는 Data객체를 통해 getData메서드를 호출하도록 합니다. 여기서 한가지 주의할 점은 getData 메서드가 비동기 작업이기 때문에 이를 이용하는 render에도 async를 추가해주어야 합니다.
const Renderer = (_=>{ const Private = Symbol(); return class{ //인자로 table을 출력할 부모 셀렉터를 받음 constructor(parent){ ... } //async로 비동기 처리 async render(data){ //인자가 계약을 맺은 Data 타입인지 확인 if(!data || !(data instanceof Data)) throw 'Invalid data type'; //Data타입이면 getData로 데이터 공급받음 const json = await data.getData(); //밸리데이션 if(!json || !json.title || !json.header || !json.items) throw 'Invalid table data'; // ..이하 내용은 동일 } } })();
json데이터 출력을 위해 데이터 공급 역할을 맡은 Data와 화면 랜더링을 맡은 Renderer가 각자 역할을 맡고 협력하여 문제를 해결하고 있습니다.
변화율에 따른 격리 : Data와 JsonData
Data와 JsonData
이제 객체 협력 모델로 Data와 Renderer의 관계를 코드로 재정의하는 것까지 마쳤습니다.
이제 완성된 코드를 클라이언트에게 보내볼…
우…우려했던 일이 일어났군요 ㅠㅠ 어쩌겠습니까… 클라이언트님이 원하시는대로 해드려야죠.
저라면 다음과 같이 수정을 했을 것입니다.
const Data = class{ constructor(url){ if(!url) throw 'Invalid URL'; this._url = url; } async getData(type){ if(type === 'json'){ const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; return await response.json(); //else if로 csv 불러오기 로직 추가! }else if(type === 'csv'){ const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; const data = await response.text(); const csv = data.split('\n').reduce((p, c)=>{ p.push(c.split(',')); return p; }, []); return csv; }else{ return this.url; } } };
getData 호출 시 type을 인자로 받아 종류에 따라 파일을 불러오는 로직을 실행하도록 하는 거죠. 만약에 클라이언트가 XML파일을 스펙에 추가하면 if 분기를 더하면 되겠죠.
움핳핳! 이제 파일 종류 추가따위 무섭지 않다..!!!
변화에 대응하는 완벽한 코드!!!!!
….일리가 없겠죠^^
저처럼 코드를 짜면 데이터의 종류가 추가될 때 마다 getData 내부에는 수많은 if가 생기게 됩니다. if가 많아진다 싶으면 의심을 해봐야합니다. 왜냐면 if가 많아질 수록 많은 분기가 생겨서 따져봐야할 경우의 수와 관리해야 할 상태가 늘어나게 되므로 그만큼 버그가 생길 확률도 높아지게 되니까요^^;; 게다가 수정을 했으면 무조건 수정된 부분의 코드는 모두 테스트를 돌려봐야합니다. csv인지 판단하는 if 한줄이 더해졌다고 이전에 잘 돌고 있었던 json 부분까지 모두 테스트를 하게 됩니다. 저런, 수정의 여파가 어마어마 합니다….
또한 더 중대한 문제가 있었으니… Data 내부에서 if로 조건을 분기해 다른 형식으로 데이터를 반환하게 될 경우 Data의 형식을 json으로 변환해서 줘야합니다. 왜냐하면 Renderer는 Data를 통해 데이터를 받을 때 1가지 형식을 반환 받을 것을 기대하고 있기 때문이죠. 따라서 현재는 csv 파일을 읽었어도 json형식으로 변환해 데이터를 리턴해야 합니다.
그렇다면 어떻게 해야할까요? 격리시키면 됩니다.
문제는 어떻게 격리시키냐인데… Renderer와 Data를 다른 클래스로 나눠 격리시켰던 것처럼 다른 클래스로 나눌까요?
Renderer는 Data객체로부터 데이터를 받고 싶어합니다. 다만, 어떤 데이터를 받을 것인가는 변화가 있을 수 있는 부분이죠. 이러한 경우에는 부모-자식 상속관계를 이용합니다. 즉 Data라는 형은 부모 클래스로 유지하되, 변화율이 다른 ‘다른 데이터 타입을 공급받는다’라는 부분은 자식 클래스로 분리해 구현하는 것이죠.
코드로 살펴보겠습니다.
const Data = class{ constructor(){ ... } async getData(){ let json = await this._getData(); return json; } //데이터 불러오는 로직은 자식 클래스에서 오버라이드 async _getData(){ throw "_getData must be overridden"; } }; //json 파일을 불러오는 자식 클래스 JsonData 생성 const JsonData = class extends Data{ constructor(data){ super(); if(!data || typeof data !== 'string') throw 'Invalid data'; this._data = data; } //오버라이드 async _getData(){ let json; const response = await fetch(this._data); if(!response.ok) throw 'data load failed'; return await response.json(); } } 만약에 csv형식으로 데이터를 받고 싶다면 자식 클래스로 CsvData를 구현해 사용하면 됩니다 const CsvData = class extends Data{ constructor(url){ super(); if(!url || typeof url !== 'string') throw 'Invalid url'; this._url = url; } async getData(){ const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; const data = await response.text(); const csv = data.split('\n').reduce((p, c)=>{ p.push(c.split(',')); return p; }, []); //csv 배열을 json으로 변경해 리턴해줘야 함 let json = { 'title':csv[0].join(), 'header':csv[1], 'items': csv.reduce((p, c, idx)=>{ if(idx > 1) p.push(c); return p; }, []) } return json; } };
이제 Data가 Data와 JsonData로 나뉘게 되었으니 Renderer도 이제 Data가 아니라 JsonData를 받도록 호스트 코드 부분을 수정해야합니다.
const renderer = new Render("#data"); //이제는 new Data가 아니라 new JsonData를 받음 renderer.render(new JsonData("71_1.json"));
Renderer와 TableRenderer
Renderer도 Data와 마찬가지로 변화율의 관점에서 살펴볼 수 있습니다.
현재는 HTML에 table로 데이터를 랜더링을 하지만 만약에 브라우저 환경에 따라 어떤 때에는 canvas로 어떤 때에는 console로 랜더링을 해야하면 어떻게 할까요? 아예 브라우저가 아니라 안드로이드에 랜더링을 해야하는 경우에는..? 여기서 우리는 Renderer도 Data로부터 데이터를 공급받아 render를 호출하는 부분과, 전달받는 데이터를 정해진 환경에 실제로 출력하는 부분은 다른 변화율을 가지고 있음을 알 수 있습니다.(특히 랜더링 하는 부분은 Native에 바인딩 되어있다고 표현합니다)
따라서 Renderer도 상속관계를 이용해 부모 클래스인 Renderer에서 Data로부터 데이터를 받아 실제로 랜더링을 하는 부분은 Renderer를 상속받은 자식클래스에서 실현하도록 합니다.
const Renderer = class{ async render(data){ if(!data || !(data instanceof Data)) throw 'Invalid data type'; //data를 받아오는 것은 부모 클래스에서 실행 const json = await data.getData(); if(!json.title || !json.header || !json.items) throw 'Invalid table data'; this._data = json; //네이티브 바인딩 된 부분은 자식 클래스로 분리 this._render(); } _render(){ '_render must be overridden'; } }; const TableRenderer = class extends Renderer{ //인자로 table을 출력할 부모 셀렉터를 받음 constructor(parent){ super(); if(!parent) throw 'Invalid parent'; this._parent = parent; } async _render(){ //테이블 그리는 로직은 동일... } };
호스트 코드도 Renderer가 아니라 이제 TableRenderer를 생성하도록 합니다.
const tableRenderer = new TableRender("#data"); tableRenderer.render(new JsonData("71_1.json"));
후~ 이렇게 변화율에 따라 Data와 Renderer까지 수정을 마치고 나니 객체들의 관계망도 다음과 같이 바뀌었네요.
프로토콜 Info의 등장
아쉽게도 문제점이 여전히 남아있습니다. 바로 Data가 주는 결과가 ‘값’이라는 점!
물론 json은 JavaScript 데이터형으로는 객체 타입이긴 하지만 그 자체가 값인 값 객체입니다.(이를 Value Object, 줄여서 VO라고 합니다)
값으로 주는게 뭐가 문제죠?? 바로 값은 값으로 밖에 검증을 하지 못한다는 점입니다. 예를 들어 숫자 3이 3인지 아닌지 알기위해서는 3이라는 값과 진짜 비교를 해봐야 합니다. 4의 값을 검증하려면 4랑 비교를 해봐야 하구요.
Renderer의 입장에서는 Data가 준 값이 올바른 값인지 확신을 할 수 없기 때문에 직접 검사를 해봐야합니다. 테이블을 그리는데 필요한 값은 title, header, items라는 필드를 가지고 있는데, 값으로 주었기 때문에 title의 값은 title인지, header의 값은 header인지, items의 값은 items인지 값을 하나 하나 비교해야 합니다.
하나 하나 검사를 해야하는 것도 피곤한데 만약 items가 아니라 record라고 바뀌게 된다면 Data와 Renderer에서 데이터 검증하는 부분의 코드를 모두 수정해야 합니다. 그것도 Data와 Renderer의 내부를 뒤집어가면서요. 수정의 여파가 어마어마해집니다.
이러한 문제를 해결하기 위해 Data와 Renderer가 값이 아니라 ‘객체’로 데이터를 주고받도록 합니다. 값을 객체로 바꾸게 되면 객체는 타입을 체크할 수 있기 때문에 값을 하나 하나 비교하지 않아도 됩니다. 간편한 검사 이외에도 다른 장점이 하나 더 있으니 바로 수정의 여파가 줄어듭니다. Data가 주는 데이터의 모양이 바뀌더라도 객체를 주고 받게 되므로 주고 받는 객체의 내용만 바꾸면 되지 Renderer의 내부를 샅샅히 살펴가며 바뀐 내용을 찾아 수정할 필요가 없어집니다.
이처럼 객체 협력 모델에서는 객체끼리 데이터를 주고 받을 때 값이 아니라 객체로 주고 받도록 합니다. 이런 객체들을 ‘프로토콜’이라고 하는데요, 즉 객체끼리 어떤 약속을 했는지를 클래스로 나타내는 역할을 합니다. 여기선 Data와 Renderer의 사이의 프로토콜(약속)으로, 테이블을 그리기 위한 데이터에는 title, header, items이라는 키의 데이터가 있어야하고, 각각의 키의 값은 이런 조건을 가지고 있어야해~라는 약속을 객체 자신으로 나타내는 것이죠.
이를 코드로 나타내면 다음과 같습니다.
const Info = class{ constructor(data){ //Info가 외부에서 데이터를 공급받을 때의 계약사항 const {title, header, items} = data; //밸리데이션 if(typeof title != 'string' || !title) throw "invalid title"; if(!Array.isArray(header) || !header.length) throw "invalid header"; if(!Array.isArray(items) || !items.length) throw "invalid items"; this._private = {title, header, items}; } //Info가 외부에 노출하는 값의 계약사항 get title(){ return this._private.title; } get header(){ return this._private.header; } get items(){ return this._private.items; } };
Data와 Render는 이제 Info라는 객체로 대화하기로 했으니 JsonData도 json값이 아니라 Info객체를 반환해주도록 해야합니다.
const JsonData = class extends Data{ constructor(url){ super(); if(!data || typeof data !== 'string') throw 'Invalid url'; this._data = data; } async _getData(){ let json; const response = await fetch(this._url); if(!response.ok) throw 'data load failed'; json = await response.json(); //Info타입으로 데이터 반환 return new Info(json); }
Info의 등장으로 인해 객체들간의 관계도 다음과 같이 변하게 되었습니다.
한편 Info의 도입으로 인해 Data와 Renderer에서 맡고 있었던 ‘데이터에 대한 검증’의 역할이 Info에게 넘겨졌습니다. Info에서 진행하는 데이터에 대한 검증을 더욱 강화하도록 합니다.
현재 위 코드에서는 title, header, items의 자료형과 데이터 유무만을 판단했습니다만, 표를 제대로 그리기 위해서는 각 열의 행 개수가 동일해야 합니다.
이 부분의 밸리데이션 코드를 다음과 같이 추가하도록 합니다.
items.forEach((v, idx)=>;{ if(!Array.isArray(v) || v.length != header.length) throw 'invalid items: ' + idx ; });
밸리데이션 코드를 추가하고 나니 에러가 납니다.
에러 부분을 살펴보니 items 6번째 데이터에 에러가 있네요. json파일을 자세히 살펴보니 데이터 중에 열의 개수가 6개가 아니라 7개인 행이 있다는 사실을 알 수 있었습니다.
그것도 모르고 제대로 된 데이터가 나온다고 알고 있었네요^^;;
이처럼 Info의 도입으로 데이터 공급 종류를 확인하고 각 공급 데이터의 유효성 검증을 한번에 처리할 수 있게 되었지만, 대신 각각의 역할을 꼼꼼히 Info에서 챙겨야한다는 점을 잊지 말아야 한다는 것을 깨달을 수 있었습니다.
내부계약과 외부계약
이제 진짜 끄읏! 인 줄 알았지만… 잘못된 부분이 남아있으니, 위 관계도를 매의 눈으로 자세히 살펴봅니다.
눈치채셨나요? 바로 Info에 JsonData와 TableRenderer가 직접 연결되었다는 점입니다.
객체 관계에서 보면 “Data가 Renderer에 테이블을 그릴 수 있는 데이터를 공급한다”라는 계약을 맺고 있습니다. 그리고 그 테이블을 그릴 수 있는 데이터를 보장하기 위해 Data와 Renderer사이에 Info가 끼어있구요. Data와 Renderer와 같은 관계를 외부 계약 관계라고 합니다.
한편, getData가 호출되면 부모 클래스인 Data의 메소드가 호출되지만 실질적인 데이터 공급은 Data의 자식 클래스인 JsonData가 오버라이드한 메소드로 실행하고 있습니다. Renderer도 마찬가지로 render가 호출되면 부모 클래스인 Renderer의 메소드가 호출되지만 실제 랜더링은 Renderer의 자식 클래스인 TableRenderer가 오버라이드한 메소드로 실행됩니다. 이와 같이 Data-JsonData, Renderer-TableRenderer의 관계는 내부 계약 관계라고 합니다.
그런데 지금 관계도를 보면 내부 계약관계에 있는 JsonData, TableRenderer가 Data와 Renderer의 외부 계약 관계 사이에서 중재하는 Info를 알고 있습니다. 알고 있다는 말은 ‘계약의 대상’이 된다는 뜻인데요, 방금 전에 말했다시피 Info는 Data와 Renderer가 계약 맺은 사항에 이용하는 프로토콜이지 엄밀히 말하자면 JsonData, TableRenderer와 직접적으로 계약을 맺은 것은 아닙니다. 만약 이렇게 JsonData나 TableRenderer가 Info를 알고있다면 앞으로 만들어질 Data와 Renderer의 자식 클래스들은 모두 Info라는 클래스를 알고 있어야만 합니다. 이러한 관계를 ‘암묵적인 계약 관계가 남아있다’라고 표현합니다.
Data클래스부터 암묵적인 계약 관계를 완전히 청산해 보겠습니다.
const Data = class{ async getData(){ let json = await this._getData(); //부모가 Info를 리턴 return new Info(json); } async _getData(){ throw '_getData must be overridden'; } }; const JsonData = class extends Data{ constructor(url){ super(); if(!url || typeof url !== 'string') throw 'Invalid url'; this._url = url; } //_getData로 변경 async _getData(){ let json; const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; json = await response.json(); if(!json) throw 'Data parsing failed'; return json; } };
위 코드에 따르면 이제 Info객체를 자식 클래스에서 직접 생성해 바로 리턴을 하지 않습니다. getData함수를 _getData로 바꾸고 자식이 해야할 일은 _getData에, 부모가 해야할 일은 getData에 나눠 기술 했습니다. 부모와 자식이 해야할 일이 따로 있다고 격리시킨 것이죠. 그리고 Info 타입은 부모만 알아야 하므로 getData에서 Info를 생성하도록 합니다.
자 이제 그럼 부모만 Info를 알게되었…..다고 생각했지만 자~세히 보면 아직도 완전히 관계가 청산되지 않았습니다. 비록 Info객체는 Data 클래스에서 생성하지만, Data가 Info를 만들 때 JsonData클래스에서 리턴해준 json을 그대로 넘겨주고 있으니까요. 이 말은 자식 클래스에서 자기가 만든 데이터가 Info에 넘겨줘야할 데이터 형식에 맞다는 것을 암묵적으로 알고 있다는 것을 의미합니다.
아직도 알고있다!
따라서 완전한 관계 청산을 위해 자식들은 json을 리턴하는 것이 아니라 부모 클래스가 필요한 정보를 속성으로 넘기도록 합니다. 처음에 저는 속성으로 넘기는 것 자체가 Info에 넘기는 것을 아는거 아닌가요?!라고 생각했지만 이것은 우리가 부모 클래스인 Data에서 Info 객체에 넘기는 것을 알고 있으니까 그런 것이지 자식 클래스에서는 이게 Info가 있는지, 어디에 쓰이는 것인지도 모르고 그냥 부모 클래스가 필요하다니까 이 속성으로 이 데이터를 주는 것 뿐이었습니다.
따라서 위와 같은 방법으로 완전히 분리하도록 합니다.
const Data = class{ constructor(){ //상속될 속성 설정 this._title = null; this._header = null; this._items = null; } async getData(){ //_getData 호출해 속성으로 데이터를 공급받음 await this._getData(); //data가 제대로 공급되었는지 확인 if(this._title === null || this._header === null || this._items === null) throw 'No data available'; //형식에 맞는 데이터 객체를 만들어 Info 생성 return new Info({title:this._title, header:this._header, items:this._items}); } async _getData(){ throw '_getData must be overridden'; } }; const JsonData = class extends Data{ constructor(url){ super(); if(!url || typeof url !== 'string') throw 'Invalid url'; this._url = url; } async _getData(){ let json; const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; json = await response.json(); if(!json) throw 'Data parsing failed'; //Data로부터 상속받은 속성만 설정 //Info는 모름. 내부 계약 거래만 처리 this._title = json.title; this._header = json.header; this._items = json.items; } };
이제 정말 Data와 JsonData가 완전히 분리 되었고, Data의 자식 클래스는 Info와의 암묵적인 계약관계에서 해방되었습니다!
CsvData도 이제 json으로 변환하지 않고 해당 속성에 공급만 하면 됩니다.
const CsvData = class extends Data{ constructor(url){ ... } async getData(){ const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; const data = await response.text(); const csv = data.split('\n').reduce((p, c)=>{ p.push(c.split(',')); return p; }, []); //csv배열을 json형식으로 변환 안함 //상속 받은 속성에 바로 할당 this._title = csv[0].join(); this._header = csv[1]; this._items = csv.reduce((p, c, idx)=>{ if(idx > 1) p.push(c); return p; }, []); } };
이제 Renderer와 TableRenderer도 수정해보겠습니다. 현재 코드에서는 TableRender가 getData 메소드를 호출하게 되면 직접 Info를 받게 됩니다. TableRenderer는 Info고 뭐고 그냥 테이블을 그리는데 필요한 3가지 title, header, items만 알면 되는데 Info까지 알고 있는 상태네요.
따라서 Info는 추상 클래스인 Renderer만 알게하고 구상 클래스인 TableRenderer는 부모에게서 상속받은 속성을 이용해 랜더링을 할 수 있도록 코드를 수정합니다.
const Renderer = class{ async render(data){ if(!data || !(data instanceof Data)) throw 'Invalid data type'; const info = await data.getData(); //Info에 대한 지식은 부모 클래스에서 감춤 //테이블을 그리는데 필요한 지식은 속성으로 공급 this._title = info.title; this._header = info.header; this._items = info.items; this._render(); } _render(){ '_render must be overridden'; } }; const TableRenderer = class extends Renderer{ constructor(parent){ //... } _render(){ // 상속받은 속성으로 caption 생성 caption.innerHTML = this._title; table.appendChild( //상속받은 속성으로 header 생성 this._header.reduce((thead, data)=>{ //...동일 내용 }, document.createElement('thead')) ); parent.appendChild(table); parent.appendChild( //상속받은 속성으로 items 생성 this._items.reduce((table, row)=>{ table.appendChild( // ... 동일내용 ); return table; }, table) ); } };
이제 객체망의 관계는 다음과 같이 바뀌게 되었습니다.
더이상 JsonData와 TableRenderer가 Info를 알지 않습니다. 수정을 통해 JsonData는 Data만, TableRenderer는 Renderer만 바라보게 되었습니다. 오직 추상층 클래스인 Data와 Renderer만 Info를 알고 있구요. 그러한 관계는 위 그림에서 화살표로 확인할 수 있습니다. 화살표의 방향은 의존성을 의미하는데 현재 관계에서는 객체당 화살표 1개씩 한 방향으로 향하고 있는 것을 볼 수 있습니다. 이렇게 의존성이 한 방향으로 구현된 관계를 단방향 의존성이라고 합니다.
의존성이 단방향으로 고정되면 수정의 여파가 한정되는 장점이 있습니다. 예를 들어 이전에는 Info를 수정하면 Data뿐만이 아니라 Info에 의존을 하고 있었던 JsonData도 영향을 받았습니다. 하지만 이제는 단방향 의존성이 구현됨으로써 Info가 수정되어도 JsonData는 영향을 받지 않게 됩니다. 단지 Data만 영향을 받게 되는 것이죠. 수정의 여파가 줄여진 것을 알 수 있습니다.
마지막으로 위 내용에 더불어 Data의 구상 클래스로 CsvData를 추가해 csv 파일의 데이터도 불러올 수 있도록 하고, Renderer의 구상 클래스로 그래프를 랜더링할 수 있는 graphRenderer를 추가해 최종적으로 다음과 같은 객체 관계망을 구성하게 되었습니다.
위 구성을 반영한 최종 코드는 다음과 같습니다.
최종 코드
<!doctype html> <html> <head> <meta charset="utf-8"> <title>CodeSpitz71-1(FINAL)</title> </head> <body> <!-- #data를 #table과 #graph로 변경--> <section id="table"></section> <section id="graph"></section> <script> const Info = class{ constructor(data){ const {title, header, items} = data; if(typeof title != 'string' || !title) throw "invalid title"; if(!Array.isArray(header) || !header.length) throw "invalid header"; if(!Array.isArray(items) || !items.length) throw "invalid items"; items.forEach((v, idx)=>{ if(!Array.isArray(v) || v.length != header.length) throw 'invalid items: ' + idx ; }); this._private = {title, header, items}; } get title(){ return this._private.title; } get header(){ return this._private.header; } get items(){ return this._private.items; } }; const Data = class{ constructor(){ this._title = null; this._header = null; this._items = null; } async getData(){ await this._getData(); if(this._title === null || this._header === null || this._items === null) throw 'No data available'; return new Info({title:this._title, header:this._header, items:this._items}); } async _getData(){ throw '_getData must be overridden'; } }; const JsonData = class extends Data{ constructor(url){ super(); if(!url || typeof url !== 'string') throw 'Invalid url'; this._url = url; } async _getData(){ let json; const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; json = await response.json(); if(!json) throw 'Data parsing failed'; this._title = json.title; this._header = json.header; this._items = json.items; } }; const CsvData = class extends Data{ constructor(url){ super(); if(!url || typeof url !== 'string') throw 'Invalid url'; this._url = url; } async _getData(){ const response = await fetch(this._url); if(!response.ok) throw 'Data Load Failed'; const data = await response.text(); const csv = data.split('\n').reduce((p, c)=>{ p.push(c.split(',')); return p; }, []); this._title = csv[0].join(); this._header = csv[1]; this._items = csv.reduce((p, c, idx)=>{ if(idx > 1) p.push(c); return p; }, []); } }; const Renderer = class{ async render(data){ if(!data || !(data instanceof Data)) throw 'Invalid data type'; const info = await data.getData(); this._title = info.title; this._header = info.header; this._items = info.items; this._render(); } _render(){ '_render must be overridden'; } }; const TableRenderer = class extends Renderer{ constructor(parent){ super(); if(!parent) throw 'Invalid parent'; this._parent = parent; } _render(){ const parent = document.querySelector(this._parent); if(!parent) throw 'Invalid Parent Element'; parent.innerHTML = ''; const table = document.createElement('table'); const caption = document.createElement('caption'); caption.innerHTML = this._title; table.appendChild(caption); table.appendChild( this._header.reduce((thead, data)=>{ const th = document.createElement('th'); th.innerHTML = data; thead.appendChild(th); return thead; }, document.createElement('thead')) ); parent.appendChild(table); parent.appendChild( this._items.reduce((table, row)=>{ table.appendChild( row.reduce((tr, data)=>{ const td = document.createElement('td'); td.innerHTML = data; tr.appendChild(td); return tr; }, document.createElement('tr')) ); return table; }, table) ); } }; //GraphRenderer클래스도 추가해 보았어요 const GraphRenderer = class extends Renderer{ constructor(parent){ super(); if(!parent) throw 'Invalid parent'; this._parent = parent; } _render(){ const parent = document.querySelector(this._parent); if(!parent) throw 'Invalid parent element'; const title = document.createElement('h1'); title.innerHTML = this._title, parent.appendChild(title); const legend = document.createElement('ul'); legend.style= 'display:inline-block;margin:0;padding:0'; parent.appendChild( this._items.reduce((legend, item)=>{ const li = document.createElement('li'); li.style = "display:block;height:30px;margin-right:20px"; li.innerHTML = item[3]; legend.appendChild(li); return legend; }, legend) ); const graph = document.createElement('ul'); graph.style= 'display:inline-block;margin:0;padding:0'; parent.appendChild( this._items.reduce((graph, item)=>{ const li = document.createElement('li'); li.style = "display:block;height:30px"; const bar = document.createElement('div'); bar.style = 'display:inline-block;height:15px;padding:5px 0;width:' + item[4].slice(0, -1) * 10+ 'px;background:#f0f0f0'; const rate = document.createElement('span'); rate.innerHTML = item[4]; rate.style="vertical-align:top;padding-left:5px;font-size:10px"; li.appendChild(bar); li.appendChild(rate); graph.appendChild(li); return graph; }, graph) ); } } //table로 데이터 시각화 const tableRenderer = new TableRenderer("#table"); // 71_1.json을 올바르게 수정한 71_1_1.json 불러옴 tableRenderer.render(new JsonData("71_1_2.json")); //Graph로 데이터 시각화 const graphRenderer = new GraphRenderer("#graph"); graphRenderer.render(new CsvData("71_1.csv")); </script> </body> </html>
CsvData객체로 Csv 데이터를 불러온 후 GraphRender로 그래프를 랜더링한 화면의 캡처
끝나지 않는 리팩토링
휴~ 끝이 없었던 격리와 리팩토링의 여정…. 이제는 완성일까요? 잘 모르겠습니다. 도대체 어디까지 격리를 시켜야 객체 협력 모델 구현이 완료될까요?
격리는 각 객체가 단 1개의 책임(역할)을 가질 때 까지 나누도록 하는데, 단일 책임 원칙이라고 합니다.
우리가 지금까지 만들어 본 클래스의 역할들을 살펴보면 다음과 같습니다.
- Data : 속성으로 가지고 있는 데이터(Info 타입)를 Render에 공급
- JsonData : json형식의 파일을 읽어 Data가 접근할 수 있는 내부 속성으로 공급
- Render : Data로부터 데이터(Info타입) 공급 받음
- TableRender : Render가 받은 데이터를 어디에, 어떻게 그릴 것인지 랜더링
- Info : Data와 Render가 주고 받는 데이터의 내용을 관리(보장)
처음 코드를 생각해보면 Table클래스에서 json파일을 불러와서 데이터를 가지고 있고, 그 가지고 있는 데이터를 html table로 그릴 수 있게 렌더링도 하고 혼자 멀티플레이를 했습니다. 많은 리펙토링 과정을 거쳐 이제는 객체들이 각각의 역할을 맡아 서로 협력하고 있는 형태로 바뀌었습니다. 나름 1개의 책임만을 가지고 있구요.
그 러 나… 사실 전 Info도 미래를 대비해서 부모-자식 클래스로 나눠 Info랑 TableInfo로 나뉘어야 되는건 아닌가 싶었습니다. 물론 다른 내용의 데이터가 공급되어야 한다면 Info도 나눠야겠지만 이 프로젝트에서 Info는 그 정도까지 나눠서 구현하지 않아도 되지 않나…라고 결론이 났습니다. 어디까지 나눌것인가?에는 밸런스도 함께 고려된다고 볼 수 있겠습니다.
결론
오늘의 내용을 정리해보면 다음과 같습니다.
ES 2015+ 문법
객체 역할 모델 변화 과정
느낀점
첫 스터디 내용은 후기로 “어렵다” 세글자로 충분하지 않았을까… 싶었을 그런 스터디었습니다^^; 객체를 다루는데 익숙하지 않은 저로서는 ES 2015+문법에 다양한 용어들이 외계어처럼 들렸기 때문이죠. (복습을 철저히 하지 않은 티가 여기서 그대로 드러납니다.)
하지만 진짜 어렵다고 깨달은 순간은 오늘 배운 내용의 코드를 빈 메모장에 부터 시작해서 온전히 제 스스로 채워나갈 때였습니다. 분명 강의를 들었을 땐 너무 당연하고, 다 아는 것만 같았는데 막상 그 내용을 제 말로 적고 코드로 바꿔보려니 머리속에 아무것도 없었다는 사실을 발견했습니다. 그래도 몇번씩이나 코드를 처음부터 짜보고, 어려운 개념들은 찬찬히 한글자 한글자씩 쪼개서 생각해보고 적어보고 되새김질해보니 아주 간신히 적은 양을 소화할 수 있었던 것 같습니다. 덕분에 후기를 작성하는데도 참 오랜 시간이 걸렸네요.
오늘 내용은 기초수준이고 다음 시간부터는 이를 바탕으로 디자인 패턴을 본격적으로 배운다하니 더 두렵습니다만…^.^ 그 때에도 포기하지 않고 잘개 쪼개고 찬찬히 살펴보며 소화시켜보도록 하겠습니다!
음질이 아주 좋아진?? 동영상 강의 보기
1강 교안은 여기서
summer| bsidesoft 신입사원
디자인을 공부했던 섬머는 개발까지 해버리겠다는 욕심으로 개발자의 세계에 입문하게 되었습니다. 개발왕이 되어 멋진 제품을 만들어내는 꿈을 꾸고 있습니다. 코드, 디자인을 포함한 세상의 모든 아름다운 것들과 미드, 그리고 달리기를 좋아합니다.
recent comment