2025-08-24 13:28

프로그래밍 접근 제어자 완벽 핸드북

  • 접근 제어자는 클래스, 메서드, 변수 등의 코드 요소에 대한 외부 접근 수준을 통제하는 키워드입니다.

  • public, protected, default, private 네 가지 유형이 있으며, 각각 다른 접근 범위를 가집니다.

  • 캡슐화를 통해 코드의 안정성, 유지보수성, 보안을 향상시키는 것이 접근 제어자의 핵심 목표입니다.

개발자라면 반드시 알아야 할 접근 제어자 완벽 핸드북

프로그래밍을 처음 배울 때 우리는 public static void main이라는 주문과도 같은 코드를 만납니다. 여기서 public이 대체 무엇을 의미하는지 깊이 생각해 본 적이 있으신가요? 바로 이 public이 오늘 우리가 탐험할 ‘접근 제어자(Access Modifier)‘의 첫 번째 단서입니다.

접근 제어자는 단순히 코드 앞에 붙는 키워드가 아닙니다. 그것은 잘 설계된 소프트웨어의 청사진이자, 동료 개발자와의 약속이며, 미래에 발생할 수 있는 수많은 버그를 막아주는 든든한 방화벽입니다. 이 핸드북을 통해 접근 제어자가 왜 만들어졌는지, 어떻게 동작하는지, 그리고 어떻게 현명하게 사용해야 하는지에 대한 모든 것을 알아보겠습니다.

1. 접근 제어자는 왜 세상에 나왔을까? (탄생 배경)

초기 프로그래밍에는 접근 제어라는 개념이 희박했습니다. 모든 데이터와 기능은 원칙적으로 어디서든 접근할 수 있었죠. 이는 작은 프로그램에서는 문제가 되지 않았지만, 프로그램의 규모가 커지고 여러 개발자가 협업하기 시작하면서 심각한 문제들을 낳았습니다.

문제점:

  1. 예상치 못한 변경: 중요한 데이터를 아무 데서나 직접 수정할 수 있다 보니, 값이 언제 어디서 바뀌었는지 추적하기가 불가능에 가까웠습니다. 이는 디버깅을 악몽으로 만들었습니다.

  2. 결합도 증가: 코드의 모든 부분이 서로 얽히고설켜, 하나의 작은 수정이 프로그램 전체에 예기치 않은 파급 효과(Side Effect)를 일으켰습니다. 마치 거미줄처럼 엮여버린 것이죠.

  3. 유지보수의 어려움: 내부 구현 방식을 바꾸고 싶어도, 그 코드를 외부에서 얼마나 많이 사용하고 있는지 알 수 없어 함부로 수정할 수 없었습니다. 라이브러리 제작자에게는 특히 치명적이었습니다.

이러한 혼돈 속에서 ‘캡슐화(Encapsulation)’‘정보 은닉(Information Hiding)’ 이라는 객체 지향 프로그래밍의 핵심 원칙이 대두되었습니다.

비유로 이해하기: 자동차 운전

우리는 자동차를 운전할 때 가속 페달, 브레이크, 핸들만 조작하면 됩니다. 엔진 내부의 실린더가 어떻게 움직이고, 연료가 어떻게 분사되는지 알 필요가 없죠. 만약 운전자가 엔진의 모든 부품을 직접 만져야 한다면 어떨까요? 운전은 극도로 복잡해지고, 실수로 중요한 부품을 잘못 건드려 차가 고장 나기 십상일 겁니다.

여기서 자동차 회사(코드 작성자)운전자(코드 사용자) 에게 꼭 필요한 기능(가속 페달, 브레이크)만 공개(public) 하고, 복잡하고 민감한 내부 부품(엔진)은 감춰(private) 둔 것입니다. 이것이 바로 접근 제어자의 기본 철학입니다.

접근 제어자는 이 ‘캡슐화’를 언어 차원에서 강제하고 지원하기 위해 탄생했습니다. 개발자가 의도적으로 “이것은 외부에서 써도 좋습니다”, “이것은 내부에서만 사용해야 합니다”라고 명시하여 코드의 질서와 안정성을 부여하는 것입니다.

2. 접근 제어자의 종류와 구조

대부분의 객체 지향 언어, 특히 Java를 기준으로 접근 제어자는 크게 네 가지로 나뉩니다. 각 제어자가 허용하는 접근 범위를 이해하는 것이 가장 중요합니다.

제어자같은 클래스같은 패키지다른 패키지 (자식 클래스)다른 패키지 (전체)
publicOOOO
protectedOOOX
defaultOOXX
privateOXXX

