나의 구형 갤럭시 단말 루팅 삽질기…(TWRP 사라짐)

안드로이드 보안 쪽이 문득 궁금해졌다. 폰에 깔린 앱들이 서버와 실제로 무슨 얘기를 주고받는지, 그걸 내 눈으로 직접 보고 싶었다.

그러려면 HTTPS 를 중간에서 열어야 하고, 그러려면 내 인증서를 폰이 믿게 만들어야 한다. 그런데 안드로이드는 사용자가 설치한 인증서를 앱이 기본으로 믿어 주지 않는다. 시스템 저장소에 넣어야 하고, 거기에 파일을 쓰려면 루트가 필요하다. 그래서 연구용으로 쓸 구형 갤럭시를 한 대 구했다.

Magisk 로 금방 될 줄 알았다. 앱으로 펌웨어 이미지를 패치해서 Odin 으로 구우면 끝 — 그게 요즘 표준 절차니까.

그런데.

Odin 은 초록색 PASS 를 띄웠다. 폰이 재부팅되고, 다시 리커버리로 들어가 보면 거기엔 삼성 순정 리커버리가 떠 있었다. TWRP 는 없었다.

다시 플래시했다. 또 PASS. 또 사라졌다.

이걸 이틀 했다. 2026년 8월 4~5일, 갤럭시 S10 5G(SM-G977N)를 붙들고 있었다. 주력폰이 아니라 보안 연구용으로 사둔 여분 기기다.

범인은 키 조합도, Odin 설정도, 내 손가락도 아니었다. /vendor/bin/install-recovery.sh 라는 셸 스크립트 한 개였다. 절반은 그걸 찾은 이야기, 절반은 왜 이틀이나 걸렸나에 대한 이야기다.

TWRP 플래시부터 순정 리커버리 재생성까지 이어지는 닫힌 고리를 여섯 단계로 그린 다이어그램. install-recovery.sh 를 no-op 으로 교체해 고리를 끊는 지점이 빨간 X 로 표시돼 있다

먼저 대가부터. 언락하면 데이터가 전부 초기화되고 Knox e-fuse 가 영구히 트립된다(warranty_bit=1). 삼성페이·보안폴더·Samsung Pass 는 그날로 끝, A/S 도 거부될 수 있고 되돌아가지 않는다. 주력폰에는 하지 마세요.

경고 하나 더. 커스텀 바이너리가 올라간 상태에서 다운로드 모드의 Lock bootloader? 화면에서 볼륨위를 누르면 영구 벽돌이다. 절대 누르지 마라.

시작하기 전에 — 이 글에 나오는 것들

안드로이드 보안 쪽을 처음 들여다볼 때 내가 제일 헷갈렸던 게 이 단어들이었다. 사전처럼 다 풀진 않고, 이 글을 읽는 데 필요한 만큼만 적는다.

용어 한 줄 이 글에서 하는 역할
루팅 안드로이드에서 root 권한을 얻는 것 시스템 인증서 저장소에 파일을 쓰려면 필요하다
부트로더 / 언락 부팅 전에 이미지 서명을 검사하는 첫 코드. 언락하면 그 검사를 강제하지 않는다 모든 것의 전제. 대신 Knox 가 트립된다
recovery 파티션 평소 쓰는 안드로이드와 별개로, 복구용으로 따로 부팅되는 작은 리눅스 이 기기에서는 여기가 루트가 사는 자리
TWRP 대표적인 커스텀 recovery. 파티션을 마운트하고 셸을 쓸 수 있다 /vendor 를 rw 로 마운트해 범인을 무력화한 곳
Magisk systemless root. 시스템 파티션을 안 건드리고 부팅 과정에 끼어든다 initmagiskinit 으로 갈아끼운다
Odin 삼성 기기에 이미지를 굽는 윈도우 도구. 폰을 다운로드 모드로 넣고 쓴다 PASS 를 띄우고도 결과가 사라지던 그 도구
vbmeta / Verified Boot 부팅 체인 무결성 검증(AVB). 이미지가 서명대로인지 본다 flags 1바이트를 꺼야 커스텀 리커버리가 뜬다

두 가지만 풀어 쓴다.

왜 루트가 필요했나. 안드로이드는 인증서 저장소가 두 개다. 사용자가 설정에서 직접 설치한 것(사용자 CA)과, 시스템 파티션에 박혀 있는 것(시스템 CA). API 24(안드로이드 7)부터 앱은 기본적으로 사용자 CA 를 믿지 않는다. 내 인증서를 앱이 믿게 하려면 시스템 저장소에 넣어야 하고, 거기에 쓰려면 루트가 있어야 한다.

이 기기는 Android 12 라 저장소가 /system/etc/security/cacerts 라는 평범한 디렉터리였다. Android 14+ 는 불변 APEX 로 옮겨가 훨씬 까다롭다.

왜 하필 recovery 를 건드리나. 순정 recovery 는 초기화와 OTA 정도만 하는 잠긴 물건이다. TWRP 같은 커스텀 recovery 로 갈아끼우면 거기서 파티션을 마운트하고 셸을 쓸 수 있다. 게다가 이 기기는 뒤에서 보듯 Magisk 자체가 recovery 파티션에 들어간다. 그래서 이 이야기의 중심이 계속 리커버리다.

boot.img 에 램디스크가 없다 — 그래서 리커버리가 지워지면 루트도 사라진다

그 표준 절차가 왜 안 통했는지부터. Magisk 는 보통 boot.img 램디스크에 자기 init 을 끼워 넣는데, 이 기기엔 램디스크가 없다.

말로 하면 안믿기니까 순정 펌웨어를 직접 깠다. 부트 헤더를 struct 로 파싱하는 열 줄짜리 파이썬이면 된다.

boot.img 와 recovery.img 의 안드로이드 부트 헤더를 파싱한 터미널 출력. boot 의 ramdisk_size 는 0, recovery 는 14,944,549 로 대비된다
이미지 kernel_size ramdisk_size page_size header_version
boot.img 45,520,908 0 2,048 1
recovery.img 45,520,908 14,944,549 2,048 1

커널 크기는 바이트까지 같은대 램디스크만 한쪽이 0이다. Magisk 공식 문서도 램디스크 없는 기기는 recovery 에 설치하라고 못박는다. 여기서 리커버리 파티션은 곧 루트고, 리커버리가 되돌아가면 루트도 날아간다.

파티션은 boot=sda14, recovery=sda15, system=sda26, vendor=sda27, vbmeta=sda24.

이틀을 잡아먹은 가설 — “키 조합 타이밍을 놓쳤다”

나는 이 작업을 AI 와 같이 하고 있었다. AI 의 첫 가설은 “키 조합 타이밍을 놓쳤다” 였다.

나쁜 가설은 아니었다. 삼성은 실제로 키 타이밍이 까다롭고, 실패 화면이 타이밍 실패와 구분되지 않는다. 관측된 증거와 모순되지 않았다.

문제는 그다음이다. 실패할 때마다 가설이 바뀌는 게 아니라 정교해졌다. 볼륨위를 언제 놓나, 전원을 몇 초 누르나. 답은 매번 달라지는데 뿌리는 같았다.

AI 가 자신 있게 틀린 것들을 나중에 표로 정리했는데, 지금 봐도 좀 아찔하다.

AI 의 주장 실제
“vbmeta 패치가 부팅 루프의 원인이다” 틀림. 검증 비활성 vbmeta 는 커스텀 리커버리에 필수다. 그대로 믿고 되돌렸으면 루팅은 영영 불가능했다
“USB 301K 지그를 사라” 틀림. 지그는 micro-USB 시대 물건이고 S10 5G 는 USB-C 다
다운로드 모드 키 조합 틀림. S20 계열 조합을 줘서 세이프 모드로 빠졌다
“TWRP 진입은 볼륨위 놓는 타이밍이 관건” 틀림. TWRP 진입에 타이밍은 필요 없다. 내가 “아까는 타이밍 안 잡아도 됐잖아”라고 한 게 맞았다

확신의 강도와 정확도는 별개다. 이걸 이틀에 걸쳐 배웠다.

규칙 하나만 못박자. AI 가 같은 가설의 변형을 세 번 이상 내놓으면 그 가설을 의심하라. 필요한 건 더 나은 변형이 아니라 프레임 교체다.

Odin 이 SetupConnection 에서 멈추는 별개의 문제도 있었다. 같은 PC 인데 포트를 6&1B2B7D3C&0&2(AMD USB 3.10 컨트롤러 1.20)로 바꾸니 붙었고, 8&1DC14B6F&...(1.10)에서는 매번 거기서 멈췄다. 한번 멈추면 세션이 오염되니 다운로드 모드에서 완전히 빠졌다가 새로 들어가야 한다.

이런 잡음이 겹치면 “내가 뭘 잘못하고 있다”는 프레임은 더 단단해진다.

프레임을 바꾼 한마디, 그리고 adb devices 한 줄

이틀째 새벽에 내가 던진 말은 근거가 없었다.

“뭔가 부트로더나 이미지가 복원된 건 아닐까?”

30분 만에 끝났다.

중요한 건 이게 새 정보가 아니었다는 점이다. grep -r recovery /vendor/etc/init/ 한 줄이면 나왔다. 내가 준 건 탐색 공간의 교체였다.

갇혀 있던 프레임 :  "우리가 이걸 제대로 못 넣고 있다"
바꾼 프레임      :  "뭔가가 이걸 되돌리고 있다"

앞쪽에서는 버튼 누르는 법을 아무리 개선해도 매 부팅 지워진다. 뒤쪽으로 옮기면 “무엇이 되돌리나”는 추적 가능한 문제가 된다.

두 프레임을 갈라줄 관측이 필요했다. 질문은 이거였다. “성공한 적이 있는데 사라진 건가, 애초에 성공한 적이 없는 건가?”

답은 adb devices 한 줄에 있었다. TWRP 가 살아 있으면 recovery product:omni_beyondx 로 나온다. 그런데 내 화면에는 unauthorized 가 찍혔다 — 순정 리커버리는 adb 인증 키가 없어서다. 성공한 적이 있었고 그 뒤에 지워진 것이었다.

범인 — /vendor 에서 매 부팅 도는 리커버리 복원 서비스

/vendor/etc/init/ 를 뒤지니 이게 있었다.

/vendor/etc/init/vendor_flash_recovery.rc

service vendor_flash_recovery /vendor/bin/install-recovery.sh
    class main
    oneshot

init 언어를 보자. oneshot 은 AOSP init README 정의 그대로 “종료해도 다시 띄우지 않는다”는 뜻이다. 그것뿐이다.

한 번만 도는 이유는 옆의 class main 에 있다. main 클래스는 부팅 시퀀스에서 class_start main 으로 한 번 시작된다. 둘을 합치면 이 서비스는 매 부팅 정확히 한 번 돈다. 한 번뿐이라 안전해 보이는데, 뒤집어 말하면 부팅할 때마다 반드시 한 번은 돈다.

그 스크립트 알맹이가 이거다.

# /vendor/bin/install-recovery.sh (원본, 경로·해시는 축약)
if ! applypatch --check EMMC:.../recovery:67633152:b7d67c0c...; then
  applypatch --patch /vendor/recovery-from-boot.p \
             --source EMMC:.../boot:57671680:32beed83... \
             --target EMMC:.../recovery:67633152:b7d67c0c...

하는 일이 명확하다. recovery 해시를 순정값과 대조하고, 다르면 boot 파티션과 /vendor/recovery-from-boot.p 델타로 순정 리커버리를 다시 만들어 덮어쓴다.

인자의 67633152 가 뭔지 궁금해서 로컬 펌웨어 폴더를 봤다. recovery 이미지 파일 크기가 정확히 67,633,152 바이트였다. EMMC:경로:크기:해시 의 크기는 대조 대상 이미지의 바이트 길이다. 이미지를 통째로 대조하고 통째로 복원하니 TWRP 든 뭐든 남을 리가 없었다.

관점을 뒤집어야 한다. 이건 루팅 방해 장치라기보다 비-A/B(non-A/B) 기기의 OTA 설계다. 리커버리가 손상되면 업데이트 경로가 막히니 재생성용 델타를 /vendor 에 두고 매 부팅 점검한다. 버그가 아니라 자가 치유고, 나는 그 치유 대상이었을 뿐이다.

(recovery-from-boot.p 의 델타 포맷은 단정하지 않겠다. 그 파일을 안 뽑아둬서 확인을 못 했다.)

