[아이티센 부트캠프] (제어자)

이언덕·2026년 3월 24일

아이티센 부트캠프

목록 보기
16/115
post-thumbnail

제어자

1. 제어자란 무엇인가

제어자는 클래스, 변수, 메서드, 생성자 앞에 붙어서 그 대상의 성격을 더 구체적으로 정해 주는 문법이다.

쉽게 말하면 이런 질문에 답을 붙여 주는 표시라고 보면 된다.

  • 이 클래스는 밖에서 써도 되는가
  • 이 변수는 값을 다시 바꿔도 되는가
  • 이 메서드는 자식 클래스가 다시 정의해도 되는가
  • 이 객체는 아무 데서나 만들어도 되는가



자바의 제어자는 크게 두 갈래로 나눌 수 있다.

  • 어디까지 접근할 수 있는지를 정하는 접근 제어자
  • static, final, abstract처럼 역할이나 성질을 정하는 제어자

제어자는 한 대상에 여러 개를 같이 붙일 수 있다.
다만 접근 제어자는 한 번에 하나만 쓸 수 있다.


예를 들면 이런 식이다.

  • public class User
  • private int age
  • final double PI
  • abstract void start()

즉, 제어자는 단순히 앞에 붙는 단어가 아니라, 그 대상을 어떻게 다룰지 정하는 규칙이다.

참고

이번 범위에서 중심은 static, final, abstract, 접근 제어자다.
추가로 native, transient, synchronized 같은 제어자도 있지만, 이들은 보통 더 뒤쪽 주제와 연결된다. 지금은 이런 제어자도 있다 정도만 알고 넘어가면 충분하다.



2. 자주 나오는 제어자 먼저 보기

제어자는 종류가 많아 보여도, 초반에 가장 자주 만나게 되는 것은 static, final, abstract다.
이 세 가지를 먼저 정확히 잡아두면 뒤쪽 개념도 훨씬 쉽게 이해할 수 있다.



2-1. static

static은 객체보다 클래스 쪽에 붙는 제어자다.
보통 자바에서는 객체를 만든 뒤 그 객체를 통해 변수나 메서드를 사용한다.
그런데 static이 붙으면 객체를 만들지 않아도 클래스 이름으로 바로 사용할 수 있다.

즉, 객체마다 따로 가져야 하는 값이 아니라 클래스 전체가 함께 쓰는 값이나 기능일 때 static을 붙인다.
예를 들어 학생 한 명 한 명의 이름은 서로 달라야 하므로 객체마다 따로 있어야 하지만, 전체 학생 수처럼 모든 객체가 함께 공유해야 하는 값은 하나만 있으면 된다. 이런 값이 static 변수에 해당한다.


메서드도 마찬가지다.
객체 상태와 상관없이 바로 실행해도 되는 기능이라면 static 메서드로 만들 수 있다.
이 경우에는 객체를 생성하지 않고도 클래스이름.메서드명() 형태로 바로 호출할 수 있다.

static은 보통 멤버변수와 메서드에 붙여서 사용한다.
반면 최상위 클래스 자체에는 붙일 수 없다.
지금 단계에서는 static을 “객체마다 따로 존재하는 것이 아니라, 클래스 전체가 함께 쓰는 것”이라고 이해하면 된다.

핵심 특징

  • static이 붙은 멤버는 객체 소속이 아니라 클래스 소속이다.
  • 객체를 만들지 않아도 클래스 이름으로 바로 접근할 수 있다.
  • static 변수는 모든 객체가 하나의 값을 공유한다.
  • static 메서드는 객체 없이도 호출될 수 있다.
  • static은 주로 멤버변수와 메서드에 붙인다.
  • 최상위 클래스 자체에는 붙일 수 없다.

예제: StaticTest

class Student {
    String name;           // 학생마다 다른 이름이므로 인스턴스 변수
    static int count = 0;  // 전체 학생 수를 함께 세는 값이므로 static 변수

    Student(String name) { // 객체가 만들어질 때 이름을 저장
        this.name = name;
        count++;           // 객체가 하나 생성될 때마다 count 1 증가
    }

    static void printCount() {   // 객체 없이 클래스 이름으로 바로 호출 가능
        System.out.println("전체 학생 수: " + count);
    }
}

public class StaticTest {
    public static void main(String[] args) {
        Student s1 = new Student("김자바");
        Student s2 = new Student("이자바");
        Student s3 = new Student("박자바");

        System.out.println(s1.name);
        System.out.println(s2.name);
        System.out.println(s3.name);

        System.out.println(Student.count); // static 변수는 클래스 이름으로 접근
        Student.printCount();              // static 메서드도 클래스 이름으로 호출

        // 출력결과
        // 김자바
        // 이자바
        // 박자바
        // 3
        // 전체 학생 수: 3
    }
}

이 코드는 static 변수와 static 메서드를 한 번에 보여주는 예제다.

name은 학생마다 다른 값이므로 인스턴스 변수다.
그래서 s1.name, s2.name, s3.name은 각각 다른 값을 가진다.

반면 count는 학생 객체가 몇 개 만들어졌는지를 전체 기준으로 하나만 세야 하는 값이다.
그래서 static이 붙어 있고, Student.count처럼 클래스 이름으로 접근한다.
객체가 세 개 생성되었기 때문에 최종적으로 count 값은 3이 된다.


printCount()도 static 메서드다.
이 메서드는 특정 학생 한 명의 정보가 아니라, 클래스 전체가 공유하는 count를 출력하는 기능이다.
그래서 객체를 통해 호출하지 않고 Student.printCount()처럼 클래스 이름으로 바로 사용할 수 있다.


