관리 메뉴

개발공부기록

애노테이션, 애노테이션이 필요한 이유, 애노테이션 정의, 메타 애노테이션, 애노테이션과 상속, 애노테이션 활용 - 검증기, 자바 기본 애노테이션 본문

자바 로드맵 강의/고급 2 - 입출력, 네트워크, 리플렉션

애노테이션, 애노테이션이 필요한 이유, 애노테이션 정의, 메타 애노테이션, 애노테이션과 상속, 애노테이션 활용 - 검증기, 자바 기본 애노테이션

소소한나구리 2025. 3. 6. 17:30
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급2편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


애노테이션이 필요한 이유

리플렉션 서블릿의 남은 문제점

  • 리플렉션 서블릿은 요청 URL과 메서드 이름이 같다면 해당 메서드를 동적으로 호출할 수 있는데 요청 URL과 메서드 이름을 다르게 하고 싶다면 지금 만든 기능으로는 구현이 불가능함
  • 그리고 앞서 /, /favicon.ico와 같이 자바 메서드 이름으로 처리하기 어려운 URL도 해결이 불가능함
  • 자바는 카멜케이스로 메서드 이름을 사용하지만 URL은 주로 -(dash)를 구분자로 사용하기 때문에 /add-member와 같은 URL을 매핑할 수 없음

리플렉션 서블릿의 남은 문제점들을 해결하려면 메서드 이름만으로는 해결이 어렵기 때문에 추가 정보를 코드 어딘가에 적어두고 읽을 수 있어야 함

아래의 코드처럼 만약 리플렉션 같은 기술로 메서드 이름뿐만 아니라 주석까지 읽어서 처리할 수 있다면 해당 메서드의 주석을 읽어서 URL 경로와 비교한 뒤에 같다면 해당 주석이 달린 메서드를 호출하는 식으로 해결을 해볼 수 있을 것임

그러나 주석은 코드가 아니기 때문에 컴파일 시점에 모두 제거되는데, 자바는 프로그램 실행 중에 읽어서 사용할 수 있는 주석인 애노테이션을 제공함

public class Controller {

    // "/site1"
    public void page1(HttpRequest request, HttpResponse response) {
    }
    
    // "/"
    public void home(HttpRequest request, HttpResponse response) {
        response.writeBody("<h1>site2</h1>");
    }

    // "/add-member"
    public void addMember(HttpRequest request, HttpResponse response) {
    }
}

애노테이션 예제

SimpleMapping

간단한 예제를 통해 애노테이션을 활용하여 고민한 문제를 해결하는지 알아보기

package annotation.mapping;

@Retention(RetentionPolicy.RUNTIME)
public @interface SimpleMapping {
    String value();
}
  • 애노테이션은 @interface 키워드를 사용해서 만듦
  • 내부에 String value라는 속성을 하나 가지는 @SimpleMapping이라는 애노테이션을 생성함
  • @Retention은 뒤에서 설명(메타 애노테이션)함, 지금은 필수로 사용해야 하는 값 정도로 생각하면 됨

TestController

package annotation.mapping;

public class TestController {
    
    @SimpleMapping(value = "/")
    public void home() {
        System.out.println("TestController.home");
    }
    
    @SimpleMapping(value = "/site1")
    public void page1() {
        System.out.println("TestController.page1");
    }
}
  • 애노테이션을 사용할 때는 @ 기호로 시작함
  • home()에는 @SimpleMapping(value = "/") 애노테이션을 붙이고 page1()에는 @SimpleMapping(value = "/site1") 애노테이션을 붙임

애노테이션은 프로그램 코드가 아니므로 애노테이션이 붙어있는 home(), page1() 같은 코드를 호출해도 프로그램에는 아무런 영향을 주지 않음

마치 주석과 비슷하다고 이해하면 되는데 일반적인 주석이 아니라 리플렉션 같은 기술로 실행 시점에 읽어서 활용할 수 있는 특별한 주석임

 

TestControllerMain

package annotation.mapping;

public class TestControllerMain {
    public static void main(String[] args) {
        TestController testController = new TestController();

        Class<? extends TestController> aClass = testController.getClass();
        for (Method method : aClass.getDeclaredMethods()) {
            SimpleMapping simpleMapping = method.getAnnotation(SimpleMapping.class);
            if (simpleMapping != null) {
                System.out.println("[" + simpleMapping.value() + "] -> " + method);
            }
        }
    }
}
/* 실행 결과
[/] -> public void annotation.mapping.TestController.home()
[/site1] -> public void annotation.mapping.TestController.page1()
*/
  • TestController 클래스의 선언된 메서드를 찾음
  • 리플렉션이 제공하는 getAnnotation() 메서드를 사용하면 붙어있는 애노테이션을 찾을 수 있음
    • Class, Method, Field, Constructor 클래스는 자신에게 붙은 애노테이션을 찾을 수 있는 getAnnotation() 메서드를 제공함
    • 예제에서는 Method.getAnnotation(SimpleMapping.class)을 사용했으므로 해당 메서드에 붙은 @SimpleMapping 애노테이션을 찾을 수 있음
  • simpleMapping.value()를 사용하여 찾은 애노테이션에 지정된 값을 조회할 수 있음
  • 실행 결과를 보면 home() 메서드에 붙은 애노테이션과 page1()의 애노테이션의 정보가 출력되는 것을 확인할 수 있음