(default는 package-private라고도 불리며, 키워드를 명시하지 않았을 때의 기본값입니다.)

public: 완전 개방 📢

  • 의미: “누구나, 어디서든 저를 사용할 수 있습니다.”

  • 범위: 프로젝트 내의 어떤 클래스에서도 접근이 가능합니다.

  • 주요 용도:

    • 다른 패키지에 제공해야 하는 핵심 기능 및 API (Application Programming Interface).

    • 객체를 생성하는 생성자.

    • main 메서드처럼 프로그램의 진입점이 되는 경우.

// com.example.api/Calculator.java
package com.example.api;

public class Calculator {
    // 이 메서드는 어디서든 호출할 수 있습니다.
    public int add(int a, int b) {
        return a + b;
    }
}

protected: 상속 관계를 위한 제한적 개방 👪

  • 의미: “같은 패키지 식구들과 다른 패키지에 있는 내 자식들까지는 사용할 수 있습니다.”

  • 범위: 같은 패키지 내의 모든 클래스와, 다른 패키지에 있더라도 상속받은 자식 클래스에서 접근 가능합니다.

  • 주요 용도:

    • 상속을 통해 확장되거나 재정의(Override)될 것을 의도한 메서드.

    • 부모 클래스가 자식 클래스에게는 공유하고 싶지만, 전혀 상관없는 외부에는 숨기고 싶은 멤버.

// com.example.animal/Animal.java
package com.example.animal;

public class Animal {
    protected String name;

    protected void cry() {
        System.out.println("동물이 웁니다.");
    }
}

// com.example.main/Dog.java
package com.example.main;
import com.example.animal.Animal;

public class Dog extends Animal {
    public void introduce() {
        // 다른 패키지이지만 자식 클래스이므로 protected 멤버에 접근 가능
        name = "멍멍이";
        System.out.print(name + "는 ");
        cry(); // cry() 메서드도 접근 가능
    }
}

default (package-private): 우리 패키지끼리 🏠

  • 의미: “같은 패키지 안에서만 저를 사용할 수 있습니다. 키워드를 생략하면 제가 됩니다.”

  • 범위: 오직 같은 패키지에 속한 클래스들만 접근할 수 있습니다. 다른 패키지에서는 자식 클래스라도 접근할 수 없습니다.

  • 주요 용도:

    • 패키지 내부에서만 사용되는 헬퍼(Helper) 클래스나 유틸리티 메서드.

    • 굳이 외부에 노출할 필요 없는, 패키지 내의 클래스들이 긴밀하게 협력할 때 사용.

// com.example.storage/StorageManager.java
package com.example.storage;

class StorageHelper { // default 접근 제어자
    // 이 클래스는 com.example.storage 패키지 내에서만 사용 가능
    void connect() {
        System.out.println("저장소에 연결합니다.");
    }
}

public class StorageManager {
    public void saveData() {
        StorageHelper helper = new StorageHelper();
        helper.connect(); // 같은 패키지이므로 접근 가능
        // ... 데이터 저장 로직
    }
}

private: 나만 볼 거야 🔒

  • 의미: “저를 선언한 바로 그 클래스 내부에서만 사용할 수 있습니다.”

  • 범위: 가장 좁은 접근 범위를 가지며, 해당 멤버가 선언된 클래스 외부에서는 그 누구도 접근할 수 없습니다.

  • 주요 용도:

    • 클래스의 내부 상태를 나타내는 멤버 변수(필드). (정보 은닉의 핵심)

    • 클래스 내부에서만 사용되는 복잡한 로직을 처리하는 보조 메서드.

// com.example.bank/Account.java
package com.example.bank;

public class Account {
    // 잔액 정보는 외부에서 직접 수정할 수 없도록 private으로 보호
    private long balance;

    public Account(long initialBalance) {
        this.balance = initialBalance;
    }

    // 입금: public 메서드를 통해 안전하게 잔액을 변경
    public void deposit(long amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    // 잔액 조회: public 메서드를 통해 값만 제공
    public long getBalance() {
        return balance;
    }

    // 내부에서만 사용하는 복리 계산 로직 (예시)
    private void applyInterest() {
        // ... 복잡한 이자 계산 로직
    }
}

3. 접근 제어자, 어떻게 사용해야 할까? (사용법 및 모범 사례)

접근 제어자를 올바르게 사용하는 것은 좋은 설계의 첫걸음입니다. 다음은 실무에서 따르는 몇 가지 원칙입니다.

원칙 1: 모든 멤버 변수(필드)는 private으로 시작하라.

클래스의 상태를 나타내는 데이터는 외부에서 마음대로 변경할 수 없도록 막아야 합니다. 이것이 캡슐화의 가장 기본입니다. 데이터에 접근해야 한다면, public으로 공개된 메서드(Getter/Setter) 를 통해 접근하도록 유도하세요.