고리를 끊는 방법

조치는 TWRP 셸에서 했다. /vendor 를 rw 로 마운트하고 원본을 백업한 뒤 no-op 으로 갈아끼웠다.

mount -o rw /dev/block/sda27 /vendor
cp -a /vendor/bin/install-recovery.sh /vendor/bin/install-recovery.sh.orig
printf '#!/vendor/bin/sh\nexit 0\n' > /vendor/bin/install-recovery.sh
chmod 750 /vendor/bin/install-recovery.sh
chcon u:object_r:vendor_file:s0 /vendor/bin/install-recovery.sh

마지막 chcon 이 빠지면 안 된다. 파일을 새로 만들면 SELinux 컨텍스트가 달라지고, init 은 정해진 컨텍스트의 파일만 실행한다. 어긋나면 avc: denied 만 쌓인다.

지우지 않고 no-op 으로 둔 이유도 같다. 파일이 없어지는 것보다 즉시 exit 0 하는 편이 조용하다. 원본은 .orig 로 남겨뒀다.

vbmeta 1바이트 — AI 가 되돌리라고 했던 그것

앞서 말한 AVB 가 여기서 나온다. vbmeta 헤더의 flags 를 0x00 에서 0x03(VERIFICATION_DISABLED | HASHTREE_DISABLED)으로 바꿔야 하는데, 정말 1바이트인지 궁금해서 직접 비교해봤다.

vbmeta 이미지의 AVB0 매직과 오프셋 120 부근을 xxd 로 비교한 터미널 출력. 패치본은 0000 0003, 순정본은 0000 0000 이고 cmp 결과 9,552바이트 중 124번째 바이트 하나만 다르다

cmp -l 결과가 딱 한 줄이다. 9,552바이트 파일에서 124번째 바이트(오프셋 123) 하나만 8진수 3 대 0으로 다르다. flags 는 오프셋 120의 uint32 인데 빅엔디언이라 최하위 바이트가 123에 온다.

여기서 오해하기 쉽다. 이 1바이트를 바꾸는 순간 vbmeta 서명은 깨진다. libavb 는 AvbVBMetaImageHeader 를 통째로 해시에 밀어 넣으니 flags 도 서명 대상이다.

그런데도 부팅되는 건 검증을 통과해서가 아니라 부트로더가 이미 언락돼 있어서다(flash.locked=0, verifiedbootstate=orange). 언락 상태에서는 vbmeta 서명이 강제되지 않는다. 잠긴 기기였다면 이 1바이트로 부팅이 막혔을 것이다.

그리고 이 1바이트가 아까 표의 첫 줄이다. AI 말을 믿고 되돌렸으면 TWRP 는 영영 안 떴을 거고, 나는 이 글을 못 쓰고 있을 거다.

순서가 전부였다 — TWRP 를 Magisk 로 패치해서 넣는다

원인을 알고 나니 절차는 짧았다. 순정 recovery 에 Magisk 를 넣는 방식은 이 기기에서는 끝내 안 됐고, TWRP 를 Magisk 로 패치해 넣는 쪽이 정답이었다.

① 부트로더 언락 (다운로드 모드 → 볼륨위 길게)
② 순정 펌웨어 플래시로 기준선 확보
③ vbmeta 검증 비활성화(flags 0x03) + TWRP 플래시 — Odin AP, Auto Reboot OFF
④ 곧바로 TWRP 로 부팅   ★ 시스템 부팅을 절대 거치지 말 것
⑤ TWRP 셸에서 install-recovery.sh 무력화   ← 이걸 먼저 해야 이후가 안 지워진다
⑥ TWRP 셸에서 /system rw 마운트 → CA 인증서 주입
⑦ 시스템 부팅 → Magisk 앱으로 TWRP recovery.img 패치 (리커버리 모드 체크)
⑧ 패치본을 Odin AP 슬롯에 단독 플래시 (Auto Reboot OFF)
⑨ adb reboot recovery 로 루트 부팅

Odin 에서 꼭 꺼야 하는 체크박스 하나

③과 ⑧에 적어둔 Auto Reboot OFF 가 말처럼 사소하지 않다. 글을 쓰면서 Odin 을 다시 띄워 화면을 확인해봤다. 쓴 건 배포본 기준 Odin3 v3.14.4 이고, 창 제목에는 v3.14 로 나온다. 기기는 붙이지 않았고 Start 도 누르지 않았다. 창만 열어본 것이다.

Odin3 v3.14 메인 창 스크린샷. BL, AP, CP, CSC, USERDATA 슬롯 다섯 개와 Log 탭, 하단의 Start·Reset·Exit 버튼이 보이고 상단 ID:COM 영역은 기기가 연결되지 않아 비어 있다

이미지 하나를 구울 때는 슬롯 다섯 개 중 AP 하나만 채운다. 아래 Options 탭이 문제의 그곳이다.

Odin3 v3.14 Options 탭 스크린샷. Auto Reboot 와 F. Reset Time 이 체크된 상태이고 Nand Erase, Re-Partition, DeviceInfo, Flash Lock, Decompress Data 는 해제돼 있다

Auto Reboot 가 기본값으로 켜져 있다. 이걸 그냥 두고 구우면 플래시가 끝나는 순간 폰이 알아서 재부팅되는데, 그 재부팅이 바로 앞에서 본 일반 부팅이다. 즉 리커버리 복원 서비스가 한 번 돌고, 방금 구운 TWRP 는 그 자리에서 사라진다. 체크박스 하나가 켜져 있다는 이유로 작업이 무효가 되는 것이다.

진짜 중요한 건 ⑤의 위치다. 내용이 아니라 위치. 뒤로 미루면 그사이 일반 부팅 한 번으로 전부 날아간다. 이틀 동안 내가 반복한 게 그거였다.

Magisk 가 램디스크에 뭘 넣었나

⑦에서 Magisk 가 TWRP 이미지에 무슨 짓을 하는지 궁금해서 패치 전후 램디스크를 cpio(newc)로 파싱해 비교해봤다. (헤더 인덱스를 헷갈리면 엔트리가 딱 1개만 나온다. 물어보지 마라.)

Magisk 패치 전후 램디스크 cpio 엔트리 비교 출력. 3,545개에서 3,554개로 늘었고 .backup 과 overlay.d 아래 9개가 추가됐으며 init 이 2,146,400 에서 199,960 바이트로 줄었다

엔트리가 3,545 → 3,554, 추가 9개에 삭제 0개. 결정적인 한 줄은 init 의 크기 변화다. 2,146,400 → 199,960 바이트.

원래 init 이 훨씬 작은 magiskinit 으로 갈아끼워졌고, 원본은 .backup/init.xz 로 남는다. 부팅하면 magiskinit 이 먼저 뜨고, 할 일을 한 뒤 진짜 init 을 복원해 넘긴다.

.backup/.magisk 는 142바이트라 열어봤다.

Magisk 패치 램디스크 안의 .backup/.magisk 설정 파일 내용. RECOVERYMODE=true, KEEPVERITY=true, PREINITDEVICE=userdata 등이 보인다

RECOVERYMODE=true. Magisk 앱에서 체크한 “리커버리 모드” 가 이미지 안에 이렇게 박힌다. 이게 있어야 magiskinit 이 recovery 파티션 전제로 분기를 탄다.

결과 — 부팅 경로가 곧 루트 스위치

파티션 배치와 부팅 경로별 루트 유무를 정리한 다이어그램. adb reboot recovery 는 루트 있는 안드로이드, 전원 버튼은 루트 없는 순정 부팅, 볼륨위 조합은 TWRP 화면으로 이어진다

Magisk 가 recovery 에 사는 구조라, 이 폰은 어떻게 켜느냐에 따라 루트 유무가 갈린다.

부팅 방법 결과
adb reboot recovery Android + Magisk (루트 있음)
TWRP → Reboot → System Android + Magisk (루트 있음)
전원 버튼만 / adb reboot 순정 부팅, 루트 없음
볼륨위 + Bixby + 전원 (볼륨위 계속 누름) TWRP 화면

adb shell su -c id 를 치면 uid=0(root) gid=0(root) groups=0(root) context=u:r:magisk:s0 가 나온다. 최종 구성은 Magisk 30.7, TWRP 3.7.0_9-1, Android 12(SDK 31), Exynos 9820.

처음에 심으려던 시스템 CA 도 파티션에 있어서, 루트 없이 켜도 그대로 동작한다. 프록시 배관과 핀닝 우회는 아직 트래픽을 잡아보지 않았으니 다음에 다뤄보겠다.

그래서 이 이야기는 구형 기기에서만 재현된다

솔직하게 한계를 적어둔다. 이 글은 전부 부트로더를 언락할 수 있는 기기에서만 성립한다. 삼성은 One UI 8(Android 16)부터 개발자 옵션에서 “OEM 잠금 해제” 항목을 코드에서 제거했고, 안티 롤백은 하드웨어 eFuse 라 하위 펌웨어로 내려갈 수도 없다.

내 주력폰 SM-S906N(Galaxy S22+)이 S906NKSS9GZE5(바이너리 9), One UI 8이다. 조사해보고 루팅 불가 판정을 내렸다. 시도조차 못 한다. 이 이야기는 S21·S10 계열 같은 구형에서만 재현된다.

마치며

갤럭시 루팅을 하다 TWRP 가 사라진다면, 순서는 이렇습니다. 리커버리로 부팅해 adb devices 부터 쳐서 실패한 건지, 성공했다가 지워진 건지를 가른다. 지워진 쪽이면 키 조합을 더 만질 이유가 없다. 곧장 /vendor/etc/init/grep 해 범인부터 무력화한다.

AI 와 같이 파는 상황이라면 신호는 하나다. 뿌리가 같은 가설이 세 번 이상 나오면 답을 더 기다릴 자리가 아니라 질문을 갈아야 하는 자리다. 근거 없는 감각도 정제하지 말고 던져라 — 근거를 만드는 건 AI 가 훨씬 잘한다.

이번 건에서 AI 는 부트 헤더 파싱도, 압축 포맷 판별도, 해시 대조도 나보다 빨랐다. 다만 프레임은 못 바꿨고, “포기하기 아깝다” 고 우기지도 않는다. 그만하자면 그만한다. 이틀째 새벽에 계속 가자고 한 판단이 없었으면 이 폰은 서랍에 들어갔을 거다. 처리량은 AI, 방향과 지속은 사람.

adbroot

삽질하면서 나한테 물었던 것들

Odin 은 PASS 라는데 왜 사라지지?

플래시는 실제로 성공한 겁니다. 다음 일반 부팅에서 install-recovery.sh 가 해시를 대조하고, 다르니까 순정 리커버리를 재생성해 덮어쓴 것이죠. Odin 이 보는 건 전송 성공 여부지 그 이후가 아닙니다.

실패한 건가, 성공했다가 지워진 건가?

리커버리로 부팅한 뒤 adb devices 를 보시면 됩니다. recovery product:omni_beyondx 면 TWRP, unauthorized 면 순정 리커버리입니다(순정은 adb 인증 키가 없습니다).

Magisk 를 왜 boot 가 아니라 recovery 에 넣지?

이 기기의 boot.imgramdisk_size 가 0이기 때문입니다. Magisk 는 램디스크에 magiskinit 을 끼워 넣는 방식이라 boot 에는 자리가 없습니다.

vbmeta 를 건드리면 부팅 루프 나는 거 아닌가?

이 기기에서는 반대였습니다. 검증 비활성 vbmeta(flags 0x03)가 없으면 커스텀 리커버리가 아예 안 뜹니다. 다만 그 1바이트로 서명은 깨집니다. 부팅되는 건 부트로더가 언락돼 있기 때문이고, 잠긴 기기라면 막힙니다.

이거, 요즘 갤럭시에서도 되나?

안 됩니다. One UI 8부터 “OEM 잠금 해제” 항목이 제거됐습니다. One UI 7 이하에서 업데이트가 끝난 기기(미국 통신사판 U/U1 제외)만 가능합니다.

