[Java/객체지향] 객체지향적으로 개발해야하는 이유

양성욱·2023년 9월 13일
0
post-thumbnail

이 글은 프로그래머스 - 실무 자바 개발을 위한 OOP와 핵심 디자인 패턴 강의를 정리한 내용입니다.

Why OOP?

왜 우리는 객체지향적으로 개발해야 할까요?
표면적으로 생각해 보면 많은 기업들과 개발자들이 객체지향 프로그래밍 방식을 사용하고 있기 때문입니다.

그럼 질문을 바꿔볼까요?
왜 많은 기업들과 개발자들이 객체지향적으로 개발하고 있을까요?

그전에, 이 글을 읽고있는 여러분은 다음 질문에 대한 답을 할 수 있으신가요?

Q. 객체지향 프로그래밍이 무엇인가요?
Q. 왜 객체지향적으로 개발해야 하나요?

지금까지는 객체지향을 설명하기 위해 붕어빵과 붕어빵틀 같은 비유를 들었을 수 있지만, 이건 잘못된 비유입니다.

클래스를 통해 인스턴스를 생성할 수 있다는 설명은 할 수 있지만, 이건 객체지향에 대한 설명은 아닙니다.

캡슐화, 상속, 추상화, 다형성 같은 객체지향의 4대 특성이나 SOLID 원칙에 대해 얘기하는 것은 괜찮지만, 그냥 키워드에 대해 암기한 내용만 얘기하는 건 객체지향을 제대로 이해했다고 보기 어렵고 듣는 사람(특히 면접관) 입장에서도 그렇게 느낄 수 있습니다.

그럼 뭐 어떤 답변을 해야하는데요...? 😡

효과적인 답변은 개발 과정에서 자주 발생하는 문제를 객체지향 프로그래밍 방식을 도입하여 해결한 예시를 들어 설명하는 것입니다.
어떤 예시를 들어야 할지 생각해 보기 위해서는, 객체지향 프로그래밍 방식이 생기기 전에는 어떤 문제가 있었는지에 대해서 먼저 알아보야아 합니다!


프로그래밍 패러다임은 지속적으로 변화해왔습니다. 객체지향이라는 패러다임이 주를 이루기 전에는 절차지향이라는 패러다임이 주를 이루었습니다.
그럼 절차지향 패러다임에서는 개발 과정 중 어떤 문제가 자주 발생하였기에 객체지향 패러다임으로 넘어간 것일까요?

절차지향 패러다임에서 발생하는 문제점

지금부터 크게 두 가지 상황을 예시로 설명해보겠습니다!

데이터와 그 데이터에 접근할 수 있는 함수 사이에 서로 연관 관계가 낮음

데이터와 그 데이터에 접근할 수 있는 함수의 연관 관계가 낮다는 것은, 문법적으로 하나의 묶음처럼 다뤄지지 않는다는 것을 의미합니다.

다음 코드를 살펴보겠습니다.

ExCodeV1

class Product {
    public String name;
    public Integer price;
    public Integer amount;
}

class SomeClass {
    public void someMethod(Product product) {
        Integer totalAmount = product.price * product.amount;
        
        // 어떤 로직들
    }

    public void anotherMethod(Product product) {
        // 어떤 로직들

        Integer totalAmount = product.price * product.amount;
        
        // 어떤 로직들
    }
}

Product 클래스는 필드로 상품의 이름, 가격, 수량을 가지고 있습니다.

현재 위 코드는 상품의 총액을 구하는 로직이 코드 이곳저곳에서 중복적으로 발생하고 있다는 문제점이 있습니다.

따라서 코드를 다음과 같이 개선해보겠습니다.

ExCodeV2

class Product {
    public String name;
    public Integer price;
    public Integer amount;
}

class ProductFunctions {
    public static Integer getTotalAmount(Product product) {
        return product.price * product.amount;
    }
}

class SomeClass {
    public void someMethod(Product product) {
        Integer totalAmount = ProductFunctions.getTotalAmount(product);

        // 어떤 로직들
    }

    public void anotherMethod(Product product) {
        // 어떤 로직들

        Integer totalAmount = ProductFunctions.getTotalAmount(product);

        // 어떤 로직들
    }
}

상품에 대한 총액을 계산하는 함수를 별도로 선언하였고, 이를 통해 V1에서 발생하던 중복 코드 문제를 해결할 수 있게 되었습니다.

그럼 여기서 다음과 같은 질문을 드려보겠습니다.

❓ Product 클래스와 ProductFunctions 클래스의 연관 관계는 높다고 할 수 있을까요?

연관 관계가 높다/낮다는 어떻게 보면 상대적인 부분입니다. 그럼 다음 코드를 살펴보겠습니다.

ExCodeV3

