본문 바로가기

System·Math·Random 클래스 사용하기

실행 환경을 읽고 시간을 재는 System, 수학 계산을 하는 Math, 같은 순서를 다시 만들 수 있는 난수를 주는 Random의 사용법과 자주 하는 실수를 익힙니다.

게시판 프로그램을 만들다 보면 게시글 47개를 한 페이지에 25개씩 보여 줄 때 몇 페이지가 필요한지 계산하거나, 추천 게시글을 무작위로 고르거나, 어떤 처리에 시간이 얼마나 걸렸는지 재야 할 때가 있습니다. 이런 일은 거의 모든 프로그램에 필요하기 때문에 자바 표준 라이브러리에 이미 도구가 준비되어 있습니다.

이 글에서는 그중 세 클래스를 다룹니다. 실행 중인 자바 프로그램과 그 환경에 접근하는 System, 수학 계산을 모아 둔 Math, 난수를 만드는 Random입니다. 셋은 흔히 함께 소개되지만 하는 일이 전혀 다르고, '같은 입력을 주면 같은 결과가 나오는가'라는 질문에 대한 답도 서로 다릅니다. System이 읽어 오는 값은 프로그램을 실행하는 컴퓨터에 따라 달라지고, Math의 계산은 입력만 같으면 결과도 같으며, Random의 결과는 시드와 호출 순서에 따라 정해집니다. 이 차이를 중심으로 하나씩 살펴보겠습니다.

System으로 출력하고 실행 환경 읽기

지금까지 출력에 써 온 System.out은 System 클래스의 static 필드로, 표준 출력(standard output)이라는 통로를 가리킵니다. System에는 이와 짝을 이루는 통로가 두 개 더 있습니다. 하나는 오류 메시지를 내보내는 표준 오류(standard error)인 System.err이고, 다른 하나는 Scanner로 키보드 입력 받기에서 Scanner에 연결했던 표준 입력 System.in입니다.

System으로 실행 환경의 정보도 읽을 수 있습니다. System.getProperty는 JVM이 시작할 때 채워 두는 시스템 속성(system property)을 이름으로 조회합니다. 다음 프로그램은 자바 버전, 운영체제 이름, 줄바꿈 문자의 길이를 출력하고, 마지막 줄은 표준 오류로 내보냅니다.

src/SystemBasics.java
public class SystemBasics {
    public static void main(String[] args) {
        System.out.println("java=" + System.getProperty("java.version"));
        System.out.println("os=" + System.getProperty("os.name"));
        System.out.println("separator=" + System.lineSeparator().length());
        System.err.println("diagnostic=sample");
    }
}

다음은 Linux에 설치한 JDK 21에서 실행한 결과입니다. 실행 환경을 읽는 예제라서 출력에도 실행한 환경이 그대로 드러납니다. 결과의 각 줄이 코드의 어느 호출에서 나왔는지 짝지어 보세요.

실행 예 — 컴퓨터마다 다릅니다
java=21.0.12.1
os=Linux
separator=1
diagnostic=sample

앞의 두 줄은 실행하는 컴퓨터에 따라 달라집니다. JDK 25를 설치했다면 java=25로 시작하는 버전이 나오고, 운영체제가 다르면 os= 뒤의 이름도 다릅니다. System.lineSeparator()는 그 운영체제에서 쓰는 줄바꿈 문자열을 돌려줍니다. 줄바꿈 문자는 화면에 보이지 않으므로 여기서는 길이를 출력했습니다. Linux와 macOS는 \n 한 글자라서 1이 나오고, Windows는 \r\n 두 글자라서 2가 나옵니다.

마지막 줄은 화면에서 다른 줄과 똑같아 보이지만 다른 통로로 나왔습니다. 이 차이는 출력을 파일로 보내 보면 드러납니다. 명령 뒤에 > result.txt를 붙이면 표준 출력만 파일로 가고 표준 오류는 그대로 화면에 남습니다.

java -cp out SystemBasics > result.txt

이렇게 실행하면 화면에는 diagnostic=sample 한 줄만 나타나고, 나머지 세 줄은 result.txt에 저장됩니다. 프로그램의 결과 데이터는 System.out으로, 진행 상황이나 오류 메시지는 System.err로 나눠 두면, 결과를 파일로 저장하거나 다른 프로그램에 넘길 때 오류 메시지가 섞여 들어가지 않습니다.

시스템 속성처럼 실행 환경에서 오는 값은 프로그램이 받는 일종의 숨은 입력입니다. 업무 로직을 처리하는 메서드 안에서 System.getProperty를 직접 부르면, 그 메서드의 결과가 매개변수만이 아니라 실행 환경에 따라서도 달라집니다. 그러면 메서드만 보고는 무엇에 영향을 받는지 알기 어렵고, 다른 환경을 흉내 내서 시험하기도 어려워집니다. 그래서 환경 값은 main 같은 시작 지점에서 한 번 읽고, 필요한 객체나 메서드에 매개변수로 넘겨 주는 편이 좋습니다. 참고로 시스템 속성은 System.setProperty로 실행 도중에 바뀔 수도 있으니, 시작할 때 읽은 값이 끝까지 같다고 가정하지 않는 것이 안전합니다.

걸린 시간 재기

어떤 코드가 실행되는 데 걸린 시간은 System.nanoTime()으로 잽니다. 이 메서드는 나노초(10억분의 1초) 단위의 long 값을 돌려주는데, 그 값 하나만으로는 아무 의미가 없습니다. API 문서에 따르면 기준점은 임의로 정해지며, 음수가 나올 수도 있습니다. 의미가 있는 것은 두 번 호출한 값의 차이, 즉 그사이에 흐른 시간뿐입니다.

src/ElapsedTime.java
public class ElapsedTime {
    public static void main(String[] args) {
        long start = System.nanoTime();

        long total = 0;
        for (int number = 1; number <= 100_000; number++) {
            total += number;
        }

        long elapsed = System.nanoTime() - start;
        System.out.println("total=" + total);
        System.out.println("elapsedMicros=" + elapsed / 1_000);
    }
}
실행 예 — 둘째 줄의 숫자는 실행할 때마다 다릅니다
total=5000050000
elapsedMicros=1449

반복문 앞뒤에서 nanoTime()을 부르고 그 차이를 구했습니다. 나노초를 1000으로 나누면 마이크로초가 되므로, 이 실행에서는 반복문에 약 1.4밀리초가 걸렸습니다. 같은 프로그램을 다시 실행하면 이 숫자는 조금씩 달라집니다. 반면 첫 줄의 합계는 언제나 5000050000입니다. 이 값은 int의 최댓값인 약 21억을 넘기 때문에 total을 long으로 선언했습니다.

