리플렉션

appti·2023년 3월 27일
0

학습 로그

목록 보기
2/8

리플렉션

자바의 리플렉션이란 메소드 및 필드를 가지고 있는 클래스의 메타데이터를 다룰 수 있는 기능입니다.

용도

리플렉션을 주로 다음과 같은 용도로 사용됩니다.

  • 동적으로 객체 생성 : 클래스가 컴파일 타임에 알려지지 않은 경우에도 리플렉션을 사용하여 클래스로부터 새로운 객체를 생성할 수 있습니다.
  • 클래스의 동작 수정: 런타임 시 클래스 동작을 수정할 수도 있습니다.
  • 디버깅 : 런타임 시점에서 객체나 클래스의 상태를 검사하기 위해 디버깅 목적으로 사용할 수 있습니다.
  • 테스트 : 객체 또는 클래스의 속성을 검사하고 확인하기 위한 테스트 목적으로 사용할 수 있습니다.

이렇게 보면 리플렉션은 굉장히 유용해 보입니다.
하지만, 리플렉션을 무심코 사용하게 된다면 수 많은 문제점을 얻을 수 있습니다.

프로덕션 코드에서의 문제점

프로덕션 환경에서 리플렉션을 섣부르게 사용하게 된다면 캡슐화를 깨뜨릴 수 있습니다.

private 접근 제어자에 대한 조건 완화

리플렉션을 활용한다면, 다음과 같은 코드 한 줄로 private 접근 제어자에 대한 조건을 완화할 수 있습니다.

Field.setAccessible(true)

내부적으로 감춰진 요소에 대해 접근할 수 있다는 사실 그 자체만으로 캡슐화를 깨뜨릴 수 있습니다.

예기치 못한 코드

접근 제어자를 통해 코드의 호출 순서 등을 핸들링하고, 값을 설정한다고 한들 접근 제어자에 대한 조건을 완화할 수 있기 때문에 의도와는 다른 코드에서 변경이 발생할 수 있습니다.

이는 예기치 못한 코드의 변경을 야기할 수 있습니다.

또한, final로 재할당을 막은 필드에도 리플렉션을 사용한다면 이를 무시할 수 있기 때문에 불변을 깨뜨릴 수 있습니다.

보안

private 접근 제어자로 감춘 상태에 대해 얼마든지 접근할 수 있으므로 외부에서 마음대로 변경이 가능해, 협력에 반드시 필요한 상태를 변경하는 등 보안을 깨뜨릴 수 있습니다.

오버헤드

리플렉션은 런타임 시 클래스의 메타데이터를 조회하고 조작하기 위해 추가 오버헤드가 발생할 수 있습니다.

테스트 코드에서의 문제점

테스트 코드에서도 동일하게 리플렉션을 사용하면 다음과 같은 문제점을 얻을 수 있습니다.

예기치 못한 코드

리플렉션을 사용한다면 애플리케이션의 흐름과는 별도의 동작을 하기 때문에 테스트 코드가 논리상 맞지 않아 복잡해집니다.

이는 시간이 흐를수록 더욱 커지는 문제점이 될 것입니다.

신뢰할 수 없는 테스트

리플렉션 기반 테스트는 해당 테스트 코드는 애플리케이션의 흐름이 아닌 테스트를 하는 특정 객체에 대해 구체적으로 알게 됩니다.

이러한 객체를 리펙토링하게 된다면, 특정 객체와 강하게 결합되어 있는 테스트 코드도 영향을 받게 되기 때문에 신뢰할 수 없는 테스트가 될 확률이 높습니다.

이러한 측면에서 리플렉션을 적용한 테스트 코드는 꾸준한 유지보수가 필요하므로 추가적인 비용이 소모됩니다.

불완전한 커버리지

리플렉션 기반 테스트는 리플렉션을 통해 조작하고 있는 특정 메소드나 필드만을 테스트할 수 있으므로 가능한 모든 흐름을 테스트할 수 없을 가능성이 큽니다.

이로 인해 누락된 테스트 범위가 발생하게 되며, 이로 인해 잠재적인 결함이 발생할 수 있습니다.

오버헤드

리플렉션은 런타임 시 클래스의 메타데이터를 조회하고 조작하기 위해 추가 오버헤드가 발생할 수 있습니다.

특히 TDD를 진행할 경우, 테스트가 최대한 빨리 진행되어야 빠른 개발이 가능하기 때문에 치명적이라고 볼 수 있습니다.

체스 미션에서의 리플렉션

이제 제가 체스 미션을 진행하면서 리플렉션을 수행하고, 경험한 문제점을 공유해보겠습니다.

Object.getClass()

저는 이번 체스 미션을 수행하면서, 처음에는 추상 클래스를 활용한 계층 구조를 통해 각 기물들을 표현했습니다.

그리고 이러한 기물들을 특정 문자로 표현하기 위해, Object.getClass()를 사용했습니다.

public enum PieceMessageConverter {

    PAWN(Pawn.class, "p"),
    INITIAL_PAWN(InitialPawn.class, "p"),
    BISHOP(Bishop.class, "b"),
    KNIGHT(Knight.class, "n"),
    ROOK(Rook.class, "r"),
    QUEEN(Queen.class, "q"),
    KING(King.class, "k");

    private static final String EMPTY_MESSAGE = ".";

    private final Class<? extends Piece> pieceType;
    private final String message;

    private static final Map<Class<? extends Piece>, String> CACHE = Arrays.stream(values())
            .collect(Collectors.toMap(
                    converter -> converter.pieceType,
                    converter -> converter.message)
            );

