한 번에 이해하는 파이썬 ‘문자열 자르기(Slicing)’
파이썬은 데이터를 분석하는 과정에서 목적에 따라, 데이터를 처리하기 위한 매우 편리하고 다양한 기능을 제공합니다. 그중에서도 문자열 자르기(슬라이싱, Slicing)은 데이터의 형태나 도메인과 관계없이 핵심적인 역할을 수행하죠. 사용자는 문자열 슬라이싱을 통해 문자열의 특정 부분을 효율적으로 추출하거나, 조작할 수 있으며, 이는 데이터 분석, 웹 애플리케이션 개발, 자동화 스크립트 등 다양한 영역에서 필수적인 기술로 쓰입니다.
파이썬을 사용해 문자열의 일부분을 잘라내는 기본적인 사용법, 그리고 구체적인 작동 원리를 이해하는 것은 더욱 강력하고 유연한 코드를 작성하는 데 있어 결정적인 차이를 만듭니다. 이번 글에서는 파이썬의 문자열 슬라이싱의 기본 문법부터, C언어로 구현된 CPython의 내부 로직까지 깊게 살펴보겠습니다.

문자열 슬라이싱 사용 방법
우선 파이썬 문자열 슬라이싱은 콜론(:)을 사용하여, 문자열의 일부분을 선택하는 방식으로 이루어집니다. 기본적인 문법은 오브젝트[시작:끝:증감] 형태를 가지며, 각 인덱스(요소)는 슬라이스의 범위를 정의하는 데 중요한 역할을 합니다.
시작(start) 인덱스는 슬라이스가 시작되는 위치를 나타냅니다. 주의할 점으로, 이 위치에 해당하는 문자는 슬라이스 결과물에 포함됩니다. 만약 시작 인덱스가 생략되면 파이썬은 0을 값으로 사용, 즉, 문자열의 처음부터 슬라이스를 시작합니다.
이어서 끝(end) 인덱스는 슬라이스가 끝나는 위치를 나타냅니다. 시작 인덱스와의 차이점으로 이 위치에 해당하는 문자는 슬라이스 결과에 포함되지 않는다는 것입니다. 끝 인덱스가 주어지지 않는다면, 슬라이스는 문자열의 마지막 문자까지 포함합니다.
마지막으로 스텝(step) 값은 슬라이스 과정에서 문자를 건너뛸 간격을 지정합니다. 이 값은 선택 사항이며, 입력되지 않는다면 1을 기본값으로 사용하여 모든 문자를 순차적으로 선택합니다.
이어서 양수 및 음수 인덱스를 활용한 슬라이싱 예시를 보겠습니다.
- 양수 인덱스: 문자열의 처음부터 인덱싱하며, 0부터 시작합니다. 예를 들어, 문자열 s = “yozm.wishket.com”에서 s[5:12]는 인덱스 5('w')부터 11('t')까지의 부분 문자열 "wishket"을 반환합니다.
- 음수 인덱스: 문자열의 끝에서부터 인덱싱하며, -1부터 시작합니다. 예를 들어, 동일한 문자열 s에서 s[-6:-1]는 뒤에서 6번째 문자('e')부터 뒤에서 2번째 문자('o')까지의 부분 문자열 "et.co"를 반환합니다.

스텝을 사용하면 더욱 다양한 방식으로 문자열을 슬라이스할 수 있습니다.
- 양수 스텝: s[2:8:2]는 인덱스 2부터 8까지 2개 간격으로 문자를 추출하여 "z.i"를 반환합니다.
- 음수 스텝: s[6:1:-2]는 인덱스 6부터 1까지 역순으로 2개 간격으로 문자를 추출하여 "i.z"를 반환합니다. 특히, s[::-1]를 사용하면 텍스트 전체를 역순으로 슬라이스하여 "moc.tekhsiw.mzoy"를 얻을 수 있습니다.

