'김영한의 실전 자바 - 중급 1편' 강의를 들으면서 복습할만한 내용을 정리하였다.

3. String 클래스

3.1 String 클래스 - 기본

자바에서 문자를 다루는 대표적인 타입은 char, String 2가지가 있다.

기본형인 char 는 문자 하나를 다룰 때 사용한다. char 를 사용해서 여러 문자를 나열하려면 char[] 를 사용해야 한다. 하지만 이렇게 char[] 을 직접 다루는 방법은 매우 불편하기 때문에 자바는 문자열을 매우 편리하게 다룰 수 있는 String 클래스를 제공한다.

String 클래스를 통해 문자열을 생성하는 방법

String str1 = "hello";
String str2 = new String("hello");

String 은 클래스다. int, boolean 같은 기본형이 아니라 참조형이다. 따라서 str1 변수에는 String 인스턴스의 참조값만 들어갈 수 있다.

문자열은 매우 자주 사용된다. 그래서 편의상 쌍따옴표로 문자열을 감싸면 자바 언어에서 new String("hello") 와 같이 변경 해준다.

String str1 = "hello"; //기존
String str1 = new String("hello"); //변경

String 클래스 구조

public final class String {
	
    //문자열 보관
 	private final char[] value;// 자바 9 이전
 	private final byte[] value;// 자바 9 이후
 
 	//여러 메서드
 	public String concat(String str) {...}
 	public int length() {...}
 	...
}

속성(필드)

private final char[] value;

여기에는 String 의 실제 문자열 값이 보관된다. 문자 데이터 자체는 char[] 에 보관된다.

String 클래스는 개발자가 직접 다루기 불편한 char[] 을 내부에 감추고 String 클래스를 사용하는 개발자가 편리하게 문자열을 다룰 수 있도록 다양한 기능을 제공한다. 그리고 메서드 제공을 넘어서 자바 언어 차원에서도 여러 편의 문법을 제공한다.

참고 : 자바 9 이후 String 클래스 변경 사항

자바 9부터는 String 클래스에서 char[] 대신에 byte[]을 사용한다.

private final byte[] value;

자바에서 문자 하나를 표현하는 char2byte 를 차지한다. 그런데 영어, 숫자는 보통 1byte 로 표현이 가능하다. 그래서 단순 영어, 숫자로만 표현된 경우 1byte 를 사용하고, 그렇지 않은 나머지의 경우 2byte 인 UTF-16 인코딩을 사용한다. 따라서 메모리를 더 효율적으로 사용할 수 있게 변경되었다.

기능(메서드)

String 클래스는 문자열로 처리할 수 있는 다양한 기능을 제공한다.

  • length() : 문자열의 길이를 반환한다.

  • charAt(int index) : 특정 index 의 문자를 반환한다.

  • subString(int beginIndex, int endIndex) : 문자열의 부분 문자열을 반환한다.

  • indexOf(String str) : 특정 문자열이 시작되는 인덱스를 반환한다.

  • toLowerCase(), toUpperCase() : 문자열을 소문자 또는 대문자로 변환한다.

  • trim() : 문자열 양 끝의 공백을 제거한다.

  • concat(String str) : 문자열을 더한다.

String 클래스와 참조형

String 은 클래스이다. 따라서 기본형이 아니라 참조형이다.

참조형은 변수에 계산할 수 있는 값이 들어있는 것이 아니라 x001 과 같이 계산할 수 없는 참조값이 들어있다. 따라서 원칙적으로 + 같은 연산을 사용할 수 없다.

자바에서 문자열을 더할 때는 String 이 제공하는 concat() 과 같은 메서드를 사용해야 한다. 하지만 문자열은 너무 자주 다루어지기 때문에 자바 언어에서 특별히 + 연산을 제공한다.


3.2 String 클래스 - 비교

String 클래스 비교할 때는 == 비교가 아니라 항상 equals() 비교를 해야 한다.

  • 동일성(Indentity) : == 연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키고 있는지 확인

  • 동등성(Equality) : equals() 메서드를 사용하여 두 객체가 논리적으로 같은지 확인

Main

String str1 = new String("hello");
String str2 = new String("hello");
System.out.println("new String() == 비교: " + (str1 == str2));
System.out.println("new String() equals 비교: " + (str1.equals(str2)));
String str3 = "hello";
String str4 = "hello";
System.out.println("리터럴 == 비교: " + (str3 == str4));
System.out.println("리터럴 equals 비교: " + (str3.equals(str4)));

