String 클래스

2024. 11. 18. 19:12Java

◎ String

-> 자바에서 문자를 다루는 타입으로 char, String이 있다.

-> 기본형 char는 문자 하나를 다룰 때 사용한다.  char를 이용해 여러 문자를 나열하려면 char[]을 사용해야 한다. 이 방법은 불편하기 때문에 자바는 문자열을 편리하게 다룰 수 있는 String 클래스를 제공한다.

-> String 클래스를 통해 문자열을 생성하는 방법은 2가지가 있다.

public class StringBasicMain {
    public static void main(String[] args) {
        String str1 = "hello";
        String str2 = new String("hello");

        System.out.println("str1 = " + str1);
        System.out.println("str2 = " + str2);
    }
}

-> String은 클래스로 기본형이 아니라 참조형이다. 따라서 원칙적으로 new 키워드를 사용해 객체를 생성해야 한다. 그런데 위 코드를 보면 String str1 = "hello"부분은 마치 기본형 변수를 선언하듯이 문자열을 변수에 그대로 선언했다.

-> 문자열은 매우 자주 사용되므로 자바에서는 String str1 = "hello"를 String str1 = new String("hello")로 바꿔준다.

 

◎ String 클래스 구조

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence,
               Constable, ConstantDesc {
               
               @Stable
    		private final byte[] value;
               ...

-> private final byte[] value; 여기에 String의 실제 문자열 값이 보관된다.

-> String 클래스는 byte[]을 내부에 감추고 String 클래스를 사용하는 개발자가 편리하게 문자열을 사용할 수 있도록 다양한 기능을 제공한다.

 

◎ String 클래스와 참조형

-> String은 클래스로 참조형이다.

-> 자바에서 문자열을 더할 때는 String에서 제공하는concat()메서드를 사용하여 더한다.

-> 문자열이 많이 사용되기 때문에 자바에서 편의성 +를 통해 문자열 연산을 제공한다.

public class StringConcatMain {
    public static void main(String[] args) {
        String a = "hello";
        String b = " Java";

        String result1 = a.concat(b);
        String result2 = a + b;

        System.out.println("result1 = " + result1);
        System.out.println("result2 = " + result2);
    }
}

-> 결과

 

String 클래스 비교

-> String 클래스 비교 시 == 비교가 아닌 equals()비교를 해야한다.

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

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

public class StringEqualsMain1 {
    public static void main(String[] args) {
        // 1
        String str1 = new String("hello");
        String str2 = new String("hello");
        System.out.println("===================== 1 =====================");
        System.out.println("new String() == 비교 : " + (str1 == str2));
        System.out.println("new String() equals() 비교 : " + (str1.equals(str2)));

        // 2
        String str3 = "hello";
        String str4 = "hello";
        System.out.println("===================== 2 =====================");
        System.out.println("new String() == 비교 : " + (str3 == str4));
        System.out.println("new String() equals() 비교 : " + (str3.equals(str4)));
    }
}

-> 결과

-> 1번에서 str1과 str2는 new 키워드를 사용해 각각의 인스턴스를 생성했다. 따라서 str1과 str2는 서로 다른 객체이므로 == 비교를 할 경우 false가 나온다. 

-> 2번에서 str3과 str4는 new 키워드가 아닌 문자열을 그대로 넣었다. 이 경우 자바에서 자동으로 new String("hello")로 바꿔준다는고 했다. 그런데 str3과 str4를 == 비교를 하면 true가 나온다.

-> 이는 자바가 문자열 풀을 사용했기 때문이다.

-> str3는 "hello"라는 문자열 리터러를 그대로 넣었다. 이 때 자바는 메모리 효율성과 성능 최적화를 위해 문자열 풀이라는 것을 사용한다. 자바가 실행되는 시점에 클래스에 문자열 리터럴이 있으면 문자열 풀에 String 인스턴스를 미리 만들고, 같은 문자열이 있으면 만들지 않는다.

-> str3가 선언된 이후 str4 = "hello"의 경우 "hello"문자열 리터럴이 문자열 풀이 있기 때문에 str3와 같은 인스턴스를 참조한다.

문자열 풀

 

※ 문자열 비교는 항상 equals()를 사용해서 동등성 비교를 해야한다.

- 만약 문자열을 비교하는 메서드를 만든 개발자와 main()메서드를 담당하여 String을 선언한 개발자가 다른 사람인 경우 문자열 비교 메서드를 담당하는 개발자는 메서드 파라미터로 넘어온 String 인스턴스가 new String()인지 문자열 리터럴로 만들어진 것인지 알 수 없다. 따라서 문자열 비교는 항상 equals()를 사용해야 한다. 

 

◎ String 클래스 불변 객체

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

public class StringImmutable1 {
    public static void main(String[] args) {
        String str = "Hello";
        str.concat(" Java");

        System.out.println("str = " + str);
    }
}

-> 위 코드는 concat()을 이용해 문자열을 더하는 코드이다. Hello에 concat()을 이용해 Java를 붙였다.

-> 예상되는 str의 결과는 "Hello Java"이다.

-> 결과

-> 그러나 결과는 Hello이다.

-> String은 불변 객체이기 때문에 변경을 하는 경우 기존 값을 변경하지 않고, 새로운 결과를 만들어 반환한다.

-> 위의 테스트 코드는 결국 아래 코드와 같다.

public class StringImmutable1 {
    public static void main(String[] args) {
        String str = "Hello";
        String newStr = str.concat(" Java");

        System.out.println("str = " + str);
    }
}

-> str과 str.concat(" Java")는 서로 다른 객체이다. 따라서 str은 "Hello"가 newStr은 "Hello Java" 가 된다.

-> String이 불변 객체인 이유는 문자열 풀에 있는 인스턴스의 값이 중간에 변경되면 같은 문자열을 참조하는 다른 변수의 값도 같이 변경이 되기 때문에 이를 막기 위해 불변 객체로 설계되었다.

-> 위 그림을 예로 들면 String이 불변객체가 아닌 경우 str3가 참조하는 값을 변경하면 같은 문자열을 참조하는 str4도 변경이 되는 사이드이펙트가 발생한다.

-> 따라서 String을 불변객체로 하여 위와 같은 사이드 이펙트를 방지한다.

 

◎ StringBuilder - 가변 String

-> String클래스는 불변이므로 String의 내부 값을 변경할 수 없어 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");

-> String 클래스가 불변이기 때문에 문자럴 더하거나 변경할 때 마다 매번 새로운 객체를 생성하게 된다. 이 과정에서 매번 새로운 String 객체를 만들고 GC해야 한다. 이는 컴퓨터의 CPU, 메모리 자원을 많이 사용하게 된다. 

-> 이 문제를 해결하려면 가변 String을 사용하면 된다. 자바에서는 StringBuilder라는 가변 String을 제공한다.

-> StringBuilder는 내부에 final이 아닌 변경 가능한 byte[]를 가지고 있다.

public class StringBuilderMain1_1 {
    public static void main(String[] args) {
        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("sb.insert() = " + sb);

        // 특정 범위의 문자열 삭제
        sb.delete(4, 8);
        System.out.println("sb.delete() = " + sb);

        // 메서드로 문자열 뒤집기
        sb.reverse();
        System.out.println("sb.reverse() = " + sb);
        
        // StringBuilder를 String으로 변환
        String string = sb.toString();
        System.out.println("string = " + string);
    }
}

-> 결과

-> StringBuilder를 통해 문자열을 가공하고 toString()을 이용해 String으로 생성해서 반환한다.

-> String 객체는 toString()으로 StringBuilder를 String으로 변환할 때 한 번만 생성된다. 

-> StringBuilder는 가변이기 때문에 항상 사이드 이펙트를 주의해야 한다.

 

String 최적화

-> 자바 컴파일러는 문자열 리터럴을 더하는 부분을 자동으로 합친다.

// 컴파일 전
String hw = "Hello, " + "World";

// 컴파일 후
String hw = "Hello, World";

-> 런타임에 별도의 문자열 결합 연산을 수행하지 않기 때문에 성능이 향상된다.

-> 문자열 변수의 경우에는 변수에 어떤 값이 들어갔는지 컴파일 시점에 알 수 없기 때문에 단순히 + 연산자로 합칠 수 없다.

String string = str1 + str2;

-> 이 경우에는 아래와 같이 최적화를 수행할 수 있다.

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

-> 다음은 최적화 성능 비교이다.

-> 먼저 단순히 문자열을 반복문 내부에서 더하는 경우의 시간을 측정한다.

public class LoopStringMain {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();

        String result = "";
        for (int i = 0; i < 100000; i++) {
            result += "Hello Java";
        }
        long endTime = System.currentTimeMillis();

        System.out.println("result = " + result);
        System.out.println("time = " + (endTime - startTime) + "ms");
        
    }
}

