설정 파일이 없는 FTP 서버, one-ftpserver

설정 파일 없이 명령행 옵션만으로 실행하는 FTP 서버 one-ftpserver를 소개합니다. 단일 바이너리 실행, --ssl 옵션 하나로 되는 FTPS, 접속 기록 로그, 자동 종료, JSON 출력을 정리하고 Java로 만들었던 첫 버전을 Go로 다시 쓰면서 바뀐 부분을 설명합니다.

배경

서버 작업을 하다 보면 PC에 있는 파일을 서버에 올리거나, 서버에 있는 로그 파일을 PC로 받고 싶을 때가 있습니다. 파일 하나를 옮기는 정도라면 다운로드는 Winstone으로, 업로드는 간단한 웹 애플리케이션인 uploader로 했습니다. Python이 설치된 서버라면 python3 -m http.server로 웹 서버를 띄우는 방법도 있습니다.

여러 개의 파일을 주고받을 때는 FTP가 편합니다. 그런데 대부분의 FTP 서버는 설정 파일을 만들고 사용자 목록을 따로 적어야 실행됩니다. 패키지를 내려받고, 압축을 풀고, 설정 파일을 편집하는 과정을 여러 번 반복하다 보니 실행할 때 옵션만 주면 바로 뜨는 서버가 필요했습니다. 그래서 2012년에 Apache FtpServer를 감싼 실행 가능한 jar 파일을 만들어서 one-ftpserver라는 이름으로 공개했습니다.

2026년 8월에는 같은 프로그램을 Go로 다시 작성해서 2.0.0을 냈습니다. 2.0.0부터는 JVM 없이 실행 파일 하나로 배포하고, jar 안에 넣어 두었던 FTPS용 key store도 없앴습니다. 이 글은 2026년 8월에 나온 2.2.0을 기준으로 씁니다.

  • 설정 파일을 읽지도, 쓰지도 않습니다. 모든 설정은 명령행 옵션이고, --help가 전체 목록입니다.

  • 바이너리 파일 하나로 실행됩니다. Linux와 Windows용으로 amd64, arm64 바이너리가 제공됩니다.

  • FTPS에 key store가 필요 없습니다. --ssl만 주면 인증서를 실행할 때 메모리에서 만듭니다.

  • 사용자 데이터베이스가 없습니다. 사용자는 --id, --password로 지정하는 한 명뿐입니다.

  • 클라이언트가 올린 파일과 접속 기록 로그 외에는 아무것도 남기지 않습니다.

설치와 실행

릴리스 페이지에서 플랫폼에 맞는 바이너리를 받습니다.

curl -LO https://github.com/benelog/one-ftpserver/releases/latest/download/one-ftpserver-linux-amd64
mv one-ftpserver-linux-amd64 one-ftpserver
chmod +x one-ftpserver

배포하는 파일은 다음의 4개입니다.

  • one-ftpserver-linux-amd64

  • one-ftpserver-linux-arm64

  • one-ftpserver-windows-amd64.exe

  • one-ftpserver-windows-arm64.exe

macOS에서는 go build로 직접 빌드하셔서 사용하실 수 있습니다.

아무 옵션 없이 실행하면 현재 디렉터리를 2121번 포트로 공유하고 익명 사용자(anonymous) 로그인을 받습니다.

one-ftpserver

포트, 아이디, 비밀번호, 공유할 디렉터리는 명령행에서 바로 지정합니다.

one-ftpserver --port=10021 --id=benelog --password=1234 --home=/srv/files

서버가 실행되면 적용된 설정과 함께, 그 설정에 맞는 클라이언트 명령을 계정 정보까지 채워서 출력합니다.

FTP server started : ftp://192.168.0.10:10021

# Settings
- ssl : false
- port : 10021
- passivePorts : [none]
- id : benelog
- password : 1234
- home : /srv/files
- timeout : [none]
- log : /home/benelog/one-ftpserver-2026-08-16.log

# Client commands
- upload : curl -T [filename] ftp://192.168.0.10:10021/ -u benelog:1234
- download : curl -O ftp://192.168.0.10:10021/[filename] -u benelog:1234
- download : wget --user=benelog --password=1234 ftp://192.168.0.10:10021/[filename]

이 명령을 그대로 복사해서 다른 PC에서 실행하면 파일을 주고받을 수 있습니다.

명령행 옵션

옵션 기본값 설명

--port

2121

제어 포트. 0을 주면 OS가 고른 포트를 잡고, 그 번호를 출력합니다.

--home

.

공유할 디렉터리. 클라이언트는 이 디렉터리 위로 올라갈 수 없습니다.

--id

anonymous

로그인할 수 있는 유일한 사용자.

--password

그 사용자의 비밀번호. --id만 주면 실행할 때 생성해서 출력합니다.

--passivePorts

passive 전송에 쓸 포트 범위. 10125-10199 형식으로 지정하고, 비워 두면 OS가 고릅니다.

--ssl

false

실행할 때 만든 인증서로 TLS 위에서 서비스합니다.

--cert

직접 준비한 인증서 PEM 파일. 지정하면 --ssl이 켜집니다.

--key

그 인증서의 개인 키 PEM 파일.

--timeout

0

30m처럼 지정한 시간이 지나면 서버가 스스로 종료합니다. 0이면 계속 떠 있습니다.

--publicHost

클라이언트에 알려 줄 주소. 비워 두면 서버가 찾아냅니다.

--json

false

설정 요약을 텍스트 대신 JSON으로 출력합니다.

--log

one-ftpserver.log

접속 기록을 남길 파일. 날짜별로 한 개씩 만들고, off를 주면 남기지 않습니다.

--console

false

접속 기록을 콘솔에도 출력합니다.

FTPS

one-ftpserver --ssl

--ssl을 주면 implicit FTPS로 동작합니다. ftps:// URL로 사용하는 방식입니다. 인증서는 서버가 시작할 때 메모리에서 만듭니다. 유효기간은 1년이고 localhost와 그 장비의 IP 주소를 담으며, 서버를 다시 띄우면 새로 만듭니다. 아무도 서명하지 않은 인증서라서 클라이언트가 검증할 방법이 없고, 그래서 서버가 출력하는 curl 명령에는 -k가 붙습니다.

curl -O ftps://192.168.0.10:2121/report.pdf -k

이 인증서는 전송 구간을 암호화할 뿐, 서버가 누구인지는 증명하지 못합니다. 서로 아는 두 사람이 신뢰할 수 없는 네트워크를 거쳐 파일을 주고받는 상황에는 충분하지만, 모르는 사람이 접속하는 서버라면 직접 발급받은 인증서를 PEM 파일 한 쌍으로 넘기는 편이 낫습니다. 한 쌍을 넘기면 --ssl은 저절로 켜지고, 출력되는 명령에서 -k가 빠집니다.

one-ftpserver --cert=fullchain.pem --key=privkey.pem

인증서를 무료로 발급받는 방법으로는 Let’s Encrypt가 편합니다. 도메인이 가리키는 장비에서 80번 포트를 열어 두고 발급받습니다.

sudo certbot certonly --standalone -d ftp.example.com

