Skip to content

[2단계 - 웹 기반 로또 게임] 애니 미션 제출합니다.#450

Merged
imakerjun merged 16 commits intowoowacourse:inaeminfrom
inaemin:step2
Apr 3, 2026
Merged

[2단계 - 웹 기반 로또 게임] 애니 미션 제출합니다.#450
imakerjun merged 16 commits intowoowacourse:inaeminfrom
inaemin:step2

Conversation

@inaemin
Copy link
Copy Markdown

@inaemin inaemin commented Mar 12, 2026

학습 목표

이번 미션을 통해 다음과 같은 학습 경험들을 쌓는 것을 목표로 합니다.

  • UI와 도메인 영역을 분리할 수 있는 설계를 고민해보고, 목적에 맞게 객체와 함수를 활용
  • TDD 방식으로 개발하며, 단위 테스트 기반으로 점진적인 리팩터링

제출 전 체크 리스트

  • 기능 요구 사항을 모두 구현했고, 정상적으로 동작하는지 확인했나요?
  • 기본적인 프로그래밍 요구 사항을 준수하고 있는지 확인했나요?
  • 테스트 코드는 모두 정상적으로 실행되나요?
  • (해당하는 경우) 배포한 데모 페이지에 정상적으로 접근할 수 있나요?

리뷰 요청 & 논의하고 싶은 내용

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) 이번 리뷰를 통해 논의하고 싶은 부분

  • 이벤트 위임 vs 직접 바인딩: 모달의 닫기/재시작 버튼에 { once: true } 리스너를 사용했는데, 이벤트 위임 방식과 비교해서 어떤 게 더 적절할지 의견을 듣고 싶습니다.
  • CSS 특이도 관리: style.css에서 @import로 컴포넌트 CSS를 먼저 불러온 뒤 공통 타이포그래피 클래스(.lotto-body)를 정의하는 구조입니다. 같은 특이도(0,1,0)일 때 나중에 선언된 쪽이 우선하기 때문에, modal.css.modal-profit-textfont-weight: bold를 줘도 .lotto-bodyfont-weight: 400에 덮어씌워지는 문제가 있었습니다. 현재는 .modal-profit-text.lotto-body로 특이도를 높여(0,2,0) 해결했는데, @import 순서를 조정하는 것과 비교해서 어떤 방식이 더 나은지 궁금합니다. (260315 14:06 수정)
  • 구입 금액 입력 단위: 다른 PR들을 보면 1,000원 단위로만 입력받도록 제한하는 경우가 대부분인데, 이런 제한이 필요하다고 생각하시는지 궁금합니다. 저는 1050원도 1장 살 수 있다고 봤거든요.
  • 당첨 번호·보너스 번호 입력 구조: 웹에서 하나의 폼으로 당첨 번호와 보너스 번호를 함께 받다 보니, 임시 변수(#tempBonusNumber)를 사용하게 되었고 보너스 번호 검증 실패 시 당첨 번호부터 다시 입력하는 흐름이 되었습니다. 이 구조에 대해 어떻게 생각하시는지, 더 나은 방법이 있을지 의견을 듣고 싶습니다. (260315 14:06 추가)

감사합니다. 천천히 보시고 리뷰 남겨주세요:)


✅ 리뷰어 체크 포인트

1단계

  • TDD를 활용해 기능을 구현하는 과정에서 적절한 테스트 우선 접근 방식을 적용했는가? 단위 테스트의 커버리지는 충분한가?
  • 도메인과 UI의 관심사를 분리하여 적절한 모듈화가 이루어졌는가? 하나의 객체나 모듈이 너무 많은 책임을 가지고 있지는 않은가?
  • 객체의 프로퍼티를 직접 조작하기보다 메시지를 던지고 있는가?
  • 불필요한 클래스를 사용하지 않고, 함수를 적극적으로 활용하여 JavaScript다운 방식으로 로직을 구현했는가?

2단계

  • 도메인 로직에 불필요한 영향을 주지 않고 UI 변경에 대응했는가?
  • DOM 조작과 이벤트 활용을 JavaScript의 개념에 맞게 이해하고 적절하게 적용했는가?
  • 웹 표준을 준수하는 마크업을 활용하며, 스타일 작성에 일관성이 있는가?