이 예제를 통해 알 수 있듯이 리플렉션 서블릿에서 해결하지 못했던 문제들을 애노테이션을 활용하면 해결할 수 있음

 

** 참고 - 애노테이션 단어

  • 자바 애노테이션(Annotation)의 영어 단어의 뜻은 일반적으로 주석, 메모를 의미함
  • 애노테이션은 코드에 추가적인 정보를 주석처럼 제공하는데 일반 주석과 달리 컴파일러나 런타임에서 해석될 수 있는 메타데이터를 제공함
  • 즉, 애노테이션은 코드에 메모를 달아놓는 것처럼 특정 정보나 지시를 추가하는 도구로 코드에 대한 메타데이터를 표현하는 방법이며 "애노테이션" 이라는 이름은 코드에 대한 추가적인 정보를 주석처럼 달아놓는다는 뜻임

애노테이션 정의

애노테이션 정의 규칙

AnnoElement

package annotation.basic;

@Retention(RetentionPolicy.RUNTIME)
public @interface AnnoElement {
    String value();
    int count() default 0;
    String[] tags() default {};

//    MyLogger data();  // 직접 만든 타입은 적용 불가
    Class<? extends MyLogger> annoData() default MyLogger.class;    // 클래스 정보는 가능
}
  • 애노테이션은 @interface 키워드로 정의함
  • 속성을 가질 수 있는데 인터페이스와 비슷하게 정의함

데이터 타입

  • 기본 타입(int, float, boolean 등)
  • String
  • Class(메타데이터) 또는 인터페이스
  • enum
  • 다른 애노테이션 타입
  • 위에서 나열한 타입들의 배열
  • 그 외에는 정의할 수 없음, 즉 일반적인 클래스(직접 정의한)를 사용할 수 없음

default 값

  • 요소에 default 값을 지정할 수 있음
  • ex) String value() default "기본 값을 적용합니다.";

요소 이름

  • 메서드 형태로 정의되며 괄호()를 포함하되 매개변수는 없어야 함

반환 값

  • void를 반환 타입으로 사용할 수 없음

예외

  • 예외를 선언할 수 없음

특별한 요소 이름

  • value라는 이름의 요소를 하나만 가질 경우 애노테이션 사용 시 요소 이름을 생략할 수 있음

애노테이션 사용

ElementData1

package annotation.basic;

@AnnoElement(value = "data", count = 10, tags = {"t1", "t2"})
public class ElementData1 {
}
  • 위에서 생성한 AnnoElement 애노테이션을 사용하여 각 속성에 값을 지정
  • 배열은 {}를 사용하여 값을 지정하면 됨

ElementData1Main

package annotation.basic;

public class ElementData1Main {
    public static void main(String[] args) {
        Class<ElementData1> annoClass = ElementData1.class;
        AnnoElement annotation = annoClass.getAnnotation(AnnoElement.class);

        System.out.println("annotation.value() = " + annotation.value());
        System.out.println("annotation = " + annotation.count());
        System.out.println("annotation.tags() = " + Arrays.toString(annotation.tags()));
    }
}
/* 실행 결과
annotation.value() = data
annotation = 10
annotation.tags() = [t1, t2]
*/
  • 리플렉션을 통해 조회한 애노테이션으로 애노테이션에 지정한 속성을 조회하면 애노테이션을 선언할 때 입력한 값들이 조회되는 것을 확인할 수 있음

ElementData2

package annotation.basic;

@AnnoElement(value = "data2", tags = "t1")
public class ElementData2 {
}
  • default 값이 있는 속성은 생략할 수 있음
  • 배열의 항목이 하나인 경우 {}를 생략할 수 있음

ElementData3

@AnnoElement("data3")
public class ElementData3 {
}

 

  • 자바에서는 입력 요소가 하나인 경우 value 키워드를 생략할 수 있도록 지원함
  • value = "data"라고 입력한 것과 동일함

메타 애노테이션

종류 및 설명

애노테이션을 정의하는 데 사용하는 특별한 애노테이션을 메타 애노테이션이라 함

 