핵심 요약

  • 벤더 파티션의 리커버리 복원 스크립트가 매 부팅 딱 한 번 돌면서, 해시가 순정값과 다르면 부트 이미지와 델타 패치로 순정 리커버리를 다시 만들어 덮어쓴다. 스크립트에 박힌 숫자 67,633,152 는 대조 대상 리커버리 이미지의 바이트 길이로, 내 로컬 펌웨어 파일 크기와 정확히 같았다.
  • 이 기기의 부트 이미지는 램디스크가 0바이트인데 리커버리 이미지는 14,944,549바이트다. 그래서 루트가 리커버리 파티션에 살고, 리커버리가 지워지면 루트도 같이 사라진다.
  • 부팅 검증을 끄는 플래그는 9,552바이트짜리 파일에서 딱 1바이트다. 그 1바이트로 서명은 깨지지만, 부트로더를 이미 언락해 둔 상태라 부팅은 된다. 이걸 부팅 루프의 원인으로 오해해 되돌렸다면 루팅은 불가능했다.
  • Magisk 로 패치한 램디스크는 엔트리가 3,545개에서 3,554개로 늘고, 부팅 첫 프로그램이 2,146,400바이트짜리에서 199,960바이트짜리로 갈린다. 원본은 압축된 채 같은 램디스크 안에 백업된다.
  • 순서가 전부다. 커스텀 리커버리를 구운 직후 시스템으로 부팅하지 말고 곧장 리커버리로 들어가 복원 스크립트부터 무력화해야 한다. 같은 가설의 변형이 세 번 넘게 나오면 프레임을 갈아라 — 이 건은 그러고 30분 만에 끝났다.

참고 자료

로컬 LLM 환경 구축: 메모리 계산부터 Ollama·llama.cpp 서버까지

글을 쓰면서..

로컬 LLM 환경 구축을 검색하면 열에 아홉은 curl | sh 로 Ollama 깔고 ollama run 한 줄 치면 끝, 이라고 말합니다. 나도 처음엔 그렇게 시작했다. 그런데 막상 내 머신에서 조금만 욕심을 내니까 “왜 이 모델은 안 뜨지”, “컨텍스트 좀 늘렸다고 갑자기 왜 이렇게 느려지지” 같은 질문이 줄줄이 나왔고, ollama run 가이드는 거기에 아무 답도 주지 못했다.

그래서 순서를 뒤집기로 했다. 설치부터 하지 말고, 메모리 예산부터 계산한 다음 그 결과로 모델과 양자화를 고르고, Ollama(빠른 길)와 llama.cpp 소스 빌드(다 뜯어보는 길)를 같은 GGUF 파일로 연결하는 흐름.

범위는 미리 정직하게 밝혀 둔다. 하드웨어 점검과 Ollama 는 이 머신에서 실제로 돌렸고, 뒤에 나오는 수치는 전부 그때 실제로 얻은 값이고, 중간에 넣은 터미널 그림은 그 출력을 그대로 옮겨 그린 것이다(화면 캡처가 아니라 출력 텍스트를 다시 렌더한 그림이다). 반면 llama.cpp 소스 빌드는 이번에 하지 않았다. 그쪽 명령과 옵션은 공식 빌드 문서를 기준으로 정리한 것이지 내 터미널 기록이 아니다. 어디까지가 내 실측이고 어디부터가 문서인지는 그때그때 표시해 두겠다.

내 환경부터 밝히면 이렇다.

  • OS: Windows 위의 WSL2(Ubuntu). 순수 리눅스도 흐름은 같고, macOS(Apple Silicon, Metal)는 llama.cpp 가 기본 지원하니 빌드 옵션만 다르다.
  • GPU: RTX 4060 Ti 8GB. 소비자용 카드고 VRAM 이 딱 8GB라 “어디까지 되나”를 계속 따져야 하는, 오히려 이 글에 딱 맞는 조건이다.
  • 나: 셸이랑 cmake 빌드는 익숙한데, 클라우드 API 만 써봤지 로컬로 모델을 띄워본 적은 없던 사람.

솔직히 관련 자료야 널렸지만, 정작 “왜 안 되는지”를 시스템 자원 관점에서 짚어 주는 글은 잘 없어서 나도 복습할 겸 정리했다. 인용하는 옵션 이름·기본값·파일 크기는 2026년 9월에 공식 문서랑 Hugging Face 에서 직접 확인한 값이지만, 버전 타니까 --help 와 문서를 같이 보시길.

로컬 LLM, 굳이 왜 깔았나와 스택 뜯어보기

클라우드 API 가 이렇게 싸고 편한데 왜 로컬에 띄우나. 내 경우엔 자동화 파이프라인 백엔드가 컸다. CLI 도구나 스크립트가 하루 종일 모델을 두들기는 워크로드는 토큰 과금이 무섭게 쌓이는데, 이미 GPU 가 책상 밑에 있으면 한계 비용이 전기료뿐이다. 여기에 사내 코드나 로그를 밖으로 못 보내는 프라이버시 문제, 네트워크 없이도 돌아야 하는 오프라인·재현 실험까지 더해지면 로컬로 내리는 게 답이 된다.

깔기 전에 스택이 어떻게 쌓이는지부터 봐야 나중에 “어느 층이 문제인지”를 짚을 수 있다. 나는 이걸 네 층으로 그려 놓고 시작했다.

로컬 LLM 스택 계층 구조: 모델 가중치 파일(GGUF/safetensors) 위에 추론 엔진(llama.cpp, vLLM), 그 위에 서빙 계층(Ollama, llama-server), 맨 위에 curl·Python·CLI 도구 클라이언트가 놓이는 4단 구조
  • 모델 가중치 파일 — llama.cpp 계열은 단일 파일 포맷 GGUF, vLLM·Transformers 계열은 safetensors 를 쓴다. GGUF 는 양자화된 텐서에 토크나이저·아키텍처 메타데이터까지 한 파일에 들어 있어서, 이 파일 하나만 있으면 돈다. 이게 로컬에서 다루기 편한 결정적 이유다.
  • 추론 엔진llama.cpp 는 C/C++ 로 짜인 추론 엔진이고 내부 텐서 라이브러리 이름이 ggml 이다. 의존성 없이 빌드되고 CPU(AVX2/AVX-512), CUDA, Metal 을 다 지원한다. vLLM 은 PyTorch 기반으로 PagedAttention·연속 배칭으로 동시 요청 처리량을 뽑는 서버인데, 데이터센터급 GPU 를 전제한다.
  • 서빙 계층Ollama 는 다운로드·캐시·HTTP API 를 바이너리 하나로 묶은 래퍼고, 정작 추론은 내부의 llama.cpp/ggml 이 한다. llama.cpp 를 직접 빌드하면 같은 역할의 llama-server 가 나온다. 그러니까 이 둘은 사실 남남이 아니다.
  • 클라이언트 — curl, Python openai SDK, 그리고 OpenAI 호환 주소만 받아먹는 CLI·에이전트 도구들.

세 선택지를 한 표로 눌러 보면 이렇다.

