좋은 객체지향 프로그래밍 방법론 : SOLID 원칙 그리고 디자인 패턴

2SEONGA·2025년 2월 11일
2

Java

목록 보기
11/13
post-thumbnail

1. 좋은, 객체지향적 프로그래밍을 위한 방법론

꼭 객체지향이 아니라 보편적으로 좋은 프로그래밍 코드란 변경이 용이하고, 유지보수와 확장이 쉬운 코드

  • 중복의 최소화 : 하나의 수정이 다른 하나의 수정을 동반해선 안된다
  • 코드 변경의 용이성 : 코드는 항상 완벽하지 않고 요구사항은 상시 바뀔 수 있다
  • 재사용성 : 정돈된 코드는 비슷하거나 다른 요구사항에도 그대로 사용 가능하다

(1) 클린 코드의 기준 : 단일 목적과 오용, 남용 방지

모든 변수, 함수, 클래스는 목적에 맞게 활용되어야한다는 원칙

  • 반복되는 목적의 작업은 하나의 함수나 클래스로 묶어야하고
    • DRY, Don’t Repeat Yourself : 반복하지 마라
    • 동일한 작업을 수행하는 로직이 여러 곳에 있지 않을 것
      • (나쁜 사례) 해당 로직 수정 시 모든 곳에서 수정 필요
  • 목적이 여러 개여서는 안되고
    • KISS, Keep It Simple and Stupid : 단일 목적을 가져야한다 + 바보가 봐도 이해가능해야한다
    • 하나의 함수는 하나의 역할만
      • (나쁜 사례) ListItem 을 생성하고 + 로컬 스토리지에 저장
  • 목적이 미래를 위한 것이어서도 안된다
    • YAGNI, You Ain’t Gonna Need It : 지금 필요한것만 만든다
    • 나중에 사용할 기능까지 미리 만들지말 것
      • (나쁜 사례) 쓸것으로 생각되서 만들었으나 쓰지 않는 API

(2) 디자인 패턴 및 객체지향 설계원칙 SOLID

  • 객체지향 설계원칙 SOLID : 객체지향 패러다임에서 더 좋은 코드란 무엇인가에 대한 고민의 결과
  • 디자인 패턴 : 특정 문맥에서 공통적으로 발생하는 문제에 대해 재사용 가능한 해결책
    • 다른 상황에 맞게 사용될 수 있는 문제들을 해결하는데에 쓰이는 템플릿

      “탑에 다리우스가 왔다면, 가장 먼저 Q 를 피하기 위해
      이속과 물방이 중요하니 판금장화부터 올리세요.


(3) 좋은 객체지향 프로그래밍 코드를 위한 원칙 2가지

  • 구현보다 인터페이스에 맞춰서 코딩할 것
    • 구현은 언제나 바뀔 수 있다. 인터페이스를 통해 유연하게 구현
  • ‘상속’보다는 인터페이스 ‘구성(Composite)’을 사용
    • ‘상속’ 이 아닌 인터페이스 ‘구성’ 시 원하는 구현을 붙였다 떼었다가 가능

2. 객체지향 설계원칙 SOLID = 추상화와 다형성

High Cohesion, Loose Coupling을 목표

= SOLID 를 다 읽고 이해하면 알겠지만 결국 추상화와 다형성을 얘기하는 것

(1) S : Single Responsibility (단일책임)

하나의 모듈(한 클래스 or 메소드)은 하나의 책임/역할만

  • 그래야지만, 모듈이 변경되는 이유가 한 가지
    • 해당 모듈이 여러 대상 또는 액터들에 대해 책임을 가져서는 안되고,
      오직 하나의 액터에 대해서만 책임을 져야 한다는 것을 의미

[단순한 코드]

  • Single Responsibility 단일책임 원칙 이전
public class Ramen {
    public void make() {
        System.out.println("물 끓이기");
        System.out.println("스프 넣기");
        System.out.println("면 넣기");
    }
}
  • Single Responsibility 단일책임 원칙 이후
@RequiredArgsConstructor
public class Ramen {
		private final Water water;
		private final Soup soup;
		private final Noodle noodle;
		
    public void make() {
        water.input();
        soup.input();
        noodle.input();
    }
}
public class Soup {
    public void input() { System.out.println("스프 넣기"); }
}

[복잡한 코드]

  • 이전 버전 : 패스워드 암호화 로직이 유저 정보를 추가하는 로직에 위치
    • 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);
    }
}

