SOLID 원칙 완벽 가이드: 객체지향 설계의 다섯 가지 핵심 원칙

SOLID 원칙 완벽 가이드: 객체지향 설계의 다섯 가지 핵심 원칙

SOLID는 객체지향 설계의 다섯 가지 핵심 원칙을 나타내는 약어다. 2000년대 초반 Robert C. Martin(Uncle Bob)이 정리하고, Michael Feathers가 이 다섯 원칙의 첫 글자를 따서 SOLID라는 이름을 붙였다.

SOLID 원칙은 “좋은 객체지향 설계란 무엇인가?”라는 질문에 대한 구체적인 답이다. 이 원칙을 따르면 코드의 결합도는 낮아지고 응집도는 높아지며, 변경에 유연하고 확장에 열린 시스템을 만들 수 있다.

디자인 패턴이 “반복되는 문제에 대한 구체적인 해법”이라면, SOLID 원칙은 “그 해법이 왜 올바른지를 설명하는 근본 원리”다. 패턴을 외우는 것보다 원칙을 체득하는 것이 먼저다. 원칙을 이해하면 패턴은 자연스럽게 따라온다.

이 글은 각 원칙이 무엇을 말하는지, 왜 필요한지, 위반하면 어떤 문제가 생기는지, Java와 Spring에서 어떻게 실현되는지, 그리고 Effective Java와 어떻게 연결되는지를 단계적으로 짚는다.


1. SRP — 단일 책임 원칙 (Single Responsibility Principle)

“한 클래스는 하나의 책임만 가져야 한다.”

SRP의 정의는 단순하지만, “책임”의 범위를 정하는 것이 핵심이다. Robert C. Martin은 이후 SRP를 더 정확하게 재정의했다.

“한 클래스는 단 하나의 변경 이유(reason to change)만 가져야 한다.”

그리고 Clean Architecture에서 한 단계 더 나아가 “액터(actor)” 개념을 도입했다.

“한 클래스는 하나의 액터에 대해서만 책임져야 한다.”

여기서 액터는 “변경을 요청하는 사람 또는 집단”이다. 같은 클래스를 변경하도록 요청하는 액터가 둘 이상이면, 그 클래스는 두 가지 이상의 책임을 갖고 있다.

SRP 위반이 만드는 실무 문제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// SRP 위반: 이 클래스를 변경해야 하는 이유가 세 가지다
public class UserService {
    private final JdbcTemplate jdbcTemplate;

    public UserService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    // 책임 1: 사용자 비즈니스 로직 (액터: 기획팀)
    public void registerUser(User user) {
        validateUser(user);
        saveUser(user);
        sendWelcomeEmail(user);
    }

    // 책임 2: 데이터베이스 접근 (액터: DBA / 인프라팀)
    private void saveUser(User user) {
        jdbcTemplate.update(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            user.getName(), user.getEmail()
        );
    }

    // 책임 3: 이메일 발송 (액터: 마케팅팀 / 운영팀)
    private void sendWelcomeEmail(User user) {
        String subject = "환영합니다!";
        String body = user.getName() + "님, 가입을 축하합니다.";
        // SMTP 연결, 이메일 전송 로직...
    }

    private void validateUser(User user) {
        if (user.getEmail() == null || !user.getEmail().contains("@")) {
            throw new IllegalArgumentException("유효하지 않은 이메일입니다.");
        }
    }
}

세 가지 변경 이유가 하나의 클래스에 얽혀 있다.

  1. 기획팀이 회원가입 절차를 변경한다면: 이메일 인증 추가, 약관 동의 확인 등
  2. DBA가 데이터 저장 방식을 변경한다면: JDBC에서 JPA 마이그레이션, 테이블 구조 변경 등
  3. 마케팅팀이 환영 이메일 형식을 변경한다면: 템플릿 교체, 발송 채널 추가 등

이 세 액터가 동시에 같은 클래스를 수정하게 되면 병합 충돌(merge conflict)이 발생하고, 한 액터의 변경이 다른 액터의 기능에 의도치 않은 영향을 줄 수 있다.

SRP 위반의 실제 사고 시나리오

다음은 SRP를 위반한 코드에서 실제로 발생할 수 있는 사고다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 급여 계산 클래스 — SRP 위반
public class Employee {
    private String name;
    private BigDecimal baseSalary;
    private int overtimeHours;

    // 액터 1: CFO (재무팀) — 급여 계산 규칙을 결정한다
    public BigDecimal calculatePay() {
        return baseSalary.add(calculateOvertimePay());
    }

    // 액터 2: COO (운영팀) — 근무 시간 보고 규칙을 결정한다
    public int reportHours() {
        return 8 + overtimeHours;
    }

    // 두 메서드가 공유하는 내부 로직
    private BigDecimal calculateOvertimePay() {
        // 초과근무 급여 = 기본급의 1.5배 × 초과근무 시간
        return baseSalary.divide(BigDecimal.valueOf(160), RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(1.5))
            .multiply(BigDecimal.valueOf(overtimeHours));
    }
}

문제가 발생하는 시나리오를 보자.

  1. COO가 reportHours()의 초과근무 시간 계산 방식을 변경 요청한다.
  2. 개발자가 calculateOvertimePay()를 수정하여 초과근무 시간 산정 기준을 변경한다.
  3. 그런데 calculatePay()도 같은 calculateOvertimePay()를 사용하고 있다.
  4. CFO의 요청 없이 급여 계산이 바뀌어 버린다.

이것이 Robert C. Martin이 “액터”를 강조하는 이유다. 서로 다른 액터의 요구사항이 같은 코드에 결합되면, 한 액터의 변경이 다른 액터에게 예기치 않은 영향을 준다.

SRP 적용: 책임별 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 책임 1: 사용자 비즈니스 로직만 담당
@Service
public class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;
    private final UserValidator userValidator;

    public UserService(UserRepository userRepository,
                       EmailService emailService,
                       UserValidator userValidator) {
        this.userRepository = userRepository;
        this.emailService = emailService;
        this.userValidator = userValidator;
    }

    public void registerUser(User user) {
        userValidator.validate(user);
        userRepository.save(user);
        emailService.sendWelcomeEmail(user);
    }
}

// 책임 2: 데이터 접근만 담당
@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void save(User user) {
        jdbcTemplate.update(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            user.getName(), user.getEmail()
        );
    }

    public Optional<User> findByEmail(String email) {
        return jdbcTemplate.query(
            "SELECT * FROM users WHERE email = ?",
            new UserRowMapper(), email
        ).stream().findFirst();
    }
}

// 책임 3: 이메일 발송만 담당
@Service
public class EmailService {
    private final JavaMailSender mailSender;

    public EmailService(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    public void sendWelcomeEmail(User user) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(user.getEmail());
        message.setSubject("환영합니다!");
        message.setText(user.getName() + "님, 가입을 축하합니다.");
        mailSender.send(message);
    }
}

// 책임 4: 검증 로직만 담당
@Component
public class UserValidator {
    public void validate(User user) {
        if (user.getEmail() == null || !user.getEmail().contains("@")) {
            throw new IllegalArgumentException("유효하지 않은 이메일입니다.");
        }
        if (user.getName() == null || user.getName().isBlank()) {
            throw new IllegalArgumentException("이름은 필수입니다.");
        }
    }
}

이제 각 클래스는 하나의 변경 이유만 갖는다. 데이터 접근 방식을 바꾸려면 UserRepository만, 이메일 발송 방식을 바꾸려면 EmailService만, 검증 규칙을 바꾸려면 UserValidator만 수정하면 된다.