class Product {
    private String name;
    private Integer price;
    private Integer amount;

    public Integer getTotalAmount() {
        return this.price * this.amount;
    }
}

class SomeClass {
    public void someMethod(Product product) {
        Integer totalAmount = product.getTotalAmount();

        // 어떤 로직들
    }

    public void anotherMethod(Product product) {
        // 어떤 로직들

        Integer totalAmount = product.getTotalAmount();

        // 어떤 로직들
    }
}

Product 클래스 내부로 ProductFunctions 클래스가 가지고 있던 함수가 들어가도록 코드를 변경하였습니다.

V2V3중 어떤 코드가 더 데이터와 함수의 관계가 밀접하게 연관되어 있다고 생각하시나요?

당연히 V3가 더 밀접하게 연관되어있습니다.

Product 클래스의 필드들은 더 이상 클래스 외부에서 접근할 수 없도록 private으로 접근 제한자가 변경되었습니다.
이 필드(데이터)에 접근하기 위해서는 오직 getTotalAmount와 같은 메서드(함수)를 통해야합니다.

이 코드는 데이터와 함수가 밀접하게 연관되어 있고, 오직 함수를 통해서만 필드를 사용할 수 있습니다.

V2에서는 ProductFunctions를 사용하지 않고도 어디서든 마음만 먹으면 필드에 접근할 수 있었습니다.
하지만 V3에서는 필드가 private을 유지할 수 있기 때문에 이러한 문제에서 자유롭습니다.

지금까지 예시를 든 내용이 바로 객체지향의 4대 특성 중 하나인 캡술화입니다.
캡슐화가 적용된 코드는 중복된 내용을 제거하고 데이터와 함수르 강하게 묶어둘 수 있습니다.

이걸 응집력이 높아진 코드라고 얘기합니다.

테스트 용이성


실제로 애플리케이션을 개발하다 보면 실제 프로덕션 환경과 테스트 환경에 따라 다른 로직을 실행시키고 싶은 상황이 올 수 있습니다.

😎 특정 작업의 처리 결과를 사용자에게 문자 메시지로 알려주는 서비스가 있다고 가정하겠습니다!

실제 서비스에서는 사용자에게 문자 메시지를 전송하지만, 테스트 환경에서는 메시지를 전송하고 싶지 않습니다. 이런 경우에는 코드를 어떻게 작성해야할까요?

MainV1

public class WithoutInterfaceExampleMain {
    public static void main(String[] args) {
        Client client = new Client();
        client.someMethod();
    }
}

Client 클래스의 인스턴스를 생성하고 해당 인스턴스의 someMethod 메서드를 호출하는 동작을 수행하는 코드입니다.

ClientV1

public class Client {
    public void someMethod() {
        // 메시지 보내기 전 실행되는 어떤 작업
        
        RealMessageSender messageSender = new RealMessageSender();
        messageSender.send();
    }
}

Client 클래스에서는 ReadMessageSender 클래스의 인스턴스를 생성하고 해당 인스턴스의 send 메서드를 호출하고 있습니다.

RealMessageSenderV1

public class RealMessageSender {
    public void send() {
        // 실제로 메시지 보내기
        System.out.println("RealMessageSender, 실제로 메시지 전송");
    }
}

RealMessageSender에서는 실제로 메시지를 전송한다고 가정하고, "실제로 메시지 전송"이라는 문자열을 출력합니다.

이제 MainV1을 실행하면 다음과 같은 출력 결과가 나옵니다.

RealMessageSender, 실제로 메시지 전송

이제 이 someMethod에 대한 기능 테스트를 진행하고 싶고, 테스트 환경에서는 실제로 메시지가 전송되지 않았으면합니다.
이를 위해 메시지를 실제로 발송하지 않는 별도의 FakeMessageSender 클래스를 새로 생성합니다. 그리고 이에 맞춰 Client 클래스도 수정해줍니다.

FakeMessageSenderV1

