객체 지향 설계 SOLID 원칙

두문자 약어 이름 개념
S SRP 단일 책임 원칙
Single Responsibility Principle
한 클래스는 하나의 책임을 가져야 한다.
O OCP 개방-폐쇄 원칙
Open-Closed Principle
소프트웨어 개체(클래스, 모듈 등)는 확장에는 열려있으나, 수정에는 닫혀 있어야 한다.
L LSP 리스코프 치환 원칙
Liskov Substitution Principle
객체의 정확성을 깨뜨리지 않으면서 상위클래스의 객체를 하위클래스의 객체로 바꿀 수 있어야한다.
I ISP 인터페이스 분리 원칙
Interface Segregation Principle
범용적인 인터페이스보다 클라이언트를 위한 인터페이스 여러개가 더 낫다.
D DIP 의존관계 역전 원칙
Dependency Inversion Principle
추상화에 의존해야지, 구체화에 의존하면 안된다.

 

 

 

인터페이스 분리 원칙

ISP 원칙이란 범용적인 인터페이스보다 사용자가 실제로 사용하는 Interface를 만들어야한다는 의미입니다.

즉, 자신이 이용하지 않는 메소드에 의존하지 않아야하는 원칙인데요. 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 사용자들이 꼭 필요한 메소드들만 이용할 수 있게 하는 것이 중요합니다.

 

 

 

 

 

인터페이스 분리 원칙의 예

 

 

1. ISP 위반 예제

public interface Phone {

    void call(String number);
    void message(String number, String text);
    void app();
    void camera();

}

public class IPhone15 implements Phone{

    @Override
    public void call(String number) {

    }

    @Override
    public void message(String number, String text) {

    }

    @Override
    public void app() {

    }

    @Override
    public void camera() {

    }
}

 

스마트폰을 추상화했습니다. 스마트폰에는 많은 기능들이 포함되어있습니다.

여기서 아이폰을 구현한다면 모든 기능들을 오버라이드 하면 문제가 없어보입니다.

 

만약 버전이 올라가면서 기능이 추가되거나

이전 버전의 핸드폰을 추가할때 지원하지 않는 기능은 어떻게 될까요?

public class FolderPhone implements Phone{

    @Override
    public void call(String number) {
        
    }

    @Override
    public void message(String number, String text) {

    }

    @Override
    public void app() {
        throw new NotSupportedException();
    }

    @Override
    public void camera() {

    }
}

public class IPhone20 implements Phone{

    @Override
    public void call(String number) {

    }

    @Override
    public void message(String number, String text) {
        throw new NotSupportedException();
    }

    @Override
    public void app() {

    }

    @Override
    public void camera() {

    }
}

 

이전 버전의 폴더폰은 app 기능이 없어서 예외를 던지고, 이후 버전이 올라가면서 더이상 message 기능을 사용하지 않는다면 이후 기능들에서 모든 message는 예외를 던지게됩니다.

 

2. ISP 적용

public interface Phone {

    void call(String number);
}

public interface Message {

    void message(String number, String text);
}

public interface App {

    void app();
}

public interface Camera {

    void camera();
}

 

각각의 기능별로 인터페이스를 분리했습니다.

 

 

public class FolderPhone implements Phone, Message, Camera{

    @Override
    public void call(String number) {
        
    }

    @Override
    public void message(String number, String text) {

    }

    @Override
    public void camera() {

    }
}

public class IPhoneXR implements Phone, Message, App, Camera{

    @Override
    public void call(String number) {

    }

    @Override
    public void message(String number, String text) {

    }

    @Override
    public void app() {

    }

    @Override
    public void camera() {

    }
}

public class IPhone20 implements Phone, App, Camera{

    @Override
    public void call(String number) {

    }

    @Override
    public void app() {

    }

    @Override
    public void camera() {

    }
}

 

작은 단위로 인터페이스가 분리되어 지원되는 기능만을 구현할 수 있게되었습니다.

 

 

 

객체 지향 설계 SOLID 원칙

두문자 약어 이름 개념
S SRP 단일 책임 원칙
Single Responsibility Principle
한 클래스는 하나의 책임을 가져야 한다.
O OCP 개방-폐쇄 원칙
Open-Closed Principle
소프트웨어 개체(클래스, 모듈 등)는 확장에는 열려있으나, 수정에는 닫혀 있어야 한다.
L LSP 리스코프 치환 원칙
Liskov Substitution Principle
객체의 정확성을 깨뜨리지 않으면서 상위클래스의 객체를 하위클래스의 객체로 바꿀 수 있어야한다.
I ISP 인터페이스 분리 원칙
Interface Segregation Principle
범용적인 인터페이스보다 클라이언트를 위한 인터페이스 여러개가 더 낫다.
D DIP 의존관계 역전 원칙
Dependency Inversion Principle
추상화에 의존해야지, 구체화에 의존하면 안된다.

 

 

 

리스코프 치환 원칙

리스코프 치환 원칙이란 부모 객체와 자식 객체가 있을 때 부모 객체를 호출하는 동작에서 자식 객체로 대체하였을때 정확성을 깨뜨리지 않으면서 완전히 대체 가능해야한다는 원칙입니다.

쉽게 말해 부모객체에서 자식객체로 변경되었어도 문제가 생기지 않아야 한다는 뜻입니다.

 

 

 

