🔥 완전한 유틸리티 만들기

642자
6분

ArgumentParser 프레임워크를 사용해 커맨드 라인 도구(command line tool)를 만드는 방법을 살펴봅시다. 여기서는 count 명령어를 구현해 볼 텐데요, 이 명령어는 단어 개수를 세는 기능을 제공합니다.

먼저 필요한 모듈을 가져와야(import) 합니다.

import ArgumentParser
import Foundation
 
swift

그리고 ParsableCommand 프로토콜을 채택하는 Count 구조체를 정의합니다. @main 속성을 사용하면 이 구조체가 프로그램 시작점(entry point)이 됩니다.

@main
struct Count: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Word counter.")
 
    // 옵션과 플래그 속성을 추가할 위치
 
    mutating func run() throws {
        // 명령어 실행 로직을 구현할 위치
    }
}
 
swift

configuration 속성으로 명령어에 대한 간단한 설명을 제공할 수 있습니다.

이제 옵션과 플래그 속성을 추가해 봅시다.

@Option(name: [.short, .customLong("input")], help: "A file to read.")
var inputFile: String
 
@Option(name: [.short, .customLong("output")], help: "A file to save word counts to.")
var outputFile: String
 
@Flag(name: .shortAndLong, help: "Print status updates while counting.")
var verbose = false
 
swift
  • inputFileoutputFile 옵션으로 입력 파일 경로와 출력 파일 경로를 받습니다.
  • verbose 플래그로 명령어 실행 중 상태 메시지 출력 여부를 제어합니다.

이제 run() 메서드 안에 명령어 실행 로직을 구현해 보겠습니다.

mutating func run() throws {
    if verbose {
        print("""
            Counting words in '\(inputFile)' \\
            and writing the result into '\(outputFile)'.
            """)
    }
 
    guard let input = try? String(contentsOfFile: inputFile) else {
        throw RuntimeError("Couldn't read from '\(inputFile)'!")
    }
 
    let words = input.components(separatedBy: .whitespacesAndNewlines)
        .map { word in
            word.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
                .lowercased()
        }
        .compactMap { word in word.isEmpty ? nil : word }
 
    let counts = Dictionary(grouping: words, by: { $0 })
        .mapValues { $0.count }
        .sorted(by: { $0.value > $1.value })
 
    if verbose {
        print("Found \(counts.count) words.")
    }
 
    let output = counts.map { word, count in "\(word): \(count)" }
        .joined(separator: "\n")
 
    guard let _ = try? output.write(toFile: outputFile, atomically: true, encoding: .utf8) else {
        throw RuntimeError("Couldn't write to '\(outputFile)'!")
    }
}
 
swift

코드를 단계별로 살펴볼까요?

  1. verbose 플래그를 설정했다면, 어떤 파일에서 단어를 세고 어느 파일에 결과를 쓸지 출력합니다.
  2. inputFile에서 문자열을 읽어옵니다. 파일 읽기에 실패하면 RuntimeError를 던집니다.
  3. 입력 문자열을 단어 단위로 쪼개고, 특수 문자를 제거하며, 소문자로 변환합니다. 빈 문자열은 제거합니다.
  4. 단어별 등장 횟수를 세고, 등장 횟수에 따라 내림차순으로 정렬합니다.
  5. verbose 플래그가 설정되었다면, 총 단어 수를 출력합니다.
  6. 결과를 "단어: 등장횟수" 형식 문자열로 변환합니다.
  7. 결과 문자열을 outputFile에 씁니다. 파일 쓰기에 실패하면 RuntimeError를 던집니다.

마지막으로 RuntimeError 타입을 정의합니다. 이 타입은 ErrorCustomStringConvertible 프로토콜을 채택해 에러 메시지를 제공합니다.

struct RuntimeError: Error, CustomStringConvertible {
    var description: String
 
    init(_ description: String) {
        self.description = description
    }
}
 
swift

자, 이제 count 명령어 구현을 완성했습니다! 터미널에서 다음과 같이 실행할 수 있습니다.

$ swift run count -i input.txt -o output.txt -v
Counting words in 'input.txt' and writing the result into 'output.txt'.
Found 42 words.
 
shell

ArgumentParser 프레임워크를 활용하니 명령어 인터페이스를 정의하고 파싱하는 코드를 깔끔하게 작성할 수 있었습니다. 아래는 전체 소스 코드입니다.

import ArgumentParser
import Foundation
 
 
@main
struct Count: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Word counter.")
 
    @Option(name: [.short, .customLong("input")], help: "A file to read.")
    var inputFile: String
 
 
    @Option(name: [.short, .customLong("output")], help: "A file to save word counts to.")
    var outputFile: String
 
 
    @Flag(name: .shortAndLong, help: "Print status updates while counting.")
    var verbose = false
 
 
    mutating func run() throws {
        if verbose {
            print("""
                Counting words in '\(inputFile)' \\
                and writing the result into '\(outputFile)'.
                """)
        }
 
        guard let input = try? String(contentsOfFile: inputFile) else {
            throw RuntimeError("Couldn't read from '\(inputFile)'!")
        }
 
        let words = input.components(separatedBy: .whitespacesAndNewlines)
            .map { word in
                word.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
                    .lowercased()
            }
            .compactMap { word in word.isEmpty ? nil : word }
 
        let counts = Dictionary(grouping: words, by: { $0 })
            .mapValues { $0.count }
            .sorted(by: { $0.value > $1.value })
 
        if verbose {
            print("Found \(counts.count) words.")
        }
 
        let output = counts.map { word, count in "\(word): \(count)" }
            .joined(separator: "\n")
 
        guard let _ = try? output.write(toFile: outputFile, atomically: true, encoding: .utf8) else {
            throw RuntimeError("Couldn't write to '\(outputFile)'!")
        }
    }
}
 
 
struct RuntimeError: Error, CustomStringConvertible {
    var description: String
 
    init(_ description: String) {
        self.description = description
    }
}
 
swift