Code

쉽게 이해하는 SOLID 원칙: 실전 적용 사례와 함께 배우기

Solid Principles

소프트웨어 개발에서 객체 지향 설계는 코드를 쉽게 변경하고 확장하며 재사용할 수 있도록 하는 데 매우 중요합니다.

SOLID 원칙은 객체 지향 프로그래밍과 소프트웨어 개발에서 유지 보수 가능하고 유연하며 확장 가능한 소프트웨어를 만들기 위한 다섯 가지 설계 원칙을 의미합니다. 이 원칙들은 로버트 C. 마틴에 의해 소개되었으며, 깔끔하고 효율적인 코드를 작성하기 위한 지침으로 널리 사용되고 있습니다. “SOLID”라는 단어의 각 글자는 다음 원칙들을 나타냅니다:

  1. 단일 책임 원칙 (SRP)
  2. 개방/폐쇄 원칙 (OCP)
  3. 리스코프 치환 원칙 (LSP)
  4. 인터페이스 분리 원칙 (ISP)
  5. 의존성 역전 원칙 (DIP)

이 글에서는 각 원칙이 Spring Boot 애플리케이션에서 어떻게 사용되는지 살펴보겠습니다.

1. 단일 책임 원칙 (SRP)

로버트 C. 마틴은 단일 책임 원칙을 다음과 같이 설명합니다:

클래스는 변경할 이유가 하나, 오직 하나만 있어야 한다.

단일 책임 원칙은 이름이 시사하듯 두 가지 주요 원칙이 포함됩니다.

  1. 클래스나 메서드는 변경할 이유가 하나만 있어야 합니다.
  2. 클래스나 메서드는 하나의 책임만 가져야 합니다.

아래 예제에서 잘못된 사용을 살펴보겠습니다:

// 잘못된 SRP 구현
@RestController
@RequestMapping("/report")
public class ReportController {

    private final ReportService reportService;

    public ReportController(ReportService reportService) {
        this.reportService = reportService;
    }