one-ftpserver --cert=/etc/letsencrypt/live/ftp.example.com/fullchain.pem \
              --key=/etc/letsencrypt/live/ftp.example.com/privkey.pem

직접 발급받은 인증서를 쓸 때 걸리기 쉬운 부분이 세 가지 있습니다.

  • 클라이언트는 접속에 쓴 이름을 인증서와 대조하므로, IP 주소가 아니라 발급받은 도메인으로 접속하게 해야 합니다.

  • /etc/letsencrypt 아래의 파일은 root만 읽을 수 있습니다. 서버를 root로 실행하거나, 읽을 수 있는 위치로 복사해야 합니다.

  • 인증서 파일은 시작할 때 한 번만 읽습니다. Let’s Encrypt 인증서는 90일마다 갱신되므로 갱신 후에는 서버를 다시 띄워야 합니다.

접속 기록 로그

로그인, 파일 전송, 목록 조회, 삭제, 이름 변경을 모두 기록합니다. 기본으로는 실행한 위치에 날짜가 붙은 파일을 만듭니다.

one-ftpserver-2026-08-16.log

날짜가 바뀌면 새 파일을 엽니다. 이름을 바꾸거나 지우는 처리는 하지 않으므로, 오래 띄워 둔 서버는 하루에 한 개씩 파일을 남깁니다. --log로 위치를 옮기고, off로 로그를 끕니다.

one-ftpserver --log=/var/log/ftp.log
one-ftpserver --log=off

--console은 같은 내용을 터미널에도 출력합니다. 전송이 되는지 눈으로 확인하려고 띄운 서버에 씁니다.

one-ftpserver --console
time=2026-08-16T10:27:25 level=INFO msg=login client=1 from=192.168.0.20:51000 id=benelog
time=2026-08-16T10:27:25 level=INFO msg=download client=1 from=192.168.0.20:51000 id=benelog path=/report.pdf
time=2026-08-16T10:27:26 level=INFO msg="download done" client=1 from=192.168.0.20:51000 id=benelog path=/report.pdf bytes=182004

두 옵션은 독립적입니다. --console만 주면 파일과 터미널에 함께 쓰고, --console --log=off를 주면 터미널에만 씁니다. 접속 기록은 표준 에러로 나가므로 설정 요약과 --json 출력은 표준 출력에 그대로 남습니다. 비밀번호는 로그에 남기지 않습니다. 로그인에 실패한 요청이 보낸 값도 마찬가지입니다.

정해진 시간 뒤 자동 종료

파일 하나를 옮기려고 띄운 서버가 그 뒤로도 계속 떠 있을 이유는 없습니다. --timeout에 지정한 시간이 지나면 서버가 스스로 종료합니다.

one-ftpserver --timeout=30m

NAT와 컨테이너 뒤에서 실행

passive 전송은 클라이언트가 다시 접속할 주소를 서버가 알려 주는 방식인데, 서버가 보는 주소와 클라이언트가 접근할 수 있는 주소가 다를 때가 있습니다. --publicHost로 알려 줄 주소를, --passivePorts로 방화벽에서 열거나 컨테이너에서 노출할 만큼 좁은 포트 범위를 지정합니다.

one-ftpserver --publicHost=203.0.113.10 --passivePorts=10125-10199
docker run -p 2121:2121 -p 10125-10199:10125-10199 ...

스크립트에서 쓰는 JSON 출력

--json은 텍스트 요약 대신 같은 내용을 담은 JSON 객체 하나를 출력합니다. 주소와 계정 정보를 텍스트에서 파싱하지 않고 꺼낼 수 있습니다.

one-ftpserver --id=benelog --json
{
  "address": "ftp://192.168.0.10:2121",
  "protocol": "ftp",
  "host": "192.168.0.10",
  "port": 2121,
  "id": "benelog",
  "password": "UEPH6KBIXDUIVEKC2Q23U3L4QI",
  "anonymous": false,
  "home": "/srv/files",
  "ssl": false,
  "log": "/home/benelog/one-ftpserver-2026-08-16.log",
  "upload": "curl -T [filename] ftp://192.168.0.10:2121/ -u benelog:UEPH6KBIXDUIVEKC2Q23U3L4QI",
  "download": "curl -O ftp://192.168.0.10:2121/[filename] -u benelog:UEPH6KBIXDUIVEKC2Q23U3L4QI",
  "get": "wget --user=benelog --password=UEPH6KBIXDUIVEKC2Q23U3L4QI ftp://192.168.0.10:2121/[filename]",
  "warnings": [
    "no --password was given, so this one was generated for this run"
  ]
}

--port=0과 같이 쓰면 포트를 정하지 않고 여러 대를 동시에 띄울 수 있습니다. 각 서버가 배정받은 포트를 출력합니다.

v0.1 Java 버전과 달라진 점

2.0.0은 Java로 만들었던 0.1을 Go로 다시 쓴 버전입니다. 실행 방법과 옵션 형식이 함께 바뀌었습니다.

  • 배포: java -jar one-ftpserver.jar 대신 플랫폼별 바이너리를 실행합니다. Maven 빌드는 없어졌습니다.

  • 옵션: port=10021처럼 쓰던 key=value 형식이 --port=10021 형식으로 바뀌었습니다. --ssl은 값을 받지 않습니다.

  • FTPS: 예전에는 jar 안에 넣어 둔 ftpkeystore.jks를 실행할 때마다 작업 디렉터리로 복사했고, 그 비밀번호가 소스 코드에 적혀 있었습니다. 지금은 실행할 때 메모리에서 인증서를 만들고 디스크에는 아무것도 쓰지 않습니다.

  • 비밀번호: --id만 주면 비밀번호가 아이디와 같아지던 동작을 고쳤습니다. 이제는 실행할 때마다 새로 만들어 출력합니다.

  • 홈 디렉터리: 클라이언트가 --home 위쪽 경로에 접근할 수 있던 문제를 고쳤습니다.

  • 익명 로그인: 클라이언트가 실제로 보내는 anonymousftp를 아이디로 받고, 비밀번호는 검사하지 않습니다.

구현에서 눈여겨본 부분

FTP 프로토콜은 ftpserverlib가 처리하고, 파일 시스템은 afero로 감쌉니다. One FTP Server가 더하는 부분은 사용자 한 명을 인증하는 driver, 실행할 때 만드는 인증서, 그리고 설정 요약 출력입니다.

한 명만 인증하는 driver

Java 버전에서는 Apache FtpServer의 UserManager 인터페이스를 구현해서 아이디와 비밀번호 한 쌍만 검사하는 SingleUserManager를 만들었습니다. Go 버전에서 그 역할을 하는 코드는 internal/oneftpserver/driver.goauthenticate입니다.

driver.go
func (d *driver) authenticate(user, pass string) bool {
	if d.config.Anonymous() {
		// "ftp" is the other name clients traditionally send for an anonymous
		// login, and the password is an e-mail address nobody checks.
		return user == AnonymousID || user == "ftp"
	}

	// Constant time by contract. Go's == makes no such promise: it happens to
	// compare 8 bytes at a time, so a short password gives an attacker nothing
	// to time today, but that is an implementation detail and not a guarantee.
	sameUser := subtle.ConstantTimeCompare([]byte(user), []byte(d.config.ID)) == 1
	samePassword := subtle.ConstantTimeCompare([]byte(pass), []byte(d.config.Password)) == 1

	return sameUser && samePassword
}

