안동민 개발노트

본문 시작

문자열 변환과 StringBuilder

문자열 반환값 누락을 고치고 값 변환·서식화·반복 결합에서 String과 StringBuilder를 선택하는 기준을 실행으로 확인합니다.

String은 불변이므로 결합과 변환마다 결과 값을 반환합니다.

짧은 표현식은 컴파일러가 효율적으로 최적화하지만, 반복문에서 누적 결합하면 중간 문자열과 복사가 많이 생길 수 있습니다.

StringBuilder는 한 작업 범위 안에서 가변 버퍼를 사용한 뒤 마지막에 String으로 확정합니다.


반환값 누락과 문자열 불변성

불변 메서드를 가변 setter처럼 호출하면 변화가 없습니다.

다음 프로그램은 접미어를 붙였다고 생각하지만 반환된 새 문자열을 저장하지 않았습니다.

lab/IgnoredStringResultBug.java
public final class IgnoredStringResultBug {
    public static void main(String[] args) {
        String title = "Object";

        title.concat(" / String");
        title.toUpperCase();

        System.out.println("title=" + title);
    }
}
잘못된 결과
title=Object

title = title.concat(...)처럼 결과를 다시 대입하거나 새 변수에 받습니다.

메서드 체인으로 여러 변환을 연결할 때도 마지막 결과를 저장해야 합니다.

원본 보존이 목적이면 expanded 같은 별도 이름이 의도를 드러냅니다.


문자열 변환과 서식

String.valueOf는 기본형과 객체를 문자열로 바꿉니다.

객체는 문자 표현 계약을 사용하고 null은 문자열 "null"이 됩니다.

formatted나 String.format은 자리표시자에 맞춰 숫자 정밀도와 배치를 표현합니다.

src/BoardSummaryFormatting.java
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=true

서식 문자열과 인수 타입이 맞지 않으면 실행 중 예외가 발생할 수 있습니다.

고정된 내부 로그에는 편리하지만 사용자 번역 문구와 지역별 숫자 표기에는 Locale을 함께 고려합니다.

구조화 로그나 데이터 교환은 문자열 하나보다 전용 필드나 직렬화 도구를 사용합니다.


문자열 결합의 처리 과정

상수 리터럴끼리의 결합은 컴파일 시 하나로 합쳐질 수 있습니다.

변수 몇 개를 잇는 짧은 표현식도 컴파일러가 내부 결합 코드를 효율적으로 만들 수 있으므로 무조건 StringBuilder로 바꿀 필요는 없습니다.

src/ReadableStringComposition.java
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

한 줄짜리 메시지는 +가 가장 읽기 쉽습니다.

성능을 추측해 수동 builder 코드를 늘리기보다 반복 횟수, 데이터 크기, 호출 빈도를 측정합니다.

문자열 템플릿 생성이 병목으로 확인되면 그 좁은 범위만 개선합니다.


StringBuilder 가변 버퍼

루프에서 result = result + item을 반복하면 매번 지금까지의 내용을 복사한 새 String이 필요합니다.

StringBuilder는 내부 버퍼에 append하고 한 번만 최종 문자열을 만듭니다.

src/BoardStringBuilder.java
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: 45m

StringBuilder는 가변이므로 여러 스레드나 장기 필드에서 공유하지 않고 한 메서드 안의 조립 도구로 사용하는 것이 안전합니다.

toString()으로 결과를 확정한 뒤 외부에는 불변 String만 반환합니다.

반환값을 버리는 String 호출과 지역 builder의 조립을 구분한다

IgnoredStringResultBug는 String 반환값을 버리고 BoardStringBuilder는 지역 버퍼를 변경한 뒤 결과 String을 report에 받는다.

서로 독립된 두 프로그램을 비교합니다. IgnoredStringResultBug의 두 호출은 연속 체인이 아니라 같은 title을 대상으로 한 별도 문장입니다.

무시된 반환값과 실제로 보관한 결과
프로그램·호출변경 대상사용한 결과
반환 누락 · concattitle의 문자열은 불변. 변수에 재대입하지 않음반환값을 버림. 다음 문장도 "Object"에서 시작
반환 누락 · toUpperCase원본 title을 바꾸지 않음반환값을 버림. 최종 출력은 title=Object
지역 조립 · appendBoardStringBuilder의 지역 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로 바꾸거나 성능 우열을 단정하지 않습니다. 내부 버퍼의 용량·복사 횟수와 실행 시간은 이 출력만으로 측정되지 않습니다.


용량과 재사용 및 동시성

예상 결과가 매우 크다면 생성자에 초기 용량을 줄 수 있지만 실제 측정 없이 정확한 수치를 맞추는 데 집착할 필요는 없습니다.

builder는 용량이 부족하면 자동 확장됩니다.

한 요청에서 만든 builder를 static 필드로 재사용하면 이전 내용이 섞이거나 동시 호출이 충돌합니다.

setLength(0)으로 비우는 방식도 예외 경로에서 초기화를 놓치기 쉽습니다.

짧게 생성하고 버리는 객체의 비용은 JVM이 잘 최적화하므로 명확한 지역 소유권을 우선합니다.

StringBuffer는 메서드 동기화를 제공하지만 현대 코드에서는 외부 동시성 설계와 지역 StringBuilder가 일반적입니다.

여러 스레드가 하나의 텍스트 결과를 협력해 만든다면 각자 부분 문자열을 만들고 안전한 경계에서 결합하는 구조가 더 단순할 수 있습니다.

성능 비교를 해석할 때 지킬 조건

한 번 실행한 currentTimeMillis 차이만으로 API 우열을 결론 내리지 않습니다.

JVM은 시작 직후 클래스 로딩과 JIT 컴파일을 수행하고, 사용되지 않는 결과는 최적화될 수 있습니다.

입력 크기와 반복 횟수를 실제 서비스와 비슷하게 잡고 결과를 소비하며 충분히 예열한 벤치마크가 필요합니다.

전문 측정에는 JMH 같은 도구가 적합합니다.

대부분의 게시글 화면에서 항목 수십 개를 조립하는 비용은 데이터베이스나 네트워크 시간보다 작습니다.

성능 수치가 사용자 지연에 영향을 주는지 프로파일링하고, 개선 뒤에도 결과 문자열이 같은지 확인합니다.

StringBuilder 선택은 “항상 빠르다”는 주문이 아니라 반복 누적이라는 구조적 신호와 측정 근거를 함께 사용합니다.


문자열 누적 방식의 선택

  • 리터럴·변수 몇 개의 짧은 결합: + 또는 formatted
  • 반복문에서 항목 수가 늘어나는 누적: StringBuilder
  • 구분자로 목록 결합: String.join 또는 스트림 joining
  • 읽기 전용 API 반환: String
  • 문자열 조립 중간 상태: 메서드 지역 StringBuilder
  • 지속적 문서 편집: 전용 자료구조나 편집 모델 검토

StringBuilder를 반환해 호출자가 계속 수정하게 하면 내부 표현과 수명 관리가 새어 나갑니다.

필요한 결과가 문자 값이면 String으로 닫습니다.


연습 문제

태그 배열을 [java | object | string] 형식으로 만드세요.

첫 항목 앞과 마지막 항목 뒤에 구분자가 생기지 않아야 하며 빈 배열은 []을 반환합니다.

해설 보기
src/TagBuilderExercise.java
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]
[]

인덱스가 0보다 클 때만 구분자를 먼저 붙이는 방식은 끝 구분자를 제거하는 후처리를 피합니다.

이 요구만 있다면 "[" + String.join(" | ", tags) + "]"도 더 간단한 해답입니다.