Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a8bb2bb
feat: 1단계 구현
chemistryx Oct 4, 2025
4f5f242
feat: 2단계 구현
chemistryx Oct 4, 2025
43cdd44
refactor: SRP를 보다 잘 준수하도록 수정
chemistryx Oct 8, 2025
b3f2321
feat: 3단계 구현
chemistryx Oct 8, 2025
d228d82
feat: 4단계 구현
chemistryx Oct 9, 2025
2994967
refactor: Ladder 도메인 로직 패키지 분리
chemistryx Oct 9, 2025
bd78398
refactor: 사용하지 않는 메소드 삭제
chemistryx Oct 9, 2025
d06f91d
refactor: LadderController::run 책임 분리
chemistryx Oct 9, 2025
784e390
feat: 5단계 구현
chemistryx Oct 9, 2025
5a0159e
test: 테스트 코드 작성
chemistryx Oct 30, 2025
f1244e4
refactor: VO에 대해서 record 도입
chemistryx Nov 1, 2025
5ef1716
refactor: scanner.nextLine() 사용
chemistryx Nov 1, 2025
2573545
refactor: Arrays.asList()로 입력 문자열 맵핑
chemistryx Nov 1, 2025
d3d50f7
refactor: 참가자 이름 공백 입력받지 않도록 제한
chemistryx Nov 1, 2025
82d4c5c
refactor: createConnections()에 대해 early return 적용
chemistryx Nov 1, 2025
854f13a
refactor: handleOutcomeQuery 책임 분리
chemistryx Nov 1, 2025
9648d42
feat: 결과 값에 대한 검증 추가
chemistryx Nov 1, 2025
7650568
refactor: \n 문자 println으로 대체
chemistryx Nov 4, 2025
bf3978b
refactor: stream method chain 분리
chemistryx Nov 4, 2025
a16fcf5
refactor: 사다리 선분 구성 문자열 상수화
chemistryx Nov 4, 2025
99a4d60
refactor: centerAlign width 주입받도록 변경
chemistryx Nov 4, 2025
e04642a
fix: 실행 결과가 올바르게 출력되지 않는 문제 수정
chemistryx Nov 4, 2025
1814a51
refactor: printLadderResult 호출 LadderController로 이관
chemistryx Nov 4, 2025
33d7096
docs: README.md 추가
chemistryx Nov 4, 2025
7933714
refactor: 검증 로직 세부 구분
chemistryx Nov 5, 2025
4f1af17
test: 검증 로직 관련 테스트 추가
chemistryx Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed src/main/java/.gitkeep
Empty file.
10 changes: 10 additions & 0 deletions src/main/java/io/suhan/ladder/Main.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.suhan.ladder;

import io.suhan.ladder.controller.LadderController;

public class Main {
public static void main(String[] args) {
LadderController controller = new LadderController();
controller.run();
}
}
57 changes: 57 additions & 0 deletions src/main/java/io/suhan/ladder/controller/LadderController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.suhan.ladder.controller;

import io.suhan.ladder.model.Game;
import io.suhan.ladder.model.GameConfiguration;
import io.suhan.ladder.model.GameConfigurationBuilder;
import io.suhan.ladder.model.GameResult;
import io.suhan.ladder.model.Participant;
import io.suhan.ladder.view.InputView;
import io.suhan.ladder.view.OutputView;
import java.util.List;
import java.util.stream.Stream;