이 예제에서 봐야 할 핵심은,
인스턴스 변수는 객체마다 따로 존재하고, static 변수와 static 메서드는 클래스 전체가 함께 쓴다는 점이다.

헷갈리기 쉬운 부분

static을 그냥 “편하게 쓰는 것” 정도로만 이해하면 헷갈린다.
핵심은 누구 기준으로 존재하느냐다.

  • name은 학생 한 명의 정보이므로 객체마다 따로 있어야 한다.
  • count는 전체 학생 수이므로 하나만 있어야 한다.

그래서 name에 static을 붙이면 안 되고, count에는 static이 잘 어울린다.


또 하나 중요한 점은 static 메서드는 객체 없이 호출될 수 있다는 것이다.
즉, 객체가 이미 만들어져 있다는 전제를 깔 수 없다.
그래서 static 메서드는 객체 없이 호출될 수 있기 때문에, 인스턴스 변수처럼 객체가 있어야 사용하는 값은 바로 사용할 수 없다.

참고

나중에 배우는 main() 메서드도 static이다.
프로그램이 시작될 때 객체를 먼저 만들지 않아도 실행되어야 하기 때문이다.



2-2. final

final은 더 이상 바꾸지 않는다는 뜻이다.

어디에 붙느냐에 따라 의미가 조금 달라진다.

  • 변수에 붙으면 값을 다시 바꿀 수 없다.
  • 메서드에 붙으면 자식 클래스가 오버라이딩할 수 없다.
  • 클래스에 붙으면 더 이상 상속할 수 없다.

즉, final은 전체적으로 여기서 끝, 더 변경하지 않음이라는 느낌으로 이해하면 된다.

초보자가 가장 먼저 많이 보는 것은 변수에 붙는 final이다.
특히 객체마다 값은 다를 수 있지만, 한 번 정해진 뒤에는 바뀌면 안 되는 값을 만들 때 자주 사용한다.


예를 들어 카드의 무늬와 번호는 객체마다 다를 수는 있지만, 한 번 정해진 뒤에는 바뀌면 안 된다.
이럴 때 final이 잘 어울린다.

예제: FinalMemberTest

class Card {
    final String KIND; // 카드 무늬는 한 번 정해지면 바뀌지 않음
    final int NUMBER;  // 카드 번호도 한 번 정해지면 바뀌지 않음

    Card(String kind, int number) {
        KIND = kind;       // 생성자에서 단 한 번 초기화
        NUMBER = number;   // 생성자에서 단 한 번 초기화
    }
}

public class FinalMemberTest {
    public static void main(String[] args) {
        Card card = new Card("Heart", 7);
        System.out.println(card.KIND + " " + card.NUMBER);

        // card.NUMBER = 10; // final이라서 값 변경 불가, 에러 발생

        // 출력결과
        // Heart 7
    }
}

이 코드는 final 멤버변수를 생성자에서 초기화하는 예제다.

KIND와 NUMBER는 객체가 만들어질 때 한 번 정해지고, 그 뒤에는 바뀌지 않는다.
즉, final은 무조건 선언과 동시에 초기화해야 하는 것이 아니라, 인스턴스 변수라면 생성자에서 한 번 초기화할 수도 있다.


이 점이 중요한 이유는,
객체마다 값은 다를 수 있지만 객체가 만들어진 뒤에는 절대 바뀌면 안 되는 값이 있기 때문이다.

헷갈리기 쉬운 부분

final이라고 해서 무조건 선언하는 줄에서 바로 값을 넣어야 하는 것은 아니다.
인스턴스 변수라면 생성자에서 한 번 초기화할 수 있다.

다만 중요한 점은 한 번 값이 정해진 뒤에는 다시 바꿀 수 없다는 것이다.



2-3. abstract

abstract는 추상의, 미완성의라는 뜻이다.

쉽게 말하면 아직 다 만들어지지 않은 상태를 나타낸다고 보면 된다.

자바에서 abstract는 크게 두 군데에 붙는다.

  • 메서드에 붙으면 추상메서드
  • 클래스에 붙으면 추상클래스

둘 다 공통으로 중요한 뜻은 같다.

  • 지금 여기서 완전히 끝난 것이 아니라, 자식 클래스가 이어서 완성해야 한다는 의미다.

즉, 부모 쪽에서는 공통 규칙이나 큰 틀만 먼저 잡아두고,
실제 완성은 자식 클래스가 맡는 구조라고 보면 된다.

추상메서드

추상메서드는 선언만 있고 내용은 없는 메서드다.
즉, 메서드 이름과 형태만 정해두고, 실제로 무엇을 할지는 아직 비워둔 상태다.

예를 들어 sound()라는 메서드가 있다고 해보자.
동물은 모두 소리를 낸다는 공통점이 있으니 sound()라는 기능이 필요하다는 것까지는 정할 수 있다.

그런데 실제 소리는 동물마다 다르다.

  • 개는 멍멍
  • 고양이는 야옹

이처럼 기능이 필요하다는 사실은 공통이지만, 내용은 자식마다 달라질 수 있다.
이럴 때 부모 클래스에서는 메서드 이름만 먼저 정해두고, 실제 내용은 자식 클래스가 구현하게 만든다.

예제: AbstractMethodTest

abstract class Animal {
    abstract void sound(); // 자식 클래스가 반드시 내용을 채워야 하는 추상메서드
}

