프로그래밍 책

오용하기 어려운 코드(불변, 빌더 패턴, 복사 패턴,) #좋은코드나쁜코드 #7장

열심히 사는 우진 2023. 4. 5. 15:11
반응형

* 본 아티클은, '좋은 코드 나쁜 코드' 7장을 기반으로 작성된 글입니다.

 

 

 

 


많은 사람들이 함께 작성하는 거대한 코드가 올바르게 작동하려면, 오용하기 어렵게 만들어야 한다.

불변 객체로 만드는 것을 고려하라

가변 객체는 추론이 어렵고, 멀티 스레드 환경에서 문제가 발생할 수 있다.

(불변 객체) 객체 생성 시에만 값을 할당

setter 함수를 없애고, 생성자에서만 값을 할당한다.

class TextOptions {
    private final Font font;
    private final Double fontSize;

    TextOptions(Font font, Double fontSize) {
        this.font = font;
        this.fontSize = fontSize;
    }
    ...
}

(불변 객체) 빌더 패턴 - 하나의 클래스를 아래와 같이 두 개의 클래스로 분리한다.

  • 값을 하나씩 설정할 수 있는 빌더 클래스
  • 빌더에 의해 작성된 불변 읽기 전용 클래스
// 읽기 전용 클래스
class TextOptions {
    private final Font font;
    private final Double? fontSize;

    TextOptions(Font font, Double? fontSize) {
        this.font = font;
        this.fontSize = fontSize;
    }

    Font getFont() {
        return font;
    }

    Double? getFontSize() {
        return fontSize;
    }
}

// 빌더 클래스
class TestOptionBuilder {
    private final Font font; // 필수 필드
    private Double? fontSize; // 선택 필드

    TextOptionBuilder(Font font) { // 필수 값만 받는다
        this.font = font;
    }

    TextOptionsBuilder setFontSize(Double fontSize) { // 세터로 필수적이지 않은 값을 받는다.
        this.fontSize = fontSize;
        return this; // 체이닝이 가능하도록 this(현재 인스턴스)를 반환한다.
    }

    TextOptions build() { // 모든 값이 정해지고 나면, build 메서드로 TextOption 객체를 반환한다.
        return new TextOptions(font, fontSize);
    }
}

호출하는 쪽의 사용법은 아래와 같다.

TextOptions getDefaultTextOptions() {
    return new TextOptionsBuilder(Font.ARIAL)
            .setFontSize(12.0)
            .build();
}

TextOptions getDefaultTextOptionsWithoutFontSize() {
    return new TextOptionsBuilder(Font.ARIAL)
            .build();
}

필드의 일부가 선택 사항일 때, 본 객체가 가변적일 위험이 있다.

빌더 패턴은 그럴 때 본 객체를 불변으로 유지하면서, 빌더 객체로 값을 설정할 수 있다.

(불변 객체) 복사 패턴

기존의 객체 값을 유지하면서, 일부 필드만 새로 설정하고 싶을 때가 있다.

이럴 때는, 필드를 새로 쓰기 할 때에 복사를 하는 패턴을 사용할 수 있다.

class TextOptions {
    private final Font font;
    private final Double? fontSize;

    TextOptions(Font font, Double? fontSize) {
        this.font = font;
        this.fontSize = fontSize;
    }

    Font getFont() {
        return font;
    }

    Double? getFontSize() {
        return fontSize;
    }

    TextOptions withFont(Font newFont) {
        return new TextOptions(newFont, this.fontSize);
    }

    TextOptions withFontSize(Double newFontSize) {
        return new TextOptions(this.font, newFontSize);
    }
}

깊은 수준까지 불변으로 만드는 것을 고려하라

객체의 필드가 참조 타입인 경우, 객체는 불변이어도, 참조 타입인 필드는 가변일 수 있다.

이 또한 문제가 될 수 있다는 것은 너무 자명하다.

TextOptions의 필드 font가 fontFamliy로 바뀐 아래의 코드를 가지고 알아보자.

class TextOptions {
    private final List<Font> fontFamliy;
    private final Double fontSize;

    TextOptions(List<Font> fontFamliy;, Double fontSize) {
        this.fontFamliy = fontFamliy;
        this.fontSize = fontSize;
    }

    List<Font> getFontFamliy() {
        return fontFamliy;
    }

    Double getFontSize() {
        return fontSize;
    }
}

해결책 : 방어적으로 복사한다

방어적으로 복사한다는 것은, 원본과의 연결을 끊는다는 것을 의미한다.

더 쉽게 말하면, 원본에 변경이 생겨도 복사본에 변경이 생기지 않도록 복사하는 것이다.

    List<Font> getFontFamliy() {
        return List.copyOf(fontFamliy);
    }

List.copyOf() 메서드는, 방어적으로 복사하면서 복사본의 변경이 불가능하게 만든다.

이는 좋은 방식이지만, 아래와 같은 문제가 있다.

  • 복사 비용이 든다
  • 컬렉션 내부에 참조형 객체가 있는 경우 → 또다시 가변 문제가 발생한다.
    • 이것까지 고려한 복사를 깊은 복사라고 한다.

해결책 : 불변적 자료구조를 사용한다

자바에서는 Guava 라이브러리의 ImmutableList 클래스를 사용할 수 있다.

