자바 변수와 기본 자료형 - 8가지 기본형과 형변환에서 값이 깨지는 지점 (자바 초급 2단원)
이 단원에서 배우는 것
1단원에서 javac 와 java 로 첫 프로그램을 돌렸다. 그 코드 안에 String title 과 int overdueDays 가 있었는데, 두 줄을 그냥 지나쳤다. 이번 단원은 그 두 줄의 차이를 파고든다. 왜 int 는 소문자이고 String 은 대문자인가, 왜 연체일에 int 를 쓰고 누적 대출 건수에는 long 을 써야 하는가.
도서 대출 시스템 예제를 계속 쓴다. 이번엔 연체료 계산에 필요한 값들을 변수로 담는다.
- 8가지 기본형의 크기와 실제 범위를 알고, 상황에 맞는 타입을 고른다.
- 기본형과 참조형이 메모리에서 어떻게 다르게 다뤄지는지 설명한다.
- 오버플로·정수 나눗셈·부동소수점 오차가 예외 없이 조용히 값을 틀리게 만드는 지점을 짚어낸다.
왜 필요한가
타입을 잘못 고르면 에러가 아니라 틀린 답이 나온다
초급자가 타입 이야기를 지루해하는 이유는 "어차피 int 쓰면 되잖아"라고 생각하기 때문이다. 그런데 자바의 숫자 타입은 범위를 넘어도 예외를 던지지 않는다. 조용히 값이 바뀐다. 아래를 직접 돌려 본다.
public class Overflow {
public static void main(String[] args) {
System.out.println(Integer.MAX_VALUE); // 2147483647
System.out.println(Integer.MAX_VALUE + 1); // -2147483648
long ms1 = 1000 * 60 * 60 * 24 * 30; // 30일을 밀리초로
long ms2 = 1000L * 60 * 60 * 24 * 30;
System.out.println(ms1); // -1702967296
System.out.println(ms2); // 2592000000
}
}
ms1 은 음수다. 받는 변수가 long 인데도 그렇다. 곱셈이 전부 int 끼리 일어나 이미 넘쳐 버린 뒤에, 그 망가진 값을 long 에 담았기 때문이다. 대입할 때 넓혀 봐야 늦었다. 예외도, 경고도 없다. 대출 만료일 계산에 이 코드가 들어가면 만료일이 과거로 찍힌다.
비슷한 사고가 금액 계산에서도 난다.
int totalFee = 100000;
int count = 7;
System.out.println(totalFee / count); // 14285
System.out.println((double) totalFee / count); // 14285.714285714286
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3); // false
둘 다 자바만의 문제가 아니라 컴퓨터가 수를 저장하는 방식에서 나온다. 다만 자바는 타입을 명시하는 언어라서, 어느 지점에서 정밀도가 사라지는지 코드만 보고 미리 알 수 있다. 이번 단원의 목표가 그 눈을 만드는 것이다.
문법과 예제
8가지 기본형
기본형(primitive type)은 여덟 개뿐이다. 전부 소문자로 시작한다. 이 여덟 개를 뺀 나머지는 전부 참조형이다.
| 타입 | 크기 | 범위 | 기본값 | 이럴 때 쓴다 |
|---|---|---|---|---|
byte | 1바이트 | -128 ~ 127 | 0 | 파일·네트워크 바이트 데이터 |
short | 2바이트 | -32,768 ~ 32,767 | 0 | 거의 안 쓴다 |
int | 4바이트 | 약 -21억 ~ 21억 | 0 | 정수의 기본 선택 |
long | 8바이트 | 약 ±9.2×10^18 | 0L | 시간(밀리초), 누적 건수, 금액 원 단위 |
float | 4바이트 | 유효자리 약 7자리 | 0.0f | 메모리가 정말 아쉬울 때만 |
double | 8바이트 | 유효자리 약 15자리 | 0.0 | 실수의 기본 선택 |
char | 2바이트 | 0 ~ 65,535 (유니코드) | '\u0000' | 문자 한 글자 |
boolean | 명세상 미정 | true / false | false | 참·거짓 |
실무에서 실제로 고민할 일은 int 냐 long 이냐, double 이냐 정수냐 정도다. short 는 거의 쓰이지 않고, float 도 요즘은 특별한 이유가 없으면 피한다. 자리를 아끼려다 정밀도를 잃는 손해가 더 크다.
public class LoanVariables {
public static void main(String[] args) {
String title = "클린 코드"; // 참조형
int overdueDays = 12; // 연체일
long totalLoanCount = 12_500_000_000L; // 개관 이래 누적 대출 건수
double lateFeeRate = 0.1; // 감면율 10%
char gradeCode = 'A'; // 회원 등급 코드
boolean isOverdue = true;
byte shelfNo = 7; // 책장 번호
short pageCount = 480;
System.out.println(title + " / " + overdueDays + "일 연체 / 등급 " + gradeCode);
System.out.println("누적 대출 " + totalLoanCount + "건");
System.out.println(shelfNo + "번 책장, " + pageCount + "쪽, 감면율 " + lateFeeRate);
}
}
숫자 사이의 밑줄(12_500_000_000L)은 Java 7 부터 쓸 수 있다. 컴파일러가 무시하므로 자릿수를 눈으로 세기 좋다.
리터럴에 붙는 접미사
자바는 코드에 그냥 쓴 숫자를 기본적으로 int 로 읽고, 소수점이 있으면 double 로 읽는다. 그래서 접미사가 필요하다.
| 쓴 것 | 결과 | 이유 |
|---|---|---|
long a = 3000000000; | 컴파일 에러 | int 범위를 넘는 int 리터럴 |
long a = 3000000000L; | 정상 | L 이 long 리터럴로 만든다 |
float f = 0.45; | 컴파일 에러 | double 을 float 에 좁혀 담을 수 없다 |
float f = 0.45f; | 정상 | f 가 float 리터럴로 만든다 |
L 은 소문자 l 도 문법상 되지만 숫자 1과 구분이 안 되므로 항상 대문자로 쓴다.
기본형과 참조형
차이는 변수 안에 무엇이 들어 있는가이다. 기본형 변수는 값 자체를 담는다. 참조형 변수는 값이 있는 곳의 주소를 담는다.
int a = 12; // a 라는 상자 안에 12 가 그대로 들어 있다
String s = "클린 코드"; // s 안에는 문자열이 놓인 곳의 주소가 들어 있다
String empty = null; // 참조형만 null 을 가질 수 있다
// int noValue = null; // 컴파일 에러
여기서 세 가지가 바로 따라 나온다. 첫째, 기본형은 null 이 될 수 없다. 둘째, 참조형은 null 인 상태로 메서드를 부르면 NullPointerException 이 난다. 셋째, 변수를 다른 변수에 대입하면 담긴 것이 복사되는데, 기본형은 값이 복사되고 참조형은 주소가 복사된다. 세 번째가 5단원(메서드 매개변수 전달)에서 결정적으로 중요해진다.
기본형 여덟 개에는 각각 대응하는 참조형 래퍼 클래스가 있다. int↔Integer, long↔Long, double↔Double, boolean↔Boolean, char↔Character 식이다. 지금 당장 필요한 것은 이 클래스들이 가진 상수와 변환 메서드다.
System.out.println(Integer.MAX_VALUE); // 2147483647
System.out.println(Integer.MIN_VALUE); // -2147483648
System.out.println(Long.MAX_VALUE); // 9223372036854775807
int days = Integer.parseInt("12"); // 문자열을 숫자로
double rate = Double.parseDouble("0.1");
System.out.println(days + 1); // 13
System.out.println(rate); // 0.1
1단원에서 main(String[] args) 로 받은 인자는 전부 문자열이므로, 숫자로 쓰려면 Integer.parseInt 를 반드시 거쳐야 한다.
형변환
작은 통에서 큰 통으로 옮기는 것은 자동이다(확대 변환). 반대는 값이 잘릴 수 있으므로 (타입) 을 직접 적어야 한다(축소 변환).
byte -> short -> int -> long -> float -> double // 왼쪽에서 오른쪽은 자동
char -> int -> long -> float -> double
public class Casting {
public static void main(String[] args) {
int days = 300;
byte b = (byte) days;
System.out.println(b); // 44 (범위 밖이라 잘렸다)
long fee = 3_000_000_000L;
System.out.println((int) fee); // -1294967296
double d = 1234.99;
System.out.println((int) d); // 1234 (반올림이 아니라 버림)
System.out.println('A' + 1); // 66 (char 가 int 로 승격)
System.out.println((char) ('A' + 1)); // B
int a = 7;
System.out.println(a / 2); // 3
System.out.println(a / 2.0); // 3.5
System.out.println(a % 2); // 1
}
}
(int) 는 반올림이 아니라 소수점 아래를 버린다. 반올림이 필요하면 Math.round 를 쓴다. 연체료를 원 단위로 떨어뜨릴 때 이걸 헷갈리면 매번 1원씩 덜 받는다.
var
Java 10 부터 지역 변수는 var 로 선언할 수 있다. 타입이 사라지는 게 아니라 컴파일러가 오른쪽을 보고 타입을 정해 준다. 정해진 뒤엔 못 바꾼다.
var title = "클린 코드"; // String 으로 확정
var days = 12; // int 로 확정
// days = "열이틀"; // 컴파일 에러
// var x; // 컴파일 에러 (초기값이 없으면 타입을 못 정한다)
var 는 지역 변수에만 쓸 수 있다. 필드(6단원)나 메서드 매개변수(5단원)에는 쓸 수 없다. 오른쪽만 봐도 타입이 명백할 때만 쓰고, var result = calc(); 처럼 읽는 사람이 타입을 짐작 못 하는 자리에는 쓰지 않는다.
실무에서 자주 틀리는 것
1. 계산은 int 로 하고 결과만 long 에 담는다
앞에서 본 1000 * 60 * 60 * 24 * 30 이 전형이다. 자바는 대입되는 변수의 타입을 보고 계산 방식을 정하지 않는다. 오른쪽 식은 오른쪽에 있는 값들의 타입만으로 계산된다.
long wrong = 1000 * 60 * 60 * 24 * 30; // -1702967296
long right = 1000L * 60 * 60 * 24 * 30; // 2592000000
규칙은 간단하다. 피연산자 중 하나만 long 이면 나머지도 long 으로 승격된다. 그래서 맨 앞 하나에만 L 을 붙여도 전부 long 연산이 된다. 같은 논리가 double 에도 적용된다.
2. 나눗셈이 먼저 일어나 소수점이 사라진다
정수끼리 나누면 결과도 정수다. 이걸 double 변수에 담아도 이미 버려진 뒤다.
int overdueCount = 3;
int totalCount = 7;
double ratio1 = overdueCount / totalCount; // 0.0 틀림
double ratio2 = (double) overdueCount / totalCount; // 0.42857142857142855
double ratio3 = overdueCount * 100.0 / totalCount; // 42.857142857142854
비율이 항상 0 이나 100 만 나온다면 십중팔구 이 문제다. 고치는 방법은 나누기 전에 한쪽을 실수로 만드는 것이다. (double)(overdueCount / totalCount) 는 순서가 틀려서 여전히 0.0 이다.
3. 돈 계산에 double 을 쓴다
0.1 + 0.2 가 0.3 이 아니라는 것은 유명하지만, 실무에서는 좀 더 교묘하게 나타난다.
double fee = 0;
for (int i = 0; i < 10; i++) {
fee += 0.1;
}
System.out.println(fee); // 0.9999999999999999
System.out.println(fee == 1.0); // false
float 은 유효자리가 짧아서 0.1f + 0.2f 가 0.3 으로 출력되기도 하는데, 정확해서가 아니라 오차가 출력 자릿수 밖으로 숨은 것이다. 더 위험하다.
실수는 절대 == 로 비교하지 않는다. 오차 허용치를 두고 비교한다.
double diff = Math.abs(fee - 1.0);
System.out.println(diff < 1e-9); // true
그리고 돈은 애초에 double 로 다루지 않는다. 방법은 두 가지다. (가) 원 단위 정수(long)로 저장하고 표시할 때만 나눈다. (나) java.math.BigDecimal 을 쓴다. 이 커리큘럼의 연체료는 100원 단위 정수이므로 (가)를 택했다. 소수 단위 통화나 이자 계산이 들어오면 (나)로 간다.
4. char 를 문자열처럼 더한다
char 는 내부적으로 숫자다. 그래서 더하기가 문자열 연결이 아니라 덧셈이 된다.
char a = 'A';
char b = 'B';
System.out.println(a + b); // 131
System.out.println("" + a + b); // AB
System.out.println(String.valueOf(a) + b); // AB
또 하나. char 는 2바이트라서 이모지처럼 코드 포인트가 큰 문자를 한 글자로 담지 못한다. 7단원에서 다시 본다.
스스로 확인하기
- 아래에서
result에 무엇이 찍히는가? 그리고 의도한 값3600000000이 나오도록 한 글자만 고쳐라.long result = 1000 * 60 * 60 * 1000; System.out.println(result); - 연체 도서 3권, 전체 대출 8권일 때 연체율을
37.5로 출력하는 한 줄을 쓰라. 변수는 모두int다. - 다음 코드의 출력을 예측하고, 왜 그런지 설명하라.
double fee = 1234.99; System.out.println((int) fee); System.out.println(Math.round(fee)); System.out.println((int) (fee * 100) / 100.0);
정답
- 모든 피연산자가
int라 곱셈 도중 오버플로가 나서-694967296이 찍힌다. 첫 숫자에L을 붙여1000L * 60 * 60 * 1000으로 고치면 하나가long이므로 전체가long연산이 되어3600000000이 나온다. System.out.println(3 * 100.0 / 8);이다. 나누기 전에 실수를 끼워 넣는 것이 핵심이다.(double) 3 / 8 * 100도 같은 결과다.3 / 8 * 100.0은0.0이므로 틀린 답이다.- 차례로
1234,1235,1234.99다.(int)는 버림이고Math.round는 반올림이다(double을 넣으면long을 돌려준다). 셋째 줄은 100을 곱해 정수로 자른 뒤 다시 100.0으로 나누는 소수점 둘째 자리 버림 관용구인데, 이 값은 이미 둘째 자리까지라 그대로 나온다. 다만 이 관용구는fee * 100이int범위를 넘으면 조용히 망가지므로 금액에는 권하지 않는다.
다음 단원에서는 이 변수들을 조건과 반복에 태운다. 연체일에 따라 등급을 나누고 연체료를 하루씩 누적하는 코드를 쓴다.