Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Step1 : 문자열 덧셈 계산기 #773

Merged
merged 15 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions src/main/java/racingcar/Car.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ public class Car {

public Car(final String name) {
if (name.length() > 5) {
throw new IllegalArgumentException();
throw new IllegalArgumentException("이름은 5글자를 넘을 수 없습니다");
}

this.name = name;
}

public int getPosition() {
return position;
}

public void move(final MovingStrategy movingStrategy) {
if (movingStrategy.movable()) {
position++;
}
}

public int getPosition() {
return position;
}
}
32 changes: 32 additions & 0 deletions src/main/kotlin/stringcalculator/ExpressionMatcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package stringcalculator
Copy link

Choose a reason for hiding this comment

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

고민되는 부분
기능을 작성하다보면 항상 고민되는 부분이 있는데, 여러명이서 협업해서 코드를 작성할 때 역할에 따라 객체를 나누고 작성하는 부분도 중요하지만 다른 동료들이 시간을 많이 사용하지 않고 빠르게 이해하고 유지보수하기 쉬운 코드를 짤 수 있을지도 같이 고민하게 됩니다.
강의와 미션을 구현하는 입장에서는 SRP를 따라서 역할에 맞게 객체를 설계하는게 맞다고 생각하는데, 문자열 덧셈 계산기와 같이 복잡하지 않은 기능을 구현할 때 여러 객체로 분할해서 코드를 만들다보면 결국 동료가 코드를 볼 때 한 눈에 보기 어렵다는 이야기도 나오고, 처음부터 역할을 나누기보다 이후에 새로운 요구사항이 들어올 때 리팩터링하면서 확장하는게 더 좋겠다는 의견도 나오고 다양한 의견이 나옵니다. 물론 역할에 맞게 나누는게 좋다고 하는 동료들도 있고요.
리뷰어님께서도 다양한 의견을 들어오셨을 것 같은데 혹시 제가 고민되는 부분에 대해서 어떤 의견이실지 궁금합니다.

사실 정답이 없는 이야기라 단순히 저의 주관적인 생각입니다.
객체지향적으로 설계하고 SOLID 원칙을 지키며 코딩하는 방식은 유지보수 관점에서 중요하다고 생각하는데, 말씀하신 것 처럼
이런 부분이 과하다고 생각할 수 있다고 생각해요. 저 또한 개인적으로 YANGI 원칙를 선호하기 때문에 저 또한 정말 필요성을 느끼면 진행하는 것을 좋아합니다만 하지만 그렇다고해서 이런 설계를 전혀 고려하지 않는 것은 지양해야한다고 생각해요.

지금은 단순하기 때문에 이런 객체지향적인 설계와 SRP 원칙을 준수하는 등의 행위가 덜 중요하게 느껴질 수 있다고 생각하지만 조금만 더 로직이 복잡해지기 시작하면 이런 설계의 필요성을 금방 느끼게 될거라 생각해요. 🤔

�특히 지금과 같이 문자열 덧셈 계산기의 경우 저희는 이 미션에서만 잠시 다루고 이후로는 유지보수하지 않을 코드이기 때문에 이런 객체지향적인 설계와 SRP를 지키며 코딩하는 것이 조금 과하다고 생각될 수 있지만 말씀하신 것처럼 지금은 학습의 관점에서 하는 것이 조금 더 강한 것은 맞습니다. 😃


import java.util.regex.Pattern

class ExpressionMatcher {

fun transform(expressionStr: String): Expression {
cjcjon marked this conversation as resolved.
Show resolved Hide resolved
val matcher = CUSTOM_DELIMITER_PATTERN.matcher(expressionStr)

return if (matcher.find()) {
Expression(
input = matcher.group(2),
delimiters = DEFAULT_DELIMITER + matcher.group(1),
cjcjon marked this conversation as resolved.
Show resolved Hide resolved
)
} else {
Expression(
input = expressionStr,
delimiters = DEFAULT_DELIMITER,
)
}
}

companion object {
private val DEFAULT_DELIMITER = listOf(",", ":")
private val CUSTOM_DELIMITER_PATTERN = Pattern.compile("//(.)\n(.*)")

data class Expression(
val delimiters: List<String>,
val input: String,
)
Comment on lines +27 to +30
Copy link

Choose a reason for hiding this comment

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

Expression 클래스를 Comapnion Object에서 정의하신 이유가 있으신가요? 🤔

Copy link
Author

@cjcjon cjcjon Jan 27, 2025

Choose a reason for hiding this comment

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

ExpressionMatcher.kt 파일에서 바깥에 같이 정의하거나 stringcalculator 패키지에 정의해도 되는데, 특정 객체를 통해서 주도적으로 생성되는 data class 같은 경우에는 해당 class의 companion 내부에서 정의하는게 명시적이라고 생각해서 companion object 내부에 정의했습니다.

}
}
cjcjon marked this conversation as resolved.
Show resolved Hide resolved
18 changes: 18 additions & 0 deletions src/main/kotlin/stringcalculator/NumberTokenizer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package stringcalculator