LSP 위반 예제

public class Counter {

    private List<Member> memberList = new ArrayList<>();
    private int count = 0;

    public void addAll(List<Member> list) {
        for (Member member : list) {
            add(member);
        }
    }

    public void add(Member member) {
        memberList.add(member);
        addCount();
    }

    protected void addCount() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {

    public static void main(String[] args) {

        Counter counter = new Counter();
        counter.add(new Member());

        List<Member> members = List.of(new Member(), new Member(), new Member(), new Member(), new Member());
        counter.addAll(members);


        System.out.println(counter.getCount()); // 6
    }

}

 

List의 개수를 count를 하는 클래스를 만들었습니다. Counter의 addAll은 자신의 클래스내에 존재하는 add를 호출하여 카운트를 합니다.

 

public class ArrayCounter extends Counter{

    @Override
    public void addAll(List<Member> list) {
        super.addAll(list);
        for (Member member : list) {
            addCount();
        }
    }

}

public class Main {

    public static void main(String[] args) {

        ArrayCounter counter = new ArrayCounter();
        counter.add(new Member());

        List<Member> members = List.of(new Member(), new Member(), new Member(), new Member(), new Member());
        counter.addAll(members);
        
        System.out.println(counter.getCount()); // 11
    }

}

addAll 메소드안에서 add 메소드르 호출하는 것을 몰랐던 개발자는 List의 사이즈를 카운트 하기 위해 addCount()메소드를 호출합니다.따라서 ArrayCounter에서 add가 11번이 호출되었고,

그 결과 Counter 객체의 결과는 6, ArrayCounter 객체의 결과는 11이 나왔습니다. 이는 부모객체를 자식객체로 대체되었을 때 완전히 대체되지 않으므로 LSP 위반입니다.

 

 

결론

LSP 원칙을 잘 적용한 예제는 우리가 자주 사용하는 컬렉션 프레임워크입니다.

        Collection<Integer> collection = new HashSet<>();
        collection.add(1);

        collection = new TreeSet<>();
        collection.add(2);

Collection 안에서 Hashset 자료형을 사용하다가 TreeSet으로 변경하더라도 add() 메소드가 동작하는데에는 전혀 문제가 되지 않습니다.

 

리스코프 치환 원칙은 자바를 배우면서 사용해온 다형성을 규칙으로서 문서화한 것이 LSP 원칙이라고 생각하시면 됩니다.

빌더 (Builder)

빌더 패턴은 복잡한 객체의 생성과 표현을 분리하여 인스턴스를 만드는 생성패턴입니다. 생성자에 들어갈 매개 변수를 메서드로 하나하나 받아들이고 마지막에 통합 빌드해서 객체를 생성하는 방식입니다.

 

 

빌더패턴을 사용하는 이유

우리는 데이터를 주입할 때 생성자를 이용하거나 기본생성자로 객체를 생성하고 이후에 Setter를 이용해서 값을 넣었습니다.

 

 

 

생성자를 이용한 주입

public class User {

    private String name;
    private int age;

    private String sex;
    private String phone;
    private int height;

    public User(String name, int age, String sex, String phone, int height) {
        this.name = name;
        this.age = age;
        this.sex = sex;
        this.phone = phone;
        this.height = height;
    }
}

public class Main {

    public static void main(String[] args) {

        User user = new User("김OO", 20, "남자", "01012345678", 70);

    }
}

 

Setter를 이용한 주입

public class User {

    private String name;
    private int age;

    private String sex;
    private String phone;
    private int height;
    