@Retention

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Retention {
    RetentionPolicy value();
}

public enum RetentionPolicy {
    SOURCE,
    CLASS,
    RUNTIME
}
  • 애노테이션의 생존 기간을 지정함
  • RetentionPolicy.SOURCE: 소스 코드에만 남아있으며 컴파일 시점에 제거됨, 특별한 경우에 사용
  • RetentionPolicy.CLASS: 컴파일 후 class 파일까지는 남아있지만 자바 실행 시점에 제거됨(기본 값), 잘 사용하지 않음
  • RetentionPolicy.RUNTIME: 자바 실행 중에도 남아있음, 대부분 이 설정을 사용하며 리플렉션을 활용하려면 이 설정을 사용해야 함

@Target

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Target {
    ElementType[] value();
}

public enum ElementType {
    TYPE,
    FIELD,
    METHOD,
    PARAMETER,
    CONSTRUCTOR,
    LOCAL_VARIABLE,
    ANNOTATION_TYPE,
    PACKAGE,
    TYPE_PARAMETER,
    TYPE_USE,
    MODULE,
    RECORD_COMPONENT;
}
  • 애노테이션을 적용할 수 있는 위치를 지정함
  • ElementType의 이름만으로도 충분히 애노테이션을 어디에 적용해야 하는지 알 수 있으며 주로 TYPE(클래스, 인터페이스, enum 등), FILED, METHOD를 사용함
  • @Target을 생략하면 모든 곳에 적용할 수 있으며 배열이기 때문에 여러 개를 지정할 수 있음

@Documented

자바 API 문서를 만들 때 해당 애노테이션이 함께 포함되는지 지정함, 보통 함께 사용함

자바 API 문서 만드는 방법은 검색

 

@Inherited

자식 클래스가 애노테이션을 상속받을 수 있음, 뒤에서 더 자세히 설명함

적용 예시

AnnoMeta

package annotation.basic;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@Documented
public @interface AnnoMeta {
}
  • @Retention: RetentionPolicy.RUNTIME을 적용하여 자바가 실행 중에도 애노테이션 정보가 남아있으므로 런타임에 리플렉션을 통해서 읽을 수 있음, SOURCE, CLASS는 자바 실행 시점에 애노테이션이 사라지므로 리플렉션을 통해서 읽을 수 없음
  • @Target: ElementType.METHOD, ElementType.TYPE을 적용하여 메서드와 타입(클래스, 인터페이스, enum 등)에 @AnnoMeta 애노테이션을 적용할 수 있음, 다른 곳에 적용하면 컴파일 오류가 발생함
  • @Documented: 자바 API 문서를 만들 때 해당 애노테이션이 포함됨

MetaData

package annotation.basic;

@AnnoMeta   // 타입에 적용
public class MetaData {

//    @AnnoMeta   // 필드에 적용 -> 컴파일 오류 발생
    private String id;

    @AnnoMeta   // 메서드에 적용
    public void call() {

    }

    public static void main(String[] args) throws NoSuchMethodException {
        AnnoMeta typeAnno = MetaData.class.getAnnotation(AnnoMeta.class);
        System.out.println("typeAnno = " + typeAnno);

        AnnoMeta methodAnno = MetaData.class.getMethod("call").getAnnotation(AnnoMeta.class);
        System.out.println("methodAnno = " + methodAnno);
    }
}
/* 실행 결과
typeAnno = @annotation.basic.AnnoMeta()
methodAnno = @annotation.basic.AnnoMeta()
*/
  • 직접 만든 @AnnoMeta를 적용해 보면 @Target의 설정처럼 메서드와 타입에만 애노테이션이 적용되고 필드에 적용해보면 컴파일 오류가 발생하는 것을 확인할 수 있음
  • 해당 애노테이션이 RetentionPolicy.RUNTIME으로 설정되어 리플렉션을 통해 타입에 적용한 애노테이션 정보와 메서드에 적용한 애노테이션 정보를 조회할 수 있는 것을 확인할 수 있으며 다른 설정으로 바꾸면 애노테이션을 찾을 수 없어 null이 출력되는 것을 확인할 수 있음

애노테이션과 상속

애노테이션의 상속

Annotation

모든 애노테이션은 java.lang.annotation.Annotation 인터페이스를 묵시적으로 상속받음

java.lang.annotation.Annotation 인터페이스는 개발자가 직접 구현하거나 확장할 수 있는 것이 아니라 자바 언어 자체에서 애노테이션을 위한 기반으로 사용되며 아래와 같은 기능을 제공함

package java.lang.annotation;

