자바의 제어자는 접근 제어자, 비접근 제어자로 나눌 수 있습니다. 우선 접근 제어자부터 살펴보도록 하겠습니다.

접근 제어자(Access modifier)

접근 제어자는 그대로 클래스 내에서 멤버로의 접근을 제어하는 역할을 합니다. 접근 제어자에는 public, protected, default, private가 있습니다. 우선 public, protected, default, private가 각각 무엇인지 간단히 살펴보고 예제를 보도록 하겠습니다.

  • public: 모든 위치에서의 접근을 허용합니다. 접근 제어자 중에서 범위가 가장 넓습니다.
  • protected: 동일 패키지 혹은 다른 패키지에 있는 자식 클래스에서만 접근할 수 있습니다.
  • default: 접근 제어자를 붙이지 않으면 자동으로 붙는 접근 제어자입니다. 동일 패키지에서만 접근할 수 있습니다.
  • private: 외부에서 접근이 불가능합니다. 즉, 선언된 클래스 내에서만 접근이 가능합니다.

이를 표로 나타내면 다음과 같습니다.

private〈 default〈 protected〈 public

여기서 패키지(package)란 사전적 의미 그대로 '상자, 포장물'을 의미합니다. 관련된 클래스 혹은 인터페이스들을 묶어놓은 상자와 같습니다. 여기에서는 패키지가 대충 이렇다는 것만 알아두고 패키지 편에서 자세히 설명하도록 하겠습니다. 궁금하신 분들은 미리 보고 오셔도 괜찮습니다.

private

아래 코드는 private 접근 제어자로 선언된 멤버에 접근하면 어떻게 되는지 보여줍니다.

class Foo {
	private int data;
	
	private void doSomething() {
    	// 필드 data는 동일 클래스 내부에서만 접근할 수 있다.
		System.out.println(data);
	}
}

class ModifierExamples {
	public static void main(String[] args) {
		Foo foo = new Foo();
		
		// 에러: 필드 Foo.data가 보이지 않습니다.
		System.out.println(foo.data);
		// 에러: Foo.doSomething()가 보이지 않습니다.
		foo.doSomething();
	}
}

위와 같이 코드를 작성하면 컴파일 에러가 발생합니다. private 접근 제어자를 사용하면 해당 멤버에는 외부에서 접근이 불가능합니다. 오로지 동일 클래스 내부에서만 접근이 가능합니다.

클래스와 인터페이스

private 접근 제어자는 클래스와 인터페이스에 사용할 수 없습니다. 외부에서 접근할 수 없는 클래스나 인터페이스는 쓸모가 없기 때문입니다. 클래스나 인터페이스에 private 접근 제어자를 붙이면 아래와 같은 에러를 볼 수 있습니다.

하지만 중첩 클래스에선 사용할 수 있습니다. 인터페이스와 중첩 클래스에 대해선 추후에 알아보도록 하겠습니다.

class OuterClass {
	...
	private class InnerClass {
		...
	}
}

생성자

private 접근 제어자를 생성자에 붙이면 아래와 같이 외부에서 생성자를 호출할 수 없으므로 클래스를 인스턴스화 할 수 없습니다. 하지만 이를 이용해서 디자인 패턴의 싱글턴 패턴이나 팩토리 메서드 패턴 등에서 사용되기도 합니다.

class A {
	private A() {
		new A();
	}
}

class ModifierExamples {
	public static void main(String[] args) {
		// 에러: 생성자 A()가 보이지 않습니다.
		A a = new A();
	}
}

default

default 접근 제어자는 우리가 접근 제어자를 쓰지 않으면 기본으로 적용됩니다. default 접근 제어자는 동일 패키지에서만 접근할 수 있어서 패키지 접근 제어자(package access modifier)라고 부르기도 합니다.

class Foo {
	int data;
}

class ModifierExamples {
	public static void main(String[] args) {
		Foo foo = new Foo();
		
		foo.data = 5;
		System.out.println(foo.data); // 5
	}
}

public

public 접근 제어자를 붙이면 모든 클래스와 패키지에서 접근할 수 있게 됩니다. 접근 범위에 제한이 없습니다.

class Foo {
	public int data;
}

class ModifierExamples {
	public static void main(String[] args) {
		Foo foo = new Foo();
		
		foo.data = 5;
		System.out.println(foo.data); // 5
	}
}

protected

protected 접근 제어자를 붙이면 동일 패키지 혹은 다른 패키지에 있는 자식 클래스에서만 접근할 수 있게 됩니다.

class Foo {
    protected int data;
}

class Bar extends Foo {
    public int getData() {
        return data;
    }
}

class ModifierExamples {
    public static void main(String[] args) {
        Bar bar = new Bar();

        bar.data = 3;
        System.out.println(bar.data); // 3
        System.out.println(bar.getData()); // 3
    }
}

비접근 제어자(Non-access modifiers)

자바의 비접근 제어자에는 static, final, abstract, transient, volatile, synchronized, native가 있습니다. 여기에서는 static, final을 다루고 abstract는 추상 클래스 편에서 다루도록 하겠습니다. 나머지 비접근 제어자들은 이 게시글에서 다루는 범위를 넘어서므로 여기서는 설명하지 않고 번외편에서 설명합니다.

static

정적 필드(static field)

같은 클래스로부터 객체를 여러 개 생성하면 각각의 객체는 고유한 상태를 가지고 있으며 서로 다른 메모리 공간에 저장됩니다. 하지만 static 제어자를 사용하면 모든 객체가 공유하는 필드를 만들 수 있으며, 이 정적 필드는 한 번만 생성되고 별도의 메모리 공간에 저장됩니다. 모든 객체는 정적 필드의 값을 변경할 수 있으나 정적 필드는 클래스를 인스턴스화 하지 않아도 변경할 수 있습니다. 덧붙여서, 정적 필드를 클래스 변수라고 부르기도 합니다.

class ObjectCounter {
	public static int numOfObjects = 0;
	
	public ObjectCounter() {
		numOfObjects++;
	}
}

class ModifierExamples {
	public static void main(String[] args) {
		for (int i = 0; i < 100; i++) {
			new ObjectCounter();
		}
		
		System.out.println("총 " + ObjectCounter.numOfObjects + "개의 객체가 생성되었습니다.");
	}
}

15행을 보시면 객체를 참조하는 참조형 변수 없이 정적 필드에 접근하는 것을 볼 수 있습니다. 인스턴스화 없이 바로 클래스를 통해 정적 필드에 접근할 수 있습니다.

정적 메서드(static method)

정적 필드 뿐만 아니라 정적 메서드도 있습니다. 정적 메서드도 정적 필드와 마찬가지로 별도의 인스턴스화 없이 바로 클래스를 통해서 정적 메서드를 호출할 수 있습니다. 덧붙여서, 정적 메서드를 클래스 메서드라고 부르기도 합니다.

class ObjectCounter {
	private static int numOfObjects = 0;
	
	public ObjectCounter() {
		numOfObjects++;
	}
	
	public static int getCount() {
		return numOfObjects;
	}
}

class ModifierExamples {
	public static void main(String[] args) {
		for (int i = 0; i < 100; i++) {
			new ObjectCounter();
		}
		
		System.out.println("총 " + ObjectCounter.getCount() + "개의 객체가 생성되었습니다.");
	}
}

하지만 인스턴스 메서드에서는 인스턴스 멤버(인스턴스 변수, 인스턴스 메서드)뿐만 아니라 정적 멤버(정적 필드, 정적 메서드)에도 접근할 수 있는 반면에, 정적 메서드에서는 오로지 정적 멤버에만 접근할 수 있습니다.

아래와 같이 정적 메서드에서 인스턴스 변수에 접근하려는 경우 에러가 발생합니다. 인스턴스 변수 data는 아직 객체가 만들어지지 않아서 메모리에 올라가지도 않았기 때문입니다.

인스턴스 변수와 인스턴스 메서드

정적 필드와 정적 메서드와의 구분을 위해서 이런 용어를 사용했습니다. 인스턴스 변수와 인스턴스 메서드는 사용하기 전에 클래스의 객체를 먼저 만들어야 하는 변수나 메서드를 말합니다.

class Foo {
	private int data;
	
	public static int getData() {
		// 에러: 정적이 아닌 필드 데이터에 대한 정적 참조를 만들 수 없습니다.
		return data;
	}
}

정적 메서드의 대표적인 예는 바로 프로그램의 진입점인 메인 메서드입니다. 메인 메서드는 정적 메서드이기 때문에 컴파일러는 메인 메서드를 호출하기 위해 불필요한 객체를 생성할 필요가 없습니다.

public static void main(String[] args) {
	// ...
}

정적 초기화 블록(static initialization block)

정적 초기화 블록 안에 있는 코드는 클래스가 최초로 메모리에 로드될 때 단 한 번만 실행됩니다. 정적 초기화 블록은 클래스 내에 여러 개가 있을 수 있으며, 소스 코드에서 나타난 순서대로 실행됩니다. 이 블록은 정적 필드의 초기화에 사용합니다. 정적 초기화 블록은 다음과 같이 작성할 수 있습니다.

static {
	// 초기화에 사용되는 코드
}

이어서 아래 예제 코드를 살펴보도록 하겠습니다.

class ModifierExamples {
	static int data;
	
    static
    {
    	data = 10;
        System.out.println("정적 초기화 블록이 실행되었습니다.");
    }
    
	public static void main(String[] args) {
		System.out.println("data: " + data);
	}
}

코드를 컴파일 후 실행하면 메인 메서드가 호출되기 이전에 정적 초기화 블록이 실행되는 것을 볼 수 있습니다. JVM이 ModifierExamples라는 클래스를 최초로 로드할 때 메인 메서드를 호출하기 전에 정적 초기화 블록을 실행하기 때문입니다. 무슨 일이 일어나고 있는지 자세히 알고 싶으신 분들은 JVM 구조와 동작원리를 살펴보시기 바랍니다.

class ModifierExamples {
	static int data;
	
    static
    {
    	data = 10;
        System.out.println("정적 초기화 블록1이 실행되었습니다.");
    }
    
    static
    {
    	data = 20;
    	System.out.println("정적 초기화 블록2이 실행되었습니다.");
    }
    
	public static void main(String[] args) {
		System.out.println("data: " + data);
	}
}

결과를 살펴보면 소스 코드에서 나타난 순서대로 정적 초기화 블록이 실행되는 것을 볼 수 있습니다. 따라서 메인 메서드에서 data의 값을 출력할 때 20이란 값이 출력됩니다.

final

final은 '(더 이상) 변경할 수 없는, 최종적인'이라는 의미를 가지고 있습니다. final은 변수, 메서드, 클래스에 붙을 수 있는데 final이 변수에 붙으면 해당 변수의 값을 변경할 수 없으며, 메서드에 붙으면 해당 메서드를 오버라이딩 할 수 없게 되며, 클래스에 붙으면 해당 클래스를 상속받을 수 없습니다. 차례대로 살펴보도록 하겠습니다.

final 변수

final 변수의 값을 초기화하면 이후에는 값을 수정할 수 없습니다. 값을 변경하려고 하면 컴파일 에러가 발생합니다. 선언과 초기화가 동시에 이루어지지 않아도 되지만, 선언되었으나 아직 초기화되지 않은 final 변수를 빈 final 변수(blank final variable)라고 부릅니다.

class Foo {
    int data;
}

class ModifierExamples {
    public static void main(String[] args) {
        final int num1 = 10;
        // 에러: final 지역 변수 num1에 할당할 수 없습니다.
        // num1 = 20;
        System.out.println(num1);

        final int num2;
        num2 = 30;
        System.out.println(num2);

        final Foo foo = new Foo();
        // 아래의 코드를 주석 해제하면 '에러: final 지역 변수 foo에 할당할 수 없습니다.'가 발생한다.
        // foo = new Foo();
        // 하지만 객체의 데이터를 변경하는 것은 가능하다.
        foo.data = 10;
        System.out.println(foo.data);
    }
}

16~20행을 보시면 아시겠지만, final이 붙은 참조형 변수는 초기화 후에 가리키고 있는 객체의 데이터를 변경할 수는 있으나 참조는 변경할 수 없습니다. 이번에는 final로 선언된 필드를 보도록 하겠습니다.

class Foo {
	// 에러: 빈 final 필드(blank final field)가 초기화되지 않았을 수 있습니다.
	final int data;
}

빈 final 필드를 초기화하지 않으면 위와 같은 에러가 발생합니다. 따라서 빈 final 필드는 선언과 동시에 초기화를 하거나, 아래와 같이 생성자 안에서 초기화를 해야 합니다.

class Foo {
	final int data;
	
	public Foo() {
		data = 100;
	}
}

빈 정적 final 필드는 생성자나 인스턴스 메서드에서 초기화할 수 없으며, 선언과 동시에 초기화를 하거나 아래와 같이 정적 초기화 블록을 사용해서 초기화해야 합니다.

class Foo {
	static final int data;
	
	static {
		data = 100;
	}
}

final 메서드

final이 메서드에 붙으면 해당 메서드를 오버라이딩 할 수 없게 됩니다. 아래와 같이 코드를 작성하면 컴파일 에러가 발생합니다.

class A {
	final void doSomething() { }
}

class B extends A {
	// 에러: A의 final 메서드를 오버라이딩 할 수 없습니다.
	void doSomething() { }
}

final 클래스

final이 클래스에 붙으면 해당 클래스를 상속받을 수 없게 됩니다. 클래스가 그 자체로 완전하고 따로 자식 클래스가 필요없는 경우에 클래스를 final로 선언할 수 있습니다. 아래와 같이 코드를 작성하면 컴파일 에러가 발생합니다.

final class A { }

// 에러: 타입 B는 final 클래스 A를 상속받을 수 없습니다.
class B extends A { }