-> 반복문 내부에 result+= "Hello, Java"; 는 아래와 같이 최적화 된다.

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

-> 결과

-> 약 9738ms가 걸렸다.

-> 반복문 내부에서 최적화가 되는 것 같지만 반복문을 돌면서 매번 new StringBuilder()가 실행된다. 즉, 반복 횟수만큼 객체를 생성해야 한다는 것이다.

-> 반복문 내부에의 문자열 연결은 런타임에 연결할 문자열의 개수와 내용에 결정된다. 이렇게 되면 컴파일러는 얼마나 반복이 일어날지, 각 반복에서 문자열이 어떻게 변할지 예측할 수 없다. 

-> 이런 경우에는 StringBuilder를 직접 사용하여 최적화한다. 다음은 최적화를 적용한 코드다.

public class LoopStringBuilderMain {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();

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

        String result = sb.toString();
        long endTime = System.currentTimeMillis();

        System.out.println("result = " + result);
        System.out.println("time = " + (endTime - startTime) + "ms");
    }
}

-> 반복문 외부에서 먼저 StringBuilder를 생성하고 반북문에서는 생성된 객체 하나에 append()를 통해 문자열을 연결하기만 하면 된다.

-> 결과

 

◎ 메서드 체이닝 - Method Chaining

 

