본문 바로가기

C 언어

예제로 보는 strtok 함수의 기능과 동작 원리

 

아래 예제 코드는 문자열 myString에서 "x"를 기준으로 해당 문자열을 잘라내 배열을 만든 후 사전순으로 정렬한 배열을 return 하는 solution 함수이다.

 

해당 코드에서 나는 strtok 함수를 사용했는데, 함수 사용이 익숙하지 않고 동작방식을 몰라 정리해서 기록하기로 하였다.특히, 문자를 자르는 함수라는건 알겠는데, 이 함수가 어떻게 구분자가 여러개 있으면 여러번 문자를 자를 수 있는지 이해가 잘 가지 않았다. 나를 포함해 strtok의 사용법이 익숙하지 않은 이들을 위해 strtok의 기능과 동작 방식을 설명해보겠다. 

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>

// 파라미터로 주어지는 문자열은 const로 주어집니다. 변경하려면 문자열을 복사해서 사용하세요.
char** solution(const char* myString, int* result_len) {
    // return 값은 malloc 등 동적 할당을 사용해주세요. 할당 길이는 상황에 맞게 변경해주세요.
    char* temp = strdup(myString); 
    char** answer = (char**)malloc(sizeof(char*) * (strlen(myString) + 1)); 
    size_t myString_len = strlen(myString);
    char* token = strtok(temp, "x"); 
    int index = 0;
    
    while(token != NULL) {
        answer[index] = (char*)malloc(sizeof(char)* strlen(token)+1); 
        strcpy(answer[index],token); 
        index++;
        token = strtok(NULL, "x"); //그 다음 토큰을 가져옴 
    }
    
    char* sort = (char*)malloc(sizeof(char)* 100); 
    for(int i=0; i<index-1; i++) {
        for(int j=i+1; j<index; j++){
            if(strcmp(answer[i],answer[j]) > 0) {
                strcpy(sort, answer[i]);
                strcpy(answer[i], answer[j]);
                strcpy(answer[j], sort);
            }    
          }
        }
        result_len = index; 
        free(temp);
        return answer;
}

int main(void) {
    const char* myString = "axbxcxdx";
    int result_len = 0; 
    char** result = solution(myString, &result_len);
    
    if(result != NULL) {
        for(int i=0; i<result_len; i++){
            printf("%s", result[i]);   
            free(result[i]);
        }
    }
    free(result);
    return 0; 
}

strtok 함수의 기능

char* temp = "axbxcxdx"; 

#문법
char *strtok(검수할 문자열, 구분자);

#예제
char* token = strtok(temp, "x");

 

strtok 함수는 문자열을 구분자("x")를 기준으로 쪼갠 후, 각 조각을 "차례대로" 반환한다. 

차례대로라는 뜻이 구분자가 5개 있으면 문자를 5번 쪼개고, 10개가 있으면 10번을 쪼갤 수 있다는 것이다. 

 

그렇다면 strtok  첫 번째 호출 시  temp 문자열에 있는 첫 번째 "x" 를 찾아 문자열을 자르는 것 까지는 알겠는데,

그 다음 구분자는 NULL을 호출하는 것만으로 어떻게 호출해서 그 다음 구분자를 찾아서 문자를 자를 수 있는걸까? 

token = strtok(NULL, "x"); //그 다음 토큰을 가져오기 위해 NULL을 호출함

 

strtok는 내부적으로 문자열의 상태를 전역 또는 정적 변수에 저장해 둔다. 전역이나 정적 변수는 프로그램이 종료될 떄까지 그 값이 유지된다는 특징이 있는데, 이 점 때문에  두 번째 호출에서 NULL을 넘겨주더라도 컴퓨터에서는 이전 호출에서 남겨둔 문자열의 위치를 기억하고 이어서 동작할 수 있는 것이다. 이 동작 방식에 대해서는 밑에서 더 자세히 설명하겠다.

strtok 구분자는 왜 더블 따옴표일까? 

눈썰미가 좋은 이들은 이미 알아차렸을지도 모른다.

c언어에서 단일 문자는 싱글 따옴표로 표시한다. (e.g. 'x' , 'a'..)

그런데 왜 구분자 "x"는 왜 더블 따옴표로 표시해야 하는걸까? 

char* token = strtok(temp, "x");

 

strtok() 함수는 여러 개의 구분자를 사용할 수 있게끔 설계되었기 떄문에 strtok는 두 번째 인자를 구분자 문자열로 받기를 기대한다.   "x" 를 매개 변수로 넘겼을 때와, 'x' 를 넘겼을 때 컴퓨터가 받을 수 있는 정보는 완전히 다르다고 봐야한다.왜냐하면 "x"는 포인터로 문자 x와 그 뒤에 오는 널 문자(\0)로 구성된 문자열을 strtok()에 넘기는 것이고 'x'는 단일 문자 x만 넘기는 것이기 떄문이다. 

  • 'x': 단일 문자, char 타입. strtok()에서는 사용할 수 없다.
  • "x": 문자열, char* 타입. strtok()의 구분자로 사용해야 한다.

