전체 보기
🚀

클린 아키텍처에서 코드 품질 향상의 근거 찾기 (with 레거시 리팩토링)

작성일자
2024/09/19
태그
DESIGN
HIGHLIGHT
프로젝트
WoowaCourse
책 종류
1 more property
지난 글에서 클린 아키텍처에 대한 오해를 풀어보았다. 이번 글에선 클린 아키텍처를 실제로 어떻게 적용할 수 있을지 구체적인 활용 방안을 공유하려 한다.

Uncle Bob 이 말하고자 하는 바

Uncle Bob의 클린 아키텍처에는 우리가 흔히 중요하다고 여기는 SRP, OCP, DIP 같은 설계 원칙들이 기저에 깔려 있다. 특히, 그는 그의 저서들에서 단일 책임 원칙(SRP)의 의미를 강조하며, 이 원칙이 단순히 “하나의 일만 해야 한다”는 것과는 다르다고 말한다. SRP는 “하나의 모듈이 변경되는 이유는 단 하나여야 한다” 또는 “하나의 모듈은 하나의 액터에 대해서만 책임을 져야 한다”라는 뜻이라고 강조한다.
그렇다면 그가 말하는 “하나의 모듈이 변경되는 이유가 하나뿐”이어야 한다는 것은 어떻게 가능할까? 바로 의존성 규칙이 이를 가능하게 한다. 이 규칙은 클린 아키텍처가 동작하는 데 가장 중요한 규칙 중 하나로, "소스 코드 의존성은 반드시 안쪽으로, 고수준의 정책을 향해야 한다"는 것이다. 이렇게 하면 외부의 세부 구현이나 기술적 변경이 안쪽의 중요한 비즈니스 로직에 영향을 미치지 않으며, 안쪽 모듈의 변경 이유는 오직 해당 모듈 내의 비즈니스 규칙이나 정책 변화에 국한될 수 있다.
이 의존성 규칙을 지키기 위한 지침으로 그는 원 모형을 제시하며, 원의 경계를 횡단하는 방법에 대해 설명한다. 원의 경계를 횡단할 때 제어 흐름과 의존성의 방향이 명백히 반대여야 한다면, 의존성 역전 원칙(DIP)을 사용해 해결하라고 제시한다.
결국 그가 클린 아키텍처를 통해 말하고자 하는 바는 전통적인 설계 원칙들과 크게 다르지 않다. 이 점을 유의한 채, 클린 아키텍처를 활용해보자. 참고로 아래의 소제목은 Uncle Bob 블로그 속의 Clean Architecture 라는 글의 소제목과 동일하다. 클린 아키텍처에서 말하는 개념을 어떻게 코드에 적용할지 레거시 코드를 리팩토링하며 탐험해보자.

원의 경계 횡단하기 (Crossing boundaries)

원의 경계를 횡단할 때 주의할 점이 있다. 바로 제어 흐름과 의존성의 방향이 명백히 반대여야 하는 경우다. 이때엔 의존성 역전 원칙을 사용해 해결해야 한다. 예를 들어 서비스에서 리포지토리를 호출해야 하는 경우, 직접 호출하면 의존성 방향이 반대가 되기 때문에 서비스가 리포지토리의 인터페이스를 호출하도록 만들고 인터페이스를 내부 원에 둔다. 그리고 외부 원에 리포지토리의 구현체를 두는 것이다. 간단하게 설명했는데, 실제 프로젝트에 이를 적용해보자.

레거시 구조: 전형적인 계층형 아키텍처

총대마켓은 JPA 와 MySQL 을 사용하고 있다. 서비스가 사용하던 편리한 형식의 객체 데이터가 JpaRepository를 통해 SQL 질의로 변환되어 데이터베이스로 전달된다. 데이터베이스에서 가져온 테이블의 행 데이터가 JpaEntityManager 를 통해 서비스가 사용할 수 있는 객체 데이터로 변환된다. 이때, JpaRepository와 JpaEntityManager는 MySQL과 총대마켓 서비스 사이에 위치한 아래와 같은 구조를 그려볼 수 있다.
위와 같은 흐름에선 서비스가 JpaRepository에 의존하게 된다. 더불어 JpaEntity에도 의존하게 된다. 서비스가 JpaEntity를 DomainEntity와 동일시해 사용한단건 DB 테이블을 클린 아키텍처 구조 상에 가장 안쪽 원에 위치하게 한 것과 마찬가지다. 기존 레거시 총대마켓의 코드를 간략히 보여주자면 아래와 같다.
// JpaEntity 겸 DomainEntity @Entity public class OfferingMember { @Id private Long id; private Member member; private Offering offering; private OfferingMemberRole role; } // JpaRepository public interface OfferingMemberRepository extends JpaRepository<OfferingMember, Long> { } // Service public class OfferingMemberService { private final OfferingMemberRepository; public long save(OfferingMemberSaveRequest request) { OfferingMember offeringMember = request.toOfferingMember(); OfferingMember savedOfferingMember = offeringMemberRepository.save(offeringMember); return saveOfferingMember.getId(); } public OfferingMemberFindResponse findById(Long id) { OfferingMember offeringMember = offeringMemberRepository.findById(id) .orElseThrow(() -> new OfferingMemberNotFoundException()); return new OfferingMemberFindResponse(offeringMember); } }
Java
복사