  • Getter: 필드 값을 반환하는 메서드 (getBalance())

  • Setter: 필드 값을 설정하는 메서드 (setBalance()). 이때 유효성 검사(e.g., 입금액이 0보다 큰지)를 추가하여 데이터의 무결성을 지킬 수 있습니다.

원칙 2: 가능한 가장 제한적인 접근 수준을 부여하라.

코드를 작성할 때, 일단 가장 좁은 범위인 private으로 선언하는 습관을 들이세요. 그러다가 다른 클래스에서 사용해야 할 필요가 생기면 default로, 상속 관계에서 필요하면 protected로, 프로젝트 전반에 걸쳐 꼭 필요한 API라면 public으로 점차 범위를 넓혀가는 것이 좋습니다. 이는 불필요한 노출을 최소화하여 코드의 결합도를 낮추는 효과적인 방법입니다.

원칙 3: public은 신중하게 사용하라.

public으로 선언된 클래스나 메서드는 “이것은 우리의 공식적인 API입니다. 마음껏 사용하셔도 좋고, 저희가 함부로 바꾸지 않겠습니다”라는 약속과 같습니다. 한번 public으로 공개된 것은 나중에 수정하기가 매우 어렵습니다. 의존하는 코드가 많아지기 때문이죠. 따라서 정말로 외부에 공개해야 할 핵심 기능이 아니라면 public 사용을 자제해야 합니다.

원칙 4: 상속을 염두에 둔다면 protected를 고려하라.

클래스를 설계할 때 이 클래스가 다른 클래스에 의해 확장될 것을 예상한다면, 자식 클래스가 부모의 특정 기능을 재정의하거나 내부 데이터에 접근해야 할 수 있습니다. 이럴 때 protected는 외부에는 정보를 숨기면서 자식에게만 제한적으로 문을 열어주는 훌륭한 도구가 됩니다.

4. 심화 내용: 접근 제어자와 상속의 관계

접근 제어자는 상속과 만났을 때 흥미로운 규칙을 만들어냅니다.

규칙: 자식 클래스는 부모 클래스의 메서드를 재정의(Override)할 때, 접근 범위를 부모보다 더 좁게 만들 수 없다.

이유는 리스코프 치환 원칙(Liskov Substitution Principle) 과 관련이 있습니다. 부모 클래스 타입으로 자식 객체를 참조할 수 있어야 하는데(다형성), 만약 자식이 부모의 public 메서드를 private으로 바꿔버리면, 부모 타입으로 호출하던 메서드를 더 이상 호출할 수 없게 되어 프로그램의 일관성이 깨지기 때문입니다.

  • protected public (O) : 더 넓히는 것은 가능

  • public private (X) : 더 좁히는 것은 불가능

  • default public (O)

  • default private (X)

class Parent {
    protected void doSomething() {
        System.out.println("부모가 무언가를 한다.");
    }
}

class Child extends Parent {
    // @Override
    // private void doSomething() { // 컴파일 에러! 접근 범위를 좁힐 수 없다.
    //     System.out.println("자식이 무언가를 한다.");
    // }

    @Override
    public void doSomething() { // 접근 범위를 넓히는 것은 허용된다.
        System.out.println("자식이 무언가를 한다.");
    }
}

결론: 좋은 설계의 문지기

지금까지 접근 제어자의 모든 것을 살펴보았습니다. 접근 제어자는 단순히 문법적인 요소가 아니라, 소프트웨어 설계 철학이 담긴 강력한 도구입니다.

  • private으로 내부를 단단히 보호하여 안정성을 높이고,

  • defaultprotected로 협력과 확장의 여지를 남겨 유연성을 확보하며,

  • public으로 잘 정제된 기능만을 외부에 제공하여 유지보수성을 극대화할 수 있습니다.

코드를 작성할 때마다 이 멤버가 어디까지 알려져야 하는지, 누구와 소통해야 하는지 잠시 멈춰 생각하는 습관을 들여보세요. 그 작은 고민이 쌓여 여러분의 코드를 더욱 견고하고 우아하게 만들어 줄 것입니다.

레퍼런스(References)

접근 제어자