    @PostMapping("/send")
    public ResponseEntity<Report> generateAndSendReport(@RequestParam String reportContent,
                                                        @RequestParam String to,
                                                        @RequestParam String subject) {
        String report = reportService.generateReport(reportContent);
        reportService.sendReportByEmail(report, to, subject);
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

// 잘못된 SRP 구현
// 클래스가 보고서 생성 및 이메일 발송에 모두 책임을 짐
@Service
@Slf4j
public class ReportServiceImpl implements ReportService {

    private final ReportRepository reportRepository;

    public ReportServiceImpl(ReportRepository reportRepository) {
        this.reportRepository = reportRepository;
    }

    @Override
    public String generateReport(String reportContent) {
        Report report = new Report();
        report.setReportContent(reportContent);
        return reportRepository.save(report).toString();
    }

    @Override
    public void sendReportByEmail(Long reportId, String to, String subject) {
        Report report = findReportById(reportId);
        sendEmail(report.getReportContent(), to, subject);
    }

    private Report findReportById(Long reportId) {
        return reportRepository.findById(reportId)
                .orElseThrow(() -> new RuntimeException("Report not found"));
    }

    private void sendEmail(String content, String to, String subject) {
       log.info(content, to, subject);
    }
}

위에서 볼 수 있듯이 ReportService여러 책임을 가지고 있어 단일 책임 원칙을 위반합니다:

  • 보고서 생성: generateReport 메서드에서 보고서를 생성하고 저장소에 저장하는 책임.
  • 이메일로 보고서 전송: sendReportByEmail 메서드에서 이메일로 보고서를 보내는 책임.

코드를 작성할 때, 하나의 클래스나 메서드에 너무 많은 작업을 넣지 않도록 해야 합니다. 이는 코드를 복잡하게 만들고 다루기 어렵게 만듭니다. 작은 변경 사항이 다른 부분에 영향을 미칠 수 있어, 사소한 업데이트에도 전체 코드를 테스트해야 합니다.


이제 이 구현을 수정해 보겠습니다;

SRP를 준수하기 위해 이러한 책임을 다른 클래스들로 분리했습니다.

@RestController
@RequestMapping("/report")
public class ReportController {

    private final ReportService reportService;
    private final EmailService emailService;

    public ReportController(ReportService reportService, EmailService emailService) {
        this.reportService = reportService;
        this.emailService = emailService;
    }

    @PostMapping("/send")
    public ResponseEntity<Report> generateAndSendReport(@RequestParam String reportContent,
                                                        @RequestParam String to,
                                                        @RequestParam String subject) {
        // 올바른 구현: reportService는 생성에 책임을 가짐
        Long reportId = Long.valueOf(reportService.generateReport(reportContent));
        // 올바른 구현: emailService는 전송에 책임을 가짐
        emailService.sendReportByEmail(reportId, to, subject);
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

@Service
public class ReportServiceImpl implements ReportService {

    private final ReportRepository reportRepository;

    public ReportServiceImpl(ReportRepository reportRepository) {
        this.reportRepository = reportRepository;
    }

    @Override
    public String generateReport(String reportContent) {
        Report report = new Report();
        report.setReportContent(reportContent);
        return reportRepository.save(report).toString();
    }
}

@Service
public class EmailServiceImpl implements EmailService {

    private final ReportRepository reportRepository;

    public EmailServiceImpl(ReportRepository reportRepository) {
        this.reportRepository = reportRepository;
    }

    @Override
    public void sendReportByEmail(Long reportId, String to, String subject) {
        Report report = findReportById(reportId);
        if (ObjectUtils.isEmpty(report) || !StringUtils.hasLength(report.getReportContent())) {
            throw new RuntimeException("Report or report content is empty");
        }
        sendEmail(report.getReportContent(), to, subject);
    }

    private Report findReportById(Long reportId) {
        return reportRepository.findById(reportId)
                .orElseThrow(() -> new RuntimeException("Report not found"));
    }

    private void sendEmail(String content, String to, String subject) {
        // 이메일 전송 로직
    }
}

리팩토링된 코드의 변경 사항은 다음과 같습니다:

  • ReportServiceImpl는 보고서 생성에 책임이 있습니다.
  • EmailServiceImpl는 이메일로 보고서를 보내는 책임이 있습니다.
  • ReportController는 적절한 서비스를 사용하여 보고서 생성 및 전송 프로세스를 관리합니다.

2. 개방/폐쇄 원칙 (OCP)

개방-폐쇄 원칙은 클래스가 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다고 말합니다. 이는 작동 중인 애플리케이션에 버그가 생기는 것을 방지하는 데 도움이 됩니다. 더 간단히 말하면, 기존 코드를 변경하지 않고도 클래스에 새로운 기능을 추가할 수 있어야 한다는 의미입니다.

Open-Closed Principle
개방/폐쇄 원칙 (Open-Closed Principle)

아래 예제에서 잘못된 사용을 살펴보겠습니다:

// OCP를 위반하는 잘못된 구현
public class ReportGeneratorService {
    public String generateReport(Report report) {
        if ("PDF".equals(report.getReportType())) {
            // PDF 보고서 생성에 대한 직접적인 구현
            return "PDF report generated";
        } else if ("Excel".equals(report.getReportType())) {
            // Excel 보고서 생성에 대한 직접적인 구현
            return "Excel report generated";
        } else {
            return "Unsupported report type";
        }
    }
}

이 잘못된 구현에서 generateReport 메서드는 보고서 유형을 확인하고 직접 보고서를 생성합니다. 이는 새로운 보고서 유형을 추가하려면 이 클래스를 수정해야 하기 때문에 개방/폐쇄 원칙을 위반합니다.

이 구현을 수정해 보겠습니다;

public interface ReportGenerator {
    String generateReport(Report report);
}

// PDF 보고서 생성을 위한 구체적인 구현
@Component
public class PdfReportGenerator implements ReportGenerator {
    @Override
    public String generateReport(Report report) {
        // PDF 보고서 구현
        return String.format("PDF report generated for %s", report.getReportType());
    }
}

// Excel 보고서 생성을 위한 구체적인 구현
@Component
public class ExcelReportGenerator implements ReportGenerator {
    @Override
    public String generateReport(Report report) {
        // Excel 보고서 구현
        return String.format("Excel report generated for %s", report.getReportType());
    }
}

// OCP를 준수하는 서비스
@Service
public class ReportGeneratorService {

    private final Map<String, ReportGenerator> reportGenerators;

    @Autowired
    public ReportGeneratorService(List<ReportGenerator> generators) {
        // 보고서 생성기 맵 초기화
        this.reportGenerators = generators.stream()
                .collect(Collectors.toMap(generator -> generator.getClass().getSimpleName(), Function.identity()));
    }

    public String generateReport(Report report, String reportType) {
        return reportGenerators.getOrDefault(reportType, unsupportedReportGenerator())
                .generateReport(report);
    }

    private ReportGenerator unsupportedReportGenerator() {
        return report -> "Unsupported report type";
    }
}

변경 사항은 다음과 같습니다:

  • ReportGenerator 인터페이스를 추가하여 보고서 생성에 대한 공통 메서드를 정의했습니다.
  • PDF와 Excel 보고서 생성을 위한 구체적인 클래스를 만들었습니다.
    • PdfReportGeneratorExcelReportGenerator
  • ReportGeneratorService 생성
    • 다양한 보고서 생성기 구현을 관리하는 서비스를 추가하였습니다. 이제 기존 코드를 변경하지 않고 새로운 보고서 생성기를 추가할 수 있습니다.

요약하자면, 이 서비스는 이러한 구현을 동적으로 처리하여 기존 코드를 변경하지 않고도 새로운 기능을 쉽게 추가할 수 있게 하며, 개방-폐쇄 원칙을 따릅니다.

3. 리스코프 치환 원칙 (LSP)

리스코프 치환 원칙은 프로그램에서 어떤 클래스가 있을 때, 그 클래스를 하위 클래스로 대체해도 문제가 발생하지 않아야 한다고 말합니다.

즉, 더 일반적인 버전을 사용하는 곳 어디에서나 특수화된 버전을 사용할 수 있어야 하며, 모든 것이 여전히 올바르게 작동해야 한다는 의미입니다.

아래 예제에서 잘못된 사용을 살펴보겠습니다:

// LSP를 위반하는 잘못된 구현
public class Bird {
    public void fly() {
        // 나는 기능
    }

    public void swim() {
        // 수영 기능
    }
}

public class Penguin extends Bird {
    // 펭귄은 날 수 없지만, fly 메서드를 오버라이드하여 예외를 던짐
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins cannot fly");
    }
}

이 구현을 수정해 보겠습니다:

// LSP를 준수하는 올바른 구현
public class Bird {
    // 공통 메서드
}

public interface Flyable {
    void fly();
}

public interface Swimmable {
    void swim();
}

public class Penguin extends Bird implements Swimmable {
    // 펭귄은 날 수 없으므로 swim 인터페이스만 구현
    @Override
    public void swim() {
        System.out.println("I can swim");
    }
}

public class Eagle extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("I can fly");
    }
}

변경 사항은 다음과 같습니다:

