김현우
  • [42경산/C언어] Get Next Line(GNL) 완벽 가이드: 버퍼 크기별 동작 원리와 정적 변수 메모리 관리
    2025년 03월 16일 18시 17분 44초에 업로드 된 글입니다.
    작성자: kugorang

    TL;DR — 3줄 핵심 요약

    • 문제: read()는 한 줄 단위로 읽지 않는다. 요청한 바이트 수보다 적게 돌려줄 수 있고, 개행이 어디 있는지도 알려주지 않는다. 그래서 "아직 반환하지 못한 나머지"를 함수 밖에 저장해야 한다.

    • 기술/원인: 시그니처가 char *get_next_line(int fd)로 고정되어 상태를 인자로 넘길 수 없다. 그래서 static 변수를 fd별로 두고, read로 채운 덩어리와 합쳐 개행 앞까지를 반환하고 뒤는 남긴다.

    • 해결/인사이트: 흐름은 네 단계다. ① 저장된 문자열에서 개행을 찾는다 ② 없으면 read로 이어붙인다 ③ 개행 앞부분을 복사해 반환한다 ④ 나머지를 static에 다시 저장하고 이전 메모리를 free한다. EOF와 오류 경로에서도 static이 가리키던 메모리를 반드시 해제한다.

    read()가 실제로 하는 일

    줄여서 gnl (겟넥라)

    ssize_t read(int fd, void *buf, size_t count);
    • 반환값이 양수: 그 바이트 수만큼 buf가 채워졌다. count보다 적을 수 있다.
    • 반환값 0: 파일 끝(EOF). 더 읽을 데이터가 없다.
    • 반환값 -1: 오류.
    • buf에는 '\0'이 자동으로 붙지 않는다. 읽은 뒤 직접 넣어야 문자열이 된다.
    • 파일 오프셋은 커널이 관리하므로 다음 호출은 이어서 읽는다.

    즉 read는 줄 단위가 아니라 바이트 덩어리 단위다. 덩어리에서 줄을 잘라내는 일은 호출한 쪽이 해야 한다.

    왜 static 변수가 필요한가

    함수가 끝나면 지역 변수는 사라진다. 그런데 한 번 읽은 덩어리에는 두 줄 이상이 들어 있을 수 있다. 이번에 반환하지 않은 뒷부분을 다음 호출에서 써야 하므로 함수 밖에 살아 있는 저장소가 필요하다. static 지역 변수는 수명이 프로그램 전체이고 접근 범위는 함수 안이다.

    static char *saved[OPEN_MAX];

    fd를 인덱스로 쓰면 파일마다 남은 문자열을 따로 보관한다. fd1과 fd2를 번갈아 읽어도 섞이지 않는다. OPEN_MAX(limits.h)는 한 프로세스가 열 수 있는 fd 수다.

    본체 흐름

    char *get_next_line(int fd)
    {
        static char *saved[OPEN_MAX];
        char        buf[BUFFER_SIZE + 1];
        ssize_t     n;
        char        *nl;
    
        if (fd < 0 || fd >= OPEN_MAX || BUFFER_SIZE <= 0)
            return (NULL);
        nl = saved[fd] ? ft_strchr(saved[fd], '\n') : NULL;
        while (!nl)
        {
            n = read(fd, buf, BUFFER_SIZE);
            if (n <= 0)
                break ;
            buf[n] = '\0';
            saved[fd] = ft_strjoin_free(saved[fd], buf);
            nl = ft_strchr(saved[fd], '\n');
        }
        if (n < 0)
            return (free(saved[fd]), saved[fd] = NULL, NULL);
        return (extract_line(&saved[fd], nl));
    }

    extract_line은 개행 위치까지를 새 문자열로 복사해 반환하고, 그 뒤를 새로 할당해 saved[fd]에 넣은 다음 이전 메모리를 해제한다. 남은 것이 없으면 saved[fd]는 NULL이 된다.

    메모리 누수가 생기는 지점

    • strjoin으로 새 문자열을 만들고 이전 saved[fd]를 free하지 않아 호출마다 누수
    • 줄을 잘라 반환한 뒤 남은 부분을 저장하지 않아 다음 호출에서 데이터 손실
    • EOF에서 saved[fd]를 free하지 않아 still reachable 발생
    • read가 -1을 반환했을 때 정리 없이 NULL 반환

    검증은 valgrind로 한다. still reachable이 남지 않아야 통과하는 경우가 많다.

    BUFFER_SIZE를 어떻게 잡을까

    • 평가는 -D BUFFER_SIZE=1, 42, 9999 등 여러 값으로 컴파일한다. 어떤 값에서도 동작해야 한다.
    • 1바이트면 read 호출이 잦아 느리지만 결과는 같아야 한다.
    • 매우 큰 값이면 char buf[BUFFER_SIZE + 1]이 스택을 넘길 수 있다. 스택 크기는 보통 8MB다.
    • BUFFER_SIZE가 0 이하이면 read가 0을 반환해 무한 루프가 되므로 진입 시 검사한다.

    긴 줄에서의 성능

    strjoin을 반복하면 매번 새로 할당하고 복사한다. 한 줄이 아주 길면 복사량이 제곱으로 늘어난다. 대안은 누적 버퍼(단순, 대부분 충분)와 동적 확장(용량을 두 배로 늘려 재할당 감소)이다. realloc이 금지되어 있어 동적 확장은 직접 구현해야 한다. 평가는 정확성과 메모리 안전성을 먼저 본다.

    평가에서 보는 것

    • 빈 파일, 개행만 있는 파일, 마지막 줄에 개행이 없는 파일
    • 여러 fd 교차 호출
    • 잘못된 fd, 읽기 오류
    • 메모리 누수 없음(leaks, valgrind)
    • 허용 함수만 사용(read, malloc, free). 디버그 출력 금지
    • Norm 규칙: 함수 25줄 이하, 파일 분리(get_next_line.c, get_next_line_utils.c)

    자주 묻는 질문 (FAQ)

    Q1. static 변수와 전역 변수는 무엇이 다른가요?

    A. 저장 위치는 같고 접근 범위가 다르다. 전역 변수는 다른 파일에서도 extern으로 접근할 수 있지만, 함수 안의 static은 그 함수 안에서만 보인다. 과제에서는 은닉이 목적이므로 static을 쓴다.

    Q2. BUFFER_SIZE가 1일 때도 정상 동작해야 하나요?

    A. 그렇다. 평가는 여러 값으로 컴파일한다. 1바이트씩 읽어도 개행을 만날 때까지 이어붙이고, 반환 후 남은 1바이트를 정확히 저장해야 한다. 이 경우를 먼저 통과시키면 대부분의 버그가 드러난다.

    Q3. EOF 이후에 다시 호출하면 무엇을 반환하나요?

    A. NULL이다. 남은 데이터가 없으면 saved[fd]를 free하고 NULL로 만든 뒤 NULL을 반환한다. 이후 같은 fd로 호출해도 read를 반복하지 않고 바로 NULL이 나와야 한다.

    728x90
    728x90
    댓글