public interface Annotation {
    boolean equals(Object obj);
    int hashCode();
    String toString();
    Class<? extends Annotation> annotationType();
}
  • boolean equals(Object obj): 두 애노테이션의 동일성을 비교
  • int hashCode(): 애노테이션의 해시코드를 반환함
  • String toString(): 애노테이션의 문자열 표현을 반환함
  • Class<? extends Annotation> annotationType(): 애노테이션의 타입을 반환함

모든 애노테이션은 기본적으로 Annotation 인터페이스를 확장하며, 이로 인해 자바에서 애노테이션은 특별한 형태의 인터페이스로 간주됨

자바에서 애노테이션을 정의할 때 개발자가 명시적으로 Annotation 인터페이스를 상속하거나 구현할 필요는 없는데 애노테이션을 @interface 키워드를 통해 정의하면 자바 컴파일러가 자동으로 Annotation 인터페이스를 확장하도록 처리해 줌

 

// 애노테이션 정의
public @interface MyCustomAnnotation {}

// 자바가 자동으로 처리
public interface MyCustomAnnotation extends java.lang.annotation.Annotation {}

애노테이션과 상속

  • 애노테이션은 다른 애노테이션이나 인터페이스를 직접 상속할 수 없으며 오직 java.lang.annotation.Annotation 인터페이스만 상속함
  • 따라서 애노테이션 사이에는 상속이라는 개념이 존재하지 않음

@Inherited

애노테이션을 정의할 때 @Inherited 메타 애노테이션을 붙이면 애노테이션을 적용한 클래스의 자식도 해당 애노테이션을 부여받을 수 있음

단, 이 기능은 클래스 상속에서만 작동하고 인터페이스의 구현체에는 적용되지 않으므로 주의해야 함

 

InheritedAnnotation, NoInheritedAnnotation

package annotation.basic.inherited;

@Inherited
@Retention(RetentionPolicy.RUNTIME)
public @interface InheritedAnnotation {
}

@Retention(RetentionPolicy.RUNTIME)
public @interface NoInheritedAnnotation {
}
  • InheritedAnnotation: @Inherited이 있는 애노테이션
  • NoInheritedAnnotation: @Inherited이 없는 애노테이션

Parent, Child

package annotation.basic.inherited;

@InheritedAnnotation
@NoInheritedAnnotation
public class Parent {
}

public class Child extends Parent {
}
  • Parent 클래스: @Inherited가 적용된 @InheritedAnnotation과 @Inherited가 없는 @NoInheritedAnnotation이 둘 다 적용되어 있음
  • Child 클래스: Parent 클래스를 상속받았으므로 @Inheritedannotation을 상속받으며 @Inherited가 없는 @NoInheritedAnnotation은 상속받지 못함

TestInterface, TestInterfaceImpl

package annotation.basic.inherited;

@InheritedAnnotation
@NoInheritedAnnotation
public interface TestInterface {
}

public class TestInterfaceImpl implements TestInterface {
}
  • TestInterface 인터페이스: Parent 클래스와 마찬가지로 @Inherited가 적용된 애노테이션과 적용되지 않는 애노테이션이 둘 다 있음
  • TestInterfaceImpl 클래스: 인터페이스의 구현에서는 애노테이션을 상속받을 수 없으므로 애노테이션이 모두 적용되지 않음, 참고로 인터페이스 부모와 인터페이스 자식의 관계에서도 애노테이션을 상속 받을 수 없음

InheritedMain

package annotation.basic.inherited;

public class InheritedMain {
    public static void main(String[] args) {
        print(Parent.class);
        print(Child.class);
        print(TestInterface.class);
        print(TestInterfaceImpl.class);

    }

    private static void print(Class<?> clazz) {
        System.out.println("class: " + clazz);
        for (Annotation annotation : clazz.getAnnotations()) {
            System.out.println(" - " + annotation.annotationType().getSimpleName());
        }
        System.out.println();
    }
}
/* 실행 결과
class: class annotation.basic.inherited.Parent
 - InheritedAnnotation
 - NoInheritedAnnotation

class: class annotation.basic.inherited.Child
 - InheritedAnnotation

class: interface annotation.basic.inherited.TestInterface
 - InheritedAnnotation
 - NoInheritedAnnotation

class: class annotation.basic.inherited.TestInterfaceImpl
*/
  • 애노테이션 정보를 편하게 출력하기 위해 print() 메서드를 만들어서 각 클래스 정보를 출력해 보면 Parent 클래스를 상속받은 Child 클래스에는 @InheritedAnnotation을 상속받았으므로 상속받은 애노테이션이 출력됨
  • 그러나 TestInterface를 구현한 TestInterfaceImpl은 애노테이션 정보가 하나도 출력되지 않는 것을 확인할 수 있음 즉, 애노테이션을 상속받지 않았음

