안녕하세요. 우테코 6기 FE 크루 바다 입니다. 😁🐳
우테코 프론트엔드 레벨1의 두 번째 미션인 "로또 게임"을 완료했습니다. 🎉🥳🎉
"로또 게임"미션을 구현하면서 발생한 이슈와 해결방법들, 알게 된 점과 느낀 점에 대해 이야기 해보려합니다.
우테코 레벨1의 미션은 페어와 함께 하는 1단계와 1단계의 도메인 로직을 최대한 유지하면서 혼자 구현하는 2단계로 나누어져 있습니다.
밑에서 예시를 든 코드들은 실제 미션 구현 시 사용된 것 이 아닌 설명을 위해 간략하게 적은 코드들 입니다.
🗂️ 모듈 구조, 순서도
jest는 테스트 결과나 로그를 시작적으로 쉽게 이해할 수 있게 프린트 포맷팅 기술을 지원하고 있습니다.
Generate unique test titles by positionally injecting parameters with printt formatting
test.each(table)(name, fn, timeout)
table의 요소의 타입에 따라서 %s(String)
, %d(Number)
, %i(Integer)
등의 문자를 활용하면 test.each의 table의 어떤 요소에 대한 테스트인지를 확인할 수 있습니다.
test.each(['1/2/3/4/5/6', '1 2 3 4 5 6'])(
'당첨 로또 번호는 쉼표(,)로 구분되어 있지 않으면 오류가 발생한다.\n [Test Case] : %s \n',
(input) => {
expect(() => new WinningLotto(input)).toThrow(
ERROR_MESSAGES.inValidWInningNumbersForm,
);
},
);
코드를 구현 하기 전에 모듈 구조,프로세스에 대해 계획을 하는 편이라 TDD로 작은 단위부터 작성하면 과연 전체적인 구조가 잡힐까?라는 의구심이 있었습니다.
하지만 미션을 진행할 수록 TDD가 "길잡이"역할을 한다는 것을 느꼈습니다. 혼자 기능 구현을 하다보면 구현 중 오류를 해결하거나 한 번 코드를 짤때 깔끔하게 짜고자 하는 욕심에서 "어? 내가 뭐하려고 했지?","아~ 이거 하던 중이 였는데" 혹은 "이 기능이 들어있어야 하는데 빠졌네"라며 길을 잃는 경우가 생깁니다. 하물며 노를 젓는 개발자가 2명인 페어와의 미션에는 길을 잃는 경우가 훨씬 많습니다. 이럴 때 TDD는 지금 당장 구현해야하는 것에 대해 테스트 케이스를 만들고 해당 테스트를 통과하는데 집중하게 해서 길을 잃는 경우가 현저히 적었고 TDD에 익숙해질 수록 구현 속도가 빨라졌습니다.
웹팩 서버는 실행 시 메모리 상에 파일을 보관하고, 실제 물리적인 dist 폴더에는 파일을 남기지 않습니다.그 결과 dist 폴더에 파일들이 빌드 되어도 웹팩 서버가 열리면 삭제됩니다.
webpack.config.js에서 웹 팩 서버를 열 때도 dist 폴더에 파일을 저장하도록 설정하도록 옵션을 수정합니다.
module.exports = {
// ... 생략 ...
devServer: {
devMiddleware: {
writeToDisk: true,
},
},
// ... 생략 ...
};
//webpack.config.js
//...
entry: './src/step2-index.js',
//....
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: './index.html',
inject: 'body',
favicon: './favicon.ico',
}),
],
//...
root/index.html에서 css,js를 불러오면 해당 파일들의 경로를 찾아올 수 없다는 오류가 뜨는데 이유는 개발 시 사용하는 index.html은 root 또는 src 내의 html이지만 웹 팩 서버나 깃허브 페이지에서 열리는 index.html은 dist 폴더 내의 html이기 때문에 개발 시 사용된 index.html에서 상대경로는 dist/index.html의 상대경로와는 다르기 때문에 오류가 납니다.
해결 방법은 절대 경로로 파일들을 설정하거나, 빌드의 진입점인 step2.index.js에서 css,js파일들을 여는 것 입니다.
TypeError: attempted to get private field on non-instance
오류는 클래스의 프라이빗 필드에 대한 접근이 해당 클래스의 인스턴스가 아닌 다른 곳에서 이루어졌음을 의미합니다.
클래스내의 private 메서드가 이벤트 리스너에서 콜백 함수로 사용되면 이때 this의 컨텍스트가 해당 클래스의 인스턴스를 잃어버립니다.
이벤트 리스너의 callback 함수(이하 콜백함수)를 bind(this)
로 묶으면 됩니다.
#addEvent() {
this.$btnPayLottoEl.addEventListener(
'click',
this.#handleClickBtn.bind(this),
);
}
#handleClickBtn() {
// 버튼 클릭 시 일어나는 기능들....
}
js에서 함수를 다른 함수의 인자로 전달할 때 전달된 함수는 실행될 때의 컨텍스트(=함수가 참조하는 this의 값)은 특별하게 설정하지 않는 이상 기본적으로 글로벌 컨텍스트(브라우저에서는 window)가 됩니다.
addEventListenr의 콜백 함수의 this도 글로벌 컨텍스트일까요? 아닙니다. addEventListenr의 콜백 함수의 this는 해당 이벤트가 적용되는 element를 가리킵니다.
그렇다면 arrow function의 this는 무엇일까요? arraow function은 자신만의 this를 가지지 않고 상위 스코프의 this를 사용합니다.
class LottoMachineCotroller(){
//...
#addEvent() {
this.$btnPayLottoEl.addEventListener(
'click',
//arrow function :(event)=>this.#handleClickBtn(event),
this.#handleClickBtn.bind(this),
);
}
#handleClickBtn() {
// 버튼 클릭 시 일어나는 기능들....
}
//...
}
this.$btnPayLottoEl.addEventListener(event, callBack)
에서 콜백함수의 this는 이벤트 리스너의 대상인 $btnPayLottoEl
를 가리켜서 콜백함수가 this.#handleClickBtn
로 클래스의 메서드를 그대로 가져와 실행하는 경우라면 bind(this)로 handleClickBtn
가 있는 LottoMachineController
를 묶어주어야합니다. 반면에 콜백 함수에 클래스의 메서드를 이용한 arrow function을 사용한다면 arrow function의 this는 상위 스코프인 LottoMachineController
가 되어서 bind 할 필요가 없습니다.
이미 prettierrc에서 prettier를 적용한고 있다는 것을 전제로 아래의 단계를 진행합니다.
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
//html 저장 시 자동으로 prettier 사용해서 포맷을 적용함
}
HTML5에서 form은 required
속성이 있는 form 하위 요소에 작성된 입력값에 대한 유효성 검사를 수행하고, 유효하지 않을 경우 submit
이벤트가 발생하더라고 폼 전송이 중단되어 사용자에게 입력값 오류를 보여줍니다.
form의 입력값에 대한 유효성 검사를 진행하는 모듈이 따로 있고 유효성 검사의 결과에 따라 진행해야하는 로직들을 다르게 설정하도록 구현했습니다. (통과-> 게임의 다음 단계 진행, 통과x-> 하단에 오류 메세지 출력)
따라서 form 자체의 유효성 검사를 비활성화하기 위해서 form에 novalidate
속성을 추가했습니다
<form id="myForm" novalidate>
<input type="number" min="1" max="45" maxLength="2" required />
<input type="submit" value="제출">
</form>
form은 서버에 사용자의 입력값을 보내고 form 자체에서 유효성 검사를 진행하는 유효한 기능을 가지고 있습니다. 이 뿐만 아니라 아래와 같은 웹 접근성 측면에서의 이점들도 가지고 있습니다.
form 태그에는 form의 하위 요소들(input등)의 값을 초기화하는 reset 기능이 있습니다.
<form id="myForm">
<!-- 폼 입력 요소들 -->
<input type="reset" value="초기화">
</form>
document.getElementById('myForm').reset();
#addEvent(){
$formElement.addEventListener('reset',(event)=>this.#handleReset(event))
}
//....
#handleReset(event){
event.preventDefault();
// reset 시 실행할 로직들....
}
event.preventDefault()
는 js에서 이벤트가 기본적으로 가진 기본 동작을 방지하기 위해 사용되는 메소드로 브라우저의 특정 요소에 설정된 기본 동작이 실행되는 것을 막고 개발자가 정의한 동작을 실행할 때 사용됩니다.
reset이벤트 시 실행되는 콜백 함수에 event.preventDefault가 있다면 reset의 기본 기능인 초기화를 막아서 원했던 초기화 기능이 실행되지 않습니다.
// 배열의 요소들입니다.
const items = ['첫 번째 항목', '두 번째 항목', '세 번째 항목'];
// 부모 요소를 찾습니다.
const parent = document.getElementById('parentElement');
// 배열을 순회하며 div 요소를 만듭니다.
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item;
parent.appendChild(div);
});
DocumentFragment
는 일반적인 DOM 트리의 일부가 아니라 요소를 추가해도 페이지에는 아무 변화도 생기지 않으며 이를 이용하면 여러 요소들을 DOM에 추가하기 전에 임시적으로 보관하고 한 번에 DOM에 추가할 수 있습니다. 많은 수의 요소를 추가해야 할 때 성능상의 이점이 있습니다.const items = ['첫 번째 항목', '두 번째 항목', '세 번째 항목'];
const parent = document.getElementById('parentElement');
// DocumentFragment 인스턴스를 생성
const fragment = document.createDocumentFragment();
// 배열을 순회하며 div 요소
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item;
fragment.appendChild(div);
});
// 모든 div 요소들을 한 번에 부모 요소에 추가
parent.appendChild(fragment);
insertAdjacentHTML?
insertAdjacentHTML
는 HTML요소에 특정한 위치에 HTML 또는 XML을 삽입할 때 사용하는 메서드 입니다.
DocumentFragment와 insertAdjacentHTML 사용하기
컴포넌트를 만들어서 DOM에 넣어주고 싶다면 DocumentFragment
와 insertAdjacentHTML
를 같이 활용하면 좋습니다.
const HtmlInjector= {
//....
private_getHtml() {
return `
<div>
html 에 넣을 요소들.....
</div>
`;
},
private_injectHtml() {
const parentElement = document.querySelector(
'.parent',
);
const html = this.private_getHtml();
//parentElemnt의 마지막 요소로 삽입
parentElement.insertAdjacentHTML('beforeend', html);
},
};
}
특정 element에 대해 여러 속성들을 넣어주기 위해서 속성들을 추상화한 후 setAttribute와 DocumentFragment를 사용했습니다.
const HtmlInjector={
//...
private_setAttributes(){
const parentElement = document.querySelector(
'.parent',
);
const fragment = document.createDocumentFragment();
const div= document.createElement('div');
// 속성들을 하나의 객체로 추상화
const attributes ={
id:'아이디',
className:'클래스',
......
};
// 요소에 속성 넣기
Object.entries(attributes).forEach(([key,value])=>{
div.setAttributes(key,value);
})
}
}
폰트,색상 등 스타일 설정 시 자주 사용되는 값들이 있다면 CSS의 변수기능을 사용하면 매우 편리합니다.
:root{
/* --설정할_스타일의_key:value */
--lotto-primary-color: #4e5ba6;
}
**rgba(var(변수키), 투명도)**
를 이용하면 됩니다. 그러나 여기서 주의할 점은 색상을 나타내는 css의 변수값은 rgb여야 한다는 것입니다. #
으로 시작하는 헥사(Hex) 색상값은 rbga에 사용될 수 없습니다.
이번 미션은 1단계의 모듈들을 최대한 변경하지 않고 웹 기반으로 바꾸는 (html에 js를 엮는 부분)이 가장 고비였던 것 같아요. 왜 많은 서비스들이 바닐라 js가 아니라 js라이브러리/프레임 워크를 사용하는 지 알 것 같더라구요. 가스레인지/인턱션/에어프라이어로 조리하다가 성냥으로 아궁이 불 떼서 요리하는 느낌이 였어요.🥲
늘 그렇지만 이번에도 저의 부족했던 부분을 알게 되고 메꾸는 시간들을 보냈습니다.
오류를 만나고 왜 나지?라며 해결해 갔던 시간들은 힘들었지만 목표했던 시간보다 빨리 구현했고 무엇보다 리뷰어에게 UX에 대한 칭찬을 많이 받아서 기분이 좋습니다.
셀프 칭찬 시간 😊👏
그럼. 다음에도 찾아뵐게요. 🖐️))
아궁이에 불 떼서 요리하는 느낌
인상적인 표현이었습니다 ㅋㅋ