본문 바로가기
Java/[스프링 핵심 원리]

[스프링 핵심 원리] JAVA 프로젝트에 OOP 원리 적용하기 (SRP, OCP, DIP)

2021. 12. 5.
반응형

앞선 포스팅에서 스프링 프레임워크와 객체 지향 설계의 SOLID 원칙의 관계에 대해 알아보았다. 이번 포스트에서는 순수 자바로 된 예제 코드를 작성한 뒤 여기에 객체 지향의 원리를 적용해볼 것이며, 그 후에는 이를 스프링으로 간단히 전환해 보겠다.

이전 포스트 -> [스프링 핵심 원리] 객체 지향 SOLID 원칙과 Spring의 관계

 

JAVA 프로젝트 설계하고 구현하기

공부를 위해 간단히 정리하여 남기는 목적이라, 강의에서보다 더 단순한 예제를 만들어볼 것이다.

 

예제 프로젝트 요구사항

  • 회원은 가입하고 조회할 수 있다.
  • 회원 데이터는 자체 DB를 구축할 수도 있고, 외부 시스템과 연동할 수 있다. (미확정)

요구사항을 보면 '미확정'인 부분이 있다. 이는 추후 서비스 기획에 따라 언제든지 바뀔 수 있다. 그러나 확정될 때까지 무한정 기다릴 수는 없기 때문에 다형성을 이용해서 인터페이스를 만들고 구현체를 언제든지 바꿀 수 있도록 설계하면 된다.

 

회원 도메인 협력 관계

회원도메인-협력관계-다이어그램
회원 도메인 협력 관계

먼저 회원 도메인의 협력 관계를 표현하는 다이어그램이다. 이는 기획자도 함께 볼 수 있는 다이어그램이다.

클라이언트가 회원 서비스를 요청하고, 회원 서비스는 회원 저장소에 데이터를 요청할 것이다. 이때 회원 저장소는 기획에 따라 다양한 방식으로 변경될 수 있다.

 

회원 클래스 다이어그램

회원-클래스-다이어그램
회원 클래스 다이어그램

다음은 회원 클래스 다이어그램이다. 이는 앞서 설계한 협력 관계 다이어그램을 토대로 개발자가 구체화한 것이다. 클래스 다이어그램은 정적인 다이어그램으로, 서비스가 돌아가지 않아도 코드만 보고 확인할 수 있다.

그림에서는 MemberService 인터페이스를 구현하는 MemberServiceImpl 클래스가 있는데, 여기서는 이 클래스가 클라이언트가 된다. 클라이언트는 MemberRepository 인터페이스에 의존하며, 회원 가입 또는 조회 서비스를 요청한다.

MemberRepository 인터페이스는 MemoryMemberRepository와 DbMemberRepository가 각각의 로직을 구현하고 있다. 기획이 변경됨에 따라 MemberRepository의 구현체는 추가될 수 있고, 역할에 대한 구현체를 변경하여 사용하면 된다.

 

회원 객체 다이어그램

회원-객체-다이어그램
회원 객체 다이어그램

마지막으로는 객체 다이어그램이다. 이는 클래스 다이어그램과는 다르게 동적인 다이어그램으로, 실제 객체 간의 참조와 관계를 나타낸다. 런타임에 결정되기 때문에 클래스 다이어그램만으로는 알 수 없다. 위 그림에서는 MemberRepository에 대한 구현체로 MemoryMemberRepository(메모리 회원 저장소)가 주입되었다는 것을 알 수 있다.

 

회원 도메인 코드 작성

자 그럼 앞서 설계한 회원 도메인의 코드를 짜 보자. 필요한 코드는 회원 데이터를 담을 Member 클래스, 회원 서비스와 관련된 MemberService 인터페이스와 그 구현체, 회원 저장소와 관련된 MemberRepository 인터페이스와 그 구현체이다. 

 

Member 클래스

Member 클래스는 회원의 데이터를 저장하기 위한 DO(Data Object)이다. 아이디, 이름, 비밀번호 정보를 가진다.

package hello.core.member;

public class Member {
    private Long id;
    private String name;
    private String password;

    public Member(Long id, String name, String password) {
        this.id = id;
        this.name = name;
        this.password = password;
    }

    // + Getter And Setter...
}

 

MemberRepository와 구현체

MemberRepository 인터페이스는 회원 저장소 기능에 대한 기능을 정의한다. 이에 대한 구현체는 앞서 설계한 클래스 다이어그램에서처럼 다양한 방식이 존재할 수 있지만, 아직 기획이 확정되지 않았기 때문에 일단 가장 간단한 메모리 저장 방식을 사용할 것이다.

package hello.core.member;

public interface MemberRepository {
    void save(Member member);
    Member findById(Long memberId);
}
package hello.core.member;

import java.util.HashMap;
import java.util.Map;

public class MemoryMemberRepository implements MemberRepository{

    // 실무에서는 CuncurrentHashMap 사용해야 함 (동시성 이슈 있기 때문)
    private static Map<Long, Member> store = new HashMap<>();

    @Override
    public void save(Member member) {
        store.put(member.getId(), member);
    }

    @Override
    public Member findById(Long memberId) {
        return store.get(memberId);
    }
}

 

MemberService와 MemberServiceImpl

MemberService 인터페이스는 회원 서비스에게 필요한 기능을 정의한다. 그리고 구현체 MemberServiceImpl 클래스는 MemberService에 정의된 기능을 구체화한다.

package hello.core.member;

public interface MemberService {
    void join(Member member);
    Member findMember(Long memberId);
}
package hello.core.member;

public class MemberServiceImpl implements MemberService {

    // MemberRepository에 대한 구현 객체 선택
    private final MemberRepository memberRepository = new MemoryMemberRepository();