public class LadderController {
public void run() {
try {
GameConfiguration configuration = readConfiguration();

Choose a reason for hiding this comment

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

개별 값(participants, outcomes, width, height)을 바로 넘기지 않고, GameConfiguration 객체를 통해 입력값을 한 번에 전달하도록 설계하신 이유가 궁금합니다!

Copy link
Author

@chemistryx chemistryx Nov 1, 2025

Choose a reason for hiding this comment

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

제가 생각해보았을때 GameConfiguration객체를 도입한 이유는 다음과 같습니다:

  1. 입력 검증 로직과 게임 로직 분리
    현재 GameConfiguration객체의 경우 GameConfigurationBuilder를 통해 생성되도록 의도하였는데, 검증 로직의 경우 빌더가 담당함으로써 컨트롤러에서는 게임 실행 자체만을 담당할 수 있도록 구성하였습니다.

  2. 입력 값에 대한 순차적 검증
    사실 빌더 패턴을 도입하게된 이유라고 볼 수도 있을 것 같은데, 기존 validate()함수와 같은 방식으로 사용하게 되면, 아래와 같이 검증이 필요한 값들에 대하여 대체로 한 곳에서 검증을 수행합니다.

private static void validateParams(List<Participant> participants, List<String> outcomes, int width, int height) {
    if (participants.size() != outcomes.size()) {
        throw new IllegalArgumentException("참가자의 수와 실행 결과의 수는 같아야 합니다.");
    }

    if (width <= 0 || height <= 0) {
        throw new IllegalArgumentException("크기는 양수만 입력할 수 있습니다.");
    }
}

해당 방식의 경우 검증할 값이 많아질 수록 가독성이 떨어진다고 판단했고, 값이 많아짐에 따라 메소드 별로 분리하는 방법도 생각해볼 수 있으나 이 방식 또한 결국 각각의 검증 메소드를 호출해야 한다는 부분이 효율적이지 못하다고 판단했습니다.
따라서 빌더를 통해 값 할당과 동시에 검증 로직을 수행하는 방식이 위 두가지 단점을 해결할 수 있다고 판단하여 빌더 패턴 방식을 도입해보았습니다.

  1. Game 생성의 단순화
    만약 인자 수가 많아진다면 인자의 순서가 헷갈릴 수도 있고, 새로운 필드가 추가된다면 이에 따라 계속 수정해야 한다는 것이 번거롭다고 판단했습니다. 따라서 객체 하나만을 넘겨주는 방식을 통해 이러한 문제를 해결할 수 있었습니다.

Copy link

Choose a reason for hiding this comment

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

로직 생성의 이유를 명확히 고민하고 코드로 잘 구현하신 점이 정말 좋네용👍

2번에 관해서는

해당 방식의 경우 검증할 값이 많아질 수록 가독성이 떨어진다고 판단했고, 값이 많아짐에 따라 메소드 별로 분리하는 방법도 생각해볼 수 있으나 이 방식 또한 결국 각각의 검증 메소드를 호출해야 한다는 부분이 효율적이지 못하다고 판단했습니다.

라는 의견에 동의합니다! builder로 검증 로직을 분리하여 현재는 도메인이 더욱 비즈니스 로직에 집중할 수 있도록 설계된 것 같아요.

다만 매우매우 개인적으로는 도메인이 유효성까지 스스로 보장하는 것도 DDD 관점에서 괜찮은 접근이라고 생각합니다! 그래도 지금은 설계 의도도 명확하고, 충분히 적절해 보여요👍

Game game = Game.of(configuration);

GameResult result = game.execute();

handleOutcomeQuery(result);
} catch (Exception e) {
System.out.println(e.getMessage());
}
}

private GameConfiguration readConfiguration() {
// method chaining으로 구성하려고 하였으나 Input 검증으로 인해 각각 따로 받음
GameConfigurationBuilder builder = new GameConfigurationBuilder();

List<Participant> participants = InputView.getParticipants().stream().map(Participant::new).toList();

Choose a reason for hiding this comment

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

입력받은 String을 List로 변환하는 과정을 두 단계로 나누어
string을 List으로 변환하는 로직은 inputView에 위치하고,
List을 List와 같이 객체들의 리스트로 저장하는 로직은 컨트롤러에 작성하셨네요!

해당 로직을 모두 controller에서 진행하지 않고, 단계 별로 계층을 다르게 한 이유가 궁금합니다!

Choose a reason for hiding this comment

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

추가로 participant 입력에 대한 검증 로직이 없어

  1. , 또는 ,,(,만으로 이루어진 문자열) 입력한 경우
  2. 공백 혹은 아무것도 입력하지 않은 경우

에 예외를 던지지 않고 다음 단계 입력 로직이 실행되네요! 적절한 위치에 검증 로직을 추가해 위와 같은 경우에도 예외문을 던지면 좋을 것 같은데, 어디에 위치시키면 좋을 것 같나요?

Copy link
Author

Choose a reason for hiding this comment

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

입력받은 String을 List로 변환하는 과정을 두 단계로 나누어 string을 List으로 변환하는 로직은 inputView에 위치하고, List을 List와 같이 객체들의 리스트로 저장하는 로직은 컨트롤러에 작성하셨네요!

해당 로직을 모두 controller에서 진행하지 않고, 단계 별로 계층을 다르게 한 이유가 궁금합니다!

해당 방식의 경우 InputView에서는 정말 기본적인 입력값에 대한 처리(e.g., 목록의 경우 split 후 List로 변환)만 담당하고, 이후 해당 값들을 의미있는 객체로 변환하는 과정은 컨트롤러와 같이 관련 있는 로직에서 담당하는 것이 더 좋다고 판단하여 두 단계로 나누어서 이루어질 수 있도록 작성하였습니다!

Copy link
Author

@chemistryx chemistryx Nov 1, 2025

Choose a reason for hiding this comment

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

추가로 participant 입력에 대한 검증 로직이 없어

  1. , 또는 ,,(,만으로 이루어진 문자열) 입력한 경우
  2. 공백 혹은 아무것도 입력하지 않은 경우

에 예외를 던지지 않고 다음 단계 입력 로직이 실행되네요! 적절한 위치에 검증 로직을 추가해 위와 같은 경우에도 예외문을 던지면 좋을 것 같은데, 어디에 위치시키면 좋을 것 같나요?

제가 테스트 해보았을때는 두 케이스(,, ,,) 모두 정상적으로 검증 로직(GameConfigurationBuilder::participants())이 실행되는데, 혹시 제가 놓친 부분이 있을까요?

image

공백 입력의 경우 #83 (comment) 에서 이어서 다루도록 하겠습니다!!

Copy link

Choose a reason for hiding this comment

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

제가 테스트 해보았을때는 두 케이스(,, ,,) 모두 정상적으로 검증 로직(GameConfigurationBuilder::participants())이 실행되는데, 혹시 제가 놓친 부분이 있을까요?

다시 실행해보니 정상 작동하네요😂

builder.participants(participants);

List<String> outcomes = InputView.getOutcomes();
builder.outcomes(outcomes);

int height = InputView.getLadderHeight();
builder.height(height);

return builder.build();
}

private void handleOutcomeQuery(GameResult result) {
Stream.generate(InputView::getParticipantForResult)
.takeWhile((input) -> !input.equals("all"))
.forEach((input) -> result.getResults().keySet().stream()
.filter((participant) -> participant.getName().equals(input))
.findFirst()
.ifPresentOrElse(
(target) -> OutputView.printGameResultOf(target, result),
() -> System.out.println("존재하지 않는 참가자입니다.")
)
);

Choose a reason for hiding this comment

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

하나의 stream에서 너무 많은 역할을 하고 있는 것 같아요! 로직을 분리해보면 좋을 것 같습니다~

Copy link
Author

Choose a reason for hiding this comment

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

확인했습니다! 질문드린 내용이랑 같은 부분인지라 전체 코멘트에 관련 내용 달아놓도록 하겠습니다!!


OutputView.printGameResult(result);
}
}
81 changes: 81 additions & 0 deletions src/main/java/io/suhan/ladder/model/Game.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package io.suhan.ladder.model;