레거시 리팩토링 1: JpaEntity 와 DomainEntity 분리

사실 Service가 JpaRepository 를 extends한 Repositry 인터페이스에 의존하는 정도는 느슨한 결합으로 보고 넘어갈 수도 있다. 허나, JpaEntity를 DomainEntity와 동일시해 사용하는 건 말이 다르다. JpaEntity는 DB 테이블 구조와 동일한데, 사실상 DB가 서비스와 도메인이 위치한 원 내부까지 침투한 것이기 때문이다. 만일 DB 테이블 구조가 바뀐다면 원의 가장 안쪽까지 영향을 받는다. 이는 클린 아키텍처에서 말하는 의존성 규칙에 어긋난다.
따라서, 최소한 JpaEntity만큼은 내부 원에 침투하지 않게끔 바꿔보자. 이를 위한 가장 적절한 방법은 DomainEntity와 JpaEntity를 분리하는 것이다. DomainEntity와 JpaEntity를 분리함으로써 레거시 구조가 아래와 같은 구조로 바뀐다.
코드로 보여주자면 아래와 같다. (최대한 핵심적인 부분만 묘사하고 getter, 기본생성자 같은 코드는 의도적으로 제외함). 일단 가장 내부 원인 DomainEntity를 외부에 영향 받지 않게 고치는데 집중해보자.
// DomainEntity public class OfferingMember { private final long id; private final Member member; // 회원 private final Offering offering; // 공구 private final OfferingMemberRole role; // 공구 총대인지, 참여자인지 } // JpaEntity @Entity public class JpaOfferingMember { @Id private Long id; private JpaMember member; private JpaOffering offering; private OfferingMemberRole role; public JpaOfferingMember(OfferingMember offeringMember) { this( new JpaMember(offeringMember.getMember()), new JpaOffering(offeringMember.getOffering()), offeringMember.getRole() ); } public OfferingMember toOfferingMember() { return new OfferingMember(member.toMember(),offering.toOffering(),role); } } // JpaRepository public interface OfferingMemberRepository extends JpaRepository<JpaOfferingMember, Long> { } // Service public class OfferingMemberService { private final OfferingMemberRepository; public long save(OfferingMemberSaveRequest request) { OfferingMember offeringMember = request.toOfferingMember(); JpaOfferingMember jpaOfferingMember = new JpaOfferingMember(offeringMember); JpaOfferingMember savedJpaOfferingMember = offeringMemberRepository.save(jpaOfferingMember); OfferingMember savedOfferingMember = savedJpaOfferingMember.toOffering(); } public OfferingMemberFindResponse findById(Long id) { OfferingMember offeringMember = offeringMemberRepository.findById(id) .map(JpaOfferingMember::toOfferingMember) .orElseThrow(() -> new OfferingMemberNotFoundException()); return new OfferingMemberFindResponse(offeringMember); } }
Java
복사

레거시 리팩토링 2: 매핑 로직을 Service에서 Repository로 이동