String 이 아닌 StringBuilder 를 사용해야하는 이유

  • 불변 Mutable : String
  • 가변 Immutable : StringBuffer / StringBuilder

(2) O : Open-Closed (개방폐쇄)

확장에 열려있고, 수정에 닫혀있다

개방 폐쇄 원칙을 지키기 위해서는 추상화에 의존 = 인터페이스에 구현체 교체

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

심화 : OCP 가 본질적으로 얘기하는 것은 추상화이며, 이는 결국 런타임 의존성과 컴파일 타임 의존성에 대한 것

  • 컴파일타임 의존성이란 코드에 표현된 클래스들의 관계를 의미 = 인터페이스 부분
  • 런타임 의존성이란 애플리케이션 실행 시점에서의 객체들의 관계를 의미 = 인터페이스 내 구체 클래스 주입

[단순한 코드]

  • Open-Closed 개방폐쇄 원칙 이전
@RequiredArgsConstructor
public class Ramen {
		private final Water water;
		private final Soup soup;
		private final Noodle noodle;
		
    public void make() {
        water.input();
        soup.input();
        noodle.input();
    }
}
public class Soup {
    public void input() { System.out.println("스프 넣기"); }
}
  • Open-Closed 개방폐쇄 원칙 이후
@RequiredArgsConstructor
public class Ramen {
		private final Water water;
		private final Soup soup;
		private final Noodle noodle;
		
    public void make() {
        water.input();
        soup.input();
        noodle.input();
    }
}
public interface Soup {
    public abstract void input();
}
public class SinSoup implements Soup {
    public void input() { System.out.println("신라면 스프 넣기"); }
}
public class JinSoup implements Soup {
    public void input() { System.out.println("진라면 스프 넣기"); }
}

[복잡한 코드]

  • 이전 버전
@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;

    ...
    
}
  • 이후 버전
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) I : Interface Segregation (인터페이스 분리)

인터페이스 내에 메소드는 최소한 개수로 (S : 클래스, 메서드 관점 - I : 인터페이스 관점)

  • 하나의 일반적인 인터페이스 < 여러 개의 구체적인 인터페이스
    • 도메인같은걸로 인터페이스를 쪼개어놓으면
      필요한 구현은 1개인데 구현체에 쓰지도 않는 것들까지 구현 필요

      ex) 라면끓이기 객체
      = 물 끓이기, 스프 넣기, 면 넣기, 파 썰어 넣기, 계란 넣기
      - 파 썰어 넣기, 계란 넣기 내 아무 것도 구현이 없음 (텅 빈 Empty 구현)
      - 기본 라면 끓이기, 라면에 고명넣기 등으로 쪼개어야함

  • 위 이미지 중 가장 우측 하단에 있는 그림은, Interface 에서 사용할 곳만 구체화(구현)하지 말라는 것

[단순한 코드]

  • Interface Segregation : 인터페이스 분리 원칙 이전
@RequiredArgsConstructor
public class Ramen {
		private final Water water;
		private final Soup soup;
		private final Noodle noodle;
		
    public void make() {
        water.input();
        soup.input();
        soup.onion();
        soup.egg();
        noodle.input();
    }
}

public interface Soup {
    public abstract void input();
    public abstract void onion();
    public abstract void egg();
}
public class SinSoup implements Soup {
    public void input() { System.out.println("신라면 스프 넣기"); }
    public void onion() { System.out.println("파 넣기"); }
    public void egg()   {}
}
public class JinSoup implements Soup {
    public void input() { System.out.println("진라면 스프 넣기"); }
    public void onion() {}
    public void egg()   { System.out.println("계란 넣기"); }
}
  • Interface Segregation 인터페이스 분리 원칙 이후
public interface Soup {
    public abstract void input();
}
public interface Onion {
    public abstract void input();
}
public interface Egg {
    public abstract void input();
}

public class SinSoup implements Soup {
    public void input() { System.out.println("신라면 스프 넣기"); }
}
public class GreenOnion implements Onion {
    public void input() { System.out.println("파 넣기"); }
}
public class SmallEgg implements Egg {
    public void input() { System.out.println("계란 넣기"); }
}

@RequiredArgsConstructor
public class Ramen {
		private final Water water;
		private final Soup soup;
		private final Onion onion;
		private final Egg egg;
		private final Noodle noodle;
		