public class FakeMessageSender {
    public void send() {
        // 메시지는 안보내고 메시지를 보냈다는 로그만 찍기
        System.out.println("FakeMessageSender, 실제로 메시지 전송되지 않음");
    }

ClientV1 코드 변경

public class Client {
    public void someMethod() {
        // 메시지 보내기 전 실행되는 어떤 작업
        
        FakeMessageSender messageSender = new FakeMessageSender();
        messageSender.send();
    }
}

실행결과를 확인하면 "실제로 메시지 전송되지 않음"이라는 문자열이 잘 출력됩니다.

FakeMessageSender, 실제로 메시지 전송되지 않음

그런데... 혹시 지금까지의 과정에서 이런 생각이 들지 않으셨나요?

🤔 그럼 매번 someMethod를 테스트할때마다 코드를 계속 변경해줘야 하나...?

맞습니다. 예제로 보여드린 코드는 단순하기에 직접 변경하고 실행하는게 크게 어렵지는 않지만, 실제로는 코드의 복잡성이 높고 변경해줘야 할 로직이 여러곳에서 사용되는 경우가 많기 때문에 이러한 방식을 사용하는건 너무다도 비효율적입니다. 그리고 자칫하면 코드를 변경하던 중 실수를 유발할 위험성이 있습니다.

객체지향 프로그래밍에서는 이러한 문제에 대한 좋은 해결책을 제시하고 있습니다.

객체지향 프로그래밍 방식으로 개선해보기

코드를 개선해보겠습니다.

MainV2

public class WithInterfaceExampleMain {
    public static void main(String[] args) {
        MessageSender messageSender = new FakeMessageSender();
        Client client = new Client(messageSender);
        client.someMethod();
    }
}

V1과 달리 V2MessageSender 의존성을 직접 Client에 파라미터로 넣어주고 있습니다.

ClientV2

public class Client {
    private MessageSender messageSender;

    Client(MessageSender messageSender) {
        this.messageSender = messageSender;
    }

    public void someMethod() {
        // 메시지 보내기 전 실행되는 어떤 작업

        messageSender.send();
    }
}

ClientV2V1과 달리 생성자를 통해 messageSender를 초기화해주고 있습니다. 그리고 입력받은 파라미터의 send메서드를 호출하고 있습니다.

그리고 여기서 굉장히 중요한 포인트가 있습니다. MessageSender는 클래스가 아닌 인터페이스라는 점입니다.

MessageSender

public interface MessageSender {
    void send();
}

RealMessageSenderV2

public class RealMessageSender implements MessageSender {
    public void send() {
        // 실제로 메시지 보내기
        System.out.println("RealMessageSender, 실제로 메시지 전송");
    }
}

FakeMessageSenderV2

public class FakeMessageSender implements MessageSender {
    public void send() {
        // 메시지는 안보내고 메시지를 보냈다는 로그만 찍기
        System.out.println("FakeMessageSender, 실제로 메시지 전송되지 않음");
    }
}

그리고 FakeMessageSenderRealMessageSender는 각각 MessageSender 인터페이스를 구현하고 있습니다.

이제 실행해보면 이전 V1과 같은 결과가 잘 출력되는 걸 확인할 수 있습니다.

RealMessageSender, 실제로 메시지 전송

FakeMessageSender, 실제로 메시지 전송되지 않음

😡 ??? 근데 변경된 코드도 Main로직을 직접 변경해주고 있는거 아니에요? 이전 코드랑 다를게 없는데요?

물론 변경된 코드 역시 Main로직에서 계속 클래스를 직접 변경해주고 있기 때문에 지금의 코드 변화가 전혀 와닿지 않을 수 있습니다.
하지만 이 코드의 변화는 충분히 의미가 있습니다.

Client에서 MessageSender 의존성을 각각 파라미터에 넘기는 역할은 실제로는 개발자가 직접 하는게 아니라 Spring같은 OOP 프레임워크가 대신해줍니다.

우리가 집중해야할 포인트는 Client의 코드가 변경되지 않고 있다는 점입니다.
ClientV1에서는 내부 코드가 계속 변경되었습니다. 하지만 V2에서는 MessageSender 인터페이스를 참조하고 있기 때문에 외부에서 어떤 의존성이 들어오든 이제 더이상 신경쓸 필요가 없어졌습니다. 즉, 더이상 MessageSender의 구현이 아닌 역할에만 집중할 수 있게 된 것입니다.

⚠️ 스프링과 같은 OOP 프레임워크를 사용한다고 해서 무조건 객체지향 프로그래밍의 이점을 누릴 수 있는건 아닙니다. 지금처럼 코드를 객체지향적으로 잘 작성한 상태에서 활용했을때 비로서 이점을 누릴 수 있습니다.

V2에 사용된 방식을 의존성 주입(DI)이라고합니다. 이 코드의 실행에 필요한 인터페이스의 구현체를 외부에서 주입받는 방식입니다.
이를 통해 Client는 외부에서 어떤 의존성을 사용할지 선택받음으로서 코드 자체에서 어떤 구현에도 의존하지 않을 수 있습니다.

정리

객체지향 프로그래밍은 절차지향적인 개발 방법에서 발생하던 여러가지 문제점들을 해결하기 위해 등장한 패러다임 입니다.

지금 예시에서는 캡슐화, 테스트에 대한 용이성에 대한 문제를 객체지향 프로그래밍 방식으로 해결해보았습니다.

profile
개발의 신이시여... 제게 집중할 수 있는 ㅎ... 네? 맥주요?

0개의 댓글