== 대신 crypto/subtleConstantTimeCompare를 쓴 이유는 상수 시간 비교를 보장받기 위해서입니다. 비교가 다른 바이트를 만나는 순간 멈추면 걸린 시간에 앞에서 몇 글자가 맞았는지가 드러난다는 것이 상수 시간 비교를 권하는 흔한 이유입니다. 다만 Go의 ==가 실제로 그렇게 새지는 않습니다. 16바이트 문자열을 Go 1.26.7에서 재 보면 불일치 위치가 0~7일 때 3.11ns, 8~15일 때 3.82ns로 두 값뿐이고 완전히 일치할 때는 그 사이인 3.48ns입니다. runtime.memequal이 바이트 단위가 아니라 8바이트씩 한 번에 비교하기 때문에 글자마다 시간이 늘어나는 구간이 없고, 오래 걸렸다고 더 많이 맞은 것도 아닙니다. 원격에서 응답 시간을 측정할 수 있는 한계가 LAN에서 100ns 수준이라는 보고를 감안하면 이 정도 차이는 신호가 되지 못합니다. 그래도 ConstantTimeCompare를 쓰는 이유는 ==가 상수 시간을 약속하지 않는다는 데 있습니다. 길이나 아키텍처, 런타임 구현이 바뀌면 위와 같이 다시 재야 하는데, 보장이 문서에 적힌 API를 쓰면 그럴 일이 없습니다.

로그인을 거절할 때는 아이디가 틀렸는지 비밀번호가 틀렸는지 구분하지 않고 같은 오류를 돌려줍니다.

홈 디렉터리 밖을 감추는 파일 시스템

인증에 성공한 클라이언트에는 홈 디렉터리를 최상위로 하는 파일 시스템을 넘깁니다.

driver.go
fs := afero.NewBasePathFs(afero.NewOsFs(), d.config.Home)
hiding := &pathHidingFs{Fs: fs, home: d.config.Home}

return &loggingFs{Fs: hiding, logger: logger}, nil

afero.NewBasePathFs가 홈 디렉터리 위로 올라가지 못하게 막는 부분입니다. 그런데 이 파일 시스템이 내는 오류 메시지에는 디스크의 전체 경로가 담기고, FTP 라이브러리는 그 메시지를 클라이언트에 그대로 전달합니다. 그래서 fs.gopathHidingFs로 한 번 더 감싸서, 오류에 담긴 경로를 클라이언트가 사용한 경로로 바꿉니다. 그 위에 로그를 남기는 loggingFs를 씌우기 때문에, 기록할 동작을 추가할 자리는 라이브러리가 아니라 이 래퍼입니다.

실행할 때마다 새로 만드는 인증서

tls.goselfSignedTLSConfig는 P-256 키와 자체 서명 인증서를 만들어 tls.Config에 바로 담습니다. 인증서에는 localhost와 그 장비의 IP 주소를 넣습니다. 클라이언트가 어느 인터페이스로 들어오든 접속에 쓴 주소가 인증서와 맞도록 하기 위해서입니다. 파일로 쓰지 않기 때문에 --ssl이 옵션 하나로 끝나고, 서버를 끄고 나면 키가 남지 않습니다.

실제 소켓으로 도는 통합 테스트

옵션 조합이 의도대로 동작하는지 확인하려면 반복 테스트가 필요합니다. Java 버전에서는 Apache Commons Net의 FTPClient로 통합 테스트를 만들었는데, Go 버전에서는 테스트에 필요한 만큼의 RFC 959 명령만 직접 구현한 클라이언트를 씁니다. server_test.go에서 서버를 실제로 띄우고 소켓으로 접속합니다.

server_test.go
func TestAFileCanBeUploaded(t *testing.T) {
	home := t.TempDir()
	summary := startServer(t, &Config{ID: "benelog", Password: "1234", Home: home})

	client := dial(t, summary.Port, false)
	client.login("benelog", "1234")
	client.store("uploaded.txt", "my works")

	stored, err := os.ReadFile(filepath.Join(home, "uploaded.txt"))
	if err != nil {
		t.Fatalf("the uploaded file is not on disk: %v", err)
	}
	if string(stored) != "my works" {
		t.Errorf("stored %q, want %q", stored, "my works")
	}
}

테스트는 모두 --port=0으로 서버를 띄웁니다. 포트를 OS가 고르므로 여러 테스트를 동시에 돌려도 포트가 겹치지 않습니다. FTPS 테스트도 같은 클라이언트를 tls.Dial로 연결해서 씁니다. implicit FTPS라서 AUTH TLS를 주고받는 절차 없이 첫 바이트부터 암호화됩니다.

품질 도구는 Go 코드에 품질 도구 적용하기에 정리한 구성을 그대로 씁니다. make check가 goimports, golangci-lint, 테스트를 차례로 실행하고, make ci는 GitHub Actions에서 push와 pull request마다 도는 명령입니다.

참고 자료

주요 변경이력
  • 2026.09.05

    • Go로 다시 만든 2.x 기준으로 새로 씀

    • ConstantTimeCompare를 쓴 이유를 실측 결과로 다시 씀

  • 2012.05.17

    • Java 버전 소개 글 최초 작성

IEEE 754 부동소수점 오차와 BigDecimal

IEEE 754 표현이 0.1 같은 십진 소수를 정확히 담지 못하는 이유와, 자바에서 그 오차를 피하는 BigDecimal과 정수형 최소 단위 두 대안, 그 위에 얹는 Java Money 같은 도메인 특화 라이브러리를 정리합니다.

자바의 double이나 float 자료형은 IEEE 754 부동소수점 표준에 따라 값을 2진수로 저장합니다. 이 방식으로는 십진수 0.1을 정확하게 표현할 수 없습니다. 0.1은 2진수로는 무한히 반복되는 소수가 되기 때문에, 유한한 비트 안에 담으려면 가장 가까운 값으로 반올림해야 합니다. 이 글에서는 그런 오차가 왜 생기는지, 어떤 경우에 위험한지를 실제 장애 사례와 함께 살펴보고, 자바에서 쓸 수 있는 대안을 정리합니다.

1. 코드로 확인하는 부동소수점 오차

부동소수점 오차는 jshell에서 간단히 확인해 볼 수 있습니다. jshell은 JDK 9부터 JDK에 포함된 대화형 실행 도구입니다. 따로 내려받을 필요 없이 JDK를 설치했다면 jshell 명령으로 바로 실행할 수 있습니다.

소수 더하기 연산
jshell> System.out.println(0.1 + 0.2);
0.30000000000000004

jshell> System.out.println(0.1 + 0.2 == 0.3);
false

jshell> System.out.println(1.03 - 0.42);
0.6100000000000001

0.1을 열 번 더해도 1.0이 되지 않습니다.

0.1 열 번 더하기
jshell> double sum = 0;
jshell> for (int i = 0; i < 10; i++) { sum += 0.1; }
jshell> System.out.println(sum);
0.9999999999999999

