티스토리 뷰

CS/공통

객체지향설계의 5대 원칙

bool-flower 2022. 2. 22. 02:20

객체지향 설계의 5대 원칙

객체지향에는 흔히 SOLID라고 불리는 5가지 원칙이 있다. 이 원칙들을 지키면서 개발한다면 시간이 지나도 변경이 용이하고, 유지보수와 확장이 쉬운 소프트웨어를 개발하는데 도움이 된다.

단일 책임 원칙(Single Responsibility Principle, SRP)


하나의 클래스는 하나의 책임만 가져야 한다. 또한 클래스는 하나의 이유만으로 변경되어야 한다.

'책임'에 대해 명확한 이해가 되지 않는다면, 일단은 '기능' 정도로만 이해하고 코드를 보면서 개념을 잡아보자.

어떠한 역할에 대해 변경사항이 발생했을 때, 영향을 받는 기능만 모아둔 클래스라면 동일한 책임을 지닌 기능만 모여 있다고 볼 수 있다. 만약 어떤 클래스가 여러 책임을 지닌 기능을 가지고 있다면, 여러 기능으로부터 변경이 필요할 수 있으므로 해당 클래스를 변경해야 하는 이유도 여러 개가 된다.

따라서, 클래스가 단 하나의 책임을 가지고 있으면, 해당 기능의 변경을 특정할 수 있고 해당 클래스를 변경해야 하는 이유와 시점도 명확해진다. 이 원칙을 지키며 개발하면 변경사항이 있을 때, 애플리케이션의 파급효과가 적어지고 그러면 SRP를 잘 따른 것으로 볼 수 있다.

다음 코드를 보자.

// 출처 : https://mangkyu.tistory.com/194

@Service 
@RequiredArgsConstructor 
public class UserService { 
	
    private final UserRepository userRepository;
    
    // 유저 추가
    public void addUser(final String email, final String pw) { 
    	
        final StringBuilder sb = new StringBuilder();
        
        // 비밀번호 암호화.
        for(byte b : pw.getBytes(StandardCharsets.UTF_8)) { 
        	sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1)); 
        } 
        
        final String encryptedPassword = sb.toString();
        
        // 유저 객체 생성.
        final User user = User.builder()
        		.email(email)
                        .pw(encryptedPassword)
                        .build();
        
        // 저장
        userRepository.save(user); 
	}
}

위의 user 추가 로직을 보면 여러 관점에서 요구사항의 변경이 발생할 수 있다.

  • user를 추가할 때 역할(Role)에 대한 정의를 추가.
  • user의 비밀번호 암호화 방식에 개선이 필요.
  • 기타 등등.

위와 같이 여러 개의 요구사항 변경(여러 액터로부터의 변경)이 있을 때 이는 UserService라는 하나의 클래스에서 변경이 이루어져야 한다. 이러한 문제가 발생하는 이유는 UserService가 여러 액터로부터 단 하나의 책임을 갖고 있지 못하기 때문이며, 이를 위해서는 비밀번호 암호화와 유저 생성에 대한 책임을 분리해야 한다.

다음 코드처럼 비밀번호 암호화를 책임지는 별도의 클래스를 만들어 UserService로부터 이를 추상화하고, 해당 클래스를 합성하여 사용한다면 이러한 문제를 해결할 수 있다. 그러면 우리는 user의 역할 정의 추가와 비밀번호 암호화 방식을 개선해달라는 변경을 분리할 수 있다.

// 출처 : https://mangkyu.tistory.com/194

@Component
public class SimplePasswordEncoder { 
	
    public void encryptPassword(final String pw) { 
    	
        final StringBuilder sb = new StringBuilder();
        
        for(byte b : pw.getBytes(StandardCharsets.UTF_8)) { 
        	sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
        }
        
        return sb.toString();
	}
} 

@Service
@RequiredArgsConstructor 
public class UserService {
	
    private final UserRepository userRepository;
    private final SimplePasswordEncoder passwordEncoder;
    
    public void addUser(final String email, final String pw) { 
    
    	final String encryptedPassword = passwordEncoder.encryptPassword(pw);
        final User user = User.builder()
        			.email(email)
                    		.pw(encryptedPassword)
                        	.build();
		userRepository.save(user);
	}
}

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


클래스는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다.

확장에 열려 있는 것, 수정에 닫혀있는 것은 무엇을 의미하는 것일까. 다음과 같이 정리된다.

  • 확장에 열려 있다 : 요구사항이 변경될 때 새로운 동작을 추가하여 애플리케이션의 기능을 확장할 수 있다.
  • 수정에 닫혀 있다 : 기존의 코드를 수정하지 않고 애플리케이션의 동작을 추가하거나 변경할 수 있다.