strtok는 구분자 중 하나라도 발견되면 문자열을 분리한다. 

예를 들어, strtok에서 "abc"라는 구분자를 전달하면, 'a', 'b', 'c'는 모두 구분자로 처리되며, 이 중 하나라도 나타나면 문자열을 분리한다. 

#include <stdio.h>
#include <string.h>

int main() {
    char str[] = "applebananaorange";
    char* token = strtok(str, "abc");  // 'a', 'b', 'c'를 구분자로 사용

    while (token != NULL) {
        printf("%s\n", token);  // 분리된 각 토큰 출력
        token = strtok(NULL, "abc");  // 다음 토큰을 추출
    }

    return 0;
}
#출력 결과
pple
n
n
or
nge

strtok 함수의 동작 원리 

이제 strtok 함수가 어떻게 NULL을 받으면 그 다음 구분자를 찾아내서 문자를 쪼개는지 살펴보자.

컴퓨터에서 문자열은 메모리의 연속적인 공간문자들이 차례대로 저장된 형태다. 각 문자는 메모리 주소를 가지고 있으며, 이 주소는 포인터로 접근할 수 있다. 예를 들어, "hello,world"라는 문자열이 메모리에 다음과 같이 저장된다고 하자

char* temp = "hello,world"
strtok(temp, ","); 

while(temp != NULL){
temp = strtok(NULL, ",");
}

 

여기서 각 문자는 특정 메모리 주소에 저장되어 있고, 마지막에 문자열의 끝을 나타내는 널 문자(\0)가 위치한다.

1. strtok 첫 번째 호출:

strtok은 전달된 문자열의 포인터를 받아서 구분자를 탐색한다. 첫 번째 호출에서 strtok("hello,world", ",")를 호출하면

  • 문자열의 첫 번째 문자 'h'의 주소(여기서 0x1000)를 받는다. (문자열의 이름 == 문자열의 첫 번째 주소값)
  • 함수는 문자 하나하나를 확인하면서 구분자 ','가 나올 때까지 진행한다.
  • 0x1005 위치에서 구분자 ','를 발견하면, 해당 위치를 \0으로 바꾸어 "hello"와 "world"를 분리한다.

메모리의 상태는 다음과 같이 변경된다. 

이제,  strtok는 정적 변수구분자 바로 다음 위치인 0x1006의 주소(문자 'w')를 저장한다. (이는 함수 설계자의 프로토콜에 기반하여 동작한다.) 이 정적 변수는 함수가 종료되어도 값이 유지되므로 다음 호출에서도 이 주소를 사용할 수 있다.

2. strtok 두 번째 호출

이제, strtok(NULL, ",")을 호출하면

  • 컴퓨터에서 함수는 새 문자열을 전달받지 않겠다는 뜻으로 받아들이고, 정적 변수에 저장된 주소(0x1006, 즉 'w'의 주소)에서 처리를 이어간다.
  • 그 위치부터 다시 구분자 ','를 찾으면서 문자열을 탐색한다. 더 이상 구분자가 없으므로 문자열의 끝까지 가서 "world"를 반환한다.

strtok가 세 번째로 호출하면 반복해서 새롭게 구분자를 만날 때까지의 문자열을 반환하고, 또 다시 구분자 다음 위치를 정적 변수에 저장해둔다.

 

여기서 기억해야 하는 점은 strtok 함수는 첫 번째 호출과 이후 호출이 다르게 동작한다는 것이다. 

그게 어떤 뜻이냐면, 첫 번째 호출에서는 분석할 문자열을 인자로 주어야 하며, 이후 호출에서는 NULL을 주어야 내부적으로 저장된 문자열을 계속 처리할 수 있다. 즉, 처음 문자열을 전달하면서 strtok을 호출하는 부분과, 그 이후 NULL로 호출하는 부분을 나누어야 한다. 그래서 첫 번째 호출은 반드시 while (token != NULL) 블록 바깥에서 실행해 주어야 한다.  

char* token = strtok(input, "x"); // 첫 번째 호출: 분석할 문자열과 함께 호출
while(token != NULL) {
    // 토큰 처리 로직
    token = strtok(NULL, "x"); // 이후 호출: NULL을 인자로 전달
}

 

자 이제 글 첫머리에 있었던 이 밈이 이해가 되는가? ㅋㅋㅋ

 

strtok()는 정적 변수를 사용해 문자열을 자르기 떄문에 while 루프에서 사용하면 의도치 않은 부작용이 발생할 수 있다.

while 루프에서 strtok()을 사용하다가, 루프 내에서 또 다른 함수가 strtok()을 호출하면 저장된 상태가 덮어써지게 된다. 즉, 문자열의 이전 위치 정보를 잃어버려서 문자열을 제대로 처리하지 못하거나 부분적으로 건너뛰는 문제가 생긴다.

 