하지만 자바 9 이상부터는 List.of() 메서드, 자바 10 이상부터는 List.copyOf() 메서드를 통해 불변 리스트를 생성할 수 있다.

Set, Map도 마찬가지이다.

이렇게 생성한 객체들은, 방어적으로 복사하지 않아도 수정이 불가능해서 안전하다.

지나치게 일반적인 데이터 유형을 피하라

위도, 경도를 나타내는 좌표를 List로 표현할 수 있다.

하지만 이는 타입만으로 의미를 추론할 수 없고, 인덱스에 의존한다.

문서를 통한 추가 설명에 지나치게 의존해야 한다.

이럴 때는, 좌표를 표현하는 전용 타입을 만들어줄 수 있다.

class LatLong {
    private final Double latibude;
    private final Double longitude;
    ...
}

시간 처리

시간을 정수로 나타내는 것은 문제가 될 수 있다.

시간은 순간(시각)을 의미할 때도, 시간의 양을 의미할 때도 있다.

이것을 모두 정수로만 표현하면 의미가 혼동되기 쉽다.

그리고 단위를 오고갈 때도 많은 문제가 생긴다.

해결책 : 적절한 자료구조를 사용하라

자바에서는 시간의 양을 Duration, 시각을 LocalDateTime 클래스로 표현할 수 있다.

Duration을 사용하면 단위 문제도 해결이 되는데, 단위를 변환하는 예시 코드는 아래와 같다.

Duration duration1 = Duration.ofSeconds(5);
print(duration1.toMillis()); // 출력 : 5000

Duration duration2 = Duration.ofMinutes(2);
print(duration1.toMillis()); // 출력 : 120000

단위가 Duration 타입 내부로 캡슐화되어, 외부에서 그것을 설정하거나 고려할 필요가 없다.

사용할 때만 원하는 단위를 사용하면 된다.

데이터에 대해 진실의 원천을 하나만 가져야 한다

다른 필드로 표현할 수 있는 값은, 새로운 필드로 가지고 있으면 안 된다.

대변, 차변을 필드로 갖는 계좌 객체가, 잔액을 새로운 필드로 가지면,

대변, 차변이 업데이트될 때 잔액이 반드시 업데이트되어야 한다.

그렇지 않은 경우 잘못된 값을 전달할 수 있다.

// 차액 데이터에 대한 원천이 2개인 경우
class UserAccount {
    private final Double credit;
    private final Double debit;
    private final Double balance;
    ...
}

// 차액 데이터에 대한 원천이 1개인 경우
class UserAccount {
    private final Double credit;
    private final Double debit;
    ...
    Double getBalance() {
        return credit - debit;
    }
}

데이터 계산에 비용이 많이 드는 경우?

지연 연산 및 캐싱으로 해결할 수 있다.

class UserAccount {
    private final List<Transaction> transactions;

    private Double? cachedCredit;
    private Double? cachedDebit;

    UserAccount(ImmutableList<Transaction> transactions) {
        this.transactions = transactions;
    }

    ...

    Double getCredit() {
        if (cachedCredit == null) { // 호출되었을 때(lazy), 값이 빈 경우에만 연산한다.
        cachedCredit = transactions
                .map(transaction -> transaction.getCredit())
                .sum();
        }
        return cachedCredit;
    }
}

지연(lazy) 연산은, 미리 연산하지 않고 필요한 경우에 연산을 수행한다는 뜻이다.

논리에 대한 진실의 원천을 하나만 가져야 한다.

정수 리스트를 파일로 저장하는 클래스,

파일에서 정수 리스트를 불러오는 클래스,

이렇게 2개의 클래스를 생각해보자.

파일로 저장하는 논리(로직)와, 파일에서 정수 리스트를 불러오는 로직은 일치해야 한다.

하지만 다음의 코드는 그렇지 못하다.

// 정수 리스트 저장 (직렬화)
class DataLogger {
    private final List<Int> loggedValues;
    ...

    saveValues(FileHandler file) {
        String serializedValues = loggedValues
                .map(value -> value.toString(Radix.BASE_10))
                .join(",");
        file.write(serializedValues);
    }
}

// 정수 리스트 불러오기 (역직렬화)
class DataLoader {
    ...

    List<Int> loadValues(FileHandler file) {
        return file.readAsString()
                .split(",")
                .map(str -> Int.parse(str, Radix.BASE_10));
    }
}

DataLogger, DataLoader는 모두 쉼표로 데이터를 구분한다는 것과, 문자열을 십진수 정수로 변환했다는 로직을 가지고 이싿.

하지만, 한 쪽의 논리만 바뀌는 경우 두 클래스는 호환되지 않는다.

해결책 : 로직을 하나로 통합한다

class IntListFormat {
    private const String DELIMITER = ",";
    private const Radix RADIX = Radix.Base_10;

    Strint serialize(List<Int> values) {
        return values
                .map(value -> value.toString(RADIX))
                .join(DELIMITER);
    }

    List<Int> deserialize(String serialized) {
        return serialized
                .split(DELIMITER)
                .map(str -> Int.parse(str, RADIX));
    }
}

위처럼, 로직을 하나의 클래스가 관리해서

하나가 변경되더라도 문제가 없도록 만들 수 있다.

반응형