현재 시각을 1970년 1월 1일(UTC)부터 흐른 밀리초로 돌려주는 System.currentTimeMillis()도 있지만, 경과 시간을 잴 때는 nanoTime()을 씁니다. 컴퓨터의 시계는 인터넷 시간과 동기화되면서 앞뒤로 조정될 수 있어서, 현재 시각끼리의 차이는 실제로 흐른 시간과 어긋날 수 있기 때문입니다. nanoTime()은 이런 시계 조정과 무관하게 흐른 시간만 재도록 만들어졌습니다. 거꾸로 nanoTime()의 값을 날짜나 시각으로 바꿔 저장해서는 안 됩니다. 날짜와 시각은 이 장 뒷부분에서 배우는 java.time API로 다룹니다.

이렇게 한 번 잰 시간으로 '이 코드는 빠르다' 또는 '느리다'고 결론 내리기는 어렵습니다. 처음 실행되는 코드는 JIT 컴파일러가 최적화하기 전이라 느리게 돌고, 운영체제가 다른 프로그램에 CPU를 넘겨주는 순간이 측정 구간에 끼어들 수도 있기 때문입니다. 성능을 제대로 비교하는 방법은 JIT 예열과 벤치마크에서 다룹니다.

System.arraycopy로 배열 구간 복사하기

System에는 배열의 일부를 다른 배열로 빠르게 복사하는 arraycopy도 있습니다. 매개변수가 다섯 개라서 처음에는 헷갈리기 쉬우므로, 먼저 예제를 보고 각 인수의 뜻을 정리하겠습니다.

src/ArrayCopyBasics.java
import java.util.Arrays;

public class ArrayCopyBasics {
    public static void main(String[] args) {
        int[] source = {20, 30, 40, 50};
        int[] target = new int[2];

        System.arraycopy(source, 1, target, 0, 2);
        System.out.println(Arrays.toString(target));
    }
}
[30, 40]

System.arraycopy(source, 1, target, 0, 2)는 'source의 1번 칸부터 2개를 target의 0번 칸부터 복사하라'는 뜻입니다. 다섯 인수를 차례로 정리하면 다음과 같습니다.

순서이 예제의 값뜻
1source복사할 값을 읽어 올 원본 배열
21원본에서 읽기 시작할 인덱스
3target값을 써 넣을 대상 배열
40대상에서 쓰기 시작할 인덱스
52복사할 원소의 개수

그래서 source[1]의 30이 target[0]으로, source[2]의 40이 target[1]로 복사되어 [30, 40]이 출력됩니다. 마지막 인수 2는 끝 인덱스가 아니라 복사할 개수라는 점을 기억해야 합니다. 복사는 값을 옮겨 적는 것이므로 source는 [20, 30, 40, 50] 그대로 남고, target의 길이도 2 그대로입니다. 바뀌는 것은 target의 두 칸에 든 값뿐입니다.

arraycopy는 원본의 두 값을 대상 슬롯에 복사한다

arraycopy의 원본 구간과 대상 구간, 호출 뒤 대상 값 변경·원본 보존을 추적합니다. 범위 선택과 호출 결과를 단계로 구별하되 개별 원소 복사 순서를 구현 보장처럼 만들면 안 됩니다.

arraycopy는 원본의 두 값을 대상 슬롯에 복사한다

System.arraycopy(source,1,target,0,2)는 source1의30을 target0에, source2의40을 target1에 복사한다. 원본20,30,40,50은 유지되고 대상만0,0에서30,40이 된다.

arraycopy는 두 값을 복사하고 원본 배열은 유지합니다

System.arraycopy(source, 1, target, 0, 2)에서 시작 인덱스는 원본 1, 대상 0, 길이는 2개입니다.

arraycopy는 두 값을 복사하고 원본 배열은 유지합니다 source20/30/40/50을 유지하며 source1의30→target0,source2의40→target1로 int값 복사. 점선은 값 복사이며 이동이나 참조 공유가 아니다. source target [0] 20 [1] 30 [2] 40 [3] 50 [0] 30 [1] 40 source[1] → target[0] source[2] → target[1] 점선: int값 복사 source의 값은 유지
원본과 대상의 두 인덱스 대응 원본 source의 1번30과 2번40이 대상 target의 0번과 1번으로 각각 복사된다. 원본의 네 값20,30,40,50은 남고 대상은30,40이 된다. 연결선은 값 복사이며 객체 이동이나 참조 공유가 아니다. 값 복사 값 복사 source · 복사 뒤에도 원본 유지 [0] 20 [1] 30 [2] 40 [3] 50 target [0] 30 [1] 40
원본 배열 source
[20, 30, 40, 50]. 복사 후에도 이 네 값이 유지됩니다.
첫 번째 대응
source[1]의 30을 target[0]에 복사합니다.
두 번째 대응
source[2]의 40을 target[1]에 복사합니다.
대상 배열 target
new int[2]의 [0, 0]이 [30, 40]으로 바뀝니다.

원문의 출력은 [30, 40]입니다. 값은 복사되며 원본에서 지워지지 않습니다. 대상 배열의 길이 2도 바뀌지 않습니다.

이 예의 조건

  • arraycopy 내부의 개별 원소 복사 순서는 구현 보장으로 표현하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2source의 20/30/40/50을 유지하며 source[1]의 30→target[0], source[2]의 40→target[1]로 int 값 복사. 점선은 값 복사이며 이동이나 참조 공유가 아니다.
19점선: int 값 복사
원 도표의 설명과 주석
구분내용
설명source의 20/30/40/50을 유지하며 source[1]의 30→target[0], source[2]의 40→target[1]로 int 값 복사. 점선은 값 복사이며 이동이나 참조 공유가 아니다.
전체 읽기 정보arraycopy는 원본의 두 값을 대상 슬롯에 복사한다 arraycopy는 원본의 두 값을 대상 슬롯에 복사한다 System.arraycopy(source,1,target,0,2)는 source[1]의 30을 target[0]에, source[2]의 40을 target[1]에 복사한다. 원본 20,30,40,50은 유지되고 대상만 0,0에서 30,40이 된다. arraycopy는 두 값을 복사하고 원본 배열은 유지합니다 System.arraycopy(source, 1, target, 0, 2) 에서 시작 인덱스는 원본 1 , 대상 0 , 길이는 2 개입니다. 원본 배열 source [20, 30, 40, 50] . 복사 후에도 이 네 값이 유지됩니다. 첫 번째 대응 source[1] 의 30 을 target[0] 에 복사합니다. 두 번째 대응 source[2] 의 40 을 target[1] 에 복사합니다. 대상 배열 target new int[2] 의 [0, 0] 이 [30, 40] 으로 바뀝니다. 원문의 출력은 [30, 40] 입니다. 값은 복사되며 원본에서 지워지지 않습니다. 대상 배열의 길이 2 도 바뀌지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
구간 지정

source의 인덱스 1부터 두 값을 대상으로 고릅니다.

  • source: [20,30,40,50]
  • 복사 구간: source[1,3) → target[0,2)
  • 원본과 대상의 대응 인덱스를 호출 전에 정한다.
벌크 복사

