본문 바로가기

Language/Java

Java Annotation

Java에서 @를 가진 식별자는 컴파일러가 애너테이션으로 해석한다. 애너테이션은 메서드, 생성자, 필드, 클래스 뿐만 아니라 매개변수, 예외처리 또는 흔하지는 않지만 코드 내 위치에 나타내게 된다.

@Service
public class HttpService implements RemoteService {
    @NonNull
    private final Integer port;
    
    @Autowired
    public HttpService(@Qualifier("port") Integer port) {this.port = port;}
    
    @Override
    Response sendRequest(String req) throws @Critical NetworkException{}
}

위와 같이 작성한 애너테이션은 자체적인 기능은 없고 프로그램에 어떠한 영향을 끼치지도 않는다.
그저 타깃의 메타데이터 같은 추가적인 정보나 전체 프로그램 관련 정보를 제공하는 Java의 한 방법으로 사용된다.

이러한 애너테이션은 단순한 주석 정도로 생각할 수도 있지만 사실 컴파일러나 컴파일 타임 도구와 통합해 버그 및 오류를 감지하거나, 로직의 흐름을 제어할 수도 있고, 애너테이션을 JVM에 넣고 Reflection을 이용해서 런타임 동안 해당 애너테이션을 사용한 객체에 접근할 수 있게 해줄 수도 있다.

 

애너테이션의 선언

애너테이션의 선언은 인터페이스와 매우 유사하다.

// java.lang.annotaion.Annotation interface extends
@interface UserInfo {
    String name() default "uheejoon";
    int age() default 20;
    Gender gender() default Gender.MALE;
}

애너테이션의 내부 요소는 사용자가 값을 명확하게 설정하지 않은 경우 사용할 기본 값을 설정할 수 있다. 

애너테이션이 가질 수 있는 타입에는 아래와 같은 것들이 있다.

  • 원시 타입
  • 문자열
  • Enums
  • Class<?>
  • 다른 애너테이션
  • 위에 나온 타입들의 배열

일반적으로 JVM은 애너테이션을 무시하기에 Java Reflection에서 모든 애너테이션을 볼 수 없다. 따라서 애너테이션의 가시성과 여러 특성을 제어하기 위해 특별한 메타 애너테이션을 붙인다. 

메타 애너테이션

메타 애너테이션은 다른 애너테이션에 적용되는 애너테이션을 말한다. 여러 메타 애너테이션 중 가장 중요한 것은 보존 애너테이션인 @Retention이다. @Retention은 적용된 애너테이션을 어떻게, 얼마나 저장할 것인지 알게해준다. 

@Retention은 RetentionPolicy라는 enum 타입의 값을 받으며 RetentionPolicy은 3가지 값을 갖는다.

  1. RetentionPolicy.SOURCE - 컴파일러가 버릴 애너테이션 표시
    주로 IDE나 컴파일러가 오류나 경고를 보내는 것을 도울 뿐 컴파일링 이후에는 프로그램에 영향을 끼치지 않는다.

  2. RetentionPolicy.CLASS - 컴파일링 뒤 클래스 파일에 표시된 애너테이션을 저장하되 JVM이 런타임 동안에는 무시
    모든 애너테이션의 기본 값이며 보존 메타 애너테이션으로 표시되지 않는다. 

  3. RetentionPolicy.RUNTIME - 컴파일러에게 클래스 파일에 표시된 애너테이션을 기록하게 하고 런타임동안 JVM이 애너테이션을 사용
    Java Refletion을 사용해 커스텀 애너테이션을 사용하고 싶을 때 사용할 수 있게 하는 보존 정책이다.
// java.lang.annotaion.Annotation interface extends
@Retention(RetentionPolicy.RUNTIME)
@interface UserInfo {
    String name() default "uheejoon";
    int age() default 20;
    Gender gender() default Gender.MALE;
}

위 내용을 토대로 런타임에 UserInfo라는 애너테이션을 사용하고 싶으면 RetentionPolicy.RUNTIME을 사용하면 된다.

 

그 다음으로 중요한 메타 애너테이션으로는 @Target이 있다. @Target 애너테이션은 애너테이션이 적용될 수 있는 타깃을 제한 하며 타깃 값은 하나 이상의 enum타입의 값을 사용한다. enum의 종류에는 

