문자열 서식과 StringBuilder
String 메서드의 반환값을 버리는 실수를 바로잡고, 값을 문자열로 바꾸고 서식에 맞춰 한 줄로 만드는 방법과 반복문에서 StringBuilder로 문자열을 효율적으로 이어 붙이는 방법을 배웁니다.
앞 글에서 String은 불변이라서, 문자열을 바꾸는 것처럼 보이는 메서드가 모두 새 문자열을 돌려준다는 것을 확인했습니다. 이 성질은 문자열을 안전하게 공유하게 해 주지만, 문자열을 만들어 내는 코드를 쓸 때는 두 가지 문제로 이어집니다. 하나는 반환값을 받지 않아 아무것도 바뀌지 않는 실수이고, 다른 하나는 반복문에서 문자열을 조금씩 이어 붙일 때마다 지금까지의 내용 전체가 다시 복사되는 비용입니다.
이 글에서는 먼저 반환값을 버리는 실수를 바로잡습니다. 이어서 숫자나 객체를 문자열로 바꾸는 String.valueOf, 자릿수와 정렬을 맞춰 한 줄을 만드는 formatted, 짧은 결합에 쓰는 +를 차례로 봅니다. 마지막으로 반복문에서 문자열을 누적할 때 쓰는 StringBuilder의 동작과 안전한 사용법을 익히고, 상황마다 어떤 방법을 고를지 정리합니다.
반환값을 받지 않으면 문자열은 그대로다
다음 프로그램은 제목 뒤에 " / String"을 붙이고 대문자로 바꾸려고 합니다. 그런데 두 메서드가 돌려준 새 문자열을 어디에도 저장하지 않았습니다.
public final class IgnoredStringResultBug {
public static void main(String[] args) {
String title = "Object";
title.concat(" / String");
title.toUpperCase();
System.out.println("title=" + title);
}
}title=Objecttitle.concat(" / String")은 "Object / String"이라는 새 문자열을 만들어 돌려주지만, 그 값을 받는 변수가 없으니 곧바로 버려집니다. 다음 줄의 title.toUpperCase()도 앞 줄의 결과가 아니라 여전히 "Object"를 가리키는 title에서 호출되고, 그렇게 만든 "OBJECT" 역시 버려집니다. 두 줄은 이어진 작업처럼 보이지만 실제로는 같은 원본에서 따로 출발해 결과를 버린 별개의 호출입니다. 그래서 title은 처음 값 그대로 출력됩니다.
결과를 쓰려면 반환값을 변수에 받아야 합니다. title = title.concat(" / String").toUpperCase();처럼 다시 대입하면 title이 새 문자열을 가리키게 되어 title=OBJECT / STRING이 출력됩니다. 원본도 남겨 두어야 한다면 String expanded = title.concat(" / String");처럼 새 이름의 변수에 받습니다. 이름이 다르면 원본과 변환 결과를 함께 쓰려는 의도가 코드에 드러납니다. 불변 값과 with 메서드에서 불변 Post의 addViewCount 반환값을 버리면 조회 수가 그대로 남던 것과 같은 문제입니다. IDE에 따라서는 이렇게 반환값을 사용하지 않는 호출에 경고를 표시해 주므로, 그런 경고를 지나치지 않는 습관도 도움이 됩니다.
값을 문자열로 바꾸는 String.valueOf
숫자나 boolean, 객체를 문자열로 바꿀 때는 String.valueOf를 씁니다. 기본형을 넘기면 그 값을 글자로 쓴 문자열을 돌려주고, 객체를 넘기면 그 객체의 toString() 결과를 돌려줍니다. obj.toString()을 직접 호출하는 것과 다른 점은 null을 다루는 방식입니다. obj가 null이면 obj.toString()은 호출할 객체가 없어서 NullPointerException이 나지만, String.valueOf(obj)는 문자열 "null"을 돌려줍니다.
public final class ValueOfConversions {
public static void main(String[] args) {
int viewCount = 95;
boolean published = true;
Object missing = null;
System.out.println(String.valueOf(viewCount) + "/" + String.valueOf(published));
System.out.println("missing=" + String.valueOf(missing));
System.out.println("concat=" + missing);
}
}95/true
missing=null
concat=null첫 줄은 int 95와 boolean true가 각각 문자열 "95"와 "true"로 바뀐 결과입니다. 둘째 줄은 null을 담은 Object 변수를 넘겨서 예외 없이 "null"을 얻었습니다. 셋째 줄처럼 문자열에 +로 값을 이어 붙일 때도 같은 규칙으로 문자열 변환이 일어나므로, null인 객체를 붙이면 null이라는 네 글자가 들어갑니다. 출력에 null이 그대로 찍혀 나오는 것을 원하지 않는다면, 문자열로 바꾸기 전에 null일 때 쓸 값을 따로 정해 두어야 합니다.
String.valueOf에는 함정이 하나 있습니다. 타입 없이 String.valueOf(null)이라고 쓰면 "null"이 아니라 NullPointerException이 납니다. String.valueOf에는 Object를 받는 버전 외에 char[]를 받는 버전도 있는데, 메서드 오버로딩과 자동 형변환에서 본 규칙대로 컴파일러가 더 구체적인 char[] 버전을 고르기 때문입니다. char[] 버전은 배열의 문자들로 문자열을 만들려고 배열에 접근하다가 실패합니다. 실제 코드에서 null 리터럴을 직접 넘길 일은 드물지만, 예제처럼 Object 타입의 변수에 담아 넘기거나 (Object) null로 타입을 밝히면 의도한 Object 버전이 호출됩니다.
서식 문자열로 한 줄 만들기
숫자를 소수점 몇째 자리까지 보여 줄지, 몇 칸에 맞춰 정렬할지를 정해야 할 때는 +로 이어 붙이기보다 서식 문자열(format string)이 편합니다. 서식 문자열은 결과 문장의 모양을 먼저 적고, 값이 들어갈 자리를 %로 시작하는 자리표시자로 표시한 것입니다. 다음 프로그램은 게시글의 조회 수와 좋아요 수로 좋아요 비율을 계산해 한 줄로 요약합니다.
public final class BoardSummaryFormatting {
public static void main(String[] args) {
int viewCount = 95;
int likeCount = 19;
boolean published = true;
double likeRate = likeCount * 100.0 / viewCount;
String summary = "title=%s, views=%d, likes=%d, likeRate=%.1f%%, published=%b"
.formatted("문자열 질문", viewCount, likeCount, likeRate, published);
System.out.println(summary);
}
}title=문자열 질문, views=95, likes=19, likeRate=20.0%, published=trueformatted는 서식 문자열에 대해 호출하는 메서드로, 인수를 앞에서부터 하나씩 자리표시자에 채웁니다. %s에는 "문자열 질문"이, 두 %d에는 95와 19가 들어갑니다. likeRate는 19 × 100.0 ÷ 95 = 20.0이고, %.1f가 소수점 아래 한 자리로 맞춰 20.0으로 씁니다. 그 뒤의 %%는 값을 받지 않고 % 기호 하나를 그대로 출력하며, 마지막 %b에는 true가 들어갑니다. formatted는 Java 15에서 추가된 메서드이고, 같은 일을 하는 정적 메서드 String.format(서식, 값...)도 있습니다.
자주 쓰는 자리표시자는 다음과 같습니다.
| 자리표시자 | 들어가는 값 | 예와 결과 |
|---|---|---|
%s | 아무 값이나 (문자열로 변환) | "%s".formatted("java") → java |
%d | 정수 | "%d".formatted(42) → 42 |
%.1f | 실수, 소수점 아래 한 자리 | "%.1f".formatted(20.0) → 20.0 |
%b | boolean | "%b".formatted(true) → true |
%% | 값을 받지 않고 % 출력 | "%d%%".formatted(80) → 80% |
%와 변환 문자 사이에 숫자를 넣으면 값이 차지할 최소 폭을 정할 수 있습니다. 다음 프로그램은 결과의 칸 수가 보이도록 대괄호로 감싸 출력합니다.
public final class FormatWidth {
public static void main(String[] args) {
System.out.println("[%5d]".formatted(42));
System.out.println("[%-8s]".formatted("java"));
}
}[ 42]
[java ]%5d는 정수를 폭 5칸에 맞춥니다. 42는 두 자리라서 앞에 공백 세 칸이 채워지고 오른쪽으로 정렬됩니다. %-8s의 -는 왼쪽 정렬을 뜻하므로, 네 글자인 java 뒤에 공백 네 칸이 채워집니다. 여러 줄을 표처럼 출력할 때 이렇게 폭을 정해 두면 열이 가지런히 맞습니다.
서식 문자열은 컴파일러가 검사하지 않고 실행할 때 해석됩니다. 그래서 %d 자리에 문자열을 넘기면 컴파일은 되지만 실행 중에 IllegalFormatConversionException이 나고, 자리표시자보다 인수가 적으면 MissingFormatArgumentException이 납니다. 반대로 인수가 남으면 아무 오류 없이 무시됩니다. 서식 문자열을 고친 뒤에는 실제로 실행해 결과를 확인해야 하는 이유입니다.
숫자 표기는 실행 환경의 로케일을 따른다는 점도 알아 두어야 합니다. 예를 들어 독일어 로케일에서는 소수점으로 쉼표를 쓰므로 String.format(Locale.GERMANY, "%.1f", 20.0)은 20,0이 됩니다. 사람이 읽는 화면이라면 이것이 맞는 표기입니다. 하지만 다른 프로그램이 읽어 들일 파일이나 로그라면 실행 환경마다 형식이 달라지면 안 되므로 String.format(Locale.ROOT, ...)처럼 로케일을 첫 인수로 넘겨 고정합니다. 여러 필드를 담은 로그나 다른 시스템과 주고받는 데이터라면 문자열 한 줄로 만들기보다 JSON 같은 형식과 전용 라이브러리를 쓰는 편이 낫습니다.
+로 이어 붙일 때 일어나는 일
값 몇 개를 이어 한 줄을 만드는 일이라면 +가 가장 읽기 쉽습니다. 다음 프로그램은 리터럴끼리 이은 문자열과 변수를 섞어 이은 문자열을 만듭니다.
public final class ReadableStringComposition {
public static void main(String[] args) {
String title = "string";
int viewCount = 45;
String constant = "Board" + " Log";
String line = constant + ": " + title + "=" + viewCount;
System.out.println(constant);
System.out.println(line);
}
}Board Log
Board Log: string=45"Board" + " Log"는 리터럴끼리의 결합이므로, 앞 글의 더 깊이 단락에서 본 것처럼 컴파일러가 미리 "Board Log"로 합쳐 둡니다. 반면 line의 식은 변수가 섞여 있어서 실행할 때 계산됩니다. 산술 연산과 문자열 결합에서 본 것처럼 +는 왼쪽부터 차례로 계산되고, 문자열과 만난 int 45는 "45"로 바뀌어 붙습니다. 이렇게 한 문장으로 끝나는 결합은 다음 절의 StringBuilder로 바꿀 필요가 없습니다. 실행 속도도 거의 차이가 없고, 코드는 +가 훨씬 읽기 쉽습니다.
더 깊이: 컴파일러가 +를 바꾸는 방식
+로 쓴 문자열 결합 식은 컴파일되면서 다른 코드로 바뀝니다. Java 8까지의 javac는 결합 식 하나를 new StringBuilder().append(...).append(...).toString() 호출로 바꿨습니다. Java 9부터는 JEP 280에 따라 invokedynamic 명령으로 컴파일하고, 실제로 어떤 방식으로 문자열을 합칠지는 실행 시점에 StringConcatFactory가 정합니다. 덕분에 JDK가 결합 전략을 개선하면 프로그램을 다시 컴파일하지 않아도 그 혜택을 받습니다. 그러니 식 하나짜리 결합을 손으로 StringBuilder 코드로 바꿔도 빨라질 것은 거의 없습니다.
하지만 이 최적화는 식 하나 안에서만 적용됩니다. 다음 절에서 보듯이 반복문에서 result = result + item을 되풀이하면 반복할 때마다 별개의 결합 식이 실행되고, 그때마다 새 문자열이 만들어집니다. 컴파일러가 식 하나를 아무리 잘 처리해도 이 반복 복사는 사라지지 않습니다.
반복문에서 이어 붙이면 복사가 쌓인다
String은 불변이므로 result = result + item은 result를 고치는 것이 아니라, 지금까지의 내용과 item을 모두 담은 새 문자열을 만듭니다. 이때 기존 내용 전체가 새 문자열로 복사됩니다. 10글자짜리 항목 4개를 반복문으로 이어 붙이면 어떻게 되는지 따라가 보겠습니다.
| 반복 | 새로 만드는 문자열의 길이 | 그때 복사하는 글자 수 | 누적 복사량 |
|---|---|---|---|
| 1 | 10 | 10 | 10 |
| 2 | 20 | 20 | 30 |
| 3 | 30 | 30 | 60 |
| 4 | 40 | 40 | 100 |
최종 결과는 40글자인데, 거기까지 가는 동안 100글자를 복사했습니다. 반복할수록 복사할 기존 내용이 길어지기 때문입니다. 항목이 n개면 누적 복사량은 10 × (1 + 2 + … + n)으로, n의 제곱에 비례해 늘어납니다. 항목이 1,000개라면 결과는 1만 글자인데 복사량은 5,005,000글자가 됩니다.
StringBuilder는 이 문제를 가변 버퍼로 해결합니다. 내부에 넉넉한 크기의 배열을 버퍼로 두고, append가 호출될 때마다 새 내용을 배열의 빈 칸에 이어서 씁니다. 기존 내용은 그 자리에 그대로 있으므로 다시 복사할 필요가 없습니다. 버퍼가 가득 차면 더 큰 배열로 한 번 옮기지만 그런 일은 가끔만 일어나고, 마지막에 toString()으로 완성된 String을 한 번 만듭니다. 이런 복사를 모두 더해도 1,000개 항목의 경우 수만 글자 수준입니다.
다음 프로그램은 게시글 세 개를 한 줄씩 이어 게시판 목록을 만듭니다.
public final class BoardStringBuilder {
public static void main(String[] args) {
Post[] entries = {
new Post("object", 40),
new Post("immutable", 50),
new Post("string", 45)
};
StringBuilder builder = new StringBuilder("게시판");
for (Post entry : entries) {
builder.append('\n')
.append("- ")
.append(entry.title())
.append(": ")
.append(entry.viewCount())
.append('m');
}
String report = builder.toString();
System.out.println(report);
}
private record Post(String title, int viewCount) { }
}게시판
- object: 40m
- immutable: 50m
- string: 45mnew StringBuilder("게시판")은 "게시판"을 처음 내용으로 담은 버퍼를 만듭니다. 반복문은 게시글마다 줄바꿈 문자, "- ", 제목, ": ", 조회 수, 문자 m을 차례로 버퍼 끝에 붙입니다. append는 char, String, int 등 여러 타입을 받도록 오버로드되어 있어서, append('\n')과 append('m')은 문자 하나를, append(entry.viewCount())는 정수 40을 글자 "40"으로 바꿔 붙입니다. 그래서 출력의 40m은 숫자 40 뒤에 문자 m을 이어 붙인 것일 뿐이고, 단위가 바뀐 것은 아닙니다. 접미 문자를 "회"로 바꾸고 싶다면 append('m')을 append("회")로 고치면 됩니다.
append를 점으로 줄줄이 이어 쓸 수 있는 것은 append가 새 객체가 아니라 그 builder 자신을 돌려주기 때문입니다. builder.append('\n')의 결과가 다시 builder이므로, 그 뒤의 .append("- ")도 같은 builder에 붙습니다. 이렇게 메서드 호출을 이어 쓰는 방식을 메서드 체이닝이라고 하며, 다음 글에서 자세히 다룹니다. 반복이 끝나면 toString()이 버퍼 내용으로 String을 만들고, 그 값을 report에 받아 출력합니다.
아래 단계별 그림은 앞의 두 프로그램을 나란히 놓고 비교합니다. IgnoredStringResultBug는 String이 돌려준 결과를 버려서 title이 그대로 남고, BoardStringBuilder는 같은 builder의 버퍼를 계속 바꾼 뒤 toString()의 결과를 report에 받습니다.
반환값을 버리는 String 호출과 지역 builder의 조립을 구분한다
String 반환값을 무시해 title을 유지하는 실행과 별도 builder에서 append 후 toString을 반환하는 실행을 비교합니다. 원문 m 문자와 builder가 동결되지 않는 조건을 유지한 값/버퍼 변화입니다.
IgnoredStringResultBug는 String 반환값을 버리고 BoardStringBuilder는 지역 버퍼를 변경한 뒤 결과 String을 report에 받는다.
String은 결과를 반환하고 builder는 내부 버퍼를 바꿉니다
서로 독립된 두 프로그램을 비교합니다. IgnoredStringResultBug의 두 호출은 연속 체인이 아니라 같은 title을 대상으로 한 별도 문장입니다.
| 프로그램·호출 | 변경 대상 | 사용한 결과 |
|---|---|---|
| 반환 누락
| title의 문자열은 불변. 변수에 재대입하지 않음 | 반환값을 버림. 다음 문장도 "Object"에서 시작 |
| 반환 누락
| 원본 title을 바꾸지 않음 | 반환값을 버림. 최종 출력은 title=Object |
| 지역 조립
| BoardStringBuilder의 지역 builder 내용을 반복해서 변경 | 입력 배열의 순서대로 세 게시글을 누적 |
| 문자열 결과
| 완성한 내용을 String으로 얻음. builder를 동결하는 호출은 아님 | report에 받고 println(report)으로 출력 |
- 반환 누락 ·
concat - 변경 대상:
title의 문자열은 불변. 변수에 재대입하지 않음사용한 결과: 반환값을 버림. 다음 문장도"Object"에서 시작 - 반환 누락 ·
toUpperCase - 변경 대상: 원본
title을 바꾸지 않음사용한 결과: 반환값을 버림. 최종 출력은title=Object - 지역 조립 ·
append - 변경 대상:
BoardStringBuilder의 지역builder내용을 반복해서 변경사용한 결과: 입력 배열의 순서대로 세 게시글을 누적 - 문자열 결과 ·
toString - 변경 대상: 완성한 내용을 String으로 얻음. builder를 동결하는 호출은 아님사용한 결과:
report에 받고println(report)으로 출력
BoardStringBuilder의 실제 출력은 다음과 같습니다. 이 main은 출력할 뿐, String을 호출자에게 반환하는 별도 서비스 메서드는 없습니다.
게시판
- object: 40m
- immutable: 50m
- string: 45m
짧은 결합을 모두 builder로 바꾸거나 성능 우열을 단정하지 않습니다. 내부 버퍼의 용량·복사 횟수와 실행 시간은 이 출력만으로 측정되지 않습니다.
이 예의 조건
- IgnoredStringResultBug와 BoardStringBuilder는 독립 프로그램입니다. 입력 준비→각 변경 호출→반환·출력의 공통 관찰 단계마다 비교합니다.
- builder의 m은 append한 접미 문자이며 시간 단위가 아닙니다. toString이 builder를 동결하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 독립된 실패 String 프로그램에서 concat과 대문자 변환은 둘 다 원 Object 문자 값에서 각각 호출하고 반환값을 버려 최종 title은 Object이다. 별도 builder 프로그램은 세 입력을 append로 누적하고 toString 결과를 report에 저장해 main이 출력한다. m은 접미 문자이며 toString이 builder를 동결하지 않는다. |
| 8 | String |
| 15 | object40 |
| 16 | immutable50 |
| 17 | string45 |
| 구분 | 내용 |
|---|---|
| 설명 | 독립된 실패 String 프로그램에서 concat과 대문자 변환은 둘 다 원 Object 문자 값에서 각각 호출하고 반환값을 버려 최종 title은 Object이다. 별도 builder 프로그램은 세 입력을 append로 누적하고 toString 결과를 report에 저장해 main이 출력한다. m은 접미 문자이며 toString이 builder를 동결하지 않는다. |
| 전체 읽기 정보 | 반환값을 버리는 String 호출과 지역 builder의 조립을 구분한다 반환값을 버리는 String 호출과 지역 builder의 조립을 구분한다 IgnoredStringResultBug는 String 반환값을 버리고 BoardStringBuilder는 지역 버퍼를 변경한 뒤 결과 String을 report에 받는다. String은 결과를 반환하고 builder는 내부 버퍼를 바꿉니다 서로 독립된 두 프로그램을 비교합니다. IgnoredStringResultBug 의 두 호출은 연속 체인이 아니라 같은 title 을 대상으로 한 별도 문장입니다. 무시된 반환값과 실제로 보관한 결과 프로그램·호출 변경 대상 사용한 결과 반환 누락 concat title 의 문자열은 불변. 변수에 재대입하지 않음 반환값을 버림. 다음 문장도 "Object" 에서 시작 반환 누락 toUpperCase 원본 title 을 바꾸지 않음 반환값을 버림. 최종 출력은 title=Object 지역 조립 append BoardStringBuilder 의 지역 builder 내용을 반복해서 변경 입력 배열의 순서대로 세 게시글을 누적 문자열 결과 toString 완성한 내용을 String으로 얻음. builder를 동결하는 호출은 아님 report 에 받고 println(report) 으로 출력 반환 누락 · concat 변경 대상: title 의 문자열은 불변. 변수에 재대입하지 않음 사용한 결과: 반환값을 버림. 다음 문장도 "Object" 에서 시작 반환 누락 · toUpperCase 변경 대상: 원본 title 을 바꾸지 않음 사용한 결과: 반환값을 버림. 최종 출력은 title=Object 지역 조립 · append 변경 대상: BoardStringBuilder 의 지역 builder 내용을 반복해서 변경 사용한 결과: 입력 배열의 순서대로 세 게시글을 누적 문자열 결과 · toString 변경 대상: 완성한 내용을 String으로 얻음. builder를 동결하는 호출은 아님 사용한 결과: report 에 받고 println(report) 으로 출력 BoardStringBuilder 의 실제 출력은 다음과 같습니다. 이 main은 출력할 뿐, String을 호출자에게 반환하는 별도 서비스 메서드는 없습니다. 게시판 - object: 40m - immutable: 50m - string: 45m 짧은 결합을 모두 builder로 바꾸거나 성능 우열을 단정하지 않습니다. 내부 버퍼의 용량·복사 횟수와 실행 시간은 이 출력만으로 측정되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 각 프로그램의 입력 준비 String 사례는 title Object, builder 사례는 세 게시글과 게시판 초기 버퍼를 준비합니다. |
|
|
| 각 문자열 호출과 버퍼 변경 String 반환값은 버리고 builder는 각 게시글의 줄을 자신의 버퍼에 append합니다. |
|
|
| 각 반환과 main 출력 String main은 원 title을 출력하고 builder main은 toString 결과를 report에 받아 출력합니다. |
|
|
반환값을 버리는 String 호출과 지역 builder의 조립을 구분한다
String 반환값을 무시해 title을 유지하는 실행과 별도 builder에서 append 후 toString을 반환하는 실행을 비교합니다. 원문 m 문자와 builder가 동결되지 않는 조건을 유지한 값/버퍼 변화입니다.
IgnoredStringResultBug는 String 반환값을 버리고 BoardStringBuilder는 지역 버퍼를 변경한 뒤 결과 String을 report에 받는다.
String은 결과를 반환하고 builder는 내부 버퍼를 바꿉니다
서로 독립된 두 프로그램을 비교합니다. IgnoredStringResultBug의 두 호출은 연속 체인이 아니라 같은 title을 대상으로 한 별도 문장입니다.
| 프로그램·호출 | 변경 대상 | 사용한 결과 |
|---|---|---|
| 반환 누락
| title의 문자열은 불변. 변수에 재대입하지 않음 | 반환값을 버림. 다음 문장도 "Object"에서 시작 |
| 반환 누락
| 원본 title을 바꾸지 않음 | 반환값을 버림. 최종 출력은 title=Object |
| 지역 조립
| BoardStringBuilder의 지역 builder 내용을 반복해서 변경 | 입력 배열의 순서대로 세 게시글을 누적 |
| 문자열 결과
| 완성한 내용을 String으로 얻음. builder를 동결하는 호출은 아님 | report에 받고 println(report)으로 출력 |
- 반환 누락 ·
concat - 변경 대상:
title의 문자열은 불변. 변수에 재대입하지 않음사용한 결과: 반환값을 버림. 다음 문장도"Object"에서 시작 - 반환 누락 ·
toUpperCase - 변경 대상: 원본
title을 바꾸지 않음사용한 결과: 반환값을 버림. 최종 출력은title=Object - 지역 조립 ·
append - 변경 대상:
BoardStringBuilder의 지역builder내용을 반복해서 변경사용한 결과: 입력 배열의 순서대로 세 게시글을 누적 - 문자열 결과 ·
toString - 변경 대상: 완성한 내용을 String으로 얻음. builder를 동결하는 호출은 아님사용한 결과:
report에 받고println(report)으로 출력
BoardStringBuilder의 실제 출력은 다음과 같습니다. 이 main은 출력할 뿐, String을 호출자에게 반환하는 별도 서비스 메서드는 없습니다.
게시판
- object: 40m
- immutable: 50m
- string: 45m
짧은 결합을 모두 builder로 바꾸거나 성능 우열을 단정하지 않습니다. 내부 버퍼의 용량·복사 횟수와 실행 시간은 이 출력만으로 측정되지 않습니다.
이 예의 조건
- IgnoredStringResultBug와 BoardStringBuilder는 독립 프로그램입니다. 입력 준비→각 변경 호출→반환·출력의 공통 관찰 단계마다 비교합니다.
- builder의 m은 append한 접미 문자이며 시간 단위가 아닙니다. toString이 builder를 동결하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 독립된 실패 String 프로그램에서 concat과 대문자 변환은 둘 다 원 Object 문자 값에서 각각 호출하고 반환값을 버려 최종 title은 Object이다. 별도 builder 프로그램은 세 입력을 append로 누적하고 toString 결과를 report에 저장해 main이 출력한다. m은 접미 문자이며 toString이 builder를 동결하지 않는다. |
| 8 | String |
| 15 | object40 |
| 16 | immutable50 |
| 17 | string45 |
| 구분 | 내용 |
|---|---|
| 설명 | 독립된 실패 String 프로그램에서 concat과 대문자 변환은 둘 다 원 Object 문자 값에서 각각 호출하고 반환값을 버려 최종 title은 Object이다. 별도 builder 프로그램은 세 입력을 append로 누적하고 toString 결과를 report에 저장해 main이 출력한다. m은 접미 문자이며 toString이 builder를 동결하지 않는다. |
| 전체 읽기 정보 | 반환값을 버리는 String 호출과 지역 builder의 조립을 구분한다 반환값을 버리는 String 호출과 지역 builder의 조립을 구분한다 IgnoredStringResultBug는 String 반환값을 버리고 BoardStringBuilder는 지역 버퍼를 변경한 뒤 결과 String을 report에 받는다. String은 결과를 반환하고 builder는 내부 버퍼를 바꿉니다 서로 독립된 두 프로그램을 비교합니다. IgnoredStringResultBug 의 두 호출은 연속 체인이 아니라 같은 title 을 대상으로 한 별도 문장입니다. 무시된 반환값과 실제로 보관한 결과 프로그램·호출 변경 대상 사용한 결과 반환 누락 concat title 의 문자열은 불변. 변수에 재대입하지 않음 반환값을 버림. 다음 문장도 "Object" 에서 시작 반환 누락 toUpperCase 원본 title 을 바꾸지 않음 반환값을 버림. 최종 출력은 title=Object 지역 조립 append BoardStringBuilder 의 지역 builder 내용을 반복해서 변경 입력 배열의 순서대로 세 게시글을 누적 문자열 결과 toString 완성한 내용을 String으로 얻음. builder를 동결하는 호출은 아님 report 에 받고 println(report) 으로 출력 반환 누락 · concat 변경 대상: title 의 문자열은 불변. 변수에 재대입하지 않음 사용한 결과: 반환값을 버림. 다음 문장도 "Object" 에서 시작 반환 누락 · toUpperCase 변경 대상: 원본 title 을 바꾸지 않음 사용한 결과: 반환값을 버림. 최종 출력은 title=Object 지역 조립 · append 변경 대상: BoardStringBuilder 의 지역 builder 내용을 반복해서 변경 사용한 결과: 입력 배열의 순서대로 세 게시글을 누적 문자열 결과 · toString 변경 대상: 완성한 내용을 String으로 얻음. builder를 동결하는 호출은 아님 사용한 결과: report 에 받고 println(report) 으로 출력 BoardStringBuilder 의 실제 출력은 다음과 같습니다. 이 main은 출력할 뿐, String을 호출자에게 반환하는 별도 서비스 메서드는 없습니다. 게시판 - object: 40m - immutable: 50m - string: 45m 짧은 결합을 모두 builder로 바꾸거나 성능 우열을 단정하지 않습니다. 내부 버퍼의 용량·복사 횟수와 실행 시간은 이 출력만으로 측정되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 각 프로그램의 입력 준비 String 사례는 title Object, builder 사례는 세 게시글과 게시판 초기 버퍼를 준비합니다. |
|
|
| 각 문자열 호출과 버퍼 변경 String 반환값은 버리고 builder는 각 게시글의 줄을 자신의 버퍼에 append합니다. |
|
|
| 각 반환과 main 출력 String main은 원 title을 출력하고 builder main은 toString 결과를 report에 받아 출력합니다. |
|
|
StringBuilder는 메서드 안에서 쓰고 String으로 돌려준다
StringBuilder는 내용을 바꿀 수 있는 가변 객체입니다. 그래서 불변인 String과 달리 여러 곳에서 함께 쓰면 한쪽의 변경이 다른 쪽에 그대로 보입니다. 가장 안전한 사용법은 한 메서드 안에서 만들어 쓰고, 다 만든 결과는 toString()으로 바꿔 String으로 돌려주는 것입니다. toString()이 돌려주는 String은 builder와 별개인 새 문자열이라서, 그 뒤에 builder를 바꿔도 이미 받은 문자열에는 영향이 없습니다.
public final class BuilderSnapshot {
public static void main(String[] args) {
StringBuilder builder = new StringBuilder("ab");
String snapshot = builder.toString();
builder.append("c");
System.out.println("snapshot=" + snapshot);
System.out.println("builder=" + builder);
}
}snapshot=ab
builder=abctoString()을 호출한 시점의 내용 ab가 snapshot에 담겼고, 그 뒤의 append("c")는 builder만 abc로 바꿨습니다. 거꾸로 보면 toString()이 builder를 잠그지도 않습니다. builder는 그 뒤에도 계속 바뀔 수 있으므로, 메서드가 builder 자체를 돌려주면 호출한 쪽이 내용을 마음대로 고칠 수 있게 됩니다. 메서드 밖으로는 String만 내보내야 결과가 나중에 바뀌지 않습니다.
builder를 아끼겠다고 필드에 하나 만들어 두고 재사용하는 것도 피해야 합니다.
// 이렇게 쓰지 않습니다
private static final StringBuilder SHARED = new StringBuilder();
static String line(String title) {
SHARED.setLength(0);
return SHARED.append("- ").append(title).toString();
}이 코드는 호출할 때마다 setLength(0)으로 버퍼를 비운 뒤 다시 씁니다. 비우는 코드를 하나라도 빠뜨리면 이전 호출의 내용이 결과에 섞이고, 두 스레드가 동시에 line을 호출하면 한 버퍼에 두 호출의 내용이 뒤섞입니다. 반면 StringBuilder를 메서드 안에서 새로 만드는 비용은 작습니다. JVM은 잠깐 쓰고 버리는 작은 객체를 할당하고 회수하는 데 최적화되어 있으므로, 공유하는 위험을 감수할 이유가 없습니다.
StringBuilder와 같은 메서드를 가진 StringBuffer라는 클래스도 있습니다. StringBuffer는 필요한 메서드에 동기화(synchronization)가 되어 있어서, 여러 스레드가 한 객체를 함께 써도 내부 상태가 깨지지 않습니다. 하지만 문자열 조립은 대개 한 스레드 안에서 끝나므로, StringBuilder의 API 문서도 한 스레드에서 쓸 때는 StringBuilder를 쓰라고 권합니다. 여러 스레드가 함께 하나의 텍스트를 만들어야 한다면 하나의 버퍼를 공유하기보다, 스레드마다 자기 부분을 따로 만든 뒤 마지막에 한 번 합치는 구조가 더 단순합니다. 스레드와 동기화는 18장부터 다룹니다.
더 깊이: 버퍼가 늘어나는 방식
StringBuilder의 버퍼에 담을 수 있는 문자 수를 용량(capacity)이라고 합니다. 인수 없이 만든 StringBuilder의 용량은 16이고, new StringBuilder("게시판")처럼 문자열로 만들면 16에 그 문자열의 길이를 더한 19가 됩니다. 내용이 용량을 넘으면 더 큰 배열을 만들어 기존 내용을 옮깁니다. ensureCapacity의 API 문서는 이때의 새 용량을 '필요한 크기'와 '기존 용량의 2배 + 2' 가운데 큰 쪽으로 정하고, OpenJDK는 append로 용량이 부족해질 때도 같은 규칙을 씁니다. 그래서 용량 16인 builder에 17글자를 붙이면 용량이 34가 됩니다.
용량을 매번 두 배 가까이 늘리기 때문에 옮기는 일은 드물게 일어나고, 전체 복사량은 최종 길이에 비례하는 수준에 머뭅니다. 용량이 늘어나는 동적 배열 만들기에서 다루는 동적 배열과 같은 원리입니다. 결과 크기를 대략 안다면 new StringBuilder(4096)처럼 처음부터 용량을 줄 수 있지만, 측정해 보지 않고 정확한 숫자를 맞추려고 애쓸 필요는 없습니다.
더 깊이: 성능을 재 볼 때 주의할 점
System.currentTimeMillis()로 두 방법의 실행 시간을 한 번씩 재서 비교하는 실험은 믿기 어렵습니다. JVM은 프로그램 시작 직후에 클래스를 불러오고, 자주 실행되는 코드를 실행 중에 기계어로 컴파일(JIT 컴파일)합니다. 그래서 처음 몇 번의 실행은 나중보다 훨씬 느립니다. 결과를 사용하지 않는 계산은 최적화 과정에서 통째로 사라지기도 합니다. 신뢰할 만한 비교를 하려면 실제 서비스와 비슷한 입력 크기를 쓰고, 충분히 예열하고, 결과를 실제로 사용하는 벤치마크가 필요합니다. JMH 같은 도구가 이런 조건을 맞춰 줍니다.
또 웹 서비스에서 게시글 몇십 개를 문자열로 조립하는 시간은 대개 데이터베이스 조회나 네트워크 전송 시간보다 훨씬 작습니다. 그러니 문자열 결합 방식을 바꾸기 전에 프로파일러로 그 부분이 실제로 느린지 먼저 확인하고, 바꾼 뒤에는 결과 문자열이 이전과 같은지도 확인합니다. StringBuilder를 고르는 이유는 '항상 빠르다'는 믿음이 아니라 '반복문에서 개수가 정해지지 않은 조각을 누적한다'는 코드의 구조입니다.
상황에 맞는 문자열 만들기 방법
지금까지 본 방법을 상황별로 정리하면 다음과 같습니다.
| 상황 | 쓰는 방법 |
|---|---|
| 리터럴과 변수 몇 개를 한 줄로 잇기 | + |
| 숫자의 자릿수나 칸 맞춤이 필요한 한 줄 | formatted, String.format |
| 목록을 구분자로 합치기 | String.join, 스트림의 Collectors.joining |
| 반복문에서 개수가 정해지지 않은 조각을 누적하기 | 메서드 안의 StringBuilder |
| 메서드 밖으로 결과 돌려주기 | String |
목록을 구분자로 합치는 일은 StringBuilder로도 할 수 있지만, String.join을 쓰면 구분자를 항목 사이에만 넣는 처리를 직접 할 필요가 없습니다. Collectors.joining은 Collectors로 List·Map 만들고 그룹으로 묶기에서 다룹니다. 어떤 방법을 쓰든 메서드가 돌려주는 값은 String이어야 합니다. StringBuilder를 돌려주면 호출한 쪽이 결과를 계속 고칠 수 있어서, 결과가 언제 확정되는지를 메서드 안에서 정할 수 없게 됩니다.
정리
String메서드는 새 문자열을 돌려주므로 반환값을 변수에 받아야 합니다. 받지 않으면 원본은 그대로입니다.String.valueOf(obj)는obj가null이면"null"을 돌려줍니다. 다만String.valueOf(null)처럼null리터럴을 그대로 넘기면char[]버전이 선택되어NullPointerException이 납니다.formatted와String.format은 서식 문자열로 자릿수와 정렬을 맞춥니다. 서식 문자열은 실행할 때 해석되므로 타입이 맞지 않으면 실행 중에 예외가 나고, 숫자 표기는 로케일을 따릅니다.- 식 하나로 끝나는
+결합은 그대로 쓰면 됩니다. 반복문에서+로 누적하면 반복마다 기존 내용 전체가 복사되어, 복사량이 항목 수의 제곱에 비례해 늘어납니다. - StringBuilder는 가변 버퍼에 이어 쓰고 마지막에
toString()으로String을 한 번 만듭니다. 메서드 안에서만 쓰고, 밖으로는String을 돌려줍니다.
연습 문제
- 태그 배열을
[java | object | string]형식의 문자열로 만드는 메서드를 StringBuilder로 작성하세요. 첫 항목 앞과 마지막 항목 뒤에는 구분자가 없어야 하고, 빈 배열이면[]를 돌려줍니다.
해설 보기
public final class TagBuilderExercise {
public static void main(String[] args) {
System.out.println(formatTags(new String[]{"java", "object", "string"}));
System.out.println(formatTags(new String[]{}));
}
private static String formatTags(String[] tags) {
StringBuilder result = new StringBuilder("[");
for (int index = 0; index < tags.length; index++) {
if (index > 0) result.append(" | ");
result.append(tags[index]);
}
return result.append(']').toString();
}
}[java | object | string]
[]구분자를 항목 뒤가 아니라 앞에 붙이되, 첫 항목(index가 0)일 때만 건너뛰는 것이 핵심입니다. 이렇게 하면 마지막에 남은 구분자를 지우는 뒤처리가 필요 없습니다. 빈 배열이면 반복문이 한 번도 돌지 않으므로 [와 ]만 남아 []가 됩니다. 메서드는 builder가 아니라 toString()의 결과를 돌려줍니다.
이 요구만 있다면 "[" + String.join(" | ", tags) + "]"가 더 간단한 답입니다. String.join은 빈 배열에 빈 문자열을 돌려주므로 이 식도 []를 만듭니다. StringBuilder 버전은 항목마다 다른 처리를 끼워 넣어야 할 때 쓸모가 있습니다.
- 다음 프로그램의 출력을 예상하고, 그렇게 나오는 이유를 설명하세요.
public final class IgnoredResultQuiz {
public static void main(String[] args) {
String name = "java";
name.toUpperCase();
name = name.concat("21");
name.replace("21", "25");
System.out.println(name);
}
}해설 보기
java21name.toUpperCase()는 "JAVA"를 만들지만 반환값을 받지 않았으므로 버려집니다. 다음 줄의 concat("21")은 여전히 소문자인 "java"에서 출발해 "java21"을 만들고, 이 결과는 name에 다시 대입했으므로 남습니다. 마지막 replace("21", "25")도 "java25"를 만들지만 받지 않았으므로 name은 "java21" 그대로입니다. 결과를 남기는 것은 반환값을 대입한 줄뿐입니다.
String.valueOf(null)은NullPointerException을 일으키는데String.valueOf((Object) null)은"null"을 돌려주는 이유를 설명하세요.
해설 보기
null은 어떤 참조 타입에도 넣을 수 있으므로, String.valueOf(null)에는 Object를 받는 버전과 char[]를 받는 버전이 모두 적용됩니다. 둘 중에서는 더 구체적인 char[] 버전이 선택됩니다. 모든 char[]는 Object에 넘길 수 있지만 그 반대는 성립하지 않기 때문입니다. char[] 버전은 배열의 문자로 문자열을 만들려고 배열에 접근하다가 NullPointerException을 일으킵니다. (Object) null로 캐스트하면 인수의 타입이 Object가 되어 char[] 버전이 적용 대상에서 빠집니다. 그래서 Object 버전이 호출되고, 이 버전은 null이면 "null"을 돌려줍니다.
다음 글
메서드 체이닝과 문자열 활용에서는 StringBuilder의 append처럼 객체 자신을 돌려주는 메서드로 체인을 만들고, 앞의 메서드가 무엇을 돌려주느냐에 따라 체인의 의미가 어떻게 달라지는지 살펴봅니다. 이어서 이 장에서 익힌 String 메서드로 검색어 세기와 파일 이름 나누기 같은 문제를 풉니다.