실행 결과

new String() == 비교: false
new String() equals 비교: true
리터럴 == 비교: true
리터럴 equals 비교: true

String 은 클래스라고 하였다. 그러므로 참조값이 들어가 있을 것이다. 하지만 str3 == str4 의 결과 값을 보면 true 가 나온다. 위에서 설명한대로면 쌍따옴표로 문자열을 감싸면 자바에서 new String("hello") 와 같이 변경해준다고 하였으므로 둘의 동일성을 비교하는 == 은 결과값이 false 가 나와야 맞다고 생각할 수 있다. 지금까지 공부한 내용대로 라면 이 생각이 만지만 여기에는 함정이 있다.

  • String str3 = "hello" 와 같이 문자열 리터널을 사용하는 경우 자바는 메모리 효율성과 성능 최적화를 위해 문자열 풀을 사용한다.

  • 자바가 실행되는 시점에 클래스에 문자열 리터널이 있으면 문자열 풀에 String 인스턴스를 미리 만들어둔다. 이때 같은 문자열이 있으면 만들지 않는다.

  • String str3 = "hello" 와 같이 문자열 리터널을 사용하면 문자열 풀에서 "hello" 라는 문자를 가진 String 인스턴스를 찾는다. 그리고 찾은 인스턴스의 참조(x003)을 반환한다.

  • String str4 = "hello" 의 경우 "hello" 문자열 리터널을 사용하므로 문자열 풀에서 str3 과 같은 x003 참조를 사용한다.

  • 문자열 풀 덕분에 같은 문자를 사용하는 경우 메모리 사용을 줄이고 문자를 만드는 시간도 줄어들기 때문에 성능도 최적화 할 수 있다.

따라서 문자열 리터널의 경우 같은 참조값을 가지므로 == 비교에 성공한다.

참고

풀(Pool)은 자원이 모여있는 곳을 의미한다. 프로그래밍에서 풀(Pool)은 공용 자원을 모아둔 곳을 뜻한다. 여러 곳에서 함께 사용할 수 있는 객체를 필요할 때 마다 생성하고, 제거하는 것은 비효율적이다. 대신에 이렇게 문자열 풀에 필요한 String 인스턴스를 미리 만들어두고 여러곳에서 재사용할 수 있다면 성능과 메모리를 더 최적화 할 수 있다.

참고로 문자열 풀은 힙 영역을 사용한다. 그리고 문자열 풀에서 문자를 찾을 때는 해시 알고리즘을 사용하기 때문에 매우 빠른 속도로 원하는 String 인스턴스를 찾을 수 있다.

그러면 왜 equals() 를 사용해서 문자열을 비교해야 할까?

문자열 리터럴을 사용하면 == 비교를 하고, new String() 을 직접 사용하는 경우에만 equals() 비교를 사용하면 되지 않을까?

이제 String 클래스를 생성하는 개발자와 생성된 String 을 사용하는 개발자가 다르다고 생각해보자. 그러면 String 을 사용하는 개발자는 비교를 하려고 할때 이 String 이 리터널로 생성됐는지 new String() 으로 생성됐는지 확인할 수 있는 방법이 없다. 따라서 문자열 비교는 항상 equals() 를 사용해서 동등성 비교를 해야 한다.


3.3 String 클래스 - 불변 객체

String 은 불변 객체이다. 따라서 생성 이후에 절대로 내부의 문자열 값을 변경할 수 없다.

Main

String str = "hello";
str.concat(" java");
System.out.println("str = " + str);

실행 결과

str = hello

실행 결과를 보면 문자가 전혀 합쳐지지 않았다.

String str2 = str1.concat(" java");
  • String 은 불변 객체이다. 따라서 변경이 필요한 경우 기존 값을 변경하지 않고, 대신에 새로운 결과를 만들어서 반환한다.

  • String.concat() 은 내부에서 새로운 String 객체를 만들어서 반환한다.

  • 따라서 불변과 기존 객체의 값을 유지한다.

String 이 불변으로 설계된 이유가 뭘까?

String 이 불변으로 설계된 이유는 앞서 불변 객체에서 배운 내용에 추가로 다음과 같은 이유가 있다.

