Now Loading ...
-
🔍[Troubleshooting] OOP와 SOLID 관계.
🚀 OOP와 SOLID의 관계 Troubleshooting !
핵심 질문: OOP면 충분한데, 왜 SOLID가 필요할까?
🤔 자주 하는 착각
// ❌ "객체지향으로 설계했으니 끝 아닌가?"
@Service
public class OrderService {
private ProductRepository productRepo;
private UserRepository userRepo;
private PaymentService paymentService;
private EmailService emailService;
private SmsService smsService;
public void processOrder(OrderRequest request) {
// 상품 조회, 결제, 이메일, SMS 등 모든 걸 한 번에...
// 200줄의 복잡한 로직
}
}
// ✅ "OOP + SOLID 원칙을 적용하면?"
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderValidator orderValidator;
private final PaymentProcessor paymentProcessor;
private final NotificationSender notificationSender;
public OrderResult processOrder(OrderRequest request) {
orderValidator.validate(request);
PaymentResult payment = paymentProcessor.process(request);
notificationSender.sendConfirmation(request, payment);
return OrderResult.from(request, payment);
}
}
“OOP로 클래스를 나누고 상속을 쓰면 되는 거 아닌가요? 왜 SOLID가 또 필요하죠?”
이는 많은 개발자들이 가지는 자연스러운 의문입니다. 하지만 OOP만으로는 건강한 설계를 보장할 수 없습니다.
🏗️ 정답: OOP는 토대, SOLID는 건강한 설계 가이드
🏢 건물 건축 비유로 이해하기
개념
건축 비유
개발 비유
역할
OOP
🏗️ 건축 구조 (기둥, 보, 벽)
클래스, 객체, 상속, 캡슐화
기본 토대
SOLID
🔧 건축 규범 (내진설계, 안전규정)
단일책임원칙(SRP), 개방-폐쇄원칙(OCP), 리스코프, 인터페이스분리, 의존역전
품질 가이드라인
왜 이 비유가 중요한가?
기둥과 벽만으로는 건물이 무너질 수 있음 (OOP만으로는 부족)
건축 규범이 있어야 안전하고 오래가는 건물이 됨 (SOLID 필요)
둘 다 필요하지만 역할이 다름
💡 OOP vs OOP+SOLID 실제 비교
시나리오: 주문 처리 시스템
🚨 OOP만 적용한 경우
@Service
public class OrderService {
// 모든 의존성을 직접 참조
private ProductRepository productRepository;
private UserRepository userRepository;
private PaymentGateway creditCardGateway; // 신용카드만 지원
private EmailSender emailSender; // 이메일만 지원
public void processOrder(Long userId, Long productId) {
// ❌ 한 메서드에서 모든 것을 처리
User user = userRepository.findById(userId);
Product product = productRepository.findById(productId);
// 재고 확인
if (product.getStock() < 1) {
throw new OutOfStockException();
}
// 신용카드 결제만 가능
creditCardGateway.charge(user.getCreditCard(), product.getPrice());
// 재고 감소
product.decreaseStock(1);
productRepository.save(product);
// 이메일 발송만 가능
emailSender.send(user.getEmail(), "주문 완료", "주문이 완료되었습니다.");
// 주문 저장
Order order = new Order(userId, productId, LocalDateTime.now());
// ... 저장 로직
}
}
🔥 문제점들:
결제 방식 추가 시 → 전체 코드 수정 필요
SMS 알림 추가 시 → 또 다시 전체 수정
테스트하기 어려움 → 실제 결제까지 테스트
✅ OOP + SOLID 적용한 경우
// 1️⃣ SRP: 각각의 책임을 분리
@Component
public class OrderValidator {
public void validate(OrderRequest request) {
// 주문 유효성 검증만 담당
}
}
@Component
public class StockManager {
public void reserveStock(String productId, int quantity) {
// 재고 관리만 담당
}
}
// 2️⃣ DIP: 인터페이스에 의존
public interface PaymentProcessor {
PaymentResult process(PaymentRequest request);
}
public interface NotificationSender {
void sendOrderConfirmation(OrderConfirmation confirmation);
}
// 3️⃣ OCP: 확장에 열려있고 수정에 닫혀있음
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderValidator orderValidator;
private final StockManager stockManager;
private final PaymentProcessor paymentProcessor; // 구현체에 의존하지 않음
private final NotificationSender notificationSender; // 확장 가능
public OrderResult processOrder(OrderRequest request) {
// 각 단계별로 명확하게 분리
orderValidator.validate(request);
stockManager.reserveStock(request.getProductId(), request.getQuantity());
PaymentResult payment = paymentProcessor.process(request.toPaymentRequest());
notificationSender.sendOrderConfirmation(OrderConfirmation.from(request, payment));
return OrderResult.success(request, payment);
}
}
🎉 장점들:
카카오페이 추가 → 구현체만 추가, 기존 코드 무변경
SMS 알림 추가 → NotificationSender 구현체만 추가
테스트 용이 → Mock 객체로 독립적 테스트
🎯 SOLID 각 원칙의 실제 효과
1. 🎯 SRP (Single Responsibility Principle) - 단일 책임 원칙
“하나의 클래스는 하나의 책임만 가져야 한다”
❌ SRP 위반 사례
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private EmailService emailService;
@Autowired
private AuditLogger auditLogger;
// 사용자 생성 (CRUD 책임)
public User createUser(UserCreateRequest request) {
// 비밀번호 검증 로직 (검증 책임)
if (request.getPassword().length() < 8) {
throw new WeakPasswordException();
}
if (!request.getPassword().matches(".*[A-Z].*")) {
throw new WeakPasswordException();
}
User user = User.builder()
.email(request.getEmail())
.password(encryptPassword(request.getPassword()))
.build();
User savedUser = userRepository.save(user);
// 환영 이메일 발송 (이메일 책임)
emailService.sendWelcomeEmail(savedUser.getEmail(), savedUser.getName());
// 감사 로그 기록 (로깅 책임)
auditLogger.logUserCreation(savedUser.getId(), LocalDateTime.now());
return savedUser;
}
// 비밀번호 암호화 (암호화 책임)
private String encryptPassword(String password) {
// 복잡한 암호화 로직...
return BCrypt.hashpw(password, BCrypt.gensalt());
}
// 사용자 조회 (CRUD 책임)
public User findUser(Long id) { ... }
// 비밀번호 변경 시 이메일 알림 (이메일 + CRUD 책임)
public void changePassword(Long userId, String newPassword) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException());
// 비밀번호 검증 (검증 책임)
validatePassword(newPassword);
user.setPassword(encryptPassword(newPassword));
userRepository.save(user);
// 이메일 알림 (이메일 책임)
emailService.sendPasswordChangeNotification(user.getEmail());
// 감사 로그 (로깅 책임)
auditLogger.logPasswordChange(userId, LocalDateTime.now());
}
}
🔥 문제점들:
여러 이유로 변경됨: 비밀번호 정책 변경, 이메일 템플릿 변경, 로깅 방식 변경
테스트 어려움: 하나의 메서드 테스트를 위해 모든 의존성 필요
재사용 불가: 다른 곳에서 비밀번호 검증 로직을 쓸 수 없음
✅ SRP 적용 후
// 1. 비밀번호 검증 책임 분리
@Component
public class PasswordValidator {
private static final int MIN_LENGTH = 8;
private static final String UPPERCASE_PATTERN = ".*[A-Z].*";
private static final String LOWERCASE_PATTERN = ".*[a-z].*";
private static final String DIGIT_PATTERN = ".*[0-9].*";
private static final String SPECIAL_CHAR_PATTERN = ".*[!@#$%^&*].*";
public void validate(String password) {
if (password == null || password.length() < MIN_LENGTH) {
throw new WeakPasswordException("비밀번호는 최소 " + MIN_LENGTH + "자 이상이어야 합니다");
}
if (!password.matches(UPPERCASE_PATTERN)) {
throw new WeakPasswordException("대문자를 포함해야 합니다");
}
if (!password.matches(LOWERCASE_PATTERN)) {
throw new WeakPasswordException("소문자를 포함해야 합니다");
}
if (!password.matches(DIGIT_PATTERN)) {
throw new WeakPasswordException("숫자를 포함해야 합니다");
}
if (!password.matches(SPECIAL_CHAR_PATTERN)) {
throw new WeakPasswordException("특수문자를 포함해야 합니다");
}
}
}
// 2. 비밀번호 암호화 책임 분리
@Component
public class PasswordEncoder {
public String encode(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12));
}
public boolean matches(String rawPassword, String encodedPassword) {
return BCrypt.checkpw(rawPassword, encodedPassword);
}
}
// 3. 사용자 이벤트 발행 책임 분리
@Component
@RequiredArgsConstructor
public class UserEventPublisher {
private final ApplicationEventPublisher eventPublisher;
public void publishUserCreated(User user) {
eventPublisher.publishEvent(new UserCreatedEvent(user));
}
public void publishPasswordChanged(Long userId) {
eventPublisher.publishEvent(new PasswordChangedEvent(userId));
}
}
// 4. 사용자 도메인 서비스 - 오직 사용자 CRUD만 담당
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordValidator passwordValidator;
private final PasswordEncoder passwordEncoder;
private final UserEventPublisher eventPublisher;
@Transactional
public User createUser(UserCreateRequest request) {
passwordValidator.validate(request.getPassword());
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.build();
User savedUser = userRepository.save(user);
eventPublisher.publishUserCreated(savedUser);
return savedUser;
}
@Transactional
public void changePassword(Long userId, String newPassword) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException());
passwordValidator.validate(newPassword);
user.changePassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
eventPublisher.publishPasswordChanged(userId);
}
@Transactional(readOnly = true)
public User findUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException());
}
}
// 5. 이벤트 핸들러로 부수 효과 처리
@Component
@RequiredArgsConstructor
@EventListener
public class UserEventHandler {
private final EmailService emailService;
private final AuditLogger auditLogger;
@EventListener
@Async
public void handleUserCreated(UserCreatedEvent event) {
User user = event.getUser();
// 환영 이메일 발송
emailService.sendWelcomeEmail(user.getEmail(), user.getName());
// 감사 로그 기록
auditLogger.logUserCreation(user.getId(), LocalDateTime.now());
}
@EventListener
@Async
public void handlePasswordChanged(PasswordChangedEvent event) {
User user = userRepository.findById(event.getUserId())
.orElseThrow(() -> new UserNotFoundException());
// 비밀번호 변경 알림
emailService.sendPasswordChangeNotification(user.getEmail());
// 감사 로그 기록
auditLogger.logPasswordChange(event.getUserId(), LocalDateTime.now());
}
}
🎉 SRP 적용 후 장점들:
명확한 책임: 각 클래스의 역할이 명확함
재사용 가능: PasswordValidator를 다른 곳에서도 사용 가능
테스트 용이: 각 컴포넌트를 독립적으로 테스트
변경 영향 최소화: 비밀번호 정책 변경 시 PasswordValidator만 수정
2. 🚪 OCP (Open-Closed Principle) - 개방-폐쇄 원칙
“확장에는 열려있고, 수정에는 닫혀있어야 한다”
❌ OCP 위반 사례
@Service
@RequiredArgsConstructor
public class PaymentService {
// 결제 방식이 추가될 때마다 이 메서드를 수정해야 함
public PaymentResult processPayment(PaymentRequest request) {
switch (request.getPaymentType()) {
case CREDIT_CARD:
return processCreditCard(request);
case BANK_TRANSFER:
return processBankTransfer(request);
case PAYPAL:
return processPaypal(request);
// 카카오페이 추가 시 -> 이 메서드를 수정해야 함!
case KAKAO_PAY:
return processKakaoPay(request);
// 네이버페이 추가 시 -> 또 이 메서드를 수정해야 함!
case NAVER_PAY:
return processNaverPay(request);
default:
throw new UnsupportedPaymentTypeException();
}
}
private PaymentResult processCreditCard(PaymentRequest request) {
// 신용카드 결제 로직
CreditCardGateway gateway = new CreditCardGateway();
return gateway.charge(request.getAmount(), request.getCreditCardInfo());
}
private PaymentResult processBankTransfer(PaymentRequest request) {
// 계좌이체 로직
BankTransferGateway gateway = new BankTransferGateway();
return gateway.transfer(request.getAmount(), request.getBankInfo());
}
// 새 결제 방식마다 메서드가 계속 추가됨...
private PaymentResult processPaypal(PaymentRequest request) { ... }
private PaymentResult processKakaoPay(PaymentRequest request) { ... }
private PaymentResult processNaverPay(PaymentRequest request) { ... }
}
🔥 문제점들:
기존 코드 수정 필요: 새 결제 방식마다 processPayment 메서드 수정
클래스 크기 증가: 결제 방식이 늘어날수록 클래스가 거대해짐
테스트 영향: 새 결제 방식 추가 시 기존 테스트도 수정 필요
✅ OCP 적용 후
// 1. 결제 처리기 인터페이스 정의
public interface PaymentProcessor {
PaymentResult process(PaymentRequest request);
boolean supports(PaymentType paymentType);
}
// 2. 각 결제 방식별 구현체
@Component
public class CreditCardProcessor implements PaymentProcessor {
@Override
public PaymentResult process(PaymentRequest request) {
CreditCardGateway gateway = new CreditCardGateway();
try {
CreditCardPaymentResult result = gateway.charge(
request.getAmount(),
request.getCreditCardInfo()
);
return PaymentResult.builder()
.transactionId(result.getTransactionId())
.status(PaymentStatus.SUCCESS)
.amount(request.getAmount())
.paymentType(PaymentType.CREDIT_CARD)
.processedAt(LocalDateTime.now())
.build();
} catch (CreditCardException e) {
return PaymentResult.failure(e.getMessage());
}
}
@Override
public boolean supports(PaymentType paymentType) {
return PaymentType.CREDIT_CARD.equals(paymentType);
}
}
@Component
public class BankTransferProcessor implements PaymentProcessor {
@Override
public PaymentResult process(PaymentRequest request) {
BankTransferGateway gateway = new BankTransferGateway();
try {
BankTransferResult result = gateway.transfer(
request.getAmount(),
request.getBankInfo()
);
return PaymentResult.builder()
.transactionId(result.getTransactionId())
.status(PaymentStatus.SUCCESS)
.amount(request.getAmount())
.paymentType(PaymentType.BANK_TRANSFER)
.processedAt(LocalDateTime.now())
.build();
} catch (BankTransferException e) {
return PaymentResult.failure(e.getMessage());
}
}
@Override
public boolean supports(PaymentType paymentType) {
return PaymentType.BANK_TRANSFER.equals(paymentType);
}
}
// 3. 새로운 결제 방식 추가 - 기존 코드 수정 없음!
@Component
public class KakaoPayProcessor implements PaymentProcessor {
@Override
public PaymentResult process(PaymentRequest request) {
KakaoPayGateway gateway = new KakaoPayGateway();
try {
KakaoPayResult result = gateway.pay(
request.getAmount(),
request.getKakaoPayInfo()
);
return PaymentResult.builder()
.transactionId(result.getTid())
.status(PaymentStatus.SUCCESS)
.amount(request.getAmount())
.paymentType(PaymentType.KAKAO_PAY)
.processedAt(LocalDateTime.now())
.build();
} catch (KakaoPayException e) {
return PaymentResult.failure(e.getMessage());
}
}
@Override
public boolean supports(PaymentType paymentType) {
return PaymentType.KAKAO_PAY.equals(paymentType);
}
}
// 4. 결제 서비스 - 수정에 닫혀있고 확장에 열려있음
@Service
@RequiredArgsConstructor
public class PaymentService {
private final List<PaymentProcessor> paymentProcessors;
public PaymentResult processPayment(PaymentRequest request) {
PaymentProcessor processor = paymentProcessors.stream()
.filter(p -> p.supports(request.getPaymentType()))
.findFirst()
.orElseThrow(() -> new UnsupportedPaymentTypeException(
"지원하지 않는 결제 방식: " + request.getPaymentType()
));
return processor.process(request);
}
}
🎉 OCP 적용 후 장점들:
기존 코드 보호: 새 결제 방식 추가 시 기존 코드 수정 불필요
확장 용이: PaymentProcessor 구현체만 추가하면 됨
독립적 개발: 각 결제 방식을 독립적으로 개발/테스트 가능
3. 🔄 LSP (Liskov Substitution Principle) - 리스코프 치환 원칙
“부모 클래스를 자식 클래스로 치환해도 프로그램이 올바르게 동작해야 한다”
❌ LSP 위반 사례
// 기본 할인 정책
public class DiscountPolicy {
public int calculateDiscount(int originalPrice) {
return (int) (originalPrice * 0.1); // 10% 할인
}
}
// 문제있는 상속 - LSP 위반
public class VipDiscountPolicy extends DiscountPolicy {
@Override
public int calculateDiscount(int originalPrice) {
if (originalPrice < 50000) {
// 부모와 다른 전제조건 - LSP 위반!
throw new IllegalArgumentException("VIP 할인은 5만원 이상부터 가능합니다");
}
return (int) (originalPrice * 0.2); // 20% 할인
}
}
// 또 다른 LSP 위반
public class NoDiscountPolicy extends DiscountPolicy {
@Override
public int calculateDiscount(int originalPrice) {
// 부모와 다른 후행조건 - 항상 0 반환
return 0; // 할인 없음
}
}
// 클라이언트 코드
@Service
public class OrderService {
public OrderResult calculateOrder(List<OrderItem> items, User user) {
int totalPrice = items.stream()
.mapToInt(item -> item.getPrice() * item.getQuantity())
.sum();
DiscountPolicy discountPolicy = getDiscountPolicy(user);
// LSP 위반으로 인한 문제 발생!
int discount = discountPolicy.calculateDiscount(totalPrice); // 예외 발생 가능
return new OrderResult(totalPrice, discount, totalPrice - discount);
}
private DiscountPolicy getDiscountPolicy(User user) {
if (user.isVip()) {
return new VipDiscountPolicy(); // 5만원 미만 시 예외 발생!
}
return new DiscountPolicy();
}
}
🔥 문제점들:
예상치 못한 예외: VIP 할인 정책에서 갑자기 예외 발생
일관성 없음: 같은 인터페이스인데 다른 동작 방식
치환 불가능: 부모를 자식으로 바꾸면 프로그램이 깨짐
✅ LSP 적용 후
// 할인 정책 인터페이스 - 명확한 계약 정의
public interface DiscountPolicy {
/**
* 주어진 가격에 대한 할인 금액을 계산합니다.
* @param originalPrice 원래 가격 (0 이상)
* @param user 사용자 정보
* @return 할인 금액 (0 이상)
* @throws IllegalArgumentException 가격이 0 미만인 경우
*/
int calculateDiscount(int originalPrice, User user);
/**
* 이 할인 정책이 적용 가능한지 확인합니다.
* @param user 사용자 정보
* @param totalPrice 총 주문 금액
* @return 적용 가능 여부
*/
boolean isApplicable(User user, int totalPrice);
}
// 기본 할인 정책
@Component
public class RegularDiscountPolicy implements DiscountPolicy {
private static final double DISCOUNT_RATE = 0.1;
@Override
public int calculateDiscount(int originalPrice, User user) {
if (originalPrice < 0) {
throw new IllegalArgumentException("가격은 0 이상이어야 합니다");
}
if (!isApplicable(user, originalPrice)) {
return 0;
}
return (int) (originalPrice * DISCOUNT_RATE);
}
@Override
public boolean isApplicable(User user, int totalPrice) {
return !user.isVip() && totalPrice >= 10000; // 1만원 이상 일반 회원
}
}
// VIP 할인 정책 - LSP 준수
@Component
public class VipDiscountPolicy implements DiscountPolicy {
private static final double DISCOUNT_RATE = 0.2;
private static final int MIN_PRICE_FOR_VIP_DISCOUNT = 50000;
@Override
public int calculateDiscount(int originalPrice, User user) {
if (originalPrice < 0) {
throw new IllegalArgumentException("가격은 0 이상이어야 합니다");
}
// 적용 불가능한 경우 예외가 아닌 0 반환 - LSP 준수
if (!isApplicable(user, originalPrice)) {
return 0;
}
return (int) (originalPrice * DISCOUNT_RATE);
}
@Override
public boolean isApplicable(User user, int totalPrice) {
return user.isVip() && totalPrice >= MIN_PRICE_FOR_VIP_DISCOUNT;
}
}
// 할인 없음 정책
@Component
public class NoDiscountPolicy implements DiscountPolicy {
@Override
public int calculateDiscount(int originalPrice, User user) {
if (originalPrice < 0) {
throw new IllegalArgumentException("가격은 0 이상이어야 합니다");
}
return 0; // 항상 0 반환 - 일관성 유지
}
@Override
public boolean isApplicable(User user, int totalPrice) {
return true; // 항상 적용 가능 (할인 없음이므로)
}
}
// 할인 정책 결정자
@Service
@RequiredArgsConstructor
public class DiscountPolicyDecider {
private final List<DiscountPolicy> discountPolicies;
public DiscountPolicy decide(User user, int totalPrice) {
return discountPolicies.stream()
.filter(policy -> policy.isApplicable(user, totalPrice))
.findFirst()
.orElse(new NoDiscountPolicy());
}
}
// 클라이언트 코드 - LSP 준수로 안전함
@Service
@RequiredArgsConstructor
public class OrderService {
private final DiscountPolicyDecider discountPolicyDecider;
public OrderResult calculateOrder(List<OrderItem> items, User user) {
int totalPrice = items.stream()
.mapToInt(item -> item.getPrice() * item.getQuantity())
.sum();
DiscountPolicy discountPolicy = discountPolicyDecider.decide(user, totalPrice);
// 어떤 구현체든 안전하게 치환 가능
int discount = discountPolicy.calculateDiscount(totalPrice, user);
return OrderResult.builder()
.originalPrice(totalPrice)
.discountAmount(discount)
.finalPrice(totalPrice - discount)
.appliedPolicy(discountPolicy.getClass().getSimpleName())
.build();
}
}
🎉 LSP 적용 후 장점들:
안전한 치환: 어떤 구현체든 예외 없이 동작
일관된 계약: 모든 구현체가 동일한 전제/후행 조건 준수
예측 가능한 동작: 클라이언트 코드가 안전함
4. 🧩 ISP (Interface Segregation Principle) - 인터페이스 분리 원칙
“클라이언트는 사용하지 않는 인터페이스에 의존하면 안 된다”
❌ ISP 위반 사례
// 거대한 인터페이스 - ISP 위반
public interface UserManager {
// 사용자 CRUD
User createUser(UserCreateRequest request);
User updateUser(Long id, UserUpdateRequest request);
void deleteUser(Long id);
User findUser(Long id);
List<User> findAllUsers();
// 인증 관련
boolean authenticate(String email, String password);
String generateToken(User user);
void logout(String token);
void resetPassword(String email);
// 이메일 관련
void sendWelcomeEmail(User user);
void sendPasswordResetEmail(String email);
void sendPromotionEmail(List<User> users, String content);
// 통계 관련
int getTotalUserCount();
List<User> getActiveUsers();
Map<String, Integer> getUserStatsByRegion();
// 관리자 전용
void banUser(Long userId, String reason);
void unbanUser(Long userId);
List<User> getBannedUsers();
}
// 문제: 이메일 발송만 필요한 클라이언트가 모든 메서드에 의존
@Component
public class EmailNotificationService {
private final UserManager userManager; // 거대한 인터페이스에 의존
public void sendMonthlyNewsletter() {
List<User> users = userManager.findAllUsers();
// 실제로는 sendPromotionEmail만 필요하지만
// 거대한 인터페이스 전체에 의존
userManager.sendPromotionEmail(users, "월간 뉴스레터");
}
}
// 문제: 사용자 조회만 필요한 클라이언트가 불필요한 의존성을 가짐
@Controller
public class UserController {
private final UserManager userManager; // 거대한 인터페이스에 의존
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
// findUser만 필요하지만 전체 인터페이스에 의존
User user = userManager.findUser(id);
return ResponseEntity.ok(user);
}
}
🔥 문제점들:
불필요한 의존성: 사용하지 않는 메서드까지 의존
변경 영향 확산: 인터페이스 변경 시 모든 클라이언트 영향
구현 부담: 구현체에서 모든 메서드를 구현해야 함
✅ ISP 적용 후
// 1. 사용자 CRUD 전용 인터페이스
public interface UserRepository {
User save(User user);
User findById(Long id);
List<User> findAll();
void deleteById(Long id);
boolean existsById(Long id);
}
// 2. 인증 전용 인터페이스
public interface AuthenticationService {
boolean authenticate(String email, String password);
String generateToken(User user);
void logout(String token);
void resetPassword(String email);
}
// 3. 이메일 전용 인터페이스
public interface UserEmailService {
void sendWelcomeEmail(User user);
void sendPasswordResetEmail(String email);
void sendPromotionEmail(List<User> users, String content);
}
// 4. 사용자 통계 전용 인터페이스
public interface UserStatisticsService {
int getTotalUserCount();
List<User> getActiveUsers();
Map<String, Integer> getUserStatsByRegion();
List<User> getUsersRegisteredBetween(LocalDate start, LocalDate end);
}
// 5. 관리자 전용 인터페이스
public interface UserAdminService {
void banUser(Long userId, String reason);
void unbanUser(Long userId);
List<User> getBannedUsers();
void sendWarningToUser(Long userId, String message);
}
// 6. 사용자 도메인 서비스
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserEventPublisher eventPublisher;
@Transactional
public User createUser(UserCreateRequest request) {
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.build();
User savedUser = userRepository.save(user);
eventPublisher.publishUserCreated(savedUser);
return savedUser;
}
@Transactional(readOnly = true)
public User findUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException());
}
}
// 각 클라이언트는 필요한 인터페이스만 의존
@Component
@RequiredArgsConstructor
public class EmailNotificationService {
private final UserEmailService userEmailService; // 이메일 인터페이스만 의존
private final UserRepository userRepository; // 사용자 조회 인터페이스만 의존
public void sendMonthlyNewsletter() {
List<User> users = userRepository.findAll();
userEmailService.sendPromotionEmail(users, "월간 뉴스레터");
}
}
@Controller
@RequiredArgsConstructor
public class UserController {
private final UserService userService; // 필요한 서비스만 의존
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.findUser(id);
return ResponseEntity.ok(user);
}
}
@Controller
@RequiredArgsConstructor
public class AdminController {
private final UserAdminService adminService; // 관리자 인터페이스만 의존
private final UserStatisticsService statsService; // 통계 인터페이스만 의존
@PostMapping("/admin/users/{id}/ban")
public ResponseEntity<Void> banUser(
@PathVariable Long id,
@RequestBody BanRequest request) {
adminService.banUser(id, request.getReason());
return ResponseEntity.ok().build();
}
@GetMapping("/admin/users/stats")
public ResponseEntity<UserStats> getUserStats() {
UserStats stats = UserStats.builder()
.totalCount(statsService.getTotalUserCount())
.activeUsers(statsService.getActiveUsers().size())
.regionStats(statsService.getUserStatsByRegion())
.build();
return ResponseEntity.ok(stats);
}
}
🎉 ISP 적용 후 장점들:
필요한 의존성만: 각 클라이언트가 실제 사용하는 인터페이스만 의존
변경 영향 최소화: 인터페이스 변경 시 해당 클라이언트만 영향
구현 단순화: 각 인터페이스별로 독립적인 구현체 작성 가능
5. ⬇️ DIP (Dependency Inversion Principle) - 의존관계 역전 원칙
“고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다”
❌ DIP 위반 사례
// 저수준 모듈들 (구체적인 구현)
public class MySqlUserRepository {
public User save(User user) {
// MySQL 특화 저장 로직
String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
// JDBC 코드...
return user;
}
public User findById(Long id) {
// MySQL 특화 조회 로직
String sql = "SELECT * FROM users WHERE id = ?";
// JDBC 코드...
return user;
}
}
public class SmtpEmailService {
public void sendEmail(String to, String subject, String content) {
// SMTP 특화 이메일 발송 로직
Properties props = new Properties();
props.put("mail.smtp.host", "smtp.gmail.com");
// JavaMail 코드...
}
}
// 고수준 모듈이 저수준 모듈에 직접 의존 - DIP 위반
@Service
public class UserService {
// 구체적인 구현체에 직접 의존!
private final MySqlUserRepository userRepository;
private final SmtpEmailService emailService;
public UserService() {
// 생성자에서 직접 인스턴스 생성 - 강한 결합!
this.userRepository = new MySqlUserRepository();
this.emailService = new SmtpEmailService();
}
public User createUser(String name, String email) {
User user = new User(name, email);
// MySQL에 강하게 결합
User savedUser = userRepository.save(user);
// SMTP에 강하게 결합
emailService.sendEmail(
savedUser.getEmail(),
"환영합니다",
"가입을 환영합니다"
);
return savedUser;
}
}
// 또 다른 DIP 위반 - 결제 서비스
@Service
public class PaymentService {
// 구체적인 PG사에 직접 의존
private final IamportPaymentGateway iamportGateway;
public PaymentService() {
this.iamportGateway = new IamportPaymentGateway();
}
public PaymentResult processPayment(PaymentRequest request) {
// 아임포트에만 의존 - 다른 PG로 변경 시 전체 수정 필요
return iamportGateway.charge(request.getAmount(), request.getCardInfo());
}
}
🔥 문제점들:
강한 결합: 구체 구현에 직접 의존하여 변경이 어려움
테스트 어려움: 실제 MySQL, SMTP를 사용해야 테스트 가능
확장성 부족: 다른 구현체로 변경 시 코드 전체 수정 필요
✅ DIP 적용 후
// 1. 고수준에서 정의한 추상화 (인터페이스)
public interface UserRepository {
User save(User user);
User findById(Long id);
List<User> findAll();
boolean existsByEmail(String email);
}
public interface EmailService {
void sendEmail(EmailMessage message);
}
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
PaymentResult refund(String transactionId, int amount);
}
// 2. 고수준 모듈 - 추상화에만 의존
@Service
@RequiredArgsConstructor // 생성자 주입
public class UserService {
// 추상화(인터페이스)에 의존
private final UserRepository userRepository;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
@Transactional
public User createUser(UserCreateRequest request) {
// 이메일 중복 확인
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException();
}
User user = User.builder()
.name(request.getName())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.build();
// 추상화를 통한 저장
User savedUser = userRepository.save(user);
// 추상화를 통한 이메일 발송
EmailMessage welcomeMessage = EmailMessage.builder()
.to(savedUser.getEmail())
.subject("환영합니다!")
.content("가입을 환영합니다, " + savedUser.getName() + "님!")
.build();
emailService.sendEmail(welcomeMessage);
return savedUser;
}
}
@Service
@RequiredArgsConstructor
public class PaymentService {
// 추상화에 의존
private final PaymentGateway paymentGateway;
@Transactional
public PaymentResult processPayment(PaymentRequest request) {
try {
// 어떤 PG사든 동일한 인터페이스로 처리
return paymentGateway.charge(request);
} catch (PaymentException e) {
throw new PaymentProcessingException("결제 처리 중 오류가 발생했습니다", e);
}
}
@Transactional
public PaymentResult processRefund(String transactionId, int amount) {
return paymentGateway.refund(transactionId, amount);
}
}
// 3. 저수준 모듈들 - 추상화를 구현
@Repository
public class MySqlUserRepository implements UserRepository {
@Override
public User save(User user) {
// MySQL JPA 구현
return userJpaRepository.save(user);
}
@Override
public User findById(Long id) {
return userJpaRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException());
}
@Override
public boolean existsByEmail(String email) {
return userJpaRepository.existsByEmail(email);
}
}
// 다른 DB 구현체도 쉽게 추가 가능
@Repository
@Profile("mongodb")
public class MongoUserRepository implements UserRepository {
@Override
public User save(User user) {
// MongoDB 구현
return mongoTemplate.save(user);
}
@Override
public User findById(Long id) {
return mongoTemplate.findById(id, User.class);
}
}
@Service
public class SmtpEmailService implements EmailService {
@Override
public void sendEmail(EmailMessage message) {
// SMTP 구현
SimpleMailMessage mailMessage = new SimpleMailMessage();
mailMessage.setTo(message.getTo());
mailMessage.setSubject(message.getSubject());
mailMessage.setText(message.getContent());
mailSender.send(mailMessage);
}
}
// 다른 이메일 서비스도 쉽게 추가
@Service
@Profile("ses")
public class AwsSesEmailService implements EmailService {
@Override
public void sendEmail(EmailMessage message) {
// AWS SES 구현
SendEmailRequest request = SendEmailRequest.builder()
.destination(Destination.builder().toAddresses(message.getTo()).build())
.message(Message.builder()
.subject(Content.builder().data(message.getSubject()).build())
.body(Body.builder().text(Content.builder().data(message.getContent()).build()).build())
.build())
.source(fromEmail)
.build();
sesClient.sendEmail(request);
}
}
@Service
public class IamportPaymentGateway implements PaymentGateway {
@Override
public PaymentResult charge(PaymentRequest request) {
// 아임포트 API 호출
IamportResponse<Payment> response = iamportClient.paymentByImpUid(request.getImpUid());
return PaymentResult.builder()
.transactionId(response.getResponse().getImpUid())
.status(convertStatus(response.getResponse().getStatus()))
.amount(response.getResponse().getAmount().intValue())
.build();
}
}
// 다른 PG사도 쉽게 추가
@Service
@Profile("toss")
public class TossPaymentGateway implements PaymentGateway {
@Override
public PaymentResult charge(PaymentRequest request) {
// 토스페이먼츠 API 호출
// ...
}
}
🎉 DIP 적용 후 장점들:
느슨한 결합: 구현체 변경 시 고수준 모듈 수정 불필요
테스트 용이성: Mock 객체로 쉽게 단위 테스트 가능
확장성: 새로운 구현체 추가가 매우 쉬움
설정 기반 전환: Profile이나 설정으로 구현체 변경 가능
// 테스트 코드 예시
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserService userService;
@Test
void createUser_성공() {
// given
UserCreateRequest request = new UserCreateRequest("테스트", "test@test.com", "password");
when(userRepository.existsByEmail(request.getEmail())).thenReturn(false);
when(passwordEncoder.encode(request.getPassword())).thenReturn("encodedPassword");
User savedUser = User.builder()
.id(1L)
.name(request.getName())
.email(request.getEmail())
.build();
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// when
User result = userService.createUser(request);
// then
assertThat(result.getName()).isEqualTo("테스트");
verify(emailService).sendEmail(any(EmailMessage.class));
}
}
⚖️ 언제 SOLID를 적용해야 할까?
🟢 SOLID 적극 적용 상황
// 복잡한 비즈니스 로직을 가진 주문 처리 시스템
@Service
@RequiredArgsConstructor
public class OrderProcessingService {
// 여러 외부 시스템과 연동
private final PaymentGateway paymentGateway;
private final InventoryService inventoryService;
private final ShippingService shippingService;
private final NotificationService notificationService;
private final LoyaltyService loyaltyService;
@Transactional
public OrderResult processOrder(OrderRequest request) {
// 복잡한 주문 처리 플로우
// 1. 재고 확인 및 예약
// 2. 결제 처리
// 3. 배송 요청
// 4. 포인트 적립
// 5. 알림 발송
// 각 단계마다 다양한 예외 상황 처리
}
}
적용 신호들:
높은 복잡도: 클래스가 100줄 이상, 메서드가 20줄 이상
다양한 변경 요인: 비즈니스 규칙, 외부 시스템, UI 요구사항 변경
확장 계획: 새로운 결제 방식, 배송업체, 알림 채널 추가 예정
팀 규모: 3명 이상의 개발자가 동시에 작업
🟡 SOLID 선택적 적용 상황
// 중간 복잡도의 사용자 관리 시스템
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public User createUser(UserCreateRequest request) {
// 적당한 복잡도의 로직
validateUserRequest(request);
User user = buildUser(request);
return userRepository.save(user);
}
private void validateUserRequest(UserCreateRequest request) {
// 검증 로직 (10줄 내외)
}
private User buildUser(UserCreateRequest request) {
// 생성 로직 (5줄 내외)
}
}
적용 고려 사항:
DIP 우선 적용: 테스트 용이성을 위해
SRP 부분 적용: 너무 큰 클래스만 분리
OCP는 확장 계획이 있을 때만
🔴 SOLID 최소 적용 상황
// 단순한 CRUD 서비스
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
public Product save(Product product) {
return repository.save(product);
}
public Product findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
public void deleteById(Long id) {
repository.deleteById(id);
}
}
최소 적용 기준:
DIP만 적용: Spring의 의존성 주입 활용
나머지 원칙은 과도한 설계: 단순함이 더 나음
🚨 자주 하는 실수들과 해결책
❌ 실수 1: 과도한 추상화
// 불필요한 추상화의 예
public interface StringProcessor {
String process(String input);
}
@Component
public class StringTrimmer implements StringProcessor {
public String process(String input) {
return input.trim();
}
}
@Component
public class StringUpperCaser implements StringProcessor {
public String process(String input) {
return input.toUpperCase();
}
}
// 이런 단순한 기능까지 인터페이스로 만들 필요 없음
✅ 해결책: 적절한 수준의 추상화
// 유틸리티성 기능은 정적 메서드나 간단한 컴포넌트로
@Component
public class StringUtils {
public String cleanAndFormat(String input) {
if (input == null) return "";
return input.trim().toUpperCase();
}
}
❌ 실수 2: 인터페이스 구현체 1:1 매칭
// 의미없는 1:1 인터페이스
public interface UserService {
User createUser(UserCreateRequest request);
}
@Service
public class UserServiceImpl implements UserService {
// 구현체가 하나뿐인데 굳이 인터페이스?
}
✅ 해결책: 필요시에만 인터페이스 생성
// 구현체가 하나뿐이고 확장 계획이 없다면 인터페이스 불필요
@Service
public class UserService {
public User createUser(UserCreateRequest request) {
// 구현 로직
}
}
// 확장 계획이 있거나 테스트를 위해 Mock이 필요한 경우에만 인터페이스 사용
❌ 실수 3: God Object 방지를 위한 과도한 분리
// 너무 세분화된 분리
@Component
public class UserNameValidator { } // 이름 검증만
@Component
public class UserEmailValidator { } // 이메일 검증만
@Component
public class UserPhoneValidator { } // 전화번호 검증만
@Component
public class UserAgeValidator { } // 나이 검증만
// 오히려 복잡도만 증가
✅ 해결책: 관련있는 책임은 함께 묶기
@Component
public class UserValidator {
public void validate(User user) {
validateName(user.getName());
validateEmail(user.getEmail());
validatePhone(user.getPhone());
validateAge(user.getAge());
}
private void validateName(String name) { }
private void validateEmail(String email) { }
private void validatePhone(String phone) { }
private void validateAge(int age) { }
}
🎓 실무 적용 로드맵
🏃♂️ 1단계: 기초 다지기 (1-2주)
DIP부터 시작
// Before: 구체 클래스에 의존
@Service
public class OrderService {
private MySqlOrderRepository repository = new MySqlOrderRepository();
}
// After: 추상화에 의존
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository; // 인터페이스에 의존
}
🚶♂️ 2단계: 책임 분리 (2-3주)
SRP 적용으로 큰 클래스 분해
// Before: 하나의 서비스가 모든 일을 담당
@Service
public class OrderService {
// 주문 생성 + 결제 + 재고관리 + 이메일 발송 + 로깅 (200줄)
}
// After: 책임별로 분리
@Service
public class OrderService {
private final OrderValidator validator;
private final PaymentService paymentService;
private final InventoryService inventoryService;
// 각각 명확한 책임
}
🏃♂️ 3단계: 확장성 고려 (3-4주)
OCP 적용으로 확장 가능한 구조
// 새로운 결제 방식, 알림 방식을 쉽게 추가할 수 있는 구조
public interface PaymentProcessor {
PaymentResult process(PaymentRequest request);
}
@Service
public class PaymentService {
private final List<PaymentProcessor> processors;
public PaymentResult process(PaymentRequest request) {
// 적절한 processor 선택해서 처리
}
}
🔄 4단계: 지속적 개선
코드 리뷰 체크리스트
SRP: 이 클래스가 변경되는 이유가 2개 이상인가?
OCP: 새 기능 추가 시 기존 코드를 수정해야 하는가?
LSP: 부모를 자식으로 바꿔도 정상 동작하는가?
ISP: 사용하지 않는 메서드에 의존하고 있는가?
DIP: 구체 클래스에 직접 의존하고 있는가?
💡 팀 단위 적용 전략
👥 작은 팀 (2-3명)
// 핵심 서비스에만 선택적 적용
@Service
@RequiredArgsConstructor
public class OrderService { // 핵심 비즈니스 로직만 SOLID 적용
private final PaymentGateway paymentGateway; // DIP
private final NotificationSender notificationSender; // ISP + OCP
}
@Service
public class ProductService { // 단순 CRUD는 기본 구조 유지
private final ProductRepository repository;
}
👥 중간 팀 (4-6명)
// 모듈별 담당자 지정, 인터페이스 중심 설계
public interface PaymentModule {
PaymentResult process(PaymentRequest request);
}
public interface NotificationModule {
void send(NotificationRequest request);
}
// 각 모듈을 독립적으로 개발 가능
👥 큰 팀 (7명 이상)
// 완전한 SOLID 적용 + 도메인별 분리
// 주문 도메인
@DomainService
public class OrderDomainService {
// 모든 SOLID 원칙 적용
}
// 결제 도메인
@DomainService
public class PaymentDomainService {
// 모든 SOLID 원칙 적용
}
// 도메인간 통신은 이벤트나 API 게이트웨이 사용
🎯 성공 지표와 측정
📊 정량적 지표
// 1. 클래스 크기 측정
// Before SOLID: 평균 200줄/클래스
// After SOLID: 평균 50-80줄/클래스
// 2. 의존성 개수
// Before: 하나의 서비스가 10개 이상의 구체 클래스에 의존
// After: 인터페이스 의존으로 결합도 감소
// 3. 테스트 커버리지
// Before: 통합테스트 위주로 느린 테스트
// After: 단위테스트 증가로 빠른 피드백
📈 정성적 지표
새 기능 추가 시간: 기존 코드 수정 없이 추가 가능
버그 수 감소: 책임이 명확해져 버그 발생 지점 명확
팀 생산성: 모듈별 독립 개발로 충돌 감소
🎪 핵심 원칙 정리
🏆 성공하는 개발자의 SOLID 마인드셋
“완벽한 설계보다는 점진적 개선을!”
5가지 실천 원칙
🎯 SRP: “이 클래스가 변경되는 이유가 2개 이상이면 분리를 고려한다”
🚪 OCP: “새 기능 추가 시 기존 코드를 수정하고 있다면 설계를 의심한다”
🔄 LSP: “부모를 자식으로 바꿨을 때 예외가 발생하면 설계를 다시 본다”
🧩 ISP: “사용하지 않는 메서드를 구현하고 있다면 인터페이스를 분리한다”
⬇️ DIP: “테스트하기 어렵다면 구체 클래스에 의존하고 있는 건 아닌지 확인한다”
🚀 마무리: 실무에서 살아남는 SOLID
⚡ 실무 적용의 황금률
“SOLID는 목적이 아니라 수단이다”
SOLID 원칙을 적용하는 이유는 원칙 자체가 목적이 아니라, 다음을 위해서입니다:
🔧 유지보수성: 6개월 후에도 이해하기 쉬운 코드
🚀 확장성: 새로운 요구사항에 빠르게 대응
🧪 테스트 용이성: 안정적인 배포를 위한 견고한 테스트
👥 협업: 팀원들과 함께 일하기 좋은 코드
🎯 실무 적용 3단계 요약
1️⃣ 시작 단계: DIP부터
// 의존성 주입으로 테스트 가능한 코드 만들기
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository; // 추상화에 의존
}
2️⃣ 발전 단계: SRP + OCP
// 책임 분리 + 확장 가능한 구조
@Service
public class OrderService {
private final List<OrderValidator> validators; // SRP
private final List<PaymentProcessor> processors; // OCP
}
3️⃣ 완성 단계: 전체 원칙 적용
// 모든 SOLID 원칙이 자연스럽게 녹아든 코드
// 각 클래스가 명확한 책임을 가지고
// 확장에 열려있으며
// 안전하게 치환 가능하고
// 필요한 인터페이스만 의존하며
// 추상화에 의존하는 구조
🎁 마지막 조언
SOLID 원칙을 맹목적으로 적용하지 마세요. 프로젝트의 규모, 팀의 크기, 요구사항의 복잡도를 고려해서 적절한 수준에서 적용하는 것이 중요합니다.
작은 프로젝트: DIP 정도만으로도 충분
중간 프로젝트: SRP + DIP + OCP 선택 적용
큰 프로젝트: 모든 SOLID 원칙 적극 활용
“오늘의 선택이 6개월 후의 나를 만든다”
지금 당장은 복잡해 보일 수 있지만, SOLID 원칙을 체득한 개발자는 더 빠르고, 더 안전하게, 더 즐겁게 개발할 수 있습니다.
여러분의 개발 여정에 SOLID가 든든한 나침반이 되기를 바랍니다! 🧭✨
-
-
Touch background to close