Java toString() — 의미 있는 문자열 표현 만들기
Object.toString()의 기본 출력 형식과 한계, 오버라이드해야 하는 이유, toString()이 자동 호출되는 상황, 올바른 구현 패턴과 formatted() 활용, 그리고 순환 참조·민감 정보 포함 같은 주의사항을 실전 코드로 정리한다
지난 글에서 equals()와 hashCode()의 계약과 올바른 구현 방법을 살펴봤다. 이번에는 Object의 또 다른 핵심 메서드인 **toString()**을 다룬다. equals/hashCode보다 단순해 보이지만, 제대로 구현하지 않으면 디버깅이 어려워지고 로그가 무의미해진다.
기본 동작
Object.toString()을 오버라이드하지 않으면 다음 형식을 반환한다.
getClass().getName() + "@" + Integer.toHexString(hashCode())
class Person {
String name = "Alice";
int age = 30;
}
Person p = new Person();
System.out.println(p); // Person@1b6d3586
// 로그에서
log.info("사용자: {}", p); // 사용자: Person@1b6d3586
Person@1b6d3586은 디버깅에 전혀 도움이 되지 않는다. 어떤 사람인지, 어떤 값을 가지는지 알 수 없다.
toString()이 자동 호출되는 상황
toString()은 명시적으로 호출하지 않아도 여러 상황에서 자동으로 호출된다.
Person p = new Person("Alice", 30);
// 1. 문자열 연결
String msg = "사용자: " + p; // p.toString() 호출
// 2. System.out.println
System.out.println(p); // p.toString() 호출
// 3. String.valueOf()
String s = String.valueOf(p); // p.toString() 호출
// 4. 포맷 문자열 %s
String f = String.format("user=%s", p); // p.toString() 호출
특히 SLF4J 같은 로거의 {} 플레이스홀더도 내부적으로 toString()을 호출한다.
기본 오버라이드 패턴
class Person {
private final String name;
private final int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name=" + name + ", age=" + age + "}";
}
}
System.out.println(new Person("Alice", 30));
// Person{name=Alice, age=30}
클래스 이름을 접두사로 포함하면 로그에서 어떤 타입인지 즉시 알 수 있다.
Java 15+ formatted() 활용
String.formatted()(Java 15+)를 사용하면 더 읽기 좋은 형식을 쉽게 만들 수 있다.
@Override
public String toString() {
return "Person{name='%s', age=%d}".formatted(name, age);
}
// Person{name='Alice', age=30}
String.format()보다 간결하고, 템플릿 문자열이 앞에 오므로 가독성이 좋다.
record의 자동 toString()
record를 사용하면 toString()도 자동 생성된다.
record Person(String name, int age) { }
System.out.println(new Person("Alice", 30));
// Person[name=Alice, age=30]
record의 toString() 형식은 ClassName[field1=value1, field2=value2]다. 중괄호 대신 대괄호를 사용한다. 필요하면 오버라이드할 수 있다.
record Person(String name, int age) {
@Override
public String toString() {
return "%s(%d세)".formatted(name, age);
}
}
System.out.println(new Person("Alice", 30)); // Alice(30세)
컬렉션의 toString()
Java 컬렉션(List, Set, Map)은 AbstractCollection이 toString()을 구현해 원소들을 출력한다.
List<Person> people = List.of(
new Person("Alice", 30),
new Person("Bob", 25)
);
System.out.println(people);
// [Person{name=Alice, age=30}, Person{name=Bob, age=25}]
원소 클래스가 toString()을 오버라이드하지 않았다면 [Person@1b6d3586, Person@4e50df2e]처럼 무의미한 출력이 나온다.
주의사항
민감 정보 포함 금지: 패스워드, 카드 번호, 토큰 등 민감 정보를 toString()에 포함하면 로그 파일에 평문으로 노출된다.
class Credential {
private final String username;
private final String password; // ← 절대 toString에 포함하지 말 것
@Override
public String toString() {
return "Credential{username=" + username + "}"; // password 생략
}
}
순환 참조 주의: 두 객체가 서로를 참조할 때 양쪽 toString()이 상대방을 출력하려 하면 StackOverflowError가 발생한다.
class A {
B b;
@Override
public String toString() {
return "A{b=" + b + "}"; // b.toString() 호출 → b.a.toString() → 무한 재귀
}
}
class B {
A a;
@Override
public String toString() {
return "B{a=" + a + "}"; // 순환 참조!
}
}
순환 참조가 있을 때는 참조 객체의 ID나 핵심 식별자만 출력한다.
toString()을 API 계약으로 삼지 말 것: toString() 출력 형식은 언제든 바뀔 수 있다. 파싱이나 비교에 사용하지 말고, 디버깅과 로깅 목적으로만 사용하라.
@ToString Lombok 어노테이션
프로젝트에 Lombok이 있다면 @ToString으로 보일러플레이트를 제거할 수 있다.
@ToString
class Person {
private final String name;
private final int age;
@ToString.Exclude
private final String password; // 제외
}
// 출력: Person(name=Alice, age=30)
Lombok을 사용하지 않는 프로젝트에서는 IDE의 “Generate toString()” 기능이나 record를 활용한다.
toString()은 작은 메서드지만 팀 전체의 디버깅 효율에 큰 영향을 미친다. 다음 글에서는 Object의 clone() 메서드를 다룬다. Cloneable 인터페이스와 얕은 복사·깊은 복사의 차이, 그리고 복사 생성자를 대안으로 활용하는 방법을 살펴볼 것이다.
지난 글: Java equals()와 hashCode() — 계약과 올바른 구현
다음 글: Java clone() — Cloneable과 깊은 복사·얕은 복사
읽어주셔서 감사합니다. 😊