class Cat extends Animal {
    void sound() {         // 부모의 추상메서드를 자식이 구현
        System.out.println("야옹");
    }
}

public class AbstractMethodTest {
    public static void main(String[] args) {
        Cat c = new Cat();
        c.sound();

        // 출력결과
        // 야옹
    }
}

이 코드는 추상메서드를 가장 단순하게 보여준다.

Animal 안의 sound()는 선언만 있고 내용이 없다.
즉, 동물은 소리를 내야 한다는 틀만 만들어둔 것이다.

그다음 Cat이 Animal을 상속받으면서 sound()의 내용을 직접 채운다.
그래서 c.sound();를 실행하면 야옹이 출력된다.


여기서 핵심은
추상메서드는 바로 쓰는 메서드가 아니라, 자식이 완성해야 하는 메서드라는 점이다.



추상클래스

추상클래스는 여러 자식 클래스에 공통으로 들어가는 내용을 미리 묶어두는 부모 클래스다.

비슷한 클래스들을 만들다 보면, 클래스마다 다른 기능도 있지만 공통으로 반복되는 변수와 메서드도 생긴다.
이럴 때 그 공통 부분을 부모 쪽으로 올려서 한 번에 정리하면 구조가 훨씬 깔끔해진다.

여기서 하나 먼저 정리하고 가야 할 것이 있다.


추상클래스는 꼭 추상메서드를 가져야만 하는 것은 아니다.
공통 부모 역할만 하게 하고, 직접 객체를 만들지 못하게 하려는 목적만으로도 abstract를 붙일 수 있다.


예를 들어 Porsche, F1Car, MonsterTruck은 서로 다른 자동차다.
그래서 각자만의 기능은 다르다.

  • Porsche는 천장을 여는 기능
  • F1Car는 부스터 기능
  • MonsterTruck은 공중제비 기능

하지만 모두 자동차이기 때문에 이름을 가지고, 시동을 걸고, 멈추는 공통 기능도 가진다.


이 공통 내용을 클래스마다 계속 반복해서 쓰기보다,
Car라는 부모 쪽에 한 번만 정리해두면 구조가 훨씬 깔끔해진다.
이때 그 공통 틀 역할을 하는 클래스가 추상클래스다.

핵심 특징

  • 여러 클래스에 공통으로 들어가는 변수와 메서드를 한 곳에 묶을 수 있다.
  • 공통된 것은 부모에 두고, 각 클래스만의 기능은 자식에 남긴다.
  • 코드 중복을 줄이고 구조를 더 깔끔하게 만들 수 있다.
  • 추상클래스도 멤버변수, 일반 메서드, 생성자를 가질 수 있다.
  • 하지만 추상클래스는 직접 객체를 만들 수 없다.
  • 실제 객체는 자식 클래스에서 생성한다.

예제: CarBeforeTest

먼저 추상클래스를 쓰지 않고, 각 클래스를 따로 만든 경우다.

class Porsche {
    String name = "포르쉐"; // 포르쉐 객체가 가지는 이름

    void start() {
        System.out.println(name + " : 시동을 겁니다.");
    }

    void stop() {
        System.out.println(name + " : 멈춥니다.");
    }

    void openRoof() {      // 포르쉐만의 기능
        System.out.println("천장을 엽니다.");
    }
}

class F1Car {
    String name = "F1차";  // F1차 객체가 가지는 이름

    void start() {
        System.out.println(name + " : 시동을 겁니다.");
    }

    void stop() {
        System.out.println(name + " : 멈춥니다.");
    }

    void boost() {         // F1차만의 기능
        System.out.println("부스터를 사용합니다.");
    }
}

class MonsterTruck {
    String name = "몬스터트럭"; // 몬스터트럭 객체가 가지는 이름

    void start() {
        System.out.println(name + " : 시동을 겁니다.");
    }

    void stop() {
        System.out.println(name + " : 멈춥니다.");
    }

    void frontFlip() {     // 몬스터트럭만의 기능
        System.out.println("공중제비를 돕니다.");
    }
}

public class CarBeforeTest {
    public static void main(String[] args) {
        Porsche p = new Porsche();
        F1Car f = new F1Car();
        MonsterTruck m = new MonsterTruck();

        p.start();
        p.openRoof();

        f.start();
        f.boost();

        m.start();
        m.frontFlip();

        // 출력결과
        // 포르쉐 : 시동을 겁니다.
        // 천장을 엽니다.
        // F1차 : 시동을 겁니다.
        // 부스터를 사용합니다.
        // 몬스터트럭 : 시동을 겁니다.
        // 공중제비를 돕니다.
    }
}

이 코드에서는 각 클래스의 차이도 잘 보인다.
하지만 동시에 name, start(), stop()은 세 클래스에 계속 반복된다.


즉, 각자 다른 기능은 다르지만, 공통 부분은 중복되고 있다.

예제: AbstractClassTest

이제 반복되는 공통 부분을 Car로 묶어보자.

abstract class Car {
    String name; // 공통으로 사용하는 자동차 이름

    Car(String name) {     // 자식 객체가 만들어질 때 부모의 name 초기화
        this.name = name;
    }

    void start() {         // 모든 자동차가 공통으로 가지는 기능
        System.out.println(name + " : 시동을 겁니다.");
    }

    void stop() {          // 모든 자동차가 공통으로 가지는 기능
        System.out.println(name + " : 멈춥니다.");
    }
}

class Porsche extends Car {
    Porsche() {
        super("포르쉐");    // 부모 생성자 호출로 name 초기화
    }

