Text block과 새로운 String API (Java 15+)
1. 개요
Java 15 이전에는 Java 코드에서 여러 줄의 JSON, SQL, HTML을 작성하기가 꽤 힘들었습니다. +로 문자열을 연결하고, \n을 추가하고, 각 " 문자를 이스케이프해야 했습니다. Text block (공식적으로는 Java 15부터, JEP 378)이 이 문제를 해결합니다. 함께 String에 몇 가지 새로운 메서드가 추가되었습니다.
이 글을 읽으면 text block을 올바르게 들여쓰기해서 작성할 수 있게 되고, \<개행> 과 \s 이스케이프를 사용할 수 있습니다. 또한 formatted(), stripIndent(), translateEscapes()를 언제 사용해야 하는지 알게 되고, Java 21까지의 추가 API들도 배우게 됩니다.
2. 준비
모든 예제는 Java 21에서 실행됩니다. 테스트에는 JUnit 5와 AssertJ를 사용합니다:
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot 3.x를 사용한다면 spring-boot-starter-test에 이미 두 라이브러리가 포함되어 있습니다.
3. Text block 기본
3.1. 문법
Text block은 세 개의 큰따옴표 """ 로 시작한 후 바로 줄바꿈 합니다. 세 개의 큰따옴표로 끝납니다:
public class TextBlockDemo {
public static void main(String[] args) {
String json = """
{
"id": 42,
"customer": "Lan",
"status": "PAID"
}
""";
System.out.print(json);
}
}
결과:
{
"id": 42,
"customer": "Lan",
"status": "PAID"
}
이 코드에는 몇 가지 주목할 점이 있습니다:
- 내부의
"문자는 이스케이프할 필요가 없습니다. - 각 줄 맨 앞의 공통 들여쓰기 부분이 제거됩니다. 이를 incidental whitespace라고 합니다. 상대적 들여쓰기, 예를 들어
"id"앞의 2개 공백은 그대로 유지됩니다. """닫는 구분자가 별도 줄에 있으므로 문자열은\n으로 끝납니다.
Text block은 일반 String입니다. 컴파일 시점에 완전히 처리되므로 실행 중 추가 비용이 없습니다.
주의:
"""다음에 바로 내용을 쓸 수 없습니다.String s = """Hello""";는 컴파일 오류를 냅니다: illegal text block open delimiter sequence.
3.2. 닫는 """ 위치가 들여쓰기를 결정합니다
컴파일러는 선행 공백이 가장 적은 줄을 찾습니다. 닫는 """를 포함한 모든 줄을 고려합니다. 그러면 모든 줄에서 정확히 그만큼의 공백을 제거합니다. 따라서 닫는 """를 왼쪽으로 이동시켜 의도적으로 들여쓰기를 추가할 수 있습니다:
String html = """
<ul>
<li>Order #1</li>
</ul>
""";
System.out.print(html);
<ul>
<li>Order #1</li>
</ul>
""" 닫기가 내용보다 4열 덜 들여쓰기되어 있으므로, 각 줄은 맨 앞에 4개의 공백을 유지합니다.
만약 문자열 끝에 \n 문자를 원하지 않는다면, """"를 내용 바로 뒤에 놓으세요:
String greeting = """
Xin chào Lan""";
System.out.println("[" + greeting + "]");
[Xin chào Lan]
3.3. 줄 끝 공백과 개행 문자
알아야 할 두 가지 규칙이 있습니다:
- 각 줄 끝의 공백은 항상 제거됩니다.
- 모든 종류의 개행 (
\r\n,\r)은\n으로 정규화됩니다. 소스 파일이 Windows 줄 끝 형식으로 저장된 경우에도 마찬가지입니다.
규칙 2는 운영 체제 간 결과의 일관성을 보장합니다. 하지만 프로토콜에서 \r\n이 필수인 경우, 예를 들어 HTTP 헤더나 RFC 4180 CSV 파일인 경우, 직접 개행 문자를 바꿔야 합니다.
4. 두 가지 새로운 이스케이프 시퀀스: \<개행> 과 \s
4.1. \로 줄 이음
긴 SQL 문장은 읽기 쉽게 여러 줄로 작성하기도 합니다. 하지만 로그 출력처럼 한 줄로 필요할 때도 있습니다. 줄 끝의 \ 문자가 개행을 억제합니다:
String sql = """
SELECT id, total \
FROM orders \
WHERE status = 'PAID'""";
System.out.println(sql);
SELECT id, total FROM orders WHERE status = 'PAID'
\ 앞의 공백은 유지됩니다. 이유는 더 이상 줄 끝이 아니기 때문입니다: \ 문자가 그 뒤에 있습니다.
4.2. \s로 공백 유지하기
\s는 하나의 공백 문자(U+0020)와 같습니다. 고정 폭 데이터를 만들 때처럼 일반적으로 제거되는 줄 끝 공백을 유지하려면 이를 사용하세요:
String report = """
Lan \s
Minh \s
""";
report.lines().forEach(line -> System.out.println("[" + line + "]"));
[Lan ]
[Minh ]
각 줄은 정확히 8자입니다. \s 앞의 4개 공백은 더 이상 줄 끝에 있지 않으므로 유지됩니다.
4.3. 내용에 """가 있을 때
두 개의 연속 " 문자는 문제없습니다. 세 개가 연속이면 text block 종료 기호로 해석되므로, 최소한 하나의 문자를 이스케이프해야 합니다:
String doc = """
Python docstring은 \"""를 사용하여 열고 닫습니다.
""";
5. Text block과 함께 추가된 String API
Java 15에서 text block을 지원하기 위해 String에 세 가지 메서드를 추가했습니다. 이것들은 컴파일러가 text block을 처리할 때 실행하는 단계들이기도 합니다.
5.1. formatted()
formatted(Object... args)는 String.format(this, args)처럼 작동하지만, 문자열 자체에서 바로 호출할 수 있습니다. 이 방식은 특히 text block과 잘 어울립니다:
record Order(long id, String customer, double total) {}
Order order = new Order(42, "Lan", 199_000);
String message = """
Đơn hàng #%d
Khách hàng: %s
Tổng tiền: %,.0f VND
""".formatted(order.id(), order.customer(), order.total());
System.out.print(message);
Đơn hàng #42
Khách hàng: Lan
Tổng tiền: 199,000 VND
천 단위 구분자는 JVM의 기본 Locale에 따라 달라집니다. 결과를 고정으로 원한다면 String.format(Locale.US, ...)을 사용하세요. formatted()는 Locale 파라미터를 받지 않습니다.
5.2. stripIndent()
이 메서드는 text block의 incidental whitespace 제거 알고리즘을 임의의 문자열에 적용합니다. 여러 줄 문자열이 소스 코드가 아닌 파일이나 데이터베이스에서 올 때 유용합니다:
String raw = " SELECT *\n FROM customers\n ";
System.out.print(raw.stripIndent());
SELECT *
FROM customers
마지막 줄은 공백만 포함하므로 들여쓰기 계산에 포함된 후 빈 문자열이 됩니다. 이것이 바로 text block의 """ 닫기의 역할입니다.
5.3. translateEscapes()
이 메서드는 문자 형태의 이스케이프 시퀀스를 실제 문자로 변환합니다. 설정 파일에서 \n 또는 \t 문자열을 포함한 템플릿을 읽을 때 필요합니다:
String fromConfig = "Xin chào\\tLan\\nCảm ơn bạn đã đặt hàng";
System.out.println(fromConfig.translateEscapes());
Xin chào Lan
Cảm ơn bạn đã đặt hàng
잘못된 이스케이프, 예를 들어 \q는 메서드가 IllegalArgumentException을 던지게 합니다. 사용자 입력 데이터에 예외 처리 없이 호출하지 마세요. 참고로 이 메서드는 \u를 처리하지 않습니다. 유니코드 이스케이프는 이미 컴파일러가 변환했기 때문입니다.
6. 기타 유용한 메서드 (Java 11 – 21)
여러 버전에 걸쳐 작은 메서드들이 많이 추가되었습니다. 보통 text block과 함께 사용됩니다:
String text = """
Lan
\s
Minh
""";
// Java 11
System.out.println(" ".isBlank()); // true
System.out.println(text.lines().filter(l -> !l.isBlank()).count()); // 2
System.out.println("\u2003Lan\u2003".strip()); // "Lan" (strip은 유니코드를 이해하고, trim은 아님)
System.out.println("-".repeat(10)); // ----------
// Java 12
System.out.print("Lan\nMinh".indent(2)); // 각 줄에 2개 공백 추가, \n으로 끝남
int length = "Lan".transform(String::length); // 3
// Java 21
System.out.println("order-42-paid".indexOf('-', 6, 13)); // 8, [6, 13) 범위 내에서만 검색
System.out.println(Arrays.toString("a,b;c".splitWithDelimiters("[,;]", 0)));
// [a, , , b, ;, c]
몇 가지 세부사항을 설명하겠습니다:
indent(n)은n > 0이면 각 줄 앞에 n개 공백을 추가하고,n < 0이면 최대 n개 공백을 제거합니다. 항상 개행을 정규화하고 끝에\n을 추가합니다.transform(Function)을 사용하면 문자열 변환 단계를 자연스럽게 연결할 수 있습니다. 예:template.transform(this::render).transform(String::strip).- Java 21부터
StringBuilder도repeat(...)를 가집니다:new StringBuilder().repeat("=", 5)는=====를 산출합니다.
7. Spring Boot에서 text block 사용
Spring Boot 3.x 애플리케이션에서 text block은 Spring Data JPA의 @Query와 매우 잘 맞습니다. 문자열 연결 없이 여러 줄 쿼리를 유지할 수 있습니다:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT o FROM Order o
WHERE o.customer.email = :email
AND o.status = 'PAID'
ORDER BY o.createdAt DESC
""")
List<Order> findPaidOrders(@Param("email") String email);
}
Text block은 컴파일 타임 상수이므로 annotation 값으로 사용할 수 있습니다. 파라미터는 여전히 :email을 통해 바인딩되므로 SQL injection으로부터 안전합니다.
8. Text block 동작에 대한 테스트 작성
들여쓰기와 개행 문자에 관한 규칙은 혼동하기 쉽습니다. 따라서 원하는 동작을 명시하는 테스트를 작성해야 합니다:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TextBlockTest {
@Test
void givenClosingDelimiterOnOwnLine_thenEndsWithNewline() {
String json = """
{
"id": 42
}
""";
assertThat(json).isEqualTo("{\n \"id\": 42\n}\n");
}
@Test
void givenBackslashAtLineEnd_thenLinesAreJoined() {
String sql = """
SELECT id \
FROM orders""";
assertThat(sql).isEqualTo("SELECT id FROM orders");
}
@Test
void givenSpaceEscape_thenTrailingSpacesArePreserved() {
String padded = """
Lan \s""";
assertThat(padded).hasSize(6).endsWith(" ");
}
@Test
void givenTemplate_whenFormatted_thenPlaceholdersAreReplaced() {
String message = """
Xin chào %s, đơn #%d đã thanh toán.""".formatted("Lan", 42);
assertThat(message).isEqualTo("Xin chào Lan, đơn #42 đã thanh toán.");
}
}
네 테스트 모두 통과합니다. 첫 번째 테스트를 특히 기억하세요: """ 닫기가 별도 줄에 있으면 문자열 끝에 \n이 생긴다는 점을 상기시킵니다.
9. 흔한 오류 및 사용하지 말아야 할 경우
Tab과 공백 혼용. Incidental whitespace를 계산할 때 컴파일러는 tab을 한 개의 공백 문자로 봅니다. 줄들을 두 가지 다른 방식으로 들여쓰면 IDE에서 정렬되어 보여도 결과가 뒤틀릴 것입니다. 에디터를 공백만 사용하도록 설정하세요.
끝의 \n 빠트리기. 문자열을 비교하거나 text block을 다른 문자열에 결합할 때, 여분의 \n으로 인해 “보기엔 같은데 equals는 false”라는 오류가 발생하기 쉽습니다. 필요하지 않으면 """ 닫기를 내용 바로 뒤에 놓거나, strip()을 호출하세요.
formatted()로 사용자 입력을 SQL과 합치기. Text block은 코드를 읽기 쉽게 할 뿐 더 안전하게 만들지는 않습니다.
// 틀린 것: SQL 주입 취약점
String sql = """
SELECT * FROM customers WHERE email = '%s'
""".formatted(email);
PreparedStatement, ? 파라미터를 사용하는 JdbcTemplate, 또는 named parameter를 사용하는 @Query를 사용하세요.
참고: JSON의 경우 text block은 테스트 데이터나 고정 payload에 적합합니다. Payload가 동적 데이터에 따라 달라진다면 Jackson을 사용하여 객체를 직렬화하세요. 문자열을 자체 연결하면 고객명에
"또는\같은 특수 문자를 이스케이프하지 않습니다.
한 줄 짧은 문자열. "Lan"은 여전히 세 줄 text block보다 낫습니다. 내용이 정말 여러 줄이거나 많은 " 문자를 포함할 때만 text block을 사용하세요.
너무 큰 템플릿. 수백 줄 HTML 이메일이 Java 클래스에 들어 있으면 코드 유지보수가 어려워집니다. 그럴 땐 템플릿을 별도 파일로 옮기세요. 예를 들어 Thymeleaf를 사용하거나 src/main/resources의 리소스로 관리하세요.
10. 결론
Text block을 사용하면 JSON, SQL, HTML을 Java에서 원본처럼 간결하고 읽기 쉽게 작성할 수 있습니다. 기억해야 할 규칙은 몇 가지뿐입니다:
"""열기 직후 반드시 줄바꿈합니다."""닫기의 위치가 들여쓰기와 문자열 끝의\n여부를 결정합니다.- 줄 끝 공백은 제거됩니다. 개행은 항상
\n으로 정규화됩니다. \로 줄을 이으세요.\s로 공백을 유지하세요.
API 측면에서 formatted()는 text block에 값을 채우기에 정말 좋습니다. stripIndent()와 translateEscapes()는 외부에서 읽은 문자열에 같은 로직을 적용할 수 있게 해줍니다. Java 21의 lines(), strip(), indent(), transform(), splitWithDelimiters() 같은 메서드들은 외부 라이브러리 없이 여러 줄 문자열을 처리하는 데 도움을 줍니다. 마지막으로 원하는 동작을 명시하는 테스트를 작성하고, 사용자 입력을 SQL과 자체 결합하는 데 text block을 사용하지 마세요.