스프링을 이용해 개발하며 언제든지 사용할 수 있는 나만의 레거시 코드를 작성해 두어야겠다는 생각이 들어 개발하던 도중 적용했던 권한검사에 관련된 부분을 적어두고자 한다. 다른 많은 방법들이 있겠지만 AOP와 Annotation을 이용한 방법이 개발 도중 만났던 문제점과 현재 회사에서 제시했던 AOP에 대한 문제점을 커버하는 해결책이라 생각하여 적용해 보았다.
AOP 사용 시 문제점
현재 근무하고 있는 회사에서는 AOP를 사용하지 않는다. 한눈에 코드의 흐름이 들어오지 않기 때문이다. 또한 나만의 레거시 코드를 위해 만들던 프로젝트에서는 일부 메서드에만 권한을 적용하고 싶었으나 순수 AOP만으로는 메서드 별로 적용할 수 없었다.
정리하자면 다음과 같다.
복잡성: 코드의 모듈화가 향상되지만, 여러 곳에서 적용되는 규칙 때문에 코드의 복잡성이 증가한다.
숨겨진 동작: AOP는 코드의 흐름을 바꿀 수 있기 때문에 코드의 동작을 이해하기 힘들다.
디버깅의 어려움: 코드의 실행 경로가 예상치 못하게 변경될 수 있기 때문에 디버깅이 힘들다.
문제 추적의 어려움: 코드의 흐름이 여러 곳으로 분산되므로 문제가 발생했을 때 문제 추적이 힘들다.
일부 메서드 적용 불가능: 메서드 별로 적용하고 싶었으나 AOP만을 이용했을 때는 어려움이 있다.
AOP 사용 시 장점
단점들이 분명 있지만 AOP 사용시 장점들도 존재한다. 그렇기에 AOP를 공부하고 적용해보고 싶었다.
장점들을 살펴보자.
관점 지향 프로그래밍: 애플리케이션을 특정 관점(보안, 로깅, 트랜잭션)으로 바라보고, 이러한 관점을 모듈화 하여 적용할 수 있다.
중복 코드 제거: AOP를 사용하면 여러 곳에서 반복되는 코드를 한 곳에 모아서 관리할 수 있다.
모듈화 및 재사용성: AOP는 비즈니스 로직과는 독립적으로 동작하기 때문에 모듈화와 재사용성을 높일 수 있다.
가독성 향상: AOP를 사용하면 핵심 비즈니스 로직과 별개로 발생하는 횡단 관심사(로깅, 트랜잭션, 보안 등...)를 분리할 수 있어 가독성이 향상된다.
AOP에는 포기하고 싶지 않은 장점들이 존재한다. 필자는 AOP를 한번쯤 사용해보고 싶었고 단점을 어느 정도 상쇄할 수 있는 방법을 찾아 적용해 보았다. 한번 살펴보자.
Custom Annotation과 함께 AOP 적용하기
AOP만을 사용한다면 특정 메서드에만 권한검사 로직을 적용하기 힘든 문제점이 존재하였다. 이를 해결하기 위해 Custom Annotation을 함께 사용하였고, Annotation이 존재한다면 무언가가 적용되어 동작하고 있다는 것을 알릴 수 있어 숨겨진 동작에 대한 단점을 어느 정도 상쇄할 수 있다고 판단하였다.
Custom Annotation 적용하기
/**
* 권한 확인 관련 ANNOTATION 구성
* AOP 적용 AuthAspect.java 클래스 파일 확인
* */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BeforeRequiresAuthorization {
String value();
}
먼저 위와 같이 어노테이션을 직접 만들어준다. value()를 선언한 이유는 각 메서드 별로 검사해야 하는 권한명을 받기 위해서다.
마지막으로 어노테이션이 적용된 메서드마다 AOP가 적용되도록 AOP클래스를 만들어 적용해 준다.
@Aspect
@Component
public class AuthAspect extends BaseController{
private final AuthFuncRepository authFuncRepository;
@Autowired
public AuthAspect(AuthFuncRepository authFuncRepository){
this.authFuncRepository = authFuncRepository;
}
@Before("@annotation(requiresAuthorization)")
public void checkAuthorization(BeforeRequiresAuthorization requiresAuthorization) {
String authorization = requiresAuthorization.value();
AuthFuncEntity auth = authFuncRepository.findByFuncNameAndAuthId(this.getSessionInfo().getFuncId(), authorization);
if(auth == null){
throw new APIException(ErrorCode.UNAUTHORIZED_FAIL);
}
}
}
필자가 메서드를 따로 적용하고 싶었던 이유는 해당 프로젝트에서 read에 관한 영역은 권한검사를 하지 않아도 된다고 생각했기 때문이다. 메뉴권한을 따로 관리하기 때문에 메뉴 접근권한 자체가 read 권한검사에 대한 역할을 수행하고 있다고 생각했기 때문이다.
그래서 AOP를 이용해서 read를 제외한 다른 기능에 대해서만 권한검사를 적용하고 싶었고 이에 대한 방법을 찾던 도중 어노테이션을 이용하여 적용할 수 있다는 방법을 알게 되어 적용하게 되었다.
정리
커스텀 어노테이션을 달아주어 무언가 동작하고 있다는 것을 알려주었고, 어노테이션의 이름을 통해 메서드가 실행되기 전에 실행된다는 것을 알려주었다. 단점들을 어느 정도 상쇄한 것처럼 보인다(필자의 주관적인 생각). 하지만 무분별하게 많이 사용했다가는 숨겨진 동작들이 많아 디버깅이 어렵고 복잡해질 것이라는 생각이 든다. AOP는 팀원들과 미리 약속된 영역들만 사용하는 게 좋을 것 같다. 권한검사, 로깅 등.... 어느 프로젝트를 유지보수하게 되어도 우리 팀에서 이 영역들은 AOP가 적용되어 있다!라는 걸 인지할 수 있도록 말이다.
기술 스택: vue.js, jsp, javascript, springframework, java, mybatis, mariaDB
협업 툴: svn
CI/CD: jenkins
기존 운영 중이던 mcms 프로젝트를 고도화하는 작업을 담당하게 되었고 사수로써 참여하게 되었다. 사수로 참여하기에는 부족한 점이 많았지만 팀장님께서 맡겨주셨고, 부사수 때와는 다르게 일에 대한 책임감과 부담감을 알게 되며 인간적으로 한층 더 성장할 수 있는 감사한 시간이었다.
MCMS란?
mcms는 Media Contents Management System의 약자로 미디어 콘텐츠(기록물)를 관리하는 아카이브다. 좀 더 풀어서 설명하자면 여러 가지 콘텐츠들을 기준별로 분류하여 관리하는 사진, 영상 콘텐츠/시청각기록물 관리 솔루션이다. 기록물을 콘텐츠별로 볼 수 있는 사용자 사이트와 기록물을 등록하고 관리하는 관리자 사이트로 구성된다.
MCMS 고도화 프로젝트 담당업무
기획 고도화 기능 중 일부인 통계페이지에 대한 기획 및 스토리보드 작성
서버이관 온프레미스 서버에서 KT클라우드 서버로 서비스 및 데이터 이관
외부 업체와 API에 대한 요구사항 정의 및 협의 API에 대한 요구사항을 정의, 협의, API 명세서 작성, 인터페이스 설계
데이터 마이그레이션 Excel -> mariaDB
페이지 구현 및 기능개발 페이지 퍼블리싱 및 요구 기능 구현
DB 백업 스크립트 적용 DB 데이터 백업을 위한 백업 스크립트 작성 및 적용
통합 테스트 통합 테스트 진행
산출물 일부 준비 테이블 목록 및 정의서, ERD, 테스트 케이스, 매뉴얼 작성
이번 프로젝트는 사수로 참여했던 만큼 책임질 일들과 해야 했던 일들이 많았다. 부사수 때는 페이지 구현과 기능개발에만 집중했었는데 그간 사수로써의 업무를 진행했던 선임이 얼마나 많은 일들을 책임지고 부담감을 느끼며 해왔는지 알게 됐다.
일정 및 이슈 관리
프로젝트 일정 및 이슈들은 모두 스프레드시트를 통해 관리했다. 퍼블리싱, API, 테스트, 에러사항들을 모두 스프레드 시트의 탭을 나누어 관리했으며 프론트는 주소를 기준으로, 백엔드는 엔드포인트를 기준으로 모든 사항들을 관리하였다. 노션으로 관리하는 방법을 생각해 보았으나 현재 팀에서는 스프레드시트를 통해 모든 프로젝트를 공유하고 있으므로 모두가 알기 편한 방법인 회사 기준에 맞춰 진행하였다.
기획
해당 프로젝트의 전체적인 기획은 아니지만 관리자 사이트 통계 페이지에 대한 기획을 개발자 둘이서 진행하였다. 클라이언트가 필요로 하는 게 뭔지 생각해 보고 성장하길 원하시는 마음에 이사님께서 맡겨주셨었다. 누군가가 정해준 대로만 만들다가 화면이 어떻게 구성돼야 사용자가 편하게 볼 수 있을지, 어떤 데이터를 보여주어야 사용자가 필요로 하는 통계 데이터를 만들 수 있을지 많은 고민을 해볼 수 있는 시간이었다. 기획하는 시간과 결과 자체가 순조롭지는 않았으나 이사님과 차장님의 조언을 통해 잘 마무리할 수 있었다.
기획을 할 때는 "누가 어떤 이유로 필요로 하는가?" 에서부터 시작을 했어야 했다. 처음에는 기록물관리 사이트에 통계 페이지이기에 콘텐츠를 기준으로 기간별 등록, 보유, 조회수에 대한 통계를 보여주려고 했었다. 그러나 클라이언트는 "부서별로 성과를 나타낼 수 있는 통계 데이터"를 필요로 했었다. 모든 기준이 부서별 성과를 나타낼 수 있는 지표가 되어야 했던 것이다. 이후 클라이언트의 필요사항에 따라 수정을 하였고 기획한 페이지를 통과받을 수 있었다. 처음부터 알고 진행했으면 좋았겠지만 그래도 이번 경험을 통해서 이러한 배움을 얻을 수 있었음에 감사한 시간이었다.
서버 이관
서버 이관 작업은 CentOS 7.9 운영체제에 직접 apach, tomcat, mariaDB를 설치하며 서비스 운영환경을 구축해 볼 수 있는 기회였다. 종종 vm에 테스트 환경을 구성하는 연습을 했었는데 실제 적용할 기회가 다가와 감사했다. 설치는 기존 온프레미스 서버에서 동작하던 것과 같이 이상 없이 작동하기 위해 기존에 설치하였던 컴파일 소스를 사용하여 클라우드 서버에 컴파일 설치 방식으로 설치하였다.
서버이관 작업을 해본 적이 없었기에 잘할 수 있을까?라는 막연한 두려움이 있었으나 큰 어려움 없이 잘 끝났다. 계정에 루트 권한을 부여받기도 했었고, 기존 온프레미스 서버와 경로를 똑같이 구성하기도 했었다. 가장 큰 요소는 vm에 같은 환경을 구성해 보며 연습하고 스크립트를 미리 정리를 해뒀던 게 큰 도움이 되었다.
외부 업체와 API에 대한 요구사항 정의 및 협의
이번에 진행한 mcms 프로젝트는 메인이 되는 사이트가 따로 있었다. 적절한 예시가 생각나지 않지만 굳이 뽑아보자면 네이버와 네이버 웹툰과 같은 관계라고 볼 수 있는 것 같다. 우리가 네이버 웹툰 측인 셈인데 로그인 기능과 콘텐츠 자동연계 기능을 구현하기 위해 우리에게 필요한 요구사항을 정리하고 협의하는 과정을 거쳤다. 협의 과정은 굉장히 순조로웠고, 해줄 수 있다는 몇 마디의 말이 오고 간 게 전부였다.
API 문서를 작성하며 배운 점은 세세하고 명확히 작성해줘야 한다는 것이었다. 테스트 URL, 상용 URL, 파라미터(타입, 필수여부, 대소문자), 암호화 방법, 데이터 목록, 포맷형식, Http Method까지 예시와 함께 틈틈이 작성해서 전달하였다. 우리가 API를 제공받는 입장이기 때문에 필요한 데이터 목록만 전달하고 나머지는 협업사 측에 맡기는 게 맞는 것 같았지만 우리가 원하는 대로 맞춰주겠다고 하셔서 그렇게 진행했다.
API 문서를 보고 작업을 하기만 해 봤지 직접 다른 업체와 협의하는 과정을 거친 건 처음이었기에 좋은 경험이 되었다.
데이터 마이그레이션
Excel에 담긴 자료들을 데이터베이스에 이관하는 작업들을 하였다. 어려울 것은 없었으나 클라이언트 측에서 전달해 준 양식대로 작성해주지 않았다는 문제였다. 부서가 제각각이라 그런지 잘 작성해 주신 부서도 있는 반면 전달된 양식에 마음대로 작성해서 주는 일이 있었다(결국은 필자가 양식대로 다시 맞춰야 하는 일이 있었다). 이미지, 영상, pdf 등.... 파일 같은 경우에는 경로가 틀리면 상당히 곤란했었다. 번거롭지만 파일을 찾지 못한 것들은 디비에 기록해 뒀다가 다시 경로를 잡고 변환해 주어야 했다.
또 특수한 상황이었기에 주말에만 작업을 진행할 수 있는 상황이었다.... 클라우드 서버로 이관하였는데 대용량의 파일들과 데이터들을 넣을 때 비용이 발생하기 때문이었다. 번거롭지만 주말에 서버를 내린 후에 파일 변환 및 데이터를 넣는 작업을 진행하였었다. 어려운 일은 없었으나 계속 신경이 쓰이는 작업이었었다.
페이지 개발 및 기능 개발
이번 프로젝트의 주된 작업은 통계 페이지와 API 연계 관련 기능 작업이었다. API 연계 작업은 걱정했던 것과 달리 협업체와의 소통과 API 문서가 잘 작성되니 무난하게 작업이 끝났다. 오히려 처음 구현해 보는 통계 기능에 대해서 많은 생각을 해보게 되었고, 많은 배움을 얻을 수 있는 시간이 되었다. 통계 페이지를 만들 때 생각해야 할 것들을 조금 정리해 본다.
통계 페이지란?
통계 페이지란 과거의 데이터를 집계하여, 저장 후 화면에 표출하는 페이지다. 데이터의 정합성이 요구되며, 오차가 있어서는 안 된다.
데이터 정합성 (data consistency)
데이터 정합성이란? ※ 어떤 데이터들의 값이 서로 일치하는 상태를 의미한다.
통계 페이지는 데이터의 정합성이 가장 중요하다. 데이터 정합성을 위해서 살펴봐야 할 것들을 정리해 보았다.
이력 (history data)
이력 데이터를 만드는 이유 1. 문제 발생 시 과거의 데이터를 통해 원인을 발견하고 해결하기 위해서 2. 데이터를 쌓아 유의미한 통계 데이터를 만들기 위해서
이력 테이블을 설계할 때는 꼭 이력만으로 집계 데이터를 만들 수 있도록 설계해야 한다. 즉, 집계 데이터를 만들기 위해 필요한 모든 데이터를 가지고 있는 게 있어야 한다.
데이터는 발생되는 이벤트(사원이라면 부서 이동, 입사, 퇴사 등…)에 따라 얼마든지 변경될 수 있다. 과거의 이력을 보기 위해 만든 통계 데이터에 이벤트에 따라 실시간으로 변경될 수 있는 데이터가 끼어든다면 추후 데이터의 정합성이 깨질 가능성이 매우 크다. 그렇기에 자세한 이력을 쌓아 이력만으로 집계 데이터를 만들 수 있어야 한다. (자세한 이력의 기준은별다른 DB Join 없이 이력 데이터만을 가지고 집계 데이터를 만들 수 있어야 한다.)
이력 데이터만을 가지고 집계 데이터를 만들 수 있다면 이력 데이터는 집계 데이터에 대한 검증 데이터가 될 수 있으며, 문제가 생겨 다시 집계를 해야 할 시 과거에 쌓아둔 이력 데이터만을 가지고 다시 집계 데이터를 만들 수 있다.
집계 (total data)
집계 데이터를 만드는 이유 ※ 집계 데이터 없이 이력 데이터만을 쿼리 하여 페이지에 표출할 때에는 데이터의 양에 따라 속도 이슈를 야기할 수 있기 때문이다.
집계 테이블을 설계할 때는 페이지에 표출할 데이터를 집계 테이블 내의 데이터 만으로 표출할 수 있도록 설계하고, 데이터를 쌓아야 한다. 하나 더 생각할 것은 집계 테이블이 정말 필요한지 생각해 보아야 한다. 이력만을 쌓고 그때그때 집계해도 충분할 수 있다.
DB 데이터 백업
최근 프로젝트를 유지보수 하며 DB의 테이블이 사라지는 일이 있었다. 후임에게 운영 DB와 테스트 DB의 정보를 알려줬었는데 마우스 클릭미스로 운영 DB의 테이블을 드롭하는 일이 발생했었다. 다행히도 매일 백업을 해두고 있었고, 밤 사이에 추가된 변경된 데이터가 없어 큰 문제없이 복구하였는데 땀이 삐질삐질 나고 식겁했던 순간이었다.
OS 환경이 centos 인 만큼 DB 데이터 백업은 crontab을 이용하여 작업했다. DB 백업 명령어를 작성하여 crontab에 등록하여 간단하게 백업할 수 있도록 만들었다.
크론탭(crontab)이란? 크론탭은 리눅스 운영체제에서 배치 작업을 스케줄링하기 위한 프로그램이다. 크론탭을 이용하면 특정 시간에 정기적인 작업이 가능하다.
통합 테스트 (Integration Testing)
이번 프로젝트는 테스트 코드를 따로 작성하지 않았다. 작성할 여유도 부족하였지만, 고도화 이전에 작성된 테스트 코드가 없었기 때문이다. 그래서 모든 개발이 완료된 후 통합 테스트(Integration Testing)를 진행하였다. 진행 방식은 스프레드시트에 시나리오를 작성하고 페이지의 UI들이 사전 정의된 기능대로 동작하는지 1차, 2차 검증을 통한 방식으로 진행하였다.
통합 테스트(Integration Testing)란? 시스템의 여러 구성요소들이 함께 작동하여 예상대로 상호작용하는지 확인하는 테스트 과정이다.
산출물 준비
산출물은 테이블 목록 및 정의서, ERD, 테스트 케이스, 사용자 매뉴얼, 관리자 매뉴얼을 작성하였다.
테이블 목록
물리적 테이블명, 논리적 테이블명을 excel 파일에 리스트화한다.
테이블 정의서
테이블명, 칼럼의 물리적, 논리적 명칭, 타입, 길이, pk, fk, notnull 등을 excel 파일에 명시한다.
ERD
ERD는 eclipse의 ermaster를 이용하여 작성하였다. 회사에서 공통적으로 사용하고 있기 때문이다.
사용자 매뉴얼
사용자 매뉴얼은 처음 사이트를 접하는 사용자가 수월하게 기능을 이용할 수 있도록 작성해 두었다.
관리자 매뉴얼
관리자 매뉴얼은 시스템이 꺼지거나 Error 발생 시 어떻게 대처해야 하는지 상세히 적어두었다.
처음 작성해 보는 것들이었지만 경험이 되었다. 생각보다 프로젝트를 마칠 때에는 이렇게 많은 파일들을 제공해 줘야 하는구나라는 것을 알게 되었다.
마무리
사수로써 프로젝트를 진행한 경험은 처음이고, 업체와 먼저 나서서 연락한 경험도 처음이기에 쉽지 않은 시간이었다. 책임감과 부담감에 야근하는 일도 많았지만 개발자로써 그리고 인간적으로 한층 성장하는 계기가 되어준 프로젝트였다. 경험이 늘어난다는 것에 대한 감사로 해당 프로젝트를 잘 마무리하였다.
이 글에 주된 주제는 GoF의 디자인 패턴에서 정의하고 있는 디렉터 빌더 패턴입니다. 그러나 이펙티브 자바의 Simple Builder Pattern도 존재하기에 공부를 위해 일부를 다루고 있습니다.
Director Builder Pattern (디렉터 빌더 패턴) 이란?
복잡한 객체들을 동일한 프로세스를 통해 단계별로 다양하게 생성할 수 있도록 하는 생성 디자인 패턴이다.
Builder: 빌더 인터페이스는 제품 즉, 인스턴스를 생성할 때 필요한 단계들을 선언한다.
ConcreateBuilderA: 빌더 인터페이스의 구현체다. 제품(Product) 생성에 필요한 단계들을 구현하며, 인터페이스를 따르지 않는 제품을 생산할 수도 있다.
Product: ConcreateBuilder를 통해 생성된 제품이다.
Director: 자주 사용되는 제품들을 정의해 두고 재사용할 수 있다.
Client: 디렉터를 호출하여 제품들을 사용한다.
빌더 패턴은 인스턴스를 만드는 방법들을 단계별로 정의하여 인스턴스를 만들어내는 패턴이라고 볼 수 있다. 아래 예제를 통해 빌더 패턴으로 어떠한 문제들을 해결할 수 있는지 알아보자.
Builder Pattern 사용 전 문제점
House(집) 객체를 만든다고 가정해 보자. 기본적으로 집을 지으려면 벽을 만들고, 문을 달고, 창문을 달아준 후, 지붕을 얹어줘야 한다. 하지만 이때 기본적인 집이 아닌 필요에 따라 옵션으로 난방 시스템, 수영장, 정원 등... 이 추가된 더 좋은 집을 원한다면 확장에 따라 클래스가 복잡해지거나, 생성자가 더러워지고 뚱뚱해지는 문제가 생긴다. Builder 패턴 등장 이전에 문제 해결을 위해 사용했던 방법을 알아보자.
점층적 생성자 패턴
점층적 생성자 패턴(Telescoping Constructor Pattern)은 생성자 오버로딩을 이용한 패턴이다. 필수 매개변수와 선택 매개변수들을 포함한 인스턴스를 생성할 때에 사용한다.
public class Home {
private int walls;
private int windows;
private int doors;
private int rooms;
private boolean hasHeating;
private boolean hasGarage;
private boolean hasSwimPool;
private boolean hasGarden;
// 생성할 인스턴스들에 필요한 선택적 매개변수 조합에 따라 생성자 메서드를 계속 만들어야 한다.
public Home(int walls, int windows, int doors, int rooms, boolean hasHeating, boolean hasGarage, boolean hasSwimPool, boolean hasGarden) {
this.walls = walls;
this.windows = windows;
this.doors = doors;
this.rooms = rooms;
this.hasHeating = hasHeating;
this.hasGarage = hasGarage;
this.hasSwimPool = hasSwimPool;
this.hasGarden = hasGarden;
}
public Home(int walls, int windows, int doors, int rooms, boolean hasHeating, boolean hasSwimPool) {
this.walls = walls;
this.windows = windows;
this.doors = doors;
this.rooms = rooms;
this.hasHeating = hasHeating;
this.hasSwimPool = hasSwimPool;
}
public Home(int walls, int windows, int doors, int rooms) {
this.walls = walls;
this.windows = windows;
this.doors = doors;
this.rooms = rooms;
}
}
public static void main(String[] args) {
// 전달해야 하는 인자값의 순서를 정확히 알기 힘들다.
Home home = new Home(4, 4, 1, 2, true, true, false, true);
}
그러나 점층적 생성자 패턴은 만들어야 하는 인스턴스에 조합이 다양해질수록 그에 대응하는 생성자 메서드를 만들어줘야 하며, 전달해야 하는 인자값이 많아질수록 인자값의 순서를 알기가 힘들어 가독성이 떨어진다.
자바 빈즈 패턴
자바 빈 패턴은 setter 메서드를 이용해 객체를 생성하는 패턴이다. 매개변수가 없는 생성자로 객체를 생성 후 setter 메서드를 이용해 클래스의 필드값을 설정하는 방식이다.
public class Home {
private int walls;
private int windows;
private int doors;
private int rooms;
private boolean hasHeating;
private boolean hasGarage;
private boolean hasSwimPool;
private boolean hasGarden;
public void setWalls(int walls) {
this.walls = walls;
}
public void setWindows(int windows) {
this.windows = windows;
}
public void setDoors(int doors) {
this.doors = doors;
}
public void setRooms(int rooms) {
this.rooms = rooms;
}
public void setHasHeating(boolean hasHeating) {
this.hasHeating = hasHeating;
}
public void setHasGarage(boolean hasGarage) {
this.hasGarage = hasGarage;
}
public void setHasSwimPool(boolean hasSwimPool) {
this.hasSwimPool = hasSwimPool;
}
public void setHasGarden(boolean hasGarden) {
this.hasGarden = hasGarden;
}
}
public static void main(String[] args) {
// 기본 집
Home defaultHome = new Home();
defaultHome.setDoors(2);
defaultHome.setWindows(2);
defaultHome.setWalls(2);
defaultHome.setRooms(3);
// 정원이 있는 집
Home hasGardenHome = new Home();
hasGardenHome.setHasGarage(true);
hasGardenHome.setDoors(2);
hasGardenHome.setWindows(2);
hasGardenHome.setWalls(2);
hasGardenHome.setRooms(3);
// 수영장이 있는 집
Home hasSwinPoolHome = new Home();
hasSwinPoolHome.setHasSwimPool(true);
hasSwinPoolHome.setDoors(2);
hasSwinPoolHome.setWindows(2);
hasSwinPoolHome.setWalls(2);
hasSwinPoolHome.setRooms(3);
}
자바 빈 패턴은 생성자 오버로딩을 사용했을 때 나타났던 가독성 문제와 많은 생성자를 만들어야 했던 문제가 사라진다. 그러나 이 방식은 객체 하나를 만들기 위해 여러 메서드를 호출해야 하며, 객체 생성 시점에 모든 값들을 주입하지 않아 일관성(consistency) 문제와 불변성(immutable) 문제가 나타나게 된다.
일관성
일관성이란 인스턴스를 생성할 때 필요한 필수 필드값들 반드시 설정되어 있어야 하며, 몇 번을 찍어내도 필수 값들이 존재하는 상태를 말한다. 그러나 자바 빈즈 패턴은 개발자가 실수로 setter 메서드를 호출하지 않는다면 해당 인스턴스는 일관성이 무너진 상태가 된다. 즉, 런타임 예외를 발생시킬 수도 있다. 해당 문제점은 생성자와 결합하여 해소할 수 있으나 불변성의 문제 때문에 자바 빈즈 패턴은 지양한다.
불변성
setter 메서드는 객체를 처음 생성할 때 필드값을 설정하기 위해 존재하는 메서드다. 그러나 setter 메서드는 외부에 노출되어 있는 메서드이다. 협업 과정 중 누군가 setter 메서드를 호출하여 객체를 조작할 수 있게 된다. 그렇기에 불변성을 보장할 수 없게 된다. (창문이 3개만 달린 Home 객체를 만들고 싶었으나 누군가가 창문을 10개로 늘릴 수도 있는 것이다.)
위와 같은 문제점들을 해결하기 위해 나온 것이 빌더 패턴(Builder Pattern)이다. 빌더 패턴은 객체 생성에 필요한 값들을 단계별로 채워갈 수 있으며, 최종적으로는 build 메서드로 인스턴스를 생성한다. 또한 모든 단계를 호출할 필요가 없어 객체의 특정 설정을 제작하는 데 필요한 단계들만을 호출하여 사용할 수 있다.
Builder Pattern 구현 예제
Builder Pattern은 구현 방법이 2가지로 나뉜다. 패턴이 등장한 시기와 해당 문제를 바라보고 해결하는 관점이 달랐기 때문이라고 생각한다.
GoF Builder Pattern: 객체의 생성을 단계별로 구현하고 싶을 때 사용한다.
Simple Builder Pattern: 생성 시 지정해야 할 인자가 많을 때 사용. 객체의 일관성 불변성이 목적이다.
public class Home {
private String name;
private int walls;
private int windows;
private int doors;
private int rooms;
private boolean hasHeating;
private boolean hasGarage;
private boolean hasSwimPool;
private boolean hasGarden;
// getter, setter 코드 생략
@Override
public String toString() {
return "Home{" +
"name='" + name + '\'' +
", walls=" + walls +
", windows=" + windows +
", doors=" + doors +
", rooms=" + rooms +
", hasHeating=" + hasHeating +
", hasGarage=" + hasGarage +
", hasSwimPool=" + hasSwimPool +
", hasGarden=" + hasGarden +
'}';
}
}
public class DefaultHomeBuilder implements HomeBuilder{
private String name;
private int walls;
private int windows;
private int doors;
private int rooms;
private boolean hasHeating;
private boolean hasGarage;
private boolean hasSwimPool;
private boolean hasGarden;
@Override
public HomeBuilder name(String name) {
this.name = name + "(기본)";
return this;
}
@Override
public HomeBuilder walls(int walls) {
this.walls = walls;
return this;
}
@Override
public HomeBuilder windows(int windows) {
this.windows = windows;
return this;
}
@Override
public HomeBuilder doors(int doors) {
this.doors = doors;
return this;
}
@Override
public HomeBuilder rooms(int rooms) {
this.rooms = rooms;
return this;
}
@Override
public HomeBuilder hasHeating(boolean hasHeating) {
this.hasHeating = hasHeating;
return this;
}
@Override
public HomeBuilder hasGarage(boolean hasGarage) {
this.hasGarage = hasGarage;
return this;
}
@Override
public HomeBuilder hasSwimPool(boolean hasSwimPool) {
this.hasSwimPool = hasSwimPool;
return this;
}
@Override
public HomeBuilder hasGarden(boolean hasGarden) {
this.hasGarden = hasGarden;
return this;
}
@Override
public Home build() {
Home home = new Home();
home.setName(this.name);
home.setWalls(this.walls);
home.setRooms(this.rooms);
home.setWindows(this.windows);
home.setDoors(this.doors);
home.setHasGarden(this.hasGarden);
home.setHasGarage(this.hasGarage);
home.setHasSwimPool(this.hasSwimPool);
home.setHasHeating(this.hasHeating);
return home;
}
}
public class ModernHomeBuilder implements HomeBuilder{
// 필드값 선언문 코드 생략
// 메서드 오버라이딩 코드 생략
}
public class VintageHomeBuilder implements HomeBuilder{
// 필드값 선언문 코드 생략
// 메서드 오버라이딩 코드 생략
}
public class HomeDirector {
private HomeBuilder homeBuilder;
public HomeDirector(HomeBuilder homeBuilder){
this.homeBuilder = homeBuilder;
}
public Home hasGardenHome(){
return homeBuilder
.hasGarden(true)
.name("정원이 있는 집")
.doors(5)
.rooms(3)
.windows(8)
.walls(4)
.build();
}
public Home hasHeatingHome(){
return homeBuilder
.hasHeating(true)
.name("난방기가 있는 따뜻한 집")
.doors(3)
.rooms(3)
.windows(12)
.walls(4)
.build();
}
public Home hasSwimPoolHome(){
return homeBuilder
.hasSwimPool(true)
.name("수영장이 있는 집")
.doors(3)
.rooms(3)
.windows(12)
.walls(4)
.build();
}
}
public class Client {
public static void main(String[] args) {
HomeDirector defaultHomeDirector = new HomeDirector(new DefaultHomeBuilder());
System.out.println(defaultHomeDirector.hasGardenHome());
HomeDirector modernHomeDirector = new HomeDirector(new ModernHomeBuilder());
System.out.println(modernHomeDirector.hasHeatingHome());
HomeDirector vintageHomeDirector = new HomeDirector(new VintageHomeBuilder());
System.out.println(vintageHomeDirector.hasSwimPoolHome());
}
}
// console 결과
Home{name='정원이 있는 집(기본 스타일)', walls=4, windows=8, doors=5, rooms=3, hasHeating=false, hasGarage=false, hasSwimPool=false, hasGarden=true}
Home{name='난방기가 있는 따뜻한 집(모던 스타일)', walls=4, windows=12, doors=3, rooms=3, hasHeating=true, hasGarage=false, hasSwimPool=false, hasGarden=false}
Home{name='수영장이 있는 집(빈티지 스타일)', walls=4, windows=12, doors=3, rooms=3, hasHeating=false, hasGarage=false, hasSwimPool=true, hasGarden=false}
GoF Builder Pattern을 구현하는 순서를 살펴보자.
빌더 인터페이스를 통해 제품을 생성할 때 필요한 단계들을 정의한다. 이때 리턴값은 자기 자신으로 하는데 그 이유는 자기 자신을 리턴함으로써 메서드 체이닝을 하기 위함이다.
만들어질 객체(Home)의 클래스를 작성한다.
각 제품을 표현하고 싶은 대로 빌더 인터페이스의 구현 클래스를 만들고 생성 단계들을 구현한다.(기본 집, 모던한 집, 빈티지한 집 등....)
디렉터 클래스를 만들어 준다. 자주 만들어질 인스턴스를 탬플릿화 한다.
클라이언트는 빌더 객체와 디렉터 객체를 모두 생성하여 디렉터에 빌더를 전달한다. 디렉터를 통해서 미리 탬플릿화 되어있는 객체를 제공받아 사용한다.
GoF의 Builder Pattern는 Simple Builder Pattern과 다르게 Director를 이용하여 자주 사용되는 제품을 템플릿화 하여 제공한다. Director 클래스는 같은 빌더 객체를 사용하여 제품을 제작하는 다양한 방법들을 캡슐화할 수 있다. 또한 Client는 미리 정의된 인스턴스를 호출함으로써 코드를 재사용할 수 있다.
Simple Builder Pattern
public class Home {
private int walls;
private int windows;
private int doors;
private int rooms;
private boolean hasHeating;
private boolean hasGarage;
private boolean hasSwimPool;
private boolean hasGarden;
// 빌더 객체로만 초기화 될수 있도록 생성자는 private 로 한다.
private Home(){}
public static class Builder {
// 필수 필드값
private int walls;
private int windows;
private int doors;
private int rooms;
// 선택 필드값
private boolean hasHeating;
private boolean hasGarage;
private boolean hasSwimPool;
private boolean hasGarden;
// 필수 필드값(매개변수)들은 빌더 생성자를 통해 받게 한다.
public Builder(int walls, int windows, int doors, int rooms){
this.walls = walls;
this.windows = windows;
this.doors = doors;
this.rooms = rooms;
}
public Builder hasHeating(boolean hasHeating) {
this.hasHeating = hasHeating;
return this;
}
public Builder hasGarage(boolean hasGarage) {
this.hasGarage = hasGarage;
return this;
}
public Builder hasSwimPool(boolean hasSwimPool) {
this.hasSwimPool = hasSwimPool;
return this;
}
public Builder hasGarden(boolean hasGarden) {
this.hasGarden = hasGarden;
return this;
}
public Home build() {
Home home = new Home();
home.walls = this.walls;
home.windows = this.windows;
home.doors = this.doors;
home.rooms = this.rooms;
home.hasHeating = this.hasHeating;
home.hasGarage = this.hasGarage;
home.hasSwimPool = this.hasSwimPool;
home.hasGarden = this.hasGarden;
return home;
}
}
}
simple builder pattern을 구현하는 순서를 살펴보자.
먼저 대상 객체인 Home의 인스턴스는 빌더를 통해서만 생성해야 하기 때문에 private로 정의한다.
빌더 클래스는 static inner class로 구현한다(그 이유는 조금 더 아래서 살펴보자.)
빌더 클래스의 생성자는 public으로 하여 필수 필드값에 대해 생성자 매개변수로 받을 수 있게 하고, 선택적 매개변수는 메서드를 제공한다. 이때 메서드의 리턴값은 builder객체 자신이어야 하는데 이는 메서드 체이닝을 하기 위함이다.
마지막으로는 최종적으로 객체를 생성하는 build() 메서드를 제공하여 최종적으로 인스턴스를 생성하여 전달한다.
이때 주의할 점은 빌더 클래스를 이용해 인스턴스를 생성할 때 그 대상이 되는 객체는 오로지 빌더 클래스에 의해 객체가 생성돼야 한다. 그렇기에 생성 대상인 외부 클래스의 생성자는 private로 정의하고, 내부 빌더 클래스에서 private 생성자를 호출함으로써 빌더 객체에 의해 초기화될 수 있도록 한다. 또한 빌더 클래스는 static inner class로 구현되야 한다. 그 이유를 알아보자.
Inner static class를 사용하는 이유
첫 번째, 하나의 빌더 클래스는 하나의 대상 객체 생성만을 생성한다. 그렇기에 두 클래스 간의 관계를 쉽게 파악할 수 있도록 물리적으로 같은 클래스에 위치한다.
두 번째, static으로 선언하는 이유는 일반 내부 클래스는 외부 클래스를 먼저 인스턴스화하지 않으면 사용할 수 없다. 외부 클래스에 인스턴스를 만들기 위한 빌더 객체가 외부 클래스의 인스턴스 없이는 만들 수 없는 모순적인 일이 생기게 된다. 그렇기에 정적 내부 클래스를 사용하여 외부 클래스의 인스턴스 없이도 빌더 객체를 생성할 수 있도록 하는 것이다.
public static void main(String[] args) {
// 외부 클래스를 먼저 생성해야만 builder 객체를 생성할 수 있다.
Home home = new Home();
Home.Builder builder = home.new Builder(1,2,3,4);
}
세 번째, 메모리 누수 문제가 있다고 한다. (이는 추후 자세히 알아보자.)
사용시기
점층적 생성자 패턴(생성자 오버로딩)을 제거하고 싶을 때
점층적 생성자 패턴은 필요한 매개변수가 많아지고 조합이 복잡해질수록 많은 생성자를 만들어야 한다. 이를 제거하고 실제로 필요한 필드값들만을 단계별로 생성하고 싶을 때 사용할 수 있다.
일관성과 불변성을 지키고 싶을때
인스턴스를 생성할 때 필요한 필수 필드값들 반드시 설정되어 있어야 하며, 값이 누군가에 의해 조작되지 않길 원할 때에 사용할 수 있다.
제품(객체)을 다양하게 표현하고 싶을 때
예제에 나온 DefaultHomeBuilder, ModernHomeBuilder, VintageHomeBuilder 빌더들은 Home을 다양하게 표현한다. 이처럼 빌더 인터페이스로 모든 단계를 구현하고 제품을 여러 표현대로 생성하고 싶을 때 사용할 수 있다.
마무리
빌더 패턴을 사용하면 객체를 구현할 때 단계적으로 필요한 필드값들만을 초기화할 수 있다. 객체(제품)를 다양하게 표현하고 싶을 때 같은 생성 코드를 재사용할 수 있다. 비즈니스 로직에서 복잡한 생성 코드를 고립시킬 수 있다 그러나 패턴이 여러 개의 새 클래스를 생성해야 하므로 복잡성이 증가한다.
관련 있는 여러 인스턴스를 만들어주는 팩토리를 구체적인 클래스에 의존하지 않고 만들 수 있게 해주는 생성패턴이다. 즉, 구체적인 구현에는 의존하지 않고 인터페이스에 주목하여, 인터페이스만을 사용해서 부품을 조립하고 제품으로 완성하는 패턴이다.
AbstractFactory: 추상적인 공장이다. 추상적인 공장에서는 추상적인 제품을 생산한다.
ProductA, ProductB: 추상적인 제품이다.
ConcreateFactory: 추상적인 공장의 구현체다. 즉, 구체적인 공장이다. 구체적인 제품을 생산한다.
ConcreateProductA, ConcreateProductB: 추상적인 제품의 구현체이다. 즉, 구체적인 제품이다.
Client: 의뢰자다. 구체적인 공장이나 제품에 대해서는 알지 못하며, 추상적인 공장을 사용하여 제품을 만들어낸다.
추상 팩토리 패턴은 인터페이스만을 사용해서 부품을 조립하고 제품으로 완성하는 패턴이다. 팩토리 패턴과 굉장히 비슷하다고 느껴진다. 그러나 이것은 디자인 패턴의 특징이다. 어떠한 문제를 바라볼때 생각하는 관점에 따라서 해결할 수 있는 방법이 다른 것이다.
추상 팩토리(Abstract factory) 패턴
팩토리 메서드(Factory method) 패턴
공통점
둘다 구체적인 객체 생성 과정을 추상화한 인터페이스를 제공한다.
관점
팩토리를 사용하는 방법 (composition)에 초점을 둔다.
팩토리를 구현하는 방법 (ingeritance)에 초점을 둔다.
목적
관련있는 여러 객체를 구체적인 클래스에 의존하지 않고 만들 수 있게 해주는 것.
구체적인 객체 생성 과정을 하위 또는 구체적인 클래스로 옮기는 것.
Abstract Factory Pattern 사용 전 문제점
public class WhiteshipFactory extends DefaultShipFactory{
@Override
public Ship createShip() {
Ship ship = new Whiteship();
ship.setAnchor(new WhiteAnchor());
ship.setWheel(new WhiteWheel());
return ship;
}
}
공장에서 배를 생산할 때에 anchor(닻)와 wheel(조종키)을 만들어줘야 한다. 위의 코드는 WhiteAnchor(닻)과 WhiteWheel(조종키)의 구체적인 코드를 알고 있다. 이때 기존에 생산하던 제품이 아니라 변형이 필요한 경우 위의 코드를 계속 변경해줘야 하는 문제점이 생긴다. 이러한 문제점을 해결하기 위한 패턴이 Abstract Factory Pattern이다. 계속 코드가 변경돼야 하는 문제점을 해결하며, 제품군을 계속 바꿔나갈 수 있게 한다.
Abstract Factory Pattern 구현 예제
public interface ShipFactory {
// java8에 추가된 디폴트 메서드
// 객체 생성 전처리/ 후처리 메서드
default Ship orderShip(String name, String email){
validate(email);
prepareFor(name);
// 선박 객체 생성
Ship ship = createShip();
sendEmailTo(email, ship);
return ship;
}
// 팩토리 추상 메서드
Ship createShip();
// Java9에 추가된 private 메서드
private void sendEmailTo(String email, Ship ship){
System.out.println(ship.getName() + " 다 만들었습니다.");
};
private void validate(String email){
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("연락처를 남겨주세요.");
}
}
private static void prepareFor(String name){
System.out.println(name + " 만들 준비중");
}
}
public abstract class DefaultShipFactory implements ShipFactory {
@Override
public void sendEmailTo(String email, Ship ship) {
System.out.println(ship.getName() + " 다 만들었습니다.");
}
}
public class WhiteshipFactory extends DefaultShipFactory{
private ShipPartsFactory shipPartsFactory;
public WhiteshipFactory(ShipPartsFactory shipPartsFactory){
this.shipPartsFactory = shipPartsFactory;
}
@Override
public Ship createShip() {
Ship ship = new Whiteship();
ship.setAnchor(shipPartsFactory.createAnchor());
ship.setWheel(shipPartsFactory.createWheel());
return ship;
}
}
@Data
public class Ship {
private String name;
private String color;
private String logo;
private Wheel wheel;
private Anchor anchor;
}
public class Whiteship extends Ship {
public Whiteship() {
setName("whiteship");
setLogo("\uD83D\uDEE5️");
setColor("white");
}
}
public interface ShipPartsFactory {
Anchor createAnchor();
Wheel createWheel();
}
public class WhiteshipPartsFactory implements ShipPartsFactory{
@Override
public Anchor createAnchor() {
return new WhiteAnchor();
}
@Override
public Wheel createWheel() {
return new WhiteWheel();
}
}
public class WhiteshipPartsProFactory implements ShipPartsFactory{
@Override
public Anchor createAnchor() {
return new WhiteAnchorPro();
}
@Override
public Wheel createWheel() {
return new WhiteWheelPro();
}
}
먼저 Client 역할을 맡고있는 WhiteshipFactory를 생성한다. 눈여겨 봐야할 곳은 WhiteshipFactory 클래스에 createShip() 메서드다. Abstract Factory Pattern 사용 전 문제점에서는 어떠한 anchor, wheel을 만들어야 하는지 구체적으로 알고있기에 변경이 필요할 경우 계속 코드가 변경돼야 하는 문제점이 있었다.
그러나 추상 팩토리 패턴에서는 anchor, wheel이 생성되는 부분을 ShipPartsFactory 인터페이스 즉, 추상적인 공장을 주입받아 구체적인 클래스에 의존하지 않고 만들수 있게 되었다. 구체적인 공장이나 제품에 대해서는 알지 못하지만, 추상적인 공장을 사용하여 제품을 만들어낼 수 있게 되었다.
사용할때는 아래 코드와 같이 사용할 수 있다.
public class ShipInventory {
public static void main(String[] args) {
ShipFactory shipFactory = new WhiteshipFactory(new WhiteshipPartsFactory());
Ship ship = shipFactory.createShip();
ShipFactory shipProFactory = new WhiteshipFactory(new WhiteshipPartsProFactory());
Ship shipPro = shipProFactory.createShip();
}
}
사용시기
다양한 제품을 찍어내야 하지만 해당 제품들에 구체적인 클래스에 의존하지 않고싶을때
위의 예제코드에서 wheel(조종키) 제품을 만들었다. 이때 하나의 제품군이 아니라 빈티지, 현대식 등... 다른 형태의 제품이 필요한 경우 추상 팩토리 패턴을 사용함으로써 기존 코드를 변경하지 않으면서도 제품군을 확장할 수 있다.
여러 제품군을 찍어내야 할때
잘 설계된 프로그램은 각 클래스는 하나의 책임만을 가지도록 한다. 만약 하나의 클래스가 여러 제품유형을 제조할 경우 추상 팩토리 패턴을 사용해 볼 가능성을 생각해 볼 수 있다.
마무리
추상 팩토리 패턴을 사용시 개방/폐쇄 원칙을 준수할 수 있으며, 제품마다의 생성 코드를 한곳으로 추출하여 단일 책임 원칙 또한 준수할 수 있다. 또한 구성 제품들과 클라이언트 코드 사이에 단단한 결합을 피할 수 있다는 장점이 있다. 그러나 너무 많은 클래스들이 생긴다. 추상 팩토리 패턴을 학습하며 필요 이상으로 코드가 복잡해진다는 생각이 들었다.
해당 글은 Java 8에 default 메서드가 추가된 이후 interface를 사용해 팩토리 메서드를 구현하는 것을 기준으로 작성된 글입니다.
Factory Method Pattern(팩토리 메서드 패턴) 이란?
팩토리 패턴은 구체적인 인스턴스에 대한 구현을 서브 클래스가 정하는 패턴이다. 즉, 부모클래스에서 객체들을 생성할 수 있는 뼈대(interface)를 제공하고, 자식 클래스들이 생성될 객체의 유형을 결정할 수 있도록 하는 생성 패턴이다.
Creator: 뼈대 즉, interface를 제공하는 최상위 팩토리 인터페이스다. 서브 클래스들에게 구현을 위임한다.
templateMethod(): 객체 생성에 관한 전처리, 후처리를 템플릿화 한 메서드(공통 작업 처리)
createProduct(): 서브 클래스에서 구현해야 할 객체 생성 추상 메서드
ConcreteCreator: 서브 클래스로써 구현해야 할 제품에 맞는 제품 객체를 반환하도록 추상 메서드를 구현한다.
Product: 제품 구현체를 추상화
ConcreteProduct: 제품 구현체
팩토리 메서드 패턴은 객체(제품)를 만들어내는 공장을 만드는 패턴이라고 볼 수 있다. 어떤 제품이 생성될지에 관한 자세한 내용은 서브 클래스에서 결정한다.
만드는 방법이 번거로움에도 불구하고 사용하는 이유는 상위 클래스는 객체 생성방식에 대 해 알 필요가 없기에 유연성을 갖게 되고, 객체의 구체적인 구현은 하위 클래스에서 구현하기 때문에 유지보수성이 증가하기 때문이다. 아래의 예제를 통해 사용하는 이유에 대해 조금 더 자세히 알아보자.
Factory Method Pattern 사용 전 문제점
@Data
public class Ship {
private String name;
private String email;
private String logo;
private String color;
}
public class ShipFactory {
public static Ship orderShip(String name, String email){
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("배 이름을 지어주세요.");
}
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("이메일을 남겨주세요.");
}
Ship ship = new Ship();
ship.setName(name);
if (name.equalsIgnoreCase("whiteship")) {
ship.setLogo("\uD83D\uDEE5");
} else if(name.equalsIgnoreCase("blackship")) {
ship.setLogo("⚓");
}
.............
}
}
위의 코드는 배를 찍어내는 공장이다. 공장에서 흰색 배와 검정색 배 두가지 종류만을 제조한다면 무척이나 간단할 것이다. 그러나 공장이 잘되서 배의 종류가 늘어나고 하늘을 나는 배, 잠수함 등... 많은 것들을 제조하기 시작 한다면 조건이 많아져 구현하는 방법이 매우 복잡해질 것이다. 또한 배에 추가적인 특성이 생길 때 마다 기존 Ship 클래스의 내용이 계속 바뀌어야 한다.
이는 객체지향 원칙중 open closed principle(개방, 폐쇄) 원칙을 어기게 된다. 요구 사항이 생길 때마다 기존 코드 즉, Ship 클래스가 변해야 되기 때문이다. 이런 때에 팩토리 패턴을 사용하여, 확장에는 열려있고 변경에는 닫혀있는 코드를 작성할 수 있다.
Factory Method Pattern 구현 예제
public interface ShipFactory {
// java8에 추가된 디폴트 메서드
// 객체 생성 전처리/ 후처리 메서드
default Ship orderShip(String name, String email){
validate(email);
prepareFor(name);
// 선박 객체 생성
Ship ship = createShip();
sendEmailTo(email, ship);
return ship;
}
// 팩토리 추상 메서드
Ship createShip();
// Java9에 추가된 private 메서드
private void sendEmailTo(String email, Ship ship){
System.out.println(ship.getName() + " 다 만들었습니다.");
};
private void validate(String email){
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("연락처를 남겨주세요.");
}
}
private static void prepareFor(String name){
System.out.println(name + " 만들 준비중");
}
}
public class WhiteShipFactory implements ShipFactory{
@Override
public Ship createShip() {
return new Whipteship();
}
}
public class BlackShipFactory implements ShipFactory{
@Override
public Ship createShip() {
return new BlackShip();
}
}
@Data
public class Ship {
private String name;
private String email;
private String logo;
private String color;
}
public class Whipteship extends Ship{
public Whipteship() {
setName("whiteship");
setLogo("\uD83D\uDEE5");
setColor("white");
}
}
public class BlackShip extends Ship{
public BlackShip(){
setName("blackship");
setLogo("⚓");
setColor("black");
}
}
public class Client {
public static void main(String[] args) {
Client client = new Client();
client.print(new WhiteShipFactory(), "whiteship", "white@naver.com");
client.print(new BlackShipFactory(), "blackship", "black@naver.com");
}
private void print(ShipFactory shipFactory, String name, String email) {
System.out.println(shipFactory.orderShip(name, email));
}
}
먼저 Creator 즉, 뼈대를 담당할 최상위 팩토리 인터페이스 ShipFactory 를 생성한다.
Java 8 버전 이후 추가된 인터페이스의 디폴트 메서드와 Java 9 버전 이후 추가된 private 메서드를 통해 인터페이스로 구성할 수 있게 되었다고 한다.
ShipFactory 인터페이스에서는 서브 클래스에서 구현해야 할 객체 생성 추상 메서드 createShip()을 추상화 한다. 이후 각 선박의 종류에 맞게 ShipFactory 인터페이스를 구현하는 서브 팩토리 클래스를 만들어 createShip() 추상 메서드를 각 객체의 특성에 맞게 구현한다.
ShipFactory 인터페이스의 orderShip() 기본 메서드를 보면 팩토리에서 진행되야 할 공통 작업 코드들을 수행하면서 특징에 따라 구현할 Ship 인스턴스를 만드는 작업만을 서브 클래스가 구현하도록 하는 모습을 볼 수 있다.
팩토리 메서드 패턴을 적용하기 전 코드는 whiteShip, blackShip에 이어 merchantShip을 추가한다면 기존 코드에 분기문을 추가하여 코드가 복잡해지고 기존 코드를 계속 수정해야 하는 문제점이 발생했을 것이다. 그러나 팩토리 메서드 패턴을 적용한다면 간단하게 서브 클래스를 구현하고 제품 객체를 정의하여 확장이 가능하다. 예제를 통해 merchantShip을 추가해보자.
public class MerchantShipFactory implements ShipFactory{
@Override
public Ship createShip() {
return new MerchantShip();
}
}
public class MerchantShip extends Ship{
public MerchantShip(){
setName("merchantShip");
setLogo("🚢");
setColor("blue");
}
}
위 코드와 같이 기존 소스코드에 아무런 변경없이 확장하였다. 그저 Client 에서 주문하기 위한 코드가 한 줄 추가되었을 뿐이다.
확장에는 열려있으며, 변경에는 닫혀있는 구조를 팩토리 메서드 패턴을 이용하여 구현할 수 있다.
사용시기
결합도를 낮추고자 할 때
팩토리 메서드 사용 전에는 인스턴스를 생성하고 제품에 따라 분기처리하는 로직이 한곳에 몰려있었다. 그러나 팩토리 메서드 이후에는 각각의 제품에 따라 인스턴스를 생성하는 공장이 나뉘고 공장에서 생성하는 제품에 로직에만 신경을 쓰게된다. 이러한 방식으로 인스턴스 생성부와 제품 처리 로직의 결합도를 낮추고 제품을 추가할때에 기존의 코드를 건드리지 않고 손쉽게 새로운 제품을 추가하고 싶을때 사용할 수 있다.
라이브러리 또는 프레임워크 사용자에게 컴포넌트를 확장하는 방법을 제공 할 때
ShipFactory라는 선박을 만들수 있는 라이브러리를 사용자에게 제공했다고 가정해보자. 해당 라이브러리는 화물용 선박, 전투용 선박 만을 제공한다. 그러나 사용자는 물고기를 잡는 어선이 필요하다. 이때 사용자에게 해당 ShipFactory를 구현할 수 있도록 제공한다면 사용자는 서브 클래스를 만들어 쉽게 어선을 구현 할 수 있다.
기존 객체를 재구성 하기보다 재사용 하려는 경우
팩토리 메서드 사용전 문제점 코드를 볼 때 생성로직과 분기처리 로직이 한곳에 모여있다. 대부분의 처리 로직이 중복되지만 제품이 새로 추가되거나, 생성해야 하는 제품에 따라 많은 부분이 변경되어야 하는 경우가 발생할 수 있다. 이때 팩토리 메서드 패턴을 사용한다면 새로 추가되는 제품에 들어가는 특수한 로직은 서브 클래스에서 구현할때 즉, 해당 제품을 만들때에 따로 처리할 수 있게된다.
기존 객체를 재구성하지 않고 재사용할 부분들은 재사용하며, 새로 추가되는 제품 처리에 필요한 로직은 해당 제품을 생산하는 서브 클래스에서 별도로 처리할 수 있다.
마무리
팩토리 메서드 패턴을 사용시 개방, 폐쇄 원칙을 준수할 수 있으며, 서브 클래스를 통해 객체를 구현하기에 수정사항 발생시 해당 서브 클래스만을 수정해도 된다는 유지보수의 편리성이 있다. 또한 제품을 따로따로 구현해도 되기에 여러 개발자가 협업을 통해 개발할 수 있다.
다만 각 제품마다 서브 클래스와 제품 구현체를 구현해주어야 하기 때문에, 작성해주어야 하는 구현체가 늘어날수록 클래스의 수가 많이 증가한다.
개발도중 Instagram API를 사용해야 할 일이 있었다. 해당 API를 사용하기 위해서는 Token을 발급받아 사용해야 하는데 토큰에 기한이 60일이라 주기적으로 새 토큰을 발급받아야 했다. 그래서 해당 기능을 하는 batch 서비스를 하나 만들게 되었고, 정말 짧은 코드지만 해당 코드를 리펙토링 하는 과정을 메모해 둔다.
초기에 작성한 코드는 위와 같았다. 서비스레이어에 모든걸 몰아넣었고, 위와 같이 작성한 코드에는 4가지 문제점이 있었다.
첫 번째: 코드만 보고 흐름이 한눈에 읽히지 않는다.
두 번째: 테스트 코드를 작성하기에 적합하지 않다.
세 번째: 서비스 레이어가 복잡하다.
callInstaRefreshTokenApi 메서드는 세 가지의 일을 처리하고 있다. 그러나 세 가지의 일을 처리하고 있다는 것이 한눈에 들어오지 않으며, 각각의 작업 단위로 테스트 해볼수가 없다. 또한 서비스 레이어가 상당히 복잡하다. 이것외에도 더 문제가 있을수 있지만 일단 필자의 짧은 지식으로는 총 3개의 문제점을 발견할 수 있었다. 문제를 해결해보자.
리팩토링
callInstaRefreshTokenApi 는 3가지 일을 한다고했다. 그 일은 아래와 같다.
토큰 재발급을 위해 DB에 저장되어있는 기존의 Token을 조회해온다.
Instagram API에 토큰 재발급을 요청한다.
새로 발급받은 토큰을 저장한다.
위에서 말했던 모든 문제들은 메소드가 적절히 분리되지 않아서 발생되는 문제라고 생각된다. 첫 번째 리펙토링때에는 작업들을 메소드별로 한 가지의 작업 단위로 분리하여 메소드들이 무엇을 하는지 표현하는 것을 목표로하였다.
@Slf4j
@SpringBootTest
public class TokenServiceTest {
// 선언부 생략....
@Test
@DisplayName("인스타그램 Refresh Token API 호출 및 데이터저장")
public void refreshInstaApiToken(){
// given
TokenVO.Request tokenRequestData = tokenDomain.getAccessToken();
// when
TokenVO.Response resultApiResponseData = tokenDomain.callInstaRefreshTokenApi(tokenRequestData.getToken());
// then
boolean result = tokenDomain.saveInstaApiToken(resultApiResponseData);
assertThat(result).isEqualTo(true);
}
}
세 가지의 작업을 각각의 메소드로 분리하였다. 메인 서비스 로직인 callInstaRefreshTokenApi 에서 세 가지 일을 처리한다는 흐름이 보이기 시작한다. 테스트 코드도 더 명확히 작성할 수 있게 되었다. 이전에는 작업이 제대로 분리되어있지 않아 각각의 기능들을 테스트할수가 없었다. 그러나 위의 코드는 작업 단위로 메소드를 작성하였기에 작업 단위로 테스트가 가능해졌다. 또한 작업의 단위로 분리함으로 인해 메소드의 이름을 통해 해당 메소드가 무슨 작업을 하는지 표현할 수 있게 되었다.
1차 리펙토링 이후 아쉬운것은 서비스 레이어에 메소드가 많아 복잡하고 작업의 흐름에만 집중하기 힘들다. 그래서 위의 코드를 한번 더 개선하였다.
2차 리팩토링 코드
@Service
public class TokenService {
@Autowired
private TokenDomain tokenDomain;
public void refreshInstaApiToken(){
TokenVO.Request tokenRequestData = tokenDomain.getAccessToken();
TokenVO.Response resultApiResponseData = tokenDomain.callInstaRefreshTokenApi(tokenRequestData.getToken());
tokenDomain.saveInstaApiToken(resultApiResponseData);
}
}
2차 개선때는 token 관련으로 일을하는 메소드들을 tokenDomain이라는 클래스를 만들어 서비스 레이어와 분리하였다. 서비스 레이어에서는 서비스의 흐름에만 집중할 수 있도록 상세한 로직들은 tokenDomain 클래스에 작성하였다. 테스트코드는 1차 리펙토링때와 같다.
마무리
리펙토링을 통하여 위에서 말했던 세 가지의 문제들이 많이 개선되었다고 생각한다.
첫 번째: 코드만 보고 흐름이 한눈에 읽히지 않는다.
각각의 작업 단위, 즉 한가지의 일만 하도록 메소드를 분리하였고, 메소드의 이름으로 무슨 작업을 하는지 명확히 표현할 수 있게 되었다.
두 번째: 테스트 코드를 작성하기에 적합하지 않다.
작업 단위로 메소드를 분리하였기에 각 기능별로 테스트를 할 수 있게 되었다.
세 번째: 서비스 레이어가 복잡하다.
상세 로직들을 구현해놓은 domain클래스를 만들어 서비스레이어와 분리함으로써 서비스 레이어는 서비스 로직의 흐름에만 집중할 수 있게 되었다.
주니어 개발자인 필자가 작성한 코드는 많은 선배 개발자들에게 보이기에 너무나도 부끄러운 코드임이 분명하다. 그러나 이 글을 읽고 누군가 조언을 해준다면 좋겠다. 메소드명, 변수명도 많은 고민을 하였지만 더 생각이 나질않고 개선점도 지금에 얕은 지식으로는 더 보이지 않는다. 긴 글 읽어주셔서 감사드립니다.