import stringcalculator.ExpressionMatcher.Companion.Expression

class NumberTokenizer {
cjcjon marked this conversation as resolved.
Show resolved Hide resolved

fun tokenize(expression: Expression): List<Int> {
val numberTokens = splitNumberTokens(expression.input, expression.delimiters)
val numbers = numberTokens.map { it.toInt() }

return numbers
}

private fun splitNumberTokens(input: String, delimiters: List<String>): List<String> {
return if (delimiters.isEmpty()) listOf(input)
else input.split(delimiters.joinToString("|").toRegex())
}
}
27 changes: 27 additions & 0 deletions src/main/kotlin/stringcalculator/StringCalculator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package stringcalculator

class StringCalculator {

private val expressionMatcher = ExpressionMatcher()
private val numberTokenizer = NumberTokenizer()
cjcjon marked this conversation as resolved.
Show resolved Hide resolved

fun calculate(calculatorExpression: String?): Int {
if (calculatorExpression.isNullOrEmpty()) return DEFAULT_VALUE

val expression = expressionMatcher.transform(calculatorExpression)
val numbers = numberTokenizer.tokenize(expression)
validateNegativeValue(numbers)

return numbers.sum()
}

private fun validateNegativeValue(tokens: List<Int>) {
if (tokens.any { it < 0 }) {
throw RuntimeException("음수의 덧셈은 제공하지 않는 기능입니다")
}
}

companion object {
private const val DEFAULT_VALUE = 0
}
}
54 changes: 54 additions & 0 deletions src/test/kotlin/stringcalculator/ExpressionMatcherTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package stringcalculator

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import stringcalculator.ExpressionMatcher.Companion.Expression

internal class ExpressionMatcherTest {

private val sut = ExpressionMatcher()

@DisplayName("커스텀 구분자가 없을경우 기본 구분자로 쉼표(,)와 콜론(:)을 포함시킨다")
@Test
fun useDefaultDelimiterIfNoCustomDelimiter() {
// given
val expressionInput = "1:2,3"

// when
val actual = sut.transform(expressionInput)

// then
assertThat(actual).isEqualTo(Expression(DEFAULT_DELIMITERS, expressionInput))
}

@DisplayName("커스텀 구분자는 //와 \n 문자 사이에 지정해 Expression에 포함할 수 있다")
@Test
fun useCustomDelimiterIfHaveCustomDelimiterInput() {
// given
val expressionInput = "//;\n1:2,3;4"

// when
val actual = sut.transform(expressionInput)

// then
assertThat(actual).isEqualTo(Expression(listOf(*DEFAULT_DELIMITERS.toTypedArray(), ";"), "1:2,3;4"))
}

@DisplayName("커스텀 구분자 이외의 모든 문자열을 입력으로 변환한다")
@Test
fun useAllInputIfNoDelimiter() {
// given
val expressionInput = ",a;2:3,12"

// when
val actual = sut.transform(expressionInput)

// then
assertThat(actual).isEqualTo(Expression(DEFAULT_DELIMITERS, ",a;2:3,12"))
}

companion object {
private val DEFAULT_DELIMITERS = listOf(",", ":")
}
}
66 changes: 66 additions & 0 deletions src/test/kotlin/stringcalculator/NumberTokenizerTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package stringcalculator

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import stringcalculator.ExpressionMatcher.Companion.Expression