    public void make() {
        water.input();
        soup.input();
        onion.input();
        egg.input();
        noodle.input();
    }
}

[복잡한 코드]

  • 이전 버전
@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) D : Dependency Inversion (의존성 역전)

인터페이스로 구현체를 연결

  • Spring 에서 모듈 계층 : (@Service) 고수준 모듈 ← 인터페이스(추상화) ← (@Autowired) 저수준 모듈
    • 고수준 모듈 : 유저 정보를 DB 에서 가져옴 (비지니스 관련, 출력(DB)과 멀다)
    • 저수준 모듈 : DB 에서 유저 테이블을 찾아 정보를 반환함 (DB, 출력과 가까움)
      • DB 가 RDBMS 가 아니라 NoSQL 이라면?
      • DB 가 동기가 아니라 비동기라면?
      • 출력에 있어서 다양한 선택지, 경우의 수

고수준 모듈은 저수준 모듈의 구현에 의존하지 않는다는 건
= 저수준이 어떻게 구현되어있든 굳이 신경 쓸 필요가 없음
= 원하는 저수준 구현체를 가져다 쓰면 됨
= 저수준이 꼭 무엇이여야만 하는 것은 아님
= 저수준은 인터페이스를 통해 뚫려있고, 아무 구체 클래스만 들어오면 장땡

  • 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 되며, 저수준 모듈이 고수준 모듈에 의존해야 한다는 것
    • 고수준 모듈: 입력과 출력으로부터 먼(비즈니스와 관련된) 추상화된 모듈
    • 저수준 모듈: 입력과 출력으로부터 가까운(HTTP, 데이터베이스, 캐시 등과 관련된) 구현 모듈
  • 그래서 고수준 모듈은 저수준 모듈들을 @Autowired
    • 의존 역전 원칙이란 결국 비즈니스와 관련된 부분이 세부 사항에는 의존하지 않는 설계 원칙

  • 의존 역전 원칙(D)은 개방 폐쇄 원칙(O)과 밀접한 관련이 있음
    • 의존 역전 원칙(D) = UserService → PasswordEncoder (추상)
      • 고수준 모듈 = UserService
      • 저수준 모듈 = PasswordEncoder
      • 고수준 모듈은 PasswordEncoder 라는 저수준 모듈의 추상에 의존 = 구현에 비의존
    • 개방 폐쇄 원칙(O) = PasswordEncoder (추상) ← SimplePasswordEncoder (구현)
      • 수정에 닫힘 = PasswordEncoder 을 사용하는 UserService 고수준 모듈은 수정되지 않음
      • 확장에 열림 = PasswordEncoder 에 대한 원하는것들을 구현체로 만들면됨
        • 구현체 : SimplePasswordEncoder, SHA256PasswordEncoder 등
  • 의존 역전 원칙(D)에서 의존성이 역전되는 시점은 “컴파일 시점” 이다.
    ← Dependancy Inversion (역전)
  • 개방 폐쇄 원칙(O)에서 구현체가 교체되는 시점은 “런타임 시점” 이다.
    ← Dependancy Injection (주입)

위 그림에서 PasswordEncoder 가 여러 구현체를 갖는 것(SimplePasswordEncoder 등)이 의존 역전 원칙(D)이 아니라, UserService 고수준 모듈이 PasswordEncoder 저수준 모듈에 의존하지 않고, PasswordEncoder 저수준 모듈이 UserService 고수준 모듈의 목적에 따라 선택되기때문에, 고수준 모듈에 의존한다고 할 수 있다.

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

[단순한 코드]

  • 앞선 실습 Soup 인터페이스의 input 메서드 구현체만 바꾸어 SinSoup 와 JinSoup 클래스 구현
public interface Soup {
    public abstract void input();
}
public class SinSoup implements Soup {
    public void input() { System.out.println("신라면 스프 넣기"); }
}
public class JinSoup implements Soup {
    public void input() { System.out.println("진라면 스프 넣기"); }
}
  • Dependency Inversion 의존성 역전 원칙에 따라 Ramen 은 Soup 라는 비구현 저수준 모듈에 의존
  • Open-Closed 개방 폐쇄 원칙에 따라 Ramen 클래스는 바뀌지 않고, Soup 구현 클래스만 바뀌었다.
    - 역할과 책임에 맞는 부분만 바꾼다, 굳이 불필요한 부분은 바뀌지 않는다.