@Inherited가 클래스 상속에만 적용되는 이유

  • 클래스의 상속과 인터페이스 구현의 차이
    • 클래스 상속은 자식 클래스가 부모 클래스의 속성과 메서드를 상속받는 개념으로 자식 클래스는 부모 클래스의 특성을 이어받기 때문에 부모 클래스에 정의된 애노테이션을 자식 클래스가 자동으로 상속받을 수 있는 논리적인 기반이 있음
    • 그러나 인터페이스는 메서드의 시그니처만을 정의할 뿐 상태나 행위를 가지지 않으므로 인터페이스의 구현체가 애노테이션을 상속한다는 개념이 잘 맞지 않음
  • 인터페이스와 다중 구현, 다이아몬드 문제
    • 인터페이스는 다중 구현이 가능하므로 인터페이스의 애노테이션을 구현 클래스에서 상속하게 되면 여러 인터페이스의 애노테이션 간의 충돌이나 모호한 상황이 발생할 수 있음

애노테이션 활용 - 검증기

일반적인 메서드를 활용한 검증기

Team, User

package annotation.validator;

public class Team {
    private String name;
    private int memberCount;

    // 멤버 변수를 초기화하는 생성자, 게터 생성
}

public class User {
    private String name;
    private int age;

    // 멤버 변수를 초기화하는 생성자, 게터 생성
}
  • 각 멤버 변수를 초기화하는 생성자와 게터를 가지는 Team, User 클래스

ValidatorV1Main

package annotation.validator;

public class ValidatorV1Main {
    public static void main(String[] args) {
        User user = new User("user1", 0);
        Team team = new Team("", 0);

        try {
            log("== user 검증 ==");
            validateUser(user);
        } catch (Exception e) {
            log(e);
        }

        try {
            log("== team 검증 ==");
            validateTeam(team);
        } catch (Exception e) {
            log(e);
        }
    }

    private static void validateUser(User user) {
        if (user.getName() == null || user.getName().isEmpty()) {
            throw new RuntimeException("이름이 비어있습니다.");
        }
        if (user.getAge() < 1 || user.getAge() > 100) {
            throw new RuntimeException("나이는 1과 100 사이여야 합니다.");
        }
    }

    private static void validateTeam(Team team) {
        if (team.getName() == null || team.getName().isEmpty()) {
            throw new RuntimeException("이름이 비어있습니다.");
        }
        if (team.getMemberCount() < 1 || team.getMemberCount() > 999) {
            throw new RuntimeException("회원 수는 1과 999 사이여야 합니다.");
        }
    }
}
/* 실행 결과
18:23:05.048 [     main] == user 검증 ==
18:23:05.049 [     main] java.lang.RuntimeException: 나이는 1과 100 사이여야 합니다.
18:23:05.050 [     main] == team 검증 ==
18:23:05.050 [     main] java.lang.RuntimeException: 이름이 비어있습니다.
*/

 

  • 각 객체를 검증하는 메서드를 각각 만들어서 User 객체의 이름과 나이를 검증하고 Team 객체의 이름과 나이를 검증함
  • 예제에서는 값이 비었는지 검증하는 부분과 숫자의 범위를 검증하는 2가지 부분이 있는데 코드를 보면 구조가 비슷하면서도 User, Team이 서로 완전히 다른 클래스이기 때문에 재사용하기 어려움
  • 그리고 각각의 필드 이름도 서로 다르고 오류 메시지도 다르고 검증해야 할 값의 범위도 다름

만약 이후에 다른 객체들이 추가되어 검증을 계속해야 한다면 비슷한 검증 코드를 계속 추가해야 하는데 이런 문제를 애노테이션을 사용하여 해결할 수 있음

애노테이션 기반 검증기

@NotEmpty

package annotation.validator;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface NotEmpty {
    String message() default "값이 비어있습니다."
}
  • 빈 값을 검증하는 데 사용할 @NotEmpty 애노테이션을 생성
  • 검증에 실패한 경우 출력할 오류 메시지인 message()를 속성으로 가지고 있으며 기본값으로 값이 비었다는 메시지를 출력함

@Range

package annotation.validator;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Range {
    int min();
    int max();
    String message() default "범위를 넘었습니다.";
}
  • 숫자의 범위를 검증하는데 사용할 @Range 애노테이션을 생성
  • 최솟값, 최댓값을 지정할 min(), max()와 마찬가지로 검증에 실패할 오류 메시지인 message()를 가지고 있음

User, Team - 수정

  • 각 필드에 검증용 애노테이션을 추가
package annotation.validator;

public class User {
    @NotEmpty(message = "이름이 비어있습니다.")
    private String name;
    
    @Range(min = 1, max = 100, message = "나이는 1과 100 사이여야 합니다.")
    private int age;