이 원칙에 대해 여러 자료를 찾아보며 내린 결론은 변경사항이 있더라도 객체를 직접적으로 수정해야 하지 않도록 설계하는 것이다. 기능이 추가 및 수정되는 등 변경이 생길 때, 객체를 직접적으로 수정해야 한다면 변경사항에 유연하지 않다고 볼 수 있다. 이는 유지보수가 어렵거나 힘들다는 의미이다. 따라서 객체를 직접 수정하지 않고도 변경사항을 적용할 수 있도록 설계해야 한다.

결과적으로 OCP추상화를 의미한다. 객체를 추상화함으로써 확장엔 열려있고, 변경엔 닫혀있는 유연한 구조를 만들 수 있는 것이다.

새로운 알고리즘을 통해 비밀번호를 암호화하는 클래스를 작성했다고 가정하자.

// 출처 : https://mangkyu.tistory.com/194

@Component
public class SHA256PasswordEncoder { 
	
    private final static String SHA_256 = "SHA-256";
    
    public String encryptPassword(final String pw) { 
    	final MessageDigest digest; 
        try { 
        	digest = MessageDigest.getInstance(SHA_256);
        } catch (NoSuchAlgorithmException e) { 
        	throw new IllegalArgumentException();
        } 
        
        final byte[] encodedHash = digest.digest(pw.getBytes(StandardCharsets.UTF_8));
	return bytesToHex(encodedHash);
    } 
    
    private String bytesToHex(final byte[] encodedHash) {
    	
        final StringBuilder hexString = new StringBuilder(2 * encodedHash.length);
        
        for (final byte hash : encodedHash) {
        	final String hex = Integer.toHexString(0xff & hash);
            
            if (hex.length() == 1) {
            	hexString.append('0');
            } 
            hexString.append(hex);
		} 
        return hexString.toString(); 
    }
}

그리고 새로운 비밀번호 암호화 정책을 적용하려고 봤더니 새로운 암호화 정책과 무관한 UserService를 다음과 같이 수정해주어야 하는 문제가 발생하였다.

// 출처 : https://mangkyu.tistory.com/194

@Service
@RequiredArgsConstructor
public class UserService{

	private final UserRespository;
    // 변경 전
   	private final SimplePasswordEncoder passwordEncoder;
    	//변경 후
    	private final SHA256PasswordEncoder passwordEncoder;
    
    ...
}

이는 기존의 코드를 수정하지 않아야 하는 개방 폐쇄 원칙에 위배된다. 그리고 나중에 또다시 또다시 비밀번호 암호화 정책을 변경해야 한다는 요구사항이 온다면 또다시 UserService에 변경이 필요해진다.

이러한 문제를 해결하고 개방 폐쇄 원칙을 지키기 위해서는 추상화를 해야한다. 추상화핵심적인 부분만 남기고, 불필요한 부분은 제거함으로써 복잡한 것을 간단히 하는 것이고, 추상화를 통해 변하지 않는 부분만 남김으로써 기능을 구체화하고 확장할 수 있다. 변하지 않는 부분은 고정하고 변하는 부분을 생략하여 추상화함으로써 변경이 필요한 경우에 생략된 부분을 수정하여 개방-폐쇄의 원칙을 지켜나간다.

그렇다면 변하지 않는 것과 변하는 것을 구분해보자.

위의 예제에서 변하지 않는 것은 사용자를 추가할 때 암호화가 필요하다는 것이고, 변하는 것은 사용되는 구체적인 암호화 정책이다. 그러므로 UserService는 어떠한 구체적인 암호화 정책이 사용되는지는 알 필요 없이 단지 passwordEncoder 객체를 통해 암호화가 된 비밀번호를 받기만 하면 된다.

그러므로 UserService가 구체적인 암호화 클래스에 의존하지 않고 PasswordEncoder라는 인터페이스에 의존하도록 추상화하면 개방 폐쇄의 원칙이 충족되는 코드를 작성할 수 있다.

// 출처 : https://mangkyu.tistory.com/194

public interface PasswordEncoder {
	String encryptPassword(final String pw);
}

@Component
public class SHA256PasswordEncoder implements PasswordEncoder {
	
    @Override
    public String encryptPassword(final String pw) {
    	...
    }
}

