Search

sealed , non-sealed

태그
Java
우테코
작성 상태
작성 완료
작성일
2024/03/16
참고 링크 2

개요

sealed 키워드에 대해 이해하기 위해서는 자바에서 인터페이스와 추상 클래스, 구체 클래스를 이용한 상속 구조를 이해하고 있어야 한다. 이 포스트는 독자가 이를 이미 이해하고 있다고 가정하고 작성했다.
Java 15에 Preview 로 추가되어 Java 17에 정식 기능으로 포함된 상위 클래스(혹은 인터페이스)에서 자신을 상속(구현)하는 클래스(인터페이스)를 제한하는 기능이다.

도입 배경

인터페이스, 추상 클래스, 구체 클래스를 이용한 견고한 코드

java-ladder
woowacourse
사다리 게임 미션의 요구사항 중 사다리에 대한 제약 조건이 있다.
사다리 타기가 정상적으로 동작하려면 라인이 겹치지 않도록 해야 한다.
|-----|-----| 모양과 같이 가로 라인이 겹치는 경우 어느 방향으로 이동할지 결정할 수 없다.
사다리가 랜덤으로 생성되는 것이 타당하지만, 랜덤은 테스트하기 어렵기 때문에 랜덤으로 결정되는 부분을 외부에서 주입할 수 있도록 하는 것이 테스트에 유리하다. 아래는 이를 위한 인터페이스다.
public interface LineGenerateStrategy { List<Boolean> generate(int lineSize); }
Java
복사
List.of(true) = |-----| , List.of(true,flase,true) = |-----| |-----| 이런 라인과 대응된다.
랜덤으로 Boolean을 집어넣는 구현체를 다음과 같이 작성할 수 있다.
public class RandomLineGenerateStrategy implements LineGenerateStrategy { private final Random random = new Random(); @Override public final List<Boolean> generate(int lineSize) { List<Boolean> generate = IntStream.range(0, lineSize) .mapToObj(value -> random.nextBoolean()) .collect(Collectors.toList()); fixInvalidBridges(generate); // 연속으로 true가 생성된 경우 고친다. return Collections.unmodifiableList(generate); } }
Java
복사
테스트 코드에서는 랜덤이 아니라 지정할 수 있도록 코드를 구현할 것이다. 그런데, 지금 구조에서는 이런 테스트 구현체가 fixInvalidBridges 와 비슷하게 true가 연속해서 나오지 않도록 강제할 수 없다.
인터페이스에 default 메서드를 추가하더라고, 구현 클래스에서 재정의가 가능하기 때문에 완전하지 않다. 이를 해결하기 위해서는 추상 클래스가 필요하다.
abstract class AbstractLineGenerateStrategy implements LineGenerateStrategy { @Override public final List<Boolean> generate(int lineSize) { List<Boolean> generate = new ArrayList<>(generateStrategy(lineSize)); fixInvalidBridges(generate); return Collections.unmodifiableList(generate); } public abstract List<Boolean> generateStrategy(int lineSize); private void fixInvalidBridges(List<Boolean> rawBridges) { // 적절한 코드 } }
Java
복사
인터페이스를 구현하는 추상 클래스를 만들고, 인터페이스에 정의된 메서드를 final로 하고, 새로 정의한 추상 메서드와 private 메서드를 이용하면 추상 클래스의 구현체는 추상 클래스에서 구현된 제한을 어길 방법이 없다.
public class RandomLineGenerateStrategy extends AbstractLineGenerateStrategy { private final Random random = new Random(); @Override public final List<Boolean> generateStrategy(int lineSize) { return IntStream.range(0, lineSize) .mapToObj(value -> random.nextBoolean()) .collect(Collectors.toList()); } }
Java
복사

정말 완벽할까?

위 코드는 잘 설계되었지만 결정적인 문제가 있다.
인터페이스를 직접 구현하는 다른 구현체가 있을 수 있다!
추상 클래스를 상속하는 것 대신 인터페이스를 직접 구현한다면 추상 클래스에서 애써 구현한 코드가 무용지물이 된다. 물론, 구현체를 대입하는 타입이 추상클래스가 된다면 문제가 없지만, 이미 인터페이스를 여기 저기서 사용했다면 쉽지 않을 것이다. 또, 지켜야 하는 구체적인 제한이 조금 다르지만, 인터페이스 레벨에서는 동일한 경우의 확장성을 생각하면 인터페이스가 있는 것이 좋다.

sealed 키워드를 활용한 더 견고한 코드

public sealed interface LineGenerateStrategy permits AbstractLineGenerateStrategy { List<Boolean> generate(int lineSize); }
Java
복사
sealed 키워드와 permits 키워드를 이용하면, 인터페이스를 구현하는 클래스를 제한할 수 있다. 위 코드 처럼 sealed 키워드를 interface 키워드 앞에 적고, 인터페이스 이름 뒤에 permits 와 구현 클래스 이름을 적어주면 된다. 구현 클래스가 여러가지라면 “,”를 구분자로 나열하면 된다.
abstract non-sealed class AbstractLineGenerateStrategy implements LineGenerateStrategy { @Override public final List<Boolean> generate(int lineSize) { List<Boolean> generate = new ArrayList<>(generateStrategy(lineSize)); fixInvalidBridges(generate); return Collections.unmodifiableList(generate); } public abstract List<Boolean> generateStrategy(int lineSize); private void fixInvalidBridges(List<Boolean> rawBridges) { // 적절한 코드 } }
Java
복사
sealed 클래스를 부모로 두는 클래스는 반드시 sealed, non-sealed, final 키워드 중 하나를 사용해야 한다. 이런 제한은 non-sealed 키워드를 사용하면, 어떤 클래스도 이 클래스를 부모 클래스로 둘 수 있다. 위 코드 같은 경우, 구현되어야 하는 제약 사항이 다 구현되어 있으므로 굳이 귀찮게 명시적으로 구현체 클래스 이름을 적을 필요가 없다.

장점

의도를 전달할 수 있다.

어떤 인터페이스를 구현하려고 했더니, 구현이 막혀있다면 “왜 이 인터페이스는 구현하지 못하게 하지?” 라는 질문을 하게 될 것이고, 인터페이스와 허용된 구현 클래스를 확인하며 그 의도를 확인하게 될것이다.

단점

OCP 원칙을 위배한다.

OCP 원칙은 기존 코드의 변경 없이 기능을 확장할 수 있어야 한다는 원칙이다. 즉, 새로운 기능을 추가하기 위해 기존의 코드를 수정하지 말아야 한다는 원칙이다. 물론, 변화를 미리 예측할 수 없기 때문에 이 원칙을 완벽히 지키는 것은 어렵다. 하지만 sealed 를 사용하면 새로운 기능이 추가될 때 대놓고 기존 코드의 수정이 필요해진다.
public sealed interface LineGenerateStrategy permits AbstractLineGenerateStrategy { List<Boolean> generate(int lineSize); }
Java
복사
이 상태에서, AbstractLineGenerateStrategy 에 구현된 로직을 따르지 않는 LineGenerateStrategy 의 구현체가 필요해지면, LineGenerateStrategy 의 permits 뒤에 새로 추가할 구현체의 이름을 적어줘야 한다. 즉, 기존 코드인 LineGenerateStrategy 가 변경된다.

DIP 원칙을 위배한다.

DIP 원칙은 구체적인 것이 추상적인 것을 의존해야지, 추상적인 것이 구체적인 것을 의존하면 안된다는 원칙이다. 추상적인 것은 변경의 여지가 적지만, 구체적인 것은 변경의 여지가 더 크기 때문이다.
public sealed interface LineGenerateStrategy permits AbstractLineGenerateStrategy { List<Boolean> generate(int lineSize); }
Java
복사
sealed 키워드를 사용하면 permits 키워드로 인해, 인터페이스인 LineGenerateStrategy가 구체적인 제한이 걸려있는 AbstractLineGenerateStrategy을 의존하게 된다.