문자열 슬라이싱은 실제 다양한 방식으로 활용될 수 있으며, 몇 가지 구체적인 예시는 다음과 같습니다.
- 특정 패턴 추출: 파일 경로에서 파일명이나 확장자를 추출하거나, URL에서 특정 부분을 분리하는 데 유용합니다. 예를 들어, 파일 경로 "C:/Users/Public/Documents/report.pdf"에서 파일명 "report"를 추출하기 위해 슬라이싱을 활용할 수 있습니다.
- 문자열 일부 수정 (새로운 문자열 생성): 파이썬 문자열은 불변(immutable)하므로, 슬라이싱을 통해 기존 문자열의 일부를 기반으로 새로운 문자열을 생성합니다. 예를 들어, 문자열 "Hello"의 첫 글자를 "J"로 바꾸려면, "J" + "Hello"[1:]와 같이 슬라이싱과 문자열 연결을 사용해야만 합니다.
- 문자열 역순 만들기: 텍스트 분석이나 데이터 처리 시 문자열을 역순으로 정렬해야 하는 경우가 있습니다. 앞서 소개한 [::-1] 슬라이스를 사용하면 간편하게 문자열을 뒤집을 수 있습니다.
불변 문자열 객체
불변이란 원래의 오브젝트의 내용을 변경할 수 없음을 의미합니다. 만약 s = ‘www.wishket.com’ 이라는 코드를 실행하면 ‘yozm.wishket.com’에서 s의 내용이 변경되고, 문제가 발생하지 않기에 내용이 변경되는 것으로 오해할 수 있는데요. 이는 메모리에 ‘www.wishket.com’이라는 문자열 오브젝트를 새롭게 만들고, 이 주소를 s에 새롭게 할당한 것입니다.

그렇기에 문자열의 내부를 변경하면 의도한 대로 불변을 변경하는 과정에서의 오류를 확인할 수 있습니다.

이 불변의 개념이 문자열 슬라이싱에서 중요한 이유는, 슬라이싱 또한 불변인 원본 문자열을 변경하지 않으면서, 문자열의 일부를 사용하여 새로운 문자열을 만드는 작업이기 때문입니다. 하지만 파이썬, 엄밀히는 CPython에서는 가능하면 메모리 복사를 피하고, 불필요한 객체 생성을 줄이기 위한 여러 최적화 전략들이 코드에 녹아있습니다. 이제 실제로 CPython이 이 슬라이싱을 처리하는 과정을 내부 코드와 함께 알아보겠습니다.
파이썬 텍스트 객체의 슬라이싱 연산은 객체의 __getitem__ 메소드에 slice 객체를 전달하여 이루어집니다. 즉, 앞서 본 s[5:12]와 아래의 slice 객체를 사용하는 코드는 동일한 역할을 합니다.

CPython에서는 unicode_subscript라는 함수를 통해 이 동작을 구현합니다. 이 함수는 인덱싱과 슬라이싱을 모두 처리하는데, 전달된 item이 정수 인덱스라면 개별 문자를 반환하고 slice 객체라면 하위 문자열을 반환하도록 되어 있습니다. (cpython 코드 원본 참고)
이어지는 코드는 슬라이싱하는 상황(slice 객체를 사용)의 핵심 로직 중 일부로, 먼저 슬라이스 범위에 해당하는 길이 slicelength를 계산한 다음, 이 값에 따라 세 갈래의 처리를 합니다.

1. 빈 문자열 슬라이싱: 만약 slicelength가 0 이하라면 즉, 슬라이싱 결과가 빈 문자열이라면 _Py_RETURN_UNICODE_EMPTY()를 호출합니다. 이 함수는 이름에서 의미하듯 비어있는 unicode를 반환하는 함수인데, 전역적으로 미리 정의된 빈 문자열 객체를 반환합니다. 즉, 임의의 문자열 s에 대해 s[1:1]처럼 정의할 수 있는 빈 문자열 “”는 CPython에 미리 하나만 생성해 두고, 모든 곳에서 참조하는 싱글톤(singleton)이기 때문에, 불필요하게 매번 새로운 객체를 만들지 않습니다.

2. 전체 문자열 슬라이싱: start == 0, step == 1 그리고 원본 문자열의 길이와 slicelength가 같아 전체 문자열을 슬라이싱 하는 경우, 새로운 객체를 만들지 않고 원본 문자열 객체를 그대로 반환합니다. 이 방법 덕분에 아래와 같이 문자열을 같은 값으로 새롭게 정의하는 것은 다른 오브젝트로 계산되지만, 전체 문자열을 슬라이싱하는 정의 방식은 같은 오브젝트로 계산됩니다. 이를 통해 불필요한 메모리 복사를 아낄 수 있습니다.

3. 부분 문자열 슬라이싱 (새 객체 생성): 앞서 다룬 두 가지 외의 일반적인 슬라이싱을 하는 경우로, 슬라이싱한 내용을 담은 새로운 문자열 객체를 생성합니다. 여기서도 한가지 눈여겨 볼 부분은 코드가 step == 1인 경우와 step != 1인 경우로 나뉘는 것입니다.
- step == 1인 경우: PyUnicode_Substring이라는 함수를 통해 원본 문자열의 start부터 slicelenth만큼을 읽어들여 새로운 오브젝트를 할당하고 해당 메모리에 문자열 데이터를 복사합니다.
- step != 1인 경우: 먼저 slicelength만큼의 버퍼 영역을 메모리 할당한 뒤 원본 문자열에서 해당 인덱스의 문자들을 하나씩 복사합니다. 이어 버퍼로부터 문자열 객체를 생성하고, 버퍼를 해제합니다.


다시 정리하면, 미리 “상수”처럼 정의된 빈 문자열을 반환하거나, 전체 문자열을 그대로 슬라이싱하여 원본 객체를 반환하는 경우가 아니라면 슬라이싱은 항상 새로운 문자열 객체를 생성하고, 내용 문자를 복사하여 채웁니다. 이는 o(n) 시간 복잡도를 갖습니다. (시간 복잡도에 대해서는아티클을 참고하세요.)
슬라이싱 결과 객체의 메모리 관리, 최적화
위에서 살펴본 구현으로부터, 슬라이싱 결과 문자열은 원본과 분리된 별도의 객체임을 알 수 있습니다. 따라서 전체 슬라이스의 예외를 빼면 원본 문자열 객체는 슬라이싱 후에도 참조 카운트가 변하지 않으며, 새로 만들어진 하위 문자열 객체의 수명이 독자적으로 관리됩니다. 이러한 설계에는 여러 가지 이유와 부가적인 내부 최적화가 존재합니다.
- 부분 슬라이스의 메모리 복사: 불변 객체인 문자열은 이론적으로는 뷰(view) 형태로 구현할 수도 있습니다. 예컨대 C나 Java의 일부 구현처럼, 원본 문자열 버퍼를 공유하면서 시작 오프셋과 길이만 따로 관리하는 방식도 생각해 볼 수 있습니다. 그러나 CPython은 이런 방식을 택하지 않았습니다. 왜냐하면 작은 부분 문자열이 큰 원본을 참조할 경우, 부분 문자열이 살아있는 한 거대한 원본 메모리를 해제하지 못해 메모리 누수를 유발할 수 있기 때문입니다. (참고)
CPython의 가비지 컬렉션은 기본적으로 참조 카운트에 기반하므로, 부분 문자열이 원본을 참조하면 원본 객체는 참조 카운트가 남아 메모리가 유지됩니다. 따라서 작은 조각을 쓰기 위해 거대한 문자열 전체를 메모리에 붙잡아 두는 상황이 발생할 수 있습니다.
이러한 이유로 CPython은 안전하고 단순한 복사를 통한 구현을 선택했습니다.

- 빈 문자열과 단일 문자 캐싱: CPython은 특정 문자열들을 전역적으로 선언, 미리 캐싱하여 매번 새로 할당하지 않도록 최적화합니다. 대표적인 것이 앞서 살펴본 빈 문자열 "" 그리고 추가로 자주 쓰이는 한 글자 문자열들입니다. 앞서 설명했듯, 빈 문자열은 unicode_empty라는 전역 싱글톤 객체로 관리되며 슬라이싱 결과가 빈 문자열이면 이 객체를 반환합니다. 또한 라틴-1 범위 (U+0000 ~ U+00FF)의 단일 문자 문자열에 대해서는 미리 256개를 만들어 unicode_latin1 배열에 캐싱해 사용합니다.
예를 들어, 슬라이싱 결과가 "A"나 "5"처럼 한 글자인 경우, 그리고 그 문자의 코드 포인트가 0~255 사이라면 CPython은 새로운 객체를 만들지 않고 이미 준비된 해당 문자를 가리키는 문자열 객체를 반환합니다.
따라서 "a"[0:1], "a"[::] 등으로 'a'를 얻으면 항상 동일한 내부 객체를 참조하게 됩니다. 반면 U+0100(256) 이상의 문자에 대해서는 기본 설정상 캐싱 되지 않아 같은 문자라도 새로운 객체가 생성됩니다.


- 기타 메모리 관리 (프리리스트): CPython은 작은 Unicode 객체 생성을 빠르게 하기 위해 프리리스트(freelist)도 사용합니다. 일정 크기 이하(예: 길이 1~8)의 Unicode 객체들은 해제 시 완전히 메모리를 반환하지 않고 내부 free 리스트에 보관했다가, 새로운 문자열 생성 시 재사용하는 최적화가 있습니다. 이 방법은 슬라이싱뿐만 아니라 모든 작은 문자열 생성에 적용되며, 메모리 할당/해제의 오버헤드를 줄여줍니다. 다만 이 부분은 CPython의 내부 구현 최적화이며, 외부에서 직접적으로 확인하기는 어렵습니다.

예제로 보는 내부 처리 흐름
위 내용을 바탕으로, 실제 파이썬 코드 예제가 내부에서 어떻게 처리되는지 다시 한번 따라가 보겠습니다. 예시로는 아래의 코드를 사용합니다.

이 코드에 대한 CPython 내부 처리 과정은 다음과 같습니다.
- sub1 = s[0:]: 이 슬라이스는 문자열 전체(‘Python Substring Example’)를 반환하므로, unicode_subscript 함수에서 두 번째 조건에 부합합니다. 따라서 CPython은 s 객체 자체를 반환합니다. 결과적으로 sub1 is s는 두 객체가 같은 객체이므로 True가 되고, id(sub1)과 id(s)도 동일합니다. 또한 새로운 메모리 할당이 일어나지 않으므로, 메모리 사용량 면에서도 이 연산은 저렴합니다(참조 카운트만 증가).
- sub2 = s[7:16]: 부분 문자열 "Substring"을 추출하는 슬라이스입니다. 이 경우 step=1이지만 전체 길이가 아니므로, unicode_subscript는 새로운 Unicode 객체를 생성하게 됩니다. 내부적으로 PyUnicode_FromUnicode(s->str + 7, 9) (9글자 길이)가 호출되고, 원본 s의 데이터 중 7번 인덱스부터 9개의 문자를 메모리 복사하여 새 버퍼에 채웁니다. 이 버퍼로 PyUnicodeObject 구조체가 할당되어 sub2를 가리키게 됩니다. 이제 sub2는 s와 다른 객체이며(sub2 is s는 False), id(sub2)도 다릅니다. 그러나 sub2의 내용은 "Substring"으로 원본의 해당 부분과 동일합니다. 원본 s와 sub2는 메모리를 공유하지 않으므로, 설령 이후에 s가 참조 회수가 없어 가비지 컬렉션 되어도 sub2는 독립적으로 내용을 유지합니다.
- sub3 = s[::2]: 이 슬라이스는 처음부터 끝까지 step=2로 (0, 2 , 4, ...) 문자를 취합니다. 구현상 step != 1이므로, CPython은 slicelength를 계산한 후 그 길이만큼 버퍼를 할당하고, 원본의 해당 인덱스 문자들을 하나씩 복사합니다. 이 예에서 s의 짝수 인덱스 문자를 모아 "Pto usrn xml" 같은 결과를 얻게 될 것입니다. 이 역시 새로운 객체 sub3를 생성하며, 메모리 복사가 발생합니다. 다만 step=2라 연속되지 않은 메모리 접근이지만, 여전히 복사 비용은 결과 문자열 길이에 비례합니다.
마치며
이렇게 파이썬 문자열 슬라이싱의 기본적인 문법부터 시작하여, 인덱싱 방식, 파라미터의 역할, 문자열 불변성의 개념, 그리고 CPython 내부 구현까지 깊이 있게 살펴보았는데요.
문자열 슬라이싱은 파이썬에서 텍스트 데이터를 효율적으로 처리하기 위한 강력한 도구이며, 그 작동 원리를 정확히 이해하는 것은 사용자가 더욱 효과적이고 유연한 코드를 작성하는 데 필수적입니다. 기본 문법과 다양한 활용법을 숙지하고, 인덱싱 방식과 파라미터의 역할을 명확히 이해하며, 문자열의 불변성이라는 핵심 개념을 기억한다면, 파이썬을 이용한 문자열 조작 능력을 한 단계 더 향상시킬 수 있을 겁니다.
더 나아가 CPython 소스 코드 탐색을 통해, 슬라이싱의 내부 작동 방식을 이해한다면 파이썬 언어 자체에 대한 깊이 있는 통찰력을 얻을 수 있죠. 앞으로도 파이썬 문서, 튜토리얼, 그리고 다양한 문제 해결을 통해 문자열 슬라이싱을 포함한 프로그래밍 능력을 더욱 발전시킬 수 있길 기대합니다.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.