[JAVA] 자바 데이터 타입과 변수

kindof

·

2021. 6. 19. 22:45

0. 들어가면서

이번 시간에는 자바 데이터 타입, 변수와 배열에 대해 공부해보려고 합니다.

 

기초적인 내용이니, 편하게 읽어보시면 좋을 것 같습니다.

 

1. 프리미티브(Primitive) 타입 종류와 값의 범위 그리고 기본 값

자바 데이터 타입에는 크게 원시 타입(Primitive type)과 참조 타입(Reference type)이 존재합니다.

 

Primitive type은 기본값이 존재하는 데이터 타입으로 Null이 존재하지 않습니다.

 

그래서 의도적으로 Wrapper 클래스를 통해 Primitive type을 Reference Type으로 관리하면서 의도하지 않은 값이 들어가거나 값이 제대로 들어가지 않았을 땐 NullPointerException 등으로 예외 처리를 할 수 있게 하기도 하죠.

 

한편, Primitive type은 정수, 실수, 문자, 논리 리터럴 등의 실제 데이터 값을 저장하고 있으며 Stack 메모리 영역에 위치합니다.

 

자바 Primitive type의 예시는 아래와 같습니다.

  • Boolean(논리형): 1byte를 차지하며 True / False값을 갖습니다.
  • char(문자형): 2byte를 차지하며 16비트 유니코드 문자 데이터를 갖습니다.
  • byte(정수형): 1byte를 차지하며 -128~127까지 값을 갖습니다.
  • short(정수형): 2byte를 차지하며 -32768~32767까지 값을 갖습니다.
  • int(정수형): 4byte를 차지하며 -2147483648 부터 2147483647(약 -21억~21억)까지 값을 갖습니다. 4byte = 32bit이기 때문에 -(2^32-1) 부터 2^32-1 값을 갖는다고 이해하면 됩니다.
  • long(정수형): 8byte를 차지하며 -9223372036854775808 ~ 9223372036854775807(-100경 ~ + 100경)까지 값을 갖는다. int형보다 더 넓은 범위의 값을 저장합니다.
  • float(실수형): 4byte를 차지하고 실수 타입을 갖습니다.
  • double(실수형): 8byte를 차지하고 float보다 더 넓은 범위의 실수 타입을 갖습니다.

한편, JVM은 피연산자 스택이 피연산자를 4Byte 단위로 저장하기 때문에 자동 형변환이 일어나는데요. 예를 들어, int보다 작은 자료형의 값을 계산시 int형으로 형변환되며, 실수형 데이터 타입에서는 double형이 기본 데이터 타입으로 쓰이게 됩니다. 이에 따라, float형 데이터 타입을 쓰려면 데이터의 맨 뒤 쪽에 'f'를 붙여줘야 하죠.

 

2. 레퍼런스(Reference) 타입

Primitive type을 제외한 타입들을 참조형 타입(Reference type)이라고 부르며 클래스, 인터페이스, 배열, 열거(Enum) 등이 있습니다.

 

레퍼런스 타입은 빈 객체를 의미하는 Null이 존재하여 아무런 값을 참조하지 않는다는 것이 빈 값을 가지고 있다는 뜻이 아니기 때문에 NullPointerException의 원인이 될 수 있죠.

 

한편, 레퍼런스 타입의 객체는 힙(Heap)메모리 영역에 저장됩니다. 다만, 힙 영역에 존재하는 객체들의 주소값은 스택에 저장되고 그 스택에 저장된 포인터값을 찾아보면 힙 영역으로 향하게 되는거죠.

 

Reference Type 변수와 Heap 영역

 

3. 리터럴

Primitive type을 설명하면서, float형 데이터를 선언할 때는 숫자 끝에 'f'를 붙여줘야 한다고 했습니다.

double number = 50.5; - O
float number = 50.5;  - X
float number = 50.5f; - O