@Service
@RequiredArgsConstructor
public class UserService {
	
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    public void addUser(final String email, final String pw) {
    	final String encryptedPassword = passwordEncoder.encryptPassword(pw);
        final User user = User.builder()
        		.email(email)
                            .pw(encryptedPassword)
                            .build();
                            
        userRepository.save(user);
    }
}

개방 폐쇄의 원칙이 본질적으로 얘기하고자 하는 것은 추상화이며, 이는 결국 런타임 의존성과 컴파일 타임 의존성에 대한 이야기이다. 여기서 런타임 의존성이란 애플리케이션 실행 시점에서의 객체들의 관계를 의미하고, 컴파일 타임 의존성이란 코드에 표현된 클래스들의 관계를 의미한다.

다형성을 지원하는 객체지향 프로그래밍에서 런타임 의존성과 컴파일타임 의존성은 동일하지 않다. 위의 예제에서 UserService는 컴파일 시점에 추상화된 PasswordEncoder에 의존하고 있지만 런타임 시점에는 구체 클래스인 SHA256 PasswordEncoder에 의존하고 있는 것이다.

객체가 알아야 하는 지식이 많으면 결합도가 높아지고, 결합도가 높아질수록 개방-폐쇄의 원칙을 따르는 구조를 설계하기가 어려워진다. 추상화를 통해 변하는 것들은 숨기고 변하지 않는 것들에 의존하게 하면 우리는 기존의 코드 및 클래스들을 수정하지 않은 채로 애플리케이션을 확장할 수 있다.

리스 코프 치환 원칙(Liskov Substitution Principle, LSP)


상위 타입의 객체를 하위 타입으로 바꾸어도 프로그램은 일관되게 동작해야 한다.

바바라 리스코프라는 사람이 올바른 상속 관계의 특징을 정의하기 위해 발표한 것이다. 해당 객체를 사용하는 클라이언트는 상위 타입이 하위 타입으로 변경되어도, 차이점을 인식하지 못한 채 상위 타입의 퍼블릭 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다는 것이다.

중요한 점은 클라이언트 입장에서 생각해야 된다는 것이다.

정사각형은 직사각형이다(Square is a Rectangle)라는 유명한 예시가 있다.

// 출처 : https://mangkyu.tistory.com/194

@Getter
@Setter
@AllArgsConstructor 
public class Rectangle { 
	
    private int width, height;
    public int getArea() {
    	return width * height;
    }
} 

public class Square extends Rectangle {
	
    public Square(int size) {
    	super(size, size);
    } 
    
    @Override 
    public void setWidth(int width) {
    	super.setWidth(width);
        super.setHeight(width);
    } 
    
    @Override 
    public void setHeight(int height) {
    	super.setWidth(height);
        super.setHeight(height);
    }
}

Square는 1개의 변수만을 생성자로 받으며, width나 height 1개 만을 설정하는 경우 모두 설정되도록 메서드가 오버 라이딩되어 있다.

위 클래스를 이용할 클라이언트의 입장에서는 직사각형의 너비와 높이는 다르다고 생각할 것이고, 따라서 직사각형을 resize() 할 때는 width와 height 두 개의 파라미터를 이용할 것이다. 

// 출처 : https://mangkyu.tistory.com/194

public void resize(Rectangle rectangle, int width, int height) {
	
    rectangle.setWidth(width); 
    rectangle.setHeight(height); 
    
    if (rectangle.getWidth() != width && rectangle.getHeight() != height) {
    	throw new IllegalStateException();
    }
}

하지만 만약 resize() 할 객체타입이 Rectangle 대신에 자식 클래스인 Square 라면 문제가 발생한다.

다음과 코드를 통해 가로와 세로를 다르게 설정하려 했지만, Square는 가로와 세로가 모두 동일하게 설정된다. 

Rectangle rectangle = new Square();
resize(rectangle, 100, 150);

클라이언트의 관점에서 부모 클래스와 자식 클래스의 행동이 호환되지 않으므로 리스코프 치환 원칙을 위반했다고 본다. 

자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다.  대체 가능성을 결정하는 것은 해당 객체를 이용하는 클라이언트임을 잊지 말자.

추상화 레벨을 맞춰서 메서드 호출이 불가능하도록 하거나 해당 추상화 레벨에 맞게 메서드를 오버 라이딩하는 게 합리적일 것이다. 

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


클라이언트는 이용하지 않는 메서드에 의존하지 않도록 인터페이스를 분리해야 한다.

인터페이스 분리 원칙LSP와 마찬가지로 클라이언트 입장에서 생각해볼 여지가 있다.