    public void setName(String name) {
        this.name = name;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public void setSex(String sex) {
        this.sex = sex;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

public class Main {

    public static void main(String[] args) {

        User user = new User();
        user.setName("김OO");
        user.setAge(20);
        user.setSex("남자");
        user.setPhone("01012345678");
        user.setHeight(70);
    }
}

 

이러한 방식은 생성자의 인자가 많아질 수록 데이터를 구별하기 어렵고, 필드와 생성자의 위치를 직관적으로 파악하기 어렵다는 것입니다. 그리고 타입이 많아질 수록 메서드 수가 기하급수적으로 늘어나 가독성과 유지보수 측면에서 안좋을 수 밖에 없습니다.

Setter는 객체를 생성하는 시점에 값을 주입하지않아 일관성(Consistency) 불변성(Immutable) 문제가 생길 수 있습니다.

 

생성자와 Setter의 문제점

 

 

일관성 문제

필수 매개변수란 객체가 초기화 될 때 반드시 설정되어야 하는 값입니다. 만약 개발자가 setAge() 메서드를 호출 하지 않고 User 객체를 생성했다면 이 객체는 일관성이 없는 상태가 됩니다. 만약 이 객체를 사용하게 된다면 예외가 발생할 수 있습니다.

 

불변성 문제

Setter의 문제는 객체를 생성한 이후에 값을 주입하는데요 이는 객체를 생성한 이후로도 누구나 외부에서 Setter를 사용해서 값을 주입할 수 있는 문제가 생깁니다. 다시 말해 누구든지 Setter를 호출해 객체를 조작할 수 있다는 뜻입니다. 따라서 Setter는 불변성이 보장되지않는 객체라고 할 수 있습니다.

 

 

 

Builder패턴의 사용

 

빌더 패턴은 이러한 문제들을 해결하기위해 Buillder 클래스를 만들어 생성되는 시점에 모든 값들을 넣어주도록 하는 패턴입니다.

 

        // 생성자 주입
        User user1 = new User("김OO", 20, "남자", "01012345678", 70);

        // Setter 주입
        User user2 = new User();
        user.setName("김OO");
        user.setAge(20);
        user.setSex("남자");
        user.setPhone("01012345678");
        user.setHeight(70);

        // Builder 주입
        User user3 = User.builder()
            .name("김OO")
            .age(20)
            .sex("남자")
            .phone("01012345678")
            .height(70)
            .build();

 

빌더 패턴을 이용하면 생성자를 오버로딩을 하지 않아도되며, 데이터의 순서에 상관없이 데이터를 주입할 수 있게되었습니다.

 

 

 

빌더패턴의 장점

  • 필요한 데이터만 설정할 수 있습니다.
  • 가독성이 좋습니다.
  • 변경 가능성을 최소화 할 수 있습니다.

 

 

빌더패턴의 구조

구현자체는 어렵지 않습니다. 위의 User 객체에 Builder 패턴을 구현해보겠습니다.

 


1. builder()

User 클래스 안에 Builder 클래스를 구현해줍니다.

public class User {

    private String name;
    private int age;

    private String sex;
    private String phone;
    private int height;

    public static UserBuilder builder() {
        return new UserBuilder();
    }

    public static class UserBuilder {

    }

}

 

 

User.builder()로 Builder 패턴을 시작해야하기 때문에 static 을 사용해 클래스를 만들어줍니다.

 

 

 

2. Chaning

    public static class UserBuilder {

        private final User user;

        public UserBuilder() {
            this.user = new User();
        }

        public UserBuilder name(String name) {
            user.name = name;
            return this;
        }

        public UserBuilder age(int age) {
            user.age = age;
            return this;
        }

        public UserBuilder sex(String sex) {
            user.sex = sex;
            return this;
        }

        public UserBuilder phone(String phone) {
            user.phone = phone;
            return this;
        }

        public UserBuilder height(int height) {
            user.height = height;
            return this;
        }

        public User build() {
            return user;
        }
    }

 

User.builder() 를 입력하면 UserBuilder 기본생성자를 통해 User 기본생성자를 주입시켜줍니다. 여기서 주의할 사항은 User의 기본생성자가 존재해야합니다. 인자를 받는 생성자가 존재한다면 기본생성자를 하나 더 만들어주어야합니다.

 

이후에 반환타입은 UserBuilder, 메서드명은 User의 변수명으로 만들어 메서드를 체이닝(Chaining) 형태로 호출하고 마지막에 build()를 통해 최종적으로 객체를 반환하도록 만들어주면 Builder 패턴을 쉽게 구현할 수 있습니다.

추상 팩토리 패턴

서로 관련이 있는 객체들을 통째로 묶어서 팩토리 클래스로 만들고,

이들 팩토리를 조건에 따라 생성하도록 다시 팩토리를 만들어서 객체를 생성하는 패턴입니다.

 

쉽게말해 추상적인 공장에서는 추상적인 부품을 조합하여 추상적인 제품을 만든다고 생각하시면됩니다.

무슨말이야? 라고 하신다면 '추상화' 라는 말은 '구체적으로 어떻게 구현되어 있는지 생각하지 않고 인터페이스에만 주목하는 상태'를 뜻합니다.

 

추상화에 대표적인 예로 부품이 많은 자동차, 컴퓨터 등이 있으나 저는 이번에 컴퓨터로 예를 들어보겠습니다.

컴퓨터의 부품으로는 키보드, 마우스, 모니터, 스피커 등등 많습니다.

각 부품마다 제조사도 다르고 일부 지원하는 기능도 다양합니다. 우리가 원래 사용하던 부품이 아니고 다른 제조사의 부품을 갈아낀다고해서 컴퓨터가 작동이 안될까요? 당연히 아닙니다. 왜냐하면 각 부품마다 인터페이스화 되어있기때문입니다. 일종의 가이드라인 또는 설계도면인것인 셈이죠.

 

이를 코드로 작성한다면 키보드, 마우스, 모니터, 스피커는 interface 입니다. 그리고 제조사별로 구현체를 갈아끼울 수 있겠죠.

 

이런식으로 말입니다. 여기에서 Factory는 어떤 구현체를 적용할 지 조합하는 공장입니다.

 

지금부터 코드를 작성하며 같이 보겠습니다. 예시를 위해 키보드와 마우스에 대해서만 작성해보도록 하겠습니다.

 

 

추상팩토리패턴의 예

 

1. 키보드

public interface Keyboard {

    void init();

}

public class LGKeyboard implements Keyboard{

    @Override
    public void init() {
        System.out.println("LG Keyboard Call");
    }
}

public class SAMSUNGKeyboard implements Keyboard{

    @Override
    public void init() {
        System.out.println("SAMSUNG Keyboard Call");
    }
}

 

 

 

 

2. 마우스

public interface Mouse {

    void init();
    
    void sound();
   
}

public class LGMouse implements Mouse{

    @Override
    public void init() {
        System.out.println("LG Mouse Call");
    }

    @Override
    public void sound() {
        System.out.println("LG Sound");
    }
}

public class SANSUNGMouse implements Mouse{

    @Override
    public void init() {
        System.out.println("SAMSUNG Mouse Call");
    }

    @Override
    public void sound() {
        System.out.println("SAMSUNG Sound");
    }
}

 

 

인터페이스에 대한 구현체는 모두 만들었습니다. 다음은 Factory를 만들어보겠습니다.

 

 

 

 

3. 팩토리

public class KeyboardFactory {

    public Keyboard createKeyboard(String type) {
        if ("LG".equals(type)) {
            return new LGKeyboard();
        }
        if ("SAMSUNG".equals(type)) {
            return new SAMSUNGKeyboard();
        }
        return new Keyboard() {
            @Override
            public void init() {
                System.out.println("Default Keyboard Call");
            }
        };
    }
}

public class MouseFactory {

    public Mouse createMouse(String type) {
        if ("LG".equals(type)) {
            return new LGMouse();
        }
        if ("SAMSUNG".equals(type)) {
            return new SANSUNGMouse();
        }
        return new Mouse() {
            @Override
            public void init() {
                System.out.println("Default Mouse Call");
            }

            @Override
            public void sound() {
                System.out.println("Default Sound");
            }
        };
    }

}

 

Factory 클래스에서 만약 type이 LG라면 LG에 맞는 구현체를 넣고, Samsung이라면 Samsung에 맞는 구현체를 넣어주는 부분입니다. 만약 둘다 아니라면 익명클래스를 이용해서 기본 부품을 넣어주었습니다.

 

이제 컴퓨터를 조립해볼까요?

 

 

 

4. 완성

public class Computer {

    private Keyboard keyboard;
    private Mouse mouse;

    public Computer(String type) {
        keyboard = createKeyboard(type);
        mouse = createMouse(type);
    }

    public void call() {
        keyboard.init();
        mouse.init();
    }

    public void click() {
        mouse.sound();
    }

    private Keyboard createKeyboard(String type) {
        KeyboardFactory keyboardFactory = new KeyboardFactory();
        return keyboardFactory.createKeyboard(type);
    }

    private Mouse createMouse(String type) {
        MouseFactory mouseFactory = new MouseFactory();
        return mouseFactory.createMouse(type);
    }

}

 

Factory 클래스의 createKeyboard와 createMouse 메서드를 사용해서 구현체를 넣어주었습니다.

 

실제 컴퓨터를 사용해보겠습니다.

 

추상팩토리패턴의 결과

public class Main {

    public static void main(String[] args) {

        Computer computer = new Computer("LG");
        computer.call();

        computer.click();
        
        // LG Keyboard Call
        // LG Mouse Call
        // LG Sound
        
        Computer computer2 = new Computer("APPLE");
        computer2.call();

        computer2.click();

        // Default Keyboard Call
        // Default Mouse Call
        // Default Sound

    }
}

 

 

이 추상 팩토리 패턴의 장점은  공장에서 생성되는 제품들의 상호 호환을 보장할 수 있습니다.

그리고 구현체들은 한 곳에서 코드를 쉽게 유지보수할 수 있습니다.

 

단점으로는 패턴과 함께 새로운 인터페이스들과 클래스들이 많이 도입되기 때문에 코드가 필요 이상으로 복잡해질 수 있습니다.

객체지향 생활체조 원칙은 소트웍스 앤솔러지(ThoughtWorks Anthology) 라는 책에 나오는 원칙이다.

 

목차

  1. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
  2. else 예약어를 사용하지 않는다.
  3. 모든 원시 값과 문자열을 포장한다.
  4. 일급 컬렉션을 쓴다.
  5. 한 줄에 점을 하나만 찍는다.
  6. 줄여 쓰지 않는다 ( 축약 금지 )
  7. 모든 엔티티를 작게 유지한다.
  8. 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  9. Getter / Setter / Property를 쓰지 않는다.

 

 

Getter / Setter / Property를 쓰지 않는다.


벌써 마지막 9번째 입니다. 여전히 마지막까지 추상적인 말만 늘어놓네요.

이 규칙은 정말로 getter setter를 쓰지말라는 의미가 아닙니다. 객체 내부에 어떤 속성이 있는지 외부에서 알지 못하게 하는 캡슐화에 초점을 맞춰야합니다.

 

TDA 원칙이라는것이 있습니다. Tell, Don't ask, 즉 정보를 묻지말고 하고싶은걸 하라는건데요.

 

 

 

예시로 값이 짝수인지 확인하는 로직을 만들어보겠습니다.

public class Number {

    private int number;

    public Number(int number) {
        this.number = number;
    }

    public int getNumber() {
        return number;
    }
    
}

public class Main {

    public static void main(String[] args) {

        Number number = new Number(5);
        int intNumber = number.getNumber();

        if (intNumber%2 == 0) {
            System.out.println("짝수임");
        } else {
            System.out.println("짝수아님");
        }
    }
}

 

number.getNumber()를 호출하면서 객체 내부의 값이 노출되었고, 그 값을 로직에 사용했습니다. 확실히 캡슐화가 깨졌습니다.

짝수인지 판단하는 로직을 Number 객체 안에 숨겨보겠습니다.

 

 

public class Number {

    private final int number;

    public Number(int number) {
        this.number = number;
    }

    public void isEven() {
        if (number%2 == 0) {
            System.out.println("짝수임");
        } else {
            System.out.println("짝수아님");
        }
    }
}

public class Main {

    public static void main(String[] args) {

        Number number = new Number(5);
        number.isEven();

    }
}

 

Getter가 사라져 Number 객체안에 값을 알지 못하게되었습니다.

 

그렇다면 Gette는 절대 사용하면 안되는것일까요? 당연히 아닙니다. 경우에 따라 다릅니다.

객체의 값을 외부로 표현해주어야할 경우에는 Getter를 사용해야합니다.

 

public void printNumber() {
    System.out.println("현재 값은 " + number + " 입니다.");
}

 

 

 

 

 

Setter의 경우도 마찬가지입니다.

 

public class Main {

    public static void main(String[] args) {

        Number number = new Number(5);
        number.setNumber(number.getNumber() + 2);

    }
}

현재값에 2를 더하는 로직입니다. setter를 사용하기 위해 getter + 2를 하는건 상당히 보기 안좋습니다.

 

public void addNumber(int number) {
    this.number += number;
}


public class Main {

    public static void main(String[] args) {

        Number number = new Number(5);
        number.addNumber(2);

    }
}

 

핵심은 캡슐화라는것을 명심해주세요.

객체지향 생활체조 원칙은 소트웍스 앤솔러지(ThoughtWorks Anthology) 라는 책에 나오는 원칙이다.

목차

  1. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
  2. else 예약어를 사용하지 않는다.
  3. 모든 원시 값과 문자열을 포장한다.
  4. 일급 컬렉션을 쓴다.
  5. 한 줄에 점을 하나만 찍는다.
  6. 줄여 쓰지 않는다 ( 축약 금지 )
  7. 모든 엔티티를 작게 유지한다.
  8. 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  9. Getter / Setter / Property를 쓰지 않는다.
3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.

 

인스턴스 변수가 많으면 많을 수록 응집도가 떨어진다. 라고 해석하면 좋을 것 같습니다. 여기서 말하는 인스턴스 변수는 기본형을 의미하는것 같습니다. 확실하지는 않아요. 사실 이번편은 3편 원시값과 문자열을 포장한다와 맥락이 상당히 유사합니다.

 

 

public class Student {

    private String name;
    private String age;
    private String score;
    
}

Student 클래스안에 3개의 인스턴스 변수가 존재합니다.

 

이를 

public class Student {

    private Name name;
    private Age age;
    private Score score;
    
}

이렇게 원시값과 문자열을 포장하거나

 

public class Student {

    private UserInfo info;
    private Score score;
    
}

public class UserInfo {

    private String name;
    private int age;

}

 

DB 제2 정규화 과정처럼 부분적 종속 변수를 묶거나 하라는 의미같습니다.

 

사실 조금 이해가 안되는 부분도 많아서 인스턴스변수가 많아지면 응집도가 낮아진다 라고 이해하고 넘어가도 좋을것같습니다.

객체지향 생활체조 원칙은 소트웍스 앤솔러지(ThoughtWorks Anthology) 라는 책에 나오는 원칙이다.

목차

  1. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
  2. else 예약어를 사용하지 않는다.
  3. 모든 원시 값과 문자열을 포장한다.
  4. 일급 컬렉션을 쓴다.
  5. 한 줄에 점을 하나만 찍는다.
  6. 줄여 쓰지 않는다 ( 축약 금지 )
  7. 모든 엔티티를 작게 유지한다.
  8. 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  9. Getter / Setter / Property를 쓰지 않는다.

 

모든 엔티티를 작게 유지한다.

 

 

이 규칙은 클래스는 50줄 이하로 유지하고, 패키지는 10개 이하의 파일만 가져야한다. 라고 책에서 설명되어있습니다. 가능한가? 싶기도 하지만 잘 생각해보면 '코드가 길어진다는 것은 클래스가 한가지 이상의 일을 하고있을 확률이 높다' 라고도 해석할 수 있을 것같습니다.

그러니 50줄 이하의 클래스, 10개 이하의 패키지에 초점을 맞추기 보단 최대한 엔티티를 작게 유지할 수 있도록 노력하는것이 제가 해석하는 바입니다.

 

 

그냥 말로만 하고 넘어가긴 아쉬우니 지금까지 만들었던 StudentList 객체가 규칙을 잘 지키고 있는지 확인해보겠습니다.

public class StudentList {

    private final List<Student> students;

    public StudentList() {
        this.students = new ArrayList<>();
    }

    public void addStudent(Student student) {
        validateStudentsSize();
        validateStudentName(student);

        students.add(student);
    }

    public Student getTopScoreStudent() {
        Student topStudent = null;
        for (Student student : students) {
            if (topStudent == null) {
                topStudent = student;
                continue;
            }

            topStudent = topStudent.compareScore(student);
        }
        return topStudent;
    }

    public List<Student> getStudents() {
        return Collections.unmodifiableList(students);
    }

    public double getAverageAge() {
        return students.stream()
            .mapToInt(student -> student.getAge().getAge()) // 쉿!
            .average()
            .orElse(0);
    }

    private void validateStudentName(Student student) {
        String name = student.getName().getName();

        for (Student studentFor : students) {
            String nameFor = studentFor.getName().getName();

            if (name.equals(nameFor)) {
                throw new IllegalStudentsException("중복된 이름이 존재합니다.");
            }
        }
    }

    private void validateStudentsSize() {
        if (students.size() > 10) {
            throw new IllegalStudentsException("최대 학생 수는 10명입니다.");
        }
    }

}

 

라인을 확인해보니 58줄.. 8줄 오버되고 있네요. 저번편을 보신분들이라면 눈에 거슬리는 메서드가 있을겁니다.

바로 validateStudentName() 메서드입니다. 아.. '코드가 길어진다는 것은 클래스가 한가지 이상의 일을 하고있을 확률이 높다' 이 말이 틀린게 하나 없었네요. 대체 어디까지 보신겁니까....

 

 

// 변경 전
private void validateStudentName(Student student) {
    String name = student.getName().getName();

    for (Student studentFor : students) {
        String nameFor = studentFor.getName().getName();

        if (name.equals(nameFor)) {
            throw new IllegalStudentsException("중복된 이름이 존재합니다.");
        }
    }
}

// 변경 후
private void validateStudentName(Student student) {
    for (Student studentFor : students) {
        student.checkDistinctName(studentFor);
    }
}

 

 자세한 내용은 5편에서 다뤘기 때문에 생략하도록 하겠습니다.

 

StudentList의 길이가 58줄에서 52줄로 6줄 줄어들었습니다. 강박적으로 줄이라면 더욱 줄일 수 있겠지만 이 정도만해도 최대한 엔티티를 작게 유지하도록 노력한것이라고 봐도 무방하겠죠?

 

객체지향 생활체조 원칙은 소트웍스 앤솔러지(ThoughtWorks Anthology) 라는 책에 나오는 원칙이다.

목차

  1. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
  2. else 예약어를 사용하지 않는다.
  3. 모든 원시 값과 문자열을 포장한다.
  4. 일급 컬렉션을 쓴다.
  5. 한 줄에 점을 하나만 찍는다.
  6. 줄여 쓰지 않는다 ( 축약 금지 )
  7. 모든 엔티티를 작게 유지한다.
  8. 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  9. Getter / Setter / Property를 쓰지 않는다.
축약 금지

 

클래스, 메서드, 변수명의 이름을 줄여쓰지 말자는 규칙입니다.

 

이전시간에 예제로 사용되었던 StudentList 객체를 가져오겠습니다.

 

public class StudentList {

    private final List<Student> students;

    public StudentList() {
        this.students = new ArrayList<>();
    }

    public void add(Student student) {
        validateStudentsSize();
        validateStudentName(student);

        students.add(student);
    }
    
    // 이하 생략 ...
}

 

객체지향 생활체조 원칙 3. 모든 원시값과 문자열을 포장한다. 편 부터 읽으신분들은 이 코드를 보자마자 add 메서드는 Student 객체를 검증하고 List에 넣는 메서드라는것을 아실겁니다. 하지만 처음보는사람이 이 코드를 본다면 어떤 역할을 하는지 유추하기에는 어려움이 있죠. 물론 예제코드가 어렵지 않아 예상이 가겠지만 코드가 더 복잡해지고 일이 많아진다면 더욱 어려워질 것입니다.

 

따라서 이름을 바꿔보겠습니다.

 

public class StudentList {

    private final List<Student> students;

    public void addStudent(Student student) {
        validateStudentsSize();
        validateStudentName(student);

        students.add(student);
    }
    
    // 이하 생략 ...
}

 

이렇게만 바꿔주어도 의미가 명확해집니다.

 

 

축약금지의 의미는 정말 간단합니다. 일에 대한 명확한 이름을 지정해주어 혼동을 주지 말라는 의미입니다.

객체지향 생활체조 원칙은 소트웍스 앤솔러지(ThoughtWorks Anthology) 라는 책에 나오는 원칙이다.

목차

  1. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
  2. else 예약어를 사용하지 않는다.
  3. 모든 원시 값과 문자열을 포장한다.
  4. 일급 컬렉션을 쓴다.
  5. 한 줄에 점을 하나만 찍는다.
  6. 줄여 쓰지 않는다 ( 축약 금지 )
  7. 모든 엔티티를 작게 유지한다.
  8. 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  9. Getter / Setter / Property를 쓰지 않는다.

 

한 줄에 점을 하나만 찍는다.

 

 

단순히 한 줄에 점의 개수를 헤아려 줄이라는것이 아닙니다. 점을 찍는 행위는 인스턴스의 접근을 하는 행위로 이는 호출자와 피호출자와의 결합도가 강하게 형성된다는 의미입니다.

 

이전시간인 일급 컬렉션에서 사용되었던 List<Student> students를 예시로 사용해보겠습니다.

 

public class Student {

    private Name name;
    private Age age;
    private Score score;
    
    .. Getter
}

Score 객체를 추가로 넣었습니다. 내부에는 int score가 존재합니다.

 

List<Student> 내에서 가장 점수가 높은 학생을 선발한다고 가정한다면

public static void main(String[] args) {
    List<Student> students = new ArrayList<>();
    
    Student student = getTopScoreStudent(students);

}

private Student getTopScoreStudent(List<Student> students) {
    Student topStudent = null;
    for (Student student : students) {
        if (topStudent == null) {
            topStudent = student;
            continue;
        }
        if (topStudent.getScore().getScore() < student.getScore().getScore()) {
            topStudent = student;
        }
    }
    return topStudent;
}

이렇게 작성될것입니다. 하지만 처음에 언급했듯이 이 클래스와 Score와는 아무런 관계가 없습니다 즉, 불필요한 결합도가 높아졌다는 것을 의미합니다. 만약 Student 객체에 대한 변경이 생긴다면? Score 객체에 대한 변경이 생긴다면? Score 의 멤버변수 타입이 변경된다면? 당연하게도 위에 메서드를 사용하고있는 객체에 변경이 불가피합니다. 그리고 변경과정에서 오류의 가능성도 증가하겠죠.

 

그럼 어떻게 해야하는지 차근차근 알아봅시다.

 

 

먼저 저번시간에 만들었던 것처럼 일급클래스를 적용시켜줍니다.

public class StudentList {

    private final List<Student> students;

    public Student getTopScoreStudent() {
        Student topStudent = null;
        for (Student student : students) {
            if (topStudent == null) {
                topStudent = student;
                continue;
            }

            if (topStudent.getScore().getScore() < student.getScore().getScore()) {
                topStudent = student;
            }
        }
        return topStudent;
    }
    
    // 나머지 메서드 ...
}

 

일급 클래스만 적용시켜도 Main 클래스와의 결합도가 낮아졌습니다.

 

 

이제 Student 객체로 일을 넘겨보겠습니다.

public class StudentList {

    private final List<Student> students;

    public Student getTopScoreStudent() {
        Student topStudent = null;
        for (Student student : students) {
            // 생략 ...

            topStudent = topStudent.compareScore(student);
        }
        return topStudent;
    }
    
    // 나머지 메서드 ...
}

public class Student {

    private Name name;
    private Age age;
    private Score score;

    public Student compareScore(Student student) {
        if (score.getScore() < student.getScore().getScore()) {
            return student;
        }
        return this;
    }
}

 

이제 마지막으로 Score 객체에 일을 넘겨보겠습니다.

 

public class Student {

    private Name name;
    private Age age;
    private Score score;

    public Student compareScore(Student student) {
        Score compareScore = score.compareScore(student.getScore());
        if (this.score == compareScore) { // 같은 주소값을 반환하기 때문에 == 를 사용했습니다.
            return this;
        }
        return student;
    }
    
    // 이하 생략 ...
}

public class Score {

    private int score;

    public Score(int score) {
        this.score = score;
    }
    public Score compareScore(Score arguScore) {
        if (score < arguScore.getScore()) {
            return arguScore;
        }
        return this;
    }
    
    // 이하 생략 ...
}

 

기존 코드와 달리 하위 객체의 일을 알 필요가 없어졌고 그저 자신의 일만 하게 되었습니다.

이로써 Main - StudentList - Student - Score 간에 결합도를 완전히 낮췄습니다.

 

결국 이 한 줄에 한 점을 찍는다는 의미는 자신이 알고있는 객체하고만 상호작용하면 되고 제 3자인 객체하고는 상호작용하지 않음으로써 결합도를 약하게 만들어 종속성을 최소화 시키는것입니다.

 

객체지향 생활체조 원칙은 소트웍스 앤솔러지(ThoughtWorks Anthology) 라는 책에 나오는 원칙이다.

목차

  1. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
  2. else 예약어를 사용하지 않는다.
  3. 모든 원시 값과 문자열을 포장한다.
  4. 일급 컬렉션을 쓴다.
  5. 한 줄에 점을 하나만 찍는다.
  6. 줄여 쓰지 않는다 ( 축약 금지 )
  7. 모든 엔티티를 작게 유지한다.
  8. 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  9. Getter / Setter / Property를 쓰지 않는다.
일급 컬렉션

 

 

일급 컬렉션이란?

Collection을 Wrapping하면서 그 외의 다른 멤버 변수가 없는 상태를 일급 컬렉션이라고 합니다.

일급컬렉션의 장점은 아래와 같습니다.

  1. 비즈니스의 종속적인 자료구조
  2. 불변성 보장
  3. 상태와 행위를 한 곳에서 관리
  4. 이름이 있는 컬렉션

 

@Getter
public class Student {

    private Name name;
    private Age age;

    public Student(Name name, Age age) {
        this.name = name;
        this.age = age;
    }
    
}

 

이전 원시값 포장에 대해서 설명할때 사용되었던 Student 객체입니다.

 

이번에는 학생명단을 만들어보겠습니다.

  • 학생명단은 최대 10명
  • 이름은 중복되지 않아야함

예시를 위해서 두가지의 검증로직이 필요하다고 가정하겠습니다. 그리고 이 검증로직을 Service 계층에서 진행했습니다.

 

public class StudentService {

    public void addStudent(List<Student> students, Student student) {
        validateStudentsSize(students);
        validateStudentName(students, student);

        students.add(student);
    }

    private void validateStudentName(List<Student> students, Student student) {
        String name = student.getName().getName();

        for (Student studentFor : students) {
            String nameFor = studentFor.getName().getName();

            if (name.equals(nameFor)) {
                throw new IllegalStudentsException("중복된 이름이 존재합니다.");
            }
        }
    }

    private void validateStudentsSize(List<Student> students) {
        if (students.size() > 10) {
            throw new IllegalStudentsException("최대 학생 수는 10명입니다.");
        }
    }
}

 

비즈니스로직을 모두 처리했지만 약간의 문제가 있습니다.

  1. List 컬렉션에 의존적입니다.
  2. List<Student>와 StudentService간에 결합도가 높습니다.
  3. 다른 사람이 보았을 때 List<Student>의 일을 파악하기 어렵습니다.
  4. 컬력션이 변경된다면 OCP원칙에 위배됩니다.

이러한 직접적인 의존성은 프로그램이 변화하거나 성장할 때 유연성을 제한하며, List<Student>의 변경은 StudentService 클래스의 범위를 넓혀 유지보수를 어렵게 만듭니다. 이런 구조는 코드 변경에 취약하며, 새로운 데이터 구조나 변경 사항이 발생할 때마다 Service 로직을 수정해야 하는 번거로움이 발생할 수 있습니다. 또한 이 문제는 개방-폐쇄원칙(OCP 원칙)을 위배하게 됩니다.

 

이제 이 문제들을 해결해 보겠습니다.

 

 

 

1. 비즈니스의 종속적인 자료구조

public class StudentList {

    private final List<Student> students;

    public StudentList() {
        this.students = new ArrayList<>();
    }

    public void add(Student student) {
        validateStudentsSize();
        validateStudentName(student);

        students.add(student);
    }
    
    public List<Student> getStudents() {
        return Collections.unmodifiableList(students);
    }

    private void validateStudentName(Student student) {
        String name = student.getName().getName();

        for (Student studentFor : students) {
            String nameFor = studentFor.getName().getName();

            if (name.equals(nameFor)) {
                throw new IllegalStudentsException("중복된 이름이 존재합니다.");
            }
        }
    }

    private void validateStudentsSize() {
        if (students.size() > 10) {
            throw new IllegalStudentsException("최대 학생 수는 10명입니다.");
        }
    }
}

 

StudentList 클래스를 도입함으로써 List<Student> 객체를 캡슐화 했고, Service에서 관리하던 비즈니스로직을 StudentList 클래스로 종속시켰습니다이 변경으로 인해 StudentService(외부에서)는 더 이상 직접적으로 List<Student>를 다루지 않고, StudentList의 메소드를 통해 학생 목록을 조작할 수 있게 되었고, 

 

따라서 컬렉션 변경에 대한 OCP원칙을 지킬 수 있게 되었고, 학생명단과 StudentService간에 결합도가 낮아졌습니다.

 

 

 

2. 불변성 보장

일급 컬렉션은 컬렉션의 불변을 보장하는데, 단순히 final 을 사용하는 것이 아니라 캡슐화를 통해 이뤄집니다.

* final은 재할당만 금지하는 것이므로 add, remove 등이 가능합니다.

 

List<Student> 객체는 StudentList 클래스 내부에 있기때문에 Student 하위에 setter가 존재하지 않는 한 값의 변경이 일어날 수 없습니다. 불변성에 대한 자세한 내용은 차후 다룰 예정입니다.

 

public List<Student> getStudents() {
    return Collections.unmodifiableList(students);
}

 

// getStudents : Collections.unmodifiableList(students);
List<Student> students = studentList.getStudents();
Student newStudent = new Student(new Name("이OO"), new Age(21));
students.add(newStudent); // UnsupportedOperationException 런타임 예외발생!

 

 

3. 상태와 행위를 한 곳에서 관리

일급컬렉션은 값과 로직이 한 객체안에 있기때문에 응집도가 높아집니다. 따라서 변경이 용이하고 유지보수가 수월해집니다.

 

만약 비즈니스 로직이 추가된다면?

Service에서 로직을 추가할 경우 결합도만 높아지게 되고 수정사항이 증가하게 될겁니다.

하지만 일급컬렉션을 사용한다면 클래스 내부에서 수정 및 추가만 하면 됩니다.

 

public class StudentList {

    private final List<Student> students;

    public StudentList() {
        this.students = new ArrayList<>();
    }
	
    // 만약 나이의 평균을 구하는 로직이 필요하다면? 
    public double averageAge() {
        return students.stream()
            .mapToInt(student -> student.getAge().getAge())
            .average()
            .orElse(0);
    }
	
    // 나머지 메서드 ...

}

 

 

4. 이름이 있는 컬렉션

List<Student> 만으로 그 의미를 추론하기는 어렵습니다. 하지만 StudentList 또는 그 상황에 맞는 이름을 붙혀준다면 그 의미는 명확해지고, 다른 사람이 코드를 분석할 때 객체 이름으로 어떤일을 할지 파악하기 쉬워진다는 겁니다.

 

public static void main(String[] args) {

    // 같은 List<Payment> 이기 때문에 ,
    // List<Payment>를 매개변수로 받는 메서드에는 모두 들어갈 수 있습니다.
    List<Payment> kakaoPay = createPayments();
    List<Payment> naverPay = createPayments();

    // 객체가 다르다면 사용용도가 명확해집니다.
    KakaoPay kakaoPay = createKakaoPay();
    NaverPay naverPay = createNaverPay();

}

 

 

+ Recent posts