위 예시처럼 50.5뒤에 붙은 f가 데이터 타입이 실수 중에서 float 타입임을 알 수 있게 합니다.

 

이를 리터럴(Literal)이라고 합니다. 그리고 그 값인 50.5를 리터럴 값(Literal Value)이라고 합니다.

 

정수형 리터럴에는 234(10진수), 030(8진수), 0xA4(16진수), 0b1010(2진수)등이 있고, 논리 타입 리터럴에는 true, false가 있습니다.

 

4. 변수 선언 및 초기화하는 방법

Primitive type은 초기화를 해주지 않으면 default value로 선언되고, 레퍼런스 타입같은 경우에는 Null로 초기화됩니다.

 

한편, 변수를 선언하고 초기화하는 데는 여려 가지 방법이 있습니다. 변수를 선언하고 초기화하는 방법은 다들 아실테니, 이 부분은 생략하겠습니다. 

 

다만, 클래스 내 인스턴스 초기화 블록만 잠깐 보고 가겠습니다.

 

인스턴스 변수의 초기화는 주로 생성자를 생성하고, 인스턴스 초기화 블록은 모든 생성자에서 공통으로 수행되야 하는 코드를 넣는데 사용합니다. 인스턴스 초기화 블록은 인스턴스가 생성될 때마다 각 인스턴스별로 초기화되죠. 아래 예시를 보겠습니다.

// 인스턴스 초기화 블록 예시
  Car(){
    serialNo = count++;
    color = 'white';
    gearType = 'auto';
  }

  Car(String color, String gearType){
    serialNo = count++;
    this.color = color;
    this.gearType = gearType;
  }

  // 위의 serialNo = count++;이 중복된다.
  {
    serialNo = count++; // 인스턴스 블록으로 빼서 선언
  }
...

 

5. 변수의 스코프(Scope)와 라이프타임

변수의 스코프란 변수를 사용할 수 있는 범위를 의미합니다.

 

간단한 변수의 스코프에 대해 먼저 설명을 해보자면,

  • 메서드 변수의 Scope : 메서드 내
  • Loop 변수의 Scope : Loop 내
  • 괄호 안의 변수 Scope : 괄호 내
  • Static 변수의 Scope: 클래스 내외부에서 접근 가능

정도로 요약할 수 있습니다. 아래 예시를 보면서 좀 더 설명하겠습니다.

public class ScopeTest{
  String classVal = "class value";
  static String staticVal = "static value";

  public void method1(){
    String methodVal = "method value";
    System.out.println(classVal); // "class value"
  }

  public static void main(String[] args){
    //메인 메서드는 static 변수가 아닐 경우 객체화해야 클래스 변수를 사용할 수 있다.
    System.out.println(staticVal); // static 변수이므로 그냥 사용 가능
    
    
    ScopeTest s = new ScopeTest();
    System.out.println(s.classVal); // static 변수가 아니므로 객체를 생성하고 사용해야 합니다.
  }
}

위 코드는 class내에서 정의된 변수와 static으로 정의된 변수의 스코프에 대해 설명하고 있습니다.

 

static 타입은 클래스 내에서 한 변수를 공유해서 어느 곳에서든지 사용할 수 있기 때문에 객체화 없이도 바로 사용할 수 있지만, 클래스 영역에서 선언한 변수는 객체화를 하고 사용하는 것이죠.

 

말이 나온 김에 static 변수의 특징에 대해서 몇 가지만 이해해봅시다.

 

static(정적) 변수는 메모리에 한 번 할당되어 변수의 값을 공유하여 사용되고 프로그램이 종료될 때 해제되는 변수입니다.

 

예를 들어 아래처럼 웹 사이트 방문 시마다 조회수를 증가시키는 Counter()프로그램이 있다고 해볼까요?

 

여기서 count라는 변수는 정적으로 선언되어 최초에 Counter 객체가 생성될 때 1회 메모리에 저장되어 모든 Counter객체가 이를 공유하게 됩니다.

public class Counter  {
  static int count = 0; // static 변수를 최초에 0으로 저장하고 객체마다 공유해서 사용

  Counter() {
      this.count++;
      System.out.println(this.count);
  }

  public static void main(String[] args) {
      Counter c1 = new Counter();
      Counter c2 = new Counter();
  }
}

static(정적) 메서드는 객체의 생성 없이 Class.MethodName()의 형태로 호출이 가능합니다.

 

보통 static 메서드는 Utility 용도로 작성할 때 많이 사용되죠. 예를 들어 "오늘의 날짜 구하기", "숫자에 콤마 추가하기" 등의 메서드를 작성할 때 클래스 메서드를 사용하는 것입니다.

 

아래 예시 코드를 보겠습니다.

import java.text.SimpleDateFormat;
import java.util.Date;
import android.util.Patterns;

public final class CommonUtils {
      public static String getCurrentDate() {
        Date date = new Date();
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyMMdd");
        return dateFormat.format(date);
    }

    public static boolean isEmailValid(String email) {
        return Patterns.EMAIL_ADDRESS.matcher(email).matches();
    }
}

 

CommonUtils라는 클래스에 몇 가지 API를 정의해두고 쓰고 있습니다.

이 떄, 각 메서드를 static 메서드로 선언해두고, 필요할 때마다 갖다 쓰는 방식이죠.

 

6. 타입 변환, 타입 프로모션과 캐스팅

타입 프로모션이란 자동 형변환을 의미합니다. 크기가 더 작은 자료형을 더 큰 자료형에 대입할 때, 자동으로 작은 자료형이 큰 자료형으로 형변환이 됩니다.

 

위에서 Primitive type들의 바이트 크기에 대해서 정리를 했었는데, 예를 들어 byte a = 10; 이라고 하고, int b = a;라고 하면, byte 데이터 타입이 크기가 작기 때문에 변수인 a를 int 데이터 타입으로 변환하여 변수 b에 저장합니다.

 

일반적으로 자동 형변환(프로모션)이 이루어지는 순서는 아래와 같습니다.

  • byte(1) < short(2) < int(4) < long(8) < float(4) < double(8)

 

여기서 float 타입의 메모리 크기는 4byte이고 long의 경우는 8byte인데 자동형변환이 되는 까닭은 표현할 수 있는 값의 범위가 float이 더 크기 때문이죠.

 

 

캐스팅(Casting)이란 프로모션과 반대로 명시적 형변환을 해주는 것입니다. 크기가 더 큰 자료형을 더 작은 자료형에 대입하는 것이죠.

예를 들어 float a = 10;이고 int b = a;라고 한다면 int형 변수에 float형 변수를 집어넣으려고 하기 때문에 오류가 발생합니다.

 

이 때, 명시적으로 형변환을 한다는 표현을 해주어서 int로 만들 수 있긴 하지만, 당연히 더 작은 크기의 자료형으로 바꾸는 것이기 때문에 데이터가 손실될 수도 있습니다. 

public class Promotion{
  public static void main(String[] args){
    float a = 10;
    int b = (int) a;     // casting
    System.out.println(a);  // 10.0
    System.out.println(b);  // 10     - 소수점 날라감
  }
}

 

6-1) 업캐스팅(Upcasting)

위에서는 Primitive type에 대한 캐스팅을 알아보았습니다. 그렇다면 Reference Type 자료형의 캐스팅은 어떻게 될까요? 대개 레퍼런스 타입의 캐스팅은 상속 관계에 있는 클래스들 간의 캐스팅에 대해 이야기할 때 많이 사용됩니다. 

 

Parent 클래스와 Parent클래스를 상속받는 Child 클래스가 있다고 해봅시다.

 

Child는 Parent 클래스를 상속받으므로 Child 클래스가 Parent 클래스보다 가지고 있는 데이터의 양이 무조건 많습니다. 기본적으로 Parent의 내용은 Child가 일단 가지기 때문이죠.

 