arraycopy 호출이 두 int 값을 대상 구간에 반영합니다.

  • 대응: 30→target[0], 40→target[1]
  • 의미: 값 복사
  • 원본 요소를 이동하거나 배열 참조를 공유하는 연산이 아니다.
호출 후

target은 30/40이고 source는 그대로입니다.

  • target: [30,40]
  • source: [20,30,40,50] 유지
  • 원본 보존과 대상 변경이 동시에 성립한다.

arraycopy는 원본의 두 값을 대상 슬롯에 복사한다

arraycopy의 원본 구간과 대상 구간, 호출 뒤 대상 값 변경·원본 보존을 추적합니다. 범위 선택과 호출 결과를 단계로 구별하되 개별 원소 복사 순서를 구현 보장처럼 만들면 안 됩니다.

arraycopy는 원본의 두 값을 대상 슬롯에 복사한다

System.arraycopy(source,1,target,0,2)는 source1의30을 target0에, source2의40을 target1에 복사한다. 원본20,30,40,50은 유지되고 대상만0,0에서30,40이 된다.

arraycopy는 두 값을 복사하고 원본 배열은 유지합니다

System.arraycopy(source, 1, target, 0, 2)에서 시작 인덱스는 원본 1, 대상 0, 길이는 2개입니다.

arraycopy는 두 값을 복사하고 원본 배열은 유지합니다 source20/30/40/50을 유지하며 source1의30→target0,source2의40→target1로 int값 복사. 점선은 값 복사이며 이동이나 참조 공유가 아니다. source target [0] 20 [1] 30 [2] 40 [3] 50 [0] 30 [1] 40 source[1] → target[0] source[2] → target[1] 점선: int값 복사 source의 값은 유지
원본과 대상의 두 인덱스 대응 원본 source의 1번30과 2번40이 대상 target의 0번과 1번으로 각각 복사된다. 원본의 네 값20,30,40,50은 남고 대상은30,40이 된다. 연결선은 값 복사이며 객체 이동이나 참조 공유가 아니다. 값 복사 값 복사 source · 복사 뒤에도 원본 유지 [0] 20 [1] 30 [2] 40 [3] 50 target [0] 30 [1] 40
원본 배열 source
[20, 30, 40, 50]. 복사 후에도 이 네 값이 유지됩니다.
첫 번째 대응
source[1]의 30을 target[0]에 복사합니다.
두 번째 대응
source[2]의 40을 target[1]에 복사합니다.
대상 배열 target
new int[2]의 [0, 0]이 [30, 40]으로 바뀝니다.

원문의 출력은 [30, 40]입니다. 값은 복사되며 원본에서 지워지지 않습니다. 대상 배열의 길이 2도 바뀌지 않습니다.

이 예의 조건

  • arraycopy 내부의 개별 원소 복사 순서는 구현 보장으로 표현하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2source의 20/30/40/50을 유지하며 source[1]의 30→target[0], source[2]의 40→target[1]로 int 값 복사. 점선은 값 복사이며 이동이나 참조 공유가 아니다.
19점선: int 값 복사
원 도표의 설명과 주석
구분내용
설명source의 20/30/40/50을 유지하며 source[1]의 30→target[0], source[2]의 40→target[1]로 int 값 복사. 점선은 값 복사이며 이동이나 참조 공유가 아니다.
전체 읽기 정보arraycopy는 원본의 두 값을 대상 슬롯에 복사한다 arraycopy는 원본의 두 값을 대상 슬롯에 복사한다 System.arraycopy(source,1,target,0,2)는 source[1]의 30을 target[0]에, source[2]의 40을 target[1]에 복사한다. 원본 20,30,40,50은 유지되고 대상만 0,0에서 30,40이 된다. arraycopy는 두 값을 복사하고 원본 배열은 유지합니다 System.arraycopy(source, 1, target, 0, 2) 에서 시작 인덱스는 원본 1 , 대상 0 , 길이는 2 개입니다. 원본 배열 source [20, 30, 40, 50] . 복사 후에도 이 네 값이 유지됩니다. 첫 번째 대응 source[1] 의 30 을 target[0] 에 복사합니다. 두 번째 대응 source[2] 의 40 을 target[1] 에 복사합니다. 대상 배열 target new int[2] 의 [0, 0] 이 [30, 40] 으로 바뀝니다. 원문의 출력은 [30, 40] 입니다. 값은 복사되며 원본에서 지워지지 않습니다. 대상 배열의 길이 2 도 바뀌지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
구간 지정

source의 인덱스 1부터 두 값을 대상으로 고릅니다.

  • source: [20,30,40,50]
  • 복사 구간: source[1,3) → target[0,2)
  • 원본과 대상의 대응 인덱스를 호출 전에 정한다.
벌크 복사

arraycopy 호출이 두 int 값을 대상 구간에 반영합니다.

  • 대응: 30→target[0], 40→target[1]
  • 의미: 값 복사
  • 원본 요소를 이동하거나 배열 참조를 공유하는 연산이 아니다.
호출 후

target은 30/40이고 source는 그대로입니다.

  • target: [30,40]
  • source: [20,30,40,50] 유지
  • 원본 보존과 대상 변경이 동시에 성립한다.

시작 위치와 개수가 배열 범위를 벗어나면 아무것도 복사하지 않고 ArrayIndexOutOfBoundsException이 납니다. 예를 들어 같은 배열로 System.arraycopy(source, 3, target, 0, 2)를 호출하면 원본의 3번과 4번 칸을 읽어야 하는데 4번 칸이 없습니다. JDK 21에서는 arraycopy: last source index 5 out of bounds for int[4]라는 메시지가 나오는데, 여기서 5는 마지막으로 읽으려던 칸의 인덱스가 아니라 시작 위치 3에 개수 2를 더한 값입니다. 반대로 원본은 충분하지만 대상이 짧으면 last destination index로 시작하는 메시지가 나옵니다.

원본과 대상이 같은 배열이어도 됩니다. 이때 두 구간이 겹치더라도 원본 구간을 임시 공간에 먼저 복사한 뒤 옮기는 것처럼 동작하도록 정해져 있어서, 값이 중간에 덮어써지는 일이 없습니다. 이 성질 덕분에 배열 안에서 원소를 한 칸씩 밀거나 당길 때도 쓸 수 있습니다. 14장에서 배열로 리스트를 직접 만들 때는 배열이 가득 차면 더 큰 배열을 만들고 이 메서드로 기존 원소를 옮겨 담습니다.

arraycopy는 값을 받을 배열을 미리 만들어 두어야 합니다. 원본의 일부로 새 배열을 만들고 싶을 뿐이라면 Arrays.copyOfRange(source, 1, 3)이 더 간단합니다. 이 메서드는 1번부터 3번 직전까지, 즉 1번과 2번 칸을 담은 새 배열 [30, 40]을 돌려줍니다. 여기서는 셋째 인수가 개수가 아니라 끝 인덱스이고 그 칸은 포함하지 않으므로, 두 메서드를 함께 쓸 때 인수의 뜻을 혼동하지 않도록 주의합니다. 몇 개 안 되는 값을 옮기면서 값마다 다른 변환이 필요하다면 평범한 for 반복문이 더 읽기 쉬울 수도 있습니다.