    // 생성자, 게터 코드 생략
}

public class Team {
    @NotEmpty(message = "이름이 비어있습니다.")
    private String name;

    @Range(min = 1, max = 999, message = "회원 수는 1과 999 사이여야 합니다.")
    private int memberCount;

    // 생성자, 게터 코드 생략
}

Validator

package annotation.validator;

public class Validator {
    
    public static void validate(Object obj) throws Exception {
        Field[] fields = obj.getClass().getDeclaredFields();

        for (Field field : fields) {
            field.setAccessible(true);
            if (field.isAnnotationPresent(NotEmpty.class)) {
                String value = (String) field.get(obj);
                NotEmpty annotation = field.getAnnotation(NotEmpty.class);
                if (value == null || value.isEmpty()) {
                    throw new RuntimeException(annotation.message());
                }
            }

            if (field.isAnnotationPresent(Range.class)) {
                long value = field.getLong(obj);
                Range annotation = field.getAnnotation(Range.class);
                if (value < annotation.min() || value > annotation.max()) {
                    throw new RuntimeException(annotation.message());
                }
            }
        }
    }
}
  • isAnnotationPresent(): 전달된 객체에 선언된 필드를 모두 찾아서 @NotEmpty, @Range 애노테이션이 붙어있는지 확인함
  • 애노테이션이 있는 경우 각 애노테이션의 속성을 기반으로 검증 로직을 수행하고 만약 검증에 실패하면 애노테이션에 적용한 메시지를 예외에 담아서 던짐
    • @NotEmpty: 필드에서 조회한 값이 null이거나 isEmpty()이면 예외를 던짐
    • @Range: 필드에서 조회한 값이 애노테이션에 정의한 min보다 작거나 max보다 크면 예외가 발생함

ValidatorV2Main

package annotation.validator;

public class ValidatorV2Main {
    public static void main(String[] args) {
        User user = new User("user1", 0);
        Team team = new Team("", 0);

        try {
            log("== user 검증 ==");
            Validator.validate(user);

            log("== team 검증 ==");
            Validator.validate(team);
        } catch (Exception e) {
            log(e);
        }
    }
}
/* 실행 결과
19:05:47.359 [     main] == user 검증 ==
19:05:47.360 [     main] java.lang.RuntimeException: 나이는 1과 100 사이여야 합니다.
19:05:47.360 [     main] == team 검증 ==
19:05:47.360 [     main] java.lang.RuntimeException: 이름이 비어있습니다.
*/
  • 검증용 애노테이션과 검증기를 사용한 덕분에 어떤 객체든지 애노테이션으로 간단하게 검증할 수 있게 되었음
  • 객체를 생성해서 검증하는 코드가 모두 사라졌지만 정상적으로 검증이 잘 진행되는 것을 확인할 수 있음

적용된 애노테이션 설명

  • @NotEmpty 애노테이션을 사용하면 필드가 비었는지 여부를 편리하게 검증할 수 있고, @Range(min = 1, max = 100)과 같은 애노테이션을 통해 숫자의 범위를 쉽게 제한할 수 있음
  • 이러한 애노테이션 기반 검증 방식은 중복되는 코드 작성 없이도 유연한 검증 로직을 적용할 수 있어 유지보수성을 높여줌
  • User클래스와 Team 클래스에 각각의 필드 이름이나 메시지들이 다르더라도 애노테이션의 속성 값을 통해 필드 이름을 지정하고 오류 메시지도 일관되게 정의할 수 있음
  • 예를 들어 @NotEmpty(message = "이름은 비어 있을 수 있습니다") 처럼 명시적인 메시지를 작성할 수 있으며 이를 통해 다양한 클래스에서 공통된 검증 로직을 재사용할 수 있게 됨
  • 또한 새로 추가되는 클래스나 필드에 대해서도 복잡한 로직을 별도로 구현할 필요 없이 적절한 애노테이션을 추가하는 것만으로 검증 로직을 쉽게 확장할 수 있음
  • 이처럼 애노테이션 기반 검증을 도입하면 코드의 가독성과 확장성이 크게 향상되어 일관된 규칙을 유지할 수 있어 전체적인 품질 관리에도 도움이 되고 클래스들이 서로 다르더라도 일관되고 재사용 가능한 검증 방식을 사용할 수 있게 됨

** 참고


자바 기본 애노테이션

자바 언어가 기본으로 제공하는 애노테이션

@Override, @Deprecated, @SuppressWarnings와 같이 자바 언어가 기본으로 제공하는 애노테이션도 있음

앞서 설명한 @Retention, @Target도 자바 언어가 기본으로 제공하는 애노테이션이지만 이것은 애노테이션 자체를 정의하기 위한 메타 애노테이션이고 지금 설명하는 내용은 코드에 직접 사용하는 애노테이션임

 