객체가 충분히 높은 응집도의 작은 단위로 설계됐더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 적절하게 분리해줄 필요가 있는데, 이를 인터페이스 분리 원칙이라고 부른다. 클라이언트목적과 용도에 적합한 인터페이스 만을 제공하는 것이다.

이 원칙을 지킴으로써 모든 클라이언트가 관심에 맞는 퍼블릭 인터페이스(외부에서 접근 가능한 메시지)에만 접근하여 불필요한 간섭을 최소화할 수 있다. 또한 기존 클라이언트에 영향을 주지 않고 유연하게 객체의 기능을 확장 및 수정할 수 있다.

그러면 어떻게 ISP를 지킬 수 있을까? 어떤 구현체에 부가 기능이 필요하다면 이 인터페이스를 구현하는 다른 인터페이스를 만들어서 해결할 수 있다. 예를 들어 파일 읽기/쓰기 기능을 갖는 구현 클래스가 있는데 어떤 클라이언트는 읽기 작업 만을 필요로 한다면 별도의 읽기 인터페이스를 만들어 제공해주는 것이다.

예를 들어 사용자가 비밀번호를 변경할 때 입력한 비밀번호가 기존의 비밀번호와 동일한지 검사해야 하는 로직을 다른 Authentication 로직에 추가해야 한다고 가정하자. 그러면 우리는 다음과 같은 isCorrectPassword라는 퍼블릭 인터페이스를 SHA256 PasswordEncoder에 추가해줄 것이다.

// 출처 : https://mangkyu.tistory.com/194

@Component
public class SHA256PasswordEncoder implements PasswordEncoder { 
	
    @Override 
    public String encryptPassword(final String pw) {
    	...
    
    } 
    
    public String isCorrectPassword(final String rawPw, final String pw) {
    	
        final String encryptedPw = encryptPassword(rawPw);
        return encryptedPw.equals(pw);
	}
}

하지만 UserService에서는 비밀번호 암호화를 위한 encryptPassword() 만을 필요로 하고, 불필요하게 isCorrectPassword를 알 필요가 없다. 현재 UserService는 PasswordEncoder를 주입받아 encrpytPassword에만 접근 가능하므로 인터페이스 분리가 잘 된 것처럼 보인다. 하지만 새롭게 추가될 Authentication 로직에서는 isCorrectPassword에 접근하기 위해 구체 클래스인 SHA256 PasswordEncoder를 주입받아야 하는데 그러면 불필요한 encryptPassword에도 접근 가능해지고, 인터페이스 분리 원칙을 위배하게 된다.

물론 PasswordEncoder에 isCorrectPassword 퍼블릭 인터페이스를 추가해줄 수 있지만, 클라이언트의 목적과 용도에 적합한 인터페이스 만을 제공한다는 인터페이스 분리 원칙을 지키기 위해서라도 이미 만든 인터페이스는 건드리지 않는 것이 좋다.

그러므로 비밀번호를 검사를 의미하는 별도의 인터페이스(PasswordChecker)를 만들고, 해당 인터페이스로 주입받도록 하여 위 상황을 해결할 수 있다.

// 출처 : https://mangkyu.tistory.com/194

public interface PasswordChecker { 
	String isCorrectPassword(final String rawPw, final String pw);
}

@Component
public class SHA256PasswordEncoder implements PasswordEncoder, PasswordChecker {
	
    @Override
    public String encryptPassword(final String pw) {
    	... 
    } 
    
    @Override
    public String isCorrectPassword(final String rawPw, final String pw) {
    	final String encryptedPw = encryptPassword(rawPw);
        return encryptedPw.equals(pw);
    }
}

클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더욱 세밀하게 제어할 수 있다. 그리고 이렇게 인터페이스를 클라이언트의 기대에 따라 분리하여 변경에 의해 의한 영향을 제어하는 것을 인터페이스 분리 원칙이라고 부른다.

의존 관계 역전 원칙(Dependency Inversion Principle, DIP)


클라이언트는 추상화(인터페이스)에 의존해야 하며, 구체화(구현된 클래스)에 의존해선 안된다.

  • 고수준 모듈 : 변경이 없는 추상화된 클래스(또는 인터페이스)
  • 저수준 모듈 : 변하기 쉬운 구체 클래스

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 되며, 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다는 것이다. 객체 지향 프로그래밍에서는 객체들 사이에 메시지를 주고받기 위해 의존성이 생기는데, 의존성 역전의 원칙은 올바른 의존 관계를 위한 원칙이다.