import io.suhan.ladder.model.ladder.Connection;
import io.suhan.ladder.model.ladder.Ladder;
import io.suhan.ladder.model.ladder.LadderFactory;
import io.suhan.ladder.model.ladder.Line;
import io.suhan.ladder.view.OutputView;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class Game {
private final Ladder ladder;
private final GameConfiguration configuration;

private Game(GameConfiguration configuration, Ladder ladder) {
this.configuration = configuration;
this.ladder = ladder;
}

public static Game of(GameConfiguration configuration) {
return new Game(configuration, LadderFactory.createLadder(configuration.getWidth(), configuration.getHeight()));
}

public static Game of(GameConfiguration configuration, Ladder ladder) {
return new Game(configuration, ladder);
}

public GameResult execute() {
List<Participant> participants = configuration.getParticipants();
List<String> outcomes = configuration.getOutcomes();
Map<Participant, String> result = new LinkedHashMap<>();

OutputView.printLadderResult(this);
Copy link

Choose a reason for hiding this comment

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

view가 domain 내부에서 호출되고있네요! 어떻게 수정할 수 있을까요?

Copy link
Author

Choose a reason for hiding this comment

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

다시 살펴보니 굳이 Game객체 내에서 호출될 필요가 없음에도 호출되고 있었네요! 해당 기능의 경우 LadderController가 담당하도록 이관했습니다!

1814a51 커밋에 반영했습니다.


for (int start = 0; start < configuration.getWidth(); start++) {
int end = traverse(start);
Participant participant = participants.get(start);
String outcome = outcomes.get(end);

result.put(participant, outcome);
}

return new GameResult(result);
}

private int traverse(int start) {
int col = start;

for (Line line : ladder.getLines()) {
col = findNextColumn(line, col);
}

return col;
}

private int findNextColumn(Line line, int col) {

return line.getConnections().stream()
.filter((connection) -> connection.getLeft() == col || connection.getRight() == col)
.findFirst()
.map((connection) -> getConnectedColumn(connection, col))
.orElse(col);
}

private int getConnectedColumn(Connection connection, int col) {
if (connection.getLeft() == col) {
return connection.getRight();
}

return connection.getLeft();
}

public Ladder getLadder() {
return ladder;
}

public GameConfiguration getConfiguration() {
return configuration;
}
}
34 changes: 34 additions & 0 deletions src/main/java/io/suhan/ladder/model/GameConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.suhan.ladder.model;