double 리터럴 0.1에 실제로 저장된 값은 BigDecimal 생성자로 확인할 수 있습니다.

double 리터럴 0.1의 실제 값
jshell> System.out.println(new BigDecimal(0.1));
0.1000000000000000055511151231257827021181583404541015625

jshell 명령행에서 0.1로 입력했지만 저장된 값은 0.1보다 아주 조금 큰 다른 수입니다.

이 오차는 자바만의 문제가 아닙니다. JavaScript의 number 타입도 IEEE 754 binary64 형식을 쓰기 때문에, Chrome 개발자 도구 콘솔에서 같은 식을 입력하면 결과가 똑같습니다.

Chrome 개발자 도구 콘솔에서 확인한 부동소수점 오차

왜 이런 값이 저장되는지는 다음 절에서 double의 비트 구조를 따라가며 살펴봅니다.

2. double(IEEE 754 binary64)의 비트 구조와 0.1의 저장 과정

IEEE 754는 여러 부동소수점 형식을 정의하는 표준입니다. 그중 자바가 쓰는 두 형식은 다음과 같습니다.

형식 1985년 명칭 크기 자바 자료형

binary32

single

32비트 (부호 1 / 지수 8 / 가수 23)

float

binary64

double

64비트 (부호 1 / 지수 11 / 가수 52)

double

1985년에 나온 첫 표준인 IEEE 754-1985에서는 이 두 형식을 single, double이라고 불렀고, 2008년 개정판에서 binary32, binary64로 이름이 바뀌어 현행 표준인 IEEE 754-2019까지 이어집니다. 표준에는 이 외에도 binary16, binary128과 10진 형식인 decimal32, decimal64, decimal128이 들어 있습니다.

IEEE 754는 실수를 2진수 과학적 표기법으로 바꾼 다음 비트로 저장합니다. 십진수 과학적 표기법에서는 소수점을 옮겨 정수부를 1~9 사이 한 자리로 만들고, 옮긴 자릿수를 10의 지수로 나타냅니다. 예를 들어 0.001은 1.0 × 10-3으로 씁니다. 2진수에서도 같은 원리로 2를 곱하거나 나눠서 정수부가 한 자리가 되도록, 즉 값이 1 이상 2 미만이 되도록 만듭니다. 2진수에서 0이 아닌 한 자리 숫자는 1뿐이므로, 이렇게 정규화한 결과는 언제나 ±1.가수 × 2지수 형태가 됩니다. 예를 들어 2진수 0.011(십진수 0.375)은 1.1 × 2-2으로 씁니다.

double (binary64)은 이렇게 정규화한 값을 64비트에 담으면서 다음 규칙을 따릅니다.

  • 부호(s)는 맨 앞 1비트에 저장합니다. 0이면 양수, 1이면 음수입니다.

  • 지수(e)는 실제 지수에 1023을 더한 값을 11비트에 저장합니다. 음수 지수까지 부호 없는 정수로 표현하기 위한 방식으로, 값을 읽을 때는 거꾸로 1023을 뺍니다.

  • 가수(f)는 1.가수에서 소수점 아래 부분만 52비트에 저장합니다. 정규화된 수는 정수부가 항상 1이므로 그 1은 생략합니다.

  • 소수점 아래가 52비트를 넘으면 가장 가까운 값으로 반올림합니다.

이 규칙대로 0.1을 저장한 결과가 아래 그림입니다. 64비트가 부호 1비트, 지수 11비트, 가수 52비트로 나뉘어 있습니다.

IEEE 754 double의 비트 구조와 0.1 저장 예

그림 아래쪽의 변환 과정을 따라가 보겠습니다. 0.1에 2를 거듭 곱해 보면 0.2, 0.4, 0.8을 거쳐 네 번째에 1.6이 되어 처음으로 1 이상 2 미만 구간에 들어옵니다. 2를 네 번 곱해서 1.6이 되었으니 거꾸로 0.1 = 1.6 ÷ 24 = 1.6 × 2-4입니다. 따라서 부호 비트는 0, 지수부는 -4 + 1023 = 1019가 됩니다. 문제는 가수입니다. 0.6을 2진수로 바꾸면 0.1001 1001 1001…처럼 1001이 무한히 반복되어 끝나지 않습니다. 52비트 안에 담으려면 반올림할 수밖에 없고, 그 결과 0.1이 아니라 0.1보다 아주 조금 큰 수가 저장됩니다. 앞에서 new BigDecimal(0.1)로 확인한 0.1000000000000000055511151231257827021181583404541015625가 바로 이 반올림된 값입니다.

모든 소수에서 오차가 생기는 것은 아닙니다. 0.5(1/2), 0.25(1/4), 0.75(3/4)처럼 분모가 2의 거듭제곱인 소수는 유한한 2진 소수가 되므로 정확하게 저장됩니다. 반면 0.1 = 1/10처럼 분모에 5가 끼어 있는 십진 소수는 2진수로는 무한히 반복되는 소수가 되어 반올림 오차를 피할 수 없습니다. 표준을 더 자세히 알고 싶다면 IEEE 754 위키백과 문서를 참고할 수 있습니다.

3. 소수의 2진수 표현 오차가 유발한 문제 사례

과학 실험의 데이터처럼 근삿값을 다루는 영역에서는 이런 오차가 용인되기도 합니다. 그러나 소수점 단위의 오차가 치명적인 버그를 만들 수 있는 분야도 있습니다. 이런 오차가 실제 장애로 이어진 국내와 해외 사례를 하나씩 살펴보겠습니다.

3.1. 2011년 NEIS 성적 처리 오류

2011년 7월 차세대 교육행정정보시스템(NEIS·나이스)에서 성적 처리 오류가 발생해, 전국 823개 고교에서 2만 9천여 명의 학기말 석차가 바뀌었습니다. 대입 수시모집을 앞둔 시점이어서 파장이 컸습니다.

나이스는 한 학기의 총점을 지필고사와 수행평가 점수를 합해 산출했습니다. 총점이 같은 학생들의 석차는 학교가 45가지 방법 중에서 고른 기준으로 정했습니다(영남일보 기사). 예를 들어 지필고사 점수를 우선하는 학교라면, 지필고사 65점에 수행평가 25점을 받은 A 학생과 지필고사 60점에 수행평가 30점을 받은 B 학생은 총점이 똑같이 90점이어도 A 학생이 앞선 등수를 받습니다. 동점자를 구분할 기준을 학교마다 미리 정해 두어야 할 만큼, 동점 판별은 석차 산출에서 중요한 절차입니다.

나이스는 총점을 소수점 이하 16자리까지 표시하도록 짜여 있었는데, 그 아래 자리에서 불규칙적으로 '1’이 나타나는 값이 발생했습니다. 글 앞부분에서 1.03 - 0.42의 결과로 0.6100000000000001이 나온 것과 같은 형태입니다. 이렇게 붙은 값 때문에 총점이 같아야 할 학생이 동점자로 판별되지 않거나 동점자 사이의 순서가 어긋나면서, A 학생이 앞서야 할 자리에서 B 학생이 앞서는 식으로 석차가 뒤바뀌었습니다(울산광역시교육청 안내문). 90과 90.00000000000001은 같은 값이 아니므로, 동점 여부를 그대로 비교했다면 두 학생은 동점자가 아니고 오차가 붙은 쪽이 더 높은 점수로 정렬됩니다. 교육과학기술부는 이 계산 오차를 보정하지 않은 것이 버그의 원인이라고 설명했습니다(경향신문 기사).