    @Override
    public void join(Member member) {
        // MemoryMemberRepository가 Override한 save() 메소드가 실행 됨
        memberRepository.save(member);
    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

 

회원 도메인 설계의 문제점

이 설계에는 문제가 있다. 추후 외부 저장소를 사용하는 것으로 결정될 때 클라이언트 코드의 변경 없이 기능을 확장하여 OCP 원칙을 잘 준수할까? 그리고 DIP를 잘 지키고 있을까? 이에 대한 대답은 둘 다 "아니오"이다. 어디가 어떻게 문제인지 이제부터 차근차근 알아보자.

 

 

 

객체 지향 원리 적용하기

기획의 변경으로 인한 저장소 기능 확장

설계한 프로그램의 문제점을 알기 위해서, 회원 저장소가 외부 저장소로 결정되었다고 가정하자. 다형성을 이용해 구현했기 때문에 인터페이스에 대한 새로운 구현체를 만드는 것으로 기능을 쉽게 확장할 수 있다.

 

그러면 외부 API를 이용하여 회원 정보를 저장하는, MemberRepository의 새로운 구현체 ApiMemberRepository를 구현해보자. 외부 API를 사용한다고 가정했기 때문에 자세한 내부 로직은 적지 않겠다. (실습을 원한다면 자신만의 특별한 로직을 적용해보아도 좋다.)

package hello.core.member;

public class ApiMemberRepository implements MemberRepository{

    @Override
    public void save(Member member) {
    	...
    }

    @Override
    public Member findById(Long memberId) {
    	...
    }
}

새로운 기능의 적용 및 문제점

방금 추가한 저장소 기능을 적용해보자. 저장소를 변경하려면 클라이언트인 MemberServiceImpl 코드를 고쳐야 한다.

package hello.core.member;

public class MemberServiceImpl implements MemberService {

    // MemberRepository에 대한 구현 객체 선택
//    private final MemberRepository memberRepository = new MemoryMemberRepository();
	private final MemberRepository memberRepository = new ApiMemberRepository();

    @Override
    public void join(Member member) {
        memberRepository.save(member);
    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

분명 역할과 구현을 충실하게 분리하고, 다형성을 활용했으며, 인터페이스와 구현 객체를 분리하였다. 그렇지만 기능의 확장 과정에서 OCP와 DIP를 위반하게 되었다.

먼저, 회원 서비스 클라이언트 MemberServiceImpl는 MemberRepository 인터페이스(추상)에 의존할 뿐만 아니라 Memory/ApiMemberRepository 클래스(구체)에도 의존하고 있다. 따라서 DIP 위반이다.

또한 기능을 확장해서 변경하면, 클라이언트인 MemberServiceImpl의 코드에 영향을 주기 때문에 변경하지 않고 확장할 수 있다는 OCP도 위반하게 된다.

 

그러면 이 문제를 어떻게 해결해야 할까? 클라이언트는 MemberRepository 인터페이스와 MemoryMemberRepository 클래스에 함께 의존하고 있기 때문에 구체 클래스를 변경할 때 클라이언트 코드도 함께 변경해야 하는 것이다. 따라서 DIP를 위반하지 않도록 추상(인터페이스)에만 의존하게 의존관계를 변경하면 된다.

package hello.core.member;

public class MemberServiceImpl implements MemberService {

//    private final MemberRepository memberRepository = new MemoryMemberRepository();
	private MemberRepository memberRepository;

    @Override
    public void join(Member member) {
        memberRepository.save(member);
    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

그러기 위해 위처럼 코드를 변경해보았다. 이 코드를 실행하면 구현체가 없기 때문에 당연하게도 NPE가 발생한다. 그럼 어떻게 해야 할까? 누군가가 클라이언트인 MemberServiceImpl에 MemberRepository의 구현 객체를 대신 생성하고 주입해주면 된다.

 

관심사의 분리

이전의 코드에서 클라이언트 MemberServiceImpl은 회원 서비스에 대한 기능을 수행해야 할 뿐만 아니라, 회원 저장소 서비스에 대한 구현 객체를 결정하여 생성하는 다양한 책임을 가지고 있다.

SRP 원칙에 맞게 회원 서비스와 회원 저장소의 책임을 확실히 분리해야 한다. 그러기 위해서는 구현 객체를 생성하고 연결해주는 별도의 역할을 만들어, 서비스들이 자신의 책임 외의 것에 신경 쓰지 않도록 관심사를 분리할 수 있다.

 

AppConfig 구현하기

애플리케이션의 전체 동작 방식을 구성(config)하기 위해, 구현 객체를 생성하고 연결하는 책임을 가지는 별도의 설정 클래스 AppConfig를 만들어보자. 

package hello.core;

import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;

// 역할에 대한 배역을 지정하는 관리자 파일
public class AppConfig {

    public MemberService memberService(){
        // * 생성자 주입
        // MemberServiceImpl에서 MemberRepository 배역에 대한 배우는 MemoryMemberRepository로 결정
        return new MemberServiceImpl(new MemoryMemberRepository());
    }
}

AppConfig는 애플리케이션의 실제 동작에 필요한 구현 객체를 생성한다. 또한 생성한 객체 인스턴스의 참조를 생성자를 통해서 주입해준다. 이 클래스를 도입하면 클라이언트 코드는 아래처럼 수정할 수 있다.

package hello.core.member;

public class MemberServiceImpl implements MemberService {

    // 생성자를 통해서 memberRepository에 뭐가 들어갈지 선택하도록 만듦 (구현체에 대한 내용이 없어짐) -> DIP 지킴
    private final MemberRepository memberRepository;
    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

	...
}

설계 변경으로 인해 MemberServiceImpl이 구체가 아닌 추상에만 의존하게 되었다. DIP를 만족하게 된 것이다.

이 클래스 입장에서는 회원 저장소 구현체로 어떤 구현체가 주입될지 알 수 없고 이는 오직 외부(AppConfig)에서 결정된다. 따라서 MemberServiceImpl은 이제부터 의존관계에 대한 고민은 외부에 맡기고 실행에만 집중하면 된다.

클라이언트인 MemberServiceImpl 입장에서 보면, 의존관계를 마치 외부에서 주입해주는 것 같다고 해서 DI(Dependency Injection: 의존관계 주입)이라고 한다.

 

AppConfig 리팩토링

마지막으로, 현재 AppConfig는 역할에 따른 구현이 잘 안보이기 때문에 이 코드를 리팩토링 해보자.

package hello.core;

import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;

// 역할에 대한 배역을 지정하는 관리자 파일
// 객체를 생성하고 관리하면서 의존관계를 연결해주는 DI 컨테이너
public class AppConfig {

    public MemberService memberService(){
        // 역할과 구현이 잘 드러나도록 표현
        return new MemberServiceImpl(memberRepository());
    }

    // 메소드 명에 역할이 드러남
    // 반환 객체에서는 구현체가 보임
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
}

위처럼 코드를 변경하면 추후 다른 서비스에서 MemberRepository를 사용할 때 불필요한 중복(new MemoryMemberRepository();)이 발생하지 않게 된다.

또한 메소드명에는 역할이, 반환 객체에서는 구현체가 한눈에 들어와 애플리케이션 전체 구성이 어떻게 되어있는지를 빠르게 파악할 수 있다.

 

새로운 구조와 저장소 정책 적용

그럼 다시 처음으로 돌아가서 회원 저장소를 메모리 저장소에서 외부 저장소로 변경해보자. 어떤 부분만 변경하면 될까?

package hello.core;

import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.ApiMemberRepository;

public class AppConfig {

    public MemberService memberService(){
        return new MemberServiceImpl(memberRepository());
    }

    public MemberRepository memberRepository() {
//        return new MemoryMemberRepository();
        return new ApiMemberRepository();
    }
}

AppConfig의 memberRepository() 메소드의 반환 객체만 변경하면 된다. AppConfig의 도입으로 애플리케이션이 크게 사용 영역과, 객체를 생성하고 구성하는 영역으로 분리되었다. 우리는 이 중 구성 영역만 변경하여 기능을 확장할 수 있게 된 것이다.

 

SOLID 원칙의 적용

여기서는 SRP, DIP, OCP가 적용된다.

 

1. SRP: 단일 책임 원칙

SRP를 적용하여 관심사를 분리하고, 구현 객체를 생성하고 연결하는 책임은 AppConfig가 담당한다. 클라이언트 객체를 실행하는 책임만 담당한다.

 

2. DIP 의존관계 주입 원칙

추상화에만 의존하고 외부(AppConfig)에서 의존관계를 주입함으로써 DIP를 만족하였다.

 

3. OCP 개방-폐쇄 원칙

다형성을 사용하고 클라이언트가 DIP를 지키기 때문에 OCP가 적용될 수 있는 환경이다. AppConfig로 인해 클라이언트 코드의 변경 없이 기능을 확장할 수 있기 때문에 OCP를 만족한다.

 

IoC, DI 그리고 컨테이너

IoC (Inversion of Control: 제어의 역전)

기존 프로그램은 구현 객체가 프로그램의 제어 흐름을 스스로 조종했다. 그러나 AppConfig가 등장한 후에는 AppConfig가 제어 흐름에 대한 권한을 가져갔다.

이렇듯 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것 제어의 역전(IoC)라고 한다.

 

DI (Dependency Injection: 의존관계 주입)

애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서, 클라이언트와 서버의 실제 의존 관계가 결정되는 것의존관계 주입(DI)이라고 한다.

의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고도 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.

 

IoC 컨테이너, DI 컨테이너

AppConfig처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 IoC 컨테이너 또는 DI 컨테이너라고 부른다. (최근에는 DI에 초점을 맞춰 주로 DI 컨테이너라고 부르면, Assembly, Object Factory 등으로도 불린다.)

 

 

 

이 포스트는 인프런의 [스프링 핵심 원리 - 기본편] (김영한) 강의를 수강하고 정리한 글입니다.

728x90
반응형

댓글