그렇다면 이 때, Parent parent = new Child(); 은 성립합니다. 그 이유는 Child의 데이터 크기가 더 크기 때문에 Parent 객체를 이 안에 맞춰서 선언할 수 있기 때문입니다.

 

보다 엄밀히 말하자면, 위와 같은 선언은 자바 컴파일러가 Parent parent = (Parent) new Child();로 캐스팅하여 읽게 됩니다. 이를 업캐스팅(Upcasting)이라고 합니다.

 

6-2) 다운캐스팅(DownCasting)

다운캐스팅은 위와 반대로 Child child = new Parent(); 라고 하는 상황입니다. 딱 봐도 안되죠.

 

왜냐하면 Parent가 더 적은 데이터를 가지고 있기 때문에 Child의 데이터를 다 반영하지 못하는 것입니다. 하지만 IDE에서 Child child = (Child) new Parent();라고 하면 컴파일 시점에 오류가 잡히지 않고 런타임 시 문제가 발생합니다. 왜 컴파일 시점에 잡아내지 못할까요?

 

그 이유는 아래와 같습니다.

"컴파일러에게 프로그래머가 형변환을 함으로써, 일단 데이터를 맞게 넣어준것 처럼 보여준다.
컴파일러는 문법이 맞다고 생각하여 넘어간다. 하지만, 프로그램이 실제로 동작할때, new Parent(); 인스턴스는 Child 형 데이터로 바꾸지 못한다는 것을 깨닫고, 런타임 오류를 뿜으며 프로그램이 종료된다. "


 

7. 타입 추론, var

타입 추론은 말그대로 변수의 타입을 명시하지 않았을 때 컴파일러가 변수의 타입을 대입된 리터럴로 추론하는 것 입니다. 그리고 이 때 'var' 키워드를 사용하죠. 아래 예시를 보겠습니다.

 

var str = "Hello world!";
if(str instanceof String){
	System.out.println("str변수의 타입은 String입니다.");
}

위 예시에서처럼 str변수가 String임을 명시해주지 않았음에도 컴파일러는 var를 String으로 인식합니다.

 

한편, var는 초기화값이 있는 지역변수로만 선언이 가능합니다. var를 멤버변수, 파라미터, 리턴 타입으로 사용할 수는 없습니다.

 

또한 var 변수의 사용 규칙에는 아래와 같이 몇 가지가 존재합니다.

  • var는 초기화없이 사용할 수 없다.
  • var타입 변수에는 null 값이 들어갈 수 없다.
  • var타입은 로컬 변수에만 선언이 가능하다.
  • 배열을 선언할 때, var대신 타입을 명시해줘야 한다.
  • Lambda Expression에는 명시적인 타입을 지정해줘야 한다.

 

그렇다면 var은 왜 사용할까요? 아래 예시를 보겠습니다.

Map<String, Integer> map1 = new HashMap<>();
var map2 = new HashMap<String, Integer>();

첫째로, 위처럼 var은 사실 뒤의 초기화 부분에서 그 타입을 알 수 있기 때문에 변수 앞에 var를 사용해서 정확히 변수의 이름에 집중할 수 있게 하는 장점이 있습니다.

 

둘째로, for-each문에서 타입을 직정 지정해주지 않아도 됩니다.

 

셋째로, 위의 var변수의 규칙 5번에서 Lambda Expression과 연관된 설명이 나오는데 이와 관련해서 var를 사용하는 장점이 존재합니다. 람다식에 대해서는 다른 포스팅에서 자세하게 다뤄보겠습니다.

Consumer<Person> personConsumer = (@Nonnull var person) -> {
    // @Nonnull 어노테이션에 의해 person에 Null check부터 수행한다.
}

 

8. 마치면서

이번 글에서는 기본적인 자바의 변수 타입, static 변수와 메서드, 그리고 타입 프로모션과 캐스팅 등에 대해 공부했습니다.


감사합니다.