IO 객체(파일, 소켓 등)처럼 동작하지만, 문자열처럼 자유롭게 제어할 수 있는 객체를 찾고 계신가요?
그렇다면 StringIO가 바로 그 답입니다.
지금부터 실용적인 예제와 함께 반드시 알아두어야 할 주의사항까지 살펴보겠습니다.
기본 사용 예제
StringIO 객체는 다음과 같이 생성합니다.
io = StringIO.new("abc")
생성된 객체에는 gets, read, each_line 같은 메서드를 사용해 데이터를 읽을 수 있습니다.
가장 유용하게 쓰이는 메서드들을 표로 정리했습니다.
| 메서드 | 설명 |
|---|---|
| gets | 입력을 한 줄씩 읽음 |
| read | 지정한 바이트 수만큼 읽음(기본값은 전체) |
| each_line | 블록을 받아 각 줄을 순회 |
| each_char | 블록을 받아 각 문자를 순회 |
| << | 데이터를 끝에 추가 |
| rewind | 내부 위치 포인터를 처음으로 되돌림 |
| string | StringIO 객체의 내용을 실제 문자열로 반환 |
StringIO에는 위치 포인터(position pointer)가 있다는 점에 주목하세요.
이 포인터는 파일 객체와 마찬가지로 지금까지 몇 바이트를 읽었는지 추적합니다.
따라서 gets나 read 같은 메서드를 호출할 때마다 해당 크기만큼 데이터를 반환하고 포인터를 앞으로 이동시킵니다.
map이나 each_line 같은 Enumerable 메서드 역시 위치 포인터를 이동시키므로 항상 염두에 두어야 합니다.
io.each_line { |line| puts line }
포인터를 처음 위치로 되돌리고 싶다면 rewind 메서드를 사용하면 됩니다.
io.pos # 45 io.rewind io.pos # 0
여기까지가 기본 내용입니다.
이제 StringIO의 실전 활용 사례를 보여드리겠습니다. 그 전에 먼저 짚고 넘어갈 것이 하나 있습니다.
StringScanner와는 무엇이 다를까?
StringIO의 기능을 살펴봤는데, 사실 루비 표준 라이브러리에는 문자열 관련 클래스가 하나 더 있습니다.
바로 StringScanner입니다.
이름도 비슷하고 메서드도 유사해서 헷갈리기 쉽지만, 차이점을 명확히 구분해 드리겠습니다.
핵심은 다음과 같습니다.
StringIO 객체는 다른 IO 객체(File, Socket 등)를 대체할 수 있는 반면, StringScanner는 파싱 작업, 즉 텍스트를 여러 토큰으로 나누어 의미를 해석하는 용도로 설계되었습니다.
물론 이름에 "String"이 들어간 것 외에도 공통점이 있습니다. 두 클래스 모두 내부적으로 위치 포인터를 사용한다는 점입니다.
표준 입출력 대체하기
명령줄 애플리케이션에서 Kernel#gets 메서드로 사용자 입력을 받는 코드를 작성한다고 가정해 봅시다.
이 코드를 테스트하려면 매번 손으로 직접 입력값을 넣어야 하는 불편함이 있습니다.
그렇다면 자동화된 테스트는 포기해야 할까요?
아니요, 전혀 그렇지 않습니다!
바로 여기서 StringIO가 빛을 발합니다. 테스트용 입력값으로 StringIO 객체를 초기화한 뒤, $stdin이 가리키는 표준 입력 객체를 교체하면 됩니다(gets를 호출하면 루비는 이 $stdin에서 사용자 입력을 찾습니다).
예제:
io = StringIO.new("input")
$stdin = io
gets
# input
이 기법은 puts 같은 메서드의 출력을 캡처하는 데에도 활용할 수 있습니다.
출력 메서드는 '표준 출력(standard output)'이라 불리는 기본 출력 장치에 결과를 출력합니다. 루비에서 이것은 IO 객체로 표현되며, 이를 StringIO 객체로 교체할 수 있습니다.
예제:
io = StringIO.new("")
$stdout = io
# $stdout에 출력
puts "Jesus Castello is from Spain & likes to help people learn Ruby."
# 원래 값 복원
$stdout = STDOUT
io.rewind
io.read
# "Jesus Castello is from Spain & likes to help people learn Ruby."
입력 버전보다 절차가 조금 더 복잡한데, 그 이유는 원래의 STDOUT 객체를 반드시 복원해야 하고, 출력 내용을 읽기 위해 StringIO의 포인터도 되감아야 하기 때문입니다.
참고로 대부분의 테스트 프레임워크는 이 작업을 대신 해주는 메서드를 제공합니다(Minitest의 assert_output, RSpec의 output 매처 등). 하지만 내부에서 어떤 일이 일어나는지 이해하고 있으면 언제나 큰 도움이 됩니다. 🙂
마무리
이번 글에서는 실제 IO 객체를 흉내 내어 그 자리를 대신할 수 있는 StringIO 클래스에 대해 알아보았습니다.
이는 화면에 출력을 작성하거나 터미널을 통해 사용자 입력을 필요로 하는 클래스를 테스트할 때 특히 유용합니다.
StringIO의 흥미로운 활용 사례를 알고 계시다면 댓글로 알려주세요. 그리고 더 많은 사람들이 볼 수 있도록 이 글을 공유하는 것도 잊지 마세요!