발자국 1주차: 읽기 좋은 코드와 현실 비즈니스 속 객체지향의 관계
해당 글은 인프런 워밍업 클럽 스터디 3기 - 백엔드 클린 코드, 테스트 코드를 진행하며 깊게 고민해 보려고 한 내용을 담습니다. 단순한 이해보다는 현실적 적용에 집중합니다.
우리는 자바를 기반한, 객체지향이라는 패러다임을 담은 개발을 하며 <객체지향>의 중요성을 귀가 닳도록 듣는다.
책임의 분리. 어디까지가 객체의 책임인가? 어떤 것은 추상이고 어떤 것이 구상인가?
책임이라는 개념은 객체지향에서 매우 중요하지만, 그와 동시에 현실에서 적용하는 데에서는 생각할 부분이 많다.
나는 비즈니스 일정과 아름다운 코드, 그리고 복잡성에 대한 사이에서 그동안 고민을 많이 해 왔었다.
강의를 들으면서 그것들을 고민하고자 했고, 그 고민에 대한 이번 주 일주일 동안의 이야기를 해 보려고 한다.
Graceful Code와 Business 사이에서
사실 SOLID? 안다. 내 연차에서 그것을 모르는 자바 개발자는 드물 것이다.
하지만 그 개발자들은 나와 같이 이 SOLID를 어떻게 적용할지에 대해 고민할 것이다.
따라서 나는 이번 주에는 SOLID 원칙을 실무적으로 적용하는 과정에서 현실적인 한계를 분석하는 데 집중하려 했다.
특히, 기존 시스템에 SOLID 원칙을 도입하려 할 때 발생하는 문제점을 파악하고, 이를 해결하기 위한 현실적인 방법을 고민하고자 했다.
그래, 현재 코드베이스의 특성을 고려하여 현실적인 타협점을 찾는 방법을 고민해 보고 싶었다.
강의를 보며 생각해 본 이번 주의 고민들을 녹이며, 지금부터 SOLID 원칙을 한 가지씩 짚어가면서 이야기해 보고자 한다.
원칙과 현실의 부딪힘
SRP(단일 책임 원칙) 적용의 현실적 고민
사실 제일 좋아하는 원칙이다. SOLID의 가장 핵심이라고 생각하기도 한다.
그렇지만 책임이란 단어는 참 모호하다.
[카페]에서 [커피]를 [주문]한다고 했을 때, [주문]이 담당하고 있는 책임은 어디까지일까?
이와 같이 나는 SRP를 고민할 때, [어디까지가 객체의 책임]인가에 대해서 고민한다.
class CafeOrderService {
private PaymentProcessor paymentProcessor;
private NotificationService notificationService;
private TransportService transportService;
public void processOrder(Order order) {
paymentProcessor.process(order);
notificationService.sendOrderConfirmation(order);
transportService.giveDrink();
}
}
카페 주문은 [결제] / [완료 안내] / [손님에게 음료 주기]를 포함할 수 있다.
그런데 카페의 할 일이 많아지게 되면, 이 클래스는 너무 많은 책임을 가지게 된다.
만약 카페가 배달 서비스를 시작하면 카페의 책임은 [배달] / [배달 기사님 콜 부르기] / [배달 완료표시] 등으로 확장될 수 있다.
카페의 할 일은 많지만 책임의 갯수가 늘어날 때는 역시 분리를 고민해야 할 것 같다.
그럼 그걸 몇 개로 제한해야 할까? 3개? 4개?
… 개수가 아니지 않을까?
내가 이번에 정의를 내리게 된 것은 SRP가 단순하게 ”하나의 클래스 = 하나의 책임“이 아니라.
“변경의 이유가 하나인가”를 고민하는 원칙이라는 것이다.
따라서 우리는 카페 주문이라는 서비스가 “언제” 바뀔지에 따라 응집도의 기준을 정해야 한다.
가령 [카페 주문]이라는 것이 여러 방식의 주문을 가질 수 있게 된다면(방식의 개수가 바뀜),
[매장 카페 주문] / [배달 카페 주문]으로 바꾸고, 외부 인터페이스는 타입에 따라 분리하도록 지정하는 것이다.
// 주문 처리를 위한 상위 인터페이스
interface OrderService {
void processOrder(Order order);
}
// 매장 주문 처리
class StoreOrderService implements OrderService {
private PaymentProcessor paymentProcessor;
private NotificationService notificationService;
private ServeService serveService;
@Override
public void processOrder(Order order) {
paymentProcessor.process(order);
notificationService.sendInStoreOrderConfirmation(order);
serveService.serveDrinkAtCounter(order);
}
}
// 배달 주문 처리
class DeliveryOrderService implements OrderService {
private PaymentProcessor paymentProcessor;
private NotificationService notificationService;
private DeliveryService deliveryService;
private DriverNotificationService driverService;
@Override
public void processOrder(Order order) {
paymentProcessor.process(order);
notificationService.sendDeliveryOrderConfirmation(order);
deliveryService.prepareForDelivery(order);
driverService.notifyAvailableDriver(order);
}
}
// 주문 타입에 따라 적절한 서비스를 선택하는 팩토리
class OrderServiceFactory {
private StoreOrderService storeOrderService;
private DeliveryOrderService deliveryOrderService;
public OrderService getOrderService(OrderType orderType) {
switch (orderType) {
case STORE:
return storeOrderService;
case DELIVERY:
return deliveryOrderService;
default:
throw new UnsupportedOperationException("지원하지 않는 타입:" + orderType);
}
}
}
// 클라이언트 코드
class CafeOrderController {
private OrderServiceFactory orderServiceFactory;
public void processOrder(Order order) {
OrderService orderService = orderServiceFactory.getOrderService(order.getType());
orderService.processOrder(order);
}
}
이제 OrderService 입장에서는 책임이 분리됐다. 타입만 ENUM에서 선택해서 넘겨 주면 되겠다.
그런데 여기서 튀어나오는 원칙이 하나 더 있다. OCP다.
OCP(개방-폐쇄 원칙) 적용의 현실적 고민
OCP. 인터페이스 정의의 핵심이다. 특히 전략 패턴에서 고민하게 되는 부분인 것 같다.
OCP 이야기가 많이 나오는 예제로 Oauth 로그인이 있다.
네이버 / 카카오 인증을 인터페이스로, 행위 중심을 토대로 폐쇄하되 앞으로 여러 가지 인증의 가능성을 넓히는 것.
그런데 나는 확장의 필요성과 지속성을 먼저 고려해야 한다고 생각한다.
“우리는 이 객체를 어디까지 확장할 것인가?”
확장성을 높이기 위해 인터페이스를 도입했지만, 너무 많은 추상화가 오히려 코드 가독성을 해치는 경우도 있을 수 있다.
이번에 크리스마스 특집으로 행사를 한다고 한다. 이 행사는 일주일간 일어나고 사라질 것이다.
그럼 우리는 여러 가지 이벤트가 발생할 때마다 늘 DiscountPolicy의 구현체를 추가해 주어야 하는 것일까?
위의 예시에서도, 이벤트성으로 음료 주문 방식에 증정 이벤트가 추가되었다고 해 보자.
그렇다면 증정 이벤트가 추가된 클래스를 만들어야 할까?
뭐, 그럴 수도 있다. 클래스는 쓰다 지우면 된다.
그런데 추가된 클래스를 나중에 삭제할 수 있다고 쉽게 생각하지만, 실제로는 코드베이스에 남아 오히려 누군가 옵션을 더하는 식으로 클래스가 커져, 유지보수 비용이 발생할 수 있다.
같은 소스코드 안에 있다면 3줄로 관리하면 되는데, 클래스를 하나 늘리는 것이 추후 휴먼 오류 가능성을 높이는 행동이 될 수 있다는 것이다. (세상에는 객체지향을 사랑하는 사람만 있지는 않다)
결국 객체의 [개방]을 위해 보장해야 하는 것은 로직이 얼마나 지속될지의 여부인 것 같다.
그렇다면 리스코프 치환 원칙은 어떠한가?
LSP(리스코프 치환 원칙) 적용의 현실적 고민
리스코프 치환 원칙의 중심은 부모에게 있다. 부모가 하는 일을 자식이 위반하지 않아야 하는 것이다.
여기서 가장 적용하기 모호해지는 것은 [부모가 하는 일]이다.
부모는 어떠한 책임을 가질까? 그리고 어떠한 행위를 할까? 역할이 모호한 만큼, 부모의 역할 또한 모호하게 느껴진다.
우리의 카페 주문 예시로 생각해 보자. 사람은 카페에게 기대하는 역할이 있다.
나에게 내가 원하는 음료수를 주는 것이다. 그것이 배달이든, 실제로 가서 주문하는 것이든 달라지는 것은 없다.
여기서 가장 중요한 것은 나 / 음료수 / 전달 이다.
나는 리스코프 원칙을 [클라이언트가 기대하는 응답을 주는 것]을 부모가 하는 일을 위반하지 않는 것이라고 생각한다.
우리의 카페 주문에서의 리스코프 책임 원칙은, “부모가 가진 인터페이스의 계약을 지키는 것”은, [음료수를 전달하는 것]일 것이다. 방식은 다르더라도 음료수만 잘 배달하면 된다. 그러면 지킨 것이다.
그렇다면 스프링에서 가장 많이 쓰이는 DIP는 어떨까?
DIP(의존성 역전 원칙) 적용의 현실적 고민
DIP는 고수준 모듈이 저수준 모듈에 의존하지 않고 둘 다 추상화에 의존하게 만든다.
스프링 프레임워크에서는 이 원칙을 기반으로 DI(의존성 주입)를 제공한다.
개발자로서 우리는 스프링에게 객체 생성을 위임하고 수많은 DI를 수행한다. new를 직접 호출할 필요가 없다니! 정말 편리하다.
그런데 모든 의존성을 인터페이스로 추상화하는 것이 항상 최선일까?
다음과 같은 상황을 고려해보자.
interface UserService {
User findById(Long id);
void register(User user);
}
class CafeUserService implements UserService {
// 카페 유저 관련 구현
}
class StoreUserService implements UserService {
// 상점 유저 관련 구현
}
현재 서비스에는 카페 사용자만 존재하고, 상점 사용자는 아직 구현 계획이 없다.
그럼에도 불구하고 “미래의 확장성”을 위해 인터페이스를 도입해야 할까?
인터페이스를 도입하면 분명 유연성을 얻을 수 있지만, 당장 StoreUserService 구현체가 필요하지 않다면 이는 불필요한 복잡성을 가져올 수 있다.
게다가 초기 스타트업에서 이 부분은 두드러진다. 도입 가능성을 예측했으나 갈아엎어지는 기획이 너무나 많으니까...
따라서 DIP 적용의 균형점은 근미래의 변경 가능성 / 팀의 개발 문화에 있다고 생각한다.
물론 대규모 엔터프라이즈 애플리케이션에서는 철저한 DIP 적용이 장기적으로 유리할 수 있다. 작은 프로젝트나 스타트업에서는 과도한 추상화가 오히려 개발 속도를 늦출 수 있다.
“도입이 상상되는 인터페이스”는 애자일과 맞지 않는다.
결국 DIP의 적용은 실용적 균형의 문제가 아닐까 생각해 본다.
이제 마지막, ISP에 대해서 생각해 보았다.
ISP(인터페이스 분리 원칙) 적용의 현실적 고민
ISP의 케이스에서 가장 경계해야 할 것은 결국 [개수]라는 생각을 한다.
얼마나 분리할 것인가? 얼마나 분리하는 것이 효율적인가?
나는 [현재 상태에서 필요한 만큼]이라고, 그러니까 최소한이라라고 생각한다.
많은 인터페이스가 생겼을 때 가장 큰 문제는 아무래도 파일이 최소 2배가 된다는 점이다.
수많은 파일은 복잡성을 높이는 것이 사실이니까. 어떠한 아키텍처가 이 수많은 인터페이스들을 깔끔하게 감당할 수 있는가?
Spring과 같은 DI 프레임워크에서는 이런 문제가 더욱 두드러질 수 있다.
수많은 작은 인터페이스들의 구현체를 모두 Bean으로 등록하고 관리해야 하기 때문이다.
동시에 나는 섣부른 인터페이스 분리를 가장 경계해야 한다고 생각한다.
인터페이스가 재정의되어야 하는 순간, 기존에 해당 인터페이스들을 사용하고 있는 객체의 로직을 전부 다 다시 살펴야 한다.
따라서 ISP 또한... 먼저 상상하지 않는 것이 중요해 보인다.
이 인터페이스를 구현한 뒤 시일이 지난 이후, 다른 구현체에서도 상태가 변하지 않을 인터페이스만을 최대한 고민해 보려 한다. 잠시 응집도가 떨어지더라도.
회고
우리는 현실과 아름다운 코드의 사이에서 무엇을 포기해야 하는가?
객체의 책임은 비즈니스의 상황마다 가변적일 수 있다는 생각을 하기 때문이다.
인터페이스가 많으면 아름답다. 하지만 한눈에 파악하는 것은 어려워진다.
전략패턴은 객체의 책임을 확장성있게 분리한다. 하지만 어떠한 객체를 할당할지 정하는 구체적인 룰이 정해져야 한다.
결국 아름다움은 집단의 룰을 포함한다.
혼자서는 무한대로 아름다울 수 있지만, 같이 하는 현실에서도 아름다움을 추구하는 것이 Readable한 코드의 핵심이라고 생각한다.
비즈니스적으로 빠른 코드 ≠ 읽기 좋은 코드 ≠ 아름다운 코드
장기적인 관점에서 아름다운 코드는 결국 집단이 얼마나 동일한 룰을 체득하고 있는지에 따라 달라진다고 생각한다.
그리고 우빈님의 강의는 그 룰에 대한 가이드라인을 주고 있다는 생각이 들었다.
이 룰을 체득한다면, 적어도 비즈니스를 위한 코드를 생각할 때 객체지향의 관점을 망치지는 않을 것이다.
다음 주 계획
다음 주에는 내가 알고 있는 클린코드와, 강의에서 이야기하는 클린 코드를 적용하면서 TDD를 공부해 볼 예정이다.
프로젝트 한 가지를 가지고 와서 이야기를 하면 좋을 것 같아서, Gilded Rose를 가지고 와 봤다.
유명한 리팩토링 kata 라이브러리니 프로젝트를 [적용]하는 방식에 있어서 많은 것을 이야기해 볼 수 있을 것 같다.
테스트 코드에 대해서도 강의를 베이스로 해서 Junit을 연습해 보고자 한다.
댓글을 작성해보세요.