SRP와 Spring의 계층 구조

Spring의 계층 구조(Controller → Service → Repository)는 SRP를 아키텍처 수준에서 실현한 것이다.

계층 책임 변경 이유 변경을 요청하는 액터
@Controller HTTP 요청/응답 처리 API 스펙 변경, 요청/응답 형식 변경 프론트엔드 개발자
@Service 비즈니스 로직 수행 비즈니스 규칙 변경 기획자, 도메인 전문가
@Repository 데이터 접근 데이터 저장소 변경, 쿼리 변경 DBA, 인프라 엔지니어

각 계층은 자신의 책임에만 집중하고, 다른 계층의 변경에 영향을 받지 않는다.

SRP와 응집도(Cohesion)의 관계

SRP는 응집도와 밀접하다. 응집도란 “클래스 내의 요소들이 얼마나 밀접하게 관련되어 있는가”를 나타내는 척도다.

높은 응집도: 클래스의 모든 메서드가 같은 데이터를 다루고, 같은 목적을 향한다.

1
2
3
4
5
6
7
8
// 높은 응집도: 모든 메서드가 balance를 중심으로 동작한다
public class BankAccount {
    private BigDecimal balance;

    public void deposit(BigDecimal amount) { balance = balance.add(amount); }
    public void withdraw(BigDecimal amount) { balance = balance.subtract(amount); }
    public BigDecimal getBalance() { return balance; }
}

낮은 응집도: 클래스의 메서드들이 서로 다른 데이터를 다루고, 서로 관련 없는 기능을 수행한다.

1
2
3
4
5
6
7
// 낮은 응집도: 메서드들이 서로 관련 없는 데이터를 다룬다
public class Utils {
    public static String formatDate(LocalDate date) { ... }
    public static BigDecimal calculateTax(BigDecimal income) { ... }
    public static void sendEmail(String to, String body) { ... }
    public static byte[] compressData(byte[] data) { ... }
}

SRP를 따르면 자연스럽게 응집도가 높은 클래스가 만들어진다.

SRP 적용 기준: “변경의 축”과 현실적 판단

클래스에 SRP를 적용할 때 가장 중요한 질문은 이것이다.

“이 클래스를 변경해야 하는 이유가 몇 가지인가?”

변경 이유가 두 가지 이상이면 분리를 검토한다. 하지만 미래에 있을지도 모르는 변경을 예측해서 미리 분리하는 것은 과잉 설계다. 실제로 변경이 발생한 축을 기준으로 분리하라. 한 번도 따로 변경된 적이 없는 두 가지 역할이라면, 아직 분리할 때가 아닐 수 있다.

현실적인 판단 기준을 정리한다.

  • 분리해야 하는 신호: 하나의 변경이 다른 기능에 영향을 줬다, 같은 클래스를 여러 팀이 동시에 수정한다, 테스트에서 관련 없는 목(mock)을 만들어야 한다
  • 분리가 과잉인 신호: 분리한 클래스가 항상 함께 변경된다, 하나의 클래스가 다른 클래스 없이는 의미가 없다, 분리했더니 단순한 위임(delegation)만 하는 클래스가 생겼다

2. OCP — 개방-폐쇄 원칙 (Open-Closed Principle)

“소프트웨어 요소는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.”

Bertrand Meyer가 1988년 Object-Oriented Software Construction에서 제안하고, Robert C. Martin이 재정립한 이 원칙은 객체지향의 핵심을 관통한다.

Meyer의 OCP vs Martin의 OCP

역사적으로 OCP는 두 가지 버전이 있다.

Meyer의 OCP (1988): 상속을 통한 확장. 기존 클래스를 수정하지 않고, 상속으로 새로운 기능을 추가한다.

1
2
3
4
5
6
7
8
9
10
// Meyer의 OCP — 상속으로 확장
public class Shape {
    public double area() { return 0; }
}

public class Circle extends Shape {
    private double radius;
    @Override
    public double area() { return Math.PI * radius * radius; }
}

Martin의 OCP (1996): 추상화와 다형성을 통한 확장. 인터페이스를 정의하고 새로운 구현체를 추가한다. 현대 OOP에서 OCP라 하면 이 버전을 가리킨다.

1
2
3
4
5
6
7
8
9
10
// Martin의 OCP — 추상화로 확장
public interface Shape {
    double area();
}

public class Circle implements Shape {
    private double radius;
    @Override
    public double area() { return Math.PI * radius * radius; }
}

OCP 위반의 전형적 패턴: if-else 체인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// OCP 위반: 새로운 할인 정책을 추가할 때마다 이 클래스를 수정해야 한다
public class DiscountCalculator {

    public BigDecimal calculateDiscount(Order order, String discountType) {
        if ("PERCENTAGE".equals(discountType)) {
            return order.getTotalAmount()
                .multiply(BigDecimal.valueOf(0.1));
        } else if ("FIXED".equals(discountType)) {
            return BigDecimal.valueOf(5000);
        } else if ("FIRST_ORDER".equals(discountType)) {
            if (order.isFirstOrder()) {
                return order.getTotalAmount()
                    .multiply(BigDecimal.valueOf(0.2));
            }
            return BigDecimal.ZERO;
        } else if ("VIP".equals(discountType)) {
            if (order.getUser().isVip()) {
                return order.getTotalAmount()
                    .multiply(BigDecimal.valueOf(0.15));
            }
            return BigDecimal.ZERO;
        }
        // 시즌 할인 추가? → else if 추가
        // 쿠폰 할인 추가? → else if 추가
        return BigDecimal.ZERO;
    }
}

이 코드의 문제는 다음과 같다.

  1. 새로운 할인 정책이 추가될 때마다 DiscountCalculator를 수정해야 한다.
  2. 모든 할인 로직이 하나의 메서드에 모여 있어서 테스트가 어렵다.
  3. 한 할인 정책을 수정하다가 다른 정책에 영향을 줄 수 있다.
  4. if-else 블록이 길어지면 가독성이 급격히 떨어진다.

OCP 적용: Strategy 패턴으로 리팩토링

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 할인 정책 인터페이스 — 확장 지점
public interface DiscountPolicy {
    BigDecimal calculateDiscount(Order order);
    boolean supports(String discountType);
}

// 각 할인 정책은 독립적인 구현체
@Component
public class PercentageDiscount implements DiscountPolicy {
    @Override
    public BigDecimal calculateDiscount(Order order) {
        return order.getTotalAmount().multiply(BigDecimal.valueOf(0.1));
    }

    @Override
    public boolean supports(String discountType) {
        return "PERCENTAGE".equals(discountType);
    }
}

@Component
public class FirstOrderDiscount implements DiscountPolicy {
    @Override
    public BigDecimal calculateDiscount(Order order) {
        if (!order.isFirstOrder()) return BigDecimal.ZERO;
        return order.getTotalAmount().multiply(BigDecimal.valueOf(0.2));
    }

    @Override
    public boolean supports(String discountType) {
        return "FIRST_ORDER".equals(discountType);
    }
}

@Component
public class VipDiscount implements DiscountPolicy {
    @Override
    public BigDecimal calculateDiscount(Order order) {
        if (!order.getUser().isVip()) return BigDecimal.ZERO;
        return order.getTotalAmount().multiply(BigDecimal.valueOf(0.15));
    }

    @Override
    public boolean supports(String discountType) {
        return "VIP".equals(discountType);
    }
}

// DiscountCalculator는 수정 없이 새로운 할인 정책을 지원한다
@Service
public class DiscountCalculator {
    private final List<DiscountPolicy> policies;

