JAVA

[Java] 객체지향(OOP)의 특징: 캡슐화

Stark97 2024. 9. 20. 18:08
반응형
 
 
 

객체지향(OOP)의 특징 중 캡슐화에 대해 알아보자

📌 서론

객체지향 언어인 Java를 사용하다 보면 캡슐화에 대해 다양한 생각을 가지게 된다.

나는 처음 캡슐화라는 말을 들었을 때 근본적으로 왜 "캡슐"이라고 부르는지는 생각하지 않고 진짜 알약 캡슐만을 생각하면서 개발하다 보니 대체 왜 이게 캡슐화라고 불리는 것인지 잘 이해가 가지 않았다.

다만 현업에 들어와 개발을 하며 시간이 흐른 지금은 이전보다는 이해도가 많이 상승하여 조금은 어떤 느낌인지 알게 되었다.

 

이번 포스트에서 "캡슐화"에 대해서 간단하게 예시를 통해 알아보도록 하자.

 

1. 캡슐화란?

캡슐화란 무엇일까

  • 캡슐화는 '캡슐 안에 무언가를 담는 것'을 의미한다. 우리가 아플 때 먹는 약 캡슐을 생각해 보자. 약 성분은 캡슐 안에 안전하게 보호되어 있다. 마찬가지로, 프로그래밍에서 캡슐화는 중요한 정보나 기능을 하나의 묶음으로 감싸서 보호하고 관리하는 것을 말한다.

캡슐화가 필요한 이유

  1. 데이터 보호
    • 중요한 정보가 외부에서 마음대로 변경되는 것을 막아준다.
    • 예를 들어, 은행 계좌의 잔액을 아무나 접근하여 바꿀 수 있다면 큰 문제가 생길 것이다.

  2. 코드의 유지보수 용이
    • 코드를 수정하거나 업데이트할 때 편리하다.
    • 만약 메서드의 내부 구현이 바뀌더라도, 사용하는 곳에서는 공개된 메서드 시그니처만 알면 된다.

  3. 오류와 버그 감소 (유효성 검증 로직을 추가)
    • 잘못된 사용으로 인한 문제를 방지해 준다.
    • 프로그램이 예상치 못한 방식으로 동작하는 것을 막을 수 있다.

캡슐화와 객체 지향 프로그래밍(OOP)

  • 객체 지향 프로그래밍(Object-Oriented Programming, OOP)은 소프트웨어 개발의 주요 패러다임 중 하나로, 캡슐화를 핵심 원칙으로 삼고 있다. OOP의 다른 주요 원칙으로는 상속(Inheritance), 다형성(Polymorphism), 추상화(Abstraction) 등이 있다. 캡슐화를 잘 활용하면 코드의 유지보수성확장성이 크게 향상된다.

캡슐화의 좋은 점 요약

  1. 정보 은닉: 중요한 데이터가 외부로부터 보호된다.
  2. 모듈화: 코드를 기능별로 나눌 수 있다.
  3. 인터페이스 단순화: 필요한 부분만 공개하여 사용이 쉬워진다.
  4. 유지보수성 향상: 내부 구현을 바꾸더라도 외부에는 영향이 없다.

 

자바에서의 캡슐화 방법

  • 자바에서는 클래스접근 제어자를 사용하여 캡슐화를 구현한다. 여기서 말하는 '접근 제어자'는 클래스의 멤버(변수와 메서드)에 대한 접근 권한을 설정하는 키워드다.

  • 주요 접근 제어자
    • private: 클래스 내부에서만 접근 가능
    • public: 어디서나 접근 가능
    • protected: 같은 패키지나 서브클래스에서 접근 가능
    • default (아무 키워드도 사용하지 않을 때): 같은 패키지 내에서만 접근 가능

 

 

 

코드로 보는 것이 이해가 더 빠를 것이다.
캡슐화가 무엇을 의미하는지 가볍게 알아보자.



2. 예시코드로 알아보는 캡슐화

1. 비밀 상자 만들기

  • 소중한 보물을 보관하는 비밀 상자가 있다고 생각해 보자. 이 상자는 열쇠가 있어야만 열 수 있다.
public class SecretBox {

    private String treasure; // 보물

