강의

멘토링

로드맵

인프런 커뮤니티 질문&답변

hello님의 프로필 이미지
hello

작성한 질문수

오브젝트 - 설계 원칙편

8-5. 리스코프 치환 원칙을 위한 가이드

8-5 책임 분리를 통한 중복 코드 제거 과정에서 특정 조합은 불가능할 때

해결된 질문

작성

·

314

·

수정됨

2

안녕하세요!

AbstractReader로부터 파싱에 대한 책임을 분리하여 Parser라는 클래스를 새로 생성하는 내용 잘 들었습니다!
여기서 궁금한 것이 생겨 질문 남깁니다..!

DatabaseReader, RedisReader, FileReader / CsvParser, JsonParser, XmlParser 구성은 총 9가지의 경우의 수가 나올 수 있을 것 같은데요

만약 '(DatabaseReader, XmlParser)는 불가능하고 (DatabaseReader, CsvParser)와 (DatabaseReader, JsonParser)가 가능하고,
(RedisReader, XmlParser)와 (RedisReader, CsvParser)는 불가능하고 (RedisReader, JsonParser)만 가능하다' 와 같이 특정 조합이 불가능한 경우는 어떻게 컴파일 타임에 가능한 조합으로만 강제할 수 있을지 궁금합니다!

답변 2

4

조영호님의 프로필 이미지
조영호
지식공유자

hello님 안녕하세요.

좋은 질문 남겨 주셔서 감사합니다. 🙂

 

컴파일타임에 의존성 주입 가능한 조합을 체크하는 방법 중에서 가장 간단한 방식은 파라미터의 타입을 명시적으로 드러내서 주입받을 수 있는 타입의 종류를 제한하는 것입니다.

간단하게 말해서 DatabaseReader와 RedisReade의 생성자에서 조합 가능한 Parser의 타입을 전달받는 생성자들을 추가하는 방식이죠.

  • (DatabaseReader, XmlParser)는 불가능하고 (DatabaseReader, CsvParser)와 (DatabaseReader, JsonParser)는 가능한 경우

public class DatabaseReader extends AbstractReader {
    public DatabaseReader(String path, CsvParser parser) {
        super(path, parser);
    }

    public DatabaseReader(String path, JsonParser parser) {
        super(path, parser);
    }
    ...
}
  • (RedisReader, XmlParser)와 (RedisReader, CsvParser)는 불가능하고 (RedisReader, JsonParser) 가능

public class RedisReader extends AbstractReader {
    private JedisPool jedisPool;

    public RedisReader(String path, JsonParser parser, JedisPool jedisPool) {
        super(path, parser);
        this.jedisPool = jedisPool;
    }
}

생성자를 private으로 바꾸고 public static 생성 메서드를 추가해도 동일한 효과를 얻을 수 있습니다.

이 방식의 단점은 새롭게 조합 가능한 Parser를 추가하게 되면 Reader 들의 생성자를 함께 수정해야 한다는 점인데, 전체적인 복잡도 관점에서 보면 수용할만한 정도의 단점이라고 생각합니다.

 

만약 새로운 Parser가 추가됐을 때 Reader를 수정하고 싶지 않다면 마커 인터페이스(marker interface)를 사용하는 방법도 있습니다.

DatabaseReader와 조합 가능한 Parser 인터페이스를 제한하기 위해 Parser를 상속받는 DatabaseParsable 인터페이스를 추가합니다.

public interface DatabaseParsable extends Parser {}

RedisReader와 조합 가능한 Parser 인터페이스를 제한하기 위해 RedisParsable 인터페이스를 추가합니다.

public interface RedisParsable extends Parser {}

DatabaseReader는 DatabaseParsable만 조합할 수 있도록 제한합니다.

public class DatabaseReader<T extends DatabaseParsable> extends AbstractReader {
    public DatabaseReader(String path, T parser) {
        super(path, parser);
    }
    ...
}

RedisReader는 RedisParsable만 조합할 수 있도록 제한합니다.

public class RedisReader<T extends RedisParsable> extends AbstractReader {
    private JedisPool jedisPool;

    public RedisReader(String path, T parser) {
        super(path, parser);
    }

    public RedisReader(String path, T parser, JedisPool jedisPool) {
        super(path, parser);
        this.jedisPool = jedisPool;
    }
    ...
 }