    void openRoof() {      // 포르쉐만의 기능
        System.out.println("천장을 엽니다.");
    }
}

class F1Car extends Car {
    F1Car() {
        super("F1차");      // 부모 생성자 호출로 name 초기화
    }

    void boost() {         // F1차만의 기능
        System.out.println("부스터를 사용합니다.");
    }
}

class MonsterTruck extends Car {
    MonsterTruck() {
        super("몬스터트럭"); // 부모 생성자 호출로 name 초기화
    }

    void frontFlip() {     // 몬스터트럭만의 기능
        System.out.println("공중제비를 돕니다.");
    }
}

public class AbstractClassTest {
    public static void main(String[] args) {
        Porsche p = new Porsche();
        F1Car f = new F1Car();
        MonsterTruck m = new MonsterTruck();

        p.start();
        p.openRoof();

        f.start();
        f.boost();

        m.start();
        m.frontFlip();

        // Car c = new Car("자동차"); // 추상클래스는 직접 객체 생성 불가

        // 출력결과
        // 포르쉐 : 시동을 겁니다.
        // 천장을 엽니다.
        // F1차 : 시동을 겁니다.
        // 부스터를 사용합니다.
        // 몬스터트럭 : 시동을 겁니다.
        // 공중제비를 돕니다.
    }
}

이제 구조가 훨씬 깔끔해진다.

원래 세 클래스에 반복되던 name, start(), stop()은 전부 Car로 올라갔다.
반대로 openRoof(), boost(), frontFlip()처럼 각 자동차만의 기능은 자식 클래스에 그대로 남아 있다.


즉, 이 예제는 추상클래스가 어떤 역할을 하는지 잘 보여준다.

  • 공통된 것은 부모가 맡고, 다른 것은 자식이 맡는다.

또 Car 안에 생성자가 있다는 점도 같이 볼 필요가 있다.
Car(String name)은 Car 객체를 직접 만들려고 있는 것이 아니라,
자식 객체가 생성될 때 부모 쪽 name 값을 먼저 채우기 위해 존재하는 생성자다.

예를 들어 Porsche()가 실행되면 super("포르쉐")가 먼저 호출되고,
그 과정에서 부모인 Car의 생성자가 실행되어 name이 초기화된다.
그래서 start() 같은 공통 메서드에서도 그 값을 그대로 사용할 수 있다.



헷갈리기 쉬운 부분

추상클래스도 공통 내용을 묶고, static도 여러 곳에서 함께 쓰는 느낌이 있어서 처음에는 둘이 비슷해 보일 수 있다.
하지만 실제로는 공통이라고 말하는 기준이 다르다.


먼저 이 예제에서 나오는 공통 요소를 짧게 정리하면 이렇다.

  • name → 자동차의 이름을 저장하는 멤버변수
  • start() → 시동을 거는 인스턴스 메서드
  • stop() → 자동차를 멈추는 인스턴스 메서드

이런 멤버변수와 메서드는 Porsche, F1Car, MonsterTruck처럼 종류가 다른 자동차라도 공통으로 필요하다.
어떤 자동차든 자기 이름이 있어야 하고, 시동을 걸고 멈추는 기능도 있어야 하기 때문이다.
그래서 이런 공통 요소는 공통 부모인 추상클래스에 올려서 함께 묶어 둘 수 있다.

abstract class Car {
    String name;

    void start() {
        System.out.println(name + " : 시동을 겁니다.");
    }

    void stop() {
        System.out.println(name + " : 멈춥니다.");
    }
}

여기서 중요한 점은, 공통 구조라고 해서 값까지 모두 같은 것은 아니라는 점이다.
name이라는 멤버변수는 모든 자동차에 공통으로 필요하지만, 그 안에 들어가는 실제 값은 객체마다 다르다.

예를 들면 다음과 같다.

  • Porsche 객체의 name → "포르쉐"
  • F1Car 객체의 name → "F1차"
  • MonsterTruck 객체의 name → "몬스터트럭"

즉, name은 구조는 공통이지만 값은 객체마다 다르다.
이런 것은 추상클래스가 맡는 영역이다.

class Porsche extends Car {
    Porsche() {
        name = "포르쉐";
    }
}

class F1Car extends Car {
    F1Car() {
        name = "F1차";
    }
}

class MonsterTruck extends Car {
    MonsterTruck() {
        name = "몬스터트럭";
    }
}

반면 static은 이런 상속용 공통 구조를 만들기 위한 문법이 아니다.
static은 객체마다 따로 존재하는 것이 아니라, 클래스 전체가 하나만 공유해야 하는 값이나 메서드에 붙인다.

예를 들어 name을 static으로 만들면 문제가 생긴다.

class Car {
    static String name;
}

이렇게 되면 Porsche, F1Car, MonsterTruck 객체가 각자 자기 이름을 가지는 것이 아니라,
클래스에 있는 name 하나를 같이 쓰게 된다.

예를 들어 이런 식이 된다.

Car.name = "포르쉐";
Car.name = "F1차";
Car.name = "몬스터트럭";

이 경우 마지막에 저장한 값으로 계속 덮어써지기 때문에, 객체마다 다른 이름을 따로 유지할 수 없다.
그래서 name은 static이 아니라 인스턴스 변수여야 한다.


start()와 stop()도 마찬가지다.
이 메서드들은 실행될 때 현재 객체가 가진 name 값을 사용해서 동작한다.

Porsche p = new Porsche();
F1Car f = new F1Car();

p.start(); // 포르쉐 : 시동을 겁니다.
f.start(); // F1차 : 시동을 겁니다.

