❓🙋♂️ 이 객체는 상태를 가지지 않는데, static 메서드로 선언해보는 것은 어떨까요?
우아한 테크코스 7기 프리코스를 수행하며 위와 같은 리뷰를 받게 되었습니다. 이를 보며 고민한 과정을 글로 남기고자 합니다.
아래의 Parser는 문자열에서 커스텀 구분자가 있는지 확인하여 구분자 리스트를 반환하고, 이를 기준으로 문자열을 자르는 등의 기능을 수행합니다. static 메서드가 아닌 인스턴스 메서드로 존재합니다.
public class Parser {
private static final int SEPARATOR_LOCATION_INDEX = 2;
public Separators getSeparatorList(boolean hasCustomSeparator, String inputStr) {
Separators separators = new Separators();
if (hasCustomSeparator) {
separators.addCustomSeparator(inputStr.charAt(SEPARATOR_LOCATION_INDEX));
}
return separators;
}
public String[] splitStrBySeparator(Separators separators, String slicedStr) {
String delimiter = separators.getSeparatorsRegex();
String[] splitStr = slicedStr.split(delimiter);
return Arrays.stream(splitStr)
.filter(str -> !str.trim().isEmpty())
.toArray(String[]::new);
}
/* 생략 */
<객체지향의 사실과 오해>라는 책을 보면 객체는 상태와 행위로 이루어져 있고, 행위로 상태가 변경됩니다.
1주차 미션에서 Number, Separator와 같은 객체는 상태와 행위를 가지고 있습니다.
이와 반면, Parser는 내부 상태를 가지지 않으며 행위만 존재합니다. 이렇게 상태가 없는 객체의 메서드는 static으로 선언해야 할까요?
아래는 제가 받은 리뷰의 일부입니다.
객체는 상태와 행위로 이루어져 있습니다.
그런데 Parser의 경우 상태 없이 행위만 정의하고 있습니다.
이렇게 상태가 없는 객체의 메서드는 static으로 선언하는게 어떨까요?
그럼 객체를 직접 생성할 필요도 없고, 기능만 수행하는 객체로 유지할 수 있을 것 같습니다.
🙋♂️ 지금부터 이 질문에 대해 제가 고민을 한 과정을 공유해보려고 합니다!
먼저 함수와 메서드의 차이점에 대해 짚고 넘어가려고 합니다. 자바에서는 함수라는 표현을 잘 사용하지 않고 주로 메서드라고 부릅니다. 이 둘의 차이점은 뭐가 있을까요?
함수는 전달 받은 입력(파라미터)에 의해서만 결과가 바뀝니다.
public static String[] splitStrBySeparator(Separators separators, String slicedStr) {
String delimiter = separators.getDelimiter();
String[] splitStr = slicedStr.split(delimiter);
return Arrays.stream(splitStr)
.filter(str -> !str.trim().isEmpty())
.toArray(String[]::new);
}
이는 구분자를 기준으로 문자열을 자르는 함수입니다. 파라미터로 받은 Separators, slicedStr에 따라 결과가 달라집니다. 즉, splitStrBySeparator라는 함수는 오직 파라미터에 의해서만 결과가 바뀝니다.
함수는 객체 지향 언어인 Java 보다는 절차 지향 언어에서 주로 정의됩니다. 절차 지향 언어에서는 클래스를 사용하지 않고, 외부에서 함수를 전역적으로 선언할 수 있습니다. 이를 통해 함수는 특정 클래스에 속하지 않고 코드 어디에서든 접근 가능해집니다.
그러나 자바에서는 모든 함수(메서드)가 반드시 클래스에 속해야 하기 때문에 이를 클래스 내부에 static 메서드의 형태로 정의하게 됩니다. 이렇게 정의된 static 메서드는 함수라고 부를 수 있습니다.
그럼 메서드는 어떨까요?
메서드는 파라미터에 의해서도 결과가 달라지지만, 내부 상태에 의해서도 변화합니다.
public class Separators {
private final Set<Character> separators;
private String separatorsRegex;
public Separators() {
separators = new HashSet<>(Arrays.asList(':', ','));
}
public void addCustomSeparator(Character customSeparator) {
validateSeparator(customSeparator);
separators.add(customSeparator);
}
public String getSeparatorsRegex() {
if (separatorsRegex == null) {
makeDelimiter();
}
return this.separatorsRegex;
}
private void makeDelimiter() {
StringBuilder delimiter = new StringBuilder();
delimiter.append("[");
for (Character separator : this.separators) {
if (separator == '-' || separator == '[' || separator == ']') {
delimiter.append("\\");
}
delimiter.append(separator);
}
delimiter.append("]");
this.separatorsRegex = delimiter.toString();
}
private void validateSeparator(Character separator) {
if (Character.isDigit(separator)) {
throw new InvalidSeparatorException(separator);
}
}
}
separators라는 내부 상태가 변경되면서 getSeparatorsRegex 메서드의 반환 값도 달라집니다. 이처럼 메서드는 객체 안에 정의된 함수이며, 파라미터 뿐 아니라 객체의 내부 상태에 의해서도 변경될 수 있습니다.
또한 메서드는 메서드가 속해있는 클래스의 인스턴스를 통해서만 호출될 수 있습니다. 메서드가 해당 객체의 상태에 접근할 수 있고, 객체의 상태를 변경할 수 있기 때문이죠.
정리하자면, 함수는 파라미터에 의해서만 결과가 바뀌며, 메서드는 내부 상태에 의해서도 바뀝니다. 객체 안에 있는 함수를 메서드라고 부르며, 모든 메서드는 함수입니다. 반대로, 모든 함수가 메서드가 되는 것은 아닙니다.
앞서 이야기한 흐름에 따르면, static 메서드는 함수와 같습니다. static 메서드는 오직 파라미터에 의해서만 결과가 바뀌며, 객체의 상태를 접근하거나 수정할 수 없습니다.
상태가 없는 객체의 메서드는 static method로 선언해도 인스턴스 메서드로 사용할 때와 큰 차이점은 없습니다.
다만, 클래스 내부의 모든 메서드를 static으로 선언했다면(주로 ~Util 등으로 이름 지은), 이는 객체라고 부를 수 없습니다. 따라서 객체의 장점을 이용할 수 없게 됩니다.
제일 먼저, 다형성을 활용할 수 없습니다. 예를 들어 Parser 라는 인터페이스를 두고, StringParser, NumberParser 처럼 다양한 구현체를 만든다고 가정해보겠습니다.
public interface Parser {
String[] parse(String input);
}
public class CommaParser implements Parser {
@Override
public String[] parse(String input) {
return input.split(",");
}
}
public class ColonParser implements Parser {
@Override
public String[] parse(String input) {
return input.split(":");
}
}
Parser를 인터페이스로 선언하고, 내부에 문자열을 자르는 행위를 나타내는 parse 메서드를 두었습니다.
Parser를 구현한 CommaParser
, ColonParser
는 각각 쉼표(,
), 콜론(:
)을 기준으로 문자열을 잘라서 반환합니다. "자른다"는 행위를 재사용할 수 있게 되었습니다.
이와 같이 parser 메서드를 static으로 선언하지 않고, 인스턴스 메서드로 선언함으로써 Parser는 객체로 활용될 수 있습니다.
반면, parse 메서드를 static으로 선언한다면, 위처럼 다형성이라는 장점을 활용할 수 없게 됩니다.
public class ParserStatic {
public static String[] parse(String input) {
return input.split(",");
}
}
ParserStatic 클래스는 객체라고 말하기 어렵습니다. 여러 가지 ParserStatic을 만들 수도 없고, parse라는 메서드를 재사용할 수도 없습니다. 앞의 예시처럼 쉼표(,
), 콜론(:
) 등 구분자를 기준으로 다양한 parse 메서드를 구현할 수 없어졌습니다.
1주차 프리코스 미션에서 지금 당장은 Parser가 확장될 일이 없어 보이지만, 만약 객체의 다형성이 필요한 시점이 왔을 때, static 메서드로만 선언한 클래스는 확장하여 사용할 수 없습니다. 확장에 닫혀 있다는 것은 좋지 않은 설계입니다.
또한 객체 지향 프로그래밍에서 다형성은 중요한 개념이기 때문에 다형성을 활용할 수 없다는 것은 큰 단점이라고 생각했습니다.
두 번째로, 클래스 내부의 메서드를 모두 static으로 선언하게 된다면, 이를 모킹하여 테스트하는 일이 굉장히 어려워집니다.
먼저 모킹을 하는 이유는, 의존하는 클래스에 대한 의존성을 잠시 끊고 독립적으로 단위 테스트하기 위함입니다.
그러나 static 메서드는 Parser를 모킹해 테스트하고 싶을 때, 이를 불가능하게 만듭니다.
parse 메서드를 static으로 선언하지 않고, Parser를 사용하는 StringWrapper라는 클래스를 테스트하는 예시를 살펴 보도록 하겠습니다.
StringWrapper 클래스에 문자열을 자르고 이를 인덱싱하고 감싸서 반환하는 기능을 하는 메서드를 선언했습니다.
public class StringWrapper {
private final Parser parser;
public StringWrapper(Parser parser) {
this.parser = parser;
}
public String wrap(String input) {
String[] splitInput = parser.parse(input);
StringBuilder result = new StringBuilder();
for (int i = 0; i < splitInput.length; i++) {
result.append(i);
result.append(splitInput[i]);
}
return String.valueOf(result);
}
}
StringWrapper는 Parser 인터페이스에 의존하며, 다양한 Parser 구현체를 주입받습니다.
이제 StringWrapper를 독립적으로 단위 테스트해보도록 하겠습니다. FakeParser를 만들고 이를 StringWrapper에 모킹해 테스트하려고 합니다.
StringWrapper를 단위 테스트하는 코드는 아래와 같습니다.
@Test
void fakeParserTest() {
FakeParser fakeParser = new FakeParser();
StringWrapper stringWrapper = new StringWrapper(fakeParser);
assertThat(stringWrapper.wrap("a,b:c")).isEqualTo("0fake1parser2for3test");
}
public class FakeParser implements Parser {
@Override
public String[] parse(String input) {
return new String[] {"fake", "parser", "for", "test"};
}
}
FakeParser는 input과는 무관하게 항상 동일한 결과를 리턴하는 가짜 목 객체입니다. 이로써 StringWrapper를 독립적으로 단위테스트 할 수 있게 됩니다. Parser와는 무관하게 StringWrapper 만을 테스트할 수 있게 되는 것이죠.
이제 parse 메서드를 static 메서드로 선언하고, 위의 과정을 다시 살펴보도록 하겠습니다.
public class StringWrapper {
public String wrap(String input) {
String[] splitInput = ParserStatic.parse(input);
StringBuilder result = new StringBuilder();
for (int i = 0; i < splitInput.length; i++) {
result.append(i);
result.append(splitInput[i]);
}
return String.valueOf(result);
}
}
StringWrapper에서 ParserStatic.parse
(클래스 이름.메서드명)로 ParserStatic 클래스의 parse 메서드를 사용하고 있습니다.
StringWrapper에 대한 테스트 코드는 아래와 같습니다.
@Test
void staticParserTest() {
StringWrapper stringWrapper = new StringWrapper();
assertThat(stringWrapper.wrap("a,b:c")).isEqualTo("0a1b:c");
}
그런데 이때, ParserStatic 클래스를 수정하다가 아래처럼 문제가 발생한다면 어떻게 될까요?
public class ParserStatic {
public static String[] parse(String input) {
throw new IllegalArgumentException("ParserStatic 클래스에 문제 발생");
}
}
parse 메서드에서 예외를 발생하기 때문에 StringWrapper에 대한 단위테스트도 실패하게 됩니다.
StringWrapper가 의존하고 있는 ParserStatic에 문제가 발생했을 때 이 영향이 StringWrapper에 미치고 있기 때문에, 이러한 의존성을 끊고 목 객체를 생성하고 싶습니다.
그러나 static 메서드의 경우 앞선 에시처럼 가짜 목 객체를 생성해 테스트에 활용할 수가 없습니다. 이로써 static 메서드를 포함한 클래스를 모킹하여 테스트하기가 어려워집니다.
static 메서드를 사용할 경우, 위와 같은 객체의 장점을 잃어버리기 때문에 잘 생각해보고 사용해야 할 것 같습니다.
단순히 상태가 없다면 static 메서드를 사용해야 한다는 것은 틀린 말입니다. 객체는 상태가 있을 수도 있고, 없을 수도 있습니다. 중요한 것은 static 메서드가 나열된 클래스는 객체로 활용될 수가 없기 때문에, 해당 클래스를 객체로 볼 것인지 아닌지를 먼저 고려해보아야 합니다. 만약 Parser를 객체로 보지 않는다면, static 메서드로 선언할 수도 있습니다. 그러나 단순히 상태가 없다고 static으로 만들면 안됩니다.
그 예시로, Spring 프로젝트를 할 때 Service, Controller 등 @Component가 붙어 빈에 등록되는 객체들은 모두 내부에 상태(인스턴스 변수)를 가질 수 없습니다. 이들은 싱글톤 빈에서 관리되기 때문에 애플리케이션 전체에서 하나의 인스턴스만 생성되어 여러 요청에서 공유됩니다. 즉, 멀티스레드에서 공유되는 객체기 때문에 변경이 가능한 상태를 뒀을 때 동시성 문제가 발생할 수 있습니다. 이들은 상태를 가지지는 않지만 명백한 객체입니다.
엘레강트 오브젝트라는 책에서 static은 객체가 아니니까 절대 쓰지 말라고 합니다. 그러나 코드나 설계에는 항상 트레이드 오프가 존재하기 때문에 적절한 시점에 사용할 줄 알아야 한다고 생각합니다. static을 통해 구현하는 것도 방법 중의 하나이며, static 메서드를 사용할 때가 더 적합한 상황도 분명히 존재합니다.
그렇기 때문에 static method로 선언할 때와 객체의 인스턴스 메서드로 선언할 때의 차이점을 분명히 알고, 필요할 때 적절한 방식을 잘 선택할 줄 아는 것이 중요합니다.
1주차 미션에서 제가 정의한 Parser의 경우 구체적인 클래스입니다. 또한 객체 내부에 상태를 가지고 있지 않기 때문에 static 메서드로 선언해도, 동작하는 데에 큰 차이점은 없습니다.
다만, 요구사항이 변경되어 Parser를 인터페이스로 만들고, CommaParser, ColonParser 등 다양한 구현체를 만들게 된다면, Parser는 반드시 객체로 만들어야 합니다. static 메서드로만 동작하는 클래스는 객체라고 부를 수 없습니다.
따라서 객체가 상태를 가지지 않으면 static 메서드를 사용하는 것이 아니라, 해당 클래스를 객체로 볼 것인지를 먼저 고민해볼 필요가 있습니다. 저의 경우 Parser를 객체로 바라봤습니다.
static 메서드 또한 분명 필요한 경우가 있기 때문에 이에 대한 공부가 더 필요할 것 같습니다!
유익한 글 잘 읽었습니다!
static 메서드에 대한 고민을 해결하는 과정에 대해 굉장히 잘 설명해주셔서 읽으면서 정말 공감이 많이 되었습니다.
저같은 경우는 Util 클래스를 static 메서드로 작성했다가 테스트가 불편해서 객체화했는데요, 그래도 객체 생성 비용을 아끼고자 싱글톤 패턴을 적용해 단일 객체를 유지하도록 작성했습니다.
상태없이 static 메서드를 사용하는 곳에서 다형성을 챙기고 싶다면 싱글톤 패턴을 적용하는 방향에 대해 어떻게 생각하시는지 궁금합니다! 😁
글 잘 읽었습니다! static 에 대해 고민해 볼 수 있는 좋은 글이네요.
권장사항은 아니긴 하지만 static 메서드를 mock 하는 것이 아예 불가능한 것은 아니고 객체를 래핑한다던가 MockedStatic 으로 가능하다고 알고 있어서 나중에 찾아보시는 것도 좋을거 같아요.