객체지향 설계원칙 SOLID
객체지향 패러다임에서 더 좋은 코드란 무엇인가에 대한 고민의 결과
High Cohesion, Loose Coupling 목표
1. Single Responsibility (단일 책임)
하나의 모듈은 한가지 책임만 가진다.
그래야지만 모듈이 변경되는 이유가 단 한가지일 수 있다.
- 해당 모듈이 여러 대상 또는 액터들에 대해 책임을 가져서는 안되고, 오직 하나의 액터에 대해서만 책임을 져야한다는 것을 의미
패스워드 암호화 로직이 유저 정보를 추가하하는 로직에 위치한다면
- addUser는 물론 암호화한 뒤에 데이터베이스에 저장되어야겠지만 암호화 로직이 Raw한 상태로 addUser에 그대로 노출되어 있음
- 암호화 로직이 변경되는 것이 addUser전체 로직에 변경을 발생시킨다.
@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);
}
}
단일책임 원칙에 따르면
- 패스워드 암호화 로직은 따로 클래스 + 메서드로 빠져있다.
- 암호화 로직이 바뀐다하더라도 addUser에서 변경할 코드는 존재하지 않는다.
@Component
public class SimplePasswordEncoder {
public String 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);
}
}
2. Open-Closed (개방폐쇄)
확장에 열려있으나 수정에 닫혀있다.
개방 폐쇄 원칙을 지키기 위해서는 추상화에 의존해야한다. = 인터페이스에 구현체 교체
- 확장에 대해 열려 있다: 요구사항이 변경될 때 새로운 동작을 추가하여 애플리케이션의 기능을 확장
- 수정에 대해 닫혀있다.: 기존의 코드를 수정하지 않고 애플리케이션의 동작을 추가하거나 변경
OCP가 본질적으로 얘기하는 것은 추상화이며, 이는 결국 런타임 의존성과 컴파일 타임 의존성에 대한 것이다.
- 컴파일 의존성: 코드에 표현된 클래스들의 관계를 의미 = 인터페이스
- 런타임 의존성: 애플리케이션 실행 시점에서의 객체들의 관계를 의미 = 인터페이스 내 구체 클래스 주입
비밀번호 암호화에 구체 클래스를 서비스에서 직접 사용한다면
- 암호화 방식을 더하거나 제거할 때 서비스 로직이 바뀌게 된다.
- SHA256PasswordEncoder 를 사용하지 않고 다른 암호화 구체 클래스를 사용하게 되면 클래스 타입 변경이 발생
@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();
}
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final SHA256PasswordEncoder passwordEncoder;
...
}
개방폐쇄 원칙에 따르면
- PasswordEncoder라는 인터페이스를 사용해서 암호화 방식에 대해 확장에는 열려있으나 서비스 로직 수정은 하지 않아도 된다.
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);
}
}
3. Interface Segregation (인터페이스 분리)
인터페이스 내 메소드는 최소한 개수
하나의 일반적인 인터페이스보다 여러개의 구체적인 인터페이스가 낫다
- 도메인 같은걸로 인터페이스를 쪼개어 놓으면 필요한 구현은 1개인데 구현체에 나머지 쓰지도 않는것들까지 구현해야한다.
- 예를 들어서 라면 끓이기 객체에 물끓이기, 스프넣기, 면 넣기, 파썰어넣기, 계란 넣기가 필요하다면
- 기본 라면 끓이기, 라면에 고명 넣기 등으로 쪼개어야한다.
- 인터페이스내 최소 메서드는 create, delete, insert가 들어간다.
라면 끓이기 클래스로 이해해보자
- 인터페이스 soup을 구현한 JinSoup에서 인터페이스 함수를 구현하지 않는 것들이 있다.
- 이렇게 구현하지 않는 메서드들이 있다면 인터페이스 분리 원칙을 위배하는 것이다
- 인터페이스는 최소한의 메서드만 가지고, 해당 메서드를 모둘 구현해야한다.
public interface Soup {
void input();
void additional();
void egg();
}
@Slf4j
public class JinSoup implements Soup {
@Override
public void input() {
log.info("진라면 스프 넣기");
}
@Override
public void additional() {
//비구현 메소드
}
@Override
public void egg() {
log.info("계란 넣기");
}
}
인터페이스 분리 원칙에 따르면
- additional()메서드를 위한 Additional인터페이스
- egg()메서드를 위한 Egg인터페이
위 두개 인터페이스를 따로 만들어야한다.
public interface Egg {
void egg();
}
public interface Additional {
void additional();
}
@Slf4j
@RequiredArgsConstructor
public class Ramen {
private final Water water;
private final Soup soup;
private final Noodle noodle;
//원래대로라면 egg쓰는 라멘 -> EggRamen클래스 , AdditinalReman 클래스로분리되어야 한다.
private final Egg egg;
private final Additional additional;
public void make() {
water.input();
soup.input();
noodle.input();
}
}
개발 시 많이 사용하는 코드로 예를 들어보자면
- PasswordEncoder인터페이스에 패스워드 확인하는 메서드(isCorrectPassword)와 암호화하는 메서드(encryptPassword)가 있다
@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);
}
}
인터페이스 분리 원칙에 따르면
- 패스워드 확인하는 메서드와 암호화하는 메서드를 각각 인터페이스로 만들어서 이 두개의 인터페이스를 다중 구현하면 된다.
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);
}
}
4. Dependency Inversion (의존성 역전)
인터페이스로 구체를 연결한다고 해서 의존성 역전이다.
고수준이 저수준을 참고할 때 인터페이스를 참조한다.
이게 뭔말이교.... 하면 코드를 살펴보자
public class Ramen {
private final Water water;
private final Soup soup;
private final Noodle noodle;
private final Egg egg; //interface
private final Additional additional; //interface
}
Soup나 Egg나 Additional인 저수준의 세부 구현에 대해서 Ramen이 몰라도됨. 어떤 구현체를 사용할 것인지는 어플리케이션에서 넣어준다.
- 고수준(Ramen)이 저수준(Soup나 Egg나 Additional을 구현한 구체 클래스)을 참고할 때 인터페이스를 참조
Ramen sinRamen = new Ramen(
new Water(),
new SinSoup(),
new SinNoodle(),
new Egg(),
new Onion())
스프링 관점에서 설명하자면
- (@Service)고주순 모듈 <- 인터페이스(추상화) <- (@Autowired)저수준 모듈
- 고수준 모듈: 유저 정보를 DB에서 가져온다. (비지니스 관련, 출력(DB) 과 멀다)
- 저수준 모듈: DB 에서 유저 테이블을 찾아 정보를 반환한다. (DB, 출력과 가깝다)
고수준 모듈은 저수준 모듈의 구현에 의존하지 않는다는 것은
- 저수준이 어떻게 구현되어 있든 신경쓸 필요가 없다.
- 원하는 저수준 구현체를 가져다 쓰면 된다.
- 저수준이 꼭 무엇이여야만 하는것은 아니다.
- 저수준은 인터페이스를 통해 뚫려있고, 아무 구체 클래스만 들어오면 장땡
고수준 모듈은 저수준 모듈의 구현에 의해 의존해서는 안되며, 저수준 모듈이 고주순 모듈에 의존해야한다는 것
그래서 고수준 모듈은 저수준 모듈을 @Autowired (고수준이 저수준 모듈 중 어떤걸 사용하는 지에 따라서 의존성 주입)
- 의존 역전 원칙이란 결국 비즈니스와 관련된 부분이 세부 사항에는 의존하지 않는 설계 원칙
의존 역전 원칙(D)은 개방 폐쇄 원칙(O)와 밀접한 관련이 있다.
의존 역전 원칙(D)에서 의존성이 역전되는 시점은 "컴파일 시점" 이라는 것
- 컴파일 의존성: 코드에 표현된 클래스들의 관계를 의미 = 인터페이스
위 그림에서 PasswordEncoder가 여러 구현체를 갖는다. 이 의존 역전 원칙(D)이 아니라, userService 고수준 모듈이 PasswordEncoder 저수준 모듈에 의존하지 않고, PasswordEncoder 저수준 모듈이 UserService 고수준 모듈의 목적에 따라 선택되기 때문에, 고수준 모듈에 의존한다고 할 수 있다.
의존 역전 원칙에서 의존성이 역전되는 시점은 컴파일 시점이다.
런타임 시점에는 UserService가 PasswordEncdoer를 구현한 구체 클래스중 하나를 바라본다.
하지만 의존 역전 원칙은 컴파일 시점 또는 소스 코드 단계에서의 의존성이 역전되는 것을 의미하며, 코드에서는 UserService가 PasswordEncoder라는 인터페이스에 의존한다.
5. Liscov Substitution (리스코프 치환)
"상속 시" 부모 클래스에 대한 가정 그대로 자식 클래스가 정의 되어야한다.
- 하위 타입은 항상 상위 타입을 대체할 수 있어야한다.
Noodle을 상속 받은 childeNoodle은 면 종류에 대해서만 다뤄야한다.
@Slf4j
public class NoodleChild extends Noodle {
public void input() {
log.info("밥넣기"); //이렇게 하면 안됨. Noodle은 면을 반환하는데 여기서 왜 밥을 반환해
//똑같이 면을 반환해야함.
}
}
리스코프 치환 원칙을 설명하기 위해 직사각형과 정사각형 관계가 있음
/**
* 직사각형 클래스
*
* @author RWB
* @since 2021.08.14 Sat 11:12:44
*/
public class Rectangle
{
protected int width;
protected int height;
/**
* 너비 반환 함수
*
* @return [int] 너비
*/
public int getWidth()
{
return width;
}
/**
* 높이 반환 함수
*
* @return [int] 높이
*/
public int getHeight()
{
return height;
}
/**
* 너비 할당 함수
*
* @param width: [int] 너비
*/
public void setWidth(int width)
{
this.width = width;
}
/**
* 높이 할당 함수
*
* @param height: [int] 높이
*/
public void setHeight(int height)
{
this.height = height;
}
/**
* 넓이 반환 함수
*
* @return [int] 넓이
*/
public int getArea()
{
return width * height;
}
}
정사각형 역시 넓게 보면 직사각형의 한 종류이므로, 직사각형을 상속하여 정사각형을 만든 경우
/**
* 정사각형 클래스
*
* @author RWB
* @since 2021.08.14 Sat 11:19:07
*/
public class Square extends Rectangle
{
/**
* 너비 할당 함수
*
* @param width: [int] 너비
*/
@Override
public void setWidth(int width)
{
super.setWidth(width);
super.setHeight(getWidth());
}
/**
* 높이 할당 함수
*
* @param height: [int] 높이
*/
@Override
public void setHeight(int height)
{
super.setHeight(height);
super.setWidth(getHeight());
}
}
/**
* 메인 클래스
*
* @author RWB
* @since 2021.06.14 Mon 00:06:32
*/
public class Main
{
/**
* 메인 함수
*
* @param args: [String[]] 매개변수
*/
public static void main(String[] args)
{
Rectangle rectangle = new Square();
rectangle.setWidth(10);
rectangle.setHeight(5);
System.out.println(rectangle.getArea());
}
}
이렇게 개발자는 Rectangle를 사용할때 이게 정사각형이다라는걸 가정할 수 없다.
리스크프 원칙에 따르면
- Shape이라는 클래스를 하나 만들어서 Rectangle과 Squaure가 상속하도록 한다.
/**
* 사각형 객체
*
* @author RWB
* @since 2021.08.14 Sat 11:39:02
*/
public class Shape
{
protected int width;
protected int height;
public Shape(int width, int height)
{
this.width = width;
this.height = height;
}
/**
* 너비 반환 함수
*
* @return [int] 너비
*/
public int getWidth()
{
return width;
}
/**
* 높이 반환 함수
*
* @return [int] 높이
*/
public int getHeight()
{
return height;
}
/**
* 넓이 반환 함수
*
* @return [int] 넓이
*/
public int getArea()
{
return width * height;
}
}
정리
결국 상속보다는 Interface 구현으로 사용하자는 걸로 SOLID원칙 설명이 끝임.
'ASAC 웹 풀스택 > Spring Boot' 카테고리의 다른 글
[Spring Boot] 기본 MVC 개발을 위한 Annotations 과 그 이해(1) - 컨트롤러로 정적/동적 페이지 반환, 타임리프 설정 변경 (1) | 2024.10.06 |
---|---|
[Spring Boot] 기본 MVC 개발을 위한 Annotations 과 그 이해(1) - 컨트롤러 없이 정적 페이지 반환 (0) | 2024.10.06 |
Java 기본 문법 및 JVM 구성(13) - Enum 활용 (0) | 2024.09.30 |
Java 기본 문법 및 JVM 구성(12) - 다형성 (0) | 2024.09.29 |
Java 기본 문법 및 JVM 구성(11) - Generic, Interface, Abstract Class (0) | 2024.09.29 |