@Override

  • 메서드 재정의가 정확하게 잘 되었는지 컴파일러가 체크하는 데 사용함
  • RetentionPolicy.SOURCE로 설정되어 있어 컴파일 이후에 @Override 애노테이션은 제거됨
  • @Override는 컴파일 시점에만 사용하는 애노테이션 이기 때문에 런타임에는 필요하지 않으므로 이렇게 설정되어 있음
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

OverrideMain

package annotation.java;

public class OverrideMain {

    static class A {
        public void call() {
            System.out.println("A.call");
        }
    }

    static class B extends A {

//        @Override   // 주석 풀면 컴파일 오류 발생
        public void callllll() {
            System.out.println("B.call");
        }
    }

    public static void main(String[] args) {
        A a = new B();
        a.call();
    }
}
/* 실행 결과
A.call
*/
  • B 클래스가 A클래스를 상속받아서 A.call() 메서드를 B 클래스가 재정의하려고 시도하려고 할 때 실수로 오타가 발생해서 재정의가 아니라 자식 클래스에 calllllll()이라는 새로운 메서드를 재정의 버림
  • 개발자의 의도는 A.call() 메서드의 재정의였지만 자바 언어는 이것을 알 방법이 없으므로 자바 문법상 그냥 B에 새로운 메서드가 하나 만들어졌을 뿐임
  • 이런 상황일 때 @Override 애노테이션을 사용하면 자바 컴파일러가 메서드 재정의 여부를 체크해 주어 문제가 발생하면 컴파일을 통과하지 않음
  • 개발자의 실수를 자바 컴파일러가 잡아주는 좋은 애노테이션이기 때문에 무조건 사용하는 것을 권장함

@Deprecated

더 이상 사용되지 않는다는 뜻으로 이 애노테이션이 적용된 기능은 아래와 같은 이유로 사용을 권장하지 않음

  • 해당 요소를 사용하면 오류가 발생할 가능성이 있음
  • 호환되지 않게 변경되거나 향후 버전에서 제거될 수 있음
  • 더 나은 최신 대체 요소로 대체됨
  • 더 이상 사용되지 않는 기능임
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, MODULE, PARAMETER, TYPE})
public @interface Deprecated {
    String since() default "";
    boolean forRemoval() default false;
}

DeprecatedClass

package annotation.java;

public class DeprecatedClass {

    public void call1() {
        System.out.println("DeprecatedClass.call1");
    }

    @Deprecated
    public void call2() {
        System.out.println("DeprecatedClass.call2");
    }

    @Deprecated(since = "2.4", forRemoval = true)
    public void call3() {
        System.out.println("DeprecatedClass.call3");
    }
}

 

  • @Deprecated를 call2() 메서드와 call3() 메서드에 적용함
  • since: 더 이상 사용하지 않게 된 버전 정보
  • forRemoval: 미래 버전에 코드가 제거될 예정임을 명시, 절대 사용하지 말 것

DeprecatedMain

package annotation.java;

public class DeprecatedMain {
    public static void main(String[] args) {
        System.out.println("DeprecatedMain.main");
        DeprecatedClass dc = new DeprecatedClass();
        dc.call1();
        dc.call2(); // IDE 노란줄 경고
        dc.call3(); // IDE 빨간줄 경고(심각), 컴파일 오류는 아니고 실행은 됨
    }
}
/* 실행 결과
DeprecatedMain.main
DeprecatedClass.call1
DeprecatedClass.call2
DeprecatedClass.call3
*/
  • @Deprecated만 적용되어 있는 메서드를 사용할 경우 IDE에서 경로를 나타냄, 인텔리제이의 경우 노란 줄로 표시됨
  • @Deprecated + forRemoval이 있는 경우 IDE 빨간색으로 심각한 경고를 나타내어 절대 사용하지 않도록 최대한 나타냄, 다만 컴파일 시점에 경고만 나타낼 뿐 프로그램은 동작함

@SuppressWarnings

  • 이름 그대로 경고를 억제하는 애노테이션
  • 자바 컴파일러가 문제를 경고하지만 개발자가 해당 문제를 잘 알고 있기 때문에 더는 경고하지 말라고 지시하는 애노테이션임
@Retention(RetentionPolicy.SOURCE)
public @interface SuppressWarnings {
    String[] value();
}

SuppressWarningCase

package annotation.java;

public class SuppressWarningCase {

    @SuppressWarnings("unused")
    public void unusedWarning() {
        // 사용되지 않는 변수 경고 억제
        int unusedVariable = 10;
    }

    @SuppressWarnings("deprecation")
    public void deprecatedMethod() {
        // 더 이상 사용되지 않는 메서드 호출
        Date date = new Date();
        int date1 = date.getDate(); // deprecated 된 메서드
    }