    // 보물을 넣는 메서드
    public void setTreasure(String treasure) {
        this.treasure = treasure;
    }

    // 보물을 보는 메서드
    public String getTreasure() {
        return treasure;
    }
    
}
  • treasure는 보물을 담는 변수인데, private으로 설정해서 외부에서 직접 볼 수 없게 되어있다.
  • setTreasure()와 getTreasure() 메서드를 통해서만 보물을 넣거나 볼 수 있다.

사용예시

public class Main {

    public static void main(String[] args) {
        SecretBox box = new SecretBox();

        box.setTreasure("금화");
        System.out.println("보물: " + box.getTreasure());
    }
    
}

2. 학교 학생 관리 시스템

  • 학교에서 학생들의 정보를 관리하는 프로그램을 만들어보자.
public class Student {

    private String name; // 이름
    private int age;     // 나이

    // 이름을 설정하는 메서드
    public void setName(String name) {
        this.name = name;
    }

    // 이름을 가져오는 메서드
    public String getName() {
        return name;
    }

    // 나이를 설정하는 메서드
    public void setAge(int age) {
        if(age > 0) {
            this.age = age;
        } else {
            System.out.println("나이는 0보다 커야 해요!");
        }
    }

    // 나이를 가져오는 메서드
    public int getAge() {
        return age;
    }
    
}
  • name과 age 변수는 private으로 보호되어 있으며 외부에서는 setName, getName, setAge, getAge 메서드를 통해서만 접근할 수 있다.
  • setAge 메서드에서는 나이가 0보다 큰지 확인하여 잘못된 입력을 방지해 준다.

사용 예시

public class Main {

    public static void main(String[] args) {
        Student student = new Student();

        student.setName("철수");
        student.setAge(10);

        System.out.println("학생 이름: " + student.getName());
        System.out.println("학생 나이: " + student.getAge());
    }
    
}

3. 게임 캐릭터 관리

  • 게임에서 캐릭터의 체력과 공격력을 관리하는 프로그램을 만들어보자.
public class GameCharacter {

    private int health;     // 체력
    private int attackPower; // 공격력

    // 체력을 설정하는 메서드
    public void setHealth(int health) {
        if(health >= 0) {
            this.health = health;
        } else {
            System.out.println("체력은 음수가 될 수 없어요!");
        }
    }

    // 체력을 가져오는 메서드
    public int getHealth() {
        return health;
    }

    // 공격력을 설정하는 메서드
    public void setAttackPower(int attackPower) {
        if(attackPower >= 0) {
            this.attackPower = attackPower;
        } else {
            System.out.println("공격력은 음수가 될 수 없어요!");
        }
    }

    // 공격력을 가져오는 메서드
    public int getAttackPower() {
        return attackPower;
    }

    // 공격하는 메서드
    public void attack(GameCharacter target) {
        target.setHealth(target.getHealth() - this.attackPower);
        System.out.println("공격했어요! 상대 체력이 " + target.getHealth() + " 남았어요.");
    }
    
}
  • health와 attackPower 변수는 private으로 보호되어 있다. (직접 접근하여 수정할 수 없다.)
  • 공격할 때는 attack 메서드를 사용하여 상대방의 체력(health)을 감소시킨다. (클래스 내부의 메서드로 상태 변경)
  • 체력과 공격력은 음수가 되지 않도록 체크해 준다.

사용 예시

public class GameMain {

    public static void main(String[] args) {
        GameCharacter hero = new GameCharacter();
        GameCharacter monster = new GameCharacter();

        hero.setHealth(100);
        hero.setAttackPower(20);

        monster.setHealth(80);
        monster.setAttackPower(15);

        hero.attack(monster); // 영웅이 몬스터를 공격
        monster.attack(hero); // 몬스터가 영웅을 공격
    }
    
}

 

 

 

캡슐화가 코드에서 어떻게 적용되는지 예시를 통해 간단히 알아봤다.
이제는 캡슐화의 정보 은닉에 대해 이해해 보자.




3. 캡슐화의 정보 은닉 (Information Hiding)

정보 은닉이란?

  • 정보 은닉(Information Hiding)은 캡슐화의 핵심 개념 중 하나로, 객체의 내부 상태와 구현 세부 사항을 외부에서 직접 접근하지 못하도록 숨기고, 필요한 부분만을 공개하는 것을 의미한다. 이를 통해 객체의 내부 구조가 변경되더라도 외부에 미치는 영향을 최소화할 수 있다.

정보 은닉의 중요성

  1. 보안 강화: 중요한 데이터가 외부에 노출되지 않도록 보호한다.
  2. 유지보수 용이: 내부 구현을 변경해도 외부 코드에 영향을 주지 않아 유지보수가 쉬워진다.
  3. 복잡성 감소: 외부에서는 필요한 기능만을 사용하게 되어 시스템의 복잡성이 줄어든다.
  4. 코드 재사용성 향상: 잘 은닉된 클래스는 다른 프로젝트에서도 쉽게 재사용할 수 있다.

정보 은닉의 구현 방법 (1번 목차의 자바의 캡슐화 방법과 동일한 내용)

  • 정보 은닉은 주로 접근 제어자(Access Modifiers)를 사용하여 구현한다. Java에서는 private, public, protected, 그리고 default 접근 제어자를 통해 클래스 멤버의 접근 범위를 지정할 수 있다. 그중에서도 private 접근 제어자를 사용하여 클래스 내부에서만 접근 가능하도록 설정하는 것이 정보 은닉의 핵심이다.

예시 코드로 이해하기: 예시 1. 학생의 점수 설정 및 조회

  • 다음은 정보 은닉을 적용한 간단한 예시다. 학생(Student) 클래스에서 학생의 성적(score)을 외부에서 직접 변경할 수 없도록 하고, 점수를 설정하고 조회할 수 있는 메서드만을 공개한다.
public class Student {

    private String name;    // 학생 이름 (정보 은닉)
    private int score;      // 학생 점수 (정보 은닉)

    // 이름을 설정하는 메서드
    public void setName(String name) {
        this.name = name;
    }

    // 이름을 가져오는 메서드
    public String getName() {
        return name;
    }

    // 점수를 설정하는 메서드
    public void setScore(int score) {
        if(score >= 0 && score <= 100) { // 점수 유효성 검사
            this.score = score;
        } else {
            System.out.println("점수는 0에서 100 사이여야 합니다.");
        }
    }

    // 점수를 가져오는 메서드
    public int getScore() {
        return score;
    }
}
  • 위의 Student 클래스에서 name과 score 변수는 private으로 선언되어 외부에서 직접 접근할 수 없다. 대신 setName, getName, setScore, getScore 메서드를 통해서만 접근할 수 있다. 특히 setScore 메서드에서는 점수가 유효한지 검사하여 잘못된 값이 설정되는 것을 방지한다.

사용 예시

public class Main {

    public static void main(String[] args) {
        Student student = new Student();

        student.setName("영희");
        student.setScore(85);

        System.out.println("학생 이름: " + student.getName());
        System.out.println("학생 점수: " + student.getScore());

        // 잘못된 점수 설정 시도
        student.setScore(150); // "점수는 0에서 100 사이여야 합니다." 출력
    }
    
}

출력

학생 이름: 영희
학생 점수: 85
점수는 0에서 100 사이여야 합니다.

예시 2. 은행 계좌 관리 시스템

  • 다음은 은행 계좌(BankAccount) 클래스에서 정보 은닉을 적용한 예시다. 계좌 잔액(balance)을 외부에서 직접 변경할 수 없도록 하고, 입금(deposit)과 출금(withdraw) 메서드를 통해서만 잔액을 변경할 수 있다.
// BankAccount.java
public class BankAccount {
    private String accountNumber; // 계좌 번호 (정보 은닉)
    private double balance;       // 잔액 (정보 은닉)

    public BankAccount(String accountNumber) {
        this.accountNumber = accountNumber;
        this.balance = 0.0;
    }

    // 계좌 번호를 가져오는 메서드
    public String getAccountNumber() {
        return accountNumber;
    }

    // 잔액을 가져오는 메서드
    public double getBalance() {
        return balance;
    }

    // 입금 메서드
    public void deposit(double amount) {
        if(amount > 0) {
            balance += amount;
            System.out.println(amount + "원이 입금되었습니다. 현재 잔액: " + balance + "원");
        } else {
            System.out.println("입금 금액은 양수여야 합니다.");
        }
    }

    // 출금 메서드
    public void withdraw(double amount) {
        if(amount > 0 && amount <= balance) {
            balance -= amount;
            System.out.println(amount + "원이 출금되었습니다. 현재 잔액: " + balance + "원");
        } else {
            System.out.println("출금 금액이 잘못되었거나 잔액이 부족합니다.");
        }
    }
}
  • 이 예시에서 balance 변수는 private으로 선언되어 외부에서 직접 변경할 수 없다. 대신 deposit과 withdraw 메서드를 통해서만 잔액을 변경할 수 있으며, 각 메서드에서는 유효한 금액인지 검사하여 잘못된 변경을 방지한다. 이를 통해 계좌 잔액의 무결성을 유지할 수 있다.

사용 예시

// BankMain.java
public class BankMain {
    public static void main(String[] args) {
        BankAccount account = new BankAccount("123-456-789");

        System.out.println("계좌 번호: " + account.getAccountNumber());
        System.out.println("초기 잔액: " + account.getBalance() + "원");

        account.deposit(50000);   // 50000원이 입금되었습니다. 현재 잔액: 50000.0원
        account.withdraw(20000);  // 20000원이 출금되었습니다. 현재 잔액: 30000.0원
        account.withdraw(40000);  // 출금 금액이 잘못되었거나 잔액이 부족합니다.
    }
}

출력

계좌 번호: 123-456-789
초기 잔액: 0.0원
50000.0원이 입금되었습니다. 현재 잔액: 50000.0원
20000.0원이 출금되었습니다. 현재 잔액: 30000.0원
출금 금액이 잘못되었거나 잔액이 부족합니다.

정보 은닉의 이점 요약

  1. 데이터 무결성 보장: 외부에서 직접 데이터에 접근하여 변경할 수 없기 때문에 데이터의 일관성과 무결성이 유지된다.
  2. 유지보수성 향상: 내부 구현이 변경되더라도 외부에 공개된 인터페이스만 유지되면 되므로, 코드 수정이 용이하다.
  3. 보안 강화: 중요한 데이터가 외부에 노출되지 않도록 보호할 수 있다.
  4. 코드의 가독성 및 관리 용이성: 클래스의 내부 구조가 숨겨져 있어 코드가 깔끔해지고 관리하기 쉬워진다.

 

 

 

한 가지만 추가 정보를 적어두려고 한다. 지금까지의 예시를 보면 모두 Setter를 사용하고 있다.
반면 우리가 개발을 배울 때 대부분은 setter 사용을 지양하라고 한다. 그 이유가 무엇일까? 그리고 setter를 안 쓰면 어떻게  상태를 변화시켜야 할까?



4. 추가정보: Setter 사용의 단점과 대안

Setter 사용의 단점은 무엇일까?

  1. 데이터 무결성 저하
    • Setter를 통해 외부에서 임의로 객체의 내부 상태를 변경할 수 있기 때문에, 잘못된 값이 설정될 가능성이 있다.

  2. 객체의 상태 제어 어려움
    • 객체의 상태가 외부에서 자유롭게 변경되면, 객체가 일관된 상태를 유지하기 어려워진다.

  3. 캡슐화 약화
    • 객체의 내부 구현이 외부에 노출되면, 캡슐화의 목적이 약화된다. 이는 유지보수성을 저하시킬 수 있다.

  4. 불변 객체의 구현 어려움
    • Setter가 존재하면 객체의 상태를 변경할 수 있어 불변 객체(Immutable Object)를 구현하기 어렵다. 불변 객체는 멀티스레딩 환경에서 안전하게 사용될 수 있어 중요한 장점이 있다.

 

 

Setter 사용을 줄이는 방법은 다음과 같다.


1. 불변 객체 사용

  • 객체의 상태를 한 번 설정하면 변경할 수 없도록 설계한다. 모든 필드를 final로 선언하고, 생성자를 통해 초기화한다.
public final class ImmutableStudent {

    private final String name;
    private final int score;

    public ImmutableStudent(String name, int score) {
        this.name = name;
        if(score < 0 || score > 100) {
            throw new IllegalArgumentException("점수는 0에서 100 사이여야 합니다.");
        }
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }
    
}

사용 예시

public class ImmutableMain {

    public static void main(String[] args) {
        ImmutableStudent student = new ImmutableStudent("영희", 85);

        System.out.println("학생 이름: " + student.getName());
        System.out.println("학생 점수: " + student.getScore());

        // 점수를 변경하려면 새로운 객체를 생성해야 함
        ImmutableStudent updatedStudent = new ImmutableStudent("영희", 90);
        System.out.println("업데이트된 학생 점수: " + updatedStudent.getScore());
    }
    
}

2. 필요한 메서드만 제공

  • Setter 대신 특정 동작을 수행하는 메서드를 제공하여 객체의 상태를 변경한다. 예를 들어, deposit이나 withdraw 같은 메서드를 통해 은행 계좌의 잔액을 관리하는 방식이다. 
  • 사실 이미 3번 목차의 BankAccount 예제에서는 이렇게 필요한 메서드만 제공하고 있었다.
public class BankAccount {

    private String accountNumber; // 계좌 번호 (정보 은닉)
    private double balance;       // 잔액 (정보 은닉)

    public BankAccount(String accountNumber) {
        this.accountNumber = accountNumber;
        this.balance = 0.0;
    }

    public String getAccountNumber() {
        return accountNumber;
    }

    public double getBalance() {
        return balance;
    }

    // 입금 메서드
    public void deposit(double amount) {
        if(amount > 0) {
            balance += amount;
            System.out.println(amount + "원이 입금되었습니다. 현재 잔액: " + balance + "원");
        } else {
            System.out.println("입금 금액은 양수여야 합니다.");
        }
    }

    // 출금 메서드
    public void withdraw(double amount) {
        if(amount > 0 && amount <= balance) {
            balance -= amount;
            System.out.println(amount + "원이 출금되었습니다. 현재 잔액: " + balance + "원");
        } else {
            System.out.println("출금 금액이 잘못되었거나 잔액이 부족합니다.");
        }
    }
    
}

사용 예시

public class BankMain {

    public static void main(String[] args) {
        BankAccount account = new BankAccount("123-456-789");

        System.out.println("계좌 번호: " + account.getAccountNumber());
        System.out.println("초기 잔액: " + account.getBalance() + "원");

        account.deposit(50000);   // 50000원이 입금되었습니다. 현재 잔액: 50000.0원
        account.withdraw(20000);  // 20000원이 출금되었습니다. 현재 잔액: 30000.0원
        account.withdraw(40000);  // 출금 금액이 잘못되었거나 잔액이 부족합니다.
    }
    
}

마지막으로 Setter 사용 시 주의사항을 알아보자.

  1. 필요한 경우에만 Setter 제공
    • 객체의 상태를 변경할 필요가 있는 경우에만 Setter를 제공하고, 그렇지 않은 경우에는 Setter를 제공하지 않는다.

  2. Setter 내부에 유효성 검사 로직 포함
    • Setter를 통해 설정되는 값이 유효한지 검사하여 데이터 무결성을 유지한다.

  3. 불변 객체 지향
    • 가능한 한 객체를 불변으로 설계하여 Setter의 필요성을 줄인다. 불변 객체는 스레드 안전성과 예측 가능성을 높여준다.

  4. 객체의 책임 분리
    • 객체가 스스로 자신의 상태를 관리하도록 하여, 외부에서 상태를 임의로 변경하지 못하도록 한다. 필요한 동작을 메서드로 제공하여 객체의 상태 변경을 통제한다.

setter 사용을 지양하는 이유

  • Setter는 객체의 상태를 외부에서 변경할 수 있게 해주는 유용한 도구이지만, 과도하게 사용하면 캡슐화의 장점을 상실하고 데이터 무결성을 해칠 수 있다는 단점을 가지고 있다. 따라서 Setter의 사용을 신중하게 고려하고, 필요한 경우에 한정하여 제공하며, 유효성 검사와 같은 추가적인 로직을 포함시켜 안전하게 사용하는 것이 중요하다.

 

 

반응형