문자열 풀에 있는 String 인스턴스의 값이 중간에 변경되면 같은 문자열을 참고하는 다른 변수의 값도 함께 변경된다.

  • String 은 자바 내부에서 문자열 풀을 통해 최적화를 한다.

  • 만약 String 내부의 값을 변경할 수 있다면, 기존에 문자열 풀에서 같은 문자를 참조하는 변수의 모든 문자가 함께 변경되어 버리는 문제가 발생한다. str3 이 참조하는 문자를 변경하면 str4 의 문자도 함께 변경되는 사이드 이펙트 문제가 발생한다.


3.4 String 클래스 - 주요 메서드

String 클래스는 문자열을 편리하게 다루기 위한 다양한 메서드를 제공한다

문자열 정보 조회

  • length() : 문자열의 길이를 반환한다.
  • isEmpty() : 문자열이 비어 있는지 확인한다. (길이가 0)
  • isBlank() : 문자열이 비어 있는지 확인한다. (길이가 0이거나 공백(Whitespace)만 있는 경우), 자바 11
  • charAt(int index) : 지정된 인덱스에 있는 문자를 반환한다.

문자열 비교

  • equals(Object anObject) : 두 문자열이 동일한지 비교한다.
  • equalsIgnoreCase(String anotherString) : 두 문자열을 대소문자 구분 없이 비교한다.
  • compareTo(String anotherString) : 두 문자열을 사전 순으로 비교한다.
  • compareToIgnoreCase(String str) : 두 문자열을 대소문자 구분 없이 사전적으로 비교한다.
  • startsWith(String prefix) : 문자열이 특정 접두사로 시작하는지 확인한다.
  • endsWith(String suffix) : 문자열이 특정 접미사로 끝나는지 확인한다.

문자열 검색

  • contains(CharSequence s) : 문자열이 특정 문자열을 포함하고 있는지 확인한다.
  • indexOf(String ch) / indexOf(String ch, int fromIndex) : 문자열이 처음 등장하는 위치를 반환한다.
  • lastIndexOf(String ch) : 문자열이 마지막으로 등장하는 위치를 반환한다.

문자열 조작 및 변환

  • substring(int beginIndex) / substring(int beginIndex, int endIndex) : 문자열의 부분 문자열을 반환한다.
  • concat(String str) : 문자열의 끝에 다른 문자열을 붙인다.
  • replace(CharSequence target, CharSequence replacement) : 특정 문자열을 새 문자열로 대체한다.
  • replaceAll(String regex, String replacement) : 문자열에서 정규 표현식과 일치하는 부분을 새 문자열로 대체한다.
  • replaceFirst(String regex, String replacement) : 문자열에서 정규 표현식과 일치하는 첫 번째 부분을 새 문자열로 대체한다.
  • toLowerCase() / toUpperCase() : 문자열을 소문자나 대문자로 변환한다.
  • trim() : 문자열 양쪽 끝의 공백을 제거한다. 단순 Whitespace 만 제거할 수 있다.
  • strip() : Whitespace 와 유니코드 공백을 포함해서 제거한다. 자바 11

문자열 분할 및 조합

  • split(String regex) : 문자열을 정규 표현식을 기준으로 분할한다.
  • join(CharSequence delimiter, CharSequence... elements) : 주어진 구분자로 여러 문자열을 결합한다.

기타 유틸리티

  • valueOf(Object obj) : 다양한 타입을 문자열로 변환한다.
  • toCharArray() : 문자열을 문자 배열로 변환한다.
  • format(String format, Object... args) : 형식 문자열과 인자를 사용하여 새로운 문자열을 생성한다.
  • matches(String regex) : 문자열이 주어진 정규 표현식과 일치하는지 확인한다.

참고 : CharSequenceString, StringBuilder 의 상위 타입이다. 문자열을 처리하는 다양한 객체를 받을 수 있다.


3.5 StringBuilder - 가변 String

불변인 String 클래스의 단점

문자를 더하는 경우

String str = "A" + "B" + "C" + "D";
String str = String("A") + String("B") + String("C") + String("D");        
String str = new String("AB") + String("C") + String("D");
String str = new String("ABC") + String("D");
String str = new String("ABCD");
  • 이 경우 총 3 개의 String 클래스가 추가로 생성된다.

  • 그런데 문제는 중간에 만들어진 new String("AB"), new String("ABC") 는 사용되지 않는다. 최종적으로 만들어진 new String("ABCD") 만 사용된다.

  • 결과적으로 중간에 만들어진 new String("AB"), new String("ABC") 는 사용되지도 않고, 이후에 GC의 대상이 된다.