inaemin added 14 commits March 12, 2026 14:23
- 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이 콘솔과 웹 출력 뷰를 동일한 인터페이스로 사용할 수 있게 맞춤
@inaemin inaemin marked this pull request as ready for review March 13, 2026 11:35
Copy link
Copy Markdown
Collaborator

@imakerjun imakerjun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 애니!
리뷰가 많이 늦었는데 기다려줘서 고마워요.
전반적으로 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 흐름을 유지하기 위한 합리적인 선택"으로 볼 수 있다고 생각해요. 반면 웹 구조에 조금 더 맞춘 방향을 고민해본다면, 도메인을 바꾸기보다 AppInputView가 값을 주고받는 방식을 웹 환경에 맞게 조금 조정해볼 수도 있을 것 같아요.

예를 들어 readWinningNumbers(){ winningNumbers, bonusNumber }를 한 번에 반환하도록 바꾸면, 임시 상태 없이도 현재 화면 구조와 더 자연스럽게 맞출 수 있거든요. 결국 이 부분은 "공통 인터페이스를 얼마나 유지하고 싶은가"와 "웹 UI 구조를 얼마나 그대로 반영하고 싶은가" 사이의 선택처럼 보여서, 애니가 지금 구조를 택한 이유도 충분히 납득할 수 있었어요.

한 줄 정리

도메인을 억지로 바꾸지 않고 웹 UI를 붙인 방향은 아주 좋았습니다. 이제는 그다음 단계로, 웹 환경에 맞는 입력 흐름과 에러 경험을 어디까지 재설계할지 고민해보면 더 좋은 코드가 될 것 같아요.

다시 한번 기다려줘서 너무 고맙고 로또 2단계까지 진행하느라 너무 고생 많았습니다 👏🏻

Comment thread index.html
Comment on lines +184 to +186
<th>일치 갯수</th>
<th>당첨금</th>
<th>당첨 갯수</th>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

갯수는 맞춤법에 맞지 않는 표현이에요~!. 올바른 표기는 개수입니다. 😄

  • 일치 갯수일치 개수
  • 당첨 갯수당첨 개수

사소하지만 사용자에게 보이는 텍스트이니 수정해주세요~!

Comment thread src/web/WebInputView.js
Comment on lines +15 to +23
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 });
});
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

닫기 버튼(closeButton)과 재시작 버튼(retryButton)이 같은 handler를 공유하면서 둘 다 resolve(true)를 호출하고 있군요! step1에서는 "y/n"으로 종료와 재시작이 구분되었는데, 웹에서는 닫기를 눌러도 게임이 재시작되네요.

의도적인 선택이라면 괜찮지만, UX에서 "닫기(X)"와 "다시 시작하기"는 보통 다른 의도에요.
사용자가 결과를 다시 보려고 모달을 닫았는데 게임이 초기화되면 당황할 수 있어요.
"닫기 = 모달만 닫고 결과를 유지" + "다시 시작하기 = 전체 초기화"로 구분하고 싶다면 handler를 분리하는 방향을 고려해보면 좋을 것 같아요!

Comment thread src/web/WebInputView.js
});
}

static async readBonusNumber() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 이 작업을 보니readBonusNumberreadWinningNumbers보다 먼저 호출되면 null을 반환하고, readBonusNumber라는 이름에서 "읽는다"는 행위가 기대되지만 실제로는 저장된 값을 반환하네요. 네이밍이 동작을 잘 설명하는지 고민 포인트가 될 것 같아요.

그리고 웹에서는 콘솔처럼 순차 입력을 받을 필요가 없으므로, readWinningNumbers{ winningNumbers, bonusNumber }를 한번에 반환하는 구조로 바꿔보는 건 어떨까요? 도메인(Lotto, WinningNumber)은 건드리지 않으면서, Controller-View 인터페이스만 조정하는 방향이 될 것 같아요.

Comment thread src/web/WebOutputView.js
}