  • Bird 클래스는 모든 새들이 공유하는 공통 속성이나 메서드를 포함합니다.
  • FlyableSwimmable 인터페이스를 도입하여 특정 행동을 나타냅니다.
  • Penguin 클래스는 펭귄의 수영 능력을 반영하기 위해 Swimmable 인터페이스를 구현합니다.
  • Eagle 클래스는 독수리의 비행 능력을 반영하기 위해 Flyable 인터페이스를 구현합니다.

특정 행동을 인터페이스로 분리하고 이를 하위 클래스에서 구현하여, 하위 클래스를 바꾸어도 문제가 발생하지 않도록 리스코프 치환 원칙을 따릅니다.

4. 인터페이스 분리 원칙 (ISP)

인터페이스 분리 원칙(Interface Segregation Principle)은 큰 인터페이스를 더 작은 인터페이스로 분할해야 한다고 주장합니다. 이렇게 하면, 구현 클래스가 자신에게 필요한 메서드에만 신경쓰도록 할 수 있습니다.

인터페이스 분리 원칙
인터페이스 분리 원칙 (Interface Segregation Principle)

아래 예시에서 잘못된 사용 사례를 살펴봅시다.

public interface Athlete {
    void compete();
    void swim();
    void highJump();
    void longJump();
}

// 인터페이스 분리 원칙을 위반한 잘못된 구현
public class Danny implements Athlete {
    @Override
    public void compete() {
        System.out.println("John Doe started competing");
    }

    @Override
    public void swim() {
        System.out.println("John Doe started swimming");
    }

    @Override
    public void highJump() {
        // John Doe에게는 필요하지 않음
    }