ElementType.(ANNOTATION_TYPE, CONSTRUCTOR, FIELD, METHOD, PARAMETER, TYPE) 등이 있고 이 값들을 사용해서 매개변수, 필드, 지역변수에만  사용 가능한 커스텀 애너테이션을 만들 수 있다.

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@interface UserInfo {
    String name() default "uheejoon";
    int age() default 20;
    Gender gender() default Gender.MALE;
}

Java Reflection과 Annotation

Class, Field, Method, Constructor, Parameter 객체에 사용 가능한 isAnnotationPresent(Class<? extends Annotation>)는 애너테이션 타입을 인수로 삼아 해당 애너테이션이 사용되었으면 true를 반환고 <T extends Annotation> T getAnnotation(Class<T> annotationCalss)는 사용된 애너테이션을 가져온다.

 

getAnnotation()을 사용해 객체를 실제로 요청하면 JVM이 런타임 동안 타깃에 존재하는 애너테이션을 분석하고 해당 애너테이션을 구현하는 동적 프록시 객체를 생성해 가져와 애너테이션에 선언했던 name(), age(), gender()와 같은 요소들은 다른 메서드들 처럼 호출이 가능한 메서드가 된다. 이때 메서드의 반환 값은 할당한 값 또는 default로 선언한 값을 가져오게된다. 

 

이제 Java 애너테이션과 Reflection을 사용해서 SqlQuery를 생성해주는 SqlQueryBuilder를 Refactoring해보자.

public class SqlQueryBuilder {
    // 기본 조회 쿼리 생성
    public String selectStatementBuilder(String tableName, List<String> columnNames) {
        String columnsString = columnNames.isEmpty() ? "*" : String.join(",", columnNames);

        return String.format("SELECT %s FROM %s", columnsString, tableName);
    }
    // 조건 추가
    public String addWhereClause(String query, List<String> ids) {
        if (ids.isEmpty()) {
            return query;
        }

        return String.format("%s WHERE id IN (%s)", query, String.join(",", ids));
    }
    // 정렬 추가
    public String addOrderBy(String query, String orderBy) {
        if (orderBy == null || orderBy.isEmpty()) {
            return query;
        }

        return String.format("%s ORDER BY %s", query, orderBy);
    }
    // limit 구문 추가
    public String addLimit(String query, Integer limit) {
        if (limit == null || limit == 0) {
            return query;
        }

        if (limit < 0) {
            throw new RuntimeException("limit cannot be negative");
        }

        return String.format("%s LIMIT %d", query, limit);
    }
}

기존에 있는 SqlQueryBuilder는 위와 같이 생겼다. 이제 위 객체를 통해서 query를 생성하고 사용하는 구문을 작성하면

SqlQueryBuilder sqlQueryBuilder = new SqlQueryBuilder();
String selectFrom = sqlQueryBuilder.selectStatementBuilder("MOVIE", Arrays.asList("Id", "Name"));
String addWhere = sqlQueryBuilder.addWhereClause(selectFrom, Arrays.asList("1", "2", "3"));
String addOrderBy = sqlQueryBuilder.addOrderBy(addWhere, "DESC");
String result = sqlQueryBuilder.addLimit(addOrderBy, 10);

/**

result: SELECT Id, Name FROM MOVIE WHERE id IN (1, 2, 3) ORDER BY DESC LIMIT 10

*/

 

위와 같이 원하는대로 잘 나오지만 SqlQueryBulider를 사용하는 곳이 많고 SqlQueryBuilder에 잦은 변화가있다면 유지보수하기에 많은 불편함을 느낄 것이다. 이제 위 코드에 Annotation과 Reflection을 추가해서 알아서 결과를 생성하도록 바꿔보자. 

 

새롭게 추가할 애너테이션과 Reflection의 역할은 다음과 같다.

먼저, 생성자를 통해 조회할 컬럼, 테이블 명, 정렬 방향 등 정보를 필드 변수로 담는다.
1. 애너 테이션을 통해 필드 변수에 담긴 값을 함수의 매개변수에 주입한다.
2. 애너 테이션을 통해 각각의 메서드들의 흐름을 제어한다 

위 2개를 토대로 애너테이션과 Reflection을 사용해서 몸과 마음이 편한 코드로 바꿔보자. 

 

우선 애너테이션을 정의하자.

public class Annotations {
    // 필드 변수를 파라미터에 주입 시키기 위한 애너테이션
    @Target({ElementType.FIELD, ElementType.PARAMETER})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface Input {
        String value();
    }

