JAVA의 가장 기본이 되는 내용

String 클래스

cwchoiit 2024. 4. 1. 14:37
728x90
반응형
SMALL

불변객체의 가장 대표적인 클래스인 String. 자바에서 문자열을 다룰 때 사용하곤 한다.

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

 public class CharArrayMain {
     public static void main(String[] args) {
         char[] charArr = new char[]{'h', 'e', 'l', 'l', 'o'};
         System.out.println(charArr);
         String str = "hello";
         System.out.println("str = " + str);
     }
}

실행결과:

hello
str = hello

 

보는 것과 같이 char 타입은 한 글자씩만 사용할 수 있기 때문에 문자열로 적합하지 않다. 그래서 String 클래스를 사용하는데, 이 String 클래스를 사용해서 문자열을 생성하는 방법은 2가지가 있다.

  • 쌍따옴표 사용: "hello"
  • 객체 생성: new String("hello");

그러나 String은 클래스고, 참조값을 가져야 하는데 이 코드는 뭔가 이상하다.

String str = "hello";

이는 자바가 문자열 다루는 것 자체가 너무 자주 사용되니까 편의상 허용해준 방법이다. 즉, 저렇게 인스턴스로 직접 만들지 않고 리터럴로 대입을 해도 허용하도록 개발자들에게 편의를 제공한다.

 

 

String 클래스 구조

String 클래스는 클래스이므로 속성과 기능을 가진다. 그리고 내부적으로 다음과 같은 속성을 가지고 있다.

private final char[] value; // 자바 9 이전
private final byte[] value; // 자바 9 이후

 

그러니까 결국 String 클래스도 내부적으로 char 타입의 배열로 문자열을 보관한다는 의미다.

 

근데 자바 9 이전과 이후에 왜 타입이 달라질까? 자바에서 문자 하나를 표현하는 char는 2byte를 차지한다. 그런데 영어, 숫자는 보통 1byte로 표현이 가능하다. 그래서 단순 영어, 숫자로만 표현된 경우 1byte를 사용하고(정확히는 Latin-1 인코딩인 경우 1byte 사용) 그렇지 않은 나머지의 경우 2byte인 UTF-16 인코딩을 사용한다. 따라서 메모리를 더 효율적으로 사용할 수 있게 변경된 것.

 

기능(메서드)은 엄청 많다. 대표적인 기능으로는 다음과 같은 것들이 있다.

  • length()
  • charAt(int index)
  • substring(int beginIndex, int endIndex)
  • indexOf(String str)
  • toLowerCase(), toUpperCase()

그리고 이렇듯 String은 클래스이기 때문에 기본형이 아닌 참조형이다. 참조형은 변수에 참조값(메모리 주소)가 들어간다. 그 말은 연산이 불가능하단 말이고 "+"와 같은 연산을 사용할 수 없는게 원칙적이나 자바에서는 문자열이 너무 자주 다루어지다 보니 편의상 특별히 "+" 연산을 제공한다.

 

String 클래스 비교

결론부터 말하면 String 클래스를 비교할 땐 반드시 equals() 메서드를 사용해야 한다.

근데, 신기한점이 있다. 다음 코드를 보자.

 

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

        System.out.println(a == b);
    }
}

이 실행결과는 어떻게 될까?

실행결과:

true

 

이상하다. '=='은 동일성 비교를 할 때 사용하는 연산자이다. 즉, 완전히 같아야 한다는 소린데 String은 클래스고 클래스라는 것은 참조형이며 참조형이면 각자 가지고 있는 메모리가 다를 것이다. 그런데도 불구하고 참이 나온다. 실제로 아래 코드를 수행해보면 기존에 알던 지식 그대로의 결과가 나온다.

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

        System.out.println(a == b);
    }
}

실행결과:

false

 

당연히 위 코드는 납득이 된다. a와 b는 각자 서로 다른 인스턴스이고 그 말은 서로 다른 참조값을 가지니까 동일성 비교('==')는 당연히 다르다. 근데 인스턴스를 새로 만들어서 값을 저장하는게 아닌 리터럴로 저장할 때는 참이 나오는데는 이유가 있다. 

 

자바는 실행되는 시점에 클래스에 String a = "hello";와 같은 문자열 리터럴이 있으면 문자열 풀에 String 인스턴스를 미리 만들어둔다. 이때 같은 문자열이 있으면 만들지 않는다. 그래서 String a = "hello";String b = "hello";는 결국 같은 참조값을 가지고 있게 된다. 즉, 자바가 문자열 풀을 만들어두고 미리 이런 문자열 리터럴을 보관해주는 덕분에 메모리 사용량을 줄이고 성능도 최적화를 해준다.

 

그럼, 여기서 질문이 생긴다. 문자열 리터럴을 사용하는 경우에는 비교 시 '=='를 사용하고 인스턴스를 직접 생성해서 사용하는 경우에만 equals()를 사용하면 될까? 아니다. 무조건 equals()를 사용해야 한다.

 