돌아보면 위에서 개방-폐쇄 원칙을 지키면서 의존 역전 원칙도 함께 준수하도록 코드를 수정했었다. 변하지 않는 것들을 추상화했기에 추상화에 의존하면 구체화된 변하는 것들이 또 변경되어도 변경이 전파되지 않으며 유연한 애플리케이션이 된다.

출처 : https://mangkyu.tistory.com/194

의존 역전 원칙이 위배되면 개방 폐쇄 원칙 역시 위배되게 될 가능성이 높다.

의존 역전 원칙에서 의존성이 역전되는 시점은 컴파일 시점이라는 것이다. 런타임 시점에는 UserService가 SHA256 PasswordEncoder라는 구체 클래스에 의존하게 된다. 하지만 의존 역전 원칙은 컴파일 시점 또는 소스 코드 단계에서의 의존성이 역전되는 것을 의미하며, 코드 상에서는 UserService가 PasswordEncoder라는 인터페이스에 의존한다.

정리 및 느낀점


처음 개발 공부를 시작할 때, 배웠던 내용이었다. 하지만 객체지향 언어를 제대로 다뤄본 적도 없는 시점에서 이 객체지향 설계 원칙을 이해하기는 어려웠던 것 같다. 

SRP : 하나의 클래스는 하나의 책임만 가져야 한다. 또한 클래스는 하나의 이유만으로 변경되어야 한다.

- 클래스가 하나의 책임을 가지고 있으면 해당 기능의 변경을 특정할 수 있고, 클래스를 변경해야 하는 이유와 시점이 명확해짐.

OCP : 클래스는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다.

- 결국에는 추상화를 얘기하는 것임. 변하지 않는 것은 고정, 변하는 것은 생략하여 추상화함으로써 추상화된 객체에 의존하게 하면 기존의 코드를 수정하지 않은 채로 애플리케이션을 확장할 수 있음.

LSP : 상위 타입의 객체를 하위 타입으로 바꾸어도 프로그램은 일관되게 동작해야 한다.

- 클라이언트 관점에서 상위 타입 객체와 하위 타입 객체를 구분하지 못하도록 해야 함.

ISP : 클라이언트는 이용하지 않는 메서드에 의존하지 않도록 인터페이스를 분리해야 한다.

- 클라이언트의 목적과 용도에 맞는 인터페이스만을 제공해야 함. 불필요한 간섭을 피할 수 있도록.

DIP:클라이언트는 추상화(인터페이스)에 의존해야 하며, 구체화(구현된 클래스)에 의존해선 안된다.

- 의존성이 역전되는 시점은 컴파일 시점임.

이번 글을 정리하면서 궁금증이 생긴 내용들.

  • 추상화, 구체화, 추상화 레벨
  • 런타임 의존성, 컴파일 의존성 
  • 컴파일 시점, 런타임 시점
  • 설계 원칙을 지키기 위한 구체적인 방법은?

참조


 

[OOP] 객체지향 프로그래밍의 5가지 설계 원칙, 실무 코드로 살펴보는 SOLID

이번에는 객체 지향 프로그래밍의 5가지 핵심 원칙인 SOLID에 대해 알아보고자 합니다. 실제로 애플리케이션을 개발할 때 어떻게 적용할 수 있을지 구체적인 예시를 들어 살펴보고자 합니다. 아

mangkyu.tistory.com

 

쉬는 중 : 네이버 블로그

블로그 이사 https://bool-flower.tistory.com/

blog.naver.com

 

객체지향 5대 원칙 | OOP의 기본 원칙 익히기 | Martin

객체지향이란? 객체지향하면 나오는 키워드들이 있다. 추상화, 캡슐화, 상속, 다형성 등등.. 객체 지향이란 결국 코드 간에 서로 관계를 맺어줌으로써, 유기적으로 프로그램을 구성하는 것이 아

blog.martinwork.co.kr

 

객체지향 설계 원칙 5가지

지난 포스팅 객체지향 주요특징에 이어 이번엔 객체지향의 주요 5가지 원칙을 정리해보려고 한다. 객체지향 5가지 원칙인프런에서 스프링 핵심 원리 - 기본편을 수강하며 정리한 내용이다. 토비

youngjinmo.github.io

'CS > 공통' 카테고리의 다른 글

VO vs DTO vs Entity  (0) 2022.08.01
Web Server와 WAS(Web Application Server)  (0) 2022.03.18
싱글톤(Singleton) 패턴  (0) 2022.02.20
댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday