Java 셸 스크립트

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

Java 셸 스크립트

간단한 작업을 할 때는 스크립트가 좋지만, 난해하며 기억하기 어려운 문법을 생각하면 Bash를 쓰고 싶지는 않다. Ruby나 Python 또는 JavaScript를 쓰는 것도 가능하긴 하다. 그러나 나는 Ruby나 Python을 별로 좋아하지도 않고 익숙하지도 않다. JavaScript는 조금 낫지만 파일 처리 API에는 익숙하지 않다.

뭔가 의미있는 작업을 하려면 결국 제일 익숙한 Java로 돌아오게 되는데, Java로 작성한 코드를 컴파일한 다음 콘솔에서 실행시키는 것은 여간 번잡한 게 아니다. 그런데 JDK 11부터는 자바 런처를 통해 소스 코드를 스크립트처럼 바로 실행할 수 있다. Java 파일을 shebang 파일처럼 실행하려면 파일이 다음 행으로 시작해야 한다.

#!/path/to/java --source version

소스 파일 확장자가 .java일 때는 --source 옵션을 생략할 수 있지만, 확장자가 .java가 아닐 때는 꼭 --source 옵션을 지정해야 한다. version에는 11, 15, 또는 16과 같이 JDK 버전을 지정하면 된다.

아주 간단한 스크립트를 작성해보자. 콘솔에 Hello, Shell! 메시지와 명령행 인자를 표시하는 아주 단순한 스크립트다. 별로 의미있는 일을 하는 건 아니지만, Java니까 의미있는 일은 필요할 때 마음대로 작성할 수 있다.

#!/usr/bin/java --source 16

import java.io.PrintStream;

public class Main {
  private static PrintStream out = System.out;
  public static void main(String[] args) {
    out.println("Hello, Shell!");
    for (String a : args) {
      out.printf(" - %s\n", a);
    }
  }
}

안타깝지만 public static void main(...)은 그대로 써줘야 한다. System.out.print*도 여전히 장황하지만, 위와 같이 out 변수를 선언한 다음에는 System.out.print*가 필요할 때 out.print*로 조금 짧게 쓸 수 있다.

위와 같이 shebang 파일을 만들었다면 파일 이름은 클래스 이름와 일치하지 않아도 된다. java-test라는 이름으로 저장했다고 하자. 파일은 실행 가능해야 하므로 chmod로 모드를 바꿔준다.

$ chmod +x java-test

이제 다음과 같이 프로그램을 실행할 수 있다.

$ ./java-test alplh beta gamma

실행 결과는 다음과 같다.

Hello, Shell!
 - alpha
 - beta
 - gamma

이렇게 하면 좋은 점이 하나 더 있다. 이 파일을 PATH에 있는 디렉터리에 옮겨 놓으면, 어느 디렉터리에서나 쉽게 java-test를 실행할 수 있게 된다.

진짜 문제

딸 아이가 과외 선생에게 받아오는 모의고사 문제를 복사해 뒀다가 여러 번 풀게 하고 싶었다. 처음에는 복사를 했는데, 한장 한장 복사하는 것은 품이 많이 든다. 생각해보니 복사하는 것 보다는 스캔해 두는 것이 더 낫겠다는 생각이 들었다. 한번 스캔해 두면 필요할 때마다 인쇄할 수 있으니까.

집에 있는 복합기에 자동 문서 공급 장치와 양면 인쇄 기능이 있다. 그러나 원본을 양면으로 스캔하는 기능은 없다. 따라서 먼저 홀수 페이지를 스캔한 다음 문서를 뒤집어 짝수 페이지를 스캔한 다음 이미지 파일을 원본 순서대로 짜맞춰야 한다. 홀수 페이지와 짝수 페이지 스캔 파일을 각각 odd, even 디렉터리에 저장했다.

Test
+- even
   +- Test_000
   +- Test_001
   +- Test_002
   +- ...
+- odd
   +- Test_000
   +- Test_001
   +- Test_002
   +- ...

스캔 결과 파일 이름은 Test_000, Test_001, Test_002와 같은 식으로 이름이 생성되는데, 이걸 홀수 페이지를 스캔한 것인지 짝수 페이지를 스캔한 것인지에 따라 다음과 같이 변환해야 한다. 원래는 첫 페이지를 홀수로 생각해야 하지만, 어찌 하다보니 짝수로 생각해버렸다. 아마 파일 번호가 000으로 시작해 그런 것 같다.

파일 이름에서 번호를 추출하고, 그 번호로 적절히 계산해 새로운 번호를 만든 다음, 새로운 번호로 파일을 복사하면 된다.