import java.util.Collections;
import java.util.List;

public class GameConfiguration {
private final List<Participant> participants;
private final List<String> outcomes;
private final int width;
private final int height;

public GameConfiguration(List<Participant> participants, List<String> outcomes, int width, int height) {
this.participants = participants;
this.outcomes = outcomes;
this.width = width;
this.height = height;
}

public List<Participant> getParticipants() {
return Collections.unmodifiableList(participants);
}

public List<String> getOutcomes() {
return Collections.unmodifiableList(outcomes);
}

public int getWidth() {
return width;
}

public int getHeight() {
return height;
}
}
43 changes: 43 additions & 0 deletions src/main/java/io/suhan/ladder/model/GameConfigurationBuilder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.suhan.ladder.model;

import java.util.List;

public class GameConfigurationBuilder {
private List<Participant> participants;
private List<String> outcomes;
private int height;

public GameConfigurationBuilder participants(List<Participant> participants) {
if (participants == null || participants.isEmpty()) {
throw new IllegalArgumentException("참가자 목록은 비어 있을 수 없습니다.");
}

this.participants = participants;

return this;
}
Comment on lines 10 to 22

Choose a reason for hiding this comment

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

현재 입력 터미널에서 엔터만 입력하여도 해당 로직이 오류를 감지하지 못하고있는데, 어디를 수정해야 할까요🤔

Copy link
Author

Choose a reason for hiding this comment

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

엔터를 입력 받은 후의 participants 배열을 찍어보니 아래와 같이 name이 공백인 객체가 들어가 있는 것을 확인했습니다.
image

따라서 Participant의 생성자에 공백 유무를 검사하는 로직을 추가하여 수정하였습니다~!

반영 커밋: d3d50f7


public GameConfigurationBuilder outcomes(List<String> outcomes) {
if (outcomes == null || outcomes.size() != participants.size()) {
throw new IllegalArgumentException("참가자의 수와 실행 결과의 수는 같아야 합니다.");
}
Copy link

Choose a reason for hiding this comment

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

outcomes == null인 검증과 outcomes.size() != participants.size()인 검증은 검증하고자하는 로직이 다르기 때문에, 에러메세지를 다르게 하여 다른 if문으로 나누어도 좋을 것 같은데 어떻게 생각하시나요?

Copy link
Author

Choose a reason for hiding this comment

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

맞습니다! outcomes == null인 경우는 빌더 패턴 사용 과정에서 값 자체가 주입이 안된 경우이므로 다르게 처리하는게 더 적합할 것 같습니다..!
마찬가지로 participants에 대한 검증 로직도 구분하여 7933714 커밋에 반영했습니다!


this.outcomes = outcomes;

return this;
}

public GameConfigurationBuilder height(int height) {
if (height <= 0) {
throw new IllegalArgumentException("높이는 양수여야 합니다.");
}

this.height = height;

return this;
}

public GameConfiguration build() {
return new GameConfiguration(participants, outcomes, participants.size(), height);
}
}
20 changes: 20 additions & 0 deletions src/main/java/io/suhan/ladder/model/GameResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.suhan.ladder.model;

import java.util.Collections;
import java.util.Map;

public class GameResult {
private final Map<Participant, String> results;

public GameResult(Map<Participant, String> results) {
this.results = results;
}

public Map<Participant, String> getResults() {
return Collections.unmodifiableMap(results);
}

public String getOutcome(Participant participant) {
return results.get(participant);
}
}
18 changes: 18 additions & 0 deletions src/main/java/io/suhan/ladder/model/Participant.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.suhan.ladder.model;