즉, 같은 start() 메서드라도 어떤 객체가 실행하느냐에 따라 결과가 달라진다.
그래서 이런 메서드도 static보다는 인스턴스 메서드로 두는 것이 자연스럽다.


반대로 자동차가 지금까지 몇 대 만들어졌는지처럼, 모든 객체가 함께 봐야 하는 값은 static이 잘 맞는다.

abstract class Car {
    static int count = 0;
    String name;

    Car(String name) {
        this.name = name;
        count++;
    }
}

여기서 count는 자동차마다 따로 있는 값이 아니라, 전체 자동차가 함께 쓰는 공용 값이다.
이럴 때 static을 붙이는 것이다.


정리하면 이렇게 볼 수 있다.

  • 추상클래스 → 자식들이 공통으로 가져야 하는 객체의 구조와 기능을 묶는다
  • static → 객체와 관계없이 클래스가 하나만 가지는 공용 값이나 공용 메서드를 만든다

즉, 둘은 겹치는 개념이 아니라 공통이라는 말의 기준이 서로 다르다고 이해하면 된다.




3. 접근 제어자란 무엇인가

접근 제어자는 어디까지 접근할 수 있는지 범위를 정하는 제어자다.

중요한 데이터가 아무 데서나 바뀌면 객체의 상태가 쉽게 망가질 수 있다.
그래서 자바는 접근 범위를 단계별로 나눠 놓았다.


종류는 네 가지다.

  • public : 어디서든 접근 가능
  • protected : 같은 패키지 + 다른 패키지의 자식 클래스에서 접근 가능
  • default : 같은 패키지 안에서만 접근 가능
  • private : 같은 클래스 안에서만 접근 가능

접근 범위가 넓은 순서는 다음과 같다.
public → protected → default → private

여기서 많이 헷갈리는 부분이 있다.

첫째, default는 키워드를 쓰는 것이 아니다.
아무 접근 제어자도 안 쓰면 그게 바로 default다.


둘째, protected를 “같은 패키지 전용”이라고 외우면 틀린다.
protected는 같은 패키지에서도 가능하고, 다른 패키지의 자식 클래스도 가능하다.


셋째, 최상위 클래스에 쓸 수 있는 접근 제어자는 public과 default뿐이다.
반면 멤버변수, 메서드, 생성자에는 네 가지를 모두 사용할 수 있다.


그리고 꼭 같이 기억해야 할 말이 하나 있다.

멤버에 접근하려면, 그 멤버가 들어 있는 클래스 자체에도 먼저 접근할 수 있어야 한다.
클래스가 막혀 있으면 그 안에 있는 public 멤버도 쓸 수 없다.




4. 접근 제어자가 왜 필요한가

접근 제어자는 클래스의 멤버를 어디까지 공개할지 정하는 장치다.

아무 값이나 밖에서 직접 건드릴 수 있게 열어두면, 객체의 상태가 쉽게 망가질 수 있다.


접근 제어자를 쓰는 이유는 크게 두 가지다.

  • 데이터를 보호하기 위해서
  • 외부에 꼭 보여줄 필요가 없는 내부 구현을 숨기기 위해서

이렇게 하면 클래스 밖에서는 필요한 기능만 보이고, 내부 구조는 함부로 건드릴 수 없어서 코드가 더 안전해진다.
이런 구조를 캡슐화라고 한다.


즉, 데이터는 안쪽에 숨기고, 필요한 기능만 바깥으로 열어두는 것이다.

예를 들어 시간 정보를 저장하는 클래스가 있다고 해보자.
시간은 0~23, 분과 초는 0~59 범위만 허용되어야 한다.


그런데 멤버변수를 그대로 열어두면 30시, -10분 같은 잘못된 값도 들어갈 수 있다.
그래서 이런 값은 직접 바꾸지 못하게 private으로 숨기고, 값을 변경할 때는 메서드를 통해서만 바꾸게 만든다.

예제: TimeTest - 문제 코드

package day8;

public class TimeTest {
    public static void main(String[] args) {
        Time t = new Time(12, 35, 30);
        System.out.println(t);

        t.hour = 30;       // 잘못된 시간이지만 직접 대입 가능
        System.out.println(t);

        t.minute = -10;    // 잘못된 분 값도 직접 대입 가능
        System.out.println(t);

        // 출력결과
        // 12시 35분 30초
        // 30시 35분 30초
        // 30시 -10분 30초
    }
}

class Time {
    int hour;              // 외부에서 직접 접근 가능
    int minute;
    int second;

    Time(int hour, int minute, int second) {
        this.hour = hour;   // 들어오는 값을 검사 없이 바로 저장
        this.minute = minute;
        this.second = second;
    }

    public String toString() {
        return String.format("%d시 %d분 %d초", hour, minute, second);
    }
}

이 코드는 멤버변수를 그대로 열어두었을 때 어떤 문제가 생기는지 보여준다.

hour, minute, second에 접근 제한이 없기 때문에 객체 바깥에서 값을 직접 바꿀 수 있다.
그래서 t.hour = 30;, t.minute = -10; 같은 코드도 그대로 실행된다.

문제는 이런 값이 시간 객체가 가져서는 안 되는 값이라는 점이다.


즉, 이 코드는 시간 정보를 저장하는 객체인데도 시간으로서의 규칙을 지키지 못한다.
값을 저장할 수는 있지만, 올바른 값만 저장되도록 막아 주는 장치가 없는 상태다.

예제: TimeTest - 해결 코드