그 이유는 우선, 코드가 길어지면 질수록 이 코드가 문자열 리터럴로 만들어진것인지 아닌지 알 턱이 없고, 만들어내는 개발자와 비교 로직을 작성하는 개발자가 다르면 더더욱 알 턱이 없어진다. 그렇기 때문에 반드시 문자열은 equals() 비교를 해야한다.

 

불변객체 String

String은 불변객체의 아주 대표적인 예이다. 한번 직접 확인해봐도 좋다. String은 setter도 없고 속성도 final로 선언되어 있다. 

 

"어? 저는 지금까지 잘 바꿔왔는데요?" 그건 기존값을 변경한 게 아니고 새 참조값을 넣었을 뿐이다. 이 질문은 가령 이런 내용일 것이다.

String a = "hello";
a = "hello java";

이건 변수 a의 값을 "hello"에서 "hello java"로 변경한 게 아니고 "hello"라는 값을 담은 인스턴스를 문자열 풀에 자바가 만들어 둔 것을 그대로 둔 상태에서 "hello java"라는 값을 담은 새로운 인스턴스의 참조값을 a에 대입한 것이다. 

 

그리고 저번 포스팅에서 다룬 불변객체가 값을 바꿀 땐 새로운 불변객체를 만들어낸다고 했는데 그것을 String 클래스에서도 확인할 수 있다. 다음 코드를 보자. 문자열을 합치는 concat() 메서드를 사용했지만 다음 코드는 전혀 값의 변화가 없다.

실행결과:

hello

 

기존에 있는 값에 저 문자열을 추가한게 아니라 추가한 문자열을 가지는 새로운 문자열을 만들어내는 불변객체이기 때문이다. 그래서 저 문자열을 추가한 결과를 찍고 싶으면 반환값을 받아야한다.

실행결과:

hello
hellohello java

 

String이 불변객체인 이유

왜 String이 불변객체일까? 문자열 풀에 있는 String 인스턴스의 값이 중간에 변경되면 같은 문자열을 참고하는 다른 변수의 값도 함께 변경되기 때문이다. 그러니까 만약에 다음 코드를 보면,

String a = "hello";
a = "hello java";

 

String a = "hello";(x001)를 자바가 보고 문자열 풀에 "hello"라는 문자열을 담은 인스턴스를 딱 한개만 보관한다. 그래서 다른 곳에서 같은 "hello"라는 문자열을 사용할 때 같은 참조값(x001)을 돌려주게 되어있다.

 

그런데 저 코드가 "hello"라는 문자열을 담은 인스턴스를 "hello java"를 담는것으로 변경해버리는 거라면 다른곳에서 참조하는 "hello"도 "hello java"로 변경된다는 것이다. 그러면 사이드 이펙트가 발생할 수 있다. 그래서 그게 아니라 "hello java"라는 문자열을 담는 새로운 인스턴스 참조값을 변수 a에 대입하는 것이다.

 

불변객체 String의 단점과 StringBuilder

String이 불변객체이고 불변객체인 이유까지 알았다. 근데 불변객체라고 장점만 있는것은 아니다. 어떤 단점이 있을까? 예를 들어 다음과 같은 코드가 있다고 가정해보자.

String str = "A" + "B" + "C" + "D";

 

아주 단순하게 A, B, C, D라는 문자를 더하는 것 같지만 이 연산을 수행하기 위해 다음과 같은 절차를 거쳐야한다.

1. A, B, C, D를 각각 담는 4개의 String 인스턴스를 만든다.

2. String은 불변객체이므로 "A" + "B"를 하기 위해 "AB"라는 새로운 String 인스턴스를 만든다.

3. String은 불변객체이므로 "AB" + "C"를 하기 위해 "ABC"라는 새로운 String 인스턴스를 만든다.

4. String은 불변객체이므로 "ABC" + "D"를 하기 위해 "ABCD"라는 새로운 String 인스턴스를 만든다.

 

저 한줄의 연산을 위해 3번의 새로운 인스턴스를 만든다. 얼마나 비효율적인가? 그럼 더 나아가서 문자열이 길어지면 길어질수록 더더더 많은 인스턴스를 만들 수 밖에 없다. 이것이 String이라는 불변객체의 단점이다. (물론, 원리는 이게 맞지만 자바가 최적화를 한다)

 

그래서 이런 문제를 해결하려면 ? 불변객체가 아닌 가변객체를 사용하면 된다.

StringBuilder

자바가 StringBuilder라는 가변 String을 제공해준다. "어 정말 가변인가요?" StringBuilder 클래스에 들어가보면 String과는 달리 final 키워드가 없는 것을 확인해볼 수 있다.

 

그럼 StringBuilder를 어떻게 사용하는지 확인해보자. 