    @SuppressWarnings({"rawtypes", "unchecked"})
    public void uncheckedCast() {
        // 제네릭 타입 캐스팅 경고 억제, raw type 사용 경고
        List list = new ArrayList();

        // 제네릭 타입과 관련된 unchecked 경고
        List<String> stringList = (List<String>) list;
    }

    @SuppressWarnings("all")
    public void suppressAllWarning() {
        // 모든 경고 억제
        Date date = new Date();
        int date1 = date.getDate();
        List list = new ArrayList();
        List<String> stringList = (List<String>) list;
    }
}

@SuppressWarnings에 사용되는 대표적인 값들은 다음과 같으며, 가급적 사용을 권장하진 않지만 아주 가끔 경고가 발생해도 사용해야 하는 코드가 있을 때 사용함

  • all: 모든 경고를 억제
  • deprecation: 사용이 권장되지 않는(deprecated) 코드를 사용할 때 발생하는 경고를 억제
  • unchecked: 제네릭 타입과 관련된 unchecked 경고를 억제
  • serial: Serializable 인터페이스를 구현할 때 serialVersionUID 필드를 선언하지 않은 경우 발생하는 경고를 억제
  • rawtypes: 제네릭 타입이 명시되지 않은(raw) 타입을 사용할 때 발생하는 경고를 억제
  • unused: 사용되지 않는 변수, 메서드, 필드 등을 선언했을 때 발생하는 경고를 억제

정리

자바 백엔드 개발자가 되려면 스프링, JPA 같은 기술은 필수로 배워야 하는데 처음 스프링이나 JPA 같은 기술을 배우면 기존에 자바 문법으로는 잘 이해가 안 되는 마법 같은 일들이 벌어짐

 

이러한 프레임워크들은 리플렉션과 애노테이션을 활용하여 아래의 마법 같은 기능들을 제공함

  • 의존성 주입(Dependency Injection): 스프링은 리플렉션을 사용하여 객체의 필드나 생성자에 자동으로 의존성을 주입하여 개발자는 단순히 @autowired 애노테이션만 붙이면 됨
  • ORM(Object-Relational Mapping): JPA는 애노테이션을 사용하여 자바 객체와 데이터베이스 테이블 간의 매핑을 정의함, @Entity, @Table, @Column 등의 애노테이션으로 객체-테이블 관계를 설정함
  • AOP(Aspect-Oriented Programming): 스프링은 리플렉션을 사용하여 런타임에 코드를 동적으로 주입하고 @Aspect, @Before, @After 등의 애노테이션으로 관점 지향 프로그래밍을 구현함
  • 설정의 자동화: @Configuration, @Bean 등의 애노테이션을 사용하여 다양한 설정을 편리하게 적용함
  • 트랜잭션 관리: @Transactional 애노테이션만으로 메서드 레벨의 DB 트랜잭션 처리가 가능해짐

이러한 기능들은 개발자가 비즈니스 로직에 집중할 수 있게 해 주며 보일러플레이트(지루한 반복) 코드를 크게 줄여주는데 이 "마법"의 이면에는 리플렉션과 애노테이션을 활용한 복잡한 메타프로그래밍이 숨어 있음

프레임워크 동작 원리를 깊이 이해하기 위해서는 리플렉션과 애노테이션에 대한 이해가 필수임

이를 통해 프레임워크가 제공하는 편의성과 그 이면의 복잡성 사이의 균형을 잡을 수 있으며 필요에 따라 프레임워크를 효과적으로 커스터마이징 하거나 최적화하거나 오류가 발생했을 때 올바르게 문제에 접근할 수 있음

 

스프링이나 JPA 같은 프레임워크들은 이번에 학습한 리플렉션과 애노테이션을 극대화해서 사용함

리플렉션과 애노테이션을 배운 덕분에 이런 기술이 마법이 아니라 리플랙션과 애노테이션을 활용한 고급 프로그래밍 기법이라는 것을 이해할 수 있으며 이러한 이해를 바탕으로 프레임워크의 동작 원리를 더 깊이 파악하고 효과적으로 활용할 수 있게 될 것임

 

결론적으로 자바 백엔드 개발자가 되기 위해 스프링과 JPA를 배우는 과정에서 리플렉션과 애노테이션은 더 이상 어렵고 낯선 개념이 아니라 프레임워크의 동작 원리를 이해하고 활용하는 데 필수적인 도구임

이러한 도구들을 잘 활용하면 개발자로서 한 단계 더 성장하게 될 것이며 복잡한 문제 상황도 잘 해결할 수 있는 강력한 무기를 가지게 될 것임

728x90