항목 Ollama llama.cpp (직접 빌드) vLLM
설치 난이도 스크립트 한 줄 cmake 빌드 (5~15분) pip 설치, CUDA/PyTorch 버전 맞추기
지원 하드웨어 CPU, NVIDIA, AMD, Apple CPU, NVIDIA, AMD, Apple, Vulkan 등 NVIDIA(주), AMD 일부
모델 포맷 GGUF (자체 레지스트리 + 로컬 파일) GGUF safetensors (HF Hub)
API 자체 /api/* + OpenAI 호환 /v1/* OpenAI 호환 /v1/* + 자체 API OpenAI 호환 /v1/*
튜닝 자유도 Modelfile·환경변수 수준 모든 플래그 직접 제어, 양자화 도구 포함 서버 옵션 풍부
적합 용도 개인 개발, 빠른 실험 자원 통제·임베디드·학습 목적 다중 사용자 서비스, 고처리량 배치

나는 8GB 카드 한 장이라 vLLM 은 애초에 후보가 아니었고, 이 글도 앞의 두 경로만 깊게 판다. vLLM 은 24GB 이상 VRAM 으로 동시 요청을 많이 받아야 할 때 꺼내면 된다.

여기서 처음 막혔다 — 메모리 예산 계산과 WSL2 함정

로컬 LLM 환경 구축의 성패는 사실상 “모델이 메모리에 들어가느냐” 하나로 갈린다. 그래서 뭘 깔기 전에 내 머신부터 재봤다.

# GPU 이름과 VRAM 총량 (NVIDIA)
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv

# 시스템 RAM, CPU 코어 수, CPU 모델
free -h
nproc
lscpu | grep -E 'Model name|^CPU\(s\)'
grep -o -w -E 'avx2|avx512f' /proc/cpuinfo | sort -u   # CPU 추론에 직결되는 SIMD 지원 여부

음.. 내 머신에서는 이렇게 나온다.

WSL2에서 nvidia-smi, free -h, nproc, lscpu 를 실행한 결과. RTX 4060 Ti 8188MiB, 메모리 15Gi, Ryzen 5 7500F 12스레드, avx2/avx512f 지원이 표시된 터미널 출력을 그대로 옮긴 그림

GPU 는 RTX 4060 Ti, VRAM 8188MiB, 드라이버 591.86. CPU 는 AMD Ryzen 5 7500F 6코어에 nproc 이 12로 나오니 스레드는 12개고, avx2avx512f 가 둘 다 잡힌다. CPU 로 밀어야 할 때 최소한 SIMD 는 제대로 얹힌다는 뜻이라 여기까진 마음이 놓였다.

문제는 free -h 줄이었다. 여기서 뒤통수를 맞았다. 내 데스크톱에 꽂혀 있는 RAM 은 분명 32GB인데, WSL 안에서 free -h 를 치니까 total 이 태연하게 15Gi 라고 나온다. 처음엔 램 한 짝이 빠졌나 싶어서 Windows 작업 관리자까지 열어 봤다. 거긴 멀쩡히 32GB.

한참 헤매다 원인을 찾았는데, 내 착각이 아니라 WSL2 의 기본 동작이었다. .wslconfigmemory 기본값이 Windows 전체 RAM 의 50%다. Microsoft 의 WSL 설정 문서에 그렇게 박혀 있다. 32GB의 절반이면 16GB, 거기서 WSL 커널이 먹는 몫을 빼면 사용자에게 보이는 total 이 15Gi 로 찍힌다. 숫자가 정확히 맞아떨어졌다.

내 머신은 지금도 이 상한이 걸린 상태 그대로다. 8GB 카드에 올릴 모델만 만지는 동안엔 굳이 손댈 이유가 없어서 두고 쓰는 중이다. 다만 CPU 로 큰 모델을 올릴 생각이라면 이 15Gi 가 그대로 천장이 되니, 그땐 %UserProfile%\.wslconfig 에 이렇게 적고

[wsl2]
memory=24GB

PowerShell 에서 wsl --shutdown 으로 한 번 재시작해 주면 상한이 올라간다. (WSL 은 종료됐다가 다음 접속 때 새 설정으로 뜬다.) 이걸 모르면 “32GB 머신인데 왜 CPU 추론이 스왑 물고 기어가지” 하면서 며칠 날린다. 참고로 GPU VRAM 은 이 제한이랑 아무 상관이 없다 — .wslconfig 를 아무리 올려도 4060 Ti 의 8188MiB 는 8188MiB 그대로다.

가중치 메모리는 그냥 파일 크기다

가중치가 먹는 메모리는 거의 파일 크기와 같다. llama.cpp 가 mmap 으로 파일을 그대로 올리기 때문이다. 그래서 계산이 단순하다. 파라미터 수 × 파라미터당 바이트.

정밀도 파라미터당 바이트 8B 모델 환산
FP16 / BF16 2 약 16GB
Q8_0 약 1.06 (8.5 bit) 약 8.5GB
Q4_K_M 약 0.61 (4.9 bit) 약 4.9GB

“약”이 붙는 건 K-quant 계열이 텐서마다 비트 수를 섞고 블록별 스케일 값이 더 붙기 때문이다. llama.cpp 의 quantize 문서에 실린 Llama 3.1 8B 실측이 Q4_K_M 4.89 bit/가중치, Q8_0 8.50 bit/가중치다. 손계산이 진짜 맞나 싶어서, Hugging Face 에 올라온 GGUF 파일 크기로 대조해 봤다(2026년 9월, GiB 단위).

모델 FP16/BF16 Q8_0 Q4_K_M Q4_K_M 이 들어가는 하드웨어
Llama 3.2 3B 5.99 3.19 1.88 거의 모든 GPU, 8GB RAM 노트북
Llama 3.1 8B 14.96 7.95 4.58 8GB VRAM(여유 약 2GB), CPU 16GB RAM
Qwen3 14B 27.51 14.62 8.38 12~16GB VRAM, CPU 32GB RAM
Qwen3 32B 61.03 32.43 18.40 24GB VRAM, CPU 64GB RAM

크기 출처: Hugging Face 저장소 bartowski/Llama-3.2-3B-Instruct-GGUF, bartowski/Meta-Llama-3.1-8B-Instruct-GGUF, bartowski/Qwen_Qwen3-14B-GGUF, bartowski/Qwen_Qwen3-32B-GGUF 에 올라온 파일 크기(GiB), 2026년 9월 확인.

모델 크기(3B/8B/14B/32B)와 양자화(FP16, Q8_0, Q4_K_M)별 GGUF 파일 크기를 막대 그래프로 비교하고, 8GB·16GB·24GB VRAM 기준선을 점선으로 표시한 메모리 예산 차트

내 4060 Ti 8GB 기준으로 보면 8B Q4_K_M(4.58GiB)이 상한선이라는 게 표에서 그대로 보인다. “8GB 카드엔 8B Q4 까지”라는 흔한 경험칙이 어디서 나오는지 이제 알겠다. 그런데 이 표에는 아직 안 들어간 놈이 하나 있다.

KV 캐시 — 이거 빼먹으면 컨텍스트 늘릴 때 터진다

추론 중엔 가중치 말고 KV 캐시가 또 필요하다. 지금까지 처리한 모든 토큰의 Key/Value 벡터를 레이어마다 쌓아 두는 공간이고, 대략 이렇게 잡는다.

KV 캐시(바이트) ≈ 2 (K와 V) × 레이어 수 × KV 헤드 수 × 헤드 차원 × 정밀도(바이트) × 컨텍스트 길이

Llama 3.1 8B 설정값(레이어 32, KV 헤드 8, 헤드 차원 128)을 FP16 으로 넣으면 토큰당 2 × 32 × 8 × 128 × 2 = 131,072 바이트, 즉 토큰당 128KiB. 8,192 토큰이면 딱 1GiB, 32K 면 4GiB, 131K 를 꽉 채우면 16GiB 로 가중치보다 커진다. 여기서 두 가지가 보인다.

하나, 손계산을 해보면 8B Q4_K_M 가중치 약 4.6GiB + 8K 컨텍스트 KV 캐시 약 1GiB + CUDA 컨텍스트·작업 버퍼 수백 MB 라서, 내 8GB 카드에 들어가긴 하는데 여유가 크지 않다. 컨텍스트를 16K 로 올리는 순간 넘친다. 이건 뒤에서 num_ctx 를 32K 까지 올려 실제로 눈으로 확인한다.

둘, 이 수치는 모델 아키텍처마다 다르다. 같은 8B라도 Qwen3 8B 는 레이어가 36개라 토큰당 144KiB 이고, GQA 안 쓰는 구형 모델은 몇 배가 된다. 그래서 “대략”이라고 쓸 수밖에 없고, 정확한 값은 서버 로그의 KV 버퍼 크기로 확인하는 게 맞다. 컨텍스트 늘려 놓고 메모리 왜 부족하냐 싶으면 십중팔구 이 항목이다.

VRAM 을 넘으면 — 오프로딩과 그 대가

가중치+KV 가 VRAM 을 넘으면 llama.cpp 는 레이어 일부만 GPU 에 올리고 나머지는 CPU 로 돌린다(-ngl, --n-gpu-layers). Ollama 도 알아서 같은 짓을 한다. 문제는 속도다. 토큰 하나 뽑을 때마다 모든 레이어 가중치를 한 번씩 읽어야 하는데, GPU 의 GDDR 은 수백 GB/s~1TB/s 급이고 CPU 의 DDR5 듀얼채널은 겨우 수십 GB/s 다. 레이어 절반이 CPU 로 넘어가면 그 절반이 열 배 이상 느린 메모리에서 읽히니까 전체 속도가 GPU 만 쓸 때의 몇 분의 일로 곤두박질친다. “GPU 있는데 왜 CPU 처럼 기어가지” 싶으면 제일 먼저 오프로딩부터 의심하면 된다.

경로 A — Ollama 로 10분 컷 (WSL2 드라이버는 건드리지 마라)

메모리 예산이 섰으니 제일 빠른 길부터. 다만 WSL2 라면 설치 전에 확인하고 갈 게 하나 있다.

WSL2 의 GPU 는 Windows 쪽 드라이버만 깔면 WSL 안에서 libcuda.so 가 자동으로 매핑되는 구조다. 그래서 GPU 를 붙이겠다고 WSL 안에서 sudo apt install nvidia-driver-550 같은 걸 때리면 그 매핑을 덮어써서 인식이 통째로 깨진다. NVIDIA 의 CUDA on WSL 가이드가 “WSL 안에 리눅스 디스플레이 드라이버를 설치하지 말라”고 못을 박아 둔 이유다. 리눅스에 익숙한 사람일수록 반사적으로 손이 가는 자리라, 여기서 흔히 막힌다.

원칙은 단순하다. Windows 쪽 NVIDIA 드라이버만 최신으로 유지하고, WSL 안에는 CUDA 툴킷만(그것도 WSL-Ubuntu 전용 패키지로) 깐다. 내 환경이 딱 이 상태라서 WSL 안에서 nvidia-smi 가 드라이버 591.86 / 8188MiB 를 정상으로 뱉고(앞의 하드웨어 점검 그림), Ollama 도 그 위에서 CUDA 백엔드로 GPU 를 그대로 잡았다.

# 1) 설치 (Linux/WSL2 공통). systemd 서비스로 등록되고 GPU 를 자동 감지한다.
curl -fsSL https://ollama.com/install.sh | sh

# 2) 모델 받고 대화. 태그를 생략하면 latest, 명시하면 크기/양자화를 고른다.
ollama pull llama3.1:8b
ollama run llama3.1:8b

# 3) 다른 터미널에서: 얼마나 GPU 에 올라갔는지 확인
ollama ps

ollama ps 의 PROCESSOR 열이 100% GPU 면 전부 VRAM 에 올라간 거고, xx%/yy% CPU/GPU 로 나오면 오프로딩이 일어난 거다. 그럼 더 작은 양자화나 모델로 내리거나 컨텍스트를 줄여야 한다. 내 4060 Ti 에선 8B Q4_K_M 이 기본 컨텍스트일 땐 100% GPU 로 잘 올라갔다.

기본 컨텍스트가 생각보다 짧다

내가 깐 Ollama 는 0.33.3 인데, 서버를 띄우면 로그에 이런 줄이 찍힌다.

msg="vram-based default context" total_vram="8.0 GiB" default_num_ctx=4096

VRAM 을 보고 기본 컨텍스트를 정하는 구조고, 내 8GB 카드에선 그 값이 4,096 토큰으로 잡혔다(Ollama FAQ 가 말하는 기본값과 같은 값이다). 긴 문서를 넣으면 이 창을 넘는 부분이 조용히 잘려 나가는데, 에러가 안 나니까 알아채기가 어렵다. 답이 이상한데 프롬프트를 아무리 고쳐도 그대로면 여기부터 의심하는 게 맞다. 조정은 세 갈래.

# (a) 서버 전체 기본값을 바꾸기
OLLAMA_CONTEXT_LENGTH=8192 ollama serve

# (b) API 요청마다 options.num_ctx 지정
curl http://localhost:11434/api/generate -d '{
  "model": "llama3.1:8b",
  "prompt": "GGUF 가 무엇인지 두 문장으로 설명해줘",
  "stream": false,
  "options": { "num_ctx": 8192 }
}'

(c)는 Modelfile 로 파생 모델을 굽는 방법이다. 시스템 프롬프트까지 같이 박아 둘 수 있어서 “사내 코드 리뷰어” 같은 용도별 모델을 만들 때 편하다.

# Modelfile
FROM llama3.1:8b
PARAMETER num_ctx 8192
PARAMETER temperature 0.2
SYSTEM 당신은 한국어로 답하는 C/C++ 코드 리뷰어입니다. 근거 없는 추측은 하지 않습니다.
ollama create c-reviewer -f Modelfile
ollama run c-reviewer

앞에서 계산했듯 num_ctx 8192 는 8B 기준 KV 캐시 약 1GiB 를 더 요구한다. 그럼 실제로 얼마나 아픈지, 아예 8배로 올려서 재봤다.

컨텍스트를 8배로 올렸더니 속도가 반토막 났다

llama3.1:8b(ollama list 기준 4.9GB)를 기본값으로 띄워 ollama psnvidia-smi 를 같이 찍고, num_ctx 만 32768 로 올려서 똑같이 찍었다. 모델은 D 드라이브(/mnt/d/ollama-models)에 받아 뒀다.

Ollama 컨텍스트 실험 터미널 출력을 그대로 옮긴 그림. ollama list 에 llama3.1:8b 4.9GB, 기본 상태의 ollama ps 는 SIZE 5.3GB·PROCESSOR 100% GPU·CONTEXT 4096 이고 nvidia-smi 는 6022MiB / 8188MiB. num_ctx 를 32768 로 올린 뒤에는 ollama ps 가 SIZE 9.5GB·PROCESSOR 34%/66% CPU/GPU·CONTEXT 32768 로 바뀌고 nvidia-smi 는 6958MiB / 8188MiB 를 가리킨다

숫자만 뽑으면 이렇다.

측정값 기본 (num_ctx 4096) num_ctx 32768
ollama ps SIZE 5.3 GB 9.5 GB
PROCESSOR 100% GPU 34%/66% CPU/GPU
nvidia-smi 사용량 6022 MiB / 8188 MiB 6958 MiB / 8188 MiB
생성 속도(eval rate) 46.39 tok/s 20.70 tok/s

컨텍스트를 8배로 올렸더니 모델이 요구하는 메모리가 5.3GB 에서 9.5GB 로 뛰었다. 8188MiB 카드에 9.5GB 가 들어갈 리 없으니 Ollama 가 34% 를 CPU 로 내려버렸고, 생성 속도는 46.39 tok/s 에서 20.70 tok/s 로 절반 이하가 됐다. 앞에서 손계산한 토큰당 128KiB 를 늘어난 28,672 토큰에 곱하면 3.5GiB 인데, 실제 증가분 4.2GB 와 얼추 맞아떨어진다. KV 캐시가 범인이라는 게 눈으로 확인된 셈이다.

정작 재미있는 건 nvidia-smi 쪽이다. VRAM 사용량은 6022MiB → 6958MiB 로 1GB 도 안 늘었다. GPU 가 꽉 차서 나머지를 시스템 RAM 으로 흘린 거니까 당연한데, VRAM 그래프만 보고 있으면 “아직 800MiB 남았네” 하고 착각하기 딱 좋다. 오프로딩이 일어났는지는 nvidia-smi 가 아니라 ollama ps 의 PROCESSOR 열로 봐야 한다. 컨텍스트를 올린 뒤엔 여기부터 다시 보는 습관을 들이자.

엔드포인트는, Ollama 가 기본으로 127.0.0.1:11434 에 바인딩하고, 자체 API(POST /api/generate, POST /api/chat) 외에 OpenAI 호환 POST /v1/chat/completions 를 준다. 그래서 기존 코드를 거의 그대로 꽂을 수 있다. 예제는 마지막 섹션에서 몰아서 다룬다.

경로 B — llama.cpp 소스 빌드 (옛 옵션 LLAMA_CUBLAS 주의)

Ollama 가 감춰 둔 걸 전부 손에 쥐고 싶으면 llama.cpp 를 직접 빌드한다. 빌드하면 build/bin/ 에 대화용 llama-cli, HTTP 서버 llama-server, 양자화 도구 llama-quantize, 벤치 llama-bench 가 나온다. 앞에서 밝혔듯 이 경로는 이번에 내가 빌드해 보지 않았다. 아래 명령과 옵션은 공식 빌드 문서를 정리한 것이니, 그렇게 읽어 달라.

먼저 옵션 이름부터 짚고 간다. 인터넷에 굴러다니는 오래된 글에는 CUDA 빌드가 cmake -B build -DLLAMA_CUBLAS=ON 으로 적혀 있는데, 이 이름은 지금 안 먹는다. 현재 공식 빌드 문서 기준 CUDA 옵션은 GGML_CUDA 다. 고약한 건 존재하지 않는 CMake 옵션을 줘도 빌드가 그냥 성공한다는 점이다. 경고 한 줄 없이 무시되고 CPU 빌드가 나오니, 나중에 “빌드는 됐는데 GPU 를 안 쓴다”로 돌아온다. 오래된 명령어를 복붙할 때 제일 조심할 대목이다.

sudo apt install -y build-essential cmake git
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp

# CPU 전용 빌드
cmake -B build
cmake --build build --config Release -j "$(nproc)"

# CUDA 빌드 (WSL-Ubuntu 용 CUDA 툴킷이 깔려 있어야 nvcc 를 찾는다)
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release -j "$(nproc)"

기본 빌드는 빌드 머신 GPU 아키텍처에 맞춰 최적화(GGML_NATIVE)되니까, 다른 GPU 로 바이너리를 옮길 거면 -DGGML_NATIVE=OFF 를 붙이거나 -DCMAKE_CUDA_ARCHITECTURES="86;89" 로 대상을 명시하자.

GGUF 받아서 띄우기

Hugging Face GGUF 저장소에서 원하는 양자화 파일 하나만 받으면 된다. llama.cpp 도구들은 저장소:양자화 형식의 -hf 옵션으로 다운로드·캐시를 대신해 주기도 한다.

# 방법 1: llama.cpp 가 직접 받는다 (기본 양자화는 Q4_K_M)
./build/bin/llama-cli -hf bartowski/Meta-Llama-3.1-8B-Instruct-GGUF:Q4_K_M

# 방법 2: 파일을 직접 받아 둔다 (huggingface_hub 의 hf CLI, 구버전은 huggingface-cli)
pip install -U huggingface_hub
hf download bartowski/Meta-Llama-3.1-8B-Instruct-GGUF \
   Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf --local-dir ./models

# OpenAI 호환 서버. -ngl 99 는 "가능한 모든 레이어를 GPU 로", -c 는 컨텍스트 길이
./build/bin/llama-server -m ./models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf -ngl 99 -c 8192

llama-server 는 기본으로 127.0.0.1:8080 에서 대기하고, 브라우저로 열면 내장 웹 UI 가 뜬다. 서버 로그에 모델 버퍼 크기랑 KV 캐시 버퍼 크기가 그대로 찍히니까, 앞에서 한 손계산이 맞았는지 여기서 눈으로 검증할 수 있다.

그리고 여기서 KV 캐시 얘기가 실전으로 돌아온다. -c 를 생략하면 llama-server 는 모델 메타데이터에 적힌 학습 컨텍스트 길이를 그대로 쓰려 드는데, Llama 3.1 은 그게 131,072 다. 앞의 손계산대로면 KV 캐시만 16GiB 를 요구하는 값이라 8GB 카드에 들어갈 방법이 없다. 컨텍스트는 반드시 -c 로 명시하자. 8GB VRAM 이면 -c 8192 정도가 무난한 출발점이다.

방금 Ollama 에서 본 게 정확히 이 현상의 순한 버전이다. 거기선 컨텍스트를 32K 로 올리자 카드에서 밀려난 만큼이 CPU 로 내려가 속도가 반토막 나는 선에서 끝났지만, -ngl 로 전부 GPU 에 올리라고 못박아 둔 상태라면 자리가 없을 때 로딩 단계에서 그냥 죽는다. 어느 쪽이든 원인은 하나, 컨텍스트 길이다.

직접 양자화해서 계산 검증하기

메모리 표 수치가 진짜 나오나 확인하는 가장 확실한 방법은 직접 양자화해 보는 거다. F16 GGUF 를 받아 llama-quantize 에 넣고 크기를 비교하면 된다.

./build/bin/llama-quantize ./models/model-f16.gguf ./models/model-Q4_K_M.gguf Q4_K_M
ls -lh ./models/

Llama 3.1 8B 기준 F16 약 15GiB 가 Q4_K_M 약 4.6GiB 로 줄어든다(파라미터당 2바이트 → 약 0.61바이트, 약 3.3배). 앞 표랑 딱 맞는다. 양자화는 CPU 만으로 몇 분이면 끝나고, 결과물은 Ollama 에서도 그대로 먹는다.

그래서 두 경로는 결국 하나로 만난다. Ollama 는 속에 llama.cpp/ggml 을 품고 있어서, 위에서 만든 GGUF 를 Modelfile 한 줄로 등록할 수 있다.

# Modelfile
FROM ./models/model-Q4_K_M.gguf
PARAMETER num_ctx 8192
ollama create my-q4 -f Modelfile
ollama run my-q4

정리하면 “모델 파일은 llama.cpp 도구로 만들고 관리하되, 일상 사용은 Ollama 편의성에 맡긴다”는 조합이 가능하다. 반대로 Ollama 가 받아 둔 blob 도 그냥 GGUF 라서 llama-server -m 으로 바로 열린다.

도구에 꽂고 성능 만지기

서버가 떴으면 나머지는 주소 문제다. Ollama 는 11434, llama-server 는 8080 이라는 것만 다르고 경로는 똑같다.

# llama-server (포트 8080). Ollama 면 11434 로 바꾸고 model 에 태그명을 넣는다.
curl -s http://localhost:8080/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "local",
    "messages": [
      {"role": "system", "content": "간결하게 한국어로 답한다."},
      {"role": "user", "content": "mmap 으로 모델을 읽으면 왜 로딩이 빠른가?"}
    ],
    "temperature": 0.2
  }' | python3 -c 'import json,sys; print(json.load(sys.stdin)["choices"][0]["message"]["content"])'

Python 이면 openai SDK 의 base_url 만 바꾸면 된다. api_key 는 SDK 가 빈 값을 거부하니까 아무 문자열이나 넣는다(Ollama 문서도 “필수지만 무시됨”이라고 한다).

from openai import OpenAI

# Ollama 라면 base_url="http://localhost:11434/v1", model="llama3.1:8b"
client = OpenAI(base_url="http://localhost:8080/v1", api_key="local")

resp = client.chat.completions.create(
    model="local",
    messages=[
        {"role": "system", "content": "간결하게 한국어로 답한다."},
        {"role": "user", "content": "KV 캐시가 컨텍스트 길이에 비례하는 이유를 설명해줘."},
    ],
    temperature=0.2,
)
print(resp.choices[0].message.content)

OpenAI 호환 API 를 받는 CLI 도구나 에이전트 프레임워크면 대개 OPENAI_BASE_URL(또는 동등한 설정)과 모델명만 바꿔 같은 식으로 붙는다. 다만 도구마다 function calling·스트리밍·구조화 출력 지원 범위가 다르니, 붙이기 전에 그 도구가 실제로 때리는 엔드포인트가 뭔지 확인하는 게 안전하다.

속도는 남의 벤치 말고 내가 잰다

토큰/초(tok/s) 절대치는 하드웨어마다 천차만별이라 남의 벤치는 참고만 하고 내 머신에서 직접 재는 게 맞다. 재는 방법은 두 가지다.

# 프롬프트 처리(pp512)와 생성(tg128) 속도를 t/s 로 출력
./build/bin/llama-bench -m ./models/model-Q4_K_M.gguf -ngl 99 -p 512 -n 128

# Ollama: 응답 끝에 prompt eval rate / eval rate 가 tokens/s 로 찍힌다
ollama run llama3.1:8b --verbose

--verbose 는 답변 끝에 측정치를 그대로 붙여 준다. 내 4060 Ti 에서 llama3.1:8b 를 기본 컨텍스트로 돌린 결과가 이거다.

ollama run llama3.1:8b
verbose 실행 결과 터미널 출력을 그대로 옮긴 그림. 페이지 폴트를 설명한 영어 답변 아래에 total duration 1.951754323s, load duration 24.715812ms, prompt eval count 21 token(s), prompt eval cached 20 token(s), prompt eval rate 3.90 tokens/s, eval count 77 token(s), eval duration 1.650821s, eval rate 46.64 tokens/s 가 출력되어 있다

맨 아랫줄 eval rate 46.64 tok/s 가 생성 속도(tg)다. 같은 조건으로 한 번 더 돌린 실행은 85토큰을 1.83초에 뽑아 46.39 tok/s 였으니, 이 머신의 8B Q4 생성 속도는 46 tok/s 언저리라고 보면 되겠다. 사람이 읽는 속도보다 한참 빨라서 체감상 답답할 일은 없다.

위쪽 prompt eval rate 가 3.90 tok/s 로 참담해 보이는데, 이건 겁먹을 숫자가 아니다. 프롬프트 21토큰 중 20토큰이 캐시에 맞아서(prompt eval cached: 20) 실제로 처리한 게 1토큰뿐이라 나온 값이다. 짧은 프롬프트의 pp 수치는 이런 식으로 의미가 없어서, pp 를 제대로 보려면 llama-bench-p 512 같은 걸 써야 한다.

반대쪽 숫자는 앞 섹션에서 이미 봤다. num_ctx 를 32768 로 올려 34% 가 CPU 로 내려간 상태에선 91토큰 / 4.40초, 20.70 tok/s 였다. 레이어의 3분의 1이 DDR5 로 내려간 것만으로 속도가 절반 이하다. 아래 표의 -ngl 이 왜 맨 위에 있는지가 이 두 수치에 다 들어 있다.

llama-bench 는 두 수치를 따로 준다. 프롬프트 처리(pp) 는 연산 병목이라 GPU 코어 성능을, 토큰 생성(tg) 은 메모리 대역폭을 반영한다. 긴 문서 넣었을 때 답이 늦게 시작되면 pp 가, 답이 한 글자씩 느리게 나오면 tg 가 문제다. 내 두 측정도 결국 tg 얘기다. 전부 GPU 면 46 tok/s, 3분의 1이 CPU 로 내려가면 20 tok/s. 그 경계가 정확히 어디인지는 각자 카드에서 직접 재보는 수밖에 없다.

체감 차이 큰 옵션 세 개만 추리면 이렇다.

옵션 (llama.cpp / Ollama) 효과 주의
-ngl N / 자동 GPU 에 올릴 레이어 수. 전부 올라가야 빠르다 넘치면 OOM 대신 조용히 느려질 수 있음
-t N / 자동 CPU 추론 스레드 수. 물리 코어 수에 맞추는 게 보통 최적 하이퍼스레드까지 채우면 오히려 느려지기도
-fa on / OLLAMA_FLASH_ATTENTION=1 플래시 어텐션. 긴 컨텍스트 메모리·속도 개선 기본값이 이미 켜는 경우가 많음

KV 캐시를 양자화하는 옵션(llama.cpp -ctk q8_0 -ctv q8_0, Ollama OLLAMA_KV_CACHE_TYPE=q8_0)도 있다. 플래시 어텐션이 켜진 상태에서 KV 캐시 메모리를 반으로 줄여 주니까, 컨텍스트가 커서 VRAM 이 빠듯할 때 제일 먼저 시도할 만하다. 8GB 카드론 특히 고마운 옵션이다.

자주 밟는 지뢰 정리

  • 내가 직접 확인한 것과, 공식 문서가 “이러지 마라”고 못박은 것들을 한 표로 묶었다.
증상 원인 조치
로딩 중 OOM, 서버 즉시 종료 가중치 + KV 캐시 > VRAM 컨텍스트(-c/num_ctx) 줄이기 → KV 캐시 양자화 → 더 낮은 양자화 → -ngl 줄여 일부 CPU 오프로딩
GPU 있는데 CPU 처럼 느림 오프로딩 발생, 또는 CPU 빌드 사용 ollama ps·서버 로그의 오프로드 레이어 수 확인, -DGGML_CUDA=ON 빌드인지 확인
WSL2 에서 GPU 미인식 WSL 안에 리눅스 드라이버를 깔았거나 Windows 드라이버가 구버전 WSL 내 드라이버 제거, nvidia-smi WSL 동작 확인, Windows 드라이버 갱신
긴 입력이 잘려 나감 기본 컨텍스트 4,096 num_ctx/-c 명시
CPU 추론이 이상하게 느림 WSL2 메모리 상한(50%)에 걸려 스왑 .wslconfigmemory 상향
CUDA 빌드했는데 GPU 안 씀 옛 옵션 LLAMA_CUBLAS 사용 -DGGML_CUDA=ON 으로 재빌드

마치며 — 어느 길로 갈지

메모리 계산부터 순서를 다시 밟아 보고 내가 내린 결론은 이렇다.

  • Ollama 로 충분한 경우: 혼자 쓰는 개발 환경, 에디터·CLI 도구 백엔드, 모델을 자주 갈아 끼우며 실험할 때. 개인 사용은 대개 여기서 끝난다.
  • llama.cpp 로 내려가야 하는 경우: 자원이 빠듯해서 레이어·스레드·KV 캐시 타입을 손으로 통제해야 할 때, 직접 양자화·변환이 필요할 때, 임베디드나 컨테이너에 최소 의존성으로 넣을 때, 그리고 추론 엔진 속을 공부하고 싶을 때. 내 8GB 카드처럼 여유가 없을수록 이쪽 통제력이 아쉬워진다.
  • vLLM 을 볼 경우: 여러 사용자 동시 요청, 24GB 이상 VRAM, safetensors 원본을 그대로 써야 할 때.

어느 쪽이든 출발점은 똑같다. 파라미터 수와 양자화 비트로 가중치 크기를 구하고, 컨텍스트 길이로 KV 캐시를 더한 뒤, 그 합이 내 VRAM(또는 RAM) 안에 들어가는지 본다. 이 손계산 하나 해두면 로컬 LLM 환경 구축에서 마주치는 문제 대부분은 미리 설명이 된다.

이번에 num_ctx 하나 만졌다고 46.39 tok/s 가 20.70 tok/s 로 주저앉는 걸 직접 보고 나니 생각이 좀 바뀌었다. “돌아가긴 하는데 왜 느리지”의 답은 대개 이 계산 안에 있다.

다음엔 미뤄 둔 llama.cpp 를 실제로 빌드해서 llama-bench 로 pp/tg 를 갈라 재보고, KV 캐시 양자화가 8GB 카드에서 얼마나 벌어 주는지까지 실측으로 붙여 보겠다. 혹시 4060 Ti 급에서 30B 를 그럭저럭 굴리는 조합을 찾은 분 있으면 알려주시면 고맙겠습니다.

자주 묻는 질문

8GB VRAM 이면 몇 B 모델까지 되나요?

8B 를 Q4_K_M 으로 받으면 파일이 4.58GiB 고, 여기에 8K 컨텍스트 KV 캐시 약 1GiB 를 더해도 8188MiB 안에 들어갑니다. 실제로 내 RTX 4060 Ti 에서 llama3.1:8b 는 기본 컨텍스트 4096 일 때 ollama ps 가 100% GPU 로, nvidia-smi 가 6022MiB 로 찍혔다. 반면 14B Q4_K_M 은 8.38GiB 라 가중치만으로 이미 넘치니, 8GB 카드에선 8B Q4 가 사실상 상한선이라고 보면 된다.

GPU 가 있는데 왜 CPU 처럼 느린가요?

십중팔구 오프로딩입니다. 모델이 VRAM 을 넘으면 레이어 일부가 CPU 로 내려가고, 그 부분은 수십 GB/s 짜리 DDR5 에서 읽히니 전체가 같이 느려진다. 문제는 nvidia-smi 로는 이게 잘 안 보인다는 것이다 — 내 실험에서 VRAM 사용량은 6022MiB 에서 6958MiB 로 거의 그대로였다. ollama ps 의 PROCESSOR 열이 100% GPU 인지 34%/66% CPU/GPU 인지부터 확인하세요.

컨텍스트를 늘렸더니 왜 갑자기 느려지나요?

KV 캐시가 컨텍스트 길이에 비례해 늘기 때문입니다. num_ctx 를 4096 에서 32768 로 올렸더니 ollama ps 의 SIZE 가 5.3GB 에서 9.5GB 로 뛰었고, 생성 속도는 46.39 tok/s 에서 20.70 tok/s 로 절반 이하가 됐다. 늘어난 28,672 토큰에 8B 기준 토큰당 128KiB 를 곱하면 3.5GiB 니까 숫자도 대충 맞아떨어진다. 컨텍스트는 필요한 만큼만 올리는 게 맞다.

WSL2 인데 32GB 램이 15Gi 로 나옵니다

램이 빠진 게 아니라 WSL2 의 기본 동작입니다. .wslconfigmemory 기본값이 호스트 RAM 의 50% 라, 32GB 머신이면 16GB 가 상한이고 커널 몫을 빼면 free -h 에 15Gi 로 보인다. CPU 로 큰 모델을 올릴 거면 %UserProfile%\.wslconfig 에서 값을 올린 뒤 wsl --shutdown 으로 재시작하면 된다. GPU VRAM 은 이 설정과 무관하다.

Ollama 와 llama.cpp 중 뭘 써야 하나요?

혼자 쓰는 개발 환경이거나 모델을 자주 갈아 끼우며 실험하는 단계라면 Ollama 로 충분합니다. 레이어 배치·스레드 수·KV 캐시 타입을 손으로 붙잡아야 하거나, GGUF 를 직접 양자화·변환해야 하거나, 최소 의존성으로 컨테이너에 넣어야 할 때 llama.cpp 를 직접 빌드할 이유가 생긴다. 둘은 같은 GGUF 파일을 쓰니 나중에 갈아타도 모델을 다시 받을 필요는 없다.

핵심 요약

  • 8B Q4_K_M 은 8GB VRAM(RTX 4060 Ti, 8188MiB)에 100% GPU 로 다 올라간다 — 기본 컨텍스트 4096 에서 적재 5.3GB, VRAM 6022MiB.
  • 컨텍스트를 4096 에서 32768 로 올리는 순간 34%/66% CPU/GPU 로 쪼개지고, 생성 속도가 46.39 tok/s 에서 20.70 tok/s 로 절반 이하가 된다.
  • 그래서 설치보다 메모리 예산이 먼저다. 가중치는 사실상 GGUF 파일 크기이고, 여기에 KV 캐시(8B·8K 컨텍스트 기준 약 1GiB)를 더해 VRAM 과 비교하면 된다.
  • WSL2 는 .wslconfig 의 메모리 기본 상한이 호스트 RAM 의 50% 라, 32GB 머신인데도 free -h 가 15Gi 로 나온다.
  • WSL 안에 리눅스 NVIDIA 드라이버를 깔면 GPU 인식이 통째로 깨진다. Windows 쪽 드라이버만 최신으로 두고, WSL 안에는 CUDA 툴킷만 깐다.

참고 자료

C언어 함수 및 라이브러리 함수 사용하기

C언어 함수 및 라이브러리 함수 사용하기. C언어에서 함수는 코드의 재사용성과 가독성을 높이는 핵심 요소입니다. 본 글에서는 사용자 정의 함수와 표준 라이브러리 함수의 개념과 활용법을 살펴봅니다. 초보자도 쉽게 이해할 수 있도록 단계별로 설명합니다.

목차

소개

C언어에서 함수는 코드의 재사용성과 가독성을 높이는 핵심 요소입니다. 본 글에서는 사용자 정의 함수와 표준 라이브러리 함수의 개념과 활용법을 살펴봅니다. 초보자도 쉽게 이해할 수 있도록 단계별로 설명합니다.

맨 위로

1. C언어 함수의 기본 개념

C언어에서 함수는 특정 작업을 수행하는 코드 블록입니다. 함수를 사용하면 코드의 재사용성과 가독성이 향상되며, 프로그램을 모듈화할 수 있습니다. 함수는 정의(definition)와 호출(call) 두 가지 중요한 개념으로 구성되어 있으며, 매개변수를 통해 데이터를 전달하고 반환값으로 결과를 돌려줄 수 있습니다. 이 섹션에서는 함수의 기본 구조와 동작 원리를 이해하고, 매개변수와 반환값의 역할을 실용적인 예제와 함께 살펴봅니다.

1.1 함수 정의와 호출의 이해

함수 정의는 함수가 수행할 작업을 명확히 작성하는 부분입니다. 기본 구조는 다음과 같습니다:

반환형 함수이름(매개변수 목록) {
    // 함수 몸체
}
  • 반환형: 함수가 돌려주는 값의 자료형
  • 함수이름: 호출 시 사용할 이름
  • 매개변수 목록: 함수에 전달할 입력값들

함수 호출은 정의된 함수를 실제로 실행시키는 과정입니다. 예를 들어:

int add(int a, int b) {
    return a + b;
}

int main() {
    int result = add(3, 5); // 함수 호출
    printf("결과: %d\n", result);
    return 0;
}

중요: 함수 호출 시 매개변수와 인자의 순서와 자료형이 일치해야 합니다.

함수는 프로그램의 흐름을 제어하는 데 중요한 역할을 하며, 복잡한 작업을 작은 단위로 나누어 관리할 수 있게 해줍니다.

1.2 매개변수와 반환값

매개변수(Parameter)는 함수에 입력되는 값이며, 반환값(Return value)은 함수가 작업 후 돌려주는 결과입니다.

매개변수 종류

종류 설명
값에 의한 전달 (Call by Value) 매개변수로 전달된 값을 복사하여 함수 내부에서 사용. 원본 값은 변경되지 않음
주소에 의한 전달 (Call by Reference) 포인터를 이용해 변수의 주소를 전달, 함수 내부에서 원본 값을 직접 변경 가능

반환값

  • 함수는 return 키워드로 값을 반환합니다.
  • 반환형이 void인 경우, 값을 반환하지 않습니다.

예제: 매개변수와 반환값 활용

#include <stdio.h>

// 두 정수의 합을 반환하는 함수
int sum(int x, int y) {
    return x + y;
}

// 배열의 값을 모두 더하는 함수 (주소에 의한 전달)
int array_sum(int *arr, int size) {
    int total = 0;
    for (int i = 0; i < size; i++) {
        total += arr[i];
    }
    return total;
}

int main() {
    int a = 10, b = 20;
    printf("sum(a, b) = %d\n", sum(a, b));

    int nums[] = {1, 2, 3, 4, 5};
    printf("array_sum(nums, 5) = %d\n", array_sum(nums, 5));

    return 0;
}

팁: 매개변수를 적절히 사용하면 함수의 재사용성과 범용성을 높일 수 있습니다.

맨 위로

2. 사용자 정의 함수 작성법

C언어에서 사용자 정의 함수는 코드의 재사용성과 가독성을 높이는 핵심 요소입니다. 함수는 특정 작업을 수행하는 코드 블록으로, 함수 선언과 구현을 통해 정의됩니다. 적절한 함수 작성법을 익히면 복잡한 프로그램도 체계적으로 관리할 수 있습니다.

2.1 함수 선언과 구현 방법

함수는 먼저 반환형과 함수명을 명시하는 선언문으로 알려주고, 실제 동작 코드를 구현하는 정의문으로 작성합니다. 선언은 컴파일러에게 함수의 존재를 알리고, 구현은 함수가 실제로 수행할 작업을 기술합니다.

// 함수 선언
int add(int a, int b);

// 함수 구현
int add(int a, int b) {
    return a + b;
}
  • 반환형: 함수가 반환하는 값의 타입을 지정합니다. 반환값이 없으면 void를 사용합니다.
  • 매개변수: 함수가 입력받는 변수들로, 타입과 이름을 명시합니다.
  • 함수명: 함수의 이름으로, 호출할 때 사용됩니다.

함수 선언은 보통 헤더 파일에 작성하거나, 함수 구현 전에 위치시킵니다. 함수 정의는 보통 소스 파일에 작성합니다.

함수 작성 시 명확한 이름과 적절한 매개변수 사용은 유지보수에 큰 도움이 됩니다.

2.2 함수의 유용한 활용 사례

함수는 프로그램을 모듈화하고 반복되는 코드를 줄이는 데 매우 유용합니다. 아래는 함수 활용의 대표적인 사례들입니다.

  1. 코드 재사용: 동일한 작업을 여러 곳에서 수행할 때, 함수로 작성하면 중복 코드를 줄일 수 있습니다.

  2. 복잡한 문제 분할: 큰 문제를 작은 함수 단위로 나누어 구현하면 이해와 디버깅이 쉬워집니다.

  3. 매개변수를 통한 다양한 동작: 함수에 인자를 전달하여 다양한 결과를 낼 수 있습니다.

  4. 재귀 함수 사용: 자기 자신을 호출하는 함수로, 반복적인 문제를 간결하게 해결할 수 있습니다.

// 재귀를 이용한 팩토리얼 함수
int factorial(int n) {
    if (n <= 1) return 1;
    else return n * factorial(n - 1);
}
  1. 포인터를 이용한 함수 내 변수 변경: 포인터를 매개변수로 전달하면 함수 내에서 변수 값을 직접 변경할 수 있습니다.
void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

함수 활용은 프로그램의 효율성과 유지보수성을 크게 향상시키므로, 다양한 형태의 함수 작성법을 익히는 것이 중요합니다.

맨 위로

3. 표준 라이브러리 함수 소개

C언어의 표준 라이브러리 함수는 프로그래밍에서 자주 필요한 기능들을 미리 구현해 놓은 함수들의 집합입니다. 입출력, 문자열 처리, 메모리 관리 등 다양한 영역에서 효율적인 개발을 가능하게 합니다. 이 섹션에서는 특히 입출력 함수와 문자열 처리 함수에 대해 실용적인 사용법과 예제를 중심으로 살펴보겠습니다.

3.1 입출력 함수 활용

C언어 표준 라이브러리에서 가장 기본이 되는 입출력 함수는 printfscanf입니다. printf는 화면에 데이터를 출력할 때, scanf는 키보드로부터 데이터를 입력받을 때 사용합니다.

printf 함수

  • 서식 지정자(format specifier)를 이용해 다양한 데이터 타입 출력 가능
  • 주요 서식 지정자 예:
서식 지정자 의미
%d 정수(int)
%f 실수(float)
%c 문자(char)
%s 문자열(char[])
int num = 10;
printf("정수 출력: %d\n", num);

scanf 함수

  • 사용자 입력을 변수에 저장
  • 입력 시 변수 주소를 전달해야 함
int age;
printf("나이를 입력하세요: ");
scanf("%d", &age);
printf("입력한 나이: %d\n", age);

중요: scanf 사용 시 입력 버퍼에 남아있는 개행 문자나 잘못된 입력으로 인해 예상치 못한 동작이 발생할 수 있으므로 주의해야 합니다.

추가 입출력 함수

  • getchar(), putchar() : 문자 단위 입출력
  • gets(), puts() : 문자열 입출력 (단, gets()는 보안 취약점으로 인해 사용 권장하지 않음)

입출력 함수들을 적절히 활용하면 사용자와의 상호작용을 원활하게 구현할 수 있습니다.

3.1.1 입출력 함수 활용

(상세 설명 내용)

3.2 문자열 처리 함수

C언어에서 문자열은 문자 배열로 표현되며, 표준 라이브러리 <string.h>에 다양한 문자열 처리 함수가 제공됩니다. 문자열 길이 측정, 복사, 연결, 비교 등 기본적인 작업을 쉽게 수행할 수 있습니다.

주요 문자열 함수

함수명 기능 예제 코드
strlen 문자열 길이 반환 strlen("hello") → 5
strcpy 문자열 복사 strcpy(dest, src)
strcat 문자열 연결 strcat(dest, src)
strcmp 문자열 비교 strcmp(str1, str2)

사용 예시

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

int main() {
    char str1[20] = "Hello";
    char str2[] = " World!";

    printf("str1 길이: %lu\n", strlen(str1));

    strcat(str1, str2);
    printf("문자열 연결 결과: %s\n", str1);

    char str3[20];
    strcpy(str3, str1);
    printf("복사된 문자열: %s\n", str3);

    if (strcmp(str1, str3) == 0) {
        printf("str1과 str3는 같습니다.\n");
    }

    return 0;
}

참고: 문자열 함수 사용 시 버퍼 크기 초과에 주의해야 하며, 안전한 함수(strncpy, strncat 등)를 사용하는 것이 좋습니다.

문자열 처리 함수는 텍스트 데이터를 다루는 프로그램에서 필수적이며, 올바른 사용법을 익히는 것이 중요합니다.

맨 위로

4. 라이브러리 함수 사용 시 주의사항

C언어에서 라이브러리 함수를 사용할 때는 헤더 파일의 올바른 포함과 링커 설정, 그리고 함수 호출 후 발생할 수 있는 오류를 적절히 처리하는 것이 중요합니다. 이를 통해 컴파일 및 실행 단계에서 발생할 수 있는 문제를 예방하고, 안정적인 프로그램을 작성할 수 있습니다.

4.1 헤더 파일 포함과 링크 문제

라이브러리 함수를 사용하려면 해당 함수가 선언된 헤더 파일을 반드시 포함해야 합니다. 헤더 파일을 포함하지 않으면 컴파일러가 함수 원형을 알 수 없어 경고나 오류가 발생할 수 있습니다.

또한, 함수가 정의된 라이브러리를 링커에 제대로 연결해야 합니다. 예를 들어, 수학 함수(sin, cos 등)를 사용하려면 <math.h>를 포함하고, 컴파일 시 -lm 옵션을 추가하여 수학 라이브러리를 링크해야 합니다.

#include <stdio.h>
#include <math.h>

int main() {
    double x = 0.5;
    double y = sin(x);
    printf("sin(%f) = %f\n", x, y);
    return 0;
}

컴파일 명령 예시:

gcc example.c -o example -lm
문제 상황 원인 해결 방법
함수 원형 미포함 헤더 파일 누락 해당 함수가 선언된 헤더 파일 포함
링커 오류 라이브러리 미링크 필요한 라이브러리 옵션 추가 (-lm 등)
중복 정의 오류 헤더 파일 중복 포함 또는 라이브러리 중복 링크 include guard 확인 및 링크 옵션 조정

헤더 파일 포함과 라이브러리 링크는 C 프로그램에서 라이브러리 함수를 올바르게 사용하는 기본 중의 기본입니다.

4.1.1 함수 사용 시 오류 처리 방법

라이브러리 함수를 호출할 때는 함수가 실패할 가능성을 항상 염두에 두고 오류 처리를 해야 합니다. 예를 들어, 파일 입출력 함수나 메모리 할당 함수는 실패 시 적절한 반환값을 통해 오류를 알립니다.

다음은 fopen 함수를 사용할 때의 오류 처리 예시입니다:

#include <stdio.h>

int main() {
    FILE *fp = fopen("nonexistent.txt", "r");
    if (fp == NULL) {
        perror("파일 열기 실패");
        return 1;
    }
    // 파일 작업 수행
    fclose(fp);
    return 0;
}

또 다른 예로, malloc 함수에서 메모리 할당 실패를 처리하는 방법:

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

int main() {
    int *arr = (int *)malloc(1000000000 * sizeof(int));
    if (arr == NULL) {
        fprintf(stderr, "메모리 할당 실패\n");
        return 1;
    }
    // 메모리 사용
    free(arr);
    return 0;
}

오류 처리는 프로그램의 안정성과 신뢰성을 높이는 필수 요소입니다. 항상 함수의 반환값을 확인하고 적절한 조치를 취하세요.

4.2 함수 사용 시 오류 처리 방법

이 하위 섹션의 내용은 자동 보정으로 채워졌습니다.

맨 위로

5. 함수 디버깅과 최적화 팁

C언어에서 함수 단위로 디버깅하는 방법과 성능 향상을 위한 최적화 기법을 소개합니다. 함수 내부의 오류를 효과적으로 찾아내고, 실행 속도와 메모리 사용을 개선하는 실용적인 팁을 제공합니다.

5.1 함수 단위 디버깅 방법

함수를 개별 단위로 디버깅하는 것은 복잡한 프로그램에서 문제를 빠르게 파악하는 데 필수적입니다. 주요 방법은 다음과 같습니다:

  1. 단위 테스트 작성: 함수별 입력과 출력을 검증하는 테스트 코드를 작성합니다. 예를 들어, assert를 이용해 함수 반환값을 확인할 수 있습니다.
#include <assert.h>

int add(int a, int b) {
    return a + b;
}

int main() {
    assert(add(2, 3) == 5);
    assert(add(-1, 1) == 0);
    return 0;
}
  1. 디버거 활용: gdb 같은 디버거를 사용해 함수 내부 변수 상태를 확인하고, 중단점(breakpoint)을 설정해 흐름을 단계별로 추적합니다.

  2. 로깅 추가: 함수 진입과 주요 변수 값을 출력하는 로그를 삽입해 실행 흐름과 상태를 파악합니다.

  3. 메모리 검사 도구 사용: Valgrind 등으로 함수 내 메모리 누수나 잘못된 접근을 점검합니다.

함수 단위 디버깅은 문제의 원인을 좁히고 코드 신뢰성을 높이는 데 매우 효과적입니다.

5.2 성능 향상을 위한 최적화 기법

함수의 성능을 높이기 위한 최적화 기법은 다음과 같습니다:

기법 설명 예제
인라인 함수 사용 작은 함수는 inline 키워드로 호출 오버헤드를 줄임 inline int square(int x) { return x * x; }
불필요한 계산 제거 반복문 밖으로 빼낼 수 있는 계산은 미리 수행 “`c
for (int i = 0; i < n; i++) {
int val = expensive_calc(); // 비효율
arr[i] = val * i;
}
// 최적화
int val = expensive_calc();
for (int i = 0; i < n; i++) {
arr[i] = val * i;
}
|
| 적절한 자료구조 선택 | 함수 내 자료구조를 효율적인 것으로 변경하여 성능 개선 | 배열 대신 해시 테이블 사용 등 |
| 루프 언롤링 | 루프 반복 횟수를 줄여 분기 비용 감소 | `for` 대신 반복문 내 명령어 중복 |
| 컴파일러 최적화 옵션 활용 | `-O2`, `-O3` 등 컴파일러 옵션으로 자동 최적화 | `gcc -O3 program.c -o program` |

```c
// 인라인 함수 예시
inline int max(int a, int b) {
    return (a > b) ? a : b;
}

최적화는 코드 가독성과 유지보수성에 영향을 줄 수 있으므로, 성능 측정을 기반으로 신중히 적용해야 합니다.

맨 위로


카테고리: 프로그래밍

태그: C언어, 함수, 라이브러리 함수, 프로그래밍, 코드 재사용

C언어 포인터의 이해

C언어 포인터의 이해

C언어 포인터의 이해. C언어에서 포인터는 메모리 주소를 직접 다루는 강력한 도구입니다. 포인터를 이해하면 메모리 관리와 효율적인 프로그래밍이 가능해집니다. 이 글에서는 포인터의 기본 개념부터 활용법까지 단계별로 살펴봅니다.

목차

소개

C언어에서 포인터는 메모리 주소를 직접 다루는 강력한 도구입니다. 포인터를 이해하면 메모리 관리와 효율적인 프로그래밍이 가능해집니다. 이 글에서는 포인터의 기본 개념부터 활용법까지 단계별로 살펴봅니다.

맨 위로

포인터의 기본 개념

포인터는 C언어에서 메모리 주소를 직접 다루는 변수로, 변수의 주소를 저장하고 이를 통해 메모리를 효율적으로 조작할 수 있게 해줍니다. 이 섹션에서는 포인터가 무엇인지, 그리고 포인터 변수를 선언하고 초기화하는 방법에 대해 실용적인 관점에서 살펴봅니다.

포인터란 무엇인가?

포인터는 메모리 주소를 저장하는 변수입니다.

포인터는 단순히 값이 아니라 메모리 상의 특정 위치(주소)를 가리키는 변수입니다. C언어에서는 변수를 선언하면 메모리의 일정 공간이 할당되는데, 포인터는 이 공간의 시작 주소를 저장합니다.

예를 들어, 정수형 변수 int a = 10;이 있을 때, a는 값 10을 저장하지만, &a는 변수 a가 저장된 메모리 주소를 가리킵니다.

int a = 10;
int *p = &a; // p는 a의 주소를 저장하는 포인터

이처럼 포인터를 통해 변수의 주소를 저장하고, 이를 이용해 변수에 간접적으로 접근하거나 조작할 수 있습니다. 포인터는 배열, 함수 매개변수 전달, 동적 메모리 할당 등 다양한 상황에서 핵심적인 역할을 합니다.


포인터의 주요 특징

특징 설명
메모리 주소 저장 변수의 메모리 주소를 저장하는 변수
간접 참조 (Dereferencing) 포인터를 통해 실제 변수의 값을 읽거나 수정 가능
타입 지정 포인터는 가리키는 데이터 타입에 따라 선언됨

포인터를 이해하는 것은 C언어를 능숙하게 다루기 위한 필수적인 단계입니다.

포인터 변수 선언과 초기화

포인터 변수를 선언할 때는 가리키는 데이터 타입을 명시해야 하며, * 기호를 사용합니다. 초기화는 보통 변수의 주소를 할당하거나 NULL로 초기화합니다.

포인터 선언 문법

타입 *포인터이름;

예를 들어, 정수를 가리키는 포인터는 다음과 같이 선언합니다:

int *p;

포인터 초기화 예시

int a = 5;
int *p = &a; // a의 주소로 초기화

int *q = NULL; // 초기화하지 않은 포인터는 NULL로 설정하는 것이 안전

중요한 점

초기화하지 않은 포인터는 쓰레기 값을 가질 수 있어, 사용 시 프로그램 오류(예: 세그멘테이션 오류)를 일으킬 수 있습니다.

포인터 사용 예

#include <stdio.h>

int main() {
    int num = 10;
    int *ptr = &num; // 포인터 선언 및 초기화

    printf("num의 값: %d\n", num);         // 10
    printf("ptr이 가리키는 값: %d\n", *ptr); // 10

    *ptr = 20; // 포인터를 통해 num 값 변경
    printf("num의 새로운 값: %d\n", num);   // 20

    return 0;
}

위 예제에서 ptrnum의 주소를 저장하고, *ptr을 통해 num의 값을 읽고 쓸 수 있습니다.


포인터 변수 선언과 초기화는 포인터를 안전하고 효과적으로 사용하는 첫걸음입니다.

포인터 변수 선언과 초기화

이 하위 섹션의 내용은 자동 보정으로 채워졌습니다.

맨 위로

포인터와 메모리

포인터는 메모리 주소를 직접 다루는 변수로, C언어에서 메모리 관리와 효율적인 데이터 조작에 핵심적인 역할을 합니다. 이 섹션에서는 메모리 주소와 변수 간의 관계를 이해하고, 포인터 연산을 실용적으로 활용하는 방법을 살펴봅니다.

메모리 주소와 변수의 관계

C언어에서 변수는 메모리의 특정 주소에 값을 저장합니다. 포인터는 이 주소를 저장하는 변수로, 변수와 메모리 주소의 관계를 이해하면 메모리 직접 조작과 효율적인 데이터 관리가 가능합니다.

  • 변수는 메모리의 특정 위치에 데이터를 저장합니다.
  • 포인터는 그 위치(주소)를 저장하는 변수입니다.
int a = 10;       // 변수 a 선언 및 초기화
int *p = &a;      // 변수 a의 주소를 포인터 p에 저장

printf("a의 값: %d\n", a);         // 10
printf("a의 주소: %p\n", &a);      // 메모리 주소 출력
printf("p가 가리키는 값: %d\n", *p); // 10

중요: & 연산자는 변수의 주소를 얻고, * 연산자는 포인터가 가리키는 주소의 값을 참조합니다.

변수 설명
a 정수형 변수, 값 10 저장
p int형 포인터, a의 주소 저장

이 관계를 이해하면 포인터를 통해 변수의 값을 직접 조작하거나, 함수에 변수의 주소를 전달해 효율적인 메모리 사용이 가능합니다.

포인터 연산의 이해

포인터는 단순히 주소를 저장하는 것 외에도 산술 연산이 가능합니다. 포인터 연산은 메모리에서 연속된 데이터 구조(배열 등)를 다룰 때 매우 유용합니다.

  • p + 1은 포인터가 가리키는 타입의 크기만큼 주소를 증가시킵니다.
  • p - 1은 주소를 감소시킵니다.
  • 두 포인터 간의 뺄셈은 요소의 개수를 반환합니다n
int arr[3] = {10, 20, 30};
int *p = arr; // arr의 첫 번째 요소 주소

printf("첫 번째 값: %d\n", *p);       // 10
printf("두 번째 값: %d\n", *(p + 1)); // 20

p++; // 포인터를 다음 요소로 이동
printf("포인터 이동 후 값: %d\n", *p); // 20

int diff = &arr[2] - &arr[0];
printf("두 주소 사이 요소 개수: %d\n", diff); // 2

주의: 포인터 연산은 반드시 같은 배열 내에서 수행해야 하며, 배열 범위를 벗어나면 정의되지 않은 동작이 발생할 수 있습니다.

연산 설명
p + n 포인터를 n개 요소만큼 증가 (자료형 크기 단위)
p – n 포인터를 n개 요소만큼 감소
p1 – p2 두 포인터 사이 요소 수 계산

포인터 연산을 통해 배열 탐색, 동적 메모리 관리, 다양한 자료구조 구현이 가능해집니다.

맨 위로

포인터 활용 기초

포인터는 C언어에서 변수의 메모리 주소를 직접 다루는 강력한 도구입니다. 이 섹션에서는 포인터를 이용해 변수의 값을 직접 변경하는 방법과, 배열과 포인터가 어떻게 밀접하게 연결되어 있는지 실용적인 관점에서 살펴봅니다. 포인터를 이해하면 메모리 관리와 효율적인 데이터 처리에 큰 도움이 됩니다.

포인터를 이용한 값 변경

포인터를 사용하면 변수의 메모리 주소를 통해 직접 값을 변경할 수 있습니다. 이는 함수 호출 시 복사본이 아닌 원본 데이터를 수정할 때 특히 유용합니다.

#include <stdio.h>

void changeValue(int *p) {
    *p = 100;  // 포인터가 가리키는 변수의 값을 100으로 변경
}

int main() {
    int num = 10;
    printf("변경 전 num: %d\n", num);  // 10
    changeValue(&num);  // num의 주소를 함수에 전달
    printf("변경 후 num: %d\n", num);  // 100
    return 0;
}

중요 포인트*p는 포인터가 가리키는 주소의 실제 값을 의미합니다. – 함수에 변수의 주소를 전달하면, 함수 내에서 그 변수의 값을 직접 수정할 수 있습니다.

이 방법은 메모리 사용을 효율화하고, 다수의 변수를 함수에 전달할 때 복사 비용을 줄여줍니다.

배열과 포인터의 관계

배열 이름은 배열의 첫 번째 원소의 주소를 가리키는 포인터로 취급됩니다. 따라서 배열과 포인터는 밀접한 관계를 가지고 있으며, 이를 활용하면 배열 요소에 유연하게 접근할 수 있습니다.

예를 들어:

#include <stdio.h>

int main() {
    int arr[3] = {10, 20, 30};
    int *p = arr;  // 배열 이름은 첫 번째 요소의 주소

    for(int i = 0; i < 3; i++) {
        printf("arr[%d] = %d\n", i, *(p + i));  // 포인터 산술 연산 사용
    }

    return 0;
}
배열 표현 포인터 표현
arr[i] *(arr + i)

중요 포인트 – 배열 이름 arr는 상수 포인터처럼 동작합니다. – 포인터 산술 연산을 통해 배열 요소에 접근할 수 있습니다.

이 관계를 이해하면 포인터를 이용해 동적 메모리 할당, 문자열 처리 등 다양한 프로그래밍 기법을 활용할 수 있습니다.

맨 위로

포인터 심화

포인터를 단순히 변수의 주소를 저장하는 용도로만 이해하는 것을 넘어서, 함수와의 연계 사용과 다중 수준의 포인터 구조를 이해하는 것이 중요합니다. 이 섹션에서는 포인터를 함수 매개변수로 활용하는 방법과 이중 포인터의 개념 및 실용적 사용법을 다룹니다. 이를 통해 메모리 관리, 데이터 구조 조작, 함수 간 데이터 전달에 대한 깊은 이해를 도모할 수 있습니다.

포인터와 함수: 매개변수로서의 포인터

함수에 포인터를 매개변수로 전달하면, 함수 내에서 원본 변수에 직접 접근하고 수정할 수 있습니다. 이는 값 복사에 의한 오버헤드를 줄이고, 함수가 여러 값을 반환하거나 큰 데이터를 효율적으로 처리할 때 유용합니다.

#include <stdio.h>

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 10, y = 20;
    printf("Before swap: x = %d, y = %d\n", x, y);
    swap(&x, &y);
    printf("After swap: x = %d, y = %d\n", x, y);
    return 0;
}

포인터 매개변수의 핵심: 함수 내에서 변수의 직접적인 변경이 가능하며, 메모리 사용을 최적화할 수 있습니다.

또한, 배열을 함수에 전달할 때 포인터를 사용하여 배열 요소에 접근할 수 있습니다. 이는 배열의 첫 번째 요소 주소를 전달하는 것과 같으며, 배열 크기 관리에 주의해야 합니다.

이중 포인터의 이해

이중 포인터는 포인터를 가리키는 포인터로, 주로 포인터 배열, 동적 메모리 할당, 다차원 배열 처리 등에 사용됩니다.

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

int main() {
    int **pp;
    int *p;
    int x = 5;

    p = &x;      // p는 x의 주소를 저장
    pp = &p;     // pp는 p의 주소를 저장

    printf("Value of x: %d\n", **pp); // 이중 역참조를 통해 x 값 출력

    // 동적 메모리 할당 예
    pp = (int **)malloc(sizeof(int *));
    *pp = (int *)malloc(sizeof(int));
    **pp = 10;
    printf("Dynamically allocated value: %d\n", **pp);

    free(*pp);
    free(pp);
    return 0;
}

이중 포인터 사용 시 주의점: 메모리 할당과 해제를 명확히 하여 메모리 누수를 방지해야 합니다.

이중 포인터는 함수에서 포인터 자체를 변경해야 할 때도 사용됩니다. 예를 들어, 함수 내에서 포인터가 가리키는 메모리를 동적 할당하고자 할 때 유용합니다.

맨 위로

포인터 사용 시 주의사항

포인터는 C언어에서 강력한 기능이지만, 잘못 사용하면 프로그램의 안정성과 성능에 심각한 문제를 일으킬 수 있습니다. 이 섹션에서는 포인터 사용 시 흔히 발생하는 문제인 메모리 누수와 잘못된 포인터 사용 예제를 통해 문제의 원인과 해결법을 실용적으로 다룹니다.

포인터와 메모리 누수

포인터를 사용하여 동적 메모리를 할당할 때, 할당한 메모리를 반드시 해제하지 않으면 메모리 누수가 발생합니다. 메모리 누수는 프로그램이 점점 더 많은 메모리를 차지하게 만들어 시스템 자원을 낭비하고, 심하면 프로그램이 비정상 종료될 수 있습니다.

중요: malloc 등으로 할당한 메모리는 사용 후 반드시 free로 해제해야 합니다.

다음은 메모리 누수의 대표적인 예시입니다:

int *ptr = (int *)malloc(sizeof(int) * 10);
// ... ptr 사용
// free(ptr); // 해제를 잊으면 메모리 누수가 발생

메모리 누수를 방지하려면 다음 원칙을 지켜야 합니다:

원칙 설명
할당과 해제 쌍 유지 할당한 메모리는 반드시 해제한다
중복 해제 금지 이미 해제한 포인터는 다시 해제하지 않는다
NULL 초기화 해제 후 포인터를 NULL로 초기화하여 이중 해제를 방지
free(ptr);
ptr = NULL;

메모리 누수를 추적할 때는 도구(예: Valgrind)를 활용하는 것도 좋은 방법입니다.

잘못된 포인터 사용 예제와 해결법

포인터 사용 시 흔히 발생하는 오류와 그 해결법을 예제를 통해 살펴봅니다.

1. 초기화되지 않은 포인터 사용

int *p;
*p = 10; // p가 가리키는 메모리가 정해지지 않아 위험

해결법: 포인터를 선언할 때 반드시 유효한 주소로 초기화하거나 NULL로 초기화 후 사용 전 점검합니다.

int value = 10;
int *p = &value;
// 또는
int *p = NULL;
if (p != NULL) {
    *p = 10;
}

2. 댕글링 포인터(Dangling Pointer)

이미 해제된 메모리를 가리키는 포인터를 사용하는 경우입니다.

int *p = malloc(sizeof(int));
free(p);
*p = 5; // 댕글링 포인터 사용

해결법: 메모리 해제 후 포인터를 NULL로 설정하여 접근을 방지합니다.

free(p);
p = NULL;

3. 배열 경계 밖 접근

int arr[3] = {1, 2, 3};
int *p = arr;
int val = *(p + 3); // 배열 범위 초과 접근

해결법: 배열이나 포인터 연산 시 범위를 항상 확인합니다.


이처럼 포인터 사용 시 발생할 수 있는 문제를 미리 인지하고, 안전한 코딩 습관을 기르는 것이 중요합니다.

맨 위로


카테고리: 프로그래밍

태그: C언어, 포인터, 메모리관리, 프로그래밍, 기초, 개발