해결된 질문
작성
·
1K
·
수정됨
3
수업을 듣던 도중 추상 클래스를 인터페이스와 함께 사용한 이유가 궁금해 질문드립니다.
상속이 코드 재사용을 위해 그다지 좋은 방법이 아니라는(+ 자세한 내용은 뒤에서 더 자세히 살펴봄) 언급을 해주셨지만, 우선 여기서는 왜 이렇게 코드를 짰는지 궁금해 고민한 부분을 여쭤보고자 합니다.
해당 강의를 듣고나서 맨 처음 들었던 생각은, "추상 클래스로만 추상화를 한 뒤, 상위 수준 객체가 인터페이스 대신 추상 클래스를 의존하면 안될까?" 였습니다.
추상클래스를 사용하더라도 read()
만 public
이고 readLines()
나 parse()
는 각각 private
, protected
이므로 외부에 노출되지 않아서 괜찮지 않을까 생각했습니다.
더 고민해 보았는데, 인터페이스를 사용한다면 코드를 유지보수하는 과정에서 다음과 같은 이점을 얻을 수 있어서 그런가? 라는 생각이 들어 질문드립니다.
상위 수준의 객체가 인터페이스에 의존하면 역할을 한 눈에 파악하기 쉽다.
Reader
는 read()
라는 메서드 시그니처로 "특정 데이터소스로부터 읽어오는 작업"을 수행한다. 라는 것을 인터페이스를 통해 명시한다. 즉, "명세" 역할을 한다.
이는 유지보수 과정에서 해당 인터페이스만 읽고 구현체가 제공하는(또는 해야하는) 기능(public 메서드)들을 확인하기 용이하다. 즉, 역할을 한 눈에 파악하기 쉽다.
추상 클래스에 의존하면 역할을 한 눈에 파악하기 어렵다.
AbstractReader
는 메서드 시그니처 뿐만 아니라 중복 로직의 경우 구현 내용까지 포함하고 있고, 여러 메서드들 중 외부에서 협력하기 위해 pubilc
으로 노출시켜 제공하는 기능을 한 눈에 파악하기 어렵다.
위 두 가지 내용이 인터페이스를 추상클래스와 함께 사용하는 이유가 될 수 있을까요?
1번에서 인터페이스와 추상클래스를 함께 사용한 이유로 언급했던 "역할을 한 눈에 파악하기 쉽다"는 장점이 있다면, 두 메서드 모두 public
일 때에도 추상클래스로만 구현하기 보다는 인터페이스와 추상클래스를 함께 사용하는 것이 좋을까요?
2번 질문은 예시 코드를 드리자면 Spring Boot 개발환경에서 작업한 코드로 CaptchaHashProcessor
인터페이스에는 public
메서드인 hash()
, verify()
두 메서드가 있습니다.
해당 인터페이스를 구현한 두 구현체에서 verify()
메서드가 중복되는 상황입니다.
두 public 메서드를 제공하는 인터페이스
public interface CaptchaHashProcessor {
HashResult hash(Long captchaId);
Long verify(String hashedCode, Long userId);
}
구현체 1 - hash
는 다르게 구현하나 verify
는 구현체 2와 내용 동일
public class RandomCaptchaHashProcessor implements CaptchaHashProcessor {
private static final SecureRandom RANDOM = new SecureRandom();
private final Encryption encryption;
private final CaptchaLogPort captchaLogPort;
private final EncryptionProperties properties;
@Override
public HashResult hash(Long captchaId) {
// 구현체마다 다름...
}
@Override
public Long verify(String encryptedCode, Long userId) {
// 중복 로직 ...
}
}
구현체 2- hash
는 다르게 구현하나 verify
는 구현체 1과 내용 동일
public class FixedCaptchaHashProcessor implements CaptchaHashProcessor {
private static final String FIXED_IV = Base64.getEncoder().encodeToString(new byte[16]);
private final Encryption encryption;
private final CaptchaLogPort captchaLogPort;
@Override
public HashResult hash(Long captchaId) {
// 구현체마다 다름...
}
@Override
public Long verify(String hashedCode, Long userId) {
// 중복 로직 ...
}
}
여기서 추상클래스로 verify
중복 로직을 이동시키면서 인터페이스를 사용한다면
인터페이스를 구현한 추상클래스
public abstract class AbstractCaptchaHashProcessor implements CaptchaHashProcessor {
protected final Encryption encryption;
private final CaptchaLogPort captchaLogPort;
@Override
public Long verify(String hashedCode, Long userId) {
// 추상 클래스로 이동한 중복 로직 ...
}
}
구현체 1
public class RandomCaptchaHashProcessor extends AbstractCaptchaHashProcessor {
private static final SecureRandom RANDOM = new SecureRandom();
private final EncryptionProperties properties;
public RandomCaptchaHashProcessor(Encryption encryption, CaptchaLogPort captchaLogPort, EncryptionProperties properties) {
super(encryption, captchaLogPort);
this.properties = properties;
}
@Override
public HashResult hash(Long captchaId) {
// 구현체마다 다름 ...
}
}
구현체 2
public class FixedCaptchaHashProcessor extends AbstractCaptchaHashProcessor {
private static final String FIXED_IV = Base64.getEncoder().encodeToString(new byte[16]);
public FixedCaptchaHashProcessor(Encryption encryption, CaptchaLogPort captchaLogPort) {
super(encryption, captchaLogPort);
}
@Override
public HashResult hash(Long captchaId) {
// 구현체마다 다름 ...
}
}
이렇게 구현할 수 있을텐데, 인터페이스를 사용하지 않는다면 hash
메서드까지 추상 클래스의 추상 메서드로 명시해서 상위 수준 클래스가 추상 클래스에 의존해도 될 것 같아서 고민이 됩니다!
이 질문을 작성하면서 다른 생각도 떠올랐는데요, CaptchaHashProcessor
는 캡챠 코드를 암호화(hash)하고 캡챠 코드를 검증(verify)한다는 두 가지 책임을 가진 것 같아서 어쩌면 암호화 책임은 인터페이스와 그 구현체들로 제공하고, 검증 책임은 또다른 클래스에서 구현하는 것이 적절한가? 하는 생각도 듭니다...
좋은 강의를 제공해주시고 긴 질문 읽어주셔서 감사합니다.
질문은 타인이 저에게 소중한 시간을 소비하는 것이라 생각해 강사님의 시간 낭비가 되지 않도록 영양가 있는 질문을 잘 하고싶습니다.
혹시나 질문의 내용 구성이나 태도, 질문을 이끌어낸 사고과정 등에서 부족한 부분이 보였다면 어떻게 개선하면 좋을지 말씀해주시면 감사하겠습니다!
답변 1
4
강명덕님 좋은 질문 남겨 주셔서 감사합니다!
두 질문에 대해 차례대로 답변드릴게요. 🙂
1. 협력을 위해 제공하는 메시지를 확인하기 용이하기 때문인가요?
먼저 첫 번째 질문인 “추상 클래스로만 추상화를 한 뒤, 상위 수준 객체가 인터페이스 대신 추상 클래스를 의존하면 안될까?”에 대해 답변 드릴게요.
강의에서 설명드렸던 것처럼 JsonReader와 CsvReader에서 추상화를 추출하는 이유는 클라이언트인 CallCollector 입장에서 두 객체가 교체 가능해야 하기 때문입니다.
이를 위해서는 CallCollector가 구체적인 JsonReader나 CsvReader에 의존하는 대신 두 타입의 객체를 포괄하는 상위 타입에만 의존하도록 의존성을 조정해야 합니다.
여기에서 고민은 말씀하신 것처럼 Reader를 인터페이스 대신 추상 클래스로 만들 것인지, 아니면 인터페이스로 만들고 중복 코드는 추상 클래스인 AbstractReader에 남기는 안을 선택할 지를 결정해여 한다는 것입니다.
강의와 다르게 Reader를 추상 클래스로 선언하면 말씀하신 것처럼 read만 public이기 때문에
public abstract class Reader {
private String path;
public Reader(String path) {
this.path = path;
}
public List<Call> read() {
List<String> lines = readLines(path);
return parse(lines);
}
private List<String> readLines(String path) {
try {
return Files.readAllLines(
Path.of(ClassLoader.getSystemResource(path).toURI()));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
protected abstract List<Call> parse(List<String> lines);
}
CallCollector는 추상 클래스인 Reader를 의존성 주입받아서 사용하면 되겠죠.
public class CallCollector {
private final Reader reader;
public CallCollector(Reader reader) {
this.reader = reader;
}
public CallHistory collect(String phone) {
List<Call> calls = reader.read();
...
}
}
이렇게 구현을 해도 CsvReader와 JsonReader를 교체하겠다는 목표는 달성할 수 있습니다.
여기에서 7-3을 포함하는 섹션7의 타이틀을 “외부 의존성과 테스트”라고 지었다는 점에 주목해 주시면 좋겠습니다.
지금 우리는 CallCollector를 좀 더 쉽게 테스트하고 싶다는 점도 목표로 하고 있습니다.
테스트 관점에서 현재의 Reader에서 신경 쓰이는 부분은 Reader가 외부의 파일 시스템에 의존하고 있다는 점입니다.
Reader의 내부 구현은 런타임에 파일 시스템이 존재한다는 것을 가정하고 있습니다.
단위 테스트는 외부의 파일 시스템에 의존하지 않고 메모리 안에 생성된 객체들을 이용해서 실행하는 것이 이상적이기 때문에 파일에 대한 의존성을 제거하고 테스트할 수 있다면 좋겠죠.
Reader를 인터페이스로 만들면 파일에 대한 의존성을 제거할 수 있습니다.
public interface Reader {
List<Call> read();
}
파일에 대한 의존성은 AbstractReader로 이동시켰습니다.
public abstract class AbstractReader implements Reader {
private String path;
public Reader(String path) {
this.path = path;
}
public List<Call> read() {
List<String> lines = readLines(path);
return parse(lines);
}
private List<String> readLines(String path) {
try {
return Files.readAllLines(
Path.of(ClassLoader.getSystemResource(path).toURI()));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
protected abstract List<Call> parse(List<String> lines);
}
이제 CallCollector는 파일에 대한 의존성을 가지지 않는 Reader 인터페이스에만 의존하기 때문에 쉽게 외부 의존성을 고려하지 않고도 쉽게 테스트할 수 있습니다.
실제로 7-3의 11:07 부분을 보시면 가짜 객체(Fake Object) 방식으로 구현된 테스트 더블인 FakeReader를 이용해서 단위 테스트를 쉽게 실행할 수 있는 방법을 보여주고 있습니다.
public class FakeReader implements Reader {
private List<Call> calls;
public FakeReader(Call ... calls) {
this.calls = List.of(calls);
}
@Override
public List<Call> read() {
return calls;
}
}
물론 Reader가 추상 클래스인 경우에도 가짜 객체를 추가할 수는 있습니다.
하지만 추상 클래스의 경우에는 Reader의 내부 구현을 이해한 상태에서 가짜 객체를 구현해야 하기 때문에 복잡해집니다.
다음은 Reader가 추상 클래스인 경우의 FakeReader 구현입니다.
public class FakeReader extends Reader {
private List<Call> calls;
public FakeReader(Call ... calls) {
super("");
this.calls = List.of(calls);
}
@Override
public List<Call> read() {
return calls;
}
@Override
protected List<Call> parse(List<String> lines) {
return List.of();
}
}
상속은 내부 구현에 대한 결합도가 높기 때문에 내부 구현을 이해해야 한다는 점이 항상 문제를 일으킵니다.
FakeReader를 구현하기 위해서는 Reader의 내부 구현을 이해해야 합니다.
read 메서드만 오버라이딩하면 파일에 대한 의존성을 제거할 수 있다는 사실을 이해해야 합니다.
FakeReader의 생성자에서 super 콜을 이용해서 Reader의 생성자를 호출해야 한다는 사실을 알고 있어야 합니다.
Reader의 내부 구현에 속하지만 테스트와는 상관이 없는 parse 메서드도 오버로딩해야 합니다.
이 경우에는 테스트를 위해 내부 구현에 대한 결합을 완전히 끊어내기 위해 인터페이스를 사용하는게 더 효과적입니다.
말씀하신 것처럼 인터페이스가 추상 클래스보다 역할을 한 눈에 파악하기 쉬운건 맞지만 설계 원칙을 기반으로 추상 클래스를 작게 만들 경우에는 이 부분에서 생각보다 차이가 크지는 않은 것 같아요.
인터페이스를 하나 추가할 때의 비용(머리 속에서 트래킹해야 하는 요소가 하나 추사된 거니까요) 대비 이익이 크지 않다면 추상 클래스로 시작하시는게 좋습니다.
이런 이슈가 발생하지 않고 단위 테스트 관점에서 큰 차이가 없는 경우라면 저도 강명덕님의 생각과 동일하게 추상 클래스로 시작했을 겁니다.
나중에 여러 클래스 계층에 적용할 필요가 있는 경우에 인터페이스를 추출하는건 생각보다 쉽기 때문이죠.
2. 두 public 메서드 중 하나는 중복 로직, 하나는 각 구현체마다 다르게 구현하는 경우에도 인터페이스와 추상클래스를 함께 사용하는 것이 좋을까요?
모든 자식 클래스들이 단일 상속 계층 안에 위치하는지 여부와 단위 테스트에 대한 필요성에 따라 판단하시면 될것 같습니다.
만약 자식 클래스들이 여러 상속 계층을 구성한다면 인터페이스로 만드시는게 좋습니다.
동일한 상속 계층에만 속한다면 인터페이스와 추상 클래스 둘 중 하나를 선택할 수 있습니다.
CaptchaHashProcessor와 협력하는 다른 클래스를 단위 테스트해야 한다면 verify 메서드의 내부 구현이 통제하기 어려운 외부 요소에 의존하고 있는지 살펴보시는게 좋습니다.
만약 verify 메서드가 불안정한 외부 요소를 사용한다면 인터페이스로 만드시는게 모킹이나 가짜 객체를 만들기에 용이합니다.
만약 verify 메서드가 통제 가능한 요소들만 사용한다면 추상 클래스로 만드셔도 무방합니다.
이렇게 모든 클래스가 단일 상속 계층에 속하고 불안정한 요소게 의존하지 않기 때문에 단위 테스트에 문제를 일으키지 않는다면 말씀하신 것처럼 일단 추상 클래스에 의존하도록 만드셔도 무방합니다.
대신에 추상 클래스의 이름은 Abstract를 제외한 CaptchaHashProcessor로 지으시는게 좋습니다.
이렇게 하면 나중에 CaptchaHashProcessor를 인터페이스로 변경하더라도 클라이언트에 주는 영향을 최소화할 수 있습니다.
중복을 제거하고 코드를 더 쉽게 재사용할 수 있는 더 좋은 방법으로는 합성(Composition)이 있으며 색션 8에서 관련된 내용을 다루고 있습니다.
질문의 내용 구성에 대해 의견을 요청하셨는데 궁금하신 내용을 명확하게 정리해 주셔서 답변을 드리기가 쉬웠습니다.
질문도 흥미로운 부분이라 강의에서 해당 부분을 좀더 자세히 설명했으면 좋았었겠구나라는 생각이 들었습니다.
좋은 질문해 주셔서 감사드리고 답변 중에 미진한 부분이나 추가로 궁금한 부분 있으면 질문 남겨주시면 감사하겠습니다!
깔끔하게 정리해 주셨네요. 🙂
정리하신 내용이 정확합니다.
한 가지만 추가로 말씀드릴게요.
이전 질문에서 Reader 안에 외부 의존성을 제거하는게 좋다고 강조했었는데 그 이유에 대해 보완 설명을 드릴게요. 🙂
Reader를 추상 클래스로 구현할 때 외부의 불안정한 요소에 의존하면 좋지 않은 이유는 전이적 의존성(Transitive Depenency)때문입니다.
Reader가 파일 시스템에 의존하고, CallCollector가 Reader에 의존할 때 파일 시스템에 대한 의존성이 Reader를 거쳐 CallCollector까지 전파될 수 있는 가능성을 전이적 의존성이라고 부릅니다.
아래 그림에서 보시는 것처럼 Reader가 추상 클래스일 경우에는 파일 시스템에 대한 의존성이 CallCollector에게 까지 전파되고, 결과적으로 FakeReader가 Reader를 상속받더라도 파일 시스템에 대한 의존성을 가진 채 단위 테스트를 실행할 수 밖에 없게 됩니다.
파일 시스템에 대한 전이적 의존성을 고민하지 않고 CallCollector를 단위 테스트하기 위해서는 Reader를 인터페이스로 구현하고 FakeReader가 이 인터페이스를 구현하도록 만드는 것입니다.
이렇게 하면 파일 시스템에 대한 의존성이 Reader 인터페이스를 기준으로 끊기게 됩니다.
아마도 이런 식의 구조는 데이터베이스에 접속하는 영속성 레이어를 모킹해서 테스트할 때 많이 보셨을 거에요.
답변이 되었는지 모르겠네요.
행복한 주말 보내시고 궁금한 내용 있으면 언제라도 질문 주세요!
더 쉽게 테스트할 수 있다는 점을 놓쳤네요! 상세한 설명 정말 감사합니다.
나름의 정리를 해보자면
첫 번째 질문 답변
테스트를 작성에 용이하도록 가짜 객체를 만들 수 있다.
Reader가 외부 파일 시스템에 의존하고 있으며 이는 추상 클래스에 중복 로직으로 이동된 부분이다.
내부 구현에 대한 결합도가 높은 “상속”으로 인해 인터페이스와 달리 추상 클래스는 내부 구현을 이해한 상태에서 가짜 객체를 구현해야 한다. (합성으로 개선 가능)
super 호출을 해야 하거나
테스트와 무관한 parse 등 메서드도 오버라이딩해야 한다.
read 메서드만 오버라이딩하면 파일에 대한 의존성을 제거할 수 있다. 따라서 인터페이스를 사용하면 이런 문제를 효과적으로 해결할 수 있다.
위와 같은 이슈가 발생하지 않고 단위 테스트 관점에서 큰 차이가 없다면 추상 클래스로 시작해도 괜찮다. (설계 원칙을 기반으로 변경에 용이하게 작게 만들도록 하기)
두 번째 질문 답변
동일한 상속 계층이라면(단일 상속이라면?) 인터페이스, 추상클래스 둘 중 하나 선택 가능하다.
해당 역할과 협력하는 다른 클래스를 단위 테스트 해야 한다면(특히 verify 메시지를 사용한다면) 추상클래스의 경우 해당 메서드 구현이 통제하기 어려운 외부 요소에 의존하고 있는지 살펴보면 좋다.
통제 어려움(불안정한 외부 요소) -> 인터페이스가 모킹에 유리
여기서 통제하기 어려운 불안정한 외부 요소는 Reader의 파일에 대한 의존성이 예시가 될 수 있다.
통제 가능 -> 추상 클래스 사용해도 무방
추가 정리
중복 코드를 제거하고 코드를 재사용하기 위해 추상화를 이용할 수 있고 이때 테스트의 유용성을 함께 고려해야 한다.
테스트의 유용성을 고려하는 방법으로는 이와 협력하는 객체의 단위 테스트를 진행할 때 모킹 또는 가짜 객체를 만들기 쉬운지로 판별 가능하다.
모킹, 가짜 객체는 테스트의 입력에 해당하며 이것들을 만들기 쉽다면 테스트의 입력을 통제하기 쉽다는 것을 뜻한다.
불안정하고 통제하기 어려운 외부 요소에 의존하고 있다면 모킹, 가짜 객체 생성이 어려우므로 이때 인터페이스를 이용하면 쉽게 해결할 수 있다.
꿀팁: 추상 클래스로만 먼저 추상화하는 경우 인터페이스로 대체 가능하도록 불필요한 접두사를 제거해 클라이언트에 주는 영향을 최소화할 수 있다.
이렇게 정리해도 괜찮을까요?
강의 내용들을 곱씹어 보며 코드를 짜보고 고민하는 과정을 통해 스스로의 주관을 세워보려 합니다. 상세히 답변해 주셔서 감사드립니다.