package day8;

public class TimeTest {
    public static void main(String[] args) {
        Time t = new Time(12, 35, 30);
        System.out.println(t);

        t.setHour(30);               // 잘못된 값이라 저장되지 않음
        System.out.println(t);

        t.setMinute(-10);            // 잘못된 값이라 저장되지 않음
        System.out.println(t);

        t.setHour(t.getHour() + 2);  // 현재 hour를 읽어 와서 2 더한 뒤 다시 저장
        System.out.println(t);

        // 출력결과
        // 12시 35분 30초
        // 12시 35분 30초
        // 12시 35분 30초
        // 14시 35분 30초
    }
}

class Time {
    private int hour;    // 외부에서 직접 접근 못 하도록 private
    private int minute;
    private int second;

    Time(int hour, int minute, int second) {
        setHour(hour);       // 생성자에서도 setter를 사용해 값 검증
        setMinute(minute);
        setSecond(second);
    }

    public int getHour() {   // 현재 hour 값을 읽어 오는 getter
        return hour;
    }

    public void setHour(int hour) {
        if (hour < 0 || hour > 23) { // 올바른 시간 범위가 아니면 저장하지 않음
            return;
        }
        this.hour = hour;
    }

    public int getMinute() { // 현재 minute 값을 읽어 오는 getter
        return minute;
    }

    public void setMinute(int minute) {
        if (minute < 0 || minute > 59) { // 올바른 분 범위가 아니면 저장하지 않음
            return;
        }
        this.minute = minute;
    }

    public int getSecond() { // 현재 second 값을 읽어 오는 getter
        return second;
    }

    public void setSecond(int second) {
        if (second < 0 || second > 59) { // 올바른 초 범위가 아니면 저장하지 않음
            return;
        }
        this.second = second;
    }

    public String toString() {
        return String.format("%d시 %d분 %d초", hour, minute, second);
    }
}

무엇을 고친 코드인가

이 코드는 앞의 문제를 private과 메서드로 고친 형태다.

앞에서는 hour, minute, second를 그대로 열어두었기 때문에,
객체 바깥에서 t.hour = 30;처럼 값을 직접 바꿀 수 있었다.
그래서 시간 객체인데도 30시, -10분 같은 잘못된 값이 그대로 들어갈 수 있었다.


이번에는 그 문제를 막기 위해 멤버변수를 모두 private으로 숨겼다.
이렇게 하면 클래스 바깥에서는 더 이상 t.hour처럼 값에 직접 접근할 수 없다.
즉, 잘못된 값을 함부로 넣는 것부터 먼저 막아 둔 것이다.

그런데 private만 쓰면 왜 안 되는가

여기서 한 가지가 바로 생긴다.
private으로 숨기기만 하면 값을 잘못 바꾸는 것은 막을 수 있지만,
동시에 값을 읽는 것과 값을 바꾸는 것도 전부 막혀 버린다.

그러면 객체를 아예 사용할 수 없게 된다.
즉, 보호는 되지만 사용도 못 하게 되는 상태가 된다.

그래서 getter와 setter가 필요하다

이 문제를 해결하려고 값을 읽는 통로와 값을 바꾸는 통로를 메서드로 따로 만들어 둔다.

여기서 역할은 이렇게 보면 된다.

  • getter → 숨겨진 값을 읽어 오는 메서드
  • setter → 숨겨진 값을 저장하거나 변경하는 메서드

예를 들어 getHour()는 현재 hour 값을 읽어 오는 getter이고,
setHour()는 hour 값을 바꾸는 setter다.
minute와 second도 같은 방식으로 getter와 setter를 둔 것이다.


즉, private이 직접 접근을 막는 장치라면,
getter와 setter는 그 대신 사용하는 공식 통로라고 보면 된다.

setter가 중요한 이유

여기서 중요한 점은 setter가 단순히 값을 넣는 메서드가 아니라는 것이다.

setHour()는 값이 0~23 범위 안에 있을 때만 저장하고,
setMinute()와 setSecond()도 각각 0~59 범위일 때만 저장한다.


즉, setter 안에 검사 조건이 들어 있기 때문에
30시, -10분 같은 잘못된 값은 객체 안으로 들어오지 못한다.
이 부분이 그냥 직접 대입하는 것과 가장 큰 차이다.

생성자에서도 setter를 쓰는 이유

또 한 가지 봐야 할 점은 생성자다.

Time(int hour, int minute, int second) {
    setHour(hour);
    setMinute(minute);
    setSecond(second);
}

여기서는 생성자 안에서도 값을 바로 저장하지 않고,
setter를 통해 저장하고 있다.


즉, 객체를 처음 만들 때도 같은 기준으로 검사를 거치게 해서
시작부터 올바른 값만 들어가도록 만든 것이다.

getter와 setter가 함께 쓰이는 흐름

값을 읽어 올 때는 getter를 사용한다.
예를 들어 아래 코드를 보자.

t.setHour(t.getHour() + 2);

이 코드는 한 줄이라서 처음 보면 조금 헷갈릴 수 있지만,
순서를 나눠서 보면 어렵지 않다.

  • 먼저 t.getHour()로 현재 시간을 읽어 온다.
  • 읽어 온 값에 2를 더한다.
  • 그 결과를 t.setHour(...)로 다시 저장한다.

즉,

  • 읽는 일은 getter가 맡고
  • 바꾸는 일은 setter가 맡는 구조다.

이 코드가 보여 주는 핵심