    // query build의 흐름 제어 - 시작 지점 제공
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface Operation {
        String value();
    }

    // query build의 흐름 제어 - 제공 받은 지점으로 부터 새로운 query 생성
    @Target(ElementType.PARAMETER)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface DependsOn {
        String value();
    }

    // query build의 마지막 부분
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface FinalResult {
    }
}

@Input은 필드와 메서드의 파라미터에 선언해 필드 변수의 값을 파라미터에 주입 시켜줄 것이고

@Operation과 @DependsOn은 한 세트로 사용해 선행 메서드, 후행 메서드를 선언 시켜줄 것이다. 다시 말해 @Operation의 결과를 @DependsOn에서 받아서 사용할 것이다.

@FinalResult는 메서드 플로우의 종결부에 해당한다.

 

각각의 애너테이션 역할에 유념하여 SqlQueryBuilder 클래스에 적용시켜보자.

public class SqlQueryBuilder {
    // 검색할 id
    @Input("ids")
    private final List<String> ids;
    // limit 개수
    @Input("limit")
    private final Integer limit;
    // table 이름
    @Input("tableName")
    private final String tableName;
    // 조회할 컬럼 이름
    @Input("columnNames")
    private final List<String> columnNames;
    // 정렬 방향
    @Input("orderBy")
    private final String orderBy;

    public SqlQueryBuilder(List<String> ids, Integer limit, String tableName, List<String> columnNames, String orderBy) {
        this.ids = ids;
        this.limit = limit;
        this.tableName = tableName;
        this.columnNames = columnNames;
        this.orderBy = orderBy;
    }

    // 기본 조회 쿼리 생성
    @Operation("selectState")
    public String selectStatementBuilder(@Input("tableName") String tableName, @Input("columnNames") List<String> columnNames) {
        String columnsString = columnNames.isEmpty() ? "*" : String.join(", ", columnNames);

        return String.format("SELECT %s FROM %s", columnsString, tableName);
    }
    // 조건 추가
    @Operation("addWhereClause")
    public String addWhereClause(@DependsOn("selectState") String query, @Input("ids") List<String> ids) {
        if (ids.isEmpty()) {
            return query;
        }

        return String.format("%s WHERE id IN (%s)", query, String.join(", ", ids));
    }
    // 정렬 추가
    @Operation("addOrderBy")
    public String addOrderBy(@DependsOn("addWhereClause") String query, @Input("orderBy") String orderBy) {
        if (orderBy == null || orderBy.isEmpty()) {
            return query;
        }

        return String.format("%s ORDER BY %s", query, orderBy);
    }
    // limit 구문 추가
    @FinalResult
    public String addLimit(@DependsOn("addOrderBy") String query, @Input("limit") Integer limit) {
        if (limit == null || limit == 0) {
            return query;
        }

        if (limit < 0) {
            throw new RuntimeException("limit cannot be negative");
        }

        return String.format("%s LIMIT %d", query, limit);
    }
}

 이제 매서드, 파라미터, 필드 간의 연관 관계를 설정했고 이를 활용해서 로직을 작성해보자.

 

먼저 @FinalResult, @Operation , @Input 이 선언된 메서드와 필드들을 가져오는 함수를 작성하자.

// @Input이 작성된 필드의 정보 - Map으로 가져오기
private Map<String, Field> getInputToField(Class<?> clazz) {
  Map<String, Field> inputNameToField = new HashMap<>();

  for (Field field : clazz.getDeclaredFields()) {
    if (!field.isAnnotationPresent(Input.class)) {
      continue;
    }

    Input input = field.getAnnotation(Input.class);
    inputNameToField.put(input.value(), field);
  }

  return inputNameToField;
}
// @Operation이 작성된 메서드의 정보 - Map으로 가져오기
private Map<String, Method> getOperationToMethod(Class<?> clazz) {
  Map<String, Method> operationNameToMethod = new HashMap<>();

  for (Method method : clazz.getDeclaredMethods()) {
    if (!method.isAnnotationPresent(Operation.class)) {
      continue;
    }

    Operation operation = method.getAnnotation(Operation.class);
    operationNameToMethod.put(operation.value(), method);
  }
  return operationNameToMethod;
}
// @FinalResult이 작성된 메서드의 정보 가져오기
private Method findFinalResultMethod(Class<?> clazz) {
  for (Method method : clazz.getDeclaredMethods()) {
    if (method.isAnnotationPresent(FinalResult.class)) {
      return method;
    }
  }

  throw new RuntimeException("No method found with FinalResult annotation");
}

 