static printError(error) {
alert(error.message);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

에러를 alert(error.message)로 보여주는 방식은 구현은 간단하지만, 브라우저 기본 UI에 의존하다 보니 입력 맥락이 끊기고 [ERROR] 접두사도 사용자에게 그대로 노출 되는데요. 다른 PR의 리뷰에서도 이런 에러 UX는 자주 언급되더라고요.

당장 구조를 크게 바꾸지 않더라도, "입력 필드 근처에 메시지를 표시하려면 현재 설계에서 무엇이 필요할까?"를 한 번 그려보면 좋겠어요. 예를 들면 outputView.printError()가 전역 alert 대신 섹션별 에러 영역을 갱신하도록 만들어 볼 수 있다는 점을 고려해보면 좋겠어요~!

참고로 이와 관련된 UI/UX 설계 경험은 레벨2에서도 진행할 예정이에요 😄

Comment thread index.html
Comment on lines +66 to +73
<input
id="winning-lotto-1"
name="winning-lotto"
type="number"
class="winning-input lotto-input lotto-body"
required
aria-label="당첨 번호 1"
/>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재밌게도 type="number"는 브라우저마다 입력 경험이 달라질 수 있어요. MDN도 "어떤 브라우저는 invalid 문자를 허용하고, 어떤 브라우저는 허용하지 않는다"고 경고하고 있고, Firefox는 여전히 number input에 비숫자 문자를 입력할 수 있는 이슈가 있어요.

그리고 Chromium 계열(Chrome, Edge 등)은 문자 입력을 어느 정도 막지만 e, -, .처럼 부동소수점/지수 표기와 관련된 문자는 중간 상태로 허용하는게 가능해요!

여기서 e가 허용되는 이유도 같이 알아두면 좋은데요. HTML number input은 "정수 전용 입력"이 아니라 기본적으로 floating-point number를 다루는 입력이고, 과학적 표기법인 1e3, 2e-1 같은 값도 숫자로 취급할 수 있어요. 그래서 브라우저 입장에서는 사용자가 e를 입력하는 순간만 보고 바로 잘못된 문자라고 단정하기 어려워요.

애니의 앱에서도 '1e3'으로 다음 스텝이 통과됩니다! ㅎㅎ
Image

그래서 "로또 번호처럼 1~45의 정수만 받아야 하는 입력"이라면 type="number"만 믿기보다 방어를 한 단계 더 두는 쪽이 좋아요.

  • type="number"를 유지한다면 min="1", max="45", step="1"로 정수 범위를 먼저 좁혀주세요.
  • 다만 이것만으로는 충분하지 않을 수 있어서, 제출 시점에 Number.isInteger()까지 확인하는 검증이 있으면 더 안전해져요.

지금 PR에서는 최소한 min, max, step="1"를 추가하고, 도메인 쪽에서도 정수 여부를 한 번 더 확인해두면 브라우저별 차이를 꽤 줄일 수 있을 것 같네요 😄

🔗 참고

Comment thread index.html
</footer>
</div>

<div class="modal-overlay" role="presentation" hidden>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

직접 만든 모달에 role="dialog", aria-modal, aria-labelledby를 붙여 접근성을 챙긴 점 너무 좋아요 👏🏻
다만 현재 방식은 포커스 트랩이나 ESC 닫기 같은 동작을 직접 관리해야 해요.
반면 <dialog>를 쓰면 브라우저가 기본적으로 제공해주는 동작이 많아서 유지보수 하기가 편리한데요.

반드시 <dialog>가 정답이라는 뜻은 아니고, 지금 구현처럼 커스텀 모달을 유지할 수도 있어요. 다만 그 경우에는 "모달이 열렸을 때 포커스가 어디로 가는가", "Tab 키가 모달 밖으로 빠져나가는가" 같은 상호작용까지 함께 설계해야 한다는 점을 기억해주면 좋을 것 같아요!

Comment thread src/web/DOMController.js
Comment on lines +1 to +9
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;
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 규모에서는 큰 문제는 아니지만, DOMController가 매번 querySelector를 호출하다 보니 이 모듈이 어떤 요소들에 의존하는지 한눈에 들어오지가 않아요. 그래서 DOM 참조를 한 곳에 모아보면 책임 범위가 더 또렷해질 것 같아요~!

예를 들어 모듈 상단에서 필요한 요소를 캐싱해두면 "이 컨트롤러가 만지는 영역"이 더 분명해지고, 나중에 셀렉터가 바뀌어도 수정하기 위해 봐야하는 코드의 영역도 줄어들거에요.

이렇게 바꿔두면 "이 모듈이 어떤 DOM에 의존하는지"가 파일 상단에 드러나서 읽기가 쉬워지고, 셀렉터가 바뀌었을 때도 수정 포인트가 줄어들 거에요~!

Suggested change
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();
};

@imakerjun imakerjun merged commit a061e9b into woowacourse:inaemin Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants