로 타입(raw type) : 제네릭 클래스나 인터페이스에서 타입 매개변수를 명시하지 않고 raw 타입으로 사용하는 것을 의미
제네릭 : 자바에서 컬렉션 클래스 및 메서드, 인터페이스 등을 작성할 때 타입을 파라미터화하는 기능
컬렉션 클래스 : 자바에서 데이터를 모으고 관리하는 데 사용되는 클래스들의 집합
//잘못된 예
List list = new ArrayList(); // 로 타입 사용
list.add("hello");
list.add(123);
String str = (String) list.get(0); // 형변환 필요
int num = (int) list.get(1); // 형변환 필요
//옳은 예
List<String> list = new ArrayList<>(); // 제네릭 사용
list.add("hello");
// list.add(123); // 컴파일 오류: 타입 불일치
String str = list.get(0); // 형변환 불필요
톱 레벨 클래스 : 다른 클래스의 내부에 정의되지 않고, 자체 파일에 독립적으로 존재하는 클래스. 즉, 톱 레벨 클래스는 패키지 내에서 최상위에 위치하는 클래스이며, 다른 클래스의 멤버가 아닌 독립된 클래스.
아래 예시는 한 파일에 두 개의 클래스가 정의된것이다. 절대 따라하면 안되는 코드이다.
class Utensil {
static final String NAME = "pan";
}
class Dessert {
stattic final String NAME = "cake";
}
위와 같은 코드로 작성할 경우 오류가 발생 할 수 있다. 이를 해결하려면 단순히 톱 레벨 클래스들을 서로 다른 소스 파일로 분리해주면 된다. 굳이 여러 톱 레벨 클래스를 한 파일에 담고 싶다면 정적 멤버 클래스로 만들 수 있다.
public class Test {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
private static class Utensil {
static final String NAME = "pan";
}
private static class Dessert {
static final String NAME = "cake";
}
}
결론 : 소스 파일 하나에는 반드시 톱레벨 클래스(혹은 톱레벨 인터페이스)를 하나만 담자. 이 규칙만 따른다면 컴파일러가 한 클래스에 대한 정의를 여러 개 만들어내는 일은 사라진다. 소스 파일을 어떤 순서로 컴파일하든 바이너리 파일이나 프로그램의 동작이 달라지는 일은 결코 일어나지 않을 것이다.
정적 멤버 클래스는 바깥 클래스의 인스턴스와 독립적으로 존재할 수 있는 클래스다. 즉, 바깥 클래스의 인스턴스 없이도 생성하고 사용할 수 있다. 정적 멤버 클래스는 바깥 클래스의 정적 멤버에만 접근할 수 있으며, 바깥 클래스의 인스턴스 멤버에는 접근할 수 없다.
비정적 멤버 클래스
비정적 멤버 클래스는 바깥 클래스의 인스턴스와 연관되어 있는 클래스다. 이러한 클래스의 객체는 바깥 클래스의 객체와 연결되어 있으며, 바깥 클래스의 인스턴스 멤버와 메서드에 접근할 수 있다. 비정적 내부 클래스는 바깥 클래스의 인스턴스를 통해서만 생성할 수 있다. 정적 클래스와 의 구문적 차이는 단지 static이 붙어 있고 없고의 차이뿐이지만, 의미상 차이는 꽤 크다.
지역 클래스 (Local Class)
메서드 내부에 선언된 클래스로, 선언된 메서드 내에서만 사용할 수 있다.
익명 클래스 (Anonymous Class)
이름이 없는 클래스로, 주로 단일 인스턴스 생성에 사용된다.
public class OuterClass {
private static int staticVar = 100;
private int instanceVar = 200;
// 정적 멤버 클래스
static class StaticMemberClass {
void display() {
System.out.println(staticVar); // 정적 변수에 접근 가능
// System.out.println(instanceVar); // 컴파일 에러: 인스턴스 변수에 접근 불가
}
}
// 비정적 멤버 클래스
class NonStaticMemberClass {
void display() {
System.out.println(staticVar); // 정적 변수에 접근 가능
System.out.println(instanceVar); // 인스턴스 변수에 접근 가능
}
}
// 메서드 내에서 지역 클래스 사용
void methodWithLocalClass() {
// 지역 클래스 정의
class LocalClass {
void display() {
System.out.println("Inside Local Class. staticVar: " + staticVar + ", instanceVar: " + instanceVar);
}
}
// 지역 클래스 인스턴스 생성 및 사용
LocalClass localInstance = new LocalClass();
localInstance.display();
}
// 메서드 내에서 익명 클래스 사용
void methodWithAnonymousClass() {
// 익명 클래스를 통한 Runnable 인터페이스 구현
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Inside Anonymous Class. staticVar: " + staticVar);
// instanceVar 접근 가능 (익명 클래스는 비정적 컨텍스트 내에 정의되었기 때문)
System.out.println("instanceVar: " + instanceVar);
}
};
// 익명 클래스 인스턴스 사용
new Thread(runnable).start();
}
public static void main(String[] args) {
OuterClass outerInstance = new OuterClass();
// 정적 멤버 클래스 인스턴스 생성 및 사용
OuterClass.StaticMemberClass staticInstance = new OuterClass.StaticMemberClass();
staticInstance.display();
// 비정적 멤버 클래스 인스턴스 생성 및 사용
OuterClass.NonStaticMemberClass nonStaticInstance = outerInstance.new NonStaticMemberClass();
nonStaticInstance.display();
// 지역 클래스 사용
outerInstance.methodWithLocalClass();
// 익명 클래스 사용
outerInstance.methodWithAnonymousClass();
}
}
결론 : 중첩 클래스에는 네 가지가 있으며, 각각의 쓰임이 다르다.
메서드 밖에서도 사용해야하거나 메서드 안에 정의하기엔 너무 길다면 멤버 클래스로 만든다.
멤버 클래스의 인스턴스 각각이 바깥 인스턴스를 참조한다면 비정적으로, 그렇지 않으면 정적으로 만들자.
중첩 클래스가 한 메서드 안에서만 쓰이면서 그 지점이 단 한곳이고 해당 타입으로 쓰기에 적합한 클래스나 인터페이스가 이미 있다면 익명 클래스로 만들고, 그렇지 않으면 지역 클래스로 만들자.
태그 달린 클래스(tagged class): 클래스 내부에 특정 "태그" 필드를 두어 객체의 유형을 나타내는 방식. 이러한 클래스는 보통 여러 유형의 객체를 하나의 클래스로 표현하고자 할 때 사용되며, 태그 값에 따라 다른 동작을 수행하도록 메서드들이 조건문(예: if-else, switch)을 사용해 구현된다.
// 태그 달린 클래스
public class Shape {
enum ShapeType { CIRCLE, RECTANGLE }
final ShapeType shapeType;
double radius; // 원일 경우 사용
double width; // 직사각형일 경우 사용
double height; // 직사각형일 경우 사용
// 원을 위한 생성자
Shape(double radius) {
shapeType = ShapeType.CIRCLE;
this.radius = radius;
}
// 직사각형을 위한 생성자
Shape(double width, double height) {
shapeType = ShapeType.RECTANGLE;
this.width = width;
this.height = height;
}
double area() {
switch (shapeType) {
case CIRCLE:
return Math.PI * radius * radius;
case RECTANGLE:
return width * height;
default:
throw new AssertionError(shapeType);
}
}
}
태그 달린 클래스는 단점이 한가득이다. 열거 타입 선언, 태그 필드, switch 문 등 쓸데없는 코드가 많다. 여러 구현이 한 클래스에 혼합돼 있어서 가독성도 나쁘다. 태그 달린 클래스는 장황하고, 오류를 내기 쉽고, 비효율적이다. 태그 달린 클래스는 클래스 계층구조를 어설프게 흉내낸 아류이다.
클래스 계층구조(Class Hierarchy) : 객체 지향 프로그래밍에서 클래스들 사이의 상속 관계를 조직화하는 방법. 이 구조는 계층적이며, 상위 클래스(superclass)로부터 하위 클래스(subclass)가 파생되는 방식으로 구성된다. 클래스 계층구조는 실세계의 개념을 모델링하는 데 있어 상속과 다형성을 활용하여 코드의 재사용성, 확장성 및 유지보수성을 향상시키는 핵심적인 요소이다.
클래스 계층구조의 구성 요소 :
상위 클래스 (Superclass) / 부모 클래스 (Parent Class): 다른 클래스에 공통된 속성과 메서드를 제공하는 클래스. 하위 클래스는 이 상위 클래스의 속성과 메서드를 상속받는다.
하위 클래스 (Subclass) / 자식 클래스 (Child Class): 상위 클래스의 속성과 메서드를 상속받아, 추가적인 속성과 메서드를 가지며 더 구체적인 개념을 모델링하는 클래스.
추상 클래스 (Abstract Class): 인스턴스화할 수 없으며, 하나 이상의 추상 메서드(구현되지 않은 메서드)를 포함할 수 있는 클래스. 하위 클래스는 추상 클래스의 모든 추상 메서드를 구현해야 한다.
인터페이스 (Interface): 모든 메서드가 추상 메서드인 특별한 유형의 클래스로, 클래스가 특정 행동을 할 수 있음을 선언하는 데 사용된다.
// 클래스 계층구조
// 추상 클래스 Shape는 도형의 공통적인 특성을 정의하는 상위 클래스입니다.
// 모든 도형은 면적을 가지므로, 면적을 계산하는 추상 메서드 area()를 선언합니다.
// 이는 클래스 계층구조에서 공통 인터페이스 역할을 합니다.
abstract class Shape {
// 추상 메서드 area()는 하위 클래스에서 구체적인 면적 계산 로직을 구현해야 합니다.
// 이 메서드는 다형성을 가능하게 하는 중요한 부분입니다.
abstract double area();
}
// Circle 클래스는 Shape 클래스를 상속받아 원의 구체적인 특성을 모델링하는 하위 클래스입니다.
// Circle은 Shape의 구체적인 구현체로, 원의 면적을 계산하는 로직을 포함합니다.
class Circle extends Shape {
final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
double area() {
// 이 메서드는 Shape 클래스에서 선언된 추상 메서드 area()의 구체적인 구현입니다.
return Math.PI * radius * radius;
}
}
// Rectangle 클래스는 Shape 클래스를 상속받아 직사각형의 구체적인 특성을 모델링하는 하위 클래스입니다.
// Rectangle 역시 Shape의 구체적인 구현체로, 직사각형의 면적을 계산하는 로직을 포함합니다.
class Rectangle extends Shape {
final double width;
final double height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
double area() {
// 이 메서드는 Shape 클래스에서 선언된 추상 메서드 area()의 구체적인 구현입니다.
return width * height;
}
}
결론 : 태그 달린 클래스를 써야 하는 상황은 거의 없다. 새로운 클래스르 작성하는 데 태그 필드가 등장한다면 태그를 없애고 계층구조로 대치하는 방법을 생각해보자. 기존 클래스가 태그 필드를 사용하고 있다면 계층구조로 리팩토링하는 걸 고민해보자.
즉, 클래스가 어떤 인터페이스를 구현한다는 것은 자신의 인스턴스로 무엇을 할 수 있는지를 클라이언트에 얘기해주는 것이다.
인터페이스는 위의 지침에 맞지 않는 예로 상수 인터페이스라는 것이 있다. 상수 인터페이스란 메서드 없이, 상수를 뜻하는 static final 필드로만 가득 찬 인터페이스를 말한다.
상수 인터페이스 안티패턴의 예시
// 상수 인터페이스 예시
public interface CarConstants {
int MAX_SPEED = 240;
String ERROR_MESSAGE = "Operation not allowed";
}
// 위의 상수 인터페이스는 이런식으로 사용될 수 있다.
public class SportsCar implements CarConstants {
public void drive() {
System.out.println("Driving at speed: " + MAX_SPEED);
}
}
이러한 패턴은 상수 인터페이스 안티패턴이다 - 절대 사용하면 안된다.
안티패턴을 사용하지 않고 상수를 공유하기 위한 더 좋은 방법은 열거형(enum)을 사용하거나, 클래스 내부에 상수를 정의하는 것이다.
public final class CarUtils {
private CarUtils() {} // 인스턴스화 방지
public static final int MAX_SPEED = 240;
public static final String ERROR_MESSAGE = "Operation not allowed";
}
결론 : 인터페이스는 타입을 정의하는 용도로만 사용해야 한다. 상수 공개용 수단으로 사용하지 말자.
interface MyInterface {
// 추상 메서드
void abstractMethod();
// 디폴트 메서드
default void defaultMethod() {
System.out.println("This is a default method.");
}
}
// MyInterface를 구현하는 클래스
class MyClass implements MyInterface {
@Override
public void abstractMethod() {
System.out.println("Abstract method implementation.");
}
}
public class Main {
public static void main(String[] args) {
MyClass myClass = new MyClass();
myClass.abstractMethod(); // "Abstract method implementation." 출력
myClass.defaultMethod(); // "This is a default method." 출력
}
}
과거에는 모든 인터페이스의 메서드가 추상 메서드 이어야 했지만 이제는 디폴트 메서드의 도입으로 인해 인터페이스에 메서드의 기본 구현을 제공할 수 있게 되었다. 기존 인터페이스에 메서드를 추가하는 길이 열렸지만 모든 기존 구현체들과 매끄럽게 연동되리라는 보장은 없다. 디폴트 메서드는 범용적이라 대부분 상황에서 잘 작동한다.
하지만 생각할 수 있는 모든 상황에서 불변식을 해치지 않는 디폴트 메서드를 작성하기란 어렵다.
즉 디폴트 메서드라는 도구가 생겼더라도 인터페이스를 설계할 때는 세심한 주의를 기울여야 한다. 디폴트 메서드는 꼭 필요한 경우가 아니면 기존 인터페이스에 디폴트 메서드로 새 메서드를 추가하는일을 피해야 한다. 추가할경우 디폴트 메서드가 기준 구현체들과 충돌하는지도 체크해야 한다. 디폴트 메서드는 인터페이스로부터 메서드를 제거하거나 기존 메서드의 시그니처를 수정하는 용도가 아니다.새로운 인터페이스를 만들 경우 표준적인 메서드 구현을 제공할때는 아주 유용한 수단이다.
새 인터페이스라면 릴리스 전 반드시 테스트를 거쳐야 한다. 최소 세가지는 구현해봐야 한다. 각 인터페이스의 인스턴스를 다양한 작업에 활용하는 클라이언트도 여러 개 만들어봐야 한다. 릴리스하기 전, 즉 바로잡을 기회가 아직 남아있을 때 결함을 찾아내야 한다. 인터페이스를 릴리스한 후라도 결함을 수정하는게 가능한 경우도 있지만 어려운 방법이니 피하자.
자바가 제공하는 다중 구현 메커니즘은 인터페이스와 추상 클래스 두가지이다. 둘의 가장 큰 차이는
추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다는 점이다. 자바는 단일 상속만 지원하니, 추상 클래스 방식은 새로운 타입을 정의하는데 큰 제약을 안게되는 셈이다.
반면 인터페이스가 선언한 메서드를 모두 정의하고 그 일반 규약을 잘 지킨 클래스라면 다른 어떤 클래스를 상속했든 같은 타입으로 취급된다.
다중 구현 메커니즘 : 한 클래스가 둘 이상의 인터페이스를 구현할 수 있는 기능
인터페이스의 장점 :
- 인터페이스는 기존 클래스에도 손쉽게 새로운 인터페이스를 구현해 넣을 수 있다.
- 인터페이스로는 계층구조가 없는 타입 프레임워크를 만들 수 있다.
- 인터페이스는 기능을 향상시키는 안전하고 강력한 수단이 된다.
결론 : 일반적으로 다중 구현용 타입으로는 인터페이스가 가장 적합하다. 복잡한 인터페이스라면 구현하는 수고를 덜어주는 골격 구현을 함께 제공하는 방법이 좋다. 골격 구현은 가능한 한 인터페이스의 디폴트 메서드로 제공하여 그 인터페이스를 구현한 모든곳에서 활용하도록 하는 것이 좋다. 가능한 한 이라고 한 이유는, 인터페이스에 걸려 있는 구현상의 제약 때문에 골격 구현을 추상 클래스로 제공하는 경우가 더 흔하기 때문이다.
메서드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다. 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기 사용 패턴) 문서로 남겨야 한다.
자기 사용 패턴 : 클래스 내부의 한 메서드가 같은 클래스의 다른 메서드를 호출하는 패턴. 자기 사용 패턴이 상속과 결합될 때 문제가 발생할 수 있다. 슈퍼클래스가 자신의 메서드를 호출할 때 실제로 실행되는 메서드가 서브클래스에서 오버라이드된 메서드일 수 있다.
아래는 API 문서의 메서드 설명 예시이다.
설명 끝 부분에 "Implementation Requirements"로 시작되는 부분은 그 메서드의 내부 동작 방식을 설명하는 곳이다.
public boolean remove(Object o)
주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다(선택적 동작). 더 정확
하게 말하면, 이 컬렉션 안에 'Object.equals(o, e)가 참인 원소' e가 하나 이상 있다면 그
중 하나를 제거한다. 주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경
됐다면) true를 반환한다.
Implementation Requirements: 이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록
구현되었다. 주어진 원소를 찾으면 반복자의 remove 메서드를 사용해 컬렉션에서 제거한다.
이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가
remove 메서드를 구현하지 않았다면 UnsupportedOperationException을 던지니 주의하자.
설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 알 수 있다. iterator 메서드로 얻은 반복자의 동작이 remove메서드의 동작에 주는 영향도 설명했다. 이런식으로 클래스를 안전하게 상속할 수 있도록 하려면 내부 구현 방식을 설명해주어야 한다.
하지만 내부 매커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다. 효율적인 하위 클래스를 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected메서드 형태로 공개해야 할 수도 있다.
훅 메서드 : 상위 클래스에서 정의되지만, 기본적으로 아무 작업도 수행하지 않거나 기본 동작만 수행하는 메서드이다. 이 메서드들을 오버라이드(재정의)함으로써, 상위 클래스의 동작 과정 중 특정 지점에서 원하는 동작을 알고리즘 흐름을 변경하지 않으면서 추가하거나 변경할 수 있다.
public abstract class Beverage {
// 이 메서드는 알고리즘의 템플릿을 제공합니다.
// 즉, 음료를 준비하는 일련의 단계를 정의합니다.
final void prepareRecipe() {
boilWater();
brew();
pourInCup();
if (customerWantsCondiments()) { // 훅(hook). 하위 클래스에서 이 메서드를 오버라이드할 수 있습니다.
addCondiments();
}
}
abstract void brew();
abstract void addCondiments();
void boilWater() {
System.out.println("물을 끓입니다.");
}
void pourInCup() {
System.out.println("컵에 붓습니다.");
}
// 이것이 바로 훅 메서드입니다. 기본적으로 이 메서드는 'true'를 반환하지만,
// 하위 클래스에서 이 메서드의 동작을 변경할 수 있습니다.
protected boolean customerWantsCondiments() {
return true;
}
}
// 하위 클래스 예시: 커피 준비하기
public class Coffee extends Beverage {
@Override
void brew() {
System.out.println("필터를 통해 커피를 우려냅니다.");
}
@Override
void addCondiments() {
System.out.println("설탕과 우유를 추가합니다.");
}
@Override
protected boolean customerWantsCondiments() {
// 여기서는 사용자가 조미료(설탕, 우유)를 추가하길 원하는지에 대한 로직을 구현할 수 있습니다.
// 예를 들어, 사용자의 입력을 받아 결정할 수 있습니다.
// 이 예제에서는 단순화를 위해 항상 'true'를 반환하도록 합니다.
return true;
}
}
이처럼 hook 메서드를 잘 활용하면 내부 동작 과정 중 중간에 끼어들어 수정할 수 있다. 어떤 메서드를 protected로 노출해야 할지는 그냥 심사숙고해서 잘 예측해보고, 실제로 하위 클래스를 만들어 보는것이 최선이다. 반드시 하위 클래스를 만들어 검증해야 한다.
클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스에 안기는 제약도 상당하기 때문에 절대 가볍게 생각하고 정할 문제가 아니다. 상속을 허용하는게 명확히 정당한 상황이 있고, 아닌 상황이 있다. 이런 문제를 해결하는 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다.
결론 : 상속용 클래슬르 설계하려면 클래스 내부에서 스스로를 어떻게 사용하는지(자기 사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화 한것은 그 클래스가 쓰이는 한 반드시 지켜야 한다. 그렇지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다. 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야할 수도 있다. 그러니 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 낫다. 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.