(여기서부턴 총대마켓 팀엔 아직 적용되지 않았고, 제안해볼지 고민 중인 내용을 정리한 것이다)
DomainEntity 자체는 외부로부터 영향을 받지 않게 리팩토링되었지만, 이로 인해 Serivce 로직이 매핑 로직으로 가득 찼다. 이때 PersistenceAdaptor라는 Repository를 감싸는 클래스를 두어 번잡한 매핑 로직을 책임지게 할 수 있다. 구조는 아래와 같아진다. (소제목에선 PersistenceAdaptor를 Repository라고 표현함. 매핑 로직만 추가됐을 뿐 Repository와 본질이 같기 때문임)
코드로 보자면 아래와 같다. Service 코드가 다시 처음처럼 간결해진 걸 볼 수 있다.
// DomainEntity public class OfferingMember { private final long id; private final Member member; // 회원 private final Offering offering; // 공구 private final OfferingMemberRole role; // 공구 총대인지, 참여자인지 } // JpaEntity @Entity public class JpaOfferingMember { @Id private Long id; private JpaMember member; private JpaOffering offering; private OfferingMemberRole role; public JpaOfferingMember(OfferingMember offeringMember) { this( new JpaMember(offeringMember.getMember()), new JpaOffering(offeringMember.getOffering()), offeringMember.getRole() ); } public OfferingMember toOfferingMember() { return new OfferingMember(member.toMember(),offering.toOffering(),role); } } // JpaRepository public interface JpaOfferingMemberRepository extends JpaRepository<JpaOfferingMember, Long> { } // PersistenceAdaptor public class OfferingMemberPersistenceAdaptor { private final JpaOfferingMemberRepository jpaOfferingMemberRepository; public OfferingMember save(OfferingMember offeringMember) { JpaOfferingMember jpaOfferingMember = new JpaOfferingMember(offeringMember); JpaOfferingMember savedJpaOfferingMember = jpaOfferingMemberRepository.save(jpaOfferingMember); return savedJpaOfferingMember.toOfferingMember(); } public OfferingMember findById(Long id) { return jpaOfferingMemberRepository.findById(id) .map(JpaOfferingMember::toOfferingMember) .orElseThrow(() -> new OfferingMemberNotFoundException()); } } // Service public class OfferingMemberService { private final OfferingMemberPersistenceAdaptor offeringMemberPersistenceAdaptor; public long save(OfferingMemberSaveRequest request) { OfferingMember offeringMember = request.toOfferingMember(); OfferingMember savedOfferingMember = offeringMemberPersistenceAdaptor.save(offeringMember); return savedOfferingMember.getId(); } public OfferingMemberFindResponse findById(Long id) { OfferingMember offeringMember = offeringMemberPersistenceAdaptor.findById(id); return new OfferingMemberFindResponse(offeringMember); } }
Java
복사

레거시 리팩토링 3: Service-Repository 간에 의존성 역전