public class Participant {
public static final int PARTICIPANT_NAME_MAX_LENGTH = 5;
private final String name;

public Participant(String name) {
if (name.length() > PARTICIPANT_NAME_MAX_LENGTH) {
throw new IllegalArgumentException("참가자의 이름은 최대 " + PARTICIPANT_NAME_MAX_LENGTH + "자만 가능합니다.");
}

this.name = name;
}

public String getName() {
return name;
}
}

Choose a reason for hiding this comment

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

수정할 필요는 없지만 같은 단순한 값을 나타내는 객체를 VO라고 하는데요, 값 객체는 class가아닌 record로 사용해도 좋습니다!
record는 모든 필드값이 불변 객체로 선언되고, equals(), hashCode(), toString()등의 코드가 자동 생성된다는 장점이 있습니다.
이러한 개념이 있다고 알고 넘어가면 좋을 것 같아요~

Copy link
Author

Choose a reason for hiding this comment

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

앗 저번 스터디에서도 잠깐 언급되었던 내용이었던 것 같은데, 아무래도 적용해주는편이 반복적인 getter 메소드도 줄일 수 있어 훨씬 좋은 것 같습니다!

f1244e4 커밋에 반영했습니다~

19 changes: 19 additions & 0 deletions src/main/java/io/suhan/ladder/model/ladder/Connection.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.suhan.ladder.model.ladder;

public class Connection {
private final int left;
private final int right;

public Connection(int left, int right) {
this.left = left;
this.right = right;
}

public int getLeft() {
return left;
}

public int getRight() {
return right;
}
}
16 changes: 16 additions & 0 deletions src/main/java/io/suhan/ladder/model/ladder/Ladder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.suhan.ladder.model.ladder;

import java.util.Collections;
import java.util.List;

public class Ladder {
private final List<Line> lines;

public Ladder(List<Line> lines) {
this.lines = lines;
}

public List<Line> getLines() {
return Collections.unmodifiableList(lines);
}
}
53 changes: 53 additions & 0 deletions src/main/java/io/suhan/ladder/model/ladder/LadderFactory.java
Copy link

Choose a reason for hiding this comment

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

메서드 호출 체인이 다소 깊은 것 같은데

indent(인덴트, 들여쓰기) depth를 2를 넘지 않도록 구현한다. 1까지만 허용한다.

위 요구사항 때문인가요? 저도 비슷한 로직 (createLine와 createConnections)은 합치려고 고민해보았지만 depth를 1까지만 허용한다는 조건때문에 쉽지 않은 것 같긴하네요🤔

Copy link
Author

Choose a reason for hiding this comment

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

네 맞습니다! 사실 두 로직이 원래 하나의 메소드로 구성되어 있었는데, 해당 조건을 만족하기 위해서 지금과 같은 형태로 변경하게 되었습니다.

안그래도 로직을 나누면서 각 메소드 자체의 길이는 줄었지만, 메소드 내 호출되는 다른 메소드를 따라가면서 이해해야 하는 과정이 추가되었다는 점에서 좀 아쉽다는 생각이 들긴 했습니다 ㅠ

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.suhan.ladder.model.ladder;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class LadderFactory {
private static final Random DEFAULT_RANDOM = new Random();

public static Ladder createLadder(int width, int height) {
return createLadder(width, height, DEFAULT_RANDOM);
}

public static Ladder createLadder(int width, int height, Random random) {
List<Line> lines = createLines(width, height, random);

return new Ladder(lines);
}

private static List<Line> createLines(int width, int height, Random random) {
List<Line> lines = new ArrayList<>();

for (int i = 0; i < height; i++) {
lines.add(createLine(width, random));
}

return lines;
}

private static Line createLine(int width, Random random) {
List<Connection> connections = createConnections(width, random);

return new Line(connections);
}

private static List<Connection> createConnections(int width, Random random) {
List<Connection> connections = new ArrayList<>();

// TODO: depth 줄이기
for (int i = 0; i < width - 1; i++) {
if (shouldConnect(random)) {
connections.add(new Connection(i, i + 1));
i += 1; // skip the right next line
}
}

return connections;
}

private static boolean shouldConnect(Random random) {
return random.nextBoolean();
}
}
16 changes: 16 additions & 0 deletions src/main/java/io/suhan/ladder/model/ladder/Line.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.suhan.ladder.model.ladder;

import java.util.Collections;
import java.util.List;

public class Line {
private final List<Connection> connections;

public Line(List<Connection> connections) {
this.connections = connections;
}

public List<Connection> getConnections() {
return Collections.unmodifiableList(connections);
}
}
Loading