Math로 계산하기

Math는 절댓값, 최댓값과 최솟값, 올림과 내림, 거듭제곱, 제곱근 같은 수학 계산을 static 메서드로 모아 둔 클래스입니다. 객체를 만들지 않고 Math.abs(-3)처럼 바로 호출하며, 메서드가 기억하는 상태가 없으므로 같은 인수를 넣으면 같은 결과가 나옵니다. 단, Math.random()은 예외입니다. 이 메서드는 이름 그대로 호출할 때마다 다른 난수를 돌려줍니다. 또 sin, exp 같은 일부 실수 함수는 더 빠르게 구현할 수 있도록 결과의 마지막 자리가 플랫폼마다 조금 달라도 되게 허용되어 있습니다. 어느 환경에서나 비트 하나까지 같은 결과가 필요하다면 같은 메서드를 가진 StrictMath를 씁니다. 이 글에서 쓰는 올림, 최댓값, 최솟값, 절댓값은 결과가 정확히 정해져 있어 이런 차이가 없습니다.

다음 프로그램은 게시판에서 흔히 하는 계산 세 가지를 보여 줍니다. 게시글 수로 페이지 수를 구하고, 값을 허용 범위 안으로 맞추고, 목표값과의 차이를 구합니다.

src/MathBasics.java
public class MathBasics {
    public static void main(String[] args) {
        int postCount = 47;
        int pageSize = 25;

        int pages = (int) Math.ceil((double) postCount / pageSize);
        int bounded = Math.min(Math.max(postCount, 1), 600);
        int distance = Math.abs(postCount - 50);

        System.out.println("pages=" + pages);
        System.out.println("bounded=" + bounded);
        System.out.println("distance=" + distance);
    }
}
pages=2
bounded=47
distance=3

게시글 47개를 25개씩 보여 주면 첫 페이지에 25개, 둘째 페이지에 22개가 들어가므로 페이지는 2개가 필요합니다. 이처럼 나머지가 조금이라도 있으면 페이지를 하나 더 만들어야 하므로 나눗셈의 결과를 올림해야 하고, 그 일을 Math.ceil이 합니다. 첫 줄의 식은 다음 순서로 계산됩니다.

계산 단계결과
(double) postCount47.0
47.0 / 251.88
Math.ceil(1.88)2.0
(int) 2.02

(double) 변환이 꼭 필요하다는 점이 중요합니다. 이것을 빼면 postCount / pageSize가 정수끼리의 나눗셈이 되어 나머지를 버린 1이 먼저 나오고, 이미 1이 된 값을 올림해 봐야 1입니다. 나눗셈에서 소수 부분을 잃기 전에 실수로 바꿔야 올림이 의미를 가집니다. Math.ceil은 결과를 double로 돌려주므로 마지막에 (int)로 바꿔 줍니다. Java 18부터는 실수 변환 없이 정수만으로 올림 나눗셈을 하는 Math.ceilDiv(postCount, pageSize)도 있습니다. 어느 쪽이든 pageSize가 0이면 페이지 계산 자체가 성립하지 않습니다. Math.ceilDiv는 0으로 나누면 ArithmeticException을 던지지만, 실수로 바꾸는 방식은 47.0을 0으로 나눈 값이 무한대(Infinity)가 되고, 이것을 int로 바꾸면 int의 최댓값 2147483647이 예외 없이 나와 버립니다. 그래서 실제 코드에서는 나누기 전에 pageSize가 양수인지 확인해야 합니다.

둘째 줄의 Math.min(Math.max(postCount, 1), 600)은 안쪽부터 읽습니다. Math.max(postCount, 1)은 둘 중 큰 값을 고르므로 1보다 작은 값을 1로 끌어올리고, 바깥의 Math.min(…, 600)은 둘 중 작은 값을 고르므로 600보다 큰 값을 600으로 끌어내립니다. 결국 값을 1부터 600 사이로 맞추는 식이고, 47은 이미 그 안에 있으므로 그대로 47입니다. Java 21부터는 같은 일을 Math.clamp(postCount, 1, 600) 한 번으로 할 수 있습니다.

셋째 줄의 Math.abs는 절댓값을 구합니다. 47에서 50을 빼면 -3이고, 그 절댓값은 3입니다. 목표값보다 크든 작든 얼마나 떨어져 있는지만 알고 싶을 때 이렇게 씁니다.

Math.abs에는 알아 둘 함정이 하나 있습니다. int가 담을 수 있는 범위는 -2147483648부터 2147483647까지라서 음수 쪽이 하나 더 깁니다. 그래서 Math.abs(Integer.MIN_VALUE)의 올바른 답인 2147483648은 int에 들어가지 않고, API 문서에 적힌 대로 입력과 같은 음수 -2147483648이 그대로 나옵니다. 절댓값이 음수로 나오는 것이니, 그 결과를 배열 인덱스나 거리로 쓰는 코드는 엉뚱하게 동작합니다. 이 값까지 다뤄야 한다면 Math.abs((long) value)처럼 먼저 long으로 바꿔 계산합니다. 잘못된 값을 돌려받느니 실패하는 편이 낫다면, 이 경우 ArithmeticException을 던지는 Math.absExact를 씁니다.

double로 하는 계산은 정확하지 않을 수 있다는 점도 기억해 둡니다. double은 값을 2진수로 저장하므로 0.1 같은 10진 소수를 정확히 나타내지 못하고, 그래서 0.1 + 0.2를 출력하면 0.30000000000000004가 나옵니다. 페이지 수처럼 결과를 정수로 다시 바꾸는 계산에서는 이런 작은 오차가 문제 되지 않지만, 금액처럼 10진수로 정확해야 하는 값이라면 BigDecimal 같은 별도의 표현을 검토해야 합니다.

Random으로 난수 만들기

Random은 java.util 패키지에 있는 난수 생성기입니다. 컴퓨터는 정해진 계산만 할 수 있으므로, Random이 만드는 수는 진짜 우연이 아니라 내부 상태에 공식을 적용해 계산한 값입니다. 값을 하나 만들 때마다 내부 상태가 다음 상태로 바뀌기 때문에 매번 다른 수가 나오고, 그 순서가 우리 눈에는 무작위처럼 보입니다. 이런 수를 의사 난수(pseudo-random number)라고 합니다.

내부 상태의 처음 값을 시드(seed)라고 합니다. new Random(42)처럼 시드를 직접 주면 처음 상태가 고정되므로, 같은 시드로 만든 Random에 같은 메서드를 같은 순서로 호출하면 언제나 같은 값이 같은 순서로 나옵니다. 다음 프로그램은 시드 42로 게시판 분류 세 개 중 하나를 두 번 고릅니다.

src/SeededPicker.java
import java.util.Random;

public class SeededPicker {
    public static void main(String[] args) {
        String[] categories = {"notice", "question", "free"};
        Random random = new Random(42);

        int first = random.nextInt(categories.length);
        int second = random.nextInt(categories.length);

        System.out.println("first=" + categories[first]);
        System.out.println("second=" + categories[second]);
    }
}
first=free
second=notice

nextInt(bound)는 0 이상 bound 미만의 정수 하나를 돌려줍니다. 여기서는 bound가 배열 길이 3이므로 0, 1, 2 중 하나가 나오고, 그 값을 그대로 배열 인덱스로 쓸 수 있습니다. 시드 42로 만든 Random의 첫 nextInt(3)은 2라서 free가 골라지고, 같은 객체의 다음 호출은 0이라서 notice가 골라집니다. 이 프로그램을 몇 번을 다시 실행해도 결과는 같습니다. Random은 API 문서에 계산 공식까지 정해져 있어서, 어느 컴퓨터의 어느 자바 구현에서 실행해도 같은 시드와 같은 호출 순서면 같은 값이 나옵니다.

배열이 비어 있으면 categories.length가 0이 되는데, 0 이상 0 미만인 정수는 없습니다. 그래서 nextInt(0)은 IllegalArgumentException을 던지며, 메시지는 bound must be positive입니다. 고를 대상이 비어 있을 수 있다면 nextInt를 부르기 전에 확인해야 합니다.

재현성에는 시드뿐 아니라 호출 순서도 포함된다는 점에 주의하세요. 두 nextInt 호출 사이에 다른 난수 호출을 하나 끼워 넣으면, 두 번째 nextInt는 원래와 다른 위치의 값을 읽게 됩니다. 같은 이유로, 아래처럼 반복할 때마다 new Random(42)를 새로 만드는 실수를 하면 매번 처음 위치부터 다시 읽게 됩니다.

for (int i = 0; i < 3; i++) {
    Random random = new Random(42); // 반복할 때마다 처음 상태로 돌아간다
    System.out.print(categories[random.nextInt(categories.length)] + " ");
}

이 코드는 free free free를 출력합니다. Random 객체 하나를 반복문 밖에서 만들어 계속 쓰면 free notice notice처럼 다음 값으로 진행합니다. 둘째와 셋째가 같은 것은 0, 1, 2 세 가지 중에서 고르다 보니 우연히 같은 수가 연달아 나온 것입니다.

시드를 고정하면 디버깅하거나 예제 결과를 다시 확인할 때 편리합니다. 실제로 매번 다른 결과가 필요하다면 new Random()처럼 시드 없이 만들면 되고, 그러면 자바가 매번 다른 시드를 골라 주므로 사실상 실행할 때마다 다른 순서가 나옵니다. 하지만 비밀번호 재설정 토큰이나 인증 코드처럼 남이 맞히면 안 되는 값에는 시드 없이 만든 Random도 쓰면 안 됩니다. Random의 계산 공식은 공개되어 있고 내부 상태도 크지 않아서, 출력된 값 몇 개만 알면 다음 값을 예측할 수 있기 때문입니다. 보안이 필요한 난수는 java.security.SecureRandom으로 만듭니다.

아래 그림은 앞의 MathBasics와 SeededPicker를 나란히 놓고 비교합니다. Math의 결과는 넘긴 인수만으로 정해지지만, Random의 결과는 시드와 그때까지 호출한 횟수에 따라 정해진다는 점을 단계별로 보여 줍니다.

계산 입력과 난수의 호출 상태를 구별한다

같은 Random(42)의 두 nextInt 호출에서 index 2→index 0으로 상태가 진행합니다. 독립 Math 예제를 고정한 채 동일 난수 객체의 호출 이력을 단계로 읽으며 상태 변화를 확인할 수 있습니다.

계산 입력과 난수의 호출 상태를 구별한다

MathBasics의 명시적 수치 입력은 pages2 bounded47 distance3을 결정한다. SeededPicker의 Random42는 같은 객체에 nextInt3를 두 번 호출하여 free 다음 notice를 고른다.

계산은 입력값으로, 난수는 시드와 호출 순서로 결정됩니다

MathBasics와 SeededPicker는 별도 프로그램입니다. 같은 숫자 인수를 반복하는 계산과, 호출할 때 내부 상태가 진행하는 난수를 구별합니다.

계산은 입력값으로, 난수는 시드와 호출 순서로 결정됩니다 Math47/25 실수 몫1.88→ceil2. 별도 Random42의 같은 객체에서 nextInt3 첫2free/다음0notice. 영역 간 실행 연결 없음. Math · 입력으로 계산 47개 / 25개 실수 몫 1.88 ceil: pages=2 bounded47 / distance3 Random42 · 같은 객체 첫 nextInt(3) index2 → free 다음 nextInt(3) index0 → notice Math와 Random 예제는 독립 · 시드와 호출 이력을 함께 고정
계산은 입력값으로, 난수는 시드와 호출 순서로 결정됩니다 Math47/25 실수 몫1.88→ceil2. 별도 Random42의 같은 객체에서 nextInt3 첫2free/다음0notice. 영역 간 실행 연결 없음. Math · 수치 입력 47개 / 25개 실수 몫 1.88 ceil의 결과 pages=2 bounded47 / distance3 Random42 · 같은 객체 첫 nextInt(3) index2 → free 다음 nextInt(3) index0 → notice 호출마다 내부 상태가 진행 별도 프로그램 사이 연결 없음
명시적 계산 입력과 난수의 시드 및 호출 순서
실행 지점결과를 정하는 입력·상태출력
MathBasics게시글 47개, 페이지 크기 25. ceil, min/max, abs에 값을 전달합니다.
pages=2
bounded=47
distance=3
난수 첫 호출new Random(42)의 첫 nextInt(3)은 인덱스 2입니다.first=free
난수 둘째 호출같은 객체의 다음 nextInt(3)은 인덱스 0입니다. 시드를 다시 설정하지 않습니다.second=notice
MathBasics
결과를 정하는 입력·상태: 게시글 47개, 페이지 크기 25. ceil, min/max, abs에 값을 전달합니다.
출력:
pages=2
bounded=47
distance=3
난수 첫 호출
결과를 정하는 입력·상태: new Random(42)의 첫 nextInt(3)은 인덱스 2입니다.
출력: first=free
난수 둘째 호출
결과를 정하는 입력·상태: 같은 객체의 다음 nextInt(3)은 인덱스 0입니다. 시드를 다시 설정하지 않습니다.
출력: second=notice

새 Random(42)에 같은 메서드 호출 순서를 적용하면 같은 난수 순서를 재현합니다. 같은 객체를 계속 호출하는 것은 처음부터 재생하는 것과 다릅니다.