문제 상황을 보여주는 예제

#include <stdio.h>
#include <string.h>

// 다른 함수에서 strtok() 호출
void anotherFunction(char* str) {
    char* token = strtok(str, ","); // 이 함수도 strtok을 사용
    while (token != NULL) {
        printf("anotherFunction: %s\n", token);
        token = strtok(NULL, ",");
    }
}

int main() {
    char str1[] = "apple orange banana";
    char str2[] = "dog,cat,bird";  // anotherFunction에서 사용할 문자열

    // 첫 번째 문자열을 공백으로 토큰화
    char* token = strtok(str1, " ");
    while (token != NULL) {
        printf("main: %s\n", token);

        // 루프 내에서 다른 함수 호출, 이 함수도 strtok()을 사용
        anotherFunction(str2);  // 여기서 strtok의 상태가 덮어써짐

        // 이어서 토큰을 가져와야 하지만 상태가 덮어써지므로 문제가 발생
        token = strtok(NULL, " ");  // 여기서 문제가 발생
    }

    return 0;
}
#출력 
main: apple // orange, banana는 출력이 안됨
anotherFunction: dog
anotherFunction: cat
anotherFunction: bird

 

게다가 strtok()은 재진입 불가(non-reentrant) 함수로 설계되었다. 이는 여러 곳에서 동시에 호출되거나, 중첩 함수에서 호출될 때 올바르게 동작하지 않는다는 의미다. 예를 들어, strtok()이 한 번 호출된 후 다른 함수에서 다시 strtok()을 호출하면 정적 변수의 상태가 덮어씌워져 엉뚱한 결과를 내게 된다.

 

strtok()을 여러 번 호출하면서 여러 문자열을 처리할 때, 루프 내에서 다시 strtok()을 호출하면 원래 처리 중이던 문자열의 위치를 잃어버리게 된다. 그 결과, 일부 토큰이 누락되거나 잘못 처리될 수 있다.

 

strtok 함수는 내부적으로 정적 변수에 현재 문자열의 위치를 저장한다는 특징 때문에 strtok 함수로 동시에 여러 문자열을 처리할 때는 주의가 필요하다. 하나의 문자열을 처리하는 중에 다른 문자열을 처리하기 위해 strtok을 호출하면, 내부에 저장된 상태가 덮어쓰여서 원래 처리하던 문자열의 위치 정보를 잃게 된다. 이를 해결하기 위해 재진입이 가능한 strtok_r 같은 재진입 가능한 함수를 사용할 수 있다. strtok_r()은 정적 변수를 사용하지 않고, 상태를 유지하기 위해 포인터를 직접 관리한다는 특징을 가져서 더 안전하다. 

 

strtok_r()을 사용한 코드 

#include <stdio.h>
#include <string.h>

// 다른 함수에서 strtok_r() 호출
void anotherFunction(char* str) {
    char* saveptr2;  // strtok_r에서 상태를 저장할 포인터
    char* token = strtok_r(str, ",", &saveptr2);
    while (token != NULL) {
        printf("anotherFunction: %s\n", token);
        token = strtok_r(NULL, ",", &saveptr2);
    }
}

int main() {
    char str1[] = "apple orange banana";
    char str2[] = "dog,cat,bird";  // anotherFunction에서 사용할 문자열
    char* saveptr1;  // strtok_r에서 상태를 저장할 포인터

    // 첫 번째 문자열을 공백으로 토큰화
    char* token = strtok_r(str1, " ", &saveptr1);
    while (token != NULL) {
        printf("main: %s\n", token);

        // 루프 내에서 다른 함수 호출, 이 함수도 strtok_r()을 사용
        anotherFunction(str2);  // 여기서는 안전하게 상태가 유지됨

        // 이어서 계속해서 토큰을 가져올 수 있음
        token = strtok_r(NULL, " ", &saveptr1);
    }

    return 0;
}
#출럭 결과
main: apple
anotherFunction: dog
anotherFunction: cat
anotherFunction: bird
main: orange
anotherFunction: dog
anotherFunction: cat
anotherFunction: bird
main: banana
anotherFunction: dog
anotherFunction: cat
anotherFunction: bird

 

정리하자면 strtok는 ..

 

1. 프로그램이 종료될 때까지 값이 유지되는 정적 변수 혹은 전역 변수에 현재 문자열의 위치를 저장한다. 

2. 첫 번째 호출에 문자열의 주소를 넘기고 두 번쨰 호출부터는 NULL을 넘겨야한다. 

3. NULL을 넘기면 컴퓨터에서는 새로운 문자열을 받지 않겠다는 뜻으로 받아들이고 이전에 찾은 구분자 다음 문자열부터 탐색을 이어간다. 

4. while 문 안에서 strtok를 잘못 사용하면 덮어쓰기 오류가 생길 수 있기 때문에 정적 변수가 아닌 포인터를 직접 관리하는 strtok_r 을  사용하는 것이 안전하다.