12. toString을 항상 재정의하라
안녕하세요, 이번 포스팅은 toString입니다.
제가 최근에 망치로 머리를 맞은 듯한 느낌이 드는 말을 들었는데요.
우아한형제들의 기술이사로 재직중이신 김영한님의 말씀입니다.
열정이나 목표는, 한 순간에 타오르고 꺼지는 불과 같습니다. 열정이나 목표만으로 무언가를 성취하기는 힘들다는 뜻입니다.
이것이 아닌, 자기 자신을 시스템에 던져 행동하는 것이 중요하다고 합니다.
예를 들어, 18시에 퇴근을 하고 19시에 집에 도착하여 19시 30분까지 밥을 먹고, 23시까지 공부를 하고, 씻고 자고....
이러한 스케줄링을 마치 cronjob에 자신을 등록해둔 것 처럼 꾸준히 행동한다면, 열정이나 목표와는 달리 어느새 성장한 본인을 볼 수 있을 것입니다.
보통 자신이 만든 객체를 확인하기 위해 아래와 같은 코드를 짜 출력합니다.
package item12;
public class Item12Main {
public static void main(String[] args) {
Human human = new Human("김동영", 25);
System.out.println("human = " + human);
}
}
human = item12.Human@7e0ea639
Process finished with exit code 0
해당 결과는, 자신이 원한 값이 아닙니다. 단지 클래스이름@16진수로표시한_해시코드를 반환할 뿐입니다.
toString의 일반 규약에 따라 '간결하면서 사람이 읽기 쉬운 형태의 유익한 정보'를 반환해야 합니다.
또한, toString의 규약은 "모든 하위 클래스에서 이 메서드를 재정의하라"고 합니다. 아주 중요한 규약입니다.
item 11과 item 12, 즉 equals와 hashCode 만큼 중요하진 않지만, toString을 잘 구현한 클래스는 디버깅하기 아주 쉬울 수 있습니다.
참고로, toString 메서드는 객체를 println, printf, 문자열 연결(+), assert 구문에 넘길 때, 혹은 디버거가 객체를 출력할 때 자동으로 불립니다.
한마디로 toSring 호출 없이 그저 객체를 위와 같은 예시로 출력하면 자동으로 불려진다는 뜻입니다.
예를 들어, 본인이 작성한 객체를 참조하는 컴포넌트가 오류 메시지를 로깅할 때 자동으로 호출할 수 있습니다. 만약 toString을 제대로 재정의하지 않는다면, 알아보기 힘든 메시지만 로그에 남을 것입니다.
toString을 제대로 재정의한다면, 다음 코드만으로 문제를 진단하기에 충분한 메시지를 남길 수 있습니다.
김동영님(25세)- 존재하지 않는 인물입니다(?)
Process finished with exit code 0
(억지스러운 예시)
특히, 좋은 toString은 이 인스턴스를 포함하는 객체에서(특히 컬렉션) 유용하게 쓰입니다. 예를 들어 map 객체에 위와 같은 인스턴스가 담겨있을 경우,
{대한민국=Human@7e0ea639}보다, {대한민국=김동영님(25세)} 가 훨씬 더 좋지 않을까요?
toSring은 그 객체가 가진 주요 정보들을 모두 반환하는 것이 좋습니다. 하지만, 객체가 거대하거나 객체의 상태가 문자열로 표현하기에 적합하지 않다면 무리가 있습니다.이런 상황이라면, 요약 정보를 담아야합니다.
toString을 구현하기에 가상 이상적인 것은, 스스로를 완벽히 설명하는 문자열입니다.
toString을 구현할 때면 반환값의 포맷을 문서화할지 정해야 합니다. 전화번호가 행렬 같은 값 클래스라면, 문서화를 권합니다. 포맷을 명시하면 그 객체는 표준적이고, 명확하고, 사람이 읽을 수 있게 됩니다. 따라서 그 값을 그대로 출력하거나, 혹은 csv처럼 다른 형식으로 저장할 수 있습니다.
포맷을 명시하기로 했다면, 명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 생성자를 함께 생성하면 좋습니다. 이를테면 BigInteger, BigDecimal과 대부분의 기본 타입 클래스처럼 말입니다.
의존 주입을 많이 생각하시던 분들은, 이게 의문이 드실 수 있습니다. (아닌가?)
바로, 내가 정의한 toString의 포맷이 고정되기 때문입니다. 이를 사용하느 는 개발자가 그 포맷에 맞춰 파싱하고, 객체를 만들고, 영속 데이터를 저장하는 코드를 작성할 것인데, 만약 포맷을 바꾼다면 어떻게 될까요?
반대로, 포맷을 명시하지 않는다면 향후 릴리즈에서 정보를 더 넣거나 포맷을 개선할 수 있는 유연성을 얻게됩니다.
포맷을 명시하든, 아니든 의도는 명확히 밝혀야 합니다. 이전 아이템 11에서 다룬 phoneNumber 클래스의 toString 메서드를 예시로 들겠습니다.
/**
* 전화번호의 문자열을 반환합니다.
* 이 문자열은, 'XXX-YYY-ZZZZ'형태의 12글자로 구성됩니다.
* XXX는 지역코드, YYY는 프리픽스, ZZZZ는 가입자 번호입니다.
* 각각의 대문자는 10진수 숫자 하나를 나타냅니다.
*
* 전화번호의 각 부분의 값이 너무 작아서 자릿수를 채울 수 없다면,
* 앞에서부터 0으로 채웁니다. 예를 들어 가입자 번호가 123이라면
* 전화번호의 마지막 네 문자는 "0123"이 됩니다.
*/
@Override
public String toString() {
return String.format("%03d-%03d-%04d", areaCode, prefix, lineNum);
}
phoneNumber = 123-456-0789
Process finished with exit code 0
해당 예시는 포맷을 명시한 예시이며, 명시하지 않기로 했다면 위와 같은 주석으로 대략의 기대 출력물을 설정해줄 수 있습니다.
포맷 명시 여부와 상관없이, toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하는 것이 좋습니다. 예를 들어 phoneNumber 클래스는 지역 코드, 프리픽스, 가입자 번호용 접근자를 제공해야 합니다. 그렇지 않으면 이 정보가 필요한 개발자는 toString의 반환값을 파싱할 수밖에 없습니다. 성능이 나빠지고, 필요하지도 않은 작업인데도 말입니다.
감히 호기를 부리자면... 이번 포스팅은 equals와 hashCode에게 흠씬 두들겨 맞으니 예방 효과가 있었나, 모두가 쉽게 생각하겠지만 그렇게 부담되지 않고 술술 읽을 수 있는 내용이었습니다. 하지만, 설명처럼 포맷을 명세화를 하냐 마냐에 구애받지 않고 주석으로 코멘트를 달아 어떤 형식으로 진행되는지 명시를 해야 향후 유지보수에 유용하다는 내용은 정말 중요한 내용인 것 같습니다.