불변인 String 클래스의 단점은 문자를 더하거나 변경할 때 마다 계속해서 새로운 객체를 생성해야 한다는 점이다. 문자를 자주 더하거나 변경해야 하는 상황이라면 더 많은 String 객체를 만들고, GC 해야 한다. 결과적으로 컴퓨터의 CPU, 메모리 자원을 더 많이 사용하게 된다. 그리고 문자열의 크기가 클수록, 문자열을 더 자주 변경할수록 시스템의 자원을 더 많이 소모한다.

StringBuilder

이 문제를 해결하는 방법은 단순한다. 바로 불변이 아닌 가변 String 이 존재하면 된다. 가변은 내부의 값을 바로 변경하면 되기 때문에 새로운 객체를 생성할 필요가 없다. 따라서 성능과 메모리 사용면에서 불변보다 더 효율적이다.

이런 문제를 해결하기 위해 자바는 StringBuilder 라는 가변 String 을 제공한다. 물론 가변의 경우 사이드 이펙트에 주의해서 사용해야 한다.

StringBuilder

public final class StringBuilder {
	char[] value;// 자바 9 이전
	byte[] value;// 자바 9 이후

	//여러 메서드
	public StringBuilder append(String str) {...}
 	public int length() {...}
    ...
}

StringBuilder 는 내부에 final 이 아닌 변경할 수 있는 byte[] 을 가지고 있다.

Main

StringBuilder sb = new StringBuilder();
sb.append("A");
sb.append("B");
sb.append("C");
sb.append("D");
System.out.println("sb = " + sb);
        
sb.insert(4, "Java");
System.out.println("insert = " + sb);

sb.delete(4, 8);
System.out.println("delete = " + sb);

sb.reverse();
System.out.println("reverse = " + sb);

//StringBuilder -> String
String string = sb.toString();
System.out.println("string = " + string);
  • StringBuilder 객체를 생성한다.
  • append() 메서드를 사용해 여러 문자열을 추가한다.
  • insert() 메서드로 특정 위치에 문자열을 삽입한다.
  • delete() 메서드로 특정 범위의 문자열을 삭제한다.
  • reverse() 메서드로 문자열을 뒤집는다.
  • toString() 메소드를 사용해 StringBuilder 의 결과를 기반으로 String 을 생성해서 반환한다.

실행 결과

sb = ABCD
insert = ABCDJava
delete = ABCD
reverse = DCBA
string = DCBA

가변(Mutable) vs 불변(Immutable)

  • String 은 불변하다. 즉, 한 번 생성되면 그 내용을 변경할 수 없다. 따라서 문자열에 변화를 주려고 할 때마다 새로운 String 객체가 생성되고, 기존 객체는 버려진다. 이 과정에서 메모리와 처리 시간을 더 많이 소모한다.

  • 반면에, StringBuilder 는 가변적이다. 하나의 StringBuilder 객체 안에서 문자열을 추가, 삭제, 수정할 수 있으며, 이때마다 새로운 객체를 생성하지 않는다. 이로 인해 메모리 사용을 줄이고 성능을 향상시킬 수 있다. 단 사이드 이펙트를 주의해야 한다.

StringBuilder 는 보통 문자열을 변경하는 동안만 사용하다가 문자열 변경이 끝나면 안전한(불변) String 으로 변환하는 것이 좋다.


3.6 String 최적화

자바의 String 최적화

문자열 리터널 최적화

컴파일 전

String helloWorld = "Hello, " + "World!";

컴파일 후

String helloWorld = "Hello, World!";

자바는 문자열 리터널을 더하는 부분을 자동으로 합쳐준다. 따라서 런타임에 별도의 문자열 결합 연산을 수행하지 않기 때문에 성능이 향상된다.

String 변수 최적화

문자열 변수의 경우 그 안에 어떤 값이 들어있는지 컴파일 시점에는 알 수 없기 때문에 단순하게 합칠 수 없다.

String result = str1 + str2;

-> 최적화

String result = new StringBuilder().append(str1).append(str2).toString();

자바에서 StringBuillder 로 변환하여 문자열을 더한뒤 다시 불변 String 으로 변환하여 최적화를 수행한다.

이렇듯 자바가 최적화를 처리해주기 때문에 지금처럼 간단한 경우에는 StringBuilder 를 사용하지 않아도 된다. 대신에 문자열 더하기(+)을 사용하면 충분한다.

String 최적화가 어려운 경우

루프안에서 문자열을 더하는 경우에는 최적화가 이루어지지 않는다.