    public DiscountCalculator(List<DiscountPolicy> policies) {
        this.policies = policies;
    }

    public BigDecimal calculateDiscount(Order order, String discountType) {
        return policies.stream()
            .filter(policy -> policy.supports(discountType))
            .findFirst()
            .map(policy -> policy.calculateDiscount(order))
            .orElse(BigDecimal.ZERO);
    }
}

시즌 할인을 추가하려면 SeasonDiscount 클래스만 만들면 된다. DiscountCalculator는 한 줄도 수정하지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 새로운 할인 정책 추가 — 기존 코드 수정 없음
@Component
public class SeasonDiscount implements DiscountPolicy {
    @Override
    public BigDecimal calculateDiscount(Order order) {
        if (isHolidaySeason()) {
            return order.getTotalAmount().multiply(BigDecimal.valueOf(0.3));
        }
        return BigDecimal.ZERO;
    }

    @Override
    public boolean supports(String discountType) {
        return "SEASON".equals(discountType);
    }

    private boolean isHolidaySeason() {
        int month = LocalDate.now().getMonthValue();
        return month == 12 || month == 1;
    }
}

OCP의 또 다른 구현: Template Method 패턴

Strategy 패턴이 “행위 전체를 교체”하는 방식이라면, Template Method 패턴은 “알고리즘의 골격은 고정하고 특정 단계만 확장 가능하게 열어두는” 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Template Method — 골격은 닫혀 있고(final), 특정 단계만 열려 있다(abstract)
public abstract class DataExportTemplate {

    // 템플릿 메서드 — 수정에 닫혀 있다
    public final void export(String query) {
        List<Map<String, Object>> data = fetchData(query);
        List<Map<String, Object>> processed = processData(data);
        String formatted = formatData(processed);
        writeOutput(formatted);
        logCompletion(processed.size());
    }

    // 공통 단계 — 모든 내보내기에서 동일
    protected List<Map<String, Object>> fetchData(String query) {
        // DB 조회
        return List.of();
    }

    // 훅 메서드 — 선택적 확장 지점
    protected List<Map<String, Object>> processData(
            List<Map<String, Object>> data) {
        return data;
    }

    private void logCompletion(int count) {
        System.out.println("내보내기 완료: " + count + "건");
    }

    // 확장에 열려 있는 단계
    protected abstract String formatData(List<Map<String, Object>> data);
    protected abstract void writeOutput(String formattedData);
}

// CSV 내보내기 — 확장
public class CsvExporter extends DataExportTemplate {
    @Override
    protected String formatData(List<Map<String, Object>> data) {
        // CSV 형식으로 변환
        return data.stream()
            .map(row -> row.values().stream()
                .map(Object::toString)
                .collect(Collectors.joining(",")))
            .collect(Collectors.joining("\n"));
    }

    @Override
    protected void writeOutput(String formattedData) {
        // 파일로 저장
    }
}

// JSON 내보내기 — 확장
public class JsonExporter extends DataExportTemplate {
    @Override
    protected String formatData(List<Map<String, Object>> data) {
        // JSON 형식으로 변환
        return new ObjectMapper().writeValueAsString(data);
    }

    @Override
    protected void writeOutput(String formattedData) {
        // API로 전송
    }
}

Spring의 JdbcTemplate, RestTemplate, TransactionTemplate이 모두 이 패턴을 사용한다. JdbcTemplate은 커넥션 획득, 예외 변환, 리소스 반환이라는 골격을 고정하고, SQL 실행과 결과 매핑만 개발자에게 열어둔다.

Java SPI: JDK 레벨의 OCP

Java의 Service Provider Interface(SPI) 메커니즘은 OCP를 JDK 레벨에서 구현한 것이다.

1
2
3
4
5
6
7
8
9
10
11
// java.sql.Driver — 인터페이스만 정의 (닫혀 있다)
public interface Driver {
    Connection connect(String url, Properties info) throws SQLException;
    boolean acceptsURL(String url) throws SQLException;
}

// MySQL 드라이버 — 구현체를 추가 (열려 있다)
public class com.mysql.cj.jdbc.Driver implements java.sql.Driver { ... }

// PostgreSQL 드라이버 — 구현체를 추가 (열려 있다)
public class org.postgresql.Driver implements java.sql.Driver { ... }

DriverManagerMETA-INF/services/java.sql.Driver 파일에 등록된 드라이버를 자동으로 로드한다. 새로운 데이터베이스 드라이버가 추가되어도 DriverManager의 코드는 수정되지 않는다.

1
2
// META-INF/services/java.sql.Driver
com.mysql.cj.jdbc.Driver

Spring의 @Component + 컴포넌트 스캔도 본질적으로 같은 패턴이다. 새로운 구현체를 만들고 @Component만 붙이면, Spring 컨테이너가 자동으로 발견하고 등록한다.

Spring의 확장 포인트: BeanPostProcessor

Spring Framework 자체가 OCP를 철저히 따르는 구조로 설계되어 있다. BeanPostProcessor는 Spring 컨테이너의 핵심 확장 포인트다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Spring 컨테이너는 수정 없이, BeanPostProcessor를 통해 확장된다
@Component
public class LoggingBeanPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        if (bean.getClass().isAnnotationPresent(Loggable.class)) {
            // 프록시를 생성하여 로깅 기능을 동적으로 추가
            return createLoggingProxy(bean);
        }
        return bean;
    }
}

ApplicationContext의 코드를 수정하지 않고, BeanPostProcessor 구현체를 추가하는 것만으로 Bean 초기화 과정에 개입할 수 있다. @Transactional, @Async, @Cacheable 같은 Spring의 핵심 기능이 모두 이 확장 메커니즘 위에 구현되어 있다.

OCP의 핵심: 추상화 경계 설정과 “첫 번째 총알”

OCP를 실현하는 열쇠는 “어디에 추상화 경계를 놓을 것인가”다.

1
2
3
4
5
6
7
변경 예상 지점을 찾는다
    ↓
인터페이스(추상화)로 경계를 만든다
    ↓
기존 코드는 인터페이스에만 의존한다
    ↓
새로운 요구사항은 새로운 구현체로 대응한다

그러나 모든 곳에 추상화를 적용하는 것은 과잉 설계다. Robert C. Martin은 이를 “첫 번째 총알은 맞아라(Take the first bullet)”라고 표현했다. 첫 번째 변경 요청이 왔을 때는 직접 코드를 수정한다. 같은 축의 두 번째 변경이 왔을 때, 그때 추상화를 도입한다.

예를 들어, 결제 수단이 “신용카드”만 있던 시스템에서 “카카오페이”가 추가될 때, 그때 PaymentStrategy 인터페이스를 도입한다. 처음부터 “미래에 다양한 결제 수단이 추가될 수 있으니까” 인터페이스를 만드는 것은 YAGNI(You Aren’t Gonna Need It) 원칙에 어긋난다.


3. LSP — 리스코프 치환 원칙 (Liskov Substitution Principle)

“상위 타입의 객체를 하위 타입의 객체로 치환해도 프로그램의 정확성이 깨져서는 안 된다.”

Barbara Liskov가 1987년 기조연설에서 처음 제안하고, 1994년 Jeannette Wing과 함께 공식화한 이 원칙은 상속의 올바른 사용 기준을 제시한다.

Liskov의 원래 정의는 더 형식적이다.

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.

단순히 “is-a 관계이면 상속을 써도 된다”가 아니라, “행위적으로 호환되어야 상속을 써도 된다”라는 더 엄격한 기준이다.

LSP 위반의 고전적 사례: 직사각형과 정사각형