@RequiredArgsConstructor
public class Ramen {
		private final Water water;
		private final Soup soup;
		private final Onion onion;
		private final Egg egg;
		private final Noodle noodle;
		
    public void make() {
        water.input();
        soup.input();
        onion.input();
        egg.input();
        noodle.input();
    }
}
public static void main(String[] args) {
    Ramen ramen = new Ramen(
            new Water(),
            new JinSoup(),
            new GreenOnion(),
            new SmallEgg(),
            new Noodle()
    );
    ramen.make();
}

(5) L : Liscov Substitution (리스코프 치환)

“상속 시” 부모 클래스에 대한 가정 그대로 자식 클래스가 정의되어야한다
(계급제)

  • 하위 타입은 항상 상위 타입을 대체 할 수 있어야함 (아빠가 변호사 나도 변호사 → 아빠 안녕, 내가 변호사)

상속 시 하위 클래스는 상위 클래스의 모든 가정을 그대로 갖고있어야하며
상위 클래스를 사용하는 개발자가 가정하는 모든 것들 제공해야함

Rectangle rectangle = new Square();
// Rectangle = 상위 클래스 | Square = 하위 클래스
resize(rectangle, 100, 150);
// Rectangle 입장에선 말이되나, Square 입장에선 아예 말이 안되는 코드
// 개발자는 Rectangle 를 사용할 때 이게 정사각형인걸 가정할 수 없음


[복잡한 코드]

  • 이전 버전
@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);
    }
}
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();
    }
}
Rectangle rectangle = new Square();
resize(rectangle, 100, 150);

3. 디자인 패턴

  • 디자인 패턴 : 특정 문맥에서 공통적으로 발생하는 문제에 대해 재사용 가능한 해결책재사용성 목표
    • 다른 상황에 맞게 사용될 수 있는 문제들을 해결하는데에 쓰이는 서술이나 템플릿

좋은 코드란 무엇인가? 변경이 용이하고, 유지보수와 확장이 쉬운 코드 (아까 배웠던 내용)

  • 객체지향 패러다임에서 더 좋은 코드란 무엇인가에 대한 고민의 결과
    • 중복의 최소화 : 하나의 수정이 다른 하나의 수정을 동반해선 안된다
    • 코드 변경의 용이성 : 코드는 항상 완벽하지 않고, 요구사항은 상시 바뀔 수 있습니다.
    • 재사용성 : 정돈된 코드는 전혀 다른 요구사항 및 비슷한 경우에도 그대로 사용이 가능합니다.

(1) 디자인 패턴 종류

  • 생성(Creational) 패턴
    • 추상 팩토리
    • 빌더
    • 팩토리 메서드
    • 프로토타입
    • 싱글톤
  • 구조(Structural) 패턴
    • 어댑터
    • 브리지
    • 컴포지트
    • 데코레이터
    • 퍼사드
    • 플라이웨이트
    • 프락시
  • 행위(Behavioral) 패턴
    • 책임연쇄
    • 커맨드
    • 인터프리터
    • 이터레이터
    • 미디에이터
    • 메멘토
    • 옵저버
    • 스테이트
    • 스트레티지
    • 템플릿 메서드
    • 비지터

(2) 주요 패턴

  • 추상 팩토리 패턴 : 메세지 전송을 위한 로직을 만들때 수많은 템플릿, 상황을 커버
  • 팩토리 메서드 패턴 : 생성자가 아닌 정적 팩토리 메서드를 통한 객체 생성
  • 빌더 패턴 : 생성자가 아닌 빌더를 통한 객체 생성
  • 싱글턴 패턴
  • 어댑터 패턴 / 데커레이터 패턴 : 어떤 메서드를 한번 감싸서 추가적인 작업으로 확장하여 사용할때
    • 어댑터 패턴 : interface 전환 = Client ← B ← A
    • 데커레이터 패턴 : interface 동일 = Client ← A ← A
  • 파사드 패턴 : 일반적으로 @Autowired 하여 여러 필요 서비스들을 모아 사용하는것
  • 프록시 패턴 : Spring AOP 동작 원리, Spring Data JPA 의 @Transactional 동작 원리
  • 이터레이터 패턴 : 일반적으로 우리가 쓰는 Collection 들
  • 옵저버 패턴 : 브로드캐스팅이 필요할때
  • 템플릿 메서드 : 추상 클래스를 사용할때 사용할 수 밖에 없는 패턴
profile
(와.. 정말 Chill하다..)

0개의 댓글