리팩토링 1과 2를 통해 가장 안쪽 원인 DomainEntity는 그 누구로도 간섭받지 않는 온전한 상태가 되었고 JpaEntity는 내부로 들어가지 않는 구조가 되었다. 허나 그 다음으로 안쪽 원인 Service는 아직 JPA에 의존한다. 정확하겐 JpaRepository에 의존한다.
여기서 누군가는 질문할 수도 있다. OfferingMemberRepository 가 인터페이스이므로 의존성 역전이 일어난게아니냐고. 허나 주의할 점은 인터페이스만 썼다고 의존성 역전이 일어나진 않는다는 거다. 의존성 역전이 일어나려면, 완전히 추상화된 인터페이스에만 의존해야 한다. 허나, offeingMemberRepository는 jpaRepositoy를 상속하고 있기에 구체적인 기술에 대한 의존이 완전히 제거되지 않았다. 이런 경우 느슨하게 결합됐다고 표현할 순 있어도 의존성이 역전됐다곤 할 수 없다.
물론 느슨한 결합 자체가 나쁜 것은 아니다. 하지만 느슨한 결합이 약해지는 시점을 고려할 필요가 있다. 그 대표적인 예가 JPQL이 도입될 때다. JPQL을 사용하면 SQL 쿼리문이 리포지토리 인터페이스에 포함되는데, 이때 서비스는 리포지토리가 작성한 구체적인 SQL 쿼리에 종속되게 된다. JPQL이 도입된 경우에는 리포지토리 인터페이스가 특정 기술에 더 강하게 종속되기 때문에 느슨한 결합이 약화되고, 의존성 역전의 필요성이 더욱 강조된다.
의존성 역전을 통해 서비스는 추상화된 인터페이스에만 의존하고, 구체적인 구현은 리포지토리 또는 Persistence Adaptor와 같은 계층에서 처리하도록 구조를 변경해보면 아래와 같은 구조가 된다.
코드로 보면 아래와 같다. 이 시점에서 주의할 점은 계층 구분이다. 아래와 같이 계층을 나눠 구분해주는 편이 좋다. 안쪽 계층부터 적어보면 아래와 같다. 이를 통해 두 번째 안쪽 계층도 바깥에 의존하지 않게 되었다. 클린 아키텍처 계열에서 가장 안쪽 계층과 두번째 안쪽 계층을 각각 도메인 게층과 애플리케이션 계층, 합쳐서 애플리케이션 코어라 부르기도 하는데, 이제 코어가 바깥의 세부사항으로부터 영향 받지 않는 구조가 되었다.
1.
Enterprise Business Rules : DomainEntity
2.
Application Business Rules: Service, PersistenceAdaptor
3.
Interface Adaptors: PersistenceAdaptorImpl, JpaRepository, JpaEntity
4.
Frameworks & Drivers : MySQL
// JpaEntity @Entity public class JpaOfferingMember { @Id private Long id; private JpaMember member; private JpaOffering offering; private OfferingMemberRole role; public JpaOfferingMember(OfferingMember offeringMember) { this( new JpaMember(offeringMember.getMember()), new JpaOffering(offeringMember.getOffering()), offeringMember.getRole() ); } public OfferingMember toOfferingMember() { return new OfferingMember(member.toMember(),offering.toOffering(),role); } } // JpaRepository public interface JpaOfferingMemberRepository extends JpaRepository<JpaOfferingMember, Long> { } // PersistenceAdaptorImpl public class OfferingMemberPersistenceAdaptorImpl implements OfferingMemberPersistenceAdaptor { private final JpaOfferingMemberRepository jpaOfferingMemberRepository; public OfferingMember save(OfferingMember offeringMember) { JpaOfferingMember jpaOfferingMember = new JpaOfferingMember(offeringMember); JpaOfferingMember savedJpaOfferingMember = jpaOfferingMemberRepository.save(jpaOfferingMember); return savedJpaOfferingMember.toOfferingMember(); } public OfferingMember findById(Long id) { return jpaOfferingMemberRepository.findById(id) .map(JpaOfferingMember::toOfferingMember) .orElseThrow(() -> new OfferingMemberNotFoundException()); } } // PersistenceAdaptor public class OfferingMemberPersistenceAdaptor { public OfferingMember save(OfferingMember offeringMember); public OfferingMember findById(Long id); } // DomainEntity public class OfferingMember { private final long id; private final Member member; // 회원 private final Offering offering; // 공구 private final OfferingMemberRole role; // 공구 총대인지, 참여자인지 } // Service public class OfferingMemberService { private final OfferingMemberPersistenceAdaptor offeringMemberPersistenceAdaptor; public long save(OfferingMemberSaveRequest request) { OfferingMember offeringMember = request.toOfferingMember(); OfferingMember savedOfferingMember = offeringMemberPersistenceAdaptor.save(offeringMember); return savedOfferingMember.getId(); } public OfferingMemberFindResponse findById(Long id) { OfferingMember offeringMember = offeringMemberPersistenceAdaptor.findById(id); return new OfferingMemberFindResponse(offeringMember); } }
Java
복사

레거시 리팩토링 4: JpaEntity와 DomainEntity의 1:1 구조 탈피