계산 비교는 여기서 사용한 Math 메서드에 한정합니다. Math.random()은 난수 생성기를 사용합니다. 일반 Random은 보안 토큰 용도가 아닙니다.

이 예의 조건

  • Math와 Random은 독립 예제이다.
  • 난수 객체의 시드와 호출 순서를 고정한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2Math의 47/25 실수 몫 1.88→ceil 결과 2. 별도 Random(42)의 같은 객체에서 nextInt(3)의 첫 결과는 인덱스 2인 free, 다음 결과는 인덱스 0인 notice. 영역 간 실행 연결 없음.
447개
525개
8bounded47
9distance3
10Random(42) · 같은 객체
원 도표의 설명과 주석
구분내용
설명Math의 47/25 실수 몫 1.88→ceil 결과 2. 별도 Random(42)의 같은 객체에서 nextInt(3)의 첫 결과는 인덱스 2인 free, 다음 결과는 인덱스 0인 notice. 영역 간 실행 연결 없음.
전체 읽기 정보계산 입력과 난수의 호출 상태를 구별한다 계산 입력과 난수의 호출 상태를 구별한다 MathBasics의 명시적 수치 입력은 pages 2, bounded 47, distance 3을 결정한다. SeededPicker의 Random(42)는 같은 객체에 nextInt(3)를 두 번 호출하여 free 다음 notice를 고른다. 계산은 입력값으로, 난수는 시드와 호출 순서로 결정됩니다 MathBasics 와 SeededPicker 는 별도 프로그램입니다. 같은 숫자 인수를 반복하는 계산과, 호출할 때 내부 상태가 진행하는 난수를 구별합니다. 명시적 계산 입력과 난수의 시드 및 호출 순서 실행 지점 결과를 정하는 입력·상태 출력 MathBasics 게시글 47 개, 페이지 크기 25 . ceil , min/max , abs 에 값을 전달합니다. pages=2 bounded=47 distance=3 난수 첫 호출 new Random(42) 의 첫 nextInt(3) 은 인덱스 2 입니다. first=free 난수 둘째 호출 같은 객체의 다음 nextInt(3) 은 인덱스 0 입니다. 시드를 다시 설정하지 않습니다. second=notice MathBasics 결과를 정하는 입력·상태: 게시글 47 개, 페이지 크기 25 . ceil , min/max , abs 에 값을 전달합니다. 출력: pages=2 bounded=47 distance=3 난수 첫 호출 결과를 정하는 입력·상태: new Random(42) 의 첫 nextInt(3) 은 인덱스 2 입니다. 출력: first=free 난수 둘째 호출 결과를 정하는 입력·상태: 같은 객체의 다음 nextInt(3) 은 인덱스 0 입니다. 시드를 다시 설정하지 않습니다. 출력: second=notice 새 Random(42) 에 같은 메서드 호출 순서를 적용하면 같은 난수 순서를 재현합니다. 같은 객체를 계속 호출하는 것은 처음부터 재생하는 것과 다릅니다. 계산 비교는 여기서 사용한 Math 메서드에 한정합니다. Math.random() 은 난수 생성기를 사용합니다. 일반 Random 은 보안 토큰 용도가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기 조건

Random(42)를 만들고 별도 Math 계산 조건을 둡니다.

  • 시드: 42
  • Math: 47/25=1.88, ceil=2
  • 기타 계산: bounded 47, distance 3
  • 계산 입력과 난수 호출 이력은 서로 다른 결정 조건이다.
첫 난수 호출

같은 객체의 첫 nextInt(3)을 사용합니다.

  • index: 2
  • 선택: free
  • 첫 호출이 난수 객체의 이력을 진행시킨다.
다음 난수 호출

같은 객체를 다시 호출합니다.

  • index: 0
  • 선택: notice
  • 시드가 같아도 매번 첫 결과를 다시 반환하는 것이 아니다.

계산 입력과 난수의 호출 상태를 구별한다

같은 Random(42)의 두 nextInt 호출에서 index 2→index 0으로 상태가 진행합니다. 독립 Math 예제를 고정한 채 동일 난수 객체의 호출 이력을 단계로 읽으며 상태 변화를 확인할 수 있습니다.

계산 입력과 난수의 호출 상태를 구별한다

MathBasics의 명시적 수치 입력은 pages2 bounded47 distance3을 결정한다. SeededPicker의 Random42는 같은 객체에 nextInt3를 두 번 호출하여 free 다음 notice를 고른다.

계산은 입력값으로, 난수는 시드와 호출 순서로 결정됩니다

MathBasics와 SeededPicker는 별도 프로그램입니다. 같은 숫자 인수를 반복하는 계산과, 호출할 때 내부 상태가 진행하는 난수를 구별합니다.

계산은 입력값으로, 난수는 시드와 호출 순서로 결정됩니다 Math47/25 실수 몫1.88→ceil2. 별도 Random42의 같은 객체에서 nextInt3 첫2free/다음0notice. 영역 간 실행 연결 없음. Math · 입력으로 계산 47개 / 25개 실수 몫 1.88 ceil: pages=2 bounded47 / distance3 Random42 · 같은 객체 첫 nextInt(3) index2 → free 다음 nextInt(3) index0 → notice Math와 Random 예제는 독립 · 시드와 호출 이력을 함께 고정
계산은 입력값으로, 난수는 시드와 호출 순서로 결정됩니다 Math47/25 실수 몫1.88→ceil2. 별도 Random42의 같은 객체에서 nextInt3 첫2free/다음0notice. 영역 간 실행 연결 없음. Math · 수치 입력 47개 / 25개 실수 몫 1.88 ceil의 결과 pages=2 bounded47 / distance3 Random42 · 같은 객체 첫 nextInt(3) index2 → free 다음 nextInt(3) index0 → notice 호출마다 내부 상태가 진행 별도 프로그램 사이 연결 없음
명시적 계산 입력과 난수의 시드 및 호출 순서
실행 지점결과를 정하는 입력·상태출력
MathBasics게시글 47개, 페이지 크기 25. ceil, min/max, abs에 값을 전달합니다.
pages=2
bounded=47
distance=3
난수 첫 호출new Random(42)의 첫 nextInt(3)은 인덱스 2입니다.first=free
난수 둘째 호출같은 객체의 다음 nextInt(3)은 인덱스 0입니다. 시드를 다시 설정하지 않습니다.second=notice
MathBasics
결과를 정하는 입력·상태: 게시글 47개, 페이지 크기 25. ceil, min/max, abs에 값을 전달합니다.
출력:
pages=2
bounded=47
distance=3
난수 첫 호출
결과를 정하는 입력·상태: new Random(42)의 첫 nextInt(3)은 인덱스 2입니다.
출력: first=free
난수 둘째 호출
결과를 정하는 입력·상태: 같은 객체의 다음 nextInt(3)은 인덱스 0입니다. 시드를 다시 설정하지 않습니다.
출력: second=notice