수학적으로 정사각형은 직사각형의 특수한 경우다. 그러므로 “정사각형 is-a 직사각형”이라고 생각하기 쉽다. 하지만 가변 객체의 행위 관점에서는 이 상속이 문제를 일으킨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 직사각형
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea() { return width * height; }
}

// LSP 위반: 정사각형이 직사각형을 상속
public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // 정사각형이므로 높이도 변경
    }

    @Override
    public void setHeight(int height) {
        this.width = height;  // 정사각형이므로 너비도 변경
        this.height = height;
    }
}

직사각형을 사용하는 코드가 있다고 하자.

1
2
3
4
5
6
public void resize(Rectangle rectangle) {
    rectangle.setWidth(10);
    rectangle.setHeight(5);
    // 직사각형이라면 넓이가 50이어야 한다
    assert rectangle.getArea() == 50;  // Square가 들어오면 실패! (25)
}

Rectangle 타입으로 받았지만, 실제로 Square가 들어오면 setHeight(5)가 너비까지 바꿔버려서 넓이가 25가 된다. 상위 타입(Rectangle)의 계약(너비와 높이를 독립적으로 설정할 수 있다)을 하위 타입(Square)이 깨뜨렸다.

핵심은 “수학적 is-a”와 “행위적 is-a”는 다르다는 점이다. 불변 객체로 만들면 이 문제가 사라진다. setWidth()setHeight()가 없고 생성 시점에 값이 고정되면, 정사각형은 직사각형과 행위적으로도 호환된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// LSP를 지키는 설계: 불변 객체
public class Rectangle {
    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getArea() { return width * height; }
    public int getWidth() { return width; }
    public int getHeight() { return height; }
}

public class Square extends Rectangle {
    public Square(int side) {
        super(side, side);
    }
}

불변이면 SquareRectangle의 행위를 깨뜨리지 않는다. getArea(), getWidth(), getHeight() 모두 올바른 값을 반환한다. Effective Java 아이템 17(변경 가능성을 최소화하라)이 LSP와 연결되는 지점이다.

계약에 의한 설계 (Design by Contract)

LSP의 형식적 기준은 Bertrand Meyer의 “계약에 의한 설계”로 구체화된다.

사전 조건(Precondition): 메서드를 호출하기 전에 참이어야 하는 조건.

  • 하위 타입은 상위 타입보다 더 강한 사전 조건을 요구해서는 안 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 상위 타입: 양수만 받는다
public class Calculator {
    public double sqrt(double value) {
        if (value < 0) throw new IllegalArgumentException("음수 불가");
        return Math.sqrt(value);
    }
}

// LSP 위반: 사전 조건을 강화했다 (0도 거부)
public class StrictCalculator extends Calculator {
    @Override
    public double sqrt(double value) {
        if (value <= 0) throw new IllegalArgumentException("0 이하 불가");
        return Math.sqrt(value);
    }
}

Calculator를 사용하던 코드가 sqrt(0)을 호출하면 정상 동작한다. 그런데 StrictCalculator로 교체하면 예외가 발생한다. 사전 조건을 강화했기 때문이다.

사후 조건(Postcondition): 메서드 실행 후에 참이어야 하는 조건.

  • 하위 타입은 상위 타입보다 더 약한 사후 조건을 보장해서는 안 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 상위 타입: 정렬된 리스트를 반환한다
public class DataProvider {
    public List<Integer> getSortedData() {
        List<Integer> data = fetchData();
        Collections.sort(data);
        return data;  // 사후 조건: 정렬된 상태
    }
}

// LSP 위반: 사후 조건을 약화했다 (정렬을 보장하지 않음)
public class FastDataProvider extends DataProvider {
    @Override
    public List<Integer> getSortedData() {
        return fetchData();  // 성능을 위해 정렬을 생략 — 계약 위반
    }
}

불변 조건(Invariant): 객체의 수명 전체에 걸쳐 항상 참이어야 하는 조건.

  • 상위 타입에서 성립하는 불변 조건은 하위 타입에서도 유지되어야 한다.

Java 표준 라이브러리의 LSP 위반 사례: Collections.unmodifiableList

1
2
3
4
5
6
List<String> original = new ArrayList<>(List.of("A", "B", "C"));
List<String> unmodifiable = Collections.unmodifiableList(original);

// List 인터페이스는 add()가 가능하다고 선언한다
// 그러나 unmodifiableList는 UnsupportedOperationException을 던진다
unmodifiable.add("D");  // UnsupportedOperationException!

Collections.unmodifiableList()List 인터페이스의 계약(“원소를 추가할 수 있다”)을 위반한다. 이것은 Java 표준 라이브러리에서의 LSP 위반 사례다. Java 9에서 도입된 List.of(), List.copyOf() 같은 불변 팩터리 메서드는 이 문제를 더 명시적으로 표현하지만, 반환 타입이 여전히 List이므로 근본적인 해결은 아니다.

이 문제가 존재하는 이유는 Java의 Collection 인터페이스가 수정 가능한 연산과 읽기 전용 연산을 분리하지 않았기 때문이다. Kotlin의 List(읽기 전용)와 MutableList(수정 가능)가 이 문제를 인터페이스 수준에서 해결한 예다.

실무에서의 LSP 위반 패턴과 해결

1
2
3
4
5
6
7
8
9
10
11
12
13
// LSP 위반 패턴 1: 지원하지 않는 연산에서 예외
public interface Bird {
    void fly();
    void eat();
}

public class Penguin implements Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("펭귄은 날 수 없습니다!");
    }
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 해결: 인터페이스 분리 (ISP와의 협력)
public interface Bird {
    void eat();
}

public interface FlyingBird extends Bird {
    void fly();
}

public class Sparrow implements FlyingBird {
    @Override
    public void fly() { System.out.println("참새가 날아갑니다."); }
    @Override
    public void eat() { System.out.println("참새가 먹이를 먹습니다."); }
}