클린 아키텍처가 된건 알겠지만, 왜 클린 아키텍처가 원래 구조보다 더 좋은지 실감 나지 않는 이들이 있을 수 있다. 이제 특정 상황을 마주했다 가정해보자.
먼저, OfferingMember 테이블에 필드가 너무 많아져서 해당 테이블을 두 개의 테이블로 쪼개는 상황을 생각해보자. 리팩토링 전 레거시 코드에선 코어(도메인,서비스)가 전부 JPA에 의존하고 있었기에 프로젝트 전역적으로 코드를 수정해야 한다. 반면 리팩토링 후 코드에선 코어가 JPA에 의존하지 않기에 데이터베이스라는 세부사항이 아무리 변한다한들 코어는 영향 받지 않는다.
이번엔, 객체지향 관점에서 생각해보자. 위에 작성한 OfferingMember라는 도메인이 맡게 되는 책임이 점점 많아진다면 우리는 단일 책임 원칙에 따라 도메인을 나눌 수 있다. 허나 리팩토링 전 레거시 코드에선 테이블을 쪼개지 않는 한 도메인으로 쓰고 있는 JpaEntity를 함부로 쪼개긴 어려웠을 것이다. 도메인을 업무 규칙에 따라 나눌 수 있어서 얻는 이점을 더 살펴보자.
예를 들어 OfferingMember 라는 도메인의 Role 필드에 따라 역할이 다른 아래와 같은 도메인이 존재한다 해보자. 레거시 코드는 아래와 같은 구조로 점점 entity와 service가 무거워지는 걸 부담할 수 밖에 없었다.
// JpaEntity 겸 DomainEntity public class OfferingMember { private final Member member; // 회원 private final Offering offering; // 공구 private OfferingMemberRole role; public boolean isOfferingOwner(Offering other) { return offering.equals(other) && role == OfferingMemberRole.PROPOSER; }; public boolean canCancel() { return offering.canCancel() && role == OfferingMemberRole.PARTICIPANT; }; } // JpaRepository public interface OfferingMemberRepository extends JpaRepository<JpaOfferingMember, Long> { } // Service public class OfferingMemberService { private final OfferingMemberRepository offeringMemberRepository; private final OfferingRepository offeringRepository; public void deleteOffering(Long offeringMemberId, Long offeringId) { OfferingMember offeringMember = offeringMemberRepository.findById(offeringMemberId) .orElseThrow(() -> new NotFoundOfferingMemberException()); Offering offering = offeringRepository.findById(offeringId) .orElseThrow(() -> new NotFoundOfferingException()); if (!offeringMember.isOfferingOwner(offering)) { throw new CanNotDeleteException(); } offeringMemberRepository.delete(offeringMember); offeringRepository.delete(offering); } public void cancelParticipation(Long offeringMemberId) { OfferingMember offeringMember = offeringMemberRepository.findById(offeringMemberId) .orElseThrow(() -> new NotFoundOfferingMemberException()); if (!offeringMember.canCancel()) { throw new CanNotCancelException(); } offeringMemberRepository.delete(offeringMember); } }
Java
복사
반면 리팩토링한 구조에선 도메인을 도메인답게 다룰 수 있고, 객체답게 다룰 수 있다. 따라서 아래와 같이 객체를 분리해 역할을 분담할 수 있다. 그리고 이 역할 분담은 비단 도메인 뿐만 아니라, 리포지토리와 서비스까지 가벼워지게 만든다.
// DomainEntity public class Proposer { // 공구 총대 private fina long id; private final Member member; // 회원 private final Offering offering; // 공구 public boolean isOfferingOwner(Offering other) { return offering.equals(other); }; } public class Participant { // 공구 참여자 private fina long id; private final Member member; // 회원 private final Offering offering; // 공구 public boolean canCancel() { return offering.canCancel(); } } // ProposerPersistenceAdaptor public interface ProposerPersistenceAdaptor { void deleteProposer(Long proposerId, Long offeringId); } // ParticipantPersistenceAdaptor public interface ParticipantPersistenceAdaptor { void cancelParticipation(Long participantId); } // ProposerService public class ProposerService { private final ProposerPersistenceAdaptor proposerPersistenceAdaptor; public void deleteOffering(Long proposerId, Long offeringId) { proposerPersistenceAdaptor.deleteProposer(proposerId, offeringId); } } // ParticipantService public class ParticipantService { private final ParticipantPersistenceAdaptor participantPersistenceAdaptor; public void cancelParticipation(Long participantId) { participantPersistenceAdaptor.cancelParticipation(participantId); } } // ProposerPersistenceAdaptorImpl public class ProposerPersistenceAdaptorImpl implements ProposerPersistenceAdaptor { private final JpaOfferingMemberRepository jpaOfferingMemberRepository; private final JpaOfferingRepository jpaOfferingRepository; public ProposerPersistenceAdaptorImpl(JpaOfferingMemberRepository jpaOfferingMemberRepository, JpaOfferingRepository jpaOfferingRepository) { this.jpaOfferingMemberRepository = jpaOfferingMemberRepository; this.jpaOfferingRepository = jpaOfferingRepository; } @Override public void deleteProposer(Long proposerId, Long offeringId) { JpaOfferingMember offeringMember = jpaOfferingMemberRepository.findById(proposerId) .orElseThrow(() -> new NotFoundOfferingMemberException()); JpaOffering offering = jpaOfferingRepository.findById(offeringId) .orElseThrow(() -> new NotFoundOfferingException()); if (!offeringMember.isOfferingOwner(offering.toOffering())) { throw new CanNotDeleteException(); } jpaOfferingMemberRepository.delete(offeringMember); jpaOfferingRepository.delete(offering); } } // ParticipantPersistenceAdaptorImpl public class ParticipantPersistenceAdaptorImpl implements ParticipantPersistenceAdaptor { private final JpaOfferingMemberRepository jpaOfferingMemberRepository; public ParticipantPersistenceAdaptorImpl(JpaOfferingMemberRepository jpaOfferingMemberRepository) { this.jpaOfferingMemberRepository = jpaOfferingMemberRepository; } @Override public void cancelParticipation(Long participantId) { JpaOfferingMember offeringMember = jpaOfferingMemberRepository.findById(participantId) .orElseThrow(() -> new NotFoundOfferingMemberException()); if (!offeringMember.canCancel()) { throw new CanNotCancelException(); } jpaOfferingMemberRepository.delete(offeringMember); } }
Java
복사