    PieceMessageConverter(final Class<? extends Piece> pieceType, final String message) {
        this.pieceType = pieceType;
        this.message = message;
    }

    public static String convert(final Class<? extends Piece> pieceType, final Camp camp) {
        final String message = CACHE.getOrDefault(pieceType, EMPTY_MESSAGE);

        if (Camp.BLACK.isSameCamp(camp)) {
            return message.toUpperCase();
        }
        return message;
    }
}

enum을 사용하기 위해서는 다음과 같은 코드를 작성해야 했습니다.

PieceMessageConverter.convert(piece.getClass(), piece.camp());

여기서 저는 Object.getClass()를 사용했으니 별 다른 문제가 없을 것이라고 생각했습니다.

JAVA API에서도 별다른 언급이 없기도 해서, 대수롭지 않게 사용했습니다.

다만, 지금 생각해보자면 조금 더 깊이 생각해봤어야 했습니다.


Object.getClass() 메소드는 리플렉션의 주요 측면인 객체 클래스의 메타데이터를 조회하고 핸들링할 수 있는 방법을 제공하기 때문에, 리플렉션의 일종으로 볼 수 있습니다.

Object.getClass()Class 객체를 반환하며, 이는 Class<? exnteds |x|>와 같은 형식으로 반환됩니다.

Class 객체는 내부적으로 해당 메소드를 호출한 런타임 시의 객체에 대한
이름, 슈퍼클래스, 인터페이스, 필드, 메서드, 주석과 같은 클래스에 대한 정보가 포함되어 있습니다.

결국 리플렉션과 동일하게 캡슐화된 정보를 모두 조회할 수 있는 것입니다.


코드 상으로도 생각해보면, 당연한 일이기도 합니다.

piece.getClass()로 호출하는 객체의 런타임 시 실제 타입이 Pawn이라고 하더라도, 컴파일 시점에서는 단지 Piece 타입일 뿐이며, 런타임 시점에는 이를 추론할 수 있을 뿐입니다.

이러한 상황에서 piece.getClass()를 통해 구체적인 Pawn 타입을 찾았다면, 이는 다형성을 내팽겨친 행위가 될 것이며, 현재 문맥에서 어떠한 타입인지 정확히 찾을 수 있기 때문에 캡슐화를 깨뜨린 행위가 될 것입니다.


이러한 상황에서 해결 방법은 다음과 같을 것입니다.

  • 내부적으로 PieceType을 가지고 있게 하고, isPieceType()과 같이 메소드를 통해 어떠한 기물 타입인지 확인하는 방법

제 경우 상속을 통해 구체적인 기물을 Pawn, Rook, Queen과 같이 Piece의 하위 클래스로 표현한 상태였는데, 이 상태에서 PieceType를 통해 각 기물을 외부에서 확인할 수 있도록 한다면 하위 클래스의 의미와 PieceType의 의미가 중복된다고 느꼈습니다.

그래서 이후 조합으로 변경했고, 개인적으로는 만족하고 있습니다.

테스트 코드에서의 리플렉션

초기 상태의 체스 판에서, 룩의 움직임을 테스트하기 위해 다음과 같은 경우를 검증하고자 했습니다.

  • 목적지에 아군 기물이 있는 상황
  • 목적지에 적군 / 빈 칸이 있는 상황
  • 경로에 아군 / 적군 기물이 있는 상황
  • 정상적인 방향인 상하좌우로 이동하는 상황
  • 정상적이지 않은 방향인 대각선 이동하는 상황

이러다보니 이러한 상황에 따라 기물의 위치를 다양하게 초기화해야하는 상황이 발생했습니다.

이를 하나씩 정상적으로 move() 메소드를 통해 이동시키기는 굉장히 귀찮았고, 여기서 리플렉션을 적용해 간단하게 초기화를 해줬습니다.


이후 Step 3에서 다음과 같은 요구사항이 추가되었습니다.

King이 잡혔을 때 게임을 종료해야 한다.

저는 이를 매 턴이 진행된 이후, 다음 턴의 진영에 킹이 있는지 확인하는 방식으로 처리하고자 했습니다.

기능을 구현한 뒤 테스트를 해보니, 엉뚱한 룩이 움직이는 로직 중 일부분이 깨지기 시작했습니다.

테스트가 전체가 깨지는 것이 아니라, @ParameterizedTest로 테스트하는 일부 케이스가 깨지기 시작했습니다.

도저히 감이 잡히지 않아 한참을 디버깅해본 결과, 깨지는 경우는 특정 진영의 킹이 없을 때였습니다.

리플렉션을 통해 제가 일부 테스트 케이스에서 킹을 지워버린 것이었습니다.
킹이 없으니 움직임을 테스트하기도 전에 게임이 끝나버리기 때문에 깨진 것이었습니다.

이로 인해 테스트에서 리플렉션을 사용할 때의 문제점을 고루 느낄 수 있었고, 이번 미션에서는 시간상의 문제로 어쩔 수 없지 진행했지만 앞으로는 리플렉션을 최대한 지양하자고 결심했습니다.

결론

리플렉션은 대단히 위험하기 때문에, 심사숙고한 뒤에 사용하는 것이 옳다고 생각합니다.

다만 현재 레벨 1을 막 끝낸 시점에서는 리플렉션을 활용하면서까지 수행할 복잡한 로직이 없기 때문에, 왠만하면 사용하지 않는 방향으로 진행할 것 같습니다.

profile
안녕하세요

0개의 댓글