public class ValueAdder {
    private int value;

    public ValueAdder add(int addValue) {
        value += addValue;
        return this;
    }

    public int getValue() {
        return value;
    }
}

-> 위 코드는 value를 누적하여 더하는 기능을 가진 클래스다.

-> add()가 호출될 때 마다 클래스 내부의 value에 값을 누적하고 자기자신을 반환한다.

public class MethodChainingMain1 {
    public static void main(String[] args) {
        ValueAdder adder = new ValueAdder();
        adder.add(1);
        adder.add(2);
        adder.add(3);

        int result = adder.getValue();
        System.out.println("result = " + result);
    }
}

-> 결과

-> add()메서드를 여러 번 호출하여 값을 누적한 결과를 출력했다.

-> 다음은 add()메서드의 반환값을 사용하는 예제다.

public class MethodChainingMain2 {
    public static void main(String[] args) {
        ValueAdder adder = new ValueAdder();
        ValueAdder adder1 = adder.add(1);
        ValueAdder adder2 = adder1.add(2);
        ValueAdder adder3 = adder2.add(3);

        int result = adder3.getValue();
        System.out.println("result = " + result);
    }
}

-> 반환된 ValueAdder를 받아 add()를 반복 수행했다.

-> 결과

-> 다음은 MethodChainingMain2의 동작 과정이다.

-> adder.add(1)를 호출하면 add()메서드가 실행되고 해당 메서드는 결과를 누적하고 자기 자신(ValueAdder 인스턴스)의 참조값 this를 반환한다.

-> adder1 변수는 adder와 같은 인스턴스를 참조하게 된다.

-> adder1.add(2)를 호출하면 마찬가지로 자기자신의 참조값을 반환한다. 이 과정이 반복되어 adder, adder1, adder2, adder3는 모두 같은 인스턴스를 참조하게 된다.

-> 이 방식은 가독성이 떨어진다. 

-> 이번에는 반환된 참조값을 새로운 변수에 넣지 않고, 바로 메서드 호출을 하도록 수정한다.

public class MethodChainingMain3 {
    public static void main(String[] args) {
        ValueAdder adder = new ValueAdder();

        int result = adder.add(1).add(2).add(3).getValue();

        System.out.println("result = " + result);
    }
}

-> 결과

 

-> add()메서드를 호출하면 ValueAdder 인스턴스 자신의 참조값이 나오기 떄문에 연속해서 add()를 호출해도 결국 같은 인스턴스에 접근하여 연산이 수행된다.

->  adder.add(1).add(2).add(3).getValue(); 이 코드는 아래와 같이 동작할 것이다.

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();

-> add() 메서드가 리턴하는 것은 항상 같은 인스턴스의 참조값이기 때문에 위와 같이 동작한다.

-> 위 코드처럼 연속해서 메서드를 연결하여 사용하는 방법을 메서들 체이닝이라 한다.

-> 메서드 체이닝이 가능한 이유는 메서드가 항상 자기 자신의 참조값을 반환하기 때문이다.

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

public class StringBuilderMain1_2 {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        String result = sb.append("A").append("B").append("C").append("D")
                .insert(4, "Java")
                .delete(4, 8)
                .reverse()
                .toString();

        System.out.println("result = " + result);
    }
}

-> 결과

 

-> StringBuilder에서 제공하는 메서드들은 자기자신의 참조값을 반환하기 때문에 위 코드 처럼 메서드 체이닝이 가능하다.

 

★ 참고 및 출처

스프링 DB 1편 - 데이터 접근 핵심 원리 

 

'Java' 카테고리의 다른 글

열거형 - ENUM  (0) 2025.04.01
래퍼, Class 클래스  (0) 2025.03.27
불변객체  (0) 2024.10.31
Object 클래스  (0) 2024.10.29
다형성2  (0) 2024.02.11