캡슐화와 접근제어자 완벽 이해: 초보자를 위한 개념 정리

캡슐화와 접근제어자, 왜 중요할까요?

객체 지향 프로그래밍(OOP)을 공부하다 보면 ‘캡슐화(Encapsulation)’와 ‘접근제어자(Access Modifier)’라는 용어를 자주 접하게 됩니다. 마치 프로그래밍의 기본 공식처럼 느껴지기도 하죠. 하지만 이 두 가지 개념이 왜 중요하고, 실제 코드에서는 어떻게 활용되는지 명확하게 이해하는 것은 생각보다 어렵습니다.

특히 프로그래밍을 처음 접하는 초보자분들에게는 더욱 어렵게 느껴질 수 있습니다. ‘캡슐화’가 단순히 데이터를 숨기는 것 이상으로 무엇을 의미하는지, ‘접근제어자’가 왜 필요한지, 그리고 public, private, protected 같은 키워드들이 각각 어떤 역할을 하는지 혼란스러울 수 있습니다.

하지만 걱정 마세요! 이 글에서는 캡슐화와 접근제어자의 핵심 개념을 초보자도 이해하기 쉽게 풀어 설명하고, 실제 코드 예시를 통해 각 접근제어자의 역할과 중요성을 명확하게 짚어드릴 것입니다. 이 글을 통해 여러분은 캡슐화와 접근제어자를 완벽하게 이해하고, 더 견고하고 효율적인 코드를 작성하는 데 필요한 기초를 다질 수 있을 것입니다.

캡슐화: 데이터와 기능을 하나로 묶는 마법

캡슐화는 객체 지향 프로그래밍의 4대 기둥 중 하나로, 데이터(속성)와 해당 데이터를 다루는 함수(메서드)를 하나의 단위로 묶는 것을 의미합니다. 마치 약을 담는 캡슐처럼, 외부에서 직접 데이터에 접근하는 것을 막고, 미리 정의된 메서드를 통해서만 데이터에 접근하고 수정하도록 제어하는 것이죠.

왜 캡슐화가 필요할까요?

  1. 데이터 보호 (Data Hiding): 외부에서 객체의 내부 데이터를 함부로 변경하지 못하도록 보호합니다. 이는 예상치 못한 오류를 방지하고 데이터의 무결성을 유지하는 데 필수적입니다. 예를 들어, 사용자의 나이를 음수로 설정하거나, 범위를 벗어나는 값으로 변경하는 것을 막을 수 있습니다.

  2. 코드의 재사용성 및 유지보수성 향상: 데이터와 관련 기능을 묶어 하나의 객체로 관리하기 때문에, 코드의 구조가 명확해지고 재사용하기 쉬워집니다. 또한, 특정 기능의 수정이 필요할 때 해당 객체 내에서만 변경하면 되므로 유지보수가 용이해집니다.

  3. 객체 간의 의존성 감소: 객체 내부의 구현이 변경되어도, 외부에서는 정의된 인터페이스(메서드)만 사용하면 되므로 다른 객체에 미치는 영향을 최소화할 수 있습니다.

캡슐화의 핵심: 접근제어자

캡슐화의 핵심적인 역할을 수행하는 것이 바로 접근제어자입니다. 접근제어자는 클래스 내부의 멤버(변수, 메서드)가 외부에서 얼마나, 어떻게 접근될 수 있는지를 지정하는 키워드입니다. 이를 통해 우리는 객체의 데이터를 보호하고, 인터페이스를 명확하게 정의할 수 있습니다.

접근제어자: 누구에게 문을 열어줄 것인가?

주요 프로그래밍 언어(Java, C++, C# 등)에서는 일반적으로 다음과 같은 접근제어자를 제공합니다. 언어마다 약간의 차이가 있을 수 있지만, 기본적인 개념은 동일합니다.

  1. public (모두에게 공개)

  2. private (아무에게도 공개 안 함, 클래스 내부에서만 접근 가능)

  3. protected (같은 패키지 또는 상속받은 클래스에서 접근 가능) – Java, C# 기준

  4. C++에서는 protected의 의미가 약간 다릅니다. (상속받은 클래스에서만 접근 가능)

각 접근제어자에 대해 자세히 알아보겠습니다.

1. public: 누구나 접근 가능

public으로 선언된 멤버는 어디서든지 접근 가능합니다. 클래스 외부, 다른 패키지, 심지어 다른 프로젝트에서도 접근할 수 있습니다.

  • 특징:

  • 가장 개방적인 접근 수준입니다.

  • 객체의 외부 인터페이스 역할을 하는 메서드(Getter, Setter 등)에 주로 사용됩니다.

  • 예시 (Java):

public class Person {

public String name; // public 변수

public void greet() { // public 메서드

System.out.println("안녕하세요, 제 이름은 " + name + "입니다.");

}

}

// 다른 클래스에서 접근

public class Main {

public static void main(String[] args) {

Person person = new Person();

person.name = "홍길동"; // public 변수에 직접 접근 가능

person.greet();        // public 메서드 호출 가능

}

}

public 변수는 편리하지만, 데이터 보호라는 캡슐화의 목적에 위배될 수 있으므로 신중하게 사용해야 합니다. 보통은 private 변수를 선언하고, public Getter/Setter 메서드를 통해 간접적으로 접근하는 방식을 선호합니다.

2. private: 철저한 비밀 유지

private으로 선언된 멤버는 해당 클래스 내부에서만 접근 가능합니다. 클래스 외부에서는 절대로 접근할 수 없습니다. 이는 객체의 내부 데이터와 구현을 외부로부터 완벽하게 숨기는 캡슐화의 핵심 원칙을 구현하는 데 사용됩니다.

  • 특징:

  • 가장 제한적인 접근 수준입니다.

  • 객체의 핵심 데이터를 보호하는 데 사용됩니다.

  • 외부에서는 public 메서드(Getter/Setter)를 통해서만 접근할 수 있습니다.

  • 예시 (Java):

public class Account {

private double balance; // private 변수: 외부에서 직접 접근 불가

// 생성자

public Account(double initialBalance) {

if (initialBalance >= 0) {

this.balance = initialBalance;

} else {

this.balance = 0;

}

}

// public Getter 메서드: balance 값을 읽을 수 있게 함

public double getBalance() {

return this.balance;

}

// public 입금 메서드

public void deposit(double amount) {

if (amount > 0) {

this.balance += amount;

System.out.println("입금 완료. 현재 잔액: " + this.balance);

} else {

System.out.println("입금액은 0보다 커야 합니다.");

}

}

// public 출금 메서드

public boolean withdraw(double amount) {

if (amount > 0 && amount <= this.balance) {

this.balance -= amount;

System.out.println("출금 완료. 현재 잔액: " + this.balance);

return true;

} else if (amount > this.balance) {

System.out.println("잔액이 부족합니다.");

return false;

} else {

System.out.println("출금액은 0보다 커야 합니다.");

return false;

}

}

}

// 다른 클래스에서 접근

public class Main {

public static void main(String[] args) {

Account myAccount = new Account(10000);

// System.out.println(myAccount.balance); // 컴파일 오류! private 멤버 접근 불가

System.out.println("현재 잔액: " + myAccount.getBalance()); // public Getter 사용

myAccount.deposit(5000);

myAccount.withdraw(3000);

myAccount.withdraw(20000); // 잔액 부족

}

}

위 예시에서 balanceprivate으로 선언되어 외부에서 직접 접근할 수 없습니다. 대신 getBalance(), deposit(), withdraw()와 같은 public 메서드를 통해서만 잔액을 확인하거나 변경할 수 있습니다. deposit()withdraw() 메서드 내부에서는 입금액이나 출금액이 유효한지, 잔액이 충분한지 등을 검사하는 로직을 추가하여 데이터의 유효성을 보장합니다. 이것이 바로 캡슐화의 강력함입니다.

3. protected: 같은 패키지 또는 상속 관계에서의 허용

protected 접근제어자는 public보다는 제한적이지만 private보다는 개방적인 접근 수준을 제공합니다.

  • Java/C# 기준:

  • 같은 패키지 내의 모든 클래스에서 접근 가능합니다.

  • 다른 패키지에 있더라도, 해당 클래스를 상속받은 자식 클래스에서는 접근 가능합니다.

  • C++ 기준:

  • 같은 클래스의 멤버와 해당 클래스를 상속받은 자식 클래스에서만 접근 가능합니다. (패키지 개념 없음)

  • 특징:

  • 주로 상속 관계에서 자식 클래스가 부모 클래스의 특정 멤버를 사용할 수 있도록 허용할 때 사용됩니다.

  • 패키지 기반의 협업이나 라이브러리 개발 시 유용하게 활용될 수 있습니다.

  • 예시 (Java):

package com.example.parent;

public class Parent {

protected int protectedVar; // protected 변수

protected void protectedMethod() { // protected 메서드

System.out.println("Parent의 protected 메서드입니다.");

}

public void publicMethod() {

protectedVar = 10; // 클래스 내부에서는 접근 가능

protectedMethod(); // 클래스 내부에서는 접근 가능

}

}
package com.example.child;

import com.example.parent.Parent; // 다른 패키지에서 Parent 클래스 가져오기

public class Child extends Parent {

public void accessParentMembers() {

protectedVar = 20; // 상속받은 자식 클래스에서 접근 가능 (Java/C#)

protectedMethod(); // 상속받은 자식 클래스에서 접근 가능 (Java/C#)

System.out.println("자식 클래스에서 protectedVar 접근: " + protectedVar);

}

}
package com.example.another;

import com.example.parent.Parent;

public class AnotherClass {

public void tryAccess(Parent p) {

// p.protectedVar = 30; // 컴파일 오류! 다른 패키지의 일반 클래스에서는 접근 불가

// p.protectedMethod(); // 컴파일 오류!

}

}

위 예시에서 Child 클래스는 Parent 클래스를 상속받았기 때문에 protectedVarprotectedMethod에 접근할 수 있습니다. 하지만 AnotherClassParent와 다른 패키지에 있고 상속 관계도 아니므로 protected 멤버에 접근할 수 없습니다.

4. 기본 접근제어자 (Default / Package-Private)

만약 접근제어자를 명시하지 않으면, 많은 언어에서 기본 접근제어자가 적용됩니다.

  • Java 기준:

  • default 또는 package-private이라고 불립니다.

  • 같은 패키지 내의 클래스에서만 접근 가능합니다. public보다는 제한적이고 protected와 유사하지만, 상속 관계와는 무관하게 패키지 내에서만 허용됩니다.

  • C# 기준:

  • internal 키워드가 이에 해당합니다. 같은 어셈블리(Assembly) 내에서만 접근 가능합니다.

  • 특징:

  • 클래스 내부의 구현 세부 사항을 숨기면서, 같은 패키지 내의 다른 클래스들과는 협업할 수 있도록 할 때 사용됩니다.

  • 예시 (Java):

package com.example.package1;

class DefaultClass { // 접근제어자 명시 안 함 -> default (package-private)

void defaultMethod() { // 접근제어자 명시 안 함 -> default (package-private)

System.out.println("Default 접근 메서드입니다.");

}

}
package com.example.package1; // 같은 패키지

public class Package1Main {

public static void main(String[] args) {

DefaultClass dc = new DefaultClass();

dc.defaultMethod(); // 같은 패키지 내에서는 접근 가능

}

}
package com.example.package2; // 다른 패키지

import com.example.package1.DefaultClass; // import 시도

public class Package2Main {

public static void main(String[] args) {

// DefaultClass dc = new DefaultClass(); // 컴파일 오류! 다른 패키지에서 접근 불가

// dc.defaultMethod(); // 컴파일 오류!

}

}

DefaultClassdefaultMethod는 접근제어자를 명시하지 않았기 때문에 package-private으로 취급됩니다. 따라서 com.example.package1 내에서는 접근할 수 있지만, com.example.package2에서는 접근할 수 없습니다.

캡슐화와 접근제어자, 어떻게 활용해야 할까요?

캡슐화와 접근제어자를 효과적으로 사용하기 위한 몇 가지 가이드라인을 제시합니다.

  1. 기본은 private: 객체의 데이터(속성)는 기본적으로 private으로 선언하여 외부로부터 보호합니다.

  2. public 인터페이스 정의: 객체가 제공해야 하는 기능은 public 메서드로 구현합니다. 이 메서드들을 통해 외부에서는 객체와 상호작용합니다.

  3. Getter/Setter 활용: private 변수의 값을 읽거나 수정해야 할 경우, public Getter(값을 읽는 메서드)와 Setter(값을 설정하는 메서드)를 제공합니다. Setter 메서드 내에서는 값의 유효성을 검사하는 로직을 추가하여 데이터 무결성을 유지할 수 있습니다.

  4. 읽기 전용 속성: 값을 수정할 필요가 없다면 Setter 메서드는 만들지 않습니다.

  5. 계산된 속성: 실제 저장되지 않고 다른 속성 값으로부터 계산되는 속성의 경우, Getter 메서드만 제공할 수 있습니다.

  6. protected는 상속 시 신중하게: protected는 상속 관계에서 유용하지만, 너무 많은 멤버를 protected로 노출하면 캡슐화의 이점을 희석시킬 수 있습니다. 꼭 필요한 경우에만 사용하도록 합니다.

  7. default (Package-Private)는 같은 패키지 내에서만: 같은 패키지 내의 클래스들 간에만 공유되어야 하는 내부 구현이나 헬퍼(helper) 메서드 등에 사용할 수 있습니다.

흔한 실수와 주의사항

  • 모든 것을 public으로 선언하는 경우: 캡슐화의 의미를 퇴색시키고, 객체의 내부 상태가 쉽게 변경되어 예기치 못한 버그를 유발할 수 있습니다.

  • 모든 것을 private으로만 선언하는 경우: 객체와 외부 사이의 상호작용이 불가능해져 객체가 제 역할을 할 수 없게 됩니다.

  • protected 남용: 상속 관계가 복잡해지고, 부모 클래스의 내부 구현에 대한 의존성이 높아져 코드 변경이 어려워질 수 있습니다.

  • Getter/Setter 메서드의 과도한 사용: 때로는 Getter/Setter 메서드가 단순히 private 변수에 접근하는 것 외에 다른 로직을 포함하지 않아 불필요하게 코드가 길어질 수 있습니다. 하지만 데이터 유효성 검사나 계산된 속성 등의 로직을 포함한다면 매우 유용합니다.

캡슐화와 접근제어자를 사용한 실전 예시: Book 클래스

이제 Book 클래스를 예로 들어 캡슐화와 접근제어자를 적용해보겠습니다.

public class Book {

// 1. private 멤버 변수: 데이터를 보호합니다.

private String title;

private String author;

private double price;

private int stockQuantity;

// 2. 생성자: 객체 초기화 시 값을 설정합니다.

public Book(String title, String author, double price, int stockQuantity) {

this.title = title;

this.author = author;

// 가격은 0보다 커야 함

this.price = (price > 0) ? price : 0;

// 재고 수량은 0 이상이어야 함

this.stockQuantity = (stockQuantity >= 0) ? stockQuantity : 0;

}

// 3. public Getter 메서드: 외부에서 데이터를 읽을 수 있도록 합니다.

public String getTitle() {

return title;

}

public String getAuthor() {

return author;

}

public double getPrice() {

return price;

}

public int getStockQuantity() {

return stockQuantity;

}

// 4. public Setter 메서드: 외부에서 데이터를 수정할 수 있도록 하되, 유효성 검사를 포함합니다.

public void setPrice(double price) {

if (price > 0) {

this.price = price;

System.out.println("가격이 " + price + "로 변경되었습니다.");

} else {

System.out.println("가격은 0보다 커야 합니다. 변경되지 않았습니다.");

}

}

// 재고 수량 변경은 입고, 판매 등의 별도 메서드를 통해 관리하는 것이 더 좋습니다.

// 하지만 단순화를 위해 Setter를 제공합니다.

// 5. 비즈니스 로직을 담은 public 메서드: 캡슐화를 통해 데이터와 기능이 결합됩니다.

public void sell(int quantity) {

if (quantity > 0 && quantity <= this.stockQuantity) {

this.stockQuantity -= quantity;

double totalPrice = this.price * quantity;

System.out.println("'" + this.title + "' " + quantity + "권 판매 완료. 총 금액: " + totalPrice);

} else if (quantity <= 0) {

System.out.println("판매 수량은 0보다 커야 합니다.");

} else {

System.out.println("재고가 부족합니다. 현재 재고: " + this.stockQuantity);

}

}

public void restock(int quantity) {

if (quantity > 0) {

this.stockQuantity += quantity;

System.out.println("'" + this.title + "' " + quantity + "권 입고 완료. 현재 재고: " + this.stockQuantity);

} else {

System.out.println("입고 수량은 0보다 커야 합니다.");

}

}

// 책 정보를 문자열로 반환하는 메서드

@Override

public String toString() {

return "Book [제목: " + title + ", 저자: " + author + ", 가격: " + price + ", 재고: " + stockQuantity + "]";

}

}
// Main 클래스에서 Book 클래스 사용

public class Library {

public static void main(String[] args) {

Book book1 = new Book("객체 지향의 세계", "김개발", 25000, 10);

Book book2 = new Book("파이썬 정복하기", "이코더", 30000, 5);

// public Getter를 통해 정보 확인

System.out.println("첫 번째 책: " + book1.getTitle());

System.out.println("두 번째 책 가격: " + book2.getPrice());

// public 메서드를 통해 기능 수행

book1.sell(3);       // 3권 판매

book2.restock(10);   // 10권 입고

// public Setter를 통해 가격 변경 시도

book1.setPrice(27000); // 정상 변경

book1.setPrice(-5000); // 오류 메시지 출력, 변경 안 됨

// 현재 상태 출력

System.out.println(book1);

System.out.println(book2);

// private 멤버에 직접 접근 시도 (컴파일 오류 발생)

// System.out.println(book1.price);

// book1.stockQuantity = -100;

}

}

Book 클래스 예시를 통해 private으로 데이터를 보호하고, public 메서드를 통해 안전하게 데이터를 다루며, sell이나 restock과 같은 비즈니스 로직을 캡슐화하는 방법을 확인할 수 있습니다.

마치며: 견고한 소프트웨어의 밑거름

캡슐화와 접근제어자는 객체 지향 프로그래밍의 핵심 원칙이며, 견고하고 유지보수하기 쉬운 소프트웨어를 만드는 데 필수적인 요소입니다.

  • 캡슐화는 데이터와 기능을 하나로 묶어 객체의 독립성을 높이고,

  • 접근제어자는 이 캡슐을 외부로부터 얼마나 개방할지를 결정하여 데이터

함께 보면 좋은 글

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