교육과학기술부 집계로는 전국 823개 고교에서 2만 9,007명의 석차가 바뀌었고, 그중 350개교 2,416명은 석차 등급까지 바뀌었습니다(제주일보 기사). 학교들은 오류 정정 프로그램으로 성적을 다시 계산하고 성적표를 재발송해야 했습니다(경향신문 기사).

같은 해 9월에 발표된 교육과학기술부 특별점검단의 결과는 어떤 타입에서 오차가 났는지까지 밝혔습니다. 구 나이스의 프로그램을 차세대 나이스로 재개발하는 과정에서 "새로 설치된 데이터베이스(DB2)의 특성상 나타나는 실수형(Double형) 자료 연산 오류를 예측하지 못하여 성적 처리 오류가 발생했던 것을 확인했다"는 내용입니다(전자신문 기사). 즉 성적 계산은 double에 해당하는 타입으로 이루어졌고, 그 오차를 잡아 주는 '오류 보정 코드’가 일부 프로그램에 빠져 있어 그곳에서 석차가 뒤바뀐 것입니다(연합뉴스 기사).

당시 네트워크 개발자 이경문 씨는 데일리시큐 기사에서 "정확한 수치 표현을 해야 하는 곳에서 부동소수점을 이용한 것"이 문제라며, 오차를 보정할 것이 아니라 정수로 처리하도록 설계를 바꾸거나 무한대의 정밀도를 지원하는 타입을 써야 한다고 지적했습니다. 이 글 뒷부분에서 소개하는 정수형 최소 단위와 BigDecimal이 바로 그 두 방향입니다. DB2의 DOUBLE 타입도 IEEE 754 binary64를 따르므로, 성적처럼 소수점 계산이 들어가는 값을 부동소수점으로 다루면 동점 여부 판정 같은 비교 연산에서 오류가 날 수 있음을 보여 주는 사례입니다.

3.2. 1991년 걸프전 패트리어트 미사일 요격 실패

0.1을 유한한 2진수로 표현할 수 없다는 문제는 인명 피해로도 이어졌습니다. 걸프전 중이던 1991년 2월 25일, 사우디아라비아 다란(Dhahran)의 미군 기지에 이라크의 스커드 미사일이 떨어져 미군 28명이 사망하고 97명이 다쳤습니다. 기지에는 요격용 패트리어트 미사일이 배치되어 있었지만 격추 시도조차 하지 못했습니다.

'역사 속의 소프트웨어 오류'(에이콘출판, 2014)의 1장 "0.000000095의 오차가 앗아간 28명의 생명"은 미국 GAO의 조사 보고서를 바탕으로 이 사건의 원인을 설명합니다. 미사일을 방어하는 패트리어트 시스템은 0.1초 단위로 시간을 세어 목표물이 나타날 구간을 예측하는데, 이 계산에 24비트 고정소수점 레지스터를 사용했습니다. 0.1은 2진수로는 무한히 반복되는 소수이므로 24비트 이후는 잘려 나갔고, 계산할 때마다 약 0.000000095의 오차가 쌓였습니다. 연속 가동 20시간이면 시계가 0.0687초, 사건 당시 포대처럼 100시간이면 0.3433초 어긋납니다. 초속 2km로 날아오는 스커드 미사일이라면 0.3433초 동안 687m를 이동합니다. 추적 레이더는 실제 미사일 위치에서 687m 벗어난 구간을 탐색했고, 미사일은 요격되지 못하고 병영에 떨어졌습니다.

이 시스템은 double 같은 부동소수점이 아니라 고정소수점으로 계산했습니다. 그러나 0.1을 유한한 비트의 2진수에 담지 못해 오차가 누적되었다는 점은 이 글에서 다룬 원리와 같습니다.

4. 자바에서 소수점 연산 오차를 피하는 대안

double형 연산에서 생기는 오차를 피하는 두 가지 방법을 소개합니다.

4.1. BigDecimal

자바에서 십진 소수를 정확하게 다루는 기본 수단은 java.math.BigDecimal입니다. Never Use Float and Double for Monetary Calculations에서도 금액 계산에 부동소수점 자료형 대신 BigDecimal을 쓰라고 권고합니다.

BigDecimal의 더하기 빼기 연산
jshell> new BigDecimal("0.1").add(new BigDecimal("0.2"))
$1 ==> 0.3

jshell> new BigDecimal("1.03").subtract(new BigDecimal("0.42"))
$2 ==> 0.61

한 가지 주의할 점은 생성자에 double을 넘기면 오차를 피하는 효과가 없어진다는 것입니다. 앞에서 봤듯이 new BigDecimal(0.1)은 0.1이 아니라 double에 저장된 근삿값을 그대로 옮겨 담습니다. 문자열을 받는 생성자나 BigDecimal.valueOf()를 사용해야 합니다.

BigDecimal을 쓴다고 반올림 정책까지 저절로 정해지지는 않습니다. 1을 3으로 나누는 것처럼 십진수로 끝나지 않는 나눗셈은 자릿수와 반올림 방식을 지정하지 않으면 예외가 발생합니다.

jshell> new BigDecimal("1").divide(new BigDecimal("3"))
|  Exception java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.

jshell> new BigDecimal("1").divide(new BigDecimal("3"), 10, RoundingMode.HALF_UP)
$1 ==> 0.3333333333

여기서 쓴 java.math.RoundingMode는 0.5를 올릴지(HALF_UP), 짝수 쪽으로 붙일지(HALF_EVEN), 무조건 버릴지(DOWN) 같은 반올림 방식을 고르는 열거형입니다. BigDecimal이 없애 주는 것은 2진수 표현에서 생기는 오차입니다. 소수점 이하 몇 자리까지 어떤 방식으로 반올림할지는 해당 분야의 특성에 맞게 팀이 정해서 지켜야 하는 규칙이 됩니다.

Joshua Bloch도 'Effective Java' 3판의 아이템 60 '정확한 답이 필요하다면 float와 double은 피하라'(Avoid float and double if exact answers are required)에서 같은 권고를 합니다. 이 항목은 BigDecimal과 함께 int, long도 대안으로 제시하는데, 둘 중 무엇을 고를지는 뒤의 선택 기준 절에서 정리합니다.

4.2. 정수형 최소 단위 계산

금액을 원(₩)이나 센트 같은 최소 단위의 정수로 다루는 방법도 널리 쓰입니다. 특히 1원 이하의 가치가 적은 한국에서는 업무 규칙과 어긋나지 않는다면 실용적인 방법입니다. 같은 이유로 기존 시스템의 데이터베이스도 원화 금액을 정수 칼럼으로 잡아 둔 경우가 많습니다. 그래서 애플리케이션에서 BigDecimal로 계산하더라도 저장하기 직전에 정수로 바꾸는 과정이 한 번은 들어갑니다. 우아한형제들 기술 블로그의 Spock으로 테스트코드를 짜보자에 나오는 예제가 이 방식을 보여 줍니다. 반올림 처리에는 BigDecimal을 쓰지만, 메서드의 입력과 리턴 타입은 long입니다.

public static long calculate(long amount, float rate, RoundingMode roundingMode) {
    return BigDecimal.valueOf(amount * rate * 0.01)
            .setScale(0, roundingMode).longValue();
}

금액 자체는 정수로 유지하고, 소수점이 생기는 비율 계산 직후에 반올림 모드를 명시한 BigDecimal로 다시 정수로 돌아오는 구조입니다.

다만 amount * rate * 0.01처럼 부동소수점 자료형이 섞이면 그 계산은 부동소수점 연산이 됩니다. long 값이 float로 변환되면서 정밀도를 잃기 때문에, 입력과 리턴 타입이 정수여도 금액이 커지면 10진수로는 예상 밖의 결과가 나옵니다.

calculate 메서드의 한계
jshell> calculate(16_777_217L, 100f, RoundingMode.HALF_UP)
$1 ==> 16777216

100%를 곱했으니 16,777,217이 그대로 나와야 하는데 1원이 사라졌습니다. float의 가수는 23비트여서 224(16,777,216)을 넘는 정수를 빠짐없이 표현하지는 못하고, 16_777_217Lfloat로 변환되는 순간 16,777,216이 됩니다. 입력을 정수로 유지해도 중간 계산 한 곳이 부동소수점이면 오차는 그대로 들어옵니다.

인용한 예제를 고쳐서 비율까지 BigDecimal로 옮기면 이 문제가 사라집니다.

곱셈까지 BigDecimal로 옮긴 버전
public static long calculate(long amount, BigDecimal percent, RoundingMode roundingMode) {
    return BigDecimal.valueOf(amount)
            .multiply(percent)
            .movePointLeft(2)
            .setScale(0, roundingMode)
            .longValue();
}

이 버전에서 calculate(16_777_217L, new BigDecimal("100"), RoundingMode.HALF_UP)은 16,777,217을 그대로 돌려줍니다. 정수형 최소 단위 방식을 쓰더라도 오차를 없애려면 모든 중간값을 정수나 BigDecimal로 유지해야 합니다.

5. BigDecimal과 정수형 최소 단위의 선택 기준

우선 계산 정밀도가 중요한 모듈에서 double형은 반환형이나 메서드 파라미터 타입으로 쓰지 않는다는 규칙은 최소한도로 추구할만 합니다. 이를 어떤 타입으로 대체할지는 앞에서 말한 BigDecimal vs '정수형 최소 단위’의 선택지가 있습니다. 성능과 코드의 간결함은 long이 낫습니다. BigDecimal은 개발자의 실수를 예방하는 측면에서는 유리합니다.

앞에서 언급한 'Effective Java' 아이템 60도 같은 기준으로 선택을 정리해 두었습니다. 소수점 추적을 시스템에 맡기고 반올림 방식까지 고르고 싶다면, 기본 타입보다 쓰기 불편하고 느리다는 비용을 감수하고 BigDecimal을 쓰라는 것이 이 항목의 결론입니다. 반대로 성능이 중요하고 소수점을 직접 추적할 수 있다면 int나 long을 쓰라는 대안도 함께 제시하는데, 앞 절에서 본 정수형 최소 단위 계산과 같은 방법입니다. 값이 십진수로 아홉 자리를 넘지 않으면 int, 열여덟 자리를 넘지 않으면 long, 그보다 크면 BigDecimal을 쓰라는 자릿수 기준도 덧붙입니다.

Chronicle Software의 Peter Lawrey는 If BigDecimal is the answer, it must have been a strange question에서 BigDecimal의 단점을 이렇게 꼽습니다.

  • 문법이 자연스럽지 않다. 연산자 대신 메서드 호출을 이어 붙여야 해서 수식의 가독성이 떨어진다.

  • double보다 메모리를 많이 사용한다. 객체를 계속 생성하므로 가비지가 많이 만들어진다.

  • 대부분의 연산에서 훨씬 느리다.

Lawrey의 결론은 "double의 반올림을 다루는 법을 모르거나 프로젝트 표준이 BigDecimal을 강제한다면 BigDecimal을 쓰되, 선택할 수 있다면 BigDecimal이 당연히 옳다고 가정하지는 말라"는 것입니다. 지연 시간에 민감한 금융 시스템 중에는 BigDecimal 없이 만들어진 시스템도 많이 돌아가고 있다는 점도 근거로 듭니다.

정수형 최소 단위 계산은 시스템 자원을 적게 쓴다는 장점이 있습니다. long 타입의 값은 객체를 만들지 않고, 연산도 CPU의 정수 연산으로 끝납니다. 대신 개발자가 정확한 연산을 더 많이 의식해야 합니다. 반올림 규칙을 정해야 한다는 점은 BigDecimal도 마찬가지라 차이가 아닙니다. 차이는 그 규칙을 빠뜨렸을 때 나타납니다. BigDecimal은 십진수로 끝나지 않는 나눗셈에서 반올림 방식을 빠뜨리면 앞에서 본 것처럼 예외를 던지지만, long 나눗셈은 소수점 이하를 경고 없이 버립니다. 최소 단위를 무엇으로 볼지를 시스템 전체가 같은 규칙으로 지켜야 한다는 부담도 BigDecimal에는 없습니다. 기본 산술 연산자로 계산하다 보니, calculate() 예제의 amount * rate * 0.01처럼 중간에 부동소수점이 섞여도 컴파일러가 막지 않습니다.

지금까지의 내용을 정리하면 다음과 같습니다.

Table 1. BigDecimal과 정수형 최소 단위 비교
자료형 저장 방식 강점 감수할 점

BigDecimal

임의 정밀도 정수와 스케일

  • 자릿수 제한 없이 십진수를 정확하게 표현

  • 최소 단위를 따로 정하지 않아도 됨

  • 메모리 사용량 증가

  • 연산 속도 저하

  • 기본 연산자 대신 메서드 체인 호출로 연산해야 하는 번거로움

long

최소 단위로 환산한 정수

  • 객체 생성이 없어 메모리 사용이 적음

  • CPU의 정수 연산으로 끝나 빠름

  • 최소 단위 규칙을 시스템 전체가 지켜야 함

  • 중간 연산에 double이나 float가 끼어들면 오차가 생김

어떤 타입을 쓸지 정하고 실제 코드에 반영하는 데 가장 중요한 것은 팀 구성원이 이해하기 쉬운 단순한 규칙으로 명시하는 것입니다. 시스템의 담당 개발자는 계속 바뀔 수 있습니다. 모든 사람이 이 글에서 다룬 오차가 생기는 원리까지 이해하고 있다고 전제하기는 어렵습니다. 규칙이 명확하고 단순하면 새로 투입된 개발자도 동일하게 따라갈 가능성이 높아집니다. 그 결과로 전체 모듈에서 소수점 이하 연산을 다루는 일관성이 지켜지고, 치명적인 오차를 만드는 코드가 들어올 가능성도 줄어듭니다. 따라서 정하려는 규칙을 간결하고 명확하게 문서로 적을 수 있는지도 판단 기준으로 삼아 봤으면 합니다.

규칙을 정하고 나면 그와 어긋나는 기존 코드까지도 고쳐야 앞으로도 일관성이 유지됩니다. 그런데 이미 잘 돌아가고 있는 데다 금액을 직접 다루는 코드는 고치기가 무섭습니다. 그래서 한번 자리 잡은 방식을 바꾸지 않는 경우도 많습니다. 그런 상황에 대비해서도 금액처럼 민감한 수치를 다루는 로직은 쉽게 테스트할 수 있는 구조로 분리해야 합니다. 테스트하기 쉬운 구조라면 소수점 오차뿐 아니라 새로 추가하는 코드의 논리 오류도 이른 시점에 감지할 수 있습니다.

6. 금액과 단위를 다루는 도메인 특화 라이브러리

앞에서 본 두 방법은 수를 어떤 형태로 담을지의 선택입니다. 그 위에서 금액, 시간, 물리량처럼 단위가 붙는 값을 전용 타입으로 감싸는 도메인 특화 라이브러리도 있습니다. 이런 라이브러리도 안에서는 결국 BigDecimal이나 정수형 최소 단위 중 하나로 수를 담습니다. 대표적인 예로 Java Money를 살펴봅니다.

6.1. Java Money (JSR 354)

Java Money는 금액과 통화를 함께 다루는 표준 API입니다. MonetaryAmount 인터페이스로 금액과 통화 단위를 묶어서 표현하며 JSR 354로 표준화됐습니다. JCP 제안서에서는 Java SE 9에 포함하는 방안도 검토했지만 최종적으로 JDK에는 들어가지 않았습니다. 그래서 API(javax.money)와 참조 구현인 Moneta를 별도 의존성으로 추가해야 사용할 수 있습니다.

build.gradle
implementation 'org.javamoney:moneta:1.4.5'

Money 구현체는 내부적으로 BigDecimal로 계산하므로, 글 앞부분에서 double로는 0.6100000000000001이 나왔던 1.03 - 0.42도 정확히 0.61이 됩니다.

MonetaryAmount price = Money.of(new BigDecimal("1.03"), "USD");
MonetaryAmount result = price.subtract(Money.of(new BigDecimal("0.42"), "USD"));
System.out.println(result);

통화가 다른 금액끼리 연산하면 예외가 발생합니다. 서로 다른 통화의 금액을 섞어 계산하는 실수를 API가 타입 수준에서 막아 줍니다.

price.add(Money.of(100, "KRW"));

Java Money는 앞의 두 방법을 대체하지 않고 그 위에 얹혀 있습니다. MonetaryAmount 인터페이스에는 구현체가 둘 있고, 각각이 이 글에서 본 두 가지 수치 표현에 그대로 대응합니다.

  • Money: 금액을 BigDecimal로 저장합니다. 그만큼 앞에서 정리한 BigDecimal의 비용도 그대로 집니다.

  • FastMoney: 금액을 long 하나에 최소 단위로 저장합니다. 소수점 아래 5자리로 스케일이 고정된다는 제약을 받는 대신, 자바독은 Money보다 10~15배 빠르다고 소개합니다.

인터페이스가 같으므로 속도가 문제가 되면 Money 대신 FastMoney로 바꿔 끼울 수 있습니다. 도메인 타입을 도입하더라도 수치 표현의 선택은 사라지지 않고, 앞 절에서 정리한 트레이드오프가 구현체 선택으로 다시 나타납니다.

Java Money를 선택할지는 통화를 여러 개 다루는지에 달렸다고 봅니다. 통화가 하나뿐이라면 BigDecimal이나 long 값을 그대로 쓰는 것으로도 충분하고, 여러 통화가 섞이는 시스템이라면 통화 불일치를 API가 걸러 준다는 이점이 별도 의존성을 감수할 만합니다. 더 많은 사용 예제는 Baeldung의 Java Money and the Currency API를 참고할 수 있습니다.

6.2. 그 밖의 도메인 특화 라이브러리

수치 표현을 도메인 타입으로 감싸는 라이브러리는 Java Money 외에도 있습니다.

  • Joda-Money: Java Money 이전부터 쓰인 금액 라이브러리입니다. Money 클래스는 금액을 BigDecimal로 저장하되 스케일을 통화의 기본 소수 자릿수(달러는 2자리, 엔은 0자리)로 고정하고, BigMoney 클래스는 스케일을 자유롭게 둡니다.

  • Units of Measurement API (JSR 385): 길이나 질량 같은 물리량과 그 단위를 타입으로 표현합니다. API 패키지는 javax.measure이고 참조 구현은 Indriya입니다. 금액에서 통화가 다르면 예외가 나는 것처럼, 단위가 맞지 않는 계산을 걸러냅니다.

  • java.time.Duration: 별도 의존성이 아니라 JDK 안에 있는 예입니다. 시간의 길이를 초(long)와 나노초(int) 두 필드로 나눠 저장합니다. 소수점 있는 초를 double 하나로 담지 않는다는 점에서 정수형 최소 단위와 같은 기법입니다.

이 목록에 맞는 라이브러리가 없더라도, 시스템 안에서 직접 만든 타입으로 같은 역할을 할 수 있습니다. 자체 개발한 코드 안에서 수를 BigDecimal이나 정수 중 무엇으로 담을지를 이 글에서 설명한 기준에 따라서 결정하고 중간 연산에서 floatdouble이 들어가서 생기는 오류를 방어해야 합니다.

BTrace로 MySQL JDBC 드라이버의 내부 동작 측정하기

실행 중인 JVM에 붙어서 MySQL Connector/J 내부 메서드의 호출 횟수와 평균 실행 시간을 집계하는 BTrace 스크립트 예제를 설명합니다.

BTrace로 실행 중인 애플리케이션의 MySQL JDBC 드라이버(Connector/J) 내부 메서드의 호출 횟수와 평균 실행 시간을 집계할 수 있습니다. 이를 수행하는 스크립트는 examples/btrace-scripts에 있습니다.

BTrace 소개

BTrace는 실행 중인 JVM에 붙어서(attach) 지정한 메서드에 계측 코드를 끼워 넣는 동적 트레이싱 도구입니다. 애플리케이션 소스를 수정하거나 서버를 재시작하지 않아도 됩니다. 추적할 지점과 수집할 값은 Java 문법으로 작성한 스크립트에 애너테이션으로 선언합니다.

이 블로그에서는 예전에 Btrace로 DBCP의 connection정보를 모니터링 하기에서 소개한 적이 있습니다. 이번 예제는 같은 도구로 Connection pool 대신 JDBC 드라이버 내부를 들여다봅니다.

무엇을 측정하는가

ConnectionMonitor.java는 MySQL Connector/J 5.1의 내부 메서드 4개를 추적합니다.

추적 대상 의미

ConnectionImpl.prepareStatement()

PreparedStatement 생성. 드라이버의 cachePrepStmts 옵션의 효과를 확인할 수 있습니다.

ConnectionImpl.setAutoCommit()

autoCommit 변경. 옵션에 따라 DB 서버와의 통신이 발생하는 지점입니다.

PreparedStatement.executeInternal()

PreparedStatement 실행이 공통적으로 거치는 내부 메서드입니다.

ConnectionImpl.execSQL()

드라이버가 실제로 SQL을 서버에 전송하는 내부 메서드입니다.

각 메서드의 호출 횟수와 평균 실행 시간을 모으면, 드라이버 튜닝 옵션(cachePrepStmts, useLocalSessionState 등)을 바꿨을 때 내부 호출이 실제로 줄어드는지 수치로 확인할 수 있습니다. 애플리케이션 코드 계층에서는 보이지 않는 드라이버 내부의 동작이라서 BTrace 같은 도구가 유용합니다.

스크립트 구조

핵심 부분만 옮기면 아래와 같습니다.

ConnectionMonitor.java
@BTrace
public class ConnectionMonitor {
	private static Aggregation prepareDuration = Aggregations.newAggregation(AggregationFunction.AVERAGE);
	private static Aggregation prepareCount = Aggregations.newAggregation(AggregationFunction.COUNT);

	@OnMethod(clazz = "com.mysql.jdbc.ConnectionImpl", method = "prepareStatement", location = @Location(Kind.RETURN))
	public static void statementPrepare(@Duration long duration) {
		Aggregations.addToAggregation(prepareDuration, duration / 1000);
		Aggregations.addToAggregation(prepareCount, 1);
	}

	@OnEvent
	public static void summary() {
		println("## com.mysql.jdbc.ConnectionImp.prepareStatement()");
		Aggregations.printAggregation("- call count : ", prepareCount);
		Aggregations.printAggregation("- average duration(microseconds) :", prepareDuration);
		Sys.exit(0);
	}
}

사용한 BTrace 기능은 세 가지입니다.

  • @OnMethod + @Location(Kind.RETURN) : 지정한 클래스·메서드가 리턴하는 시점에 핸들러를 실행합니다. 메서드 실행 시간을 담는 @Duration 파라미터는 Kind.RETURN 위치에서만 쓸 수 있습니다. 값의 단위는 나노초라서 1000으로 나눠 마이크로초로 기록했습니다.

  • Aggregation : 호출마다 값을 출력하면 부하도 크고 읽기도 어려우므로, COUNT`와 `AVERAGE 집계 함수로 요약값만 유지합니다.

  • @OnEvent : BTrace 클라이언트에서 이벤트를 보내면 실행되는 핸들러입니다. 여기서 집계 결과를 출력하고 `Sys.exit(0)`으로 세션을 끝냅니다.

저장소에는 `PrintQuery.java`도 있는데, 실행 쿼리 출력용으로 만들려다 만 것인지 `ConnectionMonitor.java`와 내용이 같은 사본입니다.

이전 BTrace 글에서 소개했던 DBCP 모니터링 스크립트들(DbcpMonitor.java, DbcpMonitorSimple.java, DbcpActiveConnectionMonitor.java)도 gist에서 이 저장소로 옮겨 함께 두었습니다. CUBRID 드라이버와 Spring 서블릿을 추적하던 CubridConnectionMonitor.java, `DaoMonitor.java`도 같은 gist에서 옮긴 스크립트입니다.

실행 방법

BTrace 클라이언트로 대상 JVM의 pid를 지정해서 실행합니다.

btrace <pid> ConnectionMonitor.java

측정을 끝내고 싶을 때 클라이언트에서 Ctrl+C를 누르면 이벤트 전송 여부를 묻는 메뉴가 나옵니다. 이벤트를 보내면 @OnEvent 핸들러가 실행되어 아래처럼 요약이 출력됩니다.

## com.mysql.jdbc.ConnectionImp.prepareStatement()
- call count :  128
- average duration(microseconds) : 42
## com.mysql.jdbc.ConnectionImpl.setAutoCommit()
- call count :  64
- average duration(microseconds) : 120
...

pom.xml에 선언한 btrace-client 의존성은 빌드용이 아니라 IDE에서 BTrace API의 자동완성과 컴파일 검증을 받기 위한 것입니다. 실제 실행은 위처럼 BTrace 클라이언트가 스크립트 파일을 직접 받아서 처리합니다.

JDK 25 기준의 선택

이 스크립트는 BTrace 1.2와 Connector/J 5.1, JDK 6~8 시절에 작성했습니다. 최신 LTS인 JDK 25 기준으로는 상황이 꽤 다릅니다.

BTrace를 계속 쓴다면

  • BTrace는 java.net(kenai) 시절을 지나 현재 GitHub의 btraceio/btrace에서 관리됩니다. 다만 마지막 릴리스가 2023년 11월의 v2.2.6으로 활동이 뜸합니다.

  • 2.x 버전부터 패키지가 `com.sun.btrace`에서 `org.openjdk.btrace`로 바뀌어 이 스크립트를 그대로 쓸 수는 없습니다.

  • Connector/J도 6.0부터 패키지가 `com.mysql.jdbc`에서 `com.mysql.cj`로 바뀌어 추적 대상 클래스명을 바꿔야 합니다.

  • JDK 21부터는 JEP 451에 따라 실행 중인 JVM에 에이전트를 동적으로 붙일 때 경고가 출력됩니다. JDK 25에서도 아직 허용되지만, 경고 없이 쓰려면 대상 JVM을 -XX:+EnableDynamicAgentLoading 옵션으로 시작해야 합니다. BTrace처럼 attach에 의존하는 도구 전반에 해당하는 변화입니다.

JDK 25라면 JFR만으로 가능

이 스크립트가 하던 "특정 메서드의 호출 횟수와 평균 실행 시간 집계"는 JDK 25부터 외부 도구 없이 JFR(Java Flight Recorder)로 할 수 있습니다. JEP 520: JFR Method Timing & Tracingjdk.MethodTiming, jdk.MethodTrace 이벤트를 추가했습니다.

java '-XX:StartFlightRecording:jdk.MethodTiming#filter=com.mysql.cj.jdbc.ConnectionImpl::prepareStatement,filename=timing.jfr' -jar app.jar

jfr view method-timing timing.jfr

필터는 클래스::메서드 형식 외에 클래스 단위, 애너테이션 단위(@com.example.Debug)로도 지정할 수 있고, jcmd로 실행 중인 JVM에 같은 필터로 기록을 시작할 수도 있습니다. 결과는 메서드별 호출 횟수와 평균 시간 표로 출력되어, `ConnectionMonitor.java`의 요약 출력과 같은 정보를 줍니다.

다만 JEP 520은 실행 시간만 기록할 뿐 메서드의 파라미터, 리턴값, 필드값은 보여주지 않습니다. 이전 글의 DBCP 스크립트처럼 객체 내부 값을 들여다보는 용도라면 여전히 BTrace나 Arthas(watch, monitor, trace 명령) 같은 계측 도구가 필요합니다.

그 밖의 대안

  • 특정 메서드가 아닌 전체적인 프로파일링이라면 JFR 상시 기록이나 async-profiler가 우선 선택지입니다.

  • SQL 실행 관찰이 목적이라면 datasource-proxyp6spy처럼 JDBC 계층을 감싸는 라이브러리도 있습니다.