자바의 OOP: 클래스, 객체, 캡슐화, 상속 및 추상화

자바는 꾸준히 세계에서 가장 인기 있는 세 가지 언어 중 하나입니다. 기업용 소프트웨어 개발, 안드로이드 모바일 앱, 대규모 웹 애플리케이션과 같은 분야에서의 채택률은 무척 높습니다. 강력한 유형 시스템, 포괄적인 생태계, 그리고 “한 번 작성하면 어디서든 실행”이 가능한 능력은 견고하고 확장 가능한 시스템을 구축하는 데 특히 매력적입니다. 본 문서에서는 자바의 객체지향 프로그래밍 기능이 개발자가 이러한 능력을 효과적으로 활용할 수 있게 하며, 적절한 코드 구성과 재사용을 통해 유지보수가 용이하고 확장 가능한 애플리케이션을 구축할 수 있도록 하는 방법을 살펴볼 것입니다.

자바 코드 구성 및 실행에 대한 참고 사항

코드를 작성하기 전에 설정을 몇 가지 해보겠습니다.

문법과 마찬가지로 자바는 코드 구성에 엄격한 규칙을 가지고 있습니다.

먼저, 각 공개 클래스는 자체 파일에 있어야 하며 클래스와 정확히 같은 이름으로 지정되어야 합니다. 확장자를 붙여야 합니다. 따라서 .java 만약 Laptop 클래스를 작성하려면 파일 이름은 Laptop.java대소문자를 구분합니다. 동일한 파일에 비공개 클래스를 가질 수 있지만 분리하는 것이 좋습니다. 아직 작성하기 전에 클래스를 구성하는 것에 대해 이야기하고 있지만 사전에 어디에 놓을지 대략적인 아이디어를 가지는 것이 좋습니다.

모든 자바 프로젝트에는 Main.java 파일에 Main 클래스가 있어야 합니다. 여기서는 클래스를 테스트하기 위해 그들을 객체화하는 방식으로 사용합니다.

Java 코드를 실행하기 위해 IntelliJ IDEA, 인기 있는 Java IDE를 사용할 것입니다. IntelliJ를 설치한 후:

  1. 새 Java 프로젝트 만들기 (파일 > 새로 만들기 > 프로젝트)
  2. src 폴더를 오른쪽 클릭하여 Main.java 파일을 생성하고 다음 내용을 붙여넣으세요:
public class Main { public static void main(String[] args) { // 여기에서 객체를 생성하고 테스트하세요 } }

클래스에 대해 이야기할 때는 Main.java 파일보다 다른 파일에 코드를 작성합니다. 그러나 객체를 생성하고 테스트하는 경우 객체를 만들고 테스트하는 경우 Main.java로 전환합니다.

프로그램을 실행하려면 main 메서드 옆의 녹색 재생 버튼을 클릭할 수 있습니다.

출력은 아래쪽의 실행 도구 창에 표시됩니다.

Java에 완전히 처음이라면, 당사의 Java 소개 강좌를 확인해보시기 바랍니다. 해당 강좌는 Java 데이터 유형과 제어 흐름의 기본 사항을 다룹니다.

그렇지 않으면, 바로 시작해 봅시다.

Java 클래스와 객체

그렇다면 클래스란 정확히 무엇인가요?

클래스는 Java에서 실제 세계의 개념을 나타내는 프로그래밍 구조입니다. 예를 들어, 이 MenuItem 클래스를 고려해 보세요 (이 클래스를 IDE에 파일로 작성하세요):

public class MenuItem { public String name; public double price; }

이 클래스는 레스토랑의 다양한 메뉴 항목을 나타내는 블루프린트 또는 템플릿을 제공합니다. 클래스의 두 속성,name,price,를 변경함으로써 우리는 햄버거나 샐러드와 같은 무수히 많은 메뉴 객체를 생성할 수 있습니다.

자바에서 클래스를 만들기 위해, 클래스의 접근 수준을 설명하는 줄을 시작합니다 (private, public, 또는 protected 중 하나) 그 후에 클래스 이름을 따릅니다. 괄호 다음에는 클래스의 속성을 개요로 작성합니다.

그러나 이 클래스에 속하는 객체를 어떻게 만들까요? Java는 생성자 메서드를 통해 이를 가능하게 합니다:

public class MenuItem { public String name; public double price; // 생성자 public MenuItem(String name, double price) { this.name = name; this.price = price; } }

생성자는 클래스에서 새 객체를 생성할 때 호출되는 특수한 메서드입니다. 이는 객체의 속성을 제공한 값으로 초기화합니다. 위 예에서 생성자는 이름과 가격 매개변수를 가져와 ‘this’ 키워드를 사용하여 이후 객체 인스턴스를 참조하여 객체의 필드에 할당합니다.

생성자의 구문은 다른 클래스 메서드와 다릅니다. 왜냐하면 반환 유형을 지정할 필요가 없기 때문입니다. 또한 생성자는 클래스와 동일한 이름을 가져야 하며, 클래스 정의 후에 선언한 속성의 수와 동일해야 합니다. 위의 생성자는 두 개의 속성을 생성하고 있습니다. 왜냐하면 클래스 정의 후에 두 개를 선언했기 때문입니다: nameprice.

클래스와 생성자를 작성한 후에는 메인 메서드에서 해당 클래스의 인스턴스(객체)를 생성할 수 있습니다:

public class Main { public static void main(String[] args) { // 여기서 객체를 생성합니다 MenuItem burger = new MenuItem("Burger", 3.5); MenuItem salad = new MenuItem("Salad", 2.5); System.out.println(burger.name + ", " + burger.price); } }

출력:

Burger, 3.5

위에서 두 개의 MenuItem 객체를 변수 burgersalad 에 생성합니다. Java에서 필요한 대로 변수의 유형을 선언해야 합니다. 이것은 MenuItem입니다. 그런 다음, 클래스의 인스턴스를 생성하기 위해 new 키워드를 작성하고 생성자 메서드를 호출합니다.

생성자 외에도 클래스에 동작을 제공하는 일반 메서드를 만들 수 있습니다. 예를 들어, 아래에는 세금을 고려한 총 가격을 계산하는 메서드를 추가합니다:

public class MenuItem { public String name; public double price; // 생성자 public MenuItem(String name, double price) { this.name = name; this.price = price; } // 세금을 고려한 가격을 계산하는 메서드 public double getPriceAfterTax() { double taxRate = 0.08; // 8% 세율 return price + (price * taxRate); } }

이제 세금을 포함한 총 가격을 계산할 수 있습니다:

public class Main { public static void main(String[] args) { MenuItem burger = new MenuItem("Burger", 3.5); System.out.println("Price after tax: $" + burger.getPriceAfterTax()); } }

Output:

Price after tax: $3.78

캡슐화

클래스의 목적은 객체를 생성하기 위한 청사진을 제공하는 것입니다. 이러한 객체들은 다른 스크립트나 프로그램에서 사용될 것입니다. 예를 들어, 우리의 MenuItem 객체들은 화면에 이름, 가격, 이미지를 표시하는 사용자 인터페이스에 의해 사용될 수 있습니다.

이러한 이유로, 우리는 우리가 의도한 대로만 인스턴스를 사용할 수 있도록 클래스를 설계해야 합니다. 현재, MenuItem 클래스는 매우 기본적이고 오류가 많습니다. 사람들은 음수로 가격이 매겨진 사과 파이나 100만 달러짜리 샌드위치와 같이 터무니없는 속성을 갖는 객체를 만들 수 있습니다:

// Main.java 내부 MenuItem applePie = new MenuItem("Apple Pie", -5.99); // 음수 가격! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // 부당하게 비싼 System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);

따라서 클래스를 작성한 후에 첫 번째 작업은 어떻게 생성되고 액세스되는지 제한하여 해당 속성을 보호하는 것입니다. 먼저, 가격에 대해 오직 양수 값만 허용하고, 우연히 지나치게 비싼 항목이 표시되는 것을 방지하기 위해 최대값을 설정하려고 합니다.

Java는 setter 메서드를 사용하여이를 달성할 수 있습니다:

public class MenuItem { private String name; private double price; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { this.name = name; setPrice(price); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }

위의 코드 블록에서 새로운 내용을 살펴보겠습니다:

1. 우리는 private 키워드를 추가하여 속성을 비공개로 설정했습니다. 이는 MenuItem 클래스 내에서만 접근할 수 있음을 의미합니다. 캡슐화는 이 중요한 단계로 시작됩니다.

2. 우리는 MAX_PRICE라는 새로운 상수를 추가했습니다:

  • 비공개 (클래스 내에서만 접근 가능)
  • 정적 (모든 인스턴스에서 공유됨)
  • 최종 (초기화 후 변경할 수 없음)
  • $100.0으로 설정하여 합리적인 최대 가격으로 설정

3. setPrice() 메소드를 추가했습니다:

  • 가격 매개변수를 받습니다
  • 가격이 음수가 아닌지 확인합니다.
  • 가격이 MAX_PRICE를 초과하지 않도록 확인합니다.
  • 유효성 검사 실패 시 설명 메시지와 함께 IllegalArgumentException을 throw합니다.
  • 모든 유효성 검사를 통과할 때만 가격을 설정합니다.

4. 생성자를 수정하여 setPrice()을 직접 가격을 할당하는 대신 사용하도록 변경했습니다. 이렇게 하면 객체 생성 중에 가격 유효성 검사가 수행됩니다.

우리는 좋은 객체 지향 설계의 핵심 요소 중 하나를 구현했습니다 — 캡슐화. 이 패러다임은 데이터 은닉과 객체 속성에 대한 제어된 액세스를 강제하여 내부 구현 세부 정보가 외부 간섭으로부터 보호되고 정의된 인터페이스를 통해서만 수정할 수 있도록 합니다.

이를 강조하기 위해 이름 속성에 캡슐화를 적용해 봅시다. 라떼, 카푸치노, 에스프레소, 아메리카노, 모카만 판매하는 커피숍이 있다고 상상해보세요.

그래서, 우리의 메뉴 항목 이름은 이 목록의 항목 중 하나여야 합니다. 이를 코드에서 어떻게 강제할 수 있는지 알아봅시다:

// 클래스의 나머지 부분 ... private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private String name; public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); }

위의 코드는 커피숍의 메뉴 항목에 대한 이름 유효성을 구현합니다. 다음과 같이 분해해 봅시다:

1. 먼저, 유일하게 허용된 음료 이름인 라떼, 카푸치노, 에스프레소, 아메리카노 및 모카를 포함하는 VALID_NAMES이라는 private static final 배열을 정의합니다. 이 배열은 다음과 같습니다:

  • private: 클래스 내에서만 접근 가능
  • static: 모든 인스턴스에서 공유됨
  • 최종: 초기화 후 수정할 수 없음

2. 음료 이름을 저장할 private String name 필드를 선언합니다

3. setName() 메서드는 유효성 검사 논리를 구현합니다:

  • String name 매개변수를 가져옵니다
  • 비교를 대소문자 구분 없이 하기 위해 소문자로 변환합니다
  • VALID_NAMES 배열을 순환합니다
  • 매치가 발견되면 이름을 설정하고 반환합니다
  • 매치가 발견되지 않으면, 모든 유효한 옵션을 나열하는 설명 메시지와 함께 IllegalArgumentException을 throw합니다

지금까지의 전체 클래스는 다음과 같습니다:

public class MenuItem { private String name; private double price; private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { setName(name); setPrice(price); } public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }

속성이 생성되는 방식을 보호한 후에, 이들이 접근되는 방식도 보호하고 싶습니다. 이는 getter 메소드를 사용하여 수행됩니다:

public class MenuItem { // 여기에 코드를 추가합니다 ... public String getName() { return name; } public double getPrice() { return price; } }

Getter 메소드는 클래스의 private 속성에 제어된 액세스를 제공합니다. 직접적인 속성 액세스로 인해 발생할 수 있는 원치 않는 수정 사항 및 캡슐화의 깨짐 문제를 해결합니다.

예를 들어, getters 없이 속성에 직접 액세스할 수 있습니다:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // 속성에 직접 액세스 item.name = "INVALID"; // 유효성 검사를 우회하여 직접 수정 가능

getters를 사용하면 올바른 액세스를 강제할 수 있습니다:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // getter를 통한 제어된 액세스 // item.name = "INVALID"; // 허용되지 않음 - 검증하는 setName()을 사용해야 함

이 캡슐화는:

  1. 잘못된 수정을 방지하여 데이터 무결성을 보호합니다
  2. 클래스를 사용하는 코드에 영향을 주지 않고 내부 구현을 변경할 수 있게 합니다
  3. 필요시 추가 로직을 포함할 수 있는 단일 접근 지점을 제공합니다
  4. 코드를 유지보수하기 쉽고 버그가 발생할 가능성을 줄입니다

상속

우리 클래스는 점점 좋아지고 있지만 몇 가지 문제가 있습니다. 예를 들어, 다양한 요리와 음료를 제공하는 대규모 레스토랑의 경우, 클래스는 충분히 유연하지 않습니다.

다양한 종류의 음식 항목을 추가하려면 여러 가지 문제에 직면하게 됩니다. 일부 요리는 포장되어 테이크아웃이 가능하지만, 다른 요리는 즉시 섭취해야 합니다. 메뉴 항목은 다양한 가격과 할인이 적용될 수 있습니다. 요리에는 온도 추적이나 특별 보관이 필요할 수 있습니다. 음료는 사용자 정의 재료로 뜨거운 것일 수도 있고 차가울 수도 있습니다. 항목에는 알레르기 정보와 portion 옵션이 필요할 수 있습니다. 현재 시스템은 이러한 다양한 요구 사항을 처리하지 못합니다.

상속은 이러한 모든 문제에 대한 우아한 해결책을 제공합니다. 공통 속성을 가진 기본 MenuItem 클래스를 정의한 후, 이러한 기본을 상속받으면서 고유한 기능을 추가하는 자식 클래스를 생성함으로써 메뉴 항목의 전문화된 버전을 만들 수 있게 해줍니다.

예를 들어, 온도 옵션이 있는 음료를 위한 Drink 클래스, 즉시 소비가 필요한 항목을 위한 Food 클래스, 특별한 저장 요구가 있는 항목을 위한 Dessert 클래스가 있을 수 있으며 — 모두 핵심 메뉴 항목 기능을 상속받습니다.

클래스 확장

다음으로 Drink를 시작으로 이러한 아이디어를 구현해 보겠습니다:

public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { this.name = name; this.price = price; this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }

부모로부터 상속받는 자식 클래스를 정의하기 위해, 자식 클래스 이름 뒤에 부모 클래스와 함께 extends 키워드를 사용합니다. 클래스 정의 후에는 이 자식 클래스가 가진 새로운 속성을 정의하고 생성자를 구현합니다.

그러나 nameprice를 초기화해야하는 것에 주목하십시오. isCold를 초기화해야하는 것에 주목하십시오. 부모 클래스에는 100개 이상의 속성이 있을 수 있으므로 이것은 이상적이지 않습니다. 또한 위의 코드는 컴파일할 때 오류를 발생시킬 것이며, 이는 부모 클래스의 속성을 초기화하는 올바른 방법이 아니기 때문입니다. 올바른 방법은 super 키워드를 사용하는 것입니다:

public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }

super 키워드는 부모 클래스의 생성자를 호출하는 데 사용됩니다. 이 경우, super(name, price)MenuItem의 생성자를 호출하여 해당 속성을 초기화하여 코드 중복을 피합니다. 우리는 새로운 isCold 속성을 Drink 클래스에만 특정하게 초기화하면 됩니다.

키워드는 매우 유연합니다. 왜냐하면 자식 클래스의 어느 부분에서든 부모 클래스를 참조하는 데 사용할 수 있기 때문입니다. 예를 들어, 부모 메서드를 호출하려면super.methodName()을 사용하고super.attributeName은 속성에 사용됩니다.

메서드 오버라이딩

이제 세금을 반영한 총 가격을 계산하는 새로운 메서드를 클래스에 추가하려고 합니다. 준비된 음식과 포장 음료와 같이 서로 다른 메뉴 항목들은 서로 다른 세율을 가질 수 있기 때문에 메서드 오버라이딩을 사용하여 각 자식 클래스에서 특정 세금 계산을 구현하고 부모 클래스에서 공통 메서드 이름을 유지할 수 있습니다.

다음은 이게 어떻게 생겼는지입니다:

public class MenuItem { // MenuItem 클래스의 나머지 부분 public double calculateTotalPrice() { // 기본 세율은 10% return price * 1.10; } } public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { // 음식은 15% 세금 return super.getPrice() * 1.15; } } public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } @Override public double calculateTotalPrice() { // 음료에는 8% 세금이 부과됩니다 return super.getPrice() * 1.08; } }

이 예에서 메소드 오버라이딩을 통해 각 하위 클래스는 calculateTotalPrice()의 고유한 구현을 제공할 수 있습니다:

베이스 MenuItem 클래스는 기본 세율을 10%로 정의합니다.

식품음료 클래스가 MenuItem을 확장할 때, 각각의 세율을 구현하기 위해 이 메서드를 재정의합니다:

  • 식품 항목은 더 높은 15%의 세율을 가집니다
  • 음료는 8%의 낮은 세율을 가지고 있습니다.

@Override 주석은 이러한 메서드가 부모 클래스 메서드를 재정의한다는 것을 명시적으로 나타내는 데 사용됩니다. 이는 메서드 시그니처가 부모 클래스와 일치하지 않을 경우 오류를 잡는 데 도움이 됩니다.

각 하위 클래스는 super.getPrice()를 사용하여 부모 클래스의 가격에 여전히 접근할 수 있으며, 재정의된 메서드가 부모 클래스 기능을 활용하면서 자체 동작을 추가할 수 있음을 보여줍니다.

간단히 말해서, 메소드 오버라이딩은 상속의 필수 요소로, 하위 클래스가 상위 클래스에서 정의된 메소드의 구현을 제공할 수 있게 하여 더 구체적인 동작을 가능하게 하면서도 동일한 메소드 시그니처를 유지할 수 있게 합니다.

추상 클래스

우리의 MenuItem 클래스 계층 구조는 작동하지만, 문제가 있습니다: 누구나 일반 MenuItem 객체를 만들 수 있어야 할까요? 결국, 우리 레스토랑에서는 모든 메뉴 항목이 음식이거나 음료수이어야 합니다. “일반 메뉴 항목”이라는 것은 없습니다.

우리는MenuItem를 추상 클래스로 만들어 이를 방지할 수 있습니다. 추상 클래스는 단지 기본 청사진을 제공합니다 – 상속을 위한 부모 클래스로만 사용될 뿐 직접 인스턴스화될 수 없습니다.

메뉴 항목을 MenuItem 추상적으로 만들기 위해, abstract 키워드를 접근 수정자 뒤에 추가합니다:

public abstract class MenuItem { private String name; private double price; public MenuItem(String name, double price) { setName(name); setPrice(price); } // 기존 getter/setter는 동일하게 유지됩니다 // 이 메소드를 추상으로 만듭니다 - 모든 서브클래스는 반드시 구현해야 합니다 public abstract double calculateTotalPrice(); }

추상 클래스에는 위의 calculateTotalPrice()와 같은 추상 메서드가 있을 수 있습니다. 이러한 추상 메서드는 하위 클래스가 구현하도록 강제하는 계약 역할을 합니다. 다시 말해, 추상 클래스의 모든 추상 메서드는 하위 클래스에서 구현되어야 합니다.

그래서, 다음과 같이 다시 작성합시다식품음료 이러한 변경 사항을 고려하여 다시 작성합시다:

public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { return getPrice() * 1.15; // 15% 세금 } } public class Drink extends MenuItem { private boolean hasCaffeine; public Drink(String name, double price, boolean hasCaffeine) { super(name, price); this.hasCaffeine = hasCaffeine; } @Override public double calculateTotalPrice() { return getPrice() * 1.10; // 10% 세금 } }

이 메뉴 시스템 구현을 통해, 추상화와 상속이 어떻게 유연하고 유지보수 가능한 코드를 만들어내는 데 함께 작용하여 서로 다른 비즈니스 요구 사항에 쉽게 적응할 수 있는 것을 확인할 수 있었습니다.

결론

오늘은 객체 지향 프로그래밍 언어인 Java가 무엇을 할 수 있는지 대략적으로 살펴보았습니다. 클래스, 객체, 그리고 캡슐화, 상속, 추상화라는 OOP의 중요한 요소들을 레스토랑 메뉴 시스템을 통해 다루었습니다.

이 시스템을 제품으로 출시할 준비를 하려면 인터페이스(추상화의 일부), 다형성, 그리고 OOP 디자인 패턴과 같은 다양한 개념들을 더 배워야 합니다. 이러한 개념에 대해 더 알고 싶다면, 자바에서의 객체 지향 프로그래밍 소개 코스를 참조하십시오.

Java 지식을 테스트하고 싶다면, 저희의 Java 인터뷰 질문 기사에 있는 몇 가지 질문에 대답해 보세요.

Source:
https://www.datacamp.com/tutorial/oop-in-java