public class SbMain {
    public static void main(String[] args) {
        StringBuilder builder = new StringBuilder();

        builder.append("A");
        builder.append("B");
        builder.append("C");
        builder.append("D");
        System.out.println("builder = " + builder);

        builder.insert(4, "JAVA");
        System.out.println("builder = " + builder);

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

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

        String string = builder.toString();
        System.out.println("string = " + string);
    }
}

실행결과:

builder = ABCD
builder = ABCDJAVA
builder = ABCD
builder = DCBA
string = DCBA

StringBuilder 객체를 생성한 후, 해당 객체에 append() 메서드를 사용해서 문자열을 뒤로 계속하여 추가할 수 있다. 보면 알겠지만 append() 메서드의 반환값을 받아온 게 아니고 그대로 builder를 사용해서 진행하고 있다. 즉, 불변객체가 아니라는 소리다.

 

insert()는 특정 위치에 문자열을 추가한다.

delete()는 특정 범위의 문자열을 삭제한다.

reverse()는 문자열을 뒤집는다.

 

toString()은 String 객체를 만들어낸다. 불변객체를 만들어낸다는 의미이고 StringBuilder로 작업을하고 모든 작업이 끝나면 사이드 이펙트를 방지하기 위해 불변객체로 변환하는것도 좋은 습관이 될 수 있다.

 

 

String 최적화

다음 코드를 얘기하면서 불변객체가 주는 비효율성을 얘기했다.

String str = "A" + "B" + "C" + "D";

그리고 이 작업 원리에 대해 자바가 최적화를 해준다고도 했다. 어떤 최적화를 해주냐면 자바가 컴파일 시에 다음 코드를 보고 그냥 저 문자를 합쳐버리는 행위를 한다. 다음과 같이.

String str = "ABCD";

즉, 런타임 시에 연산 작업을 하지 않게 자바가 컴파일 하면서 최적화를 해준다.

 

그러나 저건 그냥 문자열이니까 가능한데 변수에 담긴 문자열은 어떻게 될까?

String str = str1 + str2;

다음 코드 같은 경우에는 어떤 값이 들어있는지 컴파일 시점에는 알 수 없기 때문에 단순하게 합칠 수 없다. 그래서 다음과 같이 비슷하게 최적화를 해준다.

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

그러니까 최대한 최적화할 수 있는 만큼은 해준다는거고 이렇게 자바가 알아서 다 해주기 때문에 개발자는 "어.. 이거를 최적화해야겠다"하고 StringBuilder를 사용해서 String str = StringBuilder().append(str1).append(str2).toString() 이렇게 직접 작성안해도 된다. 그냥 + 연산을 하면 된다. 알아서 자바가 해준다.

 

근데, 그럼에도 String 최적화가 어려운 경우가 있다. 그래서 개발자가 직접 최적화해야 하는 경우가 있다. 어떤 경우냐면, 루프안에서 문자열을 더하는 경우이다.

public class Main {
    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);
        System.out.println("time = " + (endTime - startTime) + "ms");
    }
}

 

이런 경우 자바가 어떤 작업을 하냐면 대략 이런 작업을 한다. 

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

근데, 이게 최적화가 되는것 같아도 그게 아니다. 오히려 더 성능을 악화시킨다. 왜냐하면, 반복 횟수만큼 StringBuilder 객체를 생성하고 있다. 거기다가 반복횟수만큼 String 객체도 생성한다. 이게 얼마나 오래 걸릴지 실행해보자.

....
JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello Java
time = 7142ms

7초가 걸렸다. 고작 100,000번의 반복으로 7초가 걸리는건 정말 문제가 있는거다. 이럴땐 개발자가 직접 최적화하는게 훨씬 효율적이다.

다음 코드가 개발자가 직접 최적화한 경우이다.

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

        StringBuilder stringBuilder = new StringBuilder();

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

        long endTime = System.currentTimeMillis();

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

StringBuilder 객체를 딱 한번만 만들고, String 객체도 딱 한번만 만든다. 이 코드는 얼마나 걸릴까? 

...
JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello Java
time = 10ms

1초도 안 걸린다. 700배 정도 차이가 있는거다. 어마어마한 차이다. 이렇듯 개발자가 직접 최적화를 해야하는 경우도 있고 단순한 경우 자바가 알아서 최적화를 해주기 때문에 고려를 하지 않아도 되는 경우가 있다.

 

그럼 StringBuilder를 직접 사용하는 경우가 더 좋은 경우는 다음과 같은 경우가 있다고 정리하고 마치면 될듯하다.

  • 반복문에서 반복해서 문자를 연결할 때
  • 조건문을 통해 동적으로 문자열을 조합할 때
  • 복잡한 문자열의 특정 부분을 변경해야 할 때
  • 매우 긴 대용량 문자열을 다룰 때

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Wrapper Class  (0) 2024.04.02
Method Chaining  (0) 2024.04.02
불변객체 (Immutable Object)  (2) 2024.04.01
java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30