String result = "";
for (int i = 0; i < 100000; i++) {
	result += "Hello Java ";
}
----> 최적화
String result = "";
 for (int i = 0; i < 100000; i++) {
    result = new StringBuilder().append(result).append("Hello Java").toString();
}

자바에서의 최적화는 위의 코드까지만 최적화된다.

반복문의 루프 내부에서는 최적화가 되는 것처럼 보이지만, 반복 횟수만큼 객체를 생성해야 한다.

반복문 내에서의 문자열 연결은, 런타임에 연결할 문자열의 개수와 내용이 결정된다. 이런 경우, 컴파일러는 얼마나 많은 반복이 일어날지, 각 반복에서 문자열이 어떻게 변할지 예측할 수 없다. 따라서, 이런 상황에서는 최적화가 어렵다.

이럴 때 직접 StringBuilder 를 사용해서 최적화를 해준다.

StringBuilder sb = new StringBuiler();
for (int i = 0; i < 100000; i++) {
	sb.append("Hello Java ");
}
String result = sb.toString();

StringBuilder 를 직접 사용하는 것이 더 좋은 경우

  • 반복문에서 반복해서 문자를 연결할 때

  • 조건문을 통해 동적으로 문자열을 조합할 때

  • 복잡한 문자열의 특정 부분을 변경해야 할 때

  • 매우 긴 대용량 문자열을 다룰 때

참고: StringBuilder vs StringBuffer

StrinBuilder 와 똑같은 기능을 수행하는 StringBuffer 클래스도 있다.

  • StringBuffer 는 내부에 동기화가 되어 있어서, 멀티 스레드 상황에서 안전하지만 동기화 오버헤드로 인해 성능이 느리다.

  • StringBuilder 는 멀티 쓰레드 상황에서 안전하지 않지만 동기화 오버헤드가 없으므로 속도가 빠르다.


3.7 메서드 체인닝 - Method Chaining

ValueAdder

public class ValueAdder {

	private int value;
 	
    public ValueAdder add(int addValue) {
        value += addValue;
 		return this;
    }
 
 	public int getValue() {
 		return value;
    }
}
  • add() 메서드를 보면 자기 자신(this)의 참조값을 반환한다.

Main

ValueAdder adder = new ValueAdder();
adder.add(1);
adder.add(2);
adder.add(3);
  • 이러한 코드에서 add() 메서드의 반환값을 사용하는 코드로 변경해보자.
ValueAdder adder = new ValueAdder();
int result = adder.add(1).add(2).add(3).getValue();

실행 순서

add() 메서드를 호출하면 ValueAdder 인스턴스 자신의 참조값(x001)이 반환된다. 이 반환된 참조값을 변수에 담아두지 않아도 된다. 대신에 반환된 참조값을 즉시 사용해서 바로 메서드를 호출할 수 있다.

adder.add(1).add(2).add(3).getValue()
->
x001.add(1).add(2).add(3).getValue()
->
x001.add(2).add(3).getValue()
->
x001.add(3).getValue()
->
x001.getValue()

메서드 호출의 결과로 자기 자신의 참조값을 반환하면, 반환되 참조값을 사용해서 메서드 호출을 계속 이어갈 수 있다. 코드를 보면 . 을 찍고 메서드를 계속 연결해서 사용한다. 마치 메서드가 체인으로 연결된 것 처럼 보인다. 이러한 기법을 메서드 체이닝이라고 한다.

메서드 체이닝 기법은 코드를 간결하고 읽기 쉽게 만들어준다.

StringBuilder 와 메서드 체인(Chain)

StringBuilder 는 메서드 체이닝 기법을 제공한다.

String string = sb.append("A").append("B").append("C").append("D")
		.insert(4, "Java")
        .delete(4, 8)
        .reverse()
        .toString();

정리

"만드는 사람이 수고로우면 쓰는 사람이 편하고, 만드는 사람이 편하면 쓰는 사람이 수고롭다"

문제 - 검색 count

문제 설명

str에서 key 로 주어지는 문자를 찾고, 찾은 문자의 수를 출력해라.
indexOf() 를 반복문과 함께 풀면 된다.

String str = "start hello java, hello spring, hello jpa";
String key = "hello";
int count = 0;
int index = str.indexOf(key);
while (index >= 0) {
	index = str.indexOf(key, index + 1);
    count++;
}
System.out.println("count = " + count);

profile
가오리의 개발 이야기

0개의 댓글