[2단계 - 웹 기반 로또 게임] 애니 미션 제출합니다.#450
Conversation
- index.html: hidden 속성 적용, name/id를 winningLotto/bonusNumber로 통일 - index.html: 모달 오버레이 주석 해제, lotto-count span 추가 - styles/style.css: .hidden 클래스를 [hidden] 속성 선택자로 변경
- src/utils/Random.js 추가 (웹 환경 호환용) - LottoMachine.js의 import를 @woowacourse/mission-utils에서 Random.js로 변경
- App.js: viewConfig 기반 모드 방식을 생성자 DI로 변경 - App.js: winningNumber → winningNumbers 명칭 통일 - App.js: Console.print 직접 호출을 outputView.printError로 변경 - viewConfig.js 삭제 - LottoManager.js: winningNumber → winningNumbers 명칭 통일 - step1-index.js: ConsoleInputView, ConsoleOutputView 직접 주입
- showLottoSection, showWinningSection 섹션 표시/숨김 - resetPurchaseForm, resetWinningForm 폼 초기화 - disablePurchaseForm 구매 폼 비활성화 - showModalOverlay 모달 표시/숨김
- printLottos: 로또 목록 렌더링 및 섹션 표시 - printStatistics: 당첨 통계 테이블 및 수익률 렌더링 - printError: alert으로 에러 메시지 출력
- readPurchaseAmount: 구매 금액 폼 submit 처리 - readWinningNumbers: 당첨번호 + 보너스번호 폼 submit 처리 - readBonusNumber: tempBonusNumber 패턴으로 보너스번호 반환 - readIsRetry: 다시 시작하기 버튼 클릭 시 전체 초기화
- WebInputView, WebOutputView를 App에 주입하여 실행
- title을 '🎱 행운의 로또'로 변경
- index.html에 구매, 당첨 번호, 통계 모달 구조와 공통 타이포 클래스를 반영 - styles 하위 파일을 분리해 구매, 목록, 당첨 번호, 모달 스타일을 정리
- WebInputView와 DOMController에 모달 닫기와 winning form 비활성화 흐름을 추가 - WebOutputView에서 통계 렌더링을 분리하고 모달 표시 시 입력 폼 상태를 함께 제어
- ConsoleOutputView에 printError 메서드를 추가해 재시도 흐름에서 에러를 출력 - App이 콘솔과 웹 출력 뷰를 동일한 인터페이스로 사용할 수 있게 맞춤
imakerjun
left a comment
There was a problem hiding this comment.
안녕하세요 애니!
리뷰가 많이 늦었는데 기다려줘서 고마워요.
전반적으로 step1의 도메인 구조를 최대한 보존한 채 웹 UI를 입히려고 한 의도가 잘 드러난 것 같아요 👏🏻
코드에 대한 리뷰는 각 코멘트로 작성했고, 여기에는 질문에 대한 답변을 남겨둘게요.
📝 질문 답변
이벤트 위임 vs 직접 바인딩
지금 상황에서는 { once: true }를 붙인 직접 바인딩이 더 잘 맞아 보여요. 버튼이 둘뿐이고, 모달이 열릴 때의 동작도 비교적 단순해서 이벤트 위임의 장점이 크게 드러나지 않기 때문인데요. 이벤트 위임은 동적으로 생기고 사라지는 아이템이 많거나, 동일한 패턴의 클릭을 큰 컨테이너에서 한 번에 처리하고 싶을 때 더 빛나는 편이라 지금처럼 구조가 단순한 경우에는 위임까지 고려하지 않아도 괜찮아 보여요. 물론 이벤트 위임을 썼다고 해서 틀린 선택은 아니지만, 현재 코드에서는 직접 바인딩이 더 읽기 쉽고 의도도 잘 드러나는 것 같아요.
CSS 특이도 관리
현재처럼 .modal-profit-text.lotto-body로 의도를 코드에 드러낸 쪽이 @import 순서에 기대는 것보다 더 나아 보여요. import 순서로 해결하면 "왜 이 스타일이 우선하는지"가 파일 배치에 숨어버리는데, 지금 방식은 충돌 관계가 셀렉터에 바로 드러나서 읽는 입장에서 이해하기가 더 쉽거든요.
다만, 특이도를 높이는 것 자체가 좋은 게 아니라 "예측 가능하게 관리"하는 것이 더 중요하다고 생각해요. 프로젝트가 커지면 (0,2,0)을 이기려고 (0,3,0)이 필요해지는 식으로 특이도 전쟁이 시작될 수 있거든요.
그래서 장기적으로는 특이도를 계속 높이는 방향으로 가기보다, 공통 타이포그래피 클래스와 컴포넌트 스타일의 경계를 어떻게 나눌지까지 같이 고민해보면 더 좋을 것 같아요.
구입 금액 입력 단위
이건 "맞다/틀리다"를 가르는 문제라기보다, 도메인 규칙을 어떻게 정의할지에 더 가까운 주제라고 생각해요. 1050원으로 1장을 구매할 수 있다고 해석하는 것도 충분히 타당해 보여요. 중요한 건 "내가 어떤 해석을 했는지 명시했는가"인 것 같아요.
예를 들어 README에 "구입 금액은 로또 1장 가격 이상이면 유효하며, 1,000원 미만의 나머지 금액은 구매 수량 계산에 반영하지 않는다"처럼 적어두면, 다른 사람이 봤을 때도 의도를 이해하기 쉬워지고 나중에 규칙이 바뀌었을 때 수정 포인트도 더 분명해질 것 같아요. step1에서 정한 기준을 step2에서도 일관되게 유지하면 충분하다고 생각합니다.
당첨 번호/보너스 번호 입력 구조
#tempBonusNumber 자체는 기존 App 흐름을 최대한 유지하면서, 콘솔과 웹이 같은 InputView 인터페이스를 공유하도록 맞추려 할 때 충분히 나올 수 있는 선택이라고 생각해요. 특히 1단계 콘솔에서는 당첨 번호와 보너스 번호를 순서대로 입력받았고, 2단계 웹에서는 한 번에 입력받다 보니 그 차이를 View 쪽에서 흡수하려는 의도로도 읽혔어요. 그래서 임시 상태를 둔 것 자체를 크게 이상한 구조로 보지는 않았어요.
다만 그 선택에는 분명한 트레이드오프도 있어 보여요. 화면에서는 당첨 번호와 보너스 번호를 한 번에 입력받고 있는데 코드에서는 readWinningNumbers()와 readBonusNumber()로 값을 두 번에 나눠 전달하고 있거든요. 현재 구조에서도 보너스 번호 검증이 실패했다고 해서 입력값이 사라지지는 않아서 UX가 크게 어색한 것은 아니지만, UI의 입력 단위와 코드의 값 전달 단위가 조금 어긋나 보이긴 해요. 지금의 #tempBonusNumber도 그 어긋남을 메우기 위한 장치처럼 읽히고요.
그래서 저는 현재 방식도 "공통 App 흐름을 유지하기 위한 합리적인 선택"으로 볼 수 있다고 생각해요. 반면 웹 구조에 조금 더 맞춘 방향을 고민해본다면, 도메인을 바꾸기보다 App과 InputView가 값을 주고받는 방식을 웹 환경에 맞게 조금 조정해볼 수도 있을 것 같아요.
예를 들어 readWinningNumbers()가 { winningNumbers, bonusNumber }를 한 번에 반환하도록 바꾸면, 임시 상태 없이도 현재 화면 구조와 더 자연스럽게 맞출 수 있거든요. 결국 이 부분은 "공통 인터페이스를 얼마나 유지하고 싶은가"와 "웹 UI 구조를 얼마나 그대로 반영하고 싶은가" 사이의 선택처럼 보여서, 애니가 지금 구조를 택한 이유도 충분히 납득할 수 있었어요.
한 줄 정리
도메인을 억지로 바꾸지 않고 웹 UI를 붙인 방향은 아주 좋았습니다. 이제는 그다음 단계로, 웹 환경에 맞는 입력 흐름과 에러 경험을 어디까지 재설계할지 고민해보면 더 좋은 코드가 될 것 같아요.
다시 한번 기다려줘서 너무 고맙고 로또 2단계까지 진행하느라 너무 고생 많았습니다 👏🏻
| <th>일치 갯수</th> | ||
| <th>당첨금</th> | ||
| <th>당첨 갯수</th> |
There was a problem hiding this comment.
갯수는 맞춤법에 맞지 않는 표현이에요~!. 올바른 표기는 개수입니다. 😄
일치 갯수→일치 개수당첨 갯수→당첨 개수
사소하지만 사용자에게 보이는 텍스트이니 수정해주세요~!
| static async readIsRetry() { | ||
| return new Promise((resolve) => { | ||
| const retryButton = document.querySelector("button.modal-retry-button"); | ||
| const closeButton = document.querySelector("button.modal-close-button"); | ||
| const handler = this.#handleRetry(resolve); | ||
| retryButton.addEventListener("click", handler, { once: true }); | ||
| closeButton.addEventListener("click", handler, { once: true }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
닫기 버튼(closeButton)과 재시작 버튼(retryButton)이 같은 handler를 공유하면서 둘 다 resolve(true)를 호출하고 있군요! step1에서는 "y/n"으로 종료와 재시작이 구분되었는데, 웹에서는 닫기를 눌러도 게임이 재시작되네요.
의도적인 선택이라면 괜찮지만, UX에서 "닫기(X)"와 "다시 시작하기"는 보통 다른 의도에요.
사용자가 결과를 다시 보려고 모달을 닫았는데 게임이 초기화되면 당황할 수 있어요.
"닫기 = 모달만 닫고 결과를 유지" + "다시 시작하기 = 전체 초기화"로 구분하고 싶다면 handler를 분리하는 방향을 고려해보면 좋을 것 같아요!
| }); | ||
| } | ||
|
|
||
| static async readBonusNumber() { |
There was a problem hiding this comment.
현재 이 작업을 보니readBonusNumber가 readWinningNumbers보다 먼저 호출되면 null을 반환하고, readBonusNumber라는 이름에서 "읽는다"는 행위가 기대되지만 실제로는 저장된 값을 반환하네요. 네이밍이 동작을 잘 설명하는지 고민 포인트가 될 것 같아요.
그리고 웹에서는 콘솔처럼 순차 입력을 받을 필요가 없으므로, readWinningNumbers가 { winningNumbers, bonusNumber }를 한번에 반환하는 구조로 바꿔보는 건 어떨까요? 도메인(Lotto, WinningNumber)은 건드리지 않으면서, Controller-View 인터페이스만 조정하는 방향이 될 것 같아요.
| } | ||
|
|
||
| static printError(error) { | ||
| alert(error.message); |
There was a problem hiding this comment.
에러를 alert(error.message)로 보여주는 방식은 구현은 간단하지만, 브라우저 기본 UI에 의존하다 보니 입력 맥락이 끊기고 [ERROR] 접두사도 사용자에게 그대로 노출 되는데요. 다른 PR의 리뷰에서도 이런 에러 UX는 자주 언급되더라고요.
당장 구조를 크게 바꾸지 않더라도, "입력 필드 근처에 메시지를 표시하려면 현재 설계에서 무엇이 필요할까?"를 한 번 그려보면 좋겠어요. 예를 들면 outputView.printError()가 전역 alert 대신 섹션별 에러 영역을 갱신하도록 만들어 볼 수 있다는 점을 고려해보면 좋겠어요~!
참고로 이와 관련된 UI/UX 설계 경험은 레벨2에서도 진행할 예정이에요 😄
| <input | ||
| id="winning-lotto-1" | ||
| name="winning-lotto" | ||
| type="number" | ||
| class="winning-input lotto-input lotto-body" | ||
| required | ||
| aria-label="당첨 번호 1" | ||
| /> |
There was a problem hiding this comment.
재밌게도 type="number"는 브라우저마다 입력 경험이 달라질 수 있어요. MDN도 "어떤 브라우저는 invalid 문자를 허용하고, 어떤 브라우저는 허용하지 않는다"고 경고하고 있고, Firefox는 여전히 number input에 비숫자 문자를 입력할 수 있는 이슈가 있어요.
그리고 Chromium 계열(Chrome, Edge 등)은 문자 입력을 어느 정도 막지만 e, -, .처럼 부동소수점/지수 표기와 관련된 문자는 중간 상태로 허용하는게 가능해요!
여기서 e가 허용되는 이유도 같이 알아두면 좋은데요. HTML number input은 "정수 전용 입력"이 아니라 기본적으로 floating-point number를 다루는 입력이고, 과학적 표기법인 1e3, 2e-1 같은 값도 숫자로 취급할 수 있어요. 그래서 브라우저 입장에서는 사용자가 e를 입력하는 순간만 보고 바로 잘못된 문자라고 단정하기 어려워요.
애니의 앱에서도 '1e3'으로 다음 스텝이 통과됩니다! ㅎㅎ

그래서 "로또 번호처럼 1~45의 정수만 받아야 하는 입력"이라면 type="number"만 믿기보다 방어를 한 단계 더 두는 쪽이 좋아요.
type="number"를 유지한다면min="1",max="45",step="1"로 정수 범위를 먼저 좁혀주세요.- 다만 이것만으로는 충분하지 않을 수 있어서, 제출 시점에
Number.isInteger()까지 확인하는 검증이 있으면 더 안전해져요.
지금 PR에서는 최소한 min, max, step="1"를 추가하고, 도메인 쪽에서도 정수 여부를 한 번 더 확인해두면 브라우저별 차이를 꽤 줄일 수 있을 것 같네요 😄
🔗 참고
- MDN
<input type="number">: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/number - Firefox bug 1398528: https://bugzilla.mozilla.org/show_bug.cgi?id=1398528
| </footer> | ||
| </div> | ||
|
|
||
| <div class="modal-overlay" role="presentation" hidden> |
There was a problem hiding this comment.
직접 만든 모달에 role="dialog", aria-modal, aria-labelledby를 붙여 접근성을 챙긴 점 너무 좋아요 👏🏻
다만 현재 방식은 포커스 트랩이나 ESC 닫기 같은 동작을 직접 관리해야 해요.
반면 <dialog>를 쓰면 브라우저가 기본적으로 제공해주는 동작이 많아서 유지보수 하기가 편리한데요.
반드시 <dialog>가 정답이라는 뜻은 아니고, 지금 구현처럼 커스텀 모달을 유지할 수도 있어요. 다만 그 경우에는 "모달이 열렸을 때 포커스가 어디로 가는가", "Tab 키가 모달 밖으로 빠져나가는가" 같은 상호작용까지 함께 설계해야 한다는 점을 기억해주면 좋을 것 같아요!
| const showWinningSection = (visible) => { | ||
| const winningSection = document.querySelector("section.winning-section"); | ||
| winningSection.hidden = !visible; | ||
| }; | ||
|
|
||
| const showLottoSection = (visible) => { | ||
| const lottoSection = document.querySelector("section.lotto-section"); | ||
| lottoSection.hidden = !visible; | ||
| }; |
There was a problem hiding this comment.
지금 규모에서는 큰 문제는 아니지만, DOMController가 매번 querySelector를 호출하다 보니 이 모듈이 어떤 요소들에 의존하는지 한눈에 들어오지가 않아요. 그래서 DOM 참조를 한 곳에 모아보면 책임 범위가 더 또렷해질 것 같아요~!
예를 들어 모듈 상단에서 필요한 요소를 캐싱해두면 "이 컨트롤러가 만지는 영역"이 더 분명해지고, 나중에 셀렉터가 바뀌어도 수정하기 위해 봐야하는 코드의 영역도 줄어들거에요.
이렇게 바꿔두면 "이 모듈이 어떤 DOM에 의존하는지"가 파일 상단에 드러나서 읽기가 쉬워지고, 셀렉터가 바뀌었을 때도 수정 포인트가 줄어들 거에요~!
| const showWinningSection = (visible) => { | |
| const winningSection = document.querySelector("section.winning-section"); | |
| winningSection.hidden = !visible; | |
| }; | |
| const showLottoSection = (visible) => { | |
| const lottoSection = document.querySelector("section.lotto-section"); | |
| lottoSection.hidden = !visible; | |
| }; | |
| const winningSection = document.querySelector("section.winning-section"); | |
| const winningForm = document.querySelector("form.winning-form"); | |
| const showWinningSection = (visible) => { | |
| winningSection.hidden = !visible; | |
| }; | |
| const resetWinningForm = () => { | |
| winningForm.reset(); | |
| }; |
학습 목표
이번 미션을 통해 다음과 같은 학습 경험들을 쌓는 것을 목표로 합니다.
제출 전 체크 리스트
리뷰 요청 & 논의하고 싶은 내용
1) 이번 단계에서 가장 많이 고민했던 문제와 해결 과정에서 배운 점
UI와 도메인 분리 — 외부 의존성의 파급 효과
step1에서 viewConfig를 만들어 모드별 뷰를 주입하는 구조를 설계해두었는데, mission-utils가 Node.js 전용 라이브러리라 웹 환경에서 import만으로 에러가 발생했습니다. viewConfig에서 콘솔 뷰를 참조하는 것만으로도 mission-utils가 로드되었기 때문입니다. 결국 App(controller)에 뷰를 직접 주입하는 방식으로 변경했고, viewConfig는 삭제했습니다. Random 함수도 mission-utils 것을 사용하고 있었는데 마찬가지로 오류가 발생해서, 의존성을 낮추기 위해 직접 구현하는 방식으로 변경했습니다. 외부 라이브러리 의존이 환경 전환 시 얼마나 큰 영향을 줄 수 있는지 체감했습니다.
innerHTML → DocumentFragment + createElement
처음에는 innerHTML로 DOM을 그렸는데, 피드백 시간에 createElement 방식으로 구현한 이전 PR들을 보게 되었습니다. XSS 위험성을 피하기 위해 createElement 방식으로 수정해야겠다고 생각했고, 더 찾아보니 DocumentFragment + createElement 조합이 리플로우를 최소화해서 더 효율적이라는 것을 알게 되어 적용했습니다.
CSS 컴포넌트별 분리와 중복 제거
원래 하나의 파일에 모든 스타일이 있었는데 디버깅이나 관리가 힘들었습니다. 컴포넌트별로 CSS 파일을 분리하면서, 중복되는 스타일들을 많이 발견하게 되었고 공통 클래스(
.lotto-input,.lotto-button,.lotto-fieldset)로 정리할 수 있었습니다.당첨 번호와 보너스 번호의 입력 처리
웹 환경에서 당첨 번호 6개와 보너스 번호 1개를 어떻게 받을지 고민했습니다. 하나의 폼에서 함께 제출되기 때문에, WebInputView에
#tempBonusNumber임시 변수를 두고readWinningNumbers에서 당첨 번호 검증을 통과하면 보너스 번호를 임시 저장한 뒤,readBonusNumber에서 바로 반환하는 방식으로 구현했습니다. 다만 이 구조에서는 컨트롤러에서 보너스 번호 검증이 실패하면 당첨 번호부터 다시 입력해야 하는 흐름이 됩니다. (260315 14:06 추가)2) 이번 리뷰를 통해 논의하고 싶은 부분
{ once: true }리스너를 사용했는데, 이벤트 위임 방식과 비교해서 어떤 게 더 적절할지 의견을 듣고 싶습니다.style.css에서@import로 컴포넌트 CSS를 먼저 불러온 뒤 공통 타이포그래피 클래스(.lotto-body)를 정의하는 구조입니다. 같은 특이도(0,1,0)일 때 나중에 선언된 쪽이 우선하기 때문에,modal.css의.modal-profit-text에font-weight: bold를 줘도.lotto-body의font-weight: 400에 덮어씌워지는 문제가 있었습니다. 현재는.modal-profit-text.lotto-body로 특이도를 높여(0,2,0) 해결했는데,@import순서를 조정하는 것과 비교해서 어떤 방식이 더 나은지 궁금합니다. (260315 14:06 수정)#tempBonusNumber)를 사용하게 되었고 보너스 번호 검증 실패 시 당첨 번호부터 다시 입력하는 흐름이 되었습니다. 이 구조에 대해 어떻게 생각하시는지, 더 나은 방법이 있을지 의견을 듣고 싶습니다. (260315 14:06 추가)감사합니다. 천천히 보시고 리뷰 남겨주세요:)
✅ 리뷰어 체크 포인트
1단계
2단계