public class Penguin implements Bird {
    @Override
    public void eat() { System.out.println("펭귄이 물고기를 먹습니다."); }
}
1
2
3
4
5
6
7
8
9
// LSP 위반 패턴 2: instanceof 검사 — 다형성을 포기한 코드
public BigDecimal calculateShippingCost(Order order) {
    if (order instanceof DomesticOrder) {
        return calculateDomesticShipping((DomesticOrder) order);
    } else if (order instanceof InternationalOrder) {
        return calculateInternationalShipping((InternationalOrder) order);
    }
    throw new IllegalArgumentException("알 수 없는 주문 타입");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 해결: 다형성으로 행위를 각 타입에 위임
public abstract class Order {
    public abstract BigDecimal calculateShippingCost();
}

public class DomesticOrder extends Order {
    @Override
    public BigDecimal calculateShippingCost() {
        return BigDecimal.valueOf(3000);
    }
}

public class InternationalOrder extends Order {
    @Override
    public BigDecimal calculateShippingCost() {
        return BigDecimal.valueOf(15000);
    }
}

// 호출부: instanceof 없이 동작
public BigDecimal calculateShippingCost(Order order) {
    return order.calculateShippingCost();
}

LSP 위반을 감지하는 신호 정리

신호 설명 LSP 위반 이유
instanceof 검사 하위 타입에 따라 분기한다 상위 타입의 인터페이스로 동작하지 못한다
UnsupportedOperationException 상위 타입의 연산을 거부한다 계약을 위반한다
빈 메서드 구현 구현하긴 했지만 아무것도 하지 않는다 사후 조건을 약화한다
사전 조건 강화 상위 타입보다 더 엄격한 입력을 요구한다 기존 호출부가 깨질 수 있다
반환 타입 제약 변경 더 좁은 범위의 값을 반환한다 기존 호출부의 기대를 깨뜨린다

LSP와 Java의 공변 반환 타입

Java 5부터 지원하는 공변 반환 타입(covariant return type)은 LSP를 지키면서 상속의 유연성을 높이는 기능이다.

1
2
3
4
5
6
7
8
9
10
11
12
public class Animal {
    public Animal reproduce() {
        return new Animal();
    }
}

public class Dog extends Animal {
    @Override
    public Dog reproduce() {  // 반환 타입이 Animal이 아닌 Dog — 공변 반환
        return new Dog();
    }
}

Dog.reproduce()Dog를 반환해도 LSP를 위반하지 않는다. DogAnimal의 하위 타입이므로, Animal을 기대하는 코드에 Dog가 들어가도 문제없다. 사후 조건이 강화된 것이지 약화된 것이 아니다.

Effective Java 아이템 40(“@Override 어노테이션을 일관되게 사용하라”)은 이런 재정의가 올바르게 이루어졌는지 컴파일러가 검증하게 만드는 실용적 조언이다.


4. ISP — 인터페이스 분리 원칙 (Interface Segregation Principle)

“클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강제되어서는 안 된다.”

ISP는 “뚱뚱한 인터페이스(fat interface)”를 경계한다. 하나의 거대한 인터페이스보다, 역할에 맞게 분리된 작은 인터페이스 여러 개가 낫다.

ISP의 발생 배경

ISP는 Robert C. Martin이 Xerox 프로젝트에서 겪은 실제 문제에서 탄생했다. Xerox의 프린터 시스템에는 하나의 거대한 Job 인터페이스가 있었고, 이 인터페이스에 스테이플(제본), 팩스, 인쇄 등 모든 기능이 포함되어 있었다. 스테이플 기능만 수정해도 팩스와 관련된 코드가 재컴파일되어야 했고, 이것이 배포 전체를 느리게 만들었다.

ISP 위반이 만드는 구체적 문제

1
2
3
4
5
6
7
8
// ISP 위반: 모든 기기가 이 인터페이스를 구현해야 한다
public interface MultiFunctionDevice {
    void print(Document document);
    void scan(Document document);
    void fax(Document document);
    void copy(Document document);
    void staple(Document document);
}

문제 1: 불필요한 구현 강제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SimplePrinter implements MultiFunctionDevice {
    @Override
    public void print(Document document) { /* 실제 구현 */ }

    @Override
    public void scan(Document document) {
        throw new UnsupportedOperationException("스캔 기능 없음");
    }

    @Override
    public void fax(Document document) {
        throw new UnsupportedOperationException("팩스 기능 없음");
    }

    @Override
    public void copy(Document document) {
        throw new UnsupportedOperationException("복사 기능 없음");
    }

    @Override
    public void staple(Document document) {
        throw new UnsupportedOperationException("제본 기능 없음");
    }
}

문제 2: 불필요한 재컴파일과 재배포

staple() 메서드의 시그니처가 변경되면, SimplePrinter는 스테이플 기능을 사용하지 않음에도 불구하고 재컴파일되어야 한다. 대규모 시스템에서 이 파급 효과는 빌드 시간과 배포 범위에 직접 영향을 미친다.

문제 3: 클라이언트 코드의 혼란

1
2
3
4
5
6
7
8
9
10
11
12
13
public class PrintService {
    private final MultiFunctionDevice device;

    public PrintService(MultiFunctionDevice device) {
        this.device = device;
    }

    public void printDocument(Document doc) {
        device.print(doc);
        // device.fax(), device.scan(), device.staple() 등
        // 사용하지 않는 메서드가 IDE의 자동완성에 모두 노출된다
    }
}

PrintServiceprint()만 필요한데, 5개의 메서드가 전부 보인다. API가 불필요하게 넓어서 실수의 여지가 커진다.

ISP 적용: 역할별 인터페이스 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public interface Printer {
    void print(Document document);
}

public interface Scanner {
    void scan(Document document);
}

public interface Fax {
    void fax(Document document);
}

// 복합기: 필요한 인터페이스를 모두 구현
public class AllInOnePrinter implements Printer, Scanner, Fax {
    @Override
    public void print(Document document) { /* 구현 */ }
    @Override
    public void scan(Document document) { /* 구현 */ }
    @Override
    public void fax(Document document) { /* 구현 */ }
}

// 단순 프린터: Printer만 구현
public class SimplePrinter implements Printer {
    @Override
    public void print(Document document) { /* 구현 */ }
}

// 클라이언트는 필요한 인터페이스에만 의존한다
public class PrintService {
    private final Printer printer;

    public PrintService(Printer printer) {
        this.printer = printer;  // SimplePrinter든 AllInOnePrinter든 상관없다
    }

    public void printDocument(Document document) {
        printer.print(document);
        // print()만 보인다. 깔끔하다.
    }
}

Java 8 default 메서드와 ISP의 긴장

Java 8에서 도입된 default 메서드는 인터페이스에 기본 구현을 추가할 수 있게 했다. 이것이 ISP와 어떻게 상호작용하는지 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Java 8 이전: ISP를 따르기 위해 인터페이스를 분리하면
// 구현체마다 여러 인터페이스를 구현해야 했다

// Java 8: default 메서드로 선택적 기능을 표현할 수 있다
public interface EventListener {
    void onEvent(Event event);

    // default 메서드: 구현이 선택적이다
    default void onError(Event event, Throwable error) {
        // 기본 동작: 무시
    }

    default void onComplete() {
        // 기본 동작: 아무것도 하지 않음
    }
}

default 메서드는 ISP를 위반하는 것이 아니라, 인터페이스를 분리하지 않고도 선택적 기능을 표현하는 방법이다. 그러나 default 메서드가 많아지면 인터페이스가 “뚱뚱해지는” 문제가 다시 발생할 수 있으므로 주의가 필요하다.

함수형 인터페이스: ISP의 극단적 적용

Java 8의 함수형 인터페이스(@FunctionalInterface)는 ISP를 극단까지 밀어붙인 결과다. 인터페이스에 추상 메서드가 단 하나만 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

각 인터페이스가 하나의 행위만 표현한다. 클라이언트는 정확히 자신이 필요한 행위에만 의존한다. 함수형 인터페이스는 ISP의 이상적인 형태이며, 동시에 OCP(람다로 새로운 행위를 확장)와 DIP(추상화에 의존)도 자연스럽게 만족한다.

Spring Data Repository: ISP의 교과서적 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 마커 인터페이스 — 메서드 없음
public interface Repository<T, ID> { }

// CRUD만 필요하면 이 인터페이스만 사용
public interface CrudRepository<T, ID> extends Repository<T, ID> {
    <S extends T> S save(S entity);
    Optional<T> findById(ID id);
    Iterable<T> findAll();
    void deleteById(ID id);
    long count();
    boolean existsById(ID id);
}

// 페이징이 필요하면 이 인터페이스 사용
public interface PagingAndSortingRepository<T, ID>
        extends CrudRepository<T, ID> {
    Iterable<T> findAll(Sort sort);
    Page<T> findAll(Pageable pageable);
}

// JPA 전용 기능이 필요하면 이 인터페이스 사용
public interface JpaRepository<T, ID>
        extends PagingAndSortingRepository<T, ID> {
    void flush();
    <S extends T> S saveAndFlush(S entity);
    void deleteAllInBatch();
}

실무에서 대부분의 Repository는 JpaRepository를 상속하지만, 단순 CRUD만 필요한 경우 CrudRepository만 상속해도 충분하다. 필요한 기능 수준에 맞는 인터페이스를 선택할 수 있다.

ISP와 SRP의 관계

ISP와 SRP는 같은 방향을 가리키지만 적용 대상이 다르다.

  • SRP: 클래스(구현체)에 적용한다. “이 클래스는 하나의 책임만 가져야 한다.”
  • ISP: 인터페이스(추상화)에 적용한다. “이 인터페이스는 하나의 역할만 정의해야 한다.”

SRP가 “구현의 응집도”를 높인다면, ISP는 “계약의 정밀도”를 높인다. 하나가 지켜지면 다른 하나도 자연스럽게 따라오는 경우가 많다.


5. DIP — 의존 역전 원칙 (Dependency Inversion Principle)

“고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.” “추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.”

DIP는 의존성의 방향을 뒤집는다. 전통적인 절차적 프로그래밍에서는 고수준 모듈이 저수준 모듈에 직접 의존한다. DIP는 이 방향을 역전시켜, 양쪽 모두 추상화에 의존하게 만든다.

고수준 모듈과 저수준 모듈: 정확한 구분

“고수준”과 “저수준”은 기술적 복잡성이 아니라, 비즈니스 의미의 추상화 수준으로 구분한다.

고수준 모듈: 비즈니스 로직을 포함하는, 의미 있는 단위의 기능을 수행하는 모듈.

  • “주문을 처리한다”, “결제를 수행한다” — 비즈니스 의미가 있다.
  • 기술이 바뀌어도 비즈니스 규칙은 유지되어야 한다.

저수준 모듈: 고수준 모듈이 필요로 하는 기술적 세부 사항을 구현하는 모듈.

  • “MySQL에 저장한다”, “SMTP로 이메일을 보낸다” — 기술 선택이다.
  • 비즈니스 규칙과 무관하게 교체될 수 있다.

핵심은 “비즈니스 규칙이 기술 선택에 종속되어서는 안 된다”는 것이다.

DIP 위반: 비즈니스 로직이 기술에 종속된 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// DIP 위반: 고수준 모듈이 저수준 모듈의 구체 클래스에 직접 의존
public class OrderService {
    private final MySQLOrderRepository repository;
    private final SmtpEmailSender emailSender;
    private final StripePaymentGateway paymentGateway;

    public OrderService() {
        this.repository = new MySQLOrderRepository();
        this.emailSender = new SmtpEmailSender();
        this.paymentGateway = new StripePaymentGateway();
    }

    public void placeOrder(Order order) {
        paymentGateway.charge(order.getTotalAmount(), order.getPaymentInfo());
        repository.save(order);
        emailSender.send(
            order.getUserEmail(), "주문 확인", "주문이 완료되었습니다."
        );
    }
}

이 코드의 문제를 구체적으로 살펴보자.

문제 1: 기술 교체가 비즈니스 로직에 파급된다. MySQL을 PostgreSQL로 바꾸면 OrderService를 수정해야 한다. Stripe를 토스페이먼츠로 바꿔도 OrderService를 수정해야 한다. 비즈니스 규칙(“주문을 결제하고 저장하고 알림을 보낸다”)은 변하지 않았는데 코드를 바꿔야 한다.

문제 2: 테스트가 불가능하다. 단위 테스트를 작성하려면 실제 MySQL, 실제 SMTP 서버, 실제 Stripe API가 필요하다. 목(mock) 객체를 주입할 방법이 없다.

문제 3: 재사용이 불가능하다. 다른 프로젝트에서 같은 주문 로직을 쓰되 PostgreSQL + SendGrid + 토스페이먼츠 조합으로 쓰고 싶어도, OrderService를 재사용할 수 없다.

DIP 적용: 추상화 경계 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// 추상화 — 고수준 모듈이 필요로 하는 계약을 정의한다
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(String orderId);
    List<Order> findByUserId(String userId);
}