DatabaseReader와 조합 가능하지만 RedisReader와는 조합할 수 없는 CsvParser는 DatabaseParsable 인터페이스만 구현합니다.

public class CsvParser implements DatabaseParsable {
  ...
}

DatabaseReader와 RedisReader 양쪽 모두와 조합할 수 있는 JsonParser는 DatabaseParsable와 RedisParsable 인터페이스 양쪽 모두를 구현합니다.

public class JsonParser implements DatabaseParsable, RedisParsable {
  ...
}

이 방식은 생성자를 이용하는 방식과 반대로 Parser가 추가될 때 Reader 계층 클래스를 수정할 필요가 없지만, Reader가 추가되면 조합 방식에 따라 Parser 계층 클래스를 수정해야 합니다.

 

개방-폐쇄 원칙 관점에서 Reader 계층과 Parser 계층 모두를 다 폐쇄시키는 어렵기 때문에 어떤 쪽이 더 중요한 변경의 축인지에 따라 적합한 방식을 선택하시면 될것 같아요.

클래스 계층이 너무 많아져서 이 제약을 클래스 계층 전반에 걸쳐 명시하는게 점점 복잡해진다면 컴파일타임이 아닌 런타임에 제한하는 쪽으로 구현하시는게 전체적인 복잡성을 낮추는 방법일 수 있습니다.

 

답변이 되었는지 모르겠네요. 😊

감사합니다.

hello님의 프로필 이미지
hello
질문자

상세한 답변 정말 감사합니다!!

런타임에 제한하는 방법에 대한 예시도 조금만 공유해주시면 감사하겠습니다..!

조영호님의 프로필 이미지
조영호
지식공유자

런타임에 조합 가능한 인스턴스를 체크할 수 있는 가장 간단한 방법은 생성자에서 허용 가능한 타입을 체크한 후 원하는 타입이 아니면 예외를 던져서 객체가 생성되지 않도록 방지하는 것입니다.

CsvParser와 JsonParser만 받을 수 있는 DatabaseReader는 다음과 같이 구현할 수 있습니다.

public class DatabaseReader extends AbstractReader {
  private DatabaseReader(String path, Parser parser) {
    super(path, parser);

    if (!(parser instanceof CsvParser || parser instanceof JsonParser)) {
      throw new IllegalArgumentException();
    }
  }
  ...
}

JsonParser만 받을 수 있는 RedisReader는 다음과 같이 구현할 수 있습니다.

public class RedisReader extends AbstractReader {
    private JedisPool jedisPool;

    public RedisReader(String path, Parser parser, JedisPool jedisPool) {
        super(path, parser);

        if (!(parser instanceof JsonParser)) {
            throw new IllegalArgumentException();
        }

        this.jedisPool = jedisPool;
    }
  ...
}

이 방식은 조합 가능한 Parser의 타입을 코드에 명시적으로 표현하기 때문에 변경 관점에서 보면 이전에 살펴본 생성자에 명시적으로 파라미터 타입을 명시하는 것과 크게 다르지 않습니다.

추가로 컴파일 타임이 아닌 런타임에 타입을 체크하기 때문에 객체 생성 방식을 통일해야 하는 특별한 이유가 없다면 생성자에 타입을 명시하는 방식을 권장합니다.

 

Reader와 Parser 계층을 수정하지 않고 조합 규칙을 수정하거나 관리하고 싶다면 별도의 객체에 조합 규칙을 유지하는 방법을 사용할 수 있습니다.

public class ReaderRelationship {
  private Map<Class<? extends Reader>, Set<Class<? extends Parser>>> rules = new HashMap<>();

  public ReaderRelationship(Map<Class<? extends Reader>, Set<Class<? extends Parser>>>  rules) {
    this.rules = rules;
  }

  public boolean isValid(Reader reader, Parser parser) {
    if (!rules.containsKey(reader.getClass())) {
      return false;
    }

    return rules.get(reader.getClass()).contains(parser.getClass());
  }

AbstractReader의 생성자에서는 ReaderRelationship을 전달 받은 후 조합이 적절한 경우에만 객체를 생성합니다.

public abstract class AbstractReader implements Reader {
    private String path;
    private Parser parser;

