R에서의 Base64 디코딩: 완전한 가이드
R 세션에 하나의 문자열이 내려앉습니다. 뒤섞인 알파벳 수프처럼 보입니다: 글자, 숫자, 간혹 보이는 더표나 슬래시, 그리고 끝자락에 매달려 있는 등호 한 쌍. 보내준 사람은 그것이 한때는 완전히 평범한 문장, JPEG, 아니면 JSON 문서였다고 단언합니다. 그 문자열은 Base64이고, 이 페이지는 그것을 원래 모습으로 되돌리는 레시피입니다.
이 사이트의 홈 페이지가 해당 형식을 끝까지 깊이 있게 설명하기 때문에, 간단히 복습만 해 두겠습니다: Base64는 입력 바이트 3개를 64개 심볼의 알파벳에서 고른 문자 4개로 쓰고, 뒤에 붙은 = 문자 한두 개가 진짜 데이터가 어디서 끝났는지를 표시합니다. 4대 3이라는 이 교환이 바로 인코딩된 텍스트가 원본보다 약 3분의 1쯤 부풀어 있는 이유이며, 디코딩은 그 교환을 그냥 거꾸로 돌리는 것뿐입니다. 문제의 모양을 마음속에 두고, 봉투를 열어 보시죠.
여기가 R을 다른 많은 언어와 조금 다르게 만드는 반전입니다: base R에는 Base64 기능이 아예 없습니다. base 패키리에 숨어 있는 base64_decode()도 없고, 손 뻗어 쓸 한 줄짜리 내장 함수도 없습니다. 패키지를 직접 데려와야 합니다. 좋은 소식은 생태계가 여러 가지를 준비해 두고 있다는 것인데, 각각 개성이 다릅니다. 이 글을 다 읽으면 어느 쪽을 골라야 하고, 어느 쪽을 경계해야 하는지 정확히 알게 될 것입니다.
디코더 한눈에 보기
무거운 일을 맡는 패키지는 다섯 개이며, 크게 두 진영으로 나뉩니다: 더러운 입력에도 어깨만 으쓱하는 너그러운 쪽과, RFC를 계약서처럼 여겨 엄격하게 다루는 쪽. 2026년 현재 출연진은 이렇습니다:
| 패키지 | 버전 (2026) | 디코딩 진입점 | 개성 |
|---|---|---|---|
base64enc |
0.1-6 | base64decode() |
기본적으로 너그럽고, 2026년 2월에 strict 모드를 갖추었습니다 |
openssl |
2.4.2 | base64_decode() |
공백은 건너뛰지만, 엄한 눈총을 받을 만한 방식으로 조용히 실패합니다 |
b64 |
0.1.7 | decode(), decode_as_string() |
엄격하고, 벡터화되어 있으며, Rust로 작성되어 빠릅니다 |
base64 |
2.0.2 | decode() |
openssl를 감싼 파일-대-파일 편의 래퍼 |
base64url |
1.4 | base64_urldecode() |
URL에 안전한 알파벳, 패딩 없음, 조용히 관대합니다 |
준우승급 세 개도 언급할 가치가 있습니다. jsonlite 패키지는 자기 헬퍼인 base64_enc, base64_dec와 URL에 안전한 쌍을 내보내므로, 이미 JSON을 파싱하고 있다면 손에 디코더가 있을지 모릅니다. jose 패키지는 JWT 작업을 위한 base64url_decode()를 동봉합니다. 그리고 고령의 RCurl 패키지에는 아직 libcurl을 감싸는 base64() 함수가 타고 있습니다: 작동하고, 문자 지향이며, 새 코드를 위한 도구보다는 헌신적인 조부모의 느낌이 납니다.
출연진 설치하기
R가 아직 머신에 없다면, 운영체제에 들어 있습니다: Debian과 Ubuntu에는 r-base, Fedora에는 R, macOS와 Windows에는 패키지나 설치 프로그램. 그다음은 패키지로, CRAN에서 바로:
install.packages("base64enc")
install.packages("openssl")
install.packages("b64")
install.packages("base64url")
빌드 참고 두 가지입니다. 설치가 꼬이는 데가 바로 여기이기 때문입니다. openssl 패키지는 시스템의 OpenSSL를 대상으로 컴파일되므로, 맨 상태의 Linux 머신에서는 먼저 개발용 헤더가 필요할 수 있습니다:
sudo apt install libssl-dev
b64 패키지는 extendr로 감싼 Rust 엔진이기 때문에, 소스에서 빌드하려면 Rust 툴체인이 필요합니다 (sudo apt install cargo를 실행하면 rustc도 함께 가져옵니다). Windows와 macOS에서는 CRAN에서 미리 빌드된 바이너리를 받으므로 이 모든 것은 해당되지 않습니다. 패키지 관리자를 선호한다면, pak::pkg("base64enc")나 remotes::install_cran("b64")가 리포지토리에 대한 의견은 훨씬 적은 채로 같은 일을 해냅니다.
첫 디코딩: 3줄 의식
디코딩 생활의 90%는 3줄 안에 들어갑니다. 여기, 유명한 TWFu 문자열을 쓰는 정석 스모크 테스트가 있습니다:
library(base64enc)
packed <- "TWFu"
bytes <- base64decode(packed)
bytes
#> [1] 4d 61 6e
rawToChar(bytes)
#> [1] "Man"
그 작은 의식에서 눈여겨볼 것이 세 가지 있습니다. 첫째, base64decode()는 항상 raw 벡터를 건네고, 절대 문자열을 주지 않습니다. 이것은 사고가 아니라 기능입니다: Base64는 문장, JPEG, 인증서 모두를 실어 나를 수 있으며, 손에 든 것이 무엇인지 알기 전에 그 어느 것도 다르게 대우받아서는 안 되니까요. 둘째, 바이트에서 다시 텍스트로 건너가는 도약은 rawToChar()를 거치는 별개의, 의도된 단계이며, 그 단계에 문자 집합 결정이 숨어 있습니다 (나중에 더 다룹니다). 셋째, TWFu는 단어 "Man"으로 디코딩됩니다: 글자 세 개, 드라마 제로. 그 문자열을 뒷주머니에 넣어 두세요. 어떤 디코딩 코드가 TWFu를 Man으로 바꿔 놓는다면, 그 머신은 정직한 것입니다.
결국 당신도 직접 인코딩한 것을 디코딩하는 날은 반드시 오므로, 두 방향이 일치한다는 것을 증명하는 완전한 라운드 트립을 보여 드립니다:
text <- "Hello, world!"
packed <- base64encode(charToRaw(text))
packed
#> [1] "SGVsbG8sIHdvcmxkIQ=="
identical(text, rawToChar(base64decode(packed)))
#> [1] TRUE
raw 벡터의 경계
R은 두 개의 작은 함수로 바이트/텍스트 경계를 건넙니다. 이 둘을 알게 되면 이 글의 나머지는 모두 자명하게 느껴질 것입니다. charToRaw()는 문자열을 그 바이트로 바꾸고, rawToChar()는 그 반대의 일을 하며, 그 사이에는 raw 툴킷 전체가 있습니다: 바이트를 세는 length(), 살짝 엿보는 head(), 파일과 그 사이를 오가게 하는 writeBin()과 readBin(). 이 글의 모든 디코더는 의도적으로 그 경계에서 멈춥니다.
bytes <- base64decode("SGVsbG8s")
length(bytes)
#> [1] 6
head(bytes)
#> [1] 48 65 6c 6c 6f 2c
rawToChar(bytes)
#> [1] "Hello,"
그러니 [1] 48 같은 결과를 보면 "바이트 하나, 값 72, 문자 H"로 읽고, 그 바이트가 어떤 문자 집합을 입고 있는지 알 때까지 거기서 멈추세요. 그 멈춤이 바로 디코딩의 규율 전체입니다.
더러운 입력: 디코더들이 갈리는 지점
이것이 새벽 2시에 당신을 구해 주는 섹션입니다. 입력이 조금 더러워지면 어떻게 되는지에 대해 디코더들의 의견이 달라서요. 현실의 Base64는 안에 공백이 끼어 오거나, 조급한 복사-붙여넣기에 패딩이 빠지거나, 클립보드가 일부 삼킨 낯선 기호가 하나 묻어 오거나, 끝부분에 쓰레기가 매달려 옵니다. 같은 계열의 범인들에게 각 디코더가 어떻게 대답하는지 보시죠:
| 입력 | base64enc (기본) | base64enc (strict = TRUE) | openssl | b64 |
|---|---|---|---|---|
"SGVs bG8s IHdvcmxkIQ==" (안에 공백) |
"Hello, world!" (공백 건너뜀) | 오류: 잘못된 문자, 위치 제시 | "Hello, world!" (공백 건너뜀) | 오류 |
"SGVsbG8sIHdvcmxkIQ" (패딩 빠짐) |
"Hello, world!" (빠진 패딩 관용) | 오류: 패딩 부족 | 오류: 디코딩 실패 | 오류: 잘못된 패딩 |
"SGVsbG8s!IHdvcmxkIQ==" (안에 "!" 있음) |
"Hello, world!" (잘못된 문자 건너뜀) | 오류: 잘못된 문자 | 빈 raw 벡터, 오류 없음 | 오류 |
"SGVsbG8sIHdvcmxkIQ==xx" (끝에 쓰레기) |
"Hello, world!" (쓰레기 무시) | 오류: 남은 내용 | 빈 raw 벡터, 오류 없음 | 오류 |
"TQ==" (깨끗함) |
M | M | M | M |
그 표를 두 번 읽어 보세요. 전체 이야기가 다 들어 있거든요. 기본 base64decode()는 친절한 세관 검사원입니다: 알파벳 밖의 문자는 건너뛰고, 빠진 패딩은 관용하며, 남은 내용은 무시하므로, 이메일 모양이든 클립보드 모양이든 문자열은 그냥 통과합니다. 오랜 침묵 끝에 2026년 2월, 릴리스 0.1-5와 함께 찾아온 strict = TRUE 모드는 법의 감식관입니다: 문자열 전체를 검증하고, 범인의 정확한 위치를 이름으로 부르며, 교과서에서 벗어난 것은 무엇이든 거부합니다. b64는 기본적으로 엄격하며, 절반만 성공하는 일은 결코 없습니다. 그리고 openssl는 불편한 중간에 앉아 있습니다: 공백은 기꺼이 건너뛰고, 패딩 부족에는 올바르게 오류를 내지만, 진정 불법인 문자나 끝에 붙은 쓰레기에 부딪히면 빈 raw 벡터를 돌려주고 아무 말도 하지 않습니다. 그 조용한 빈 결과는 이 글 전체에서 가장 위험한 행동입니다. 일어나는 모습을 지켜 보시죠:
dirty <- "SGVsbG8s!IHdvcmxkIQ=="
openssl::base64_decode(dirty)
#> raw(0) # 비어 있음. 오류도, 경고도, 힌트도 없음.
openssl를 기준으로 코드를 쓴다면, 신뢰하기 전에 받아든 것의 길이를 확인하세요. "내 데이터는 어디로 간 걸까"라는 종류의 수수께끼를 통째로 막아 주는 작은 습관이니까요.
엄격 진영을 위해, base64enc이 정확한 모습을 보여 드립니다. 당신 자신의 콘솔에서 보게 될 오류 메시지와 그대로입니다:
base64enc::base64decode("TWF u", strict = TRUE)
#> v=30000, pad=0, org='u'
#> Error: Invalid character (' ') at position 4 in base64 string (not allowed in strict mode)
base64enc::base64decode("TWF", strict = TRUE)
#> v=10000, pad=3, org=''
#> Error: Missing padding (1 characters) at the end of the base64 string (not allowed in strict mode)
base64enc::base64decode("TWF=xx", strict = TRUE)
#> v=20000, pad=0, org='xx'
#> Error: Trailing content 'xx' after padding at position 4 in base64 string (not allowed in strict mode)
기다려야 할 특이점이 하나 있습니다: strict 호출은 성공이든 실패든, 전부 콘솔에 C 레벨 상태 줄을 떠들어대는데, v=30000, pad=0, org='u' 같은 것입니다. 이것은 컴파일된 코드에서의 직접 쓰기로, R 경고가 아니므로 suppressWarnings()로는 건드릴 수 없습니다. 해롭지는 않지만, 테스트에서 콘솔 출력을 비교한다면 왜 한 줄이 더 나오는지를 이제 아실 것입니다.
R 특유의 것: NA, 벡터와 조용한 결합
Base64에는 이색적인 점이 있고, R은 자기 것을 추가로 얹습니다. 첫 번째는 NA를 통해 물어뜯어 옵니다. NA_character_를 디코더에 넘기면, 디코더가 보기 전에 R이 조용히 문자열 "NA"로 강제 변환하는데, "NA"는 완벽하게 유효한 Base64 그룹입니다. 결과는 오류도 아니고 빈 벡터도 아닙니다. 바이트 0x34, 곧 문자 "4"입니다:
base64decode(NA_character_)
#> [1] 34
rawToChar(base64decode(NA_character_))
#> [1] "4"
그러니 결측값으로 가득한 열은 결측값으로 디코딩되지 않습니다. 문자 4로 가득한 열로 디코딩되며, 당신의 하류 코드는 신나게 그것을 처리해 줍니다. 입력에 NA가 들어올 수 있다면, 먼저 걸러 내세요:
values <- c("TWFu", NA_character_, "TQ==")
clean <- values[!is.na(values)]
out <- vapply(clean, function(x) rawToChar(base64decode(x)), character(1))
unname(out)
#> [1] "Man" "M"
두 번째 이색적인 점은 벡터 입력입니다. 그리고 디코더들은 완전히 다른 방향으로 꺾입니다. base64decode()는 문자 벡터를 하나의 문자열을 이은 조각 여러 개로 보고 그들을 붙여 주므로, 두 요소가 하나의 raw 벡터로 돌아옵니다. openssl::base64_decode()는 그 정도까지도 가지 못합니다: 인수를 축소한 뒤, 두 요소짜리 논리 테스트에 부딪히는데, 이것이 R의 가장 정직한 오류 메시지 중 하나를 만들어 냅니다. 그리고 b64::decode()는 진정으로 벡터화된 단 하나뿐인 존재로, 입력 하나당 디코딩된 덩어리 하나를 돌려줍니다:
base64decode(c("TWFu", "TQ=="))
#> [1] 4d 61 6e 4d
openssl::base64_decode(c("TWFu", "TQ=="))
#> Error in if (is.na(text)) : the condition has length > 1
out <- b64::decode(c("TWFu", "TQ=="))
rawToChar(out[[1]])
#> [1] "Man"
rawToChar(out[[2]])
#> [1] "M"
정리하면: 반복이든 벡터화든 의도적으로 선택하고, 입력이 벡터라 출력도 벡터라고는 절대 가정하지 마세요.
URL에 안전한 알파벳
표준 Base64는 알파벳의 마지막 두 칸을 +와 /에 씁니다. 그리고 그 둘은 바로 URL이 싫어하는 문자입니다. 더표는 %2B가 되고, 슬래시는 %2F가 되며, 패딩은 %3D가 되니, 어디에든 붙여 넣을 수 있어야 할 토큰이 백분호부터 걸치기 시작합니다. RFC 4648 5절에 정의된 URL에 안전한 변형은 이 두 문자를 -와 _로 바꾸고, 보통 패딩도 함께 버립니다. R에는 그 세계로 들어가는 문이 세 개 있습니다.
첫 번째 문은 b64 엔진입니다. 엔진이란 구성해 둔 알파벳과 패딩 정책일 뿐이며, 이 패키지는 필요한 네 가지를 동봉합니다:
library(b64)
std <- encode(as.raw(c(0xfb, 0xef, 0xbe)))
std
#> [1] "++++"
url <- encode(as.raw(c(0xfb, 0xef, 0xbe)), engine("url_safe"))
url
#> [1] "----"
decoded <- decode(url, engine("url_safe"))[[1]]
toString(decoded)
#> [1] "fb, ef, be"
엔진은 "standard" (기본값), "standard_no_pad", "url_safe", "url_safe_no_pad"이며, 같은 엔진 객체가 양쪽 방향에서 작동하므로 코드가 대칭을 유지합니다. 알파벳에 대해서도 엄격한데, 표준 엔진에 "----"를 먹여 보면 "Invalid byte 45, offset 0."라고 대답합니다. 안심이 되는 대답입니다.
두 번째 문은 전용 base64url 패키지입니다. 작고 목적은 하나: URL에 안전한 알파벳, 패딩 없음, 항상. 주의할 점 하나: 이 디코더는 너그러운 편이라, 손상된 문자열의 유효한 접두어를 기꺼이 디코딩한 뒤 아무 불평 없이 거기서 멈춥니다. URL에 안전한 값이 신뢰할 수 없는 출처에서 왔다면, b64를 우선하세요.
base64url::base64_urlencode("hello world")
#> [1] "aGVsbG8gd29ybGQ"
base64url::base64_urldecode("aGVsbG8g!!!")
#> [1] "hello " # 나머지는 조용히 버려짐
세 번째 문은 jose입니다. JWT 작업을 위해 base64url_encode()와 base64url_decode()를 내보내며, 바라던 대로 raw 벡터를 돌려줍니다.
그리고 가끔 필요한 일회용 작업이어서 base64enc 안에서 머물고 싶다면, 문자열 수술에 패딩 보정이면 충분합니다:
url <- "----"
fixed <- gsub("_", "/", gsub("-", "+", url), fixed = TRUE)
padded <- switch(as.character(nchar(fixed) %% 4L),
"0" = fixed,
"2" = paste0(fixed, "=="),
"3" = paste0(fixed, "="))
rawToChar(base64decode(padded))
#> [1] "\xfb\xef\xbe"
JWT: 엿보기와 신뢰하기
대부분의 사람이 URL에 안전한 Base64를 만나는 이유는 JSON Web Token입니다. JWT는 점으로 이어진 base64url 세 부분입니다: 서명 방식을 설명하는 헤더, 클레임으로 된 페이로드, 그리고 전체를 신뢰할 만하게 만들어 주는 서명. R에서 첫 두 부분을 디코딩하는 것은 5줄짜리 일입니다. strsplit()의 fixed = TRUE에 주목하세요: 점은 정규식 와일드카드이며, 그 플래그가 없으면 R은 싱글벙글하며 당신의 토큰을 한 글자씩 쪼개는데, 이것은 현존하는 R 코드에서 가장 흔한 Base64 버그입니다.
jwt <- jose::jwt_encode_hmac(jose::jwt_claim(sub = "1234567890", iat = 1516239022, name = "John Doe"), "0123456789abcdef")
parts <- strsplit(jwt, ".", fixed = TRUE)[[1]]
header <- base64url::base64_urldecode(parts[1])
header
#> [1] "{\"typ\":\"JWT\",\"alg\":\"HS256\"}"
payload <- jsonlite::fromJSON(base64url::base64_urldecode(parts[2]))
payload$name
#> [1] "John Doe"
정직한 면책 선언 두 가지입니다. 첫째, JWT를 디코딩하는 것은 신뢰가 아니라 엿보기입니다: 세 번째 부분은 서명이며, 발행자의 키와 대조해 볼 때만 의미가 있습니다. 그 일은 jose 패키지가 일족 전체를 담당합니다. 2.0 릴리스 (2026년 4월)는 ED25519 지원을 추가하고 typ 헤더를 선택 사항으로 만들었습니다; 1.x의 jwk_read()/jwk_write() 헬퍼 (또는 그 별칭 read_jwk()/write_jwk())를 보여주는 오래된 튜토리얼은 여전히 유효합니다. 2.0에도 같은 JWK 읽기/쓰기 헬퍼가 동봉되어 있기 때문이죠:
library(jose)
claims <- jwt_decode_hmac(jwt, "0123456789abcdef")
claims$name
#> [1] "John Doe"
claims$sub
#> [1] "1234567890"
sp <- jwt_split(jwt)
sp$header
#> $alg
#> [1] "HS256"
#>
#> $typ
#> [1] "JWT"
sp$payload$name
#> [1] "John Doe"
jwt_decode_hmac()는 서명을 검증하고 exp와 nbf 클레임도 시행하며, 토큰이 만료되었다면 오류를 던집니다. 그리고 둘째: base64url::base64_urldecode() 일당은 헤더와 페이로드를 잘 디코딩하지만, 서명 부분은 바이너리이므로 문자열로 디코딩하기보다 raw를 돌려주는 함수 (예: "url_safe_no_pad" 엔진을 쓰는 b64::decode())로 디코딩하세요.
문자 집합: 그 바이트는 무슨 텍스트인가?
이 글의 모든 디코더는 raw 벡터 경계에서 의도적으로 멈춥니다. "그것은 무슨 텍스트였지?"라는 답은 바이트가 어떤 문자 집합으로 싸여 왔는지에 달려 있거든요. 미리 좋은 소식부터: 보내는 쪽이 UTF-8을 썼다면, 현대 웹의 대부분이 그렇듯, 당신의 삶은 짧고 행복합니다. base64enc는 그것을 위한 문지기까지 동봉합니다:
packed <- base64encode(charToRaw("caf\u00e9"))
bytes <- base64decode(packed)
checkUTF8(bytes, quiet = TRUE)
#> [1] TRUE
rawToChar(bytes)
#> [1] "café"
checkUTF8()는 결정하지 않고 "이 바이트들은 UTF-8로 읽을 수 있는가?"라고 묻습니다. quiet = TRUE이면 TRUE나 FALSE를 돌려주고, 기본값은 더 직관적이에요: 잘못된 바이트에는 오류를 던지며 범인을 이름으로 부릅니다. INVALID byte 0xff at 0x0 (0, line 1): invalid start byte (FE/FF) 같은 방식으로요. NUL 바이트를 포함한 문자열에도 FALSE를 보고하는데, 그런 문자열은 유효한 UTF-8 R 문자열의 일부일 수 없기 때문입니다. 문지기로 쓰면 나머지는 뒤따라옵니다.
문지기가 중요한 이유가 여기 있습니다. UTF-8 로케일에서 rawToChar()는 바이트가 유효한 UTF-8이 아니라도 오류를 던지지 않습니다. Encoding() == "unknown"로 표시한 문자열을 건네줄 뿐이며, 로케일과 R 빌드에 따라 같은 호출이 "invalid multibyte sequence" 오류로 죽기도 하는데, 적어도 이것은 정직합니다. 어느 쪽이든, 낯선 바이트에 안전장치 없이 rawToChar()을 쓰는 순간 모지바케가 태어나는 것입니다:
broken <- as.raw(c(0xc3, 0x28)) # 잘린 UTF-8 쌍
checkUTF8(broken, quiet = TRUE)
#> [1] FALSE
rawToChar(broken) # 에러 없음. 이게 문제야.
#> [1] "\xc3("
그리고 보내는 쪽이 아예 다른 것을 썼다면, Latin-1이나 Windows-1252, Shift-JIS 같은 것이라면, 레시피는 raw로 디코딩한 뒤 이름 붙은 문자 집합을 이해하는 iconv()로 번역하는 것입니다:
latin1_bytes <- as.raw(c(0x63, 0x61, 0x66, 0xe9)) # Latin-1의 "café"
iconv(rawToChar(latin1_bytes), from = "latin1", to = "UTF-8")
#> [1] "café"
이모지도 한 마디 할 가치가 있습니다. 여기에도 R의 이야기가 있으니까요. 최신 R은 이모지가 사는 U+FFFF 위 코드를 순수한 UTF-8로 저장하므로, 라운드 트립이 작동하고 바이트 수도 다른 모든 언어가 기대하는 것과 일치합니다. 오래된 R 릴리스는 같은 문자들을 서로게이트 페어로 저장했는데, 이 방식은 흔히 CESU-8라고 불립니다. 그래서 이모지 하나가 경계를 건널 때 6바이트의 유효하지 않은 UTF-8이 되었습니다. 이모지가 망가진 채로 전신에 도착하는 오래된 R 코드를 물려받았다면, 이것이 수위의 의심 대상입니다: nchar(x, type = "bytes")를 확인하고, 보내는 쪽의 언어가 만들었을 결과와 비교해 보세요.
emoji <- "\U0001F600"
nchar(emoji, type = "bytes")
#> [1] 4
round_trip <- base64decode(base64encode(charToRaw(emoji)))
identical(emoji, rawToChar(round_trip))
#> [1] TRUE
원칙은 이렇습니다: UTF-8이라고 가정하고, checkUTF8()로 검증하며, 다른 문자 집합이라는 명확한 지식만 있을 때 iconv()에 손을 대세요. 절대 추측하지 마세요.
파일: 줄바꿈된 텍스트에서 진짜 바이트까지
문자열은 쉽습니다; 파일은 Base64가 자신의 가치를 입증하는 곳입니다. 어떤 패키지와도 동작하는 수동 파이프라인으로 시작해 보죠: 인코딩된 텍스트를 읽고, 디코딩하고, 바이트를 씁니다.
cat("SGVsbG8sIHdvcmxkIQ==", file = "hello.b64")
bytes <- base64decode(paste(readLines("hello.b64"), collapse = ""))
writeBin(bytes, "hello.txt")
readLines("hello.txt")
#> [1] "Hello, world!"
그다음은 편의 계층입니다. base64enc는 당신을 대신해 읽고 쓸 수 있습니다: file 인수는 Base64 텍스트를 담은 파일을 가리키고, output는 디코딩된 바이트가 내려앉을 곳을 가리킵니다. 반환값은 쓴 바이트의 개수로, 고마운 덤인 무공해 건전성 검사입니다:
n <- base64decode(file = "hello.b64", output = "hello.txt")
n
#> [1] 13
readLines("hello.txt")
#> [1] "Hello, world!"
이메일이나 PEM 장갑을 견뎌 온 종류의, 줄바꿈된 입력은 너그러운 디코더들에게 문제없습니다: 남아 있는 것이 유효한 Base64만이라면, 줄바꿈이 어디에 떨어졌든 그사이를 투명하게 꿰뚫어 봅니다. MIME은 줄 사이에 CRLF를 두고 76자에서 줄바꿈하고; PEM 블록은 64에서 줄바꿈합니다. 물론 엄격한 엔진은 줄바꿈을 아예 거부합니다.
wrapped <- paste0("VGhlIHF1aWNrIGJ", "\r\n",
"yb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw==")
rawToChar(base64decode(wrapped))
#> [1] "The quick brown fox jumps over the lazy dog"
rawToChar(openssl::base64_decode(wrapped))
#> [1] "The quick brown fox jumps over the lazy dog"
b64::decode(wrapped)
#> Error: Invalid byte 13, offset 15.
b64 패키지는 이름이 가장 명확한 파일 쌍을 가지고 있고, Rust의 속도를 갖추었기 때문에 파일이 클 때 손이 가는 존재입니다. 다만 날카로운 모서리가 하나 있습니다: decode_file()는 파일을 바이트 단위로 읽으며 무자비합니다. 뒤에 붙은 줄바꿈 한 줄 - writeLines()는 추가하지만 cat()은 절대 추가하지 않는 바로 그 종류 - 이 Rust 엔진을 발작(panic)시키고, User function panicked: decode_file_ 형태의 잡을 수 있는 오류로 표면화됩니다. 인코딩된 텍스트를 cat()이나 writeBin(charToRaw(enc), path)로 쓰면 그 모서리는 둔한 채로 남습니다.
writeBin(charToRaw("file payload bytes"), "payload.bin")
enc <- b64::encode_file("payload.bin")
cat(enc, file = "payload.b64")
bytes <- b64::decode_file("payload.b64")
rawToChar(bytes)
#> [1] "file payload bytes"
base64 패키지는 세 번째 진입로입니다: 순수하게 파일 지향이며, 짝을 이룬 함수 쌍을 가지고 있습니다:
base64::encode("payload.bin", "payload.b64")
base64::decode("payload.b64", "payload.out")
readLines("payload.out")
#> [1] "file payload bytes"
실전에서 만날 파일 모양이 하나 더 있습니다: 인코딩된 덩어리로 가득한 데이터 프레임 열. API 덤프에서 매우 흔합니다. 몇백 행이라면 단순한 벡터화 호출이 충분히 좋습니다:
df <- data.frame(content = c(b64::encode("alpha"), b64::encode("beta"),
b64::encode("gamma")))
decoded <- b64::decode(df$content)
df$decoded <- vapply(decoded, function(x) rawToChar(x), character(1))
df$decoded
#> [1] "alpha" "beta" "gamma"
API와 웹 응답
JSON API들은 바이너리를 텍스트에 구겨 넣는 것을 좋아하고, Base64는 그들이 가장 사랑하는 손가방입니다. R의 패턴은 매번 동일합니다: 가져오고, 파싱하고, 디코딩하고, 그 바이트가 무엇인지를 결정하는 것. 현대적인 HTTP 클라이언트는 httr2이고, JSON 쪽은 jsonlite입니다:
library(httr2)
library(jsonlite)
res <- req_perform(request("https://httpbin.org/get?attachment=TWFuIGlzIGhlcmU%3D&name=man.txt"))
data <- fromJSON(rawToChar(res$body))
bytes <- base64decode(data$args$attachment)
writeBin(bytes, data$args$name)
length(bytes)
#> [1] 11
길 위에 쓸 참고 세 가지입니다. 요청 생성자는 request()이며, httr2의 첫 릴리스부터 그 이름을 써 왔습니다. 응답 본문은 raw 벡터로 도착하므로, JSON으로 파싱하기 전에 rawToChar()를 거치세요. 그리고 API는 방언을 쓸 수 있습니다: 교과서 버전을 기대하는 디코더에 먹이기 전에, 그 Base64가 URL에 안전한지, 아니면 패딩이 벗겨져 있는지 확인하세요. 어떤 API는 아예 이중 인코딩을 하는데, Base64의 Base64인 셈이며, 디코딩 한 번에 기대하던 바이트 대신 Base64가 더 나와 줄 때 그것을 알아볼 수 있습니다.
data URI와 자기 완결형 문서
data: URI 스킴 (RFC 2397)은 문서가 자기 자신의 내용을 싣게 해 줍니다: MIME 타입, 페이로드가 인코딩되어 있으면 "base64"라는 단어, 그리고 페이로드 그 자체. 크롤링하는 HTML 속에서 이들을 끊임없이 만날 것이며, 하나를 디코딩하는 것은 2단계 문자열 조작 뒤에 이어지는 보통의 디코딩입니다:
img_tag <- "<img src=\"data:image/png;base64,iVBORw0KGgoAAA\" />"
uri <- regmatches(img_tag, regexec("src=\"([^\"]*)\"", img_tag))[[1]][2]
uri
#> [1] "data:image/png;base64,iVBORw0KGgoAAA"
payload <- sub("^data:[^,]*,", "", uri)
bytes <- base64decode(payload)
length(bytes)
#> [1] 10
같은 모양은 CSS, PDF 주석, 그리고 자기 완결형 R Markdown 보고서에서도 나타납니다. 거기서는 플롯이 <img> 태그로 납작하게 눌러져 있어 HTML이 별다른 부속 파일 없이 떠다닐 수 있죠. 그런 문서를 렌더링하면서 내장된 이미지를 추출하고 싶다면, 전부 트릭은 이것입니다: data: 문자열을 찾아, 첫 쉼표에서 잘라, 디코딩합니다.
데이터베이스, 이메일과 설정
누군가 바이너리를 텍스트 열에 넣고 싶어 할 때면 Base64가 데이터베이스에 나타납니다. 그리고 디코딩 쪽은 문자열의 열을 다시 바이트로 되돌리는 일입니다. DBI와 RSQLite를 통한 SQLite와의 라운드 트립을 보시죠: 인코딩된 값을 저장하고, 쿼리로 다시 가져오고, 디코딩하면 바이트가 손에 들어옵니다:
library(DBI)
library(RSQLite)
db <- dbConnect(SQLite(), ":memory:")
dbExecute(db, "CREATE TABLE files (name TEXT, payload TEXT)")
stored <- base64encode(charToRaw("stored in a database"))
dbExecute(db, paste0("INSERT INTO files VALUES ('note.txt', '", stored, "')"))
row <- dbGetQuery(db, "SELECT * FROM files")
rawToChar(base64decode(row$payload))
#> [1] "stored in a database"
SQLite는 바이너리를 BLOB로 네이티브 저장할 수도 있고, 그렇다면 Base64는 아예 불필요하며 열은 raw 벡터로 R에 돌아옵니다. class(dbGetQuery(db, "SELECT payload FROM blobs")$payload[[1]]) == "raw"처럼요. TEXT 안의 Base64 변형은 이동성을 위해 존재합니다: 텍스트 편집기로 검사할 수 있고, 어떤 다른 언어든 바이너리 드라이버 없이 읽을 수 있거든요.
이메일은 줄바꿈된 Base64의 출생지입니다. MIME 부분은 줄 사이에 CRLF를 두고 76자에서 줄바꿈하며, 당신이 읽어 본 이메일 시스템은 전부 첨부 파일을 정확히 그렇게 실어 나랐습니다. R에는 일급의 메일 클라이언트는 없지만, .eml 파일을 받을 때가 오면 첨부 파일의 base64 부분은 그저 줄바꿈된 텍스트일 뿐입니다: 너그러운 디코더들이 디코딩하는 동안 그것을 풀어 줄 거예요. mime 패키지는 당신이 손에 든 것이 무엇인지를 알아내는 데 도움을 줍니다:
mime::guess_type("photo.jpg")
#> [1] "image/jpeg"
part <- "SGVsbG8s\r\nCnRoaXMgaXMgYW4g\r\nZW1haWwgYXR0YWNobWVudA=="
rawToChar(base64decode(part))
#> [1] "Hello,\nthis is an email attachment"
설정 파일이 이번 순시를 마무리합니다. 인증서나 덩어리가 YAML이나 JSON 설정, 또는 환경 변수 안에 Base64-인코딩되어 저장되어 있다면, R은 그것을 보통의 문자열로 읽고 당신은 필요할 때 디코딩합니다:
config_line <- "api_cert: TFRTU0VDUkVU"
cert_b64 <- sub("^api_cert: ", "", config_line)
raws <- base64decode(cert_b64)
rawToChar(raws)
#> [1] "LTSSECRET"
큰 페이로드
R 문자열에는 2^31 - 1 바이트라는 단단한 천장이 있고, Base64 문자열이 충분히 크면 그 천장에 부딪힐 수 있습니다. 인코딩된 형태가 원본보다 약 3분의 1 더 크기 때문이죠. base64enc 패키지는 2022년 릴리스부터 이것을 처리해 왔습니다: base64encode()에 줄 너비를 주면 거대한 문자열 하나 대신 줄들의 벡터를 돌려주고, 디코딩 쪽의 file = 인수는 파일을 줄 단위로 읽어 디코딩하므로 거대한 문자열 하나를 변수에 얹고 있을 필요가 없습니다.
수백 메가바이트의 파일이라면 실용적인 패턴은 정렬된 청크로 읽는 것입니다. Base64 그룹은 독립적이므로, 길이가 4의 배수인 어떤 청크든 혼자서 디코딩됩니다. 삐뚤어진 끝을 실어 나를 작은 버퍼 하나만 있으면 됩니다:
chunk <- 65536L
con <- file("huge.b64", "rb")
on.exit(close(con))
leftover <- ""
out <- raw(0)
while (TRUE) {
piece <- readChar(con, chunk, useBytes = TRUE)
if (length(piece) == 0) break
piece <- gsub("[\r\n]", "", piece)
ready <- paste0(leftover, piece)
take <- floor(nchar(ready) / 4) * 4
if (take > 0) {
out <- c(out, base64decode(substr(ready, 1, take)))
leftover <- substr(ready, take + 1, nchar(ready))
} else {
leftover <- ready
}
}
if (nchar(leftover) > 0) out <- c(out, base64decode(leftover))
length(out)
b64 패키지는 이 지형의 속도 챔피언입니다: Rust 엔진이 50메가바이트 파일을 단시간에 디코딩하며, 벡터화된 decode()는 인코딩된 값이 많은 열을 한 번의 호출로 처리합니다. 행마다 반복하는 것과는 극적인 차이죠. 문자열이 많이 있다면, 직접 system.time() 비교를 빠르게 돌려 보세요. 행 단위 루프와 벡터 호출 한 번 사이의 격차는 보통 중요할 만큼 큽니다.
명령줄
모든 것이 완전한 R 세션을 필요로 하는 것은 아닙니다. 클래식 Unix 도구들은 Base64를 네이티브로 말하며, R은 그들에게 일을 넘길 수도 있고 그들로부터 일을 받아 올 수도 있습니다. Linux에서는 base64 -d가 디코딩합니다 (macOS와 다른 BSD 시스템은 -D를 씁니다):
echo "SGVsbG8sIHdvcmxkIQ==" | base64 -d
#> Hello, world!
그리고 한 줄짜리 Rscript가 스크립트에서 쓰시는 같은 패키지로 같은 일을 해냅니다:
Rscript -e 'library(base64enc); writeLines(rawToChar(base64decode("TWFu")))'
#> Man
빠른 확인과 파이프에는 셸을, 결과가 데이터 프레임, 파일, 보고서에 살아야 할 때는 R을 쓰세요. 주의 한 가지: 명령줄 인수는 크기 한계 (ARG_MAX)가 있으므로, 수메가바이트짜리 문자열을 터미널에 붙여 넣지 마세요. 대신 파일로 파이프를 보내세요.
알아둘 만한 함정
이것이 R 개발자들을 물어뜯는 방식의 짧은 목록입니다. 전부 Base64 일반의 성질이 아니라 생태계 고유의 것입니다:
- openssl은 조용히 실패합니다.
base64_decode()는 알파벳 밖의 문자나 끝에 붙은 쓰레기에 부딪히면 오류도 경고도 없이 빈 raw 벡터를 돌려줍니다. 결과를 믿기 전에 항상length()를 확인하세요. - 디코더에게 NA는 결측값이 아닙니다.
NA_character_는 문자열"NA"로 강제 변환되며, 그것은 바이트0x34로 디코딩됩니다. 디코딩 전에NA를 걸러 내세요. - 벡터는 당신이 기대하는 대로 행동하지 않습니다.
base64decode()는 입력을 하나의 raw 벡터로 붙이고,openssl::base64_decode()는 여러 요소 입력에서the condition has length > 1로 죽으며, 진정으로 벡터화된 것은b64::decode()뿐입니다. - rawToChar은 조용한 파괴자입니다. UTF-8 로케일에서 유효하지 않은 UTF-8 바이트는 오류를 일으키지 않고
"unknown"로 표시된 문자열이 됩니다. 먼저checkUTF8()로 점검하세요. - JWT의 점은 정규식 와일드카드입니다.
fixed = TRUE없는strsplit(jwt, ".")는 모든 글자에서 쪼개집니다. 이것이 R 코드에서 가장 흔한 Base64 버그입니다. - b64 파일은 무자비합니다. 인코딩된 파일의 끝에 붙은 줄바꿈은
b64::decode_file()를 발작(panic)시킵니다.writeLines()가 아니라cat()으로 쓰세요. - 너그러운 것이 신뢰에는 충분히 너그러운 것이 아닙니다.
base64decode()의 기본 모드는 어디에나 있는 잘못된 문자를 건너뛰므로, 손상된 문자열이 오류 없이 그럴듯해 보이는 쓰레기로 디코딩될 수 있습니다. 신뢰 경계에서는strict = TRUE를 쓰세요. - b64의 오류 메시지는 Rust를 말합니다. 원치 않는 타입을 넘기면
Both cases of Either errored같은 문장을 기대하세요. 메시지는 의도적으로 도움이 안 됩니다; Rust 타입 시스템의 어깨 으쓱이기 때문입니다.
최선의 실천
- 일상의 일에는
base64enc::base64decode()를 기본으로 쓰고, 입력이 신뢰 경계를 넘는 곳이면 어디서든strict = TRUE를 켜두세요: 신뢰할 수 없는 API, 사용자 업로드, 서명된 무엇이었든. - 속도, 진정한 벡터화, 또는 URL에 안전한/패딩 없는 엔진이 필요하면
b64에 손을 대되, 그것이 설계상 엄격하다는 것을 받아들이세요. openssl가 이미 프로젝트에 있다면 사용하되, 길이를 확인할 때까지 모든 결과를 의심스럽다고 여겨 주세요.- 문자 집합은 의도적으로 결정하세요: UTF-8로 가정하고,
checkUTF8()로 검증하며, 보내는 쪽이 다르게 명시했을 때만iconv()를 쓰세요. - JWT에는
fixed = TRUE를 곁들인strsplit()로 엿보되,jose가 서명을 검증한 뒤에야 신뢰하세요. - 테스트에
TWFu를 남겨 두세요: 당신이 쓰는 어떤 디코딩 경로에도 바이트 3개짜리 스모크 테스트니까요. - Base64가 무엇이지 않은지 기억하세요: 그것은 암호화가 아니고, 압축도 아닙니다. 그것은 패킹 테이프이며, 이 글을 가진 사람은 그것이 한 일의 전부를 거스를 수 있습니다. 자유롭게 디코딩하고, 가려서 신뢰하세요.
R에서의 Base64 짧은 역사
형식은 오래되었습니다. 1987년 Privacy-Enhanced Mail 프로토콜을 위해 표준화되었고 (RFC 989), 1993년 MIME에 채택되었으며 (RFC 1521, 그리고 1996년에 최종 RFC 2045), 2003년 RFC 3548에서 다듬어졌고, 2006년 RFC 4648에서 URL에 안전한 알파벳을 포함한 현대적인 모양을 얻었습니다. R의 이야기는 훨씬 짧고 빠르게 움직입니다. Simon Urbanek의 base64enc 패키지는 2012년 9월에 CRAN에 처음 올라와 그때부터 일꾼(workhorse) 자리를 지키고 있으며, 2015년에 checkUTF8()를, 2022년에는 긴 벡터 지원을 갖추었습니다. 2024년 10월, 오래된 base64 패키지는 호환성 래퍼임을 명시하며 재발행되었고, 그 자체 설명이 이제 새 애플리케이션을 base64enc, openssl나 jsonlite로 가리킵니다. 그러고 2024년 초 b64가 왔습니다 (현재 릴리스 0.1.7, 2025년 7월). extendr로 지은 Rust 엔진으로, 진정한 벡터화와 알파벳 한 마당을 가져왔습니다. 2026년 2월, base64enc는 오랫동안 기다려온 strict 모드를 릴리스했고, 2026년 4월 jose 패키지는 jwt_* 함수를 중심으로 자신을 재설계했습니다. 다른 언어가 하나의 라이브러리로 낸 것을 R은 10년이 걸렸지만, 그 결과 도구상자 속의 모든 디코더에게 분명한 일과 분명한 개성이 주어졌습니다.
재미있는 사실들
완전한 가이드라면 웃음으로 끝내야 하므로:
base64decode(NA_character_)는 문자"4"를 돌려줍니다.NA_character_가 디코더에게 문자열"NA"로 건너가고,"NA"는 유효한 Base64 그룹이기 때문이죠. 이 언어에서 가장 R 특유의 1바이트 놀라움.openssl::base64_decode()에 불법 문자가 하나 섞인 문자열을 먹여 보면,raw(0)를 완전한 침묵과 함께 돌려줍니다. 생태계에서 가장 위험한 고요함.- strict
base64decode()호출은 매번v=30000, pad=0, org='u'같은 C 레벨 상태 줄을 떠들어댑니다. 이것은 경고가 아닙니다. C 코드가 목울대를 가다듬는 소리입니다. b64는 당신이 본 적도 없는 알파벳을 디코딩할 수 있습니다: BinHex, IMAP modified UTF-7, 그리고 bcrypt와 crypt의 맞춤 알파벳. R은 같은 엔진으로 1980년대 Macintosh 첨부 파일도, 현대의 비밀번호 해시도 읽을 수 있습니다.- R 문자열은 2^31 - 1 바이트에서 멈추므로,
base64enc는 긴 입력에 줄들의 벡터를 돌려주는 법을 배웠습니다. 형식이 언어의 벽에 부딪혔고, 패키지는 사다리를 자라냈습니다. - 단어 "base64"는
YmFzZTY0로 인코딩됩니다. 자기 자신을 설명할 수 있는 형식은, 모르세로 말하는 거울의 기술적 대응물입니다. - NUL 바이트 한 개도 여전히 4자리를 쓰게 합니다:
"AA==". Base64에서는 아무것도란 결코 아무것도 아닙니다. - 당신의 R 빌드에는 별명이 있습니다. R 4.5.0은 "How About a Twenty-Six"라고 불리는데, R 버전은 피너츠 만화와 영화에서 이름을 따오기 때문이고, 그 이름을 빌려온 스트립은 그 이름이 붙는 릴리스보다 수십 년 오래되었습니다. 당신이 디코딩에 쓰고 있는 버전조차 유머 감각을 가진 셈이죠.
마무리
어떤 동무들과 어울리느냐에 따라 디코더를 고르세요: 일상의 일에는 base64enc, 경계에서는 strict = TRUE를 켜 두고; 이미 프로젝트에 있는 openssl, 단 결과를 확인한다는 전제로; 속도, 벡터, URL에 안전한 엔진이 원하면 b64; 그리고 파일 잡무와 URL에 안전한 문자열에는 작은 전문가 base64와 base64url. 바이트를 텍스트라 부르자면 먼저 checkUTF8()로 지키고, 문자 집합이 다른 것으로 알면 iconv()를 쓰며, 중간에 있는 raw 벡터가 기능이라는 사실을 기억하세요: 그것은 라이브러리가 추측하게 두지 않고, 바이트의 의미를 당신이 결정하도록 강제합니다. 모든 것을 디코딩하되, 검증된 것만 신뢰하세요. 그리고 반대 방향으로 가야 할 때가 오면 - 문자열을 풀어낼 때가 아니라, 당신의 바이트를 여행용으로 문자열에 싸서 보내야 할 때 - 자매 문서가 R에서의 Base64 인코딩을 자세히 다룹니다.
마지막 업데이트: 2026-09-08
관련 문서: R에서의 Base64 인코딩: 완전한 가이드