컴퓨터 과학의 모든 문제는 또 다른 간접 계층을 추가해 풀 수 있다.
하지만 대부분 또 다른 문제를 양산한다. - 데이비드 휠러
일반적으로 메서드는 하나의 작업에만 특화되어야 한다.
불 메서드 매개변수는 메서드가 적어도 두 가지 작업을 수행함을 의미한다.
class Logbook {
static final Path CAPTAIN_LOG = Paths.get("/var/log/captain.log");
static final Path CREW_LOG = Paths.get("/var/log/crew.log");
void log(String message, boolean classified) throws IOException {
if (classified) {
writeMessage(message, CAPTAIN_LOG);
} else {
writeMessage(message, CREW_LOG);
}
}
void writeMessage(String message, Path location) throws IOException {
String entry = LocalDate.now() + " " + message;
Files.write(location, Collections.singleton(entry),
StandardCharsets.UTF_8, StandardOpenOption.APPEND);
}
}
위 예제는 Logbook
클래스를 수정한 버전이다.
log()
메서드의 두 번째 매개변수로 true/false
를 전달하여 공개/비공개 메시지를 작성할 수 있다.
log()
메서드를 호출하는 방법은 다음과 같다.
logbook.log("Aliens sighted!", true);
logbook.log("Toliet broken", false);
코드에 문제는 없지만 지금의 Logbook
클래스는 읽기 불편하고 구조화가 덜 되었다.
이는 두 번째 매개변수로 전달하는 boolean
변수를 잘못 설정하면 메시지 유형이 쉽게 달라지기 때문이다.
class Logbook {
static final Path CAPTAIN_LOG = Paths.get("/var/log/captain.log");
static final Path CREW_LOG = Paths.get("/var/log/crew.log");
void writeToCaptainLog(String message) throws IOException {
writeMessage(message, CAPTAIN_LOG);
}
void writeToCrewLog(String message) throws IOException {
writeMessage(message, CREW_LOG);
}
void writeMessage(String message, Path location) throws IOException {
String entry = LocalDate.now() + " " + message;
Files.write(location, Collections.singleton(entry),
StandardCharsets.UTF_8, StandardOpenOption.APPEND);
}
}
boolean
이 사용된 메서드라면 메서드를 여러 개 분리하는 것으로 코드 가독성을 높일 수 있다.logbook.writeToCaptainLog("Aliens sighted!");
logbook.writeToCrewLog("Toilet broken, Again...");
훌륭한 디자인은 결코 쉽지 않다.
무엇이 훌륭한 디자인인지 처음부터 제대로 이해하는 것은 불가능에 가깝다. (중략)
현실적으로 이러한 직관을 쌓는 유일한 방법은 다양한 시도를 통해 무엇이 실패하고 무엇이 옳은지 깨닫는 것뿐이다.
class Logbook {
static final Path CREW_LOG = Paths.get("/var/log/crew.log");
List<String> readEntries(LocalDate date) throws IOException {
final List<String> entries = Files.readAllLines(CREW_LOG, StandardCharsets.UTF_8);
if (date == null) {
return entries;
}
List<String> result = new LinkedList<>();
for (String entry : entries) {
if (entry.startsWith(date.toString())) {
result.add(entry);
}
}
return result;
}
}
readEntries()
메서드는 date
라는 매개변수를 전달하고 있다.readEntries()
에 date
를 전달하면 해당 날짜의 로그 리스트를 반환하고, null
을 전달하면 전체 로그 리스트를 반환한다.readEntries()
메서드는 두 가지 이상의 일을 하고 있다.앞서 설명했듯 하나의 메서드가 두 가지 이상의 일을 하는 것은 메서드의 의미 파악을 어렵게 만든다.
더군다나readEntries()
메서드의null
매개변수는 호출했을 때 그 의미를 파악하기 어렵다.
class Logbook {
static final Path CREW_LOG = Paths.get("/var/log/crew.log");
List<String> readEntries(LocalDate date) throws IOException {
Objects.requireNonNull(date);
List<String> result = new LinkedList<>();
for (String entry : readAllEntries()) {
if (entry.startsWith(date.toString())) {
result.add(entry);
}
}
return result;
}
List<String> readAllEntries() throws IOException {
return Files.readAllLines(CREW_LOG, StandardCharsets.UTF_8);
}
}
readEntries()
메서드는 더 이상 date
매개변수가 null
인 것을 허용하지 않는다.readAllEntries()
메서드를 새로 만들었다.NPE
가 일어날 가능성을 줄일 수 있다.class Inventory {
LinkedList<Supply> supplies = new LinkedList();
void stockUp(ArrayList<Supply> delivery) {
supplies.addAll(delivery);
}
LinkedList<Supply> getContaminatedSupplies() {
LinkedList<Supply> contaminatedSupplies = new LinkedList<>();
for (Supply supply : supplies) {
if (supply.isContaminated()) {
contaminatedSupplies.add(supply);
}
}
return contaminatedSupplies;
}
}
getContaminatedSupplies()
메서드는 stockUp()
로 생성한 Supply
객체들의 LinkedList
를 순회한다.Inventory
클래스는 다음의 코드로 호출할 수 있다.Stack<Supply> delivery = cargoShip.unload();
ArrayList<Supply> loadableDelivery = new ArrayList<>(delivery);
inventory.stockUp(loadableDelivery);
Stack
을 통해 제품을 전달하는데, 정작 Inventory
에 제품을 채우기 위해서는 ArrayList
가 필요하다.Inventory
에 ArrayList
를 넣으면 stockUp()
메서드가 제품을 내부의 ArrayList
로 옮긴다.getContaminatedSupplies()
가 LinkedList
에서 제품을 골라낸다.자바 API를 보면 인터페이스와 클래스는 흔히 광범위한 타입 계층 구조를 형성한다.
변수에 추상적인 타입을 사용하면 할수록 코드는 더 유연해진다.
class Inventory {
List<Supply> supplies = new LinkedList(); // (1) LinkedList to List
void stockUp(Collection<Supply> delivery) { // (2) ArrayList to Collection
supplies.addAll(delivery);
}
List<Supply> getContaminatedSupplies() { // (3) LinkedList to List
List<Supply> contaminatedSupplies = new LinkedList<>();
for (Supply supply : supplies) {
if (supply.isContaminated()) {
contaminatedSupplies.add(supply);
}
}
return contaminatedSupplies;
}
}
supplies
필드에 LinkedList
대신 List
인터페이스 타입을 사용하고 있다.ArrayList
로 저장되는지 LinkedList
로 저장되는지 알 수 없게 되었다.stockUp()
메서드의 매개변수에 어떤 Collection
이든 허용된다.Collection
은 자바에서 자료 구조에 객체를 저장하는 가장 기본적인 인터페이스이다.Collection
의 어천 하위 타입이든지 이 메서드로 전달할 수 있게 되었다.getContaminatedSupplies()
메서드가 추상적인 타입의 List
를 반환하게 되었다.Stack<Supply> delivery = cargoShip.unload();
inventory.stockUp(delivery);
Inventory
는 아무런 변환 없이도 Stack
을 온전히 받아들이게 되었다.Stack
이외에도 Set
, List
, Vector
등의 자료 구조도 허용된다.OCP(Open/Closed Principle)
소프트웨어 요소는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.OCP는 자유로은 상속을 통한 확장과 재사용성을 추구하는 원칙이다.
이 원칙은 다형성과 역할/구현 분리의 개념을 적용한다.
스프링에서는 제어의 역전(IoC), 의존 관계 주입(DI)의 개념이 이에 해당한다.
기본적으로 객체의 상태는 불변이다.
가능하면 객체를 불변으로 만들어야 잘못 사용할 가능성을 줄일 수 있다.
class Distance {
DistanceUnit unit;
double value;
Distance(DistanceUnit unit, double value) {
this.unit = unit;
this.value = value;
}
static Distance km(double value) {
return new Distance(DistanceUnit.KILOMETERS, value);
}
void add(Distance distance) {
distance.convertTo(unit);
value += distance.value;
}
void convertTo(DistanceUnit otherUnit) {
double conversionRate = unit.getConversionRate(otherUnit);
unit = otherUnit;
value = conversionRate * value;
}
}
Distance
클래스를 오용할 여지가 있다는 것이 문제이다.Distance
클래스는 다음의 코드와 같이 사용될 수 있다.Distance toMars = new Distance(DistanceUnit.KILIMETER, 56_000_000);
Distance margToVenus = new Distance(DistanceUnit.LIGHTYEARS, 0.000012656528);
Distance toVenusViaMars = toMars; // 같은 객체가 됨
toVenusVisMars.add(marsToVenus);
toMars
는 지구에서 화성까지의 거리, marsToVenus
는 화성에서 금성까지의 거리를 나타내는 변수이다.toVenusViaMars
변수로 계산한다.toVenusViaMars
와 toMars
가 가리키는 객체가 같다는 것이다.toVenusViaMars.add()
를 호출하면 toMars
의 값까지 간접적으로 변경된다.final class Distance {
final DistanceUnit unit;
final double value;
Distance(DistanceUnit unit, double value) {
this.unit = unit;
this.value = value;
}
Distance add(Distance distance) {
return new Distance(unit, value + distance.convertTo(unit).value);
}
Distance convertTo(DistanceUnit otherUnit) {
double conversionRate = unit.getConversionRate(otherUnit);
return new Distance(otherUnit, conversionRate + value);
}
}
객체에서 유효하지 않은 변경이 일어나지 않도록 하려면 가변성을 제한하면 된다.
value
와 unit
필드에 final
키워드를 설정하여 변경이 불가능하다.Distance toMars = new Distance(DistanceUnit.KILOMETERS, 56_000_000);
Distance marsToVenus = new Distance(DistanceUnit.LIGHTYEARS, 0.000012656528);
Distance toVenusViaMars = toMars.add(marsToVenus).convertTo(DistanceUnit.MILES);
객체를 더 많이 생성해야 한다는 단점은 있지만 자바에서 작은 객체는 비용이 적게 든다.
스프링 JPA에서 엔티티 클래스를 만드는 경우,
final
키워드를 사용할 수 없다.
자세한 내용은 final 클래스는 왜 JPA 엔티티가 될 수 없을까?를 참고하자.
상태와 동작의 결합은 객체 지향 프로그래밍의 기본 틀 중 하나이다.
동작만 있고 상태가 없는 클래스는 객체 지향 디자인에 문제가 있다는 말이 된다.
class Hull {
int holes;
}
class HullRepairUnit {
void repairHole(Hull hull) {
if (isIntact(null)) {
return;
}
hull.holes--;
}
boolean isIntact(Hull hull) {
return hull.holes == 0;
}
}
Hull
클래스는 상태를 표현하고, holes
의 개수를 저장한다.HullRepairUnit
클래스는 holes
를 수정하는 동작을 수행한다.holes
개수에 접근하고 수정하는 것을 막을 수 없다.hull
매개변수도 검증하지 않고 있다.상태와 동작이 분리되어 있는 것을 알아차리지 못할 경우가 종종 있을 수 있다.
이럴 땐 먼저 너무 큰 클래스나 자신에게 속한 메서드 매개변수만 연산하는 클래스를 찾고,
비슷한 작업을 수행하는 변수와 메서드를 하나의 클래스로 묶어서 클래스를 간소화해야 한다.
마지막으로 전후(before-and-after) 비교를 통해 디자인적으로 좋아졌는지 확인해야 한다.
class Hull {
int holes;
void repairHole() {
if (isIntact()) {
return;
}
holes--;
}
boolean isIntact() {
return holes == 0;
}
}
holes
에 쉽게 접근할 수 있다.getter
나 setter
를 두어서 holes
속성을 외부에 노출할 필요도 없게 되었다.요약하면 메서드 내에서 입력 매개변수만 다루고, 자신이 속한 클래스의 인스턴스 변수는 다루지 않는 경우를 주의깊게 봐야 한다.
이는 상태와 동작이 분리되었음을 의미하고, 정보 은닉이 불가능하기 때문이다.
명백하지 않은 객체는 외부에서 접근할 수 있는 내부 상태가 거의 항상 비어 있다.
이러한 상태를 어떤 방식으로 조작할지는 신중히 결정해야 한다.
class Inventory {
private final List<Supply> supplies;
Inventory(List<Supply> supplies) {
this.supplies = supplies;
}
List<Supply> getSupplies() {
return supplies;
}
}
Inventory
는 자료 구조를 포함하는 매우 일반적인 클래스이다.Inventory
의 생성자에 삽입된다.// 빈 externalSupplies 생성
List<Supply> externalSupplies = new ArrayList<>();
Inventory inventory = new Inventory(externalSupplies);
inventory.getSupplies().size(); // == 0
externalSupplies.add(new Supply("Apple"));
// inventory는 내부의 제품 리스트를 전혀 보호하지 않음
// 따라서 변경 연산을 수행하면 재고 상태가 바뀌어버림
inventory.getSupplies().size(); // == 1
inventory.getSupplies().add(new Supply("Banana"));
inventory.getSupplies().size(); // == 2
externalSupplies
를 새로운 Inventory
에 전달하고, 이어서 getSupplies()
가 빈 리스트를 반환한다.inventory
는 내부의 제품 리스트를 전혀 보호하지 않는다.externalSupplies
리스트에 제품을 추가하거나, getSupplies()
가 반환한 리스트에 변경 연산을 수행하면 재고 상태가 바뀌어버린다.supplies
에 final
키워드를 붙인다고 해도 막을 수 없다.new ArrayList<>()
로 생성한 리스트 하나뿐이기 때문이다.inventory
는 이 리스트로의 참조만 supplies
필드에 저장하고, getSupplies()
를 통해 그 참조를 반환한다.Inventory
는 내부 구조로의 참조를 getter
를 통해 외부에 노출하는 셈이다.class Inventory {
private final List<Supply> supplies;
Inventory(List<Supply> supplies) {
this.supplies = new ArrayList<>(supplies);
}
List<Supply> getSupplies() {
// unmodifiableList()로 wrapping
return Collections.unmodifiableList(supplies);
}
}
Inventory
는 내부 구조를 훨씬 더 잘 보호한다.Supply
객체로 내부 ArrayList
를 채운다.null
이 들어오면 바로 예외를 발생시킨다.getSupplies()
로 바로 노출하지 않고 unmodifiableList()
로 래핑한 후 노출하고 있다.supplies
에 대한 접근은 읽기 접근만 가능하게 된다.Inventory
의 사용법이 바뀌게 된다.List<Supply> externalSupplies = new ArrayList<>();
Inventory inventory = new Inventory(externalSupplies);
inventory.getSupplies().size(); // == 0
externalSupplies.add(new Supply("Apple"));
inventory.getSupplies().size(); // == 0
// UnsupportedOperationException 발생
inventory.getSupplies().add(new Supply("Banana"));
externalSupplies
리스트와 getSupplies()
가 반환한 리스트 모두 조작할 수 없게 되었다.inventory
의 내부 상태에는 전혀 영향이 없게 된다.getSupplies()
가 반환한 리스트를 수정하려 하면 UnsupportedOperationException
이 발생하니 더 안전해졌다.이렇게 객체의 주소를 복사하지 않고 객체 내부 값만 참조하여 복사하는 방식을 방어 복사(defensive copying)이라고 한다.
자세한 내용은 얕은 복사, 방어 복사, 깊은 복사를 참고하자.
getter
와setter
는 둘 다 보호해야 하는 대상이다.
보통은setter
를 허용하지 않는 편이 훨씬 편한 방법이다.
메서드 호출 시 적절히 반환할 값이 없을 때 그냥
null
을 반환하는 프로그램이 종종 있다.
이런 코드는 프로그램의 안정성을 크게 해칠 우려가 있다.
class SpaceNations {
static List<SpaceNation> nations = Arrays.asList(
new SpaceNation("US", "United States"),
new SpaceNation("KO", "Korea")
);
static SpaceNation getByCode(String code) {
for (SpaceNation nation : nations) {
if (nation.getCode().equals(code)) {
return nation;
}
}
return null;
}
}
getByCode()
메서드에 알려지지 않은 국가 코드를 넣으면 NPE
가 발생한다.null
을 반환할 가능성이 있어서 매번 명시적으로 반환값을 확인해야 한다.class SpaceNations {
/** 널 객체 */
static final SpaceNation UNKNOWN_NATION = new SpaceNation("", "");
static List<SpaceNation> nations = Arrays.asList(
new SpaceNation("US", "United States"),
new SpaceNation("KO", "Korea")
);
static SpaceNation getByCode(String code) {
for (SpaceNation nation : nations) {
if (nation.getCode().equals(code)) {
return nation;
}
}
return UNKNOWN_NATION;
}
}
IllegalArgumentException
이나 NoSuchElementException
과 같은 예외를 던지는 방법도 있다.null
을 반환하는 대신 널 객체, 즉 객체에 실질적인 값이 없음을 명시적으로 표현한 객체를 반환하는 방식이다.String us = SpaceNations.getByCode("US").getName(); // -> "United States"
String anguilla = SpaceNations.getByCode("AI").getName(); // -> ""
UNKNOWN_NATION
이 나올 경우에 대한 대응은 여전히 호출하는 쪽에 있다.널 객체는 빈 문자열, 빈 컬렉션, 또는 특수 클래스 인스턴스 등 다양한 형태를 가진다.
하지만 어떤 형태든 공통 목표는 “비용이 막대한 실수”가 일어나지 않도록 하는 것이다.
누구나 훌륭한 디자인을 할 수 있다.
전체를 더 낫게 만드는 세세한 변경들이 모여 좋은 디자인을 만들어내고 그 방법은 클래스의 결함을 찾아내는 것이다.
이 장의 내용은 [자바 코딩의 기술: 똑똑하게 코딩하는 법]의 7장 내용을 정리한 것입니다.