Java 06
String 탐구: 혼란을 넘어 안전하게 다루는 법
String은 기본형처럼 자주 쓰이지만 메서드도 있고, 문자열 비교에서 ==와 equals가 갈라져 혼란스럽다.
근거 · 교안 p47-p53
1장: String의 진짜 모습: 단순한 문자를 넘어선 참조형 객체
솔라의 시선이 노트북 화면의 코드 한 줄에 머물렀다. String name = "솔라"; 지극히 평범하고 익숙한 코드였다. 마치 int number = 10;처럼, 변수를 만들고 값을 넣는 자연스러운 과정. 하지만 바로 다음 줄에 나오는 name.length()가 솔라의 생각을 멈추게 했다. 작은 점 하나가 모든 것을 어색하게 만들었다.
방금 전까지 온라인 강의에서 강사가 아무렇지 않게 “문자열의 길이를 구하는 메서드입니다”라고 설명하고 넘어간 부분이었다. 하지만 솔라의 머릿속에서는 int와 String이 서로 다른 규칙을 따르는 장면이 겹쳐 보이며 혼란이 일었다. 숫자를 담는 변수 뒤에는 점을 찍고 무언가를 실행시킨 기억이 없었다.
솔라는 거실 소파에 앉아 책을 읽고 있던 루나에게 노트북을 들고 다가갔다.
“언니, 나 이거 이상해.”
루나는 책에서 눈을 떼고 솔라가 가리키는 화면을 보았다.
“어떤 게?”
“이거. String 말이야. int처럼 그냥 변수에 값을 담는 거 아니었어? int age = 20; 이렇게. 그런데 왜 String은 name.length()처럼 뒤에 점을 찍고 명령을 내릴 수가 있는 거야? age.length()는 안 되잖아. 꼭 System.out.println()처럼. String은 그냥 글자 덩어리인 줄 알았는데, 갑자기 무슨 기능을 가진 로봇처럼 행동하니까 헷갈려.”
솔라의 말에는 ‘나는 이렇게 이해했는데, 왜 다르게 움직이지?‘라는 날카로운 마찰이 담겨 있었다. 값을 담는 단순한 상자로 여겼던 것이 갑자기 스스로 무언가를 할 수 있다고 말하는 듯한 이질감이었다.
루나는 잠시 생각하더니, 테이블 위에 놓인 작은 보석 상자와 엽서 한 장을 집어 들었다.
“솔라, 이리 와서 앉아봐. 간단한 상상 놀이를 하나 해보자.”
루나는 먼저 작은 보석 상자를 솔라 앞에 놓았다. 그리고 작은 메모지에 숫자 ‘10’을 적어 그 안에 넣고 뚜껑을 닫았다.
“이 상자를 변수 age라고 생각해봐. int age = 10; 코드를 실행하면, age라는 이름의 상자 안에 숫자 10이 직접 들어가는 거야. 아주 간단하지. 상자 자체가 값을 품고 있어.”
솔라는 고개를 끄덕였다. 그건 자신이 이해한 그대로였다.
“좋아. 그럼 이번엔 String.”
루나는 ‘바다’라고 쓰인 풍경 사진 엽서를 테이블 건너편에 두었다. 엽서는 그 자체로 하나의 완성된 객체처럼 보였다. 이어서 루나는 작은 포스트잇 한 장을 떼어내 ‘저기 건너편에 있는 바다 엽서’라고 적고는, 솔라 앞에 놓인 빈 카드에 붙였다. 그리고 그 카드에 message라는 이름을 붙였다.
“이 카드가 변수 String message야. 이 message 카드 안을 들여다봐. 뭐가 들어있지?”
“음… ‘바다’라는 엽서 자체가 아니라, ‘저기 건너편에 있는 바다 엽서’라고 적힌 쪽지가 들어있네.”
“바로 그거야.” 루나가 말했다. “String 같은 자료형은 int처럼 값 자체를 변수 안에 담지 않아. 대신 실제 값, 그러니까 저 ‘바다’ 엽서가 어디에 있는지 알려주는 ‘주소’나 ‘쪽지’를 담고 있지. 이 쪽지를 ‘참조’라고 불러.”
솔라의 눈이 동그래졌다. 상자와 쪽지. 너무나 단순한 비유였지만, 머릿속을 맴돌던 혼란의 실체가 보이는 듯했다.
“그럼… String name = "솔라"; 라고 하면, name이라는 변수 상자 안에는 ‘솔라’라는 글자가 통째로 들어있는 게 아니라, 어딘가에 존재하는 ‘솔라’라는 글자 덩어리를 가리키는 쪽지가 들어있는 거구나!”
“정확해. 그리고 name.length()처럼 점을 찍고 메서드를 호출하는 건, name이라는 변수 자체가 아니라 그 변수가 가리키고 있는 저쪽의 실제 String 객체에게 ‘네가 가진 기능을 실행해줘’라고 명령하는 거야. 저 엽서에 ‘길이를 재는 기능’이 내장되어 있는 것처럼 말이지.”
솔라는 아, 하고 짧은 탄성을 뱉었다. 변수는 그저 심부름꾼이었을 뿐, 진짜 능력은 저 건너편의 객체가 가지고 있었다. 자신이 본 것은 변수의 능력이 아니라, 변수가 가리키는 대상의 능력이었던 것이다.
루나는 솔라가 스스로 결론에 도달하도록 한 박자 기다려주었다.
솔라는 스스로 자신의 생각을 정리했다. “그러니까 String은 단순한 값 덩어리가 아니라, 여러 기능을 가진 ‘객체’고, 변수는 그 객체를 가리키는 ‘참조’를 저장하는 거구나. 겉모습은 비슷해 보여도 속은 완전히 달랐던 거네.”
이제 String이 왜 메서드를 가질 수 있는지 명확해졌다. 그것은 더 이상 모순이 아니었다. ‘값을 담는 상자’와 ‘객체를 가리키는 쪽지’라는 두 가지 모델이 머릿속에 선명하게 구분되자, 이전의 혼란은 눈 녹듯 사라졌다. String은 처음부터 객체였고, 변수는 그 객체의 리모컨 같은 존재였다.
판단이 바뀌자 새로운 질문이 고개를 들었다.
“언니, 그럼 저 String이라는 객체는 ‘길이 재기’ 말고 또 어떤 기능들을 가지고 있어? 그리고 글자들을 서로 더할 때는 이 ‘참조’라는 게 어떻게 움직이는 거야?“
2장: 불변하는 String, 안전하게 다루는 법: 연결과 유용한 메서드
솔라의 질문이 끝나자마자, 그녀는 마치 답을 기다릴 수 없다는 듯 자신의 노트북을 다시 활짝 열었다. 화면에는 조금 전의 코드가 그대로 남아있었다. String name = "솔라"; 솔라는 이제 이 한 줄이 ‘솔라’라는 글자 덩어리 객체를 가리키는 name이라는 참조를 만드는 과정이라는 것을 알고 있었다.
호기심에 찬 솔라는 name 바로 뒤에 마침표(.)를 찍었다. 그러자 스크린에 작은 드롭다운 메뉴가 펼쳐지며 수많은 목록이 나타났다. charAt(), contains(), substring(), toUpperCase()… 마치 숨겨진 기능들의 카탈로그 같았다. 솔라는 스크롤을 내리며 목록을 훑어보았다. length()는 이 수많은 기능 중 하나에 불과했다. 객체를 가리킨다는 것이 어떤 의미인지 눈앞에서 펼쳐지는 광경에 잠시 넋을 잃었다.
“와, 이렇게나 많다고?”
솔라는 감탄하며 첫 번째 질문에 대한 답의 일부를 스스로 찾아냈다. 하지만 그녀의 진짜 궁금증은 두 번째 질문에 있었다. 바로 문자열을 ‘더하는’ 연산이었다. 솔라는 새로운 코드를 작성하기 시작했다.
String str1 = "안녕";
String str2 = str1 + ", 솔라!";
코드를 작성한 솔라는 잠시 고민에 빠졌다. 첫 번째 줄은 ‘안녕’이라는 String 객체를 만들고 str1이 그 객체를 가리키게 한다. 여기까지는 이제 명확했다. 문제는 두 번째 줄이었다.
“언니, 이거 봐봐.” 솔라가 루나를 불렀다. “str1에 ‘, 솔라!’를 더해서 str2에 넣었잖아. 그럼 원래 str1이 가리키던 ‘안녕’ 객체에 내용이 추가돼서 ‘안녕, 솔라!’가 되고, str2는 그 바뀐 객체를 같이 가리키게 되는 거 아닐까?”
솔라의 추측은 지극히 합리적이었다. 이미 있는 것에 무언가를 더하면, 원래 있던 것이 변하는 게 당연하게 느껴졌다. 값을 직접 담던 int의 세계에서는 number = number + 1;이 number 상자 안의 숫자를 직접 바꾸는 일이었으니까.
루나는 솔라의 말을 조용히 듣고는, 옆에 있던 메모지를 가져와 펜으로 그림을 그리기 시작했다. 지난번의 상자와 쪽지 비유를 이어가는 듯했다.
“네 생각대로 한번 따라가 보자.”
루나는 먼저 ‘안녕’이라고 쓰인 엽서 모양의 그림을 그렸다. 그리고 str1이라고 이름 붙인 네모 상자를 그리고, 그 안에서 엽서를 향해 화살표를 그었다. 이것이 첫 번째 줄의 상태였다.
“자, 이제 두 번째 줄. 네 말대로라면, str1이 가리키던 저 ‘안녕’ 엽서에 ‘, 솔라!’라는 글씨를 덧붙여야겠지?”
루나는 펜을 들어 ‘안녕’ 엽서에 덧붙여 쓰려는 시늉을 했다. 하지만 이내 펜을 멈추고는 말했다.
“그런데 만약 이 엽서가 이미 코팅되어 있어서 아무것도 더 쓸 수 없다면 어떡하지? 한 번 만들어지면 절대 바꿀 수 없는, 박제된 나비처럼 말이야.”
“더 쓸 수 없다고? 그럼 어떻게 더해?” 솔라가 되물었다. 그녀의 머릿속에서는 ‘수정’의 개념이 가로막힌 것이다.
“새로 만드는 거야.”
루나는 ‘안녕’ 엽서는 그대로 둔 채, 그 옆에 더 큰 엽서를 새로 그렸다. 그리고 그 안에는 ‘안녕, 솔라!’라고 처음부터 다시 적었다.
“String 객체는 바로 이 코팅된 엽서 같아. 한 번 만들어진 내용은 절대 변하지 않아. 이걸 ‘불변(immutable)’하다고 해. 그래서 + 연산은 기존의 String 객체를 수정하는 게 아니라, 두 내용을 합친 완전히 새로운 String 객체를 만드는 작업이야.”
루나는 말을 이으며 그림을 완성했다. 새로 그린 ‘안녕, 솔라!’ 엽서를 향해, str2라고 이름 붙인 두 번째 네모 상자에서 뻗어 나온 화살표를 그렸다.
솔라는 루나가 그린 그림을 뚫어지게 쳐다봤다. 그림 속에서 str1의 화살표는 여전히 처음의 ‘안녕’ 엽서를 꿋꿋이 가리키고 있었다. str2의 화살표만이 새로운 ‘안녕, 솔라!’ 엽서를 가리킬 뿐이었다. str1은 전혀 영향을 받지 않았다.
“아…! 더하는 게 아니라, 합쳐서 새 걸 만드는 거구나. 그럼 원래 있던 str1은 그대로 ‘안녕’인 상태로 남아있는 거고?”
“직접 확인해볼까?”
솔라는 재빨리 노트북에 코드를 추가했다.
System.out.println(str1);
System.out.println(str2);
실행 결과는 명확했다.
안녕
안녕, 솔라!
화면에 찍힌 두 줄의 결과는 루나의 그림을 그대로 증명하고 있었다. str1은 변하지 않았다. 솔라의 이전 모델, 즉 ‘참조하는 객체의 내용이 바뀔 것이다’라는 예측은 완전히 빗나갔다. String을 다루는 방식은 값을 수정하는 것이 아니라, 새로운 결과를 만들어내는 방식이었다.
솔라는 자신의 판단이 바뀌는 것을 느꼈다. “그럼 아까 봤던 substring()이나 toUpperCase() 같은 다른 메서드들도 마찬가지겠네? 원래 객체를 바꾸는 게 아니라, 잘라낸 새로운 문자열 객체나 대문자로 바뀐 새로운 문자열 객체를 만들어서 돌려주는 거구나.”
루나는 조용히 고개를 끄덕였다.
String의 불변성. 이 하나의 원칙이 모든 메서드와 연산의 동작 방식을 관통하고 있었다. 이제 String을 다루는 것은 더 이상 혼란스러운 마법이 아니었다. 기존의 것을 건드리지 않고 항상 새로운 결과물을 만들어내는, 예측 가능하고 안전한 규칙이었다.
판단이 명확해지자, 또 다른 날카로운 질문이 이전의 혼란이 있던 자리를 파고들었다.
“언니, 그럼 이상한데. 만약에 String a = "안녕"; 이랑 String b = "안녕"; 이렇게 똑같은 내용으로 두 개를 만들면, 메모리에는 ‘안녕’ 엽서가 한 장만 있고 둘 다 그걸 가리키는 거야, 아니면 똑같이 생긴 ‘안녕’ 엽서가 두 장 생기는 거야? 만약 두 장이라면, 이 둘이 같은지 비교할 때 == 이걸 쓰면… 이건 쪽지를 비교하는 거야, 엽서 내용을 비교하는 거야?”
참조와 객체, 그리고 불변성까지 이해하고 나니, ‘비교’라는 가장 근본적인 연산의 작동 방식에 새로운 의문이 생겨난 것이다.
3장: 헷갈리는 문자열 비교, 이제는 확실하게: ==와 equals()
이전의 발견으로 얻은 명쾌함은 솔라를 곧바로 다음 행동으로 이끌었다. 그녀는 이전 대화의 그림과 메모가 펼쳐진 테이블 옆에서, 다시 노트북을 끌어당겼다. 새로운 의문은 더 이상 막연한 혼란이 아니었다. 그것은 명확한 가설을 세우고 직접 부딪쳐 확인할 수 있는 구체적인 실험 대상이었다. 솔라의 손가락이 키보드 위에서 빠르게 움직였다.
화면에 새로운 코드 몇 줄이 나타났다. 솔라는 자신의 질문을 코드로 번역하고 있었다. 똑같은 내용의 String 두 개를 만들면, 메모리에서는 어떤 일이 벌어질까? 그리고 그 둘은 ‘같다’고 할 수 있을까?
String a = "안녕";
String b = "안녕";
// 실험 1: 똑같이 생긴 엽서를 가리키는 두 개의 쪽지
System.out.println(a == b);
코드를 작성하며 솔라의 머릿속에는 이미 하나의 시나리오가 그려지고 있었다. ‘안녕’이라는 내용의 엽서는 자원을 아끼기 위해 한 장만 만들어두고, 변수 a와 b는 모두 그 한 장짜리 엽서를 가리키는 쪽지를 받게 될 것이다. 그렇다면 두 쪽지의 내용은 동일할 테니, 결과는 true가 나와야 했다. 그녀는 int x = 10; int y = 10; 일 때 x == y 가 true였던 기억을 떠올렸다. String도 비슷하게 동작하지 않을까?
하지만 곧바로 솔라는 자신의 가설에 허점을 만들기 위한 두 번째 실험을 설계했다.
String c = new String("안녕");
// 실험 2: 한 쪽은 기존 엽서, 다른 한 쪽은 새로 주문 제작한 엽서
System.out.println(a == c);
‘new’ 키워드는 ‘새로운 객체를 만들어달라’는 명시적인 요청이다. 솔라는 이 코드가 ‘안녕’이라고 똑같이 쓰여있지만, 기성품이 아닌 특별 주문으로 제작된 새로운 엽서를 만드는 것과 같다고 추론했다. 그렇다면 변수 c가 가진 쪽지는 a가 가진 쪽지와는 다른, 새로운 엽서를 가리킬 것이다. a와 c가 가리키는 엽서의 ‘내용’은 같지만, 엽서 ‘자체’는 다른 것. 그렇다면 이 비교의 결과는 어떻게 될까?
솔라는 숨을 참고 실행 버튼을 눌렀다. 결과는 즉시 콘솔 창에 나타났다.
true
false
예상과 현실이 반씩 섞인 결과였다. 첫 번째 true는 그녀의 가설을 지지했지만, 두 번째 false는 == 연산자의 작동 방식에 대한 근본적인 질문을 던졌다.
“언니, 이거 봐. 내 생각이 반은 맞고 반은 틀렸어.”
솔라의 목소리에는 실망감보다 지적인 흥분이 묻어났다. 루나는 그림을 그리던 메모지를 내려놓고 솔라의 노트북 화면을 들여다보았다.
“어떤 부분이 맞았고, 어떤 부분이 틀렸다고 생각해?” 루나가 되물었다.
“a == b가 true인 걸 보면, 그냥 "안녕"이라고 쓸 때는 정말 똑같은 객체 하나를 공유해서 쓰는 게 맞는 것 같아. 컴퓨터가 똑똑하게 자원을 아끼는 거지. 그런데 new String("안녕")으로 만든 c는, 내용이 똑같은데도 a랑 다르대. false가 나왔어. ==는 대체 뭘 비교하는 걸까? 엽서에 쓰인 글씨를 비교하는 게 아니었어?”
솔라는 ‘값을 담는 상자’ 모델에서 ==가 상자 안의 값을 비교했던 것을 기억하고 있었다. 하지만 이제 변수가 ‘객체를 가리키는 쪽지’라는 것을 안 이상, ==의 역할도 재정의되어야 했다. 그녀의 이전 모델이 다시 한번 흔들리고 있었다.
루나는 말없이 펜을 들어 이전의 엽서 그림 옆에 새로운 그림을 그리기 시작했다.
“우리가 계속 이야기했던 ‘쪽지’와 ‘엽서’로 다시 한번 보자.”
루나는 먼저 특별한 공간처럼 보이는 점선으로 된 상자를 하나 그렸다. 그리고 그 안에 ‘안녕’이라고 쓰인 엽서 그림을 딱 하나만 그렸다.
“여기는 특별한 ‘공유 엽서 보관함’ 같은 곳이야. 코드를 쓸 때 "안녕"처럼 큰따옴표로 문자열을 만들면, 자바는 먼저 이 보관함에 똑같은 내용의 엽서가 있는지 확인해. 있으면 새 엽서를 만들지 않고 기존 엽서를 가리키는 쪽지를 줘.”
루나는 a와 b라는 네모 상자를 그리고, 두 상자 모두에서 화살표를 그려 공유 보관함 안의 ‘안녕’ 엽서를 가리키게 했다.
“a와 b가 받은 쪽지는 둘 다 ‘공유 보관함 속 1번 엽서’를 가리키지. 두 쪽지에 적힌 주소는 완전히 똑같아. == 연산자는 바로 이 쪽지에 적힌 주소를 비교하는 거야. 그러니 결과가 true가 나온 거지.”
솔라의 눈빛이 흔들렸다. “아, ==는 엽서의 내용이 아니라 쪽지의 주소를 비교하는 거구나!”
“그럼 두 번째 실험은 어떨까?” 루나는 말을 이었다. “new String("안녕")은 ‘보관함에 있든 없든 무조건 새로 하나 만들어 줘!’라는 강력한 요청이야.”
루나는 공유 보관함 바깥쪽에 ‘안녕’이라고 쓰인 엽서를 하나 더 그렸다. 이것은 주문 제작된 새 엽서였다. 그리고 c라는 네모 상자를 그려, 그곳에서 새로 그린 엽서를 향해 화살표를 이었다.
이제 그림은 명확해졌다. a의 화살표는 보관함 안의 엽서를, c의 화살표는 보관함 밖의 새 엽서를 가리키고 있었다. 두 엽서에 적힌 내용은 ‘안녕’으로 같았지만, 두 엽서는 명백히 서로 다른 위치에 있는 별개의 존재였다.
“a가 가진 쪽지와 c가 가진 쪽지는 서로 다른 엽서를 가리키고 있으니, 주소 값이 다르지. 그래서 a == c는 false가 되는 거야.”
솔라는 그림과 코드의 결과를 번갈아 보았다. 모든 조각이 맞춰지는 순간이었다. 혼란의 원인은 ==가 내용 비교를 할 것이라는 섣부른 기대감 때문이었다. 참조형 변수에게 ==는 ‘두 변수가 정확히 동일한 단 하나의 객체를 가리키고 있는가?’를 묻는, 훨씬 더 엄격한 질문이었던 것이다.
“그럼… 그럼 엽서에 적힌 내용이 같은지는 어떻게 확인해? 로그인할 때 아이디나 비밀번호가 맞는지 확인할 때는 주소가 아니라 내용이 같은지가 중요하잖아.”
“바로 그럴 때 쓰라고 엽서 자체에 기능이 내장되어 있지.” 루나가 말했다. “쪽지가 아니라, 엽서에게 직접 물어보는 거야. ‘네 몸에 적힌 글자들이 저쪽 엽서에 적힌 글자들이랑 똑같니?’라고.”
솔라는 아! 하는 소리를 내며 자신의 코드에 무언가를 추가하기 시작했다. == 연산자를 지우고, 마침표를 찍자 나타났던 수많은 기능 목록을 떠올렸다. 그중 가장 그럴듯한 이름을 가진 메서드를 찾아 입력했다.
System.out.println(a.equals(c));
엔터를 누르자, 콘솔 창에 새로운 결과가 찍혔다.
true
equals. ‘동등하다’는 뜻의 이 메서드는, 변수가 가리키는 주소와 상관없이 오직 객체의 ‘내용’만을 비교하여 결과를 알려주었다. 비로소 솔라는 예측 가능하고 안전하게 문자열의 내용을 비교할 수 있는 방법을 찾은 것이다.
판단이 명확해지자, 솔라는 어지럽게 열어두었던 코드 파일들을 닫고 깨끗한 텍스트 편집기 창을 하나 열었다. 그리고 스스로를 위한 ‘안전한 String 사용 가이드라인’ 초안을 작성하기 시작했다. 그것은 더 이상 누군가의 설명을 듣고 정리하는 것이 아닌, 자신의 실험과 실패, 그리고 깨달음으로 얻어낸 단단한 규칙이었다.
[솔라의 String 탐구 노트]
-
String 비교 절대 원칙:
equals()를 사용한다.- 문자열의 ‘내용’이 같은지 확인하고 싶을 때는 무조건
equals()메서드를 호출해야 한다. id.equals("sola")(O)
- 문자열의 ‘내용’이 같은지 확인하고 싶을 때는 무조건
-
==연산자는 함정일 수 있다.==는 두 변수가 가리키는 메모리 주소(객체 자체)가 같은지 비교한다.- 내용이 같아도
new String()으로 만들면false가 나올 수 있어 위험하다. 로그인 로직에==를 썼다간 큰일 날 것이다!
노트를 작성하는 솔라의 얼굴에는 더 이상 혼란의 그림자가 없었다. 겉모습과 실제의 차이, 참조와 값의 구분, 그리고 불변성이라는 개념들을 통과하며 얻어낸 명확한 판단 기준이 그녀의 손끝에서 단단한 문장으로 기록되고 있었다. 이제 그녀에게 String은 더 이상 헷갈리는 존재가 아니었다. 그저 안전하게 사용하기 위한 명확한 규칙을 가진, 강력하고 유용한 도구일 뿐이었다.