이렇게 해두면 시간 객체는 아무 값이나 받아들이는 단순한 저장 상자가 아니라,
자기 규칙을 스스로 지키는 객체가 된다.

시간이라면 시간답게 0~23시, 0~59분, 0~59초라는 범위를 유지해야 하는데,
그 역할을 private과 getter, setter가 함께 해 주는 것이다.


결국 이 예제의 핵심은
멤버변수를 private으로 숨기고, getter와 setter를 통해서만 접근하게 만들면
객체가 자기 상태를 더 안전하게 관리할 수 있다는 점이다.

즉, 접근 제어자는 단순히 “밖에서 못 건드리게 막는 것”에서 끝나는 것이 아니라,
객체가 올바른 값만 유지하도록 통로를 통제하는 역할까지 한다고 이해하면 된다.




5. 생성자에도 접근 제어자를 붙일 수 있다

접근 제어자는 멤버변수나 메서드뿐 아니라 생성자에도 붙일 수 있다.

생성자에 접근 제어자를 붙인다는 것은 결국 객체를 만들 수 있는 범위를 정한다는 뜻이다.


특히 생성자에 private을 붙이면 클래스 밖에서는 new로 객체를 만들 수 없다.
객체를 생성하려면 생성자를 호출해야 하는데, private 생성자는 그 클래스 내부에서만 접근할 수 있기 때문이다.

이렇게 하면 클래스 밖에서는 객체를 마음대로 만들지 못한다.
대신 클래스 내부에서 객체를 미리 하나 만들어 두고, 외부에서는 그 객체를 받아서만 사용하게 만들 수 있다.


이때 자주 사용하는 방식이 getInstance() 같은 메서드다.
이 메서드는 새 객체를 만드는 것이 아니라, 이미 만들어 둔 객체를 반환하는 역할을 한다.

그리고 getInstance()는 객체를 만들지 않고 클래스 이름으로 바로 호출해야 하므로 static이어야 한다.

예제: SingletonTest

final class Printer {
    private static final Printer instance = new Printer(); // 객체를 클래스 내부에서 하나만 미리 생성

    private Printer() { // 외부에서 new Printer()를 못 하게 private 생성자 사용
    }

    public static Printer getInstance() { // 이미 만들어 둔 객체를 반환
        return instance;
    }

    public void print() {
        System.out.println("출력합니다.");
    }
}

public class SingletonTest {
    public static void main(String[] args) {
        Printer p = Printer.getInstance(); // new가 아니라 getInstance()로 객체 받기
        p.print();

        // Printer p2 = new Printer(); // private 생성자라서 에러

        // 출력결과
        // 출력합니다.
    }
}

이 코드는 생성자를 private으로 막아서 클래스 밖에서 new Printer()를 하지 못하게 만든 예제다.

핵심은 이 줄이다.

private static final Printer instance = new Printer();

여기서 Printer 객체를 클래스 내부에서 한 번 만들어 두고,
이후에는 getInstance()가 그 객체를 그대로 돌려준다.


그래서 main()에서는 new Printer()를 쓰는 대신, 아래처럼 이미 준비된 객체를 받아서 사용한다.

Printer p = Printer.getInstance();
p.print();

즉, 이 예제의 핵심은
객체를 직접 만들게 하지 않고, 정해진 객체만 사용하게 만든다는 점이다.

참고

Runtime 클래스도 비슷한 방식으로 이해할 수 있다.
Runtime은 현재 실행 중인 자바 프로그램의 실행 환경과 관련된 기능을 다루는 클래스다.

그래서 Runtime도 직접 new로 객체를 만드는 것이 아니라,
Runtime.getRuntime()으로 정해진 객체를 받아서 사용한다.




6. 코드 예제로 이해하기

이번 예제는 SalaryExam에서 MethodLab4의 getRandom() 메서드를 가져다 쓰는 상황이다.


핵심은 단순하다.

  • SalaryExam은 day6 패키지에 있다.
  • MethodLab4는 day4 패키지에 있다.
  • 즉, 서로 다른 패키지에 있는 클래스다.

그래서 SalaryExam에서 MethodLab4를 사용하려면 먼저 import가 필요하다.
그런데 import만 했다고 해서 그 안의 메서드를 무조건 다 쓸 수 있는 것은 아니다.

예제: MethodLab4.java

package day4;

public class MethodLab4 {

    static int getRandom(int n) { // 접근 제어자를 안 썼으므로 default
        return (int)(Math.random() * n) + 1;
    }

    public static int getRandom(int min, int max) { // public이라 다른 패키지에서도 사용 가능
        return (int)(Math.random() * (max - min + 1)) + min;
    }
}

이 코드에서 중요한 부분은 getRandom(int n)이다.


이 메서드는 앞에 public이 없고, 접근 제어자를 아예 쓰지 않았다.
접근 제어자를 생략하면 default가 된다.

즉, 이 메서드는 같은 패키지 안에서만 사용할 수 있는 메서드다.

반면 getRandom(int min, int max)는 public이 붙어 있으므로 다른 패키지에서도 사용할 수 있다.

예제: SalaryExam.java

package day6;

import day4.MethodLab4; // 다른 패키지의 클래스를 사용하기 위해 import

public class SalaryExam {
    public static void main(String[] args) {
        System.out.println(MethodLab4.getRandom(10));     // 에러
        System.out.println(MethodLab4.getRandom(1, 10));  // 가능
    }
}

SalaryExam은 import day4.MethodLab4;를 통해 다른 패키지의 MethodLab4를 사용할 수 있다.
하지만 MethodLab4.getRandom(10)은 에러가 난다.