// even
*_000 -> *_000
*_001 -> *_002
*_002 -> *_004

따라서 다음과 같은 함수를 정의할 있다. n 은 스캔 파일 번호다.

%math Page_{even}(n) = 2n

홀수 페이지는 조금 복잡해진다. 문서 전체를 뒤집어 뒤부터 스캔하므로 Test_000은 마지막 페이지가 되어야 하기 때문이다. 약간의 계산 끝에 홀수 페이지를 처리할 때 사용할 공식을 만들었다. 스캔 파일 번호뿐 아니라 전체(홀수 페이지) 파일 개수( count )도 함께 넘겨야 한다.

%math Page_{odd}(n, count) = 2(count - n) - 1

원래 파일 이름을 새로운 파일 이름으로 변환하는 함수는 다음과 같이 작성할 수 있다. 정규식으로 원래 파일 이름에서 prefix와 번호를 뽑아낸 다음 위에서 정의한 함수를 이용해 새 파일 번호를 계산해 새 파일 이름을 구한다. 컨트롤 플래그가 있는 게 조금 마음에 안 들긴 하지만, 그냥 간단한 스크립트니까 넘어가기로 한다.

  private static String newName(String name, boolean toEven, int max) {
    Pattern p = Pattern.compile("^(.*)_(\\d{3}).jpg$");
    Matcher m = p.matcher(name);
    if (m.find()) {
      String prefix = m.group(1);
      int n = Integer.valueOf(m.group(2));
      int page = (toEven) ? pageEven(n) : pageOdd(n, max);
      return String.format("%s_%03d.jpg", prefix, page);
    }
    throw new RuntimeException("No number found.");
  }

처음에는 명령행으로 --even, --odd 인자를 받게 했다. 짝수 페이지 파일에 작업할 때는 --even을, 홀수 페이지 파일에 작업할 때는 --odd를 지정하도록 말이다. 그런데 생각해보니, 이미 디렉터리 이름이 even, odd로 되어 있으니 현재 디렉터리 이름을 보고 그에 맞게 동작하도록 하면 더 좋을 것 같았다.

조금 더 생각해보니, even 디렉터리와 odd 디렉터리에서 스크립트를 반복해 실행하는 대신 그냥 그 상위 디렉터리인 Test에서 evenodd를 한 번에 처리하게 하면 더 편해진다는 걸 깨달았다. 따라서 복사하는 함수를 다음과 같이 작성하고, main에서 copy 함수를 홀수, 짝수 페이지 파일에 대해 각각 호출하면 된다.

전체 코드는 다음과 같다.

#!/usr/bin/env java --source 16

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Mcp {
  private static int pageEven(int n) {
    return n * 2;
  }

  private static int pageOdd(int n, int cnt) {
    return 2 * (cnt -  n) - 1;
  }

  private static String newName(String name, boolean toEven, int max) {
    Pattern p = Pattern.compile("^(.*)_(\\d{3}).jpg$");
    Matcher m = p.matcher(name);
    if (m.find()) {
      String prefix = m.group(1);
      int n = Integer.valueOf(m.group(2));
      int page = (toEven) ? pageEven(n) : pageOdd(n, max);
      return String.format("%s_%03d.jpg", prefix, page);
    }
    throw new RuntimeException("No number found.");
  }

  private static void copy(String dir) throws IOException {
    boolean isEven = switch (dir) {
      case "even" -> true;
      case "odd" -> false;
      default -> throw new RuntimeException("invalid directory: " + dir);
    };

    File d = new File(".", dir);
    File[] files = d.listFiles(f -> f.getName().endsWith(".jpg"));
    Arrays.sort(files);

    for (File f : files) {
      String name = f.getName();
      String newName = newName(name, isEven, files.length);
      File dest =  new File(".", newName);
      System.out.printf("%s -> %s\n", name, newName);
      Files.copy(f.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
    }
  }

  public static void main(String[] args) throws IOException {
    copy("even");
    copy("odd");
  }
}

파일을 mcp로 저장하고 실행 chmod로 실행권한을 설정한 다음 $PATH로 잡힌 디렉터리로 옮긴다. 이미지 파일을 저장한 디렉터리(위 예에서는 Test)에 가서 mcp만 실행하면 올수 페이지와 짝수 페이지 스캔 이미지를 모두 한 디렉터리로 순서에 맞게 복사한다. 이렇게 해서 종이를 한 장씩 넘겨가며 복사하는 대신 문서 자동 공급기를 이용해 한방에 스캔할 수 있게 되었다.

참고