새 Random(42)에 같은 메서드 호출 순서를 적용하면 같은 난수 순서를 재현합니다. 같은 객체를 계속 호출하는 것은 처음부터 재생하는 것과 다릅니다.

계산 비교는 여기서 사용한 Math 메서드에 한정합니다. Math.random()은 난수 생성기를 사용합니다. 일반 Random은 보안 토큰 용도가 아닙니다.

이 예의 조건

  • Math와 Random은 독립 예제이다.
  • 난수 객체의 시드와 호출 순서를 고정한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2Math의 47/25 실수 몫 1.88→ceil 결과 2. 별도 Random(42)의 같은 객체에서 nextInt(3)의 첫 결과는 인덱스 2인 free, 다음 결과는 인덱스 0인 notice. 영역 간 실행 연결 없음.
447개
525개
8bounded47
9distance3
10Random(42) · 같은 객체
원 도표의 설명과 주석
구분내용
설명Math의 47/25 실수 몫 1.88→ceil 결과 2. 별도 Random(42)의 같은 객체에서 nextInt(3)의 첫 결과는 인덱스 2인 free, 다음 결과는 인덱스 0인 notice. 영역 간 실행 연결 없음.
전체 읽기 정보계산 입력과 난수의 호출 상태를 구별한다 계산 입력과 난수의 호출 상태를 구별한다 MathBasics의 명시적 수치 입력은 pages 2, bounded 47, distance 3을 결정한다. SeededPicker의 Random(42)는 같은 객체에 nextInt(3)를 두 번 호출하여 free 다음 notice를 고른다. 계산은 입력값으로, 난수는 시드와 호출 순서로 결정됩니다 MathBasics 와 SeededPicker 는 별도 프로그램입니다. 같은 숫자 인수를 반복하는 계산과, 호출할 때 내부 상태가 진행하는 난수를 구별합니다. 명시적 계산 입력과 난수의 시드 및 호출 순서 실행 지점 결과를 정하는 입력·상태 출력 MathBasics 게시글 47 개, 페이지 크기 25 . ceil , min/max , abs 에 값을 전달합니다. pages=2 bounded=47 distance=3 난수 첫 호출 new Random(42) 의 첫 nextInt(3) 은 인덱스 2 입니다. first=free 난수 둘째 호출 같은 객체의 다음 nextInt(3) 은 인덱스 0 입니다. 시드를 다시 설정하지 않습니다. second=notice MathBasics 결과를 정하는 입력·상태: 게시글 47 개, 페이지 크기 25 . ceil , min/max , abs 에 값을 전달합니다. 출력: pages=2 bounded=47 distance=3 난수 첫 호출 결과를 정하는 입력·상태: new Random(42) 의 첫 nextInt(3) 은 인덱스 2 입니다. 출력: first=free 난수 둘째 호출 결과를 정하는 입력·상태: 같은 객체의 다음 nextInt(3) 은 인덱스 0 입니다. 시드를 다시 설정하지 않습니다. 출력: second=notice 새 Random(42) 에 같은 메서드 호출 순서를 적용하면 같은 난수 순서를 재현합니다. 같은 객체를 계속 호출하는 것은 처음부터 재생하는 것과 다릅니다. 계산 비교는 여기서 사용한 Math 메서드에 한정합니다. Math.random() 은 난수 생성기를 사용합니다. 일반 Random 은 보안 토큰 용도가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기 조건

Random(42)를 만들고 별도 Math 계산 조건을 둡니다.

  • 시드: 42
  • Math: 47/25=1.88, ceil=2
  • 기타 계산: bounded 47, distance 3
  • 계산 입력과 난수 호출 이력은 서로 다른 결정 조건이다.
첫 난수 호출

같은 객체의 첫 nextInt(3)을 사용합니다.

  • index: 2
  • 선택: free
  • 첫 호출이 난수 객체의 이력을 진행시킨다.
다음 난수 호출

같은 객체를 다시 호출합니다.

  • index: 0
  • 선택: notice
  • 시드가 같아도 매번 첫 결과를 다시 반환하는 것이 아니다.

중복 없이 여러 개 뽑기

배운 내용을 묶어서, 1번부터 45번까지의 게시글 가운데 추천 게시글 여섯 개를 중복 없이 고르는 프로그램을 만들어 보겠습니다. 방법은 단순합니다. 번호를 하나 뽑아서 이미 고른 번호 중에 있는지 확인하고, 없을 때만 저장합니다. 이미 있는 번호라면 저장하지 않고 다시 뽑습니다.

src/UniqueFeaturedPostIds.java
import java.util.Arrays;
import java.util.Random;

public final class UniqueFeaturedPostIds {
    public static void main(String[] args) {
        FeaturedPostIdGenerator generator = new FeaturedPostIdGenerator(new Random(12));
        System.out.println(Arrays.toString(generator.generate(6)));
    }

    private static final class FeaturedPostIdGenerator {
        private final Random random;

        FeaturedPostIdGenerator(Random random) {
            this.random = random;
        }

        int[] generate(int count) {
            if (count < 1 || count > 45) throw new IllegalArgumentException("count");
            int[] selected = new int[count];
            int size = 0;

            while (size < count) {
                int candidate = random.nextInt(45) + 1;
                boolean duplicate = false;
                for (int index = 0; index < size; index++) {
                    if (selected[index] == candidate) {
                        duplicate = true;
                        break;
                    }
                }
                if (!duplicate) selected[size++] = candidate;
            }

            Arrays.sort(selected);
            return selected;
        }
    }
}
[17, 20, 22, 33, 41, 44]

random.nextInt(45)는 0부터 44까지를 돌려주므로 1을 더해 1부터 45까지의 번호를 만듭니다. size는 지금까지 고른 번호의 개수이자 다음 번호를 저장할 칸의 인덱스입니다. 안쪽 for는 selected의 0번부터 size - 1번까지, 즉 실제로 채운 칸만 검사합니다. 아직 채우지 않은 칸에는 기본값 0이 들어 있지만, 후보는 언제나 1 이상이므로 그 칸까지 검사하더라도 결과는 같습니다. 다만 '채운 칸만 본다'는 의도를 코드에 드러내려고 size까지만 검사했습니다. 중복이 아니면 selected[size++] = candidate가 후보를 size번 칸에 저장한 뒤 size를 1 늘립니다. count가 45를 넘으면 중복 없는 번호를 그만큼 뽑을 수 없어 반복문이 끝나지 않으므로, 메서드 첫 줄에서 미리 거부합니다.

이 클래스는 Random을 직접 만들지 않고 생성자에서 받습니다. 그래서 이 예제처럼 시드를 고정한 Random을 넘기면 결과를 다시 확인할 수 있고, 실제 서비스에서는 시드 없이 만든 Random을 넘기면 됩니다. 클래스의 코드는 한 줄도 바꿀 필요가 없습니다. 결과를 반환하기 전에 Arrays.sort로 정렬했으므로 번호는 뽑힌 순서가 아니라 크기 순서로 나옵니다.