각각의 정보들을 가져왔으면 이제 핵심 로직을 작성해야한다. 

 

구현에 있어 제일 까다로운 부분은 @DependsOn이 있는 파라미터에 @Operation이 있는 메서드의 결과 값을 넣어줘야 한다는 것이다. 이 점을 해결하기 위해서는 @FinalResult가 있는 메서드의 @DependsOn부터 해서 @DependsOn이 없는 메서드가 나올 때까지 재귀로 구현해주어야한다. 코드는 아래와 같다.

/**
instance -> SqlQueryBuilder 객체
currentMethod -> @FinalResult가 있는 메서드 부터 
operationMethod -> @Operation이 있는 메서드의 정보
inputToField -> @Input이 있는 필드 변수들의 정보
*/
private Object executeWithDependencies(Object instance,
                                              Method currentMethod,
                                              Map<String, Method> operationToMethod,
                                              Map<String, Field> inputToField) {
  List<Object> parameterValues = new ArrayList<>(currentMethod.getParameterCount());
  // @Operation이 작성된 메서드의 파라미터들을 가져오고
  for (Parameter parameter : currentMethod.getParameters()) {
    Object value = null;
    // @DependsOn이 파라미터에 존재하면
    if (parameter.isAnnotationPresent(DependsOn.class)) {
      // 어떤 @Operation과 연결되어있는지 찾고
      String dependencyOperationName = parameter.getAnnotation(DependsOn.class).value();
      Method dependencyMethod = operationToMethod.get(dependencyOperationName);
      
      // 해당 @Operation이 작성된 메서드에서 똑같은 로직을 실행한다. - 메서드를 실행한 값이 들어오게 됨
      value = executeWithDependencies(instance, dependencyMethod, operationToMethod, inputToField);
      // 파라미터에 @Input 이 있으면
    } else if (parameter.isAnnotationPresent(Input.class)) {
      // @Input의 값을 가져와
      String inputName = parameter.getAnnotation(Input.class).value();
      // 일치하는 필드를 가져오고
      Field field = inputToField.get(inputName);
      field.setAccessible(true);
      // 해당 필드에 있는 값을 넣어준다.
      value = field.get(instance);
    }
    // 각 메서드 파라미터들을 배열로 담고
    parameterValues.add(value);
  }
  // 해당 메서드를 실행시킨다.
  return currentMethod.invoke(instance, parameterValues.toArray());
}
// 실질적 로직 실행 함수
public <T> T execute(Object instance) {
  Class<?> clazz = instance.getClass();

  Map<String, Method> operationToMethod = getOperationToMethod(clazz);
  Map<String, Field> inputToField = getInputToField(clazz);

  Method finalResultMethod = findFinalResultMethod(clazz);
  return (T) executeWithDependencies(instance, finalResultMethod, operationToMethod, inputToField);
}

이제 위의 메서드들을 실행해보자.

SqlQueryBuilder sqlQueryBuilder = new SqlQueryBuilder(
  Arrays.asList("1", "2", "3"), // ids
  10, // limits
  "Movies", //tableName
  Arrays.asList("Id", "Name"), //columns
  "DESC" // orderBy
);

String sqlQuery = execute(sqlQueryBuilder);

/**

result: SELECT Id, Name FROM Movies WHERE id IN (1, 2, 3) ORDER BY DESC LIMIT 10

*/

문제 없이 잘 나오는 것을 볼 수가 있다.

만약 여기서 ids에 해당하는 로직을 제외하고 싶다면 해당 파라미터에 빈 값을 넣어주면 된다.

SqlQueryBuilder sqlQueryBuilder = new SqlQueryBuilder(
  Arrays.asList(), // ids
  10, // limits
  "Movies", //tableName
  Arrays.asList("Id", "Name"), //columns
  "DESC" // orderBy
);

/**

SELECT Id, Name FROM Movies ORDER BY DESC LIMIT 10

*/

'Language > Java' 카테고리의 다른 글

[Java] Collection 과 Collections  (0) 2025.03.03
Java 런타임 데이터 영역  (0) 2024.10.08
Java native 키워드로 Rust코드와 연동하기  (2) 2024.09.19
Java Modifier(제어자)  (1) 2024.09.17
Java Reflection(3)  (1) 2024.09.15