이유는 import 문제가 아니라, getRandom(int n)이 default 메서드이기 때문이다.

default는 같은 패키지에서만 접근 가능하다.
즉, day4 안에서는 쓸 수 있지만, 다른 패키지인 day6의 SalaryExam에서는 사용할 수 없다.

반면 getRandom(int min, int max)는 public이므로 다른 패키지에서도 쓸 수 있다.



왜 에러가 나는가

정리하면 흐름은 이렇다.

  • SalaryExam과 MethodLab4는 서로 다른 패키지다.
  • import는 클래스를 가져다 쓸 수 있게 해준다.
  • 하지만 그 안의 메서드를 실제로 쓸 수 있는지는 접근 제어자가 결정한다.
  • getRandom(int n)은 default라서 다른 패키지에서는 접근할 수 없다.
  • 그래서 에러가 난다.

즉, 이 예제의 핵심은
클래스를 import했다고 해서 그 안의 모든 메서드를 다 쓸 수 있는 것은 아니라는 점이다.

해결 방법

다른 패키지에서도 이 메서드를 쓰고 싶다면 public을 붙여야 한다.

수정 예제: MethodLab4.java

package day4;

public class MethodLab4 {

    public static int getRandom(int n) { // public으로 바꾸면 다른 패키지에서도 사용 가능
        return (int)(Math.random() * n) + 1;
    }

    public static int getRandom(int min, int max) {
        return (int)(Math.random() * (max - min + 1)) + min;
    }
}
``
이제 `getRandom(int n)`도 `public`이 되었기 때문에
다른 패키지에 있는 `SalaryExam`에서도 사용할 수 있다.
<br/>
#### 수정 후: `SalaryExam.java`
```java
package day6;

import day4.MethodLab4;

public class SalaryExam {
    public static void main(String[] args) {
        System.out.println(MethodLab4.getRandom(10));
        System.out.println(MethodLab4.getRandom(1, 10));
    }
}

이제는 에러가 나지 않는다.

이 예제로 봐야 할 포인트

이 예제에서 꼭 봐야 할 것은 하나다.

import는 다른 패키지의 클래스를 가져오는 것이고,
그 안의 메서드를 실제로 사용할 수 있는지는 접근 제어자가 결정한다.


즉,

  • default 메서드 → 같은 패키지에서만 사용 가능
  • public 메서드 → 다른 패키지에서도 사용 가능

이라는 차이가 실제 코드에서 그대로 드러난다.




7. 헷갈리기 쉬운 부분

1) default는 키워드를 쓰는 것이 아니다

default라고 직접 쓰는 것이 아니다.
아무 접근 제어자도 안 쓰면 그게 default다.

그래서 코드에서 제어자가 비어 있으면 “아무거나 가능”이 아니라,
오히려 같은 패키지 안에서만 가능이라고 이해해야 한다.



2) protected는 같은 패키지 전용이 아니다

이 부분은 자주 틀린다.
protected는

  • 같은 패키지에서도 접근 가능하고
  • 다른 패키지라도 자식 클래스면 접근 가능하다

즉, 단순히 “같은 패키지용”이라고 외우면 틀리기 쉽다.



3) abstract는 반드시 완성되어야 한다

추상메서드는 선언만 있고 몸통이 없다.
그래서 자식 클래스가 그 내용을 채워 넣어야 한다.

자식 클래스가 완성하지 않으면 그 자식 클래스도 다시 abstract여야 한다.



4) final과 abstract는 방향이 반대다

final은 더 이상 바꾸지 않겠다는 뜻이다.
반대로 abstract는 아직 덜 끝났으니 상속해서 완성해야 한다는 뜻이다.

그래서 둘은 성격이 서로 반대다.



5) 같이 쓸 수 없는 조합

다음 조합은 주의해야 한다.

  • 메서드에 static과 abstract를 함께 쓸 수 없다
  • 클래스에 abstract와 final을 동시에 쓸 수 없다
  • abstract 메서드는 private일 수 없다

이유를 하나씩 보면 더 이해가 쉽다.


static 메서드는 객체 없이 바로 사용하는 메서드다.
반면 abstract 메서드는 자식 클래스가 구현해서 완성해야 하는 메서드다.
하나는 바로 쓰는 쪽이고, 하나는 자식에게 맡기는 쪽이라 함께 쓸 수 없다.


abstract 클래스는 상속해서 완성하라는 뜻이다.
반대로 final 클래스는 더 이상 상속하지 말라는 뜻이다.
그래서 클래스에 abstract와 final을 동시에 붙일 수 없다.

abstract 메서드는 자식이 구현해야 하는데, private이면 자식이 그 메서드를 볼 수조차 없다.
그래서 abstract private도 성립할 수 없다.




핵심만 다시 정리하면

  • 제어자는 클래스, 변수, 메서드, 생성자에 붙어서 그 대상의 성격과 사용 범위를 정한다.
  • static은 클래스 전체가 함께 쓰는 값이나 기능에 붙는다.
  • final은 더 이상 바꾸지 않음을 뜻한다.
  • abstract는 아직 완성되지 않았고, 자식이 이어서 완성해야 함을 뜻한다.
  • 접근 제어자는 어디까지 접근할 수 있는지를 정한다.
  • private과 getter, setter를 활용하면 데이터를 보호하는 캡슐화를 만들 수 있다.
  • 생성자에도 접근 제어자를 붙일 수 있고, 이를 이용하면 객체 생성 방식도 제한할 수 있다.

0개의 댓글