다른분들 코드리뷰를 보다가 static 메서드를 사용하는거를 보고 "static 메서드를 사용하는 이유가 뭔지 알 수 있을까요?" 라는 리뷰가 달렸는데 제가 관련자는 아니지만 만약 내가 답변이 남긴다면 뭐라고 남길가 해봤는데 논리적인 근거를 가지고 명확하게 설명을 못하는 저 자신을 발견했습니다. 저도 옛날에 공부하고 어느덧 다 휘발되고 관성적으로 쓰고 있었습니다. 마치 한국어 문법 모르는 부분을 사용하는 데 느낌적으로 이게 맞다라고 생각하고 쓰듯이 말이죠 그래서 이참에 정리해보았습니다.
혹시 여러분들은 잘 설명하실 수 있나요?
🚀 정적 팩토리 메서드
정적 팩토리 메서드(Static Factory Method)는 객체의 생성 방식을 단순화하고 제어하기 위한 패턴으로, 클래스의 생성자를 직접 호출하지 않고, 대신 해당 클래스에서 제공하는 메서드를 통해 객체를 생성하는 방법입니다. 이 메서드는 static 키워드로 정의되며, 클래스 외부에서 객체를 생성할 때 유연성과 가독성을 높여줍니다.
🧶 정적 팩토리 메서드의 기본 구조
`Car`클래스는 `Car`객체를 생성할 수 있는 정적 팩토리 메서드`of`를 제공합니다.
이 메서드를 사용해 객체를 생성할 수 있습니다.
public class Car {
private String model;
private String color;
// private 생성자
private Car(String model, String color) {
this.model = model;
this.color = color;
}
// 정적 팩토리 메서드
public static Car of(String model, String color) {
return new Car(model, color);
}
// 기타 메서드
public String getModel() {
return model;
}
public String getColor() {
return color;
}
}
사용 예시
public class Main {
public static void main(String[] args) {
// 정적 팩토리 메서드를 사용한 객체 생성
Car car = Car.of("Tesla Model S", "Red");
System.out.println("Car Model: " + car.getModel());
System.out.println("Car Color: " + car.getColor());
}
}
🔥 정적 팩토리 메서드의 장점
1️⃣ 이름을 가질수 있다.
2️⃣ 호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
3️⃣ 다양한 하위 타입 반환 가능
4️⃣ 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
5️⃣ 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도된다.
그럼 이에 대해 하나씩 알아보겠습니다.
1️⃣ 이름을 가질 수 있다.
정적 팩토리 메서드의 가장 큰 장점 중 하나는 메서드 이름을 통해 객체가 어떻게 생성되는지 명확히 알 수 있다는겁니다. `new`로 객체를 생성할 때는 그 의도가 명확하지 않을 수 있지만, 정적 팩토리 메서드는 이름으로 의도를 드러낼 수 있습니다.
public class Patient {
private String name;
private int age;
private boolean urgent;
private boolean minor;
public Patient(String name, int age, boolean urgent) {
this.name = name;
this.age = age;
this.urgent = urgent;
}
public Patient(String name, int age, boolean minor) {
this.name = name;
this.age = age;
this.minor = minor;
}
}
자바에서는 위와 같이 완전히 동일한 시그니처의 생성자가 같이 있을 수 없습니다.
물론 매개변수 순서를 바꿔서 사용하면 가능하긴 합니다. 하지만 이런식으로 한다면 이 코드를 사용하는 입장에서는 구분하기가 어려워집니다. 그렇지만 생성자를 만들기 위해서는 이름을 똑같게 만들어야 하기 때문에 어쩔수 없습니다. 이때 바로 사용할 수 있는데 바로 정적 팩터리 메서드입니다.
정적 팩터리 메서드를 사용하면 아래와 같이 이름을 붙여서 더 구분감있게 사용할 수 있습니다.
public class Patient {
private String name;
private int age;
private boolean urgent;
private boolean minor;
public static Patient urgentPatient(String name, int age) {
Patient patient = new Patient();
patient.name = name;
patient.age = age;
patient.urgent = true;
return patient;
}
public static Patient minorPatient(String name, int age) {
Patient patient = new Patient();
patient.name = name;
patient.age = age;
patient.minor = true;
return patient;
}
}
이런식으로 이름을 가질 수 있는 정적 팩터리 메서드를 사용하면 생성자의 시그니처가 중복되는 경우에 관해서 제약이 없기 때문에 정적 팩터리 메서드를 고려해보는 것도 좋습니다. 아울러 이름까지 가질 수 있으니 전달하고자 하는 바를 명확하게 전달할 수도 있습니다.
2️⃣ 호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
정적 팩터리 메서드를 사용하면 호출될 때마다 객체를 새로 생성하지 않기 때문에 메모리 사용양이 줄어늘고 이로인해 성능을 향상시킬 수 있으며 객체 생성 비용이 높은 경우에는 특이 도움이됩니다.
그리고 언제 어느 인스턴스를 살아 있게 할지도 철저히 통제가 가능하고 이런 클래스를 인스턴스 통제 클래스라고 합니다.
인스턴스를 통제하면 아래와 같은 장점이 존재합니다.
- 클래스를 싱글턴으로 만들 수 있다. (Item 3)
- 인스턴스화 불가로 만들 수도 있다. (Item 4)
- 불변값 클래스에서 동치인 인스턴스가 단 하나뿐임을 보장할 수 있다. (Item 5)
카드로 예시를 들어보겠습니다.
미리 정의된 카드 객체를 캐싱하고 정적 팩토리 메서드를 사용하여 객체 생성을 제어하는 방법을 사용해보겠습니다.
카드 덱의 각 카드를 미리 생성해놓고, 필요할 때 캐싱된 카드 객체를 반환하는 방식으로 설계합니다.
이 방법을 통해 불필요한 객체 생성을 피하고 성능을 최적화할 수 있습니다.
import java.util.HashMap;
import java.util.Map;
import java.util.stream.IntStream;
public class Card {
// 카드의 숫자와 문양을 정의
public enum Suit {
HEARTS, DIAMONDS, CLUBS, SPADES
}
private static final int MIN_CARD_NUMBER = 1;
private static final int MAX_CARD_NUMBER = 13;
// 미리 생성된 카드 객체를 캐싱하기 위한 맵
private static final Map<String, Card> cardCache = new HashMap<>();
static {
// 각 문양에 대해 1부터 13까지의 카드 생성
for (Suit suit : Suit.values()) {
IntStream.rangeClosed(MIN_CARD_NUMBER, MAX_CARD_NUMBER)
.forEach(number -> cardCache.put(generateKey(suit, number), new Card(suit, number)));
}
}
private final Suit suit;
private final int number;
// 생성자를 private으로 설정하여 외부에서 생성하지 못하게 함
private Card(Suit suit, int number) {
this.suit = suit;
this.number = number;
}
// 정적 팩토리 메서드: 캐싱된 카드 객체 반환
public static Card of(Suit suit, int number) {
if (number < MIN_CARD_NUMBER || number > MAX_CARD_NUMBER) {
throw new IllegalArgumentException("Invalid card number: " + number);
}
return cardCache.get(generateKey(suit, number));
}
// 카드의 고유 키 생성 (문양과 숫자로 식별)
private static String generateKey(Suit suit, int number) {
return suit.name() + "_" + number;
}
// 카드 정보 출력 메서드
@Override
public String toString() {
return suit + " " + number;
}
}
사용 예시
public class Main {
public static void main(String[] args) {
// 정적 팩토리 메서드를 통해 카드를 생성 (캐싱된 객체 반환)
Card card1 = Card.of(Card.Suit.HEARTS, 1); // Hearts 1
Card card2 = Card.of(Card.Suit.SPADES, 13); // Spades 13
Card card3 = Card.of(Card.Suit.HEARTS, 1); // 이미 생성된 Hearts 1 객체
System.out.println(card1); // 출력: HEARTS 1
System.out.println(card2); // 출력: SPADES 13
System.out.println(card3); // 출력: HEARTS 1
// card1과 card3가 동일한 객체인지 확인 (캐싱을 통한 동일 객체 반환)
System.out.println(card1 == card3); // 출력: true
}
}
3️⃣ 반환 타입의 하위 타입 객체를 반환할 수 있다. &
4️⃣ 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
이 두 가지 장점은 함께 설명하면 이해하기 쉽습니다.
하위 타입 객체를 반환할 수 있는 능력은 반환할 객체의 클래스를 자유롭게 선택할 수 있게 하는 엄청난 유연성을 선물합니다.
다양한 언어서비스 관련 코드로 예를 들어보겠습니다.
보통 생성자만을 사용한다면 아래와 같이 사용할겁니다.
public class KoreanHelloService implements HelloService {
@Override
public String hello() {
return "안녕하세요";
}
}
public class EnglishHellowService implements HelloSerice {
@Override
public String hello() {
return "hello";
}
}
이런식으로 생성자를 사용하면 딱 `English Hello Serice`나 `Koeran Hello Serice`만 가져올 수 있습니다.
왜냐하면 해당하는 클래스의 인스턴스만 만들어주기 때문이죠
하지만 정적 팩터리 메서드를 사용하면 리턴하는 반환타입에 얼마든지 호환 가능한 다른 타입들의 클래스 또는 인스턴스를 리턴이 가능하게 해줍니다. 여기서 네번째 장점인 매개변수에 따라서 각기 다른 클래스의 객체를 반환할 수도 있다는 것이 나옵니다.
아래의 코드는 정적 팩터리 메서드를 사용한 예시입니다.
public class HelloServiceFactory {
public static HelloService of(String lang) {
if (lang.equals("ko")) {
return new KoreanHelloService();
} else if (lang.equals("en")) {
return new EnglishHelloService();
} else {
throw new IllegalArgumentException("지원하지 않는 언어입니다.");
}
}
}
사용할 때는 다음과 같이 간단하게 호출할 수 있습니다.
HelloService service = HelloServiceFactory.of("ko");
System.out.println(service.hello()); // "안녕하세요" 출력
이렇게 하면 매개변수에 따라 다른 클래스의 객체를 반환할 수 있고, 반환 타입은 `HelloService` 인터페이스이므로 하위 타입의 객체를 반환할 수 있습니다.
❗️ 자바 8 이후의 변화: 인터페이스의 정적 메서드
자바8 전에는 인터페이스에 정적 메서드를 선언할 수 없었지만 자바 8부터는 인터페이스가 정적 메서드를 가질 수 없다는 제한이 풀렸기기 때문에 인스턴스화 불가 동반 클래스를 둘 이유가 없어졌습니다. 동반 클래스에 두었던 public 정적 멤버들 상당수를 그냥 인터페이스 자체에 두면 됩니다.
public interface HelloService {
static HelloSerive of(String lang) {
if(lang.equals("ko") {
return new KoreanHelloSerivce();
}
if(lang.equals("en"){
return new EnglishHelloSerice();
}
}
}
❓인스턴스화 불가 동반 클래스
자바에서 인스턴스화 불가 동반 클래스의 개념을 이해하기 위해 먼저 "인스턴스화 불가"와 "동반 클래스"라는 용어를 먼저 알아야 합니다.
📌 인스턴스화 불가
인스턴스화 불가란, 클래스의 객체를 생성할 수 없음을 의미합니다. 즉, new 키워드를 사용하여 클래스의 인스턴스를 만들 수 없는 상태를 말합니다. 자바에서는 이를 추상 클래스나 인터페이스를 통해 구현할 수 있습니다.
📌 동반 클래스
동반 클래스는 어떤 클래스와 밀접하게 연관된 기능을 제공하는 클래스를 의미합니다. 특히, 정적 메서드나 정적 변수를 이용해 클래스 레벨에서 사용할 수 있는 기능이나 데이터를 제공합니다. 자바에서는 이 역할을 인터페이스나 추상 클래스가 할 수 있지만, 객체를 생성할 수 없어 직접적인 방법으로는 사용할 수 없습니다.
📌 자바8 이전
자바 8 이전에는 인터페이스에 정적 메서드를 선언할 수 없었습니다. 따라서, 클래스와 관련된 공통의 정적 메서드나 상수를 관리하기 위해 인스턴스화할 수 없는 클래스(예를 들어, 추상 클래스에 정적 메서드를 추가하는 방법)를 사용하는 경우가 많았습니다. 이런 클래스를 "인스턴스화 불가 동반 클래스"라고 할 수 있습니다.
public class CalculatorUtils { private CalculatorUtils() { } // 인스턴스화 방지 public static int add(int a, int b) { return a + b; } public static int subtract(int a, int b) { return a - b; } }
여기서 `CalculatorUtils` 클래스는 인스턴스화할 수 없고, `CalculatorUtils.add(5, 3)` 처럼 직접 메소드를 호출해 사용합니다.
자바 8부터는 인터페이스 내에 정적 메소드와 디폴트 메소드를 선언할 수 있게 되었습니다. 이로 인해, 정적 메소드를 포함해야 하는 경우에 인스턴스화 불가능한 동반 클래스를 따로 만들 필요가 크게 줄어들었습니다.
public interface CalculatorOperations {
static int add(int a, int b) {
return a + b;
}
static int subtract(int a, int b) {
return a - b;
}
}
이 방식으로, 우리는 별도의 인스턴스화 불가능한 클래스를 만들지 않고도, 필요한 계산 기능을 제공할 수 있게 되었습니다. 사용 방식은 이전과 동일하게 `CalculatorOperations.add(5, 3)`처럼 직접 호출하면 됩니다.
자바 8 이전에는 정적 메서드를 모아두기 위해 인스턴스화할 수 없는 동반 클래스를 만드는 방식이 필요했습니다. 하지만 자바 8 이후에는 인터페이스에 정적 메서드를 직접 추가할 수 있게 되면서, 이러한 필요성이 줄어들었습니다. 이는 코드를 더 간결하게 만들고, 유지보수를 쉽게 하는 데 도움을 줍니다.
여기서 `createAnimal()` 메서드는 `Dog` 또는 `Cat` 같은 다양한 하위 클래스를 반환할 수 있습니다.
이렇게 하면 클라이언트 코드가 구체적인 구현 클래스에 의존하지 않게 됩니다.
public class AnimalFactory {
public static Animal createAnimal(String type) {
if ("dog".equals(type)) {
return new Dog();
} else if ("cat".equals(type)) {
return new Cat();
}
return null;
}
}
5️⃣ 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도된다.
이 장점은 유연한 설계를 가능하게 한다는 점에서 매우 강력합니다. 즉, 정적 팩토리 메서드를 작성할 때, 실제로 반환할 구체적인 클래스가 나중에 추가되거나 변경될 수 있는 상황에도, 코드의 구조를 미리 잡아놓을 수 잇다는 의미입니다.
예를 들어봅시다. 우리가 `Payment`라는 인터페이스를 설계하고, 이 인터페이스를 구현하는 구체적인 결제 방식(카드 결제, 계좌 이체 등)은 나중에 추가될 수 있다고 가정해봅시다.
// 결제 관련 인터페이스
public interface Payment {
void pay(int amount);
}
// 정적 팩토리 메서드를 포함한 PaymentFactory 클래스
public class PaymentFactory {
// 정적 팩토리 메서드: 결제 방식을 반환하지만, 구체적인 클래스는 나중에 정의 가능
public static Payment getPaymentMethod(String type) {
switch (type) {
case "creditCard":
return new CreditCardPayment(); // 나중에 정의될 클래스
case "bankTransfer":
return new BankTransferPayment(); // 나중에 정의될 클래스
default:
throw new IllegalArgumentException("Unknown payment method");
}
}
}
여기서 중요한 점은 `CreditCardPayment`와 `BankTransferPayment` 같은 구체적인 클래스는 아직 존재하지 않을 수도 있다는 것입니다. 우리는 나중에 이러한 클래스를 정의하게 될 수 있습니다.
나중에 추가될 수 있는 클래스들
// 나중에 추가되는 클래스: 신용카드 결제
class CreditCardPayment implements Payment {
@Override
public void pay(int amount) {
System.out.println("Paid " + amount + " using Credit Card.");
}
}
// 나중에 추가되는 클래스: 계좌 이체 결제
class BankTransferPayment implements Payment {
@Override
public void pay(int amount) {
System.out.println("Paid " + amount + " using Bank Transfer.");
}
}
사용 예시
public class Main {
public static void main(String[] args) {
// 나중에 정의된 클래스들을 통해 결제 방식 선택
Payment payment1 = PaymentFactory.getPaymentMethod("creditCard");
payment1.pay(1000); // 출력: Paid 1000 using Credit Card.
Payment payment2 = PaymentFactory.getPaymentMethod("bankTransfer");
payment2.pay(2000); // 출력: Paid 2000 using Bank Transfer.
}
}
정리
1️⃣ 초기 설계 단계
`PaymentFactory` 클래스의 `getPaymentMethod()` 정적 팩토리 메서드는 Payment 인터페이스를 반환합니다. 이때 구체적인 구현 클래스인 `CreditCardPayment`나 `BankTransferPayment`는 존재하지 않아도 됩니다. 즉, 우리는 인터페이스나 추상 클래스만 정의한 채로 나중에 구체적인 클래스가 작성될 것이라고 가정할 수 있습니다.
2️⃣ 클래스가 나중에 추가되거나 변경 가능
실제 구체적인 구현은 나중에 작성할 수 있기 때문에, 코드가 처음부터 특정 클래스에 종속되지 않습니다. 이렇게 작성된 정적 팩토리 메서드는 나중에 구현 클래스가 추가되거나 수정되어도 그대로 사용할 수 있습니다. 즉, 구현체가 바뀌더라도 `getPaymentMethod()` 메서드를 사용하는 코드는 전혀 영향을 받지 않습니다.
3️⃣ 유연성
이 구조는 특히 라이브러리 설계나 플러그인 아키텍처를 설계할 때 유용합니다. 라이브러리 사용자에게는 Payment 인터페이스와 PaymentFactory만 제공하고, 나중에 사용자가 자신만의 Payment 구현체를 추가할 수 있게 설계할 수도 있습니다.
❗️ 정적 팩토리 메서드의 단점
1️⃣ 상속이 불가능
정적 팩토리 메서드는 상속과 호환되지 않는다는 단점이 있습니다. 왜냐하면 정적 메서드는 클래스에 귀속되기 때문에 하위 클래스에서 재정의(override)할 수 없기 때문이죠. 이는 상속을 통해 기능을 확장하려는 경우 불편함을 줄 수 있습니다.
이 경우, `Dog` 클래스는 animal`의 createAnimal()`을 재정의하지 못해, `Dog`를 생성할 때 정적 팩토리 메서드를 사용할 수 없습니다. 따라서 상속을 사용하는 구조에서는 제한적일 수 있습니다.
public class Animal {
public static Animal createAnimal() {
return new Animal();
}
}
public class Dog extends Animal {
// Animal 클래스의 createAnimal 메서드를 오버라이드 불가
// 대신에 별도의 팩토리 메서드를 만들어야 함
}
2️⃣ 코드가 길어질 수 있음
객체 생성을 정적 메서드로 감싸다 보면, 특히 다양한 객체를 반환하는 경우 메서드가 많아져 코드가 다소 복잡해질 수 있습니다. 이런 경우에는 단순히 `new`를 사용하는 것보다 가독성이 떨어질 수 있습니다.
아래 코드에서는 단순히 `new Car()`를 사용하는 것보다 객체 생성 메서드가 많아지면서 코드가 다소 복잡해질 수 있습니다.
특히 여러 타입의 객체를 생성할 때, 이처럼 많은 정적 메서드가 필요할 수 있습니다.
public class Car {
private String model;
private String color;
public Car(String model, String color) {
this.model = model;
this.color = color;
}
// 여러 개의 정적 팩토리 메서드
public static Car createSportsCar() {
return new Car("Sports", "Red");
}
public static Car createSUV() {
return new Car("SUV", "Black");
}
public static Car createTruck() {
return new Car("Truck", "Blue");
}
}
📚 Reference
- Effective Java Item 1
'JAVA > 개념' 카테고리의 다른 글
Collection.forEach vs Stream.forEach (1) | 2024.11.20 |
---|---|
stream.forEach (feat. 종단 연산) (1) | 2024.11.18 |
스트림의 중간 연산, 종단 연산 (0) | 2024.11.18 |