    @Override
    public void longJump() {
        // John Doe에게는 필요하지 않음
    }
}

Danny가 수영 선수라고 가정해봅시다. 그는 자신의 역할과 무관한 highJump와 longJump 메서드를 빈 구현으로 제공해야 합니다.

이제 이 구현을 수정해 봅시다:

public interface Athlete {
    void compete();
}

public interface JumpingAthlete {
    void highJump();
    void longJump();
}

public interface SwimmingAthlete {
    void swim();
}

// 인터페이스 분리 원칙을 준수한 올바른 구현
public class Danny implements Athlete, SwimmingAthlete {
    @Override
    public void compete() {
        System.out.println("John Doe started competing");
    }

    @Override
    public void swim() {
        System.out.println("John Doe started swimming");
    }
}

원래의 Athlete 인터페이스는 일반적인 활동을 위한 Athlete, 점프 관련 활동을 위한 JumpingAthlete, 수영을 위한 SwimmingAthlete로 세 개의 별도 인터페이스로 분리되었습니다.

이렇게 하면 인터페이스 분리 원칙을 준수하여, 클래스가 필요하지 않은 메서드를 구현하지 않아도 됩니다.

5. 의존성 역전 원칙 (DIP)

의존성 역전 원칙은 고수준 모듈이 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다고 말합니다. 추상화는 세부 사항에 의존해서는 안 됩니다.

아래 예제에서 잘못된 사용을 살펴보겠습니다:

// 의존성 역전 원칙을 위반하는 잘못된 구현
@Service
public class PayPalPaymentService {
    public void processPayment(Order order) {
        // 결제 처리 로직
    }
}

@RestController
public class PaymentController {

    // 특정 구현에 대한 직접 의존성
    private final PayPalPaymentService paymentService;

    // 특정 구현을 직접 초기화하는 생성자
    public PaymentController() {
        this.paymentService = new PayPalPaymentService();
    }

    @PostMapping("/pay")
    public void pay(@RequestBody Order order) {
        paymentService.processPayment(order);
    }
}

이 구현을 수정해 보겠습니다:

// 인터페이스 도입
public interface PaymentService {
    void processPayment(Order order);
}

// 서비스 클래스에서 인터페이스 구현
@Service
public class PayPalPaymentService implements PaymentService {
    @Override
    public void processPayment(Order order) {
        // 결제 처리 로직
    }
}

@RestController
public class PaymentController {

    private final PaymentService paymentService;

    // 생성자 주입
    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/pay")
    public void pay(@RequestBody Order order) {
        paymentService.processPayment(order);
    }
}

변경 사항은 다음과 같습니다:

  • PaymentService 인터페이스를 도입했습니다.
  • 컨트롤러의 생성자에 PaymentService 인터페이스를 주입하여 추상화를 제공했습니다.
  • 컨트롤러는 추상화(PaymentService)에 의존하여, 인터페이스를 구현하는 클래스를 주입할 수 있게 했습니다.

의존성 역전 원칙(DIP)의존성 주입(Dependency Injection)은 Spring 프레임워크에서 연결된 개념입니다. DIP는 코드의 느슨한 결합을 유지하는 것을 목표로 하며, 이는 Spring에서 의존성 주입을 통해 런타임 동안 애플리케이션을 관리하는 방식과 연결됩니다.

결론

SOLID 원칙은 객체 지향 프로그래밍(OOP)에서 매우 중요합니다. 이 원칙들은 유지보수가 쉽고, 유연하며, 확장 가능한 소프트웨어를 설계하기 위한 지침과 모범 사례를 제공합니다.

이 글에서는 Spring Boot 어플리케이션에서 SOLID 원칙을 잘못 적용한 사례를 먼저 논의했습니다. 그 후, 관련 예제를 통해 이러한 문제들이 어떻게 해결되었는지 살펴보았습니다. 모든 예제는 기본적인 수준에서 제공하였습니다.

혹시 궁금하신 사항이 있으시면 댓글 달아주세요 !!

끝까지 읽어주셔서 감사합니다. (_ _)

Written by 개발자서동우
안녕하세요! 저는 기술 분야에서 활동 중인 개발자 서동우입니다. 명품 플랫폼 (주)트렌비의 창업 멤버이자 CTO로 활동했으며, AI 기술회사 (주)헤드리스의 공동 창업자이자 CTO로서 역할을 수행했습니다. 다양한 스타트업에서 일하며 회사의 성장과 더불어 비즈니스 상황에 맞는 기술 선택, 개발팀 구성 및 문화 정착에 깊은 경험을 쌓았습니다. 개발 관련 고민은 언제든지 편하게 연락주세요 :) https://linktr.ee/dannyseo Profile

Leave a Reply

Your email address will not be published. Required fields are marked *