    protected AbstractReader(String path, Parser parser, ReaderRelationship relationship) {
        if (!relationship.isValid(this, parser)) {
            throw new IllegalArgumentException();
        }

        this.path = path;
        this.parser = parser;
    }
}

각각의 Reader는 생성자를 오버라이딩해야겠죠.

public class RedisReader extends AbstractReader {
  private JedisPool jedisPool;

  public RedisReader(String path, Parser parser, JedisPool jedisPool, ReaderRelationship relationship) {
    super(path, parser, relationship);
    this.jedisPool = jedisPool;
  }
  ...
}

이제 유효한 조합을 명시해서 ReaderRelationship을 생성한 후 RedisReader의 생성자에 전달하면 런타임에 원하지 않는 객체들이 조합되는 것을 방지할 수 있습니다.

ReaderRelationship relationship = new ReaderRelationship(new HashMap<>() {{
  put(DatabaseReader.class, Set.of(CsvParser.class, JsonParser.class));
  put(RedisReader.class, Set.of(CsvParser.class));
}});

// JsonParser는 RedisReader와 조합 불가능하기 때문에 예외 발생
RedisReader reader = new RedisReader(
                         "calls.json", 
                         new JsonParser(), 
                         jedisPool, 
                         relationship);

이렇게 하면 조합 규칙을 코드 구조가 아니라 데이터로 관리할 수 있기 때문에 개방-폐쇄 원칙을 준수하는 코드를 만들 수 있게 됩니다.

 

답변이 되었는지 모르겠네요. 🙂

감사합니다.

0

안녕하세요, 인프런 AI 인턴입니다. hello님!

기존 설계에서 ‘Reader’와 ‘Parser’의 조합으로 총 9가지 경우의 수가 가능하다고 설명하셨고, 특정 조합이 불가능한 경우를 컴파일 타임에 강제하고 싶다고 하셨는데요, 이를 해결하기 위한 방법으로 몇 가지를 제안드리겠습니다.

1. 구체적인 클래스를 이용한 조합 제한

가장 간단한 방법은 각 가능한 조합에 대해 구체적인 클래스를 생성하는 것입니다. 즉, 가능한 조합에 대해서만 해당하는 클래스를 명시적으로 만들어 주는 방식입니다. 이 방법은 경우의 수가 적을 때는 유효할 수 있지만, 경우의 수가 많아진다면 관리가 어려울 수 있습니다.

2. 제너릭 타입과 제한된 다형성

자바에서는 제너릭 타입과 제한된 다형성을 활용하여 가능 조합을 제한할 수 있습니다. 각 Reader가 특정 Parser를 받을 수 있도록 인터페이스 또는 추상 기능을 정의하는 것입니다.

// 인터페이스 정의
interface Parser {}
class CsvParser implements Parser {}
class JsonParser implements Parser {}
class XmlParser implements Parser {}

// 특정 Reader에만 적용 가능한 파서 조합 정의
interface DatabaseCompatibleParser extends Parser {}
class CsvDatabaseParser extends CsvParser implements DatabaseCompatibleParser {}
class JsonDatabaseParser extends JsonParser implements DatabaseCompatibleParser {}

interface RedisCompatibleParser extends Parser {}
class JsonRedisParser extends JsonParser implements RedisCompatibleParser {}

3. 빌더 패턴 이용

빌더 패턴을 활용하여 불가능한 조합을 방지할 수 있습니다. 빌더 내부에서 가능한 조합을 확인하거나 제한할 수 있게 로직을 추가합니다.

4. 검증 함수 사용

런타임에 검증함수를 사용하여 조합을 확인할 수도 있습니다. 다만, 이 방법은 컴파일 타임이 아닌 런타임에 오류를 확인하게 됩니다.

추가 제언

컴파일 타임 강제는 코드의 복잡도를 높일 수 있으므로, 유지보수성과 개발의 용이성을 위해 복잡도가 높은 경우 런타임 검증도 고려해볼 수 있습니다.

관련한 관점에서 Interface Segregation Principle를 적용하여 인터페이스를 잘게 나누는 것도 방법이 될 수 있습니다.

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다. 현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏 추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

hello님의 프로필 이미지
hello

작성한 질문수

질문하기