같은 generator로 generate를 한 번 더 부르면 결과가 어떻게 될까요? 생성자에서 받은 Random 객체를 계속 쓰므로, 두 번째 호출은 첫 호출이 남긴 상태에서 이어서 뽑습니다. 그래서 대개 다른 여섯 개가 나옵니다. 처음과 똑같은 결과를 다시 보고 싶다면 new Random(12)로 새 생성기를 만들어야 합니다. 결과를 재현하려면 시드와 함께 그때까지의 호출 이력도 같아야 한다는 앞 절의 내용이 여기서도 그대로 적용됩니다. 물론 이 생성기도 Random을 쓰므로 예측되어도 괜찮은 추천 노출 같은 기능에만 써야 합니다.

어떤 도구를 쓸까

지금까지 본 도구를 하려는 일에 따라 정리하면 다음과 같습니다.

하려는 일쓸 도구
결과와 오류 메시지를 나눠 출력하기System.out, System.err
실행 중인 자바 버전, 운영체제 같은 환경 정보 읽기System.getProperty
코드가 실행되는 데 걸린 시간 재기System.nanoTime
배열의 연속 구간을 다른 배열로 복사하기System.arraycopy, Arrays.copyOfRange
올림, 절댓값, 최댓값 같은 수학 계산Math
다시 만들어 낼 수 있는 난수 순서시드를 준 Random
남이 예측하면 안 되는 보안용 난수SecureRandom
클래스의 필드와 메서드를 실행 중에 조사하기Class와 리플렉션(28장)

정리

  • System.out은 결과를, System.err는 오류 메시지를 내보냅니다. 표준 출력만 파일로 보내도 오류 메시지는 화면에 남습니다. System.getProperty로 읽는 환경 값은 숨은 입력이므로 시작 지점에서 읽어 넘겨 줍니다.
  • System.nanoTime()은 두 호출의 차이로 경과 시간을 잴 때만 씁니다. 값 하나는 의미가 없고, 날짜나 시각으로 쓸 수도 없습니다.
  • System.arraycopy(원본, 원본 시작, 대상, 대상 시작, 개수)의 마지막 인수는 끝 인덱스가 아니라 개수입니다.
  • Math의 메서드는 같은 인수에 같은 결과를 돌려줍니다. 정수 나눗셈의 올림은 실수로 바꾼 뒤 계산하고, Math.abs(Integer.MIN_VALUE)는 음수라는 점을 기억합니다.
  • Random은 시드와 호출 순서가 같으면 같은 값을 돌려줍니다. 보안용 값에는 SecureRandom을 씁니다.

연습 문제

  1. 1부터 100 사이의 게시글 수를 무작위로 하나 고르고, 페이지당 25개씩 보여 줄 때 필요한 페이지 수를 출력하세요. 결과를 다시 확인할 수 있도록 new Random(7)을 쓰고, 난수는 한 번만 뽑습니다. 뽑은 값이 정말 1부터 100 사이인지는 Math.min이나 Math.max로 맞추지 말고 조건문으로 검사하세요. 끝으로 게시글 수가 1, 25, 26, 100일 때 페이지 수가 각각 얼마인지도 손으로 계산해 보세요.

  2. 다음 코드의 출력을 예상하세요. source는 본문과 같은 {20, 30, 40, 50}입니다.

    int[] target = new int[4];
    System.arraycopy(source, 2, target, 1, 2);
    System.out.println(Arrays.toString(target));
  3. 본문의 UniqueFeaturedPostIds에서 generate를 같은 generator로 두 번 호출하면 두 결과가 같을까요? 두 결과를 같게 만들려면 무엇을 바꿔야 하는지 설명하세요.

해설 보기

1번. nextInt(100)은 0부터 99까지를 돌려주므로 1을 더하면 1부터 100까지가 됩니다.

src/RandomPagesExercise.java
import java.util.Random;

public final class RandomPagesExercise {
    public static void main(String[] args) {
        int postCount = new Random(7).nextInt(100) + 1;
        if (postCount < 1 || postCount > 100) {
            throw new IllegalStateException("postCount");
        }
        int pages = (int) Math.ceil((double) postCount / 25);
        System.out.println("posts=" + postCount);
        System.out.println("pages=" + pages);
    }
}
posts=37
pages=2

범위를 Math.min과 Math.max로 맞추지 않고 조건문으로 검사한 데는 이유가 있습니다. 예를 들어 + 1을 빠뜨리는 실수를 했다고 해 봅시다. 0이 나왔을 때 Math.max로 1에 맞춰 버리면 프로그램은 아무 일 없이 돌아가고 실수는 숨습니다. 조건문으로 검사하면 범위를 벗어난 값이 나오는 순간 예외가 나서 난수를 만드는 식이 틀렸다는 사실을 바로 알 수 있습니다.

게시글이 37개면 37 ÷ 25 = 1.48을 올림해 2페이지입니다. 따로 계산한 네 경우는 1개일 때 1페이지, 25개일 때 1페이지, 26개일 때 2페이지, 100개일 때 4페이지입니다. 첫 페이지가 꽉 차는 25와, 한 개가 넘쳐 다음 페이지가 필요한 26을 비교해 보면 올림이 왜 필요한지 분명해집니다.

2번. 출력은 [0, 40, 50, 0]입니다. 원본의 2번 칸부터 2개, 즉 40과 50을 읽어 대상의 1번 칸부터 씁니다. 그래서 target[1]이 40, target[2]가 50이 되고, 손대지 않은 0번과 3번 칸에는 new int[4]가 채워 둔 기본값 0이 남습니다.

3번. 대개 다릅니다. generator는 생성자에서 받은 Random 객체 하나를 계속 쓰므로, 두 번째 호출은 첫 호출이 끝난 상태에서 이어서 뽑기 때문입니다. 같은 결과를 원한다면 두 번째 호출 전에 new FeaturedPostIdGenerator(new Random(12))로 생성기를 새로 만들어야 합니다. 그러면 시드 12의 처음 상태에서 다시 시작하므로 [17, 20, 22, 33, 41, 44]가 다시 나옵니다. 반대로 generate를 부를 때마다 메서드 안에서 new Random(12)를 만들도록 고치면 매번 같은 결과가 나오는데, 이것은 본문에서 본 '반복할 때마다 새 Random을 만드는 실수'와 같은 구조라서 추천 기능으로는 쓸모가 없어집니다.

다음 글

문자열로 등급을 나타낼 때의 문제에서는 회원 등급처럼 정해진 몇 가지 값 중 하나를 문자열로 나타낼 때 어떤 버그가 생기는지 살펴봅니다. 오타 하나가 컴파일도 통과하고 예외도 없이 잘못된 결과를 만드는 과정을 확인하고, 이 문제를 타입으로 막는 enum으로 가는 길을 준비합니다.