public interface NotificationSender {
    void send(String to, String subject, String body);
}

public interface PaymentGateway {
    PaymentResult charge(BigDecimal amount, PaymentInfo paymentInfo);
    PaymentResult refund(String transactionId, BigDecimal amount);
}

// 고수준 모듈: 추상화에만 의존한다
@Service
public class OrderService {
    private final OrderRepository repository;
    private final NotificationSender notificationSender;
    private final PaymentGateway paymentGateway;

    public OrderService(OrderRepository repository,
                         NotificationSender notificationSender,
                         PaymentGateway paymentGateway) {
        this.repository = repository;
        this.notificationSender = notificationSender;
        this.paymentGateway = paymentGateway;
    }

    public void placeOrder(Order order) {
        PaymentResult result = paymentGateway.charge(
            order.getTotalAmount(), order.getPaymentInfo());

        if (!result.isSuccess()) {
            throw new PaymentFailedException(result.getErrorMessage());
        }

        order.markAsPaid(result.getTransactionId());
        repository.save(order);

        notificationSender.send(
            order.getUserEmail(), "주문 확인", "주문이 완료되었습니다."
        );
    }
}

// 저수준 모듈: 추상화를 구현한다
@Repository
public class JpaOrderRepository implements OrderRepository {
    private final JpaOrderEntityRepository jpaRepository;

    @Override
    public void save(Order order) {
        jpaRepository.save(OrderEntity.from(order));
    }

    @Override
    public Optional<Order> findById(String orderId) {
        return jpaRepository.findById(orderId).map(OrderEntity::toDomain);
    }

    @Override
    public List<Order> findByUserId(String userId) {
        return jpaRepository.findByUserId(userId).stream()
            .map(OrderEntity::toDomain)
            .toList();
    }
}

@Component
public class SendGridNotificationSender implements NotificationSender {
    @Override
    public void send(String to, String subject, String body) {
        // SendGrid API 호출
    }
}

@Component
public class TossPaymentGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(BigDecimal amount, PaymentInfo info) {
        // 토스페이먼츠 API 호출
    }

    @Override
    public PaymentResult refund(String transactionId, BigDecimal amount) {
        // 토스페이먼츠 환불 API 호출
    }
}

DIP와 Hexagonal Architecture (육각형 아키텍처)

DIP를 아키텍처 수준에서 적용하면 Hexagonal Architecture(또는 Ports and Adapters 아키텍처)가 된다. Alistair Cockburn이 제안한 이 아키텍처는 애플리케이션의 핵심 로직(도메인)을 기술적 세부 사항(인프라)으로부터 완전히 분리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────┐
│           Infrastructure Layer           │
│  ┌─────────┐ ┌─────────┐ ┌───────────┐ │
│  │  MySQL   │ │ SendGrid│ │ Toss Pay  │ │
│  │ Adapter  │ │ Adapter │ │  Adapter  │ │
│  └────┬─────┘ └────┬────┘ └─────┬─────┘ │
│       │implements   │implements  │impl.  │
│  ┌────▼─────────────▼────────────▼─────┐ │
│  │         Port (Interface)            │ │
│  │  OrderRepository                    │ │
│  │  NotificationSender                 │ │
│  │  PaymentGateway                     │ │
│  └────────────────┬────────────────────┘ │
│                   │depends on            │
│  ┌────────────────▼────────────────────┐ │
│  │         Domain Layer                │ │
│  │  OrderService (비즈니스 로직)         │ │
│  │  Order (도메인 모델)                  │ │
│  └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘

도메인 레이어(고수준)가 중심에 있고, 인프라 레이어(저수준)가 바깥에 있다. 의존성의 방향은 항상 바깥에서 안쪽으로 향한다. Port(인터페이스)는 도메인이 정의하고, Adapter(구현체)는 인프라가 구현한다.

DIP, DI, IoC: 세 개념의 정확한 구분

이 세 가지는 자주 혼동되지만, 서로 다른 레벨의 개념이다.

개념 레벨 핵심 질문
DIP 설계 원칙 “무엇에 의존할 것인가?” 추상화(인터페이스)에 의존하라
DI 구현 기법 “의존성을 어떻게 제공할 것인가?” 외부에서 주입하라 (생성자, 세터 등)
IoC 아키텍처 패턴 “누가 제어권을 갖는가?” 프레임워크(컨테이너)가 갖는다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// DIP: "추상화에 의존하라" — 이것은 설계 결정이다
public class OrderService {
    private final OrderRepository repository;  // 구체 클래스가 아닌 인터페이스

    // DI: "외부에서 주입하라" — 이것은 의존성 제공 방식이다
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

// IoC: "컨테이너가 제어한다" — 이것은 프레임워크의 역할이다
@Configuration
public class AppConfig {
    @Bean
    public OrderRepository orderRepository(DataSource dataSource) {
        return new JpaOrderRepository(dataSource);
    }

    @Bean
    public OrderService orderService(OrderRepository repository) {
        return new OrderService(repository);  // 컨테이너가 조립한다
    }
}

DIP 없이 DI만 있으면 의미가 없다. 구체 클래스를 주입해봤자 결합도는 그대로다. DI 없이 DIP만 있으면 수동 조립의 복잡함이 남는다. IoC 없이 DI만 있으면 개발자가 객체 그래프를 직접 조립해야 한다. 세 가지가 결합될 때 비로소 “유연하고, 테스트 가능하며, 자동으로 조립되는” 시스템이 만들어진다.

Effective Java와 DIP의 연결

Effective Java 아이템 64(“객체는 인터페이스를 사용해 참조하라”)는 DIP의 실용적 버전이다.

1
2
3
4
5
6
7
8
9
// 좋은 예: 인터페이스 타입으로 참조
Set<String> names = new LinkedHashSet<>();
Map<String, Order> orders = new HashMap<>();
List<User> users = new ArrayList<>();

// 나쁜 예: 구체 클래스 타입으로 참조
LinkedHashSet<String> names = new LinkedHashSet<>();
HashMap<String, Order> orders = new HashMap<>();
ArrayList<User> users = new ArrayList<>();

구체 클래스 대신 인터페이스로 참조하면, LinkedHashSetTreeSet으로, HashMapConcurrentHashMap으로 교체할 때 참조하는 코드를 수정할 필요가 없다.


6. SOLID 원칙의 유기적 관계

다섯 가지 원칙은 독립적이지 않다. 서로 보완하고 강화한다.

원칙 간 연결 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
SRP ─── 클래스의 책임을 분리한다
 │
 ├── OCP가 강화된다: 책임이 분리된 클래스는 변경 지점이 명확해서
 │                  추상화 경계를 설정하기 쉽다
 │
 ├── ISP로 이어진다: 클래스의 책임이 분리되면
 │                  인터페이스도 자연스럽게 역할별로 분리된다
 │
 └── DIP와 결합한다: 분리된 책임은 인터페이스로 추상화되어
                    고수준 모듈에서 주입받기에 적합해진다

OCP ─── 확장에 열려 있고 수정에 닫혀 있다
 │
 ├── LSP가 전제된다: 확장(새로운 구현체 추가)이 올바르게 동작하려면
 │                  하위 타입이 상위 타입의 계약을 지켜야 한다
 │
 └── DIP가 필수적이다: 수정에 닫혀 있으려면
                     구체 클래스가 아닌 추상화에 의존해야 한다

LSP ─── 하위 타입은 상위 타입을 대체할 수 있다
 │
 └── ISP가 도움이 된다: 인터페이스가 충분히 작으면
                      하위 타입이 계약을 위반할 가능성이 줄어든다

실무 리팩토링 사례: if-else 코드를 SOLID로 개선하기

SOLID 원칙이 어떻게 협력하는지를 리팩토링 과정으로 보여준다.

Before: 모든 원칙을 위반하는 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class NotificationService {
    public void sendNotification(String channel, String userId, String message) {
        User user = new UserDao().findById(userId);  // DIP 위반: 구체 클래스

        if ("EMAIL".equals(channel)) {
            // SRP 위반: 이메일 발송 로직이 여기에
            SmtpClient smtp = new SmtpClient("smtp.example.com");
            smtp.send(user.getEmail(), "알림", message);

        } else if ("SMS".equals(channel)) {
            // OCP 위반: 새 채널 추가 시 수정 필요
            SmsApi smsApi = new SmsApi("api-key-123");
            smsApi.send(user.getPhone(), message);

        } else if ("PUSH".equals(channel)) {
            FcmClient fcm = new FcmClient();
            fcm.send(user.getDeviceToken(), message);
        }
        // Slack 추가? → else if 추가
    }
}

Step 1: ISP + DIP — 인터페이스 추출

1
2
3
4
5
// ISP: 채널별 인터페이스 정의
public interface MessageChannel {
    void send(User user, String message);
    boolean supports(String channel);
}

Step 2: OCP — 각 채널을 독립 구현체로 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Component
public class EmailChannel implements MessageChannel {
    private final SmtpClient smtpClient;

    public EmailChannel(SmtpClient smtpClient) {
        this.smtpClient = smtpClient;
    }

    @Override
    public void send(User user, String message) {
        smtpClient.send(user.getEmail(), "알림", message);
    }

    @Override
    public boolean supports(String channel) {
        return "EMAIL".equals(channel);
    }
}

@Component
public class SmsChannel implements MessageChannel {
    private final SmsApi smsApi;

    public SmsChannel(SmsApi smsApi) {
        this.smsApi = smsApi;
    }

    @Override
    public void send(User user, String message) {
        smsApi.send(user.getPhone(), message);
    }

    @Override
    public boolean supports(String channel) {
        return "SMS".equals(channel);
    }
}

Step 3: SRP + DIP — NotificationService를 비즈니스 흐름만 담당하도록 정리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
public class NotificationService {
    private final UserRepository userRepository;  // DIP: 인터페이스에 의존
    private final List<MessageChannel> channels;  // DIP: 인터페이스 목록에 의존

    public NotificationService(UserRepository userRepository,
                                List<MessageChannel> channels) {
        this.userRepository = userRepository;
        this.channels = channels;
    }

    public void sendNotification(String channel, String userId, String message) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));

        channels.stream()
            .filter(ch -> ch.supports(channel))
            .findFirst()
            .orElseThrow(() -> new UnsupportedChannelException(channel))
            .send(user, message);
    }

    // 모든 채널로 동시 발송
    public void broadcast(String userId, String message) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));
        channels.forEach(ch -> ch.send(user, message));
    }
}

After: 다섯 원칙이 모두 적용된 코드

  • SRP: NotificationService는 “누구에게 어떤 채널로 보낼지 결정”만 한다. 실제 발송은 각 MessageChannel이 담당한다.
  • OCP: Slack 채널을 추가하려면 SlackChannel 클래스만 만들면 된다.
  • LSP: 모든 MessageChannel 구현체가 send() 계약을 충실히 이행한다.
  • ISP: MessageChannelsend()supports()만 정의한다.
  • DIP: NotificationServiceMessageChannel 인터페이스에만 의존한다.

7. SOLID 원칙 위반의 실무 신호

코드 냄새(Code Smell)와 SOLID 매핑