internal class NumberTokenizerTest {

private val sut = NumberTokenizer()

@DisplayName("숫자로 변환할 수 없을경우 NumberFormatException 예외가 발생한다")
@Test
fun errorIfNoNumberFormat() {
// given
val delimiters = listOf(":")
val input = "1:asdf"
val expr = Expression(delimiters, input)

// when
val actual = kotlin.runCatching { sut.tokenize(expr) }

// then
assertThat(actual.exceptionOrNull()).isInstanceOf(NumberFormatException::class.java)
}

@DisplayName("구분자가 없을경우 입력된 문자열 자체를 숫자로 변환한다")
@Test
fun parseNumberIfNoDelimiter() {
// given
val delimiters = emptyList<String>()
val input = "12345"

// when
val actual = sut.tokenize(Expression(delimiters, input))

// then
assertThat(actual).isEqualTo(listOf(12345))
}

@DisplayName("입력된 문자열을 구분자를 통해 나눈다")
@ParameterizedTest
@MethodSource("provideDelimiterSource")
fun tokenize(delimiters: List<String>, input: String, answer: List<Int>) {
// given
val exp = Expression(delimiters, input)

// when
val actual = sut.tokenize(exp)

// then
assertThat(actual).isEqualTo(answer)
}

companion object {
@JvmStatic
fun provideDelimiterSource() = listOf(
Arguments.of(listOf(":"), "1:2:3", listOf(1, 2, 3)),
Arguments.of(listOf(":", ","), "1:2,3", listOf(1, 2, 3)),
Arguments.of(listOf("=", ",", ":"), "1=2,3:4", listOf(1, 2, 3, 4)),
)
}
}
77 changes: 77 additions & 0 deletions src/test/kotlin/stringcalculator/StringCalculatorTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package stringcalculator

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatExceptionOfType
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.NullAndEmptySource
import org.junit.jupiter.params.provider.ValueSource
cjcjon marked this conversation as resolved.
Show resolved Hide resolved

internal class StringCalculatorTest {

private val sut = StringCalculator()

@DisplayName(value = "빈 문자열 또는 null 값을 입력할 경우 0을 반환해야 한다.")
@ParameterizedTest
@NullAndEmptySource
fun emptyOrNull(text: String?) {
assertThat(sut.calculate(text)).isZero
}

@DisplayName(value = "숫자 하나를 문자열로 입력할 경우 해당 숫자를 반환한다.")
@ParameterizedTest
@ValueSource(strings = ["1"])
fun oneNumber(text: String) {
assertThat(sut.calculate(text)).isSameAs(text.toInt())
}

@DisplayName(value = "숫자 두개를 쉼표(,) 구분자로 입력할 경우 두 숫자의 합을 반환한다.")
@ParameterizedTest
@ValueSource(strings = ["1,2"])
fun twoNumbers(text: String?) {
assertThat(sut.calculate(text)).isSameAs(3)
}

@DisplayName(value = "구분자를 쉼표(,) 이외에 콜론(:)을 사용할 수 있다.")
@ParameterizedTest
@ValueSource(strings = ["1,2:3"])
fun colons(text: String?) {
assertThat(sut.calculate(text)).isSameAs(6)
}

@DisplayName(value = "//와 \\n 문자 사이에 커스텀 구분자를 지정할 수 있다.")
@ParameterizedTest
@ValueSource(strings = ["//;\n1;2;3"])
fun customDelimiter(text: String?) {
assertThat(sut.calculate(text)).isSameAs(6)
}

@DisplayName(value = "문자열 계산기에 음수를 전달하는 경우 RuntimeException 예외 처리를 한다.")
@ParameterizedTest
@ValueSource(strings = ["-1", "1:-3", "3,5,7:-29"])
fun negative(text: String) {
assertThatExceptionOfType(RuntimeException::class.java).isThrownBy {
sut.calculate(text)
}
}

@DisplayName(value = "문자열 계산기로 합을 계산한다")
@ParameterizedTest
@MethodSource("provideSumArguments")
cjcjon marked this conversation as resolved.
Show resolved Hide resolved
fun sum(text: String, result: Int) {
assertThat(sut.calculate(text)).isSameAs(result)
}

companion object {
@JvmStatic
fun provideSumArguments() = listOf(
Arguments.of("0", 0),
Arguments.of("1", 1),
Arguments.of("1,3,5", 9),
Arguments.of("4:15,25", 44),
Arguments.of("//;\n1;2;3,4:5", 15),
)
}
}