프로그래밍
이란 마치 작은 세계를 창조하는 것과 같다고 생각했다. 그 세계 속에서 객체
는 주인공이자 배역, 그리고 어떤 면에서는 작은 조력자이기도 하다. 그렇기에 객체
가 어떻게 태어나고, 어떻게 움직이고, 어떻게 사라지는지를 고민하는 것은 자바 프로그래밍을 하는데에 있어서 객체
의 생명주기
에 대한 고민이 절로 떠오르게 된다.
💡 객체의 생명주기란?
객체가 생성된 후부터 폐기될 때 까지의 기간으로, 객체가 생성되어 메모리에 올라가는 시점부터 더이상 사용하지 않아 파괴되면서 메모리에서 사라지게 되는 기간을 말한다.
그렇다면 객체의 생명주기가 왜 중요할까?
결론부터 말하면 자바에서 객체 생성과 파괴를 고려해야 하는 이유는 프로그램의 성능, 메모리 관리, 코드 가독성, 유지보수성 등 여러 측면에서 중요한 영향을 미친다.
차근차근 알아보자.
객체 생성은 비싸고 비용이 많이 드는 작업이다. 64비트 JDK에서 객체는 12바이트의 헤더, 8바이트의 배수로 이뤄지기 때문에 최소 16바이트를 소비한다. 또한 객체 참조에도 메모리를 소비한다. 수치로만 봤을 때는 적은 비용이라고 생각이 들지만, 전체 프로그램을 생각한다면 무시할 수 없는 비용이다. 예를 들어서, primitive int
는 4바이트에 불과하지만, Integer
객체를 사용할 때는 16바이트(객체) + 8바이트(참조)로 5배나 되는 메모리를 사용하게 된다.
메모리 누수란 더 이상 사용하지 않는 객체들이 힙(Heap)
영역에 남아있어 불필요하게 메모리를 차지하고 있는 상황을 의미한다. 사용하지 않는 객체들이 계속 메모리에 상주하고 있으면 성능 저하를 야기하고, 최악의 상황에는 Out of Memory Error
를 뱉어내게 되므로 주의가필요하다.
자바에서는 위와 같은 메모리 누수를 방지하기 위해서 Garbage Collection
을 도입하여 더 이상 참조되지 않는 객체를 탐지하여 자동으로 메모리에서 해제해주는 런타임 시스템이 있다. 보기에는 만능인 것처럼 보이지만, 가비지 컬렉션도 결국 하나의 프로그램
이기 때문에 가비지 컬렉션이 더 자주 발생하도록 유발된다면 프로그램의 전체적인 성능에 영향을 미칠 수 밖에 없다. 특히 가비지 컬렉션으로 해제되지 않는 자원을 가진 객체 (InputStream, Socket) 등을 가질 경우에는 명시적으로 자원을 해제하는 것이 필요하다.
인스턴스가 생성될 때마다 호출되는 인스턴스 초기화 메서드
로 객체가 생성될 때마다 초기화 해주는 메서드로 생각하면 된다.
public class Person {
private String name;
private String major;
public Person(String name, String major) {
this.name = name;
this.major = major;
}
public static void main(String[] args) {
Person person = new Person("mark", "IT");
}
}
객체 생성의 역할을 하는 클래스 메서드
로 말로 설명하기는 어려운 느낌이 있으므로 간단한 예시를 보자.
public class Person {
private String name;
private String major;
private Person() {}
public static Person of(String name, String major) {
Person person = new Person();
person.name = name;
person.major = major
return person;
}
public static void main(String[] args) {
Person person = Person.of("mark", "IT");
}
}
위의 코드를 보면 정적 팩토리 메서드를 사용해서 new
키워드를 사용해 생성자를 직접 호출하지 않고 static method
를 이용해 객체를 생성하여 객체를 생성했다.
왜 굳이 위와 같이 static method
를 사용해서 객체를 생성할까? 오히려 위의 생성자 방식보다 코드가 더 복잡해 보이기도 한다. 하지만, 이러한 정적 팩토리 메서드 방식에는 여러가지 장점이 있다.
자바의 생성자의 이름은 클래스의 이름과 반드시 같아야 한다.
이는, 자바에서 정의하는 생성자의 특징이나 제한사항으로 반드시 지켜져야 한다.
하지만, 정적 팩토리 메서드는 객체를 생성할 때 메서드에 이름을 지어줄 수 있다!
이게 무슨 의미일까? 아래 예제 코드를 보자.
public class Laptop {
private String brand;
private int price;
public Laptop(String brand, int price) {
this.brand = brand;
this.price = price;
}
public Laptop(String brand) {
this.brand = brand;
}
public Laptop(int price) {
this.price = price;
}
public static void main(String[] args) {
Laptop laptopWithBoth = new Laptop("SAMSUNG", 123_450);
Laptop laptopWithBrand = new Laptop("LG");
Laptop laptopWithPrice = new Laptop(456_780);
}
}
위의 코드는 생성자 방식을 이용해서 객체를 생성하는 코드이다.
brand
와 price
모두 초기화 값을 주고 만드는 객체와 각각의 초기화 값만 주고 만드는 객체로 총 3개의 객체가 만들어졌다. 해당 코드의 큰 특징은 객체를 만들 때 모두 new Laptop
생성자를 사용해서 만들었다는 것이다.
이렇게 생성된 객체들은 어떤 의도인지, 어떤 의미를 가지는지 파악하기가 어렵다는 단점이 있다.
이번엔 정적 팩토리 메서드를 사용한 방법을 보자.
public class Laptop {
private String brand;
private int price;
private Laptop() {}
public static Laptop of(String brand, int price) {
Laptop laptop = new Laptop();
laptop.brand = brand;
laptop.price = price;
return laptop;
}
public static Laptop withBrand(String brand) {
Laptop laptop = new Laptop();
laptop.brand = brand;
return laptop;
}
public static Laptop withPrice(int price) {
Laptop laptop = new Laptop();
laptop.price = price;
return laptop;
}
public static void main(String[] args) {
Laptop laptopWithBoth = Laptop.of("SAMSUNG", 123_450);
Laptop laptopWithBrand = Laptop.withBrand("LG");
Laptop laptopWithPrice = Laptop.withPrice(456_780);
}
}
위의 코드를 보면 객체 생성을 할 때 호출하는 메서드가 이름 을 가진다는 것을 알 수 있다.
of
, withBrand
, withPrice
의 이름을 가짐으로써 생성된 객체들이 어떤 의도로 생
성 되었는지 명확하게 알 수 있다.
추가로, 생성자는 "하나의 시그니처로 생성자를 하나만 만들 수 있다" 라는 제약이 있다.
역시 무슨 의미인지 모르겠으므로 아래 예제 코드를 보자.
public class Laptop {
private String brand;
private String model;
private int price;
public Laptop(String brand) {
this.brand = brand;
}
public Laptop(String model) {
this.model = model;
}
}
해당 코드는 컴파일 에러
가 발생한다.
왜일까? 그 이유는 매개변수 타입과 수가 같은 생성자를 중복해서 선언했기 때문이다.
사람의 입장에서는 같은 String
타입이라도 brand와 model의 차이점을 알고있지만,
컴퓨터의 입장에서는 brand와 model 상관없이 같은 String
타입으로 보이기 때문에 생성자가 호출되면 어떤 생성자를 호출할지 모르기 때문이다.
하지만, 정적 팩토리 메서드 방법에서는 어떨까?
public class Laptop {
private String brand;
private String model;
private int price;
private Laptop(){}
public static Laptop withBrand(String brand) {
Laptop laptop = new Laptop();
laptop.brand = brand;
return laptop;
}
public static Laptop withModel(String model) {
Laptop laptop = new Laptop();
laptop.model = model;
return laptop;
}
}
해당 코드는 당연하게도 컴파일 에러
가 발생하지 않는다.
brand와 model 을 넣어주는 생성자 메서드의 이름이 각각 다르기 때문에 선언된 메서드 이름을 호출하여 원하는 필드에 값을 넣어 객체를 생성하는 것이 가능하다.
📑 정리
- 객체 생성 정적 메서드에 이름을 부여하여 코드 가독성을 높일 수 있다.
- 자바에서 제공하는 생성자의 매개변수 제약 조건이 사라진다.
정적 팩토리 메서드는 객체의 생성을 책임지고 있으므로 인스턴스의 생성 방식에 대해서 관리할 수 있다. 이 말은 다음과 같은 의미를 말한다.
- 항상 새로운 객체가 반환되도록 할 수 있다.
- 새로운 객체를 생성을 금지하고 항상 같은 객체만 반환하게 할 수 있다.
생성자를 사용하면 new
키워드가 붙기 때문에 항상 새로운 객체를 생성한다. 하지만 Collections
처럼 클래스 메서드만 이용하는 것과 같이 객체를 만들지 않도록 하거나 또는 객체를 오로지 하나만 쓸 수 있도록 만들고 싶은 경우에 정적 팩토리 메서드는 빛을 발한다.
먼저 객체화 불가 클래스 예제 코드를 보자.
public class Collections {
private Collections() {
}
@SuppressWarnings("unchecked")
public static <T extends Comparable<? super T>> void sort(List<T> list) {
list.sort(null);
}
@SuppressWarnings({"unchecked", "rawtypes"})
public static <T> void sort(List<T> list, Comparator<? super T> c) {
list.sort(c);
}
...
}
다음 코드는 익숙하게 사용했던 java.util 내의 Collections 유틸리티 클래스 코드 일부이다. 유틸리티 클래스는 주로 관련된 메서드 집힙을 static method
로 제공하여 특정 작업을 수행하는데 사용되는 클래스로 해당 클래스는 상태를 저장하지 않기 때문에 객체를 만들 필요가 없다.
다음은 객체를 단 하나만 생성하는 예제 코드를 보자.
public class Singleton {
private static Singleton INSTANCE = new Singleton();
private Singleton(){}
public static Singleton getInstance() {
return INSTANCE;
}
}
다음 코드는 static method
인 getInstance()
를 호출하면 항상 같은 객체를 반환하게 만드는 클래스 코드이다. 이 말은 무엇을 의미할까?
정적 팩토리 메서드는 객체 생성을 통제할 수 있다는 말이 된다.
정적 팩토리 메서드를 이용하면 객체 생성을 통제할 수 있는 클래스인 인스턴스 통제 클래스 (instance-controlled class)
를 만들 수 있고 단 하나의 객체만을 생성하고 사용할 수 있게 만드는 불변 클래스 (noninstantiable)
를 만들 수도 있다.
💡 인스턴스 통제 클래스 (instance-controlled class) 란?
특정 클래스의 인스턴스 생성을 제어하거나 관리하는 디자인 패턴을 나타내며, 이 디자인 패턴은 일반적으로 싱글톤(Singleton) 패턴과 관련이 있다.
💡 하위 타입이란? 불변 클래스 (noninstantiable) 란?
한번 생성되면 내부 상태가 변경되지 않는 클래스를 말한다. 즉, 객체의 상태(멤버 변수)를 수정할 수 없는 클래스를 의미한다.
📑 정리
- 불필요한 객체의 생성을 막을 수 있다.
- 상황에 따라서 객체 생성을 통제하는 것이 가능하다.
💡 하위 타입이란?
객체 지향 프로그래밍에서 상속 관계에서 나타나는 개념으로, 하위 타입은 부모 클래스 또는 인터페이스를 상속받은 클래스나 인터페이스를 의미한다.
이미지 출처
자기 자신이 아닌 반환할 객체의 클래스를 선택할 수 있다는건 클래스에서 만들어줄 객체의 클래스를 선택할 수 있는 유연함이 생긴다는 말과 같다.
아래 예제 코드를 보자.
public class Membership {
static Membership of(int point) {
if (point >= 120_000) {
return new VIP();
} else if (point >= 100_000){
return new Gold();
} else {
return new White();
}
}
}
class VIP extends Membership {
}
class Gold extends Membership {
}
class White extends Membership {
}
다음 코드는 Membership
클래스의 static method
를 호출하여
하위 타입인 VIP
, Gold
, White
객체를 만들어 반환하는 코드이다.
위 방식의 장점은 Membership
클래스 내부에서 하위 타입을 구현한 것도 아니고,
Membership
클래스는 하위 타입 생성을 연결해주는 역할만을 하기 때문에,
구현 로직은 숨길 수 있으면서 API를 경량화 할수 있기 때문에 난이도를 낮출 수 있다.
프로그래머의 입장에서 해당 메서드만 보고 객체를 가져올 것임을 알기 때문에,
굳이 하위 타입을 찾아볼 필요가 없게된다.
📑 정리
- 하위 타입의 객체를 유연하게 반환할 수 있다.
- 하위 타입의 구현체를 숨길 수 있다.
- API를 경량화 할 수 있다.
- 프로그래머의 API 사용 난이도를 낮춰줄 수 있다.
4번 내용은 3번 내용과 맥락이 같다고 볼 수 있다.
3번에서 봤던 예제 코드를 다시 살펴보자.
public class Membership {
static Membership of(int point) {
if (point >= 120_000) {
return new VIP();
} else if (point >= 100_000){
return new Gold();
} else {
return new White();
}
}
}
class VIP extends Membership {
}
class Gold extends Membership {
}
class White extends Membership {
}
Membership
클래스 내의 static method
로직을 보면,
매개변수로 주어진 point
값에 따라서 VIP
, Gold
, White
객체를 선택적으로 반환하고 있다는 것을 알 수 있다.
📑 정리
- 선택적으로 하위 타입의 객체를 반환할 수 있다.
말이 너무 어려우므로, 코드를 같이 살펴보자.
import java.util.Optional;
import java.util.ServiceLoader;
public interface Item {
String type();
static Item getInstance() {
ServiceLoader<Item> serviceLoader = ServiceLoader.load(Item.class);
Optional<Item> item = serviceLoader.findFirst();
return item.orElseThrow(() -> new IllegalStateException("No implementation found"));
}
}
class ImportantItem implements Item {
@Override
public String type() {
return "Important";
}
}
class NormalItem implements Item {
@Override
public String type() {
return "Normal";
}
}
다음 코드는 메서드와 반환할 타입만 정해두고 실제로 반환될 클래스는 나중에 구현하는게 가능하다. 협업을 진행할 때 인터페이스를 합의해서 만들고 static method
를 미리 만들어 두는 것이 가능할 것 같다.
예제 코드는 다음과 같이, 나와있지만 실제로 findFirst()
메서드를 호출했을 때, 어떤 구현 클래스를 가져올지 모르기 때문에, 실제로 활용하는 환경에서는 어떻게 사용하는지 궁금하다.
📑 정리
- 구현 클래스가 없어도 정적 팩토리 메서드를 이용해 객체를 반환하는 것이 가능하다.
정적 팩토리 메서드를 사용하면 private
생성자를 이용하므로 상속
을 할 수 없다.
하지만, 조합
원칙이나 불변 타입
을 만드는 관점에서 볼 때는 장점이 되기도 한다.
실제로 정적 팩토리 메서드를 이용하려면 해당 클래스의 역할과 책임을 잘 파악해서 관계를 어떻게 맺을지 고민해보는 것이 필요해 보인다.
💡 조합 원칙이란?
객체 지향 프로그래밍에서 코드를 구성할 때, 상속보다는 객체를 조합하여 사용하라는 원칙을 의미한다. 이는 유연성, 재사용성, 복잡성 감소 등의 이점을 제공하여 코드의 유지보수와 확장이 더 용이하도록 한다.
상속 보다는 조합을 사용하자
📑 정리
- 정적 팩토리 메서드만을 이용해 객체를 생성할 경우 상속이 불가능하다.
- 조합 원칙이나 불변 타입 관점에서 볼 때는 장점이 될 수 있다.
생성자는 자바의 기본 문법이기 때문에 보자마자 바로 파악할 수 있지만, 정적 팩토리 메서드는 하나의 기법으로 프로그래머가 해당 클래스에 정적 팩토리 메서드를 사용했다는 사실을 알아야 파악할 수 있다는 단점이 있다. 이를 완화하기 위해서는 널리 통용되는 이름을 정적 팩토리 메서드 이름으로 사용하는 방법이 있다.
메서드 | 설명 | 예제 |
---|---|---|
from | 매개변수를 하나 받아 해당 타입의 인스턴스를 반환(형변환 method) | Date d = Date.from(instant); |
of | 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드 | Set faceCards = EnumSet.of(JACK, QUEEN, KING); |
valueOf | from과 of의 더 자세한 버전 | Set faceCards = BigInteger.valueOf(Integer.MAX_VALUE); |
instance getInstance | 매개변수를 받을 경우 매개변수로 명시한 인스턴스를 반환하지만 같은 인스턴스임을 보장하지는 않음 | StackWalker luke = StackWalker.getInstance(options); |
create newInstance | instance 혹은 getInstance와 같지만 매번 새로운 인스턴스를 생성해 반환한다. | Object newArr = Array.newInstance(classObj,arrayLen); |
getType | getInstance와 같으나 생성할 클래스가 아닌 다른 클레스의 팩터리 메서드를 정의할 때 사용한다. | FileStore fs = Files.getFileStore(path) |
newType | newInstance와 같으나 생성할 클래스가 아닌 다른 클레스의 팩터리 메서드를 정의할 때 사용한다. | BufferedReader br = Files.newBufferedReader(path); |
type | getType과 newType의 간결한 버전 | List litany = Collections.list(legachLitancy); |
🔎 참조