코드 냄새 위반 가능한 원칙 대응 방법
하나의 클래스가 500줄 이상 SRP 책임 단위로 클래스 분리
새 기능 추가 시 기존 클래스의 if-else가 늘어남 OCP 인터페이스 + 다형성으로 전환
instanceof 검사가 빈번함 LSP 상속 구조 재설계 또는 인터페이스 분리
UnsupportedOperationException 던지는 메서드 ISP + LSP 인터페이스를 더 작은 단위로 분리
구체 클래스를 직접 new로 생성 DIP 인터페이스 도입 + 생성자 주입
테스트에서 목(mock)을 만들 수 없다 DIP + SRP 인터페이스 추출 + 의존성 주입
하나의 변경이 여러 파일에 파급 SRP + OCP 변경 축을 기준으로 책임 재분배
인터페이스에 메서드가 10개 이상 ISP 역할별 인터페이스 분리
생성자 매개변수가 7개 이상 SRP 클래스가 너무 많은 의존성을 갖고 있다
순환 의존 발생 SRP + DIP 책임 경계 재설계, 이벤트로 결합 끊기

리팩토링 우선순위

SOLID 위반을 발견했을 때, 모든 것을 한 번에 고칠 필요는 없다.

  1. 테스트를 막는 위반부터 해결한다: DIP 위반으로 테스트를 작성할 수 없다면, 인터페이스 추출부터 한다.
  2. 변경이 빈번한 부분의 위반을 해결한다: 자주 바뀌는 코드에 OCP가 적용되지 않으면, 매번 수정 비용이 발생한다.
  3. 버그가 발생하는 부분의 위반을 해결한다: LSP 위반은 런타임 오류로 이어지므로 조기에 수정해야 한다.
  4. 복잡성이 높은 부분의 위반을 해결한다: SRP 위반으로 클래스가 비대해졌다면, 가독성과 유지보수성 문제가 누적된다.

8. SOLID와 Effective Java의 연결

SOLID 원칙 관련 Effective Java 아이템
SRP 아이템 15: 클래스와 멤버의 접근 권한을 최소화하라
OCP 아이템 20: 추상 클래스보다는 인터페이스를 우선하라
LSP 아이템 18: 상속보다는 컴포지션을 사용하라
LSP 아이템 40: @Override 어노테이션을 일관되게 사용하라
ISP 아이템 21: 인터페이스는 구현하는 쪽을 생각해 설계하라
DIP 아이템 5: 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
DIP 아이템 64: 객체는 인터페이스를 사용해 참조하라

Effective Java를 읽다 보면, 각 아이템이 결국 SOLID 원칙 중 하나를 구체적인 Java 코드 수준에서 설명하고 있음을 알 수 있다. 원칙을 이해하면 아이템의 존재 이유를 깊이 이해할 수 있고, 아이템을 학습하면 원칙을 자연스럽게 체득할 수 있다.


9. 실무 체크리스트

SRP 체크리스트

1
2
3
4
5
6
✅ 이 클래스를 변경해야 하는 이유가 하나인가?
✅ 이 클래스를 변경하도록 요청하는 액터가 하나인가?
✅ 클래스 이름이 하나의 명확한 역할을 표현하는가?
✅ 클래스의 모든 메서드가 동일한 추상화 수준에서 작동하는가?
✅ 이 클래스의 테스트를 작성할 때, 관련 없는 목(mock)을 만들 필요가 없는가?
✅ 생성자 매개변수가 4개 이하인가?

OCP 체크리스트

1
2
3
4
5
✅ 새로운 요구사항(기능 추가)이 기존 코드 수정 없이 처리되는가?
✅ 변경 가능성이 높은 부분이 인터페이스로 추상화되어 있는가?
✅ if-else나 switch가 타입에 따라 분기하고 있지 않은가?
✅ 새로운 구현체를 추가할 때 기존 코드에 영향을 주지 않는가?
✅ Spring의 @Component 스캔으로 자동 등록이 가능한 구조인가?

LSP 체크리스트

1
2
3
4
5
✅ 상위 타입 참조로 하위 타입을 사용해도 동작이 올바른가?
✅ UnsupportedOperationException을 던지는 메서드가 없는가?
✅ instanceof 검사 없이 상위 타입의 인터페이스만으로 동작하는가?
✅ 하위 타입이 상위 타입의 사전/사후 조건을 위반하지 않는가?
✅ @Override 어노테이션을 일관되게 사용하고 있는가?

ISP 체크리스트

1
2
3
4
5
✅ 인터페이스를 구현할 때 빈 메서드나 예외를 던지는 메서드가 없는가?
✅ 클라이언트가 인터페이스의 모든 메서드를 실제로 사용하는가?
✅ 인터페이스의 메서드 수가 적절한가? (일반적으로 3~5개 이하)
✅ 서로 다른 클라이언트가 같은 인터페이스의 다른 부분만 사용하고 있지 않은가?
✅ 함수형 인터페이스로 분리할 수 있는 단일 행위가 섞여 있지 않은가?

DIP 체크리스트

1
2
3
4
5
✅ 고수준 모듈이 저수준 모듈의 구체 클래스에 직접 의존하지 않는가?
✅ 의존 방향이 추상화(인터페이스)를 향하고 있는가?
✅ new를 통한 구체 클래스 생성이 비즈니스 로직 안에 있지 않은가?
✅ 저수준 모듈을 교체해도 고수준 모듈의 코드를 수정하지 않아도 되는가?
✅ 인터페이스가 고수준 모듈(도메인)의 관점에서 정의되어 있는가?

결론

SOLID 원칙은 객체지향 설계를 위한 다섯 가지 나침반이다.

SRP(단일 책임 원칙): 클래스를 변경해야 하는 이유를 하나로 제한한다. 같은 클래스를 서로 다른 액터가 변경하도록 만들지 마라. 변경의 파급 효과를 최소화한다.

OCP(개방-폐쇄 원칙): 기존 코드를 수정하지 않고도 기능을 확장할 수 있게 설계한다. 추상화와 다형성이 핵심이다. 단, 첫 번째 총알은 맞아라.

LSP(리스코프 치환 원칙): 하위 타입이 상위 타입의 행위 계약을 지키도록 강제한다. 다형성의 신뢰성을 보장한다. 사전 조건을 강화하지 말고, 사후 조건을 약화하지 마라.

ISP(인터페이스 분리 원칙): 인터페이스를 역할 단위로 작게 분리한다. 클라이언트가 불필요한 의존을 갖지 않도록 한다. 함수형 인터페이스는 ISP의 이상적 형태다.

DIP(의존 역전 원칙): 고수준 모듈과 저수준 모듈이 모두 추상화에 의존하게 만든다. 비즈니스 로직이 기술 선택에 종속되지 않도록 한다.

이 다섯 가지 원칙이 추구하는 공통 목표는 하나다. 변경에 유연하고, 확장에 열려 있으며, 테스트하기 쉬운 코드를 만드는 것이다.

그러나 원칙을 교조적으로 따르는 것은 금물이다. 모든 클래스에 인터페이스를 만들고, 모든 의존성을 추상화하면 코드의 복잡성만 늘어난다. SOLID는 “항상 적용해야 하는 규칙”이 아니라 “설계를 평가하고 개선하기 위한 기준”이다.

Robert C. Martin의 말을 빌리자면, “원칙은 방향이다. 목적지가 아니다.” 코드에서 문제가 발생했을 때, 그 문제가 어떤 원칙의 위반에서 비롯되었는지 진단할 수 있게 되면, 비로소 SOLID를 체득했다고 할 수 있다.


다음 글에서는 객체지향 프로그래밍(OOP)의 네 가지 핵심 개념 을 정리한다.