참고자료
이 포스팅은 바로 이전 포스팅인 리플렉션과 굉장히 밀접한 관계가 있다. 그래서 이전 포스팅을 먼저 정독하고 이 포스팅을 보아야 한다.
2024.10.21 - [JAVA의 가장 기본이 되는 내용] - 리플렉션
애노테이션이 필요한 이유
이전 포스팅에서 리플렉션을 활용해서 HTTP 서버를 훨씬 더 사용하기 편하고 합리적으로 변경했다. 그럼에도 불구하고 남은 문제점이 있었다.
- 리플렉션 서블릿은 요청 URL과 메서드 이름이 같다면 해당 메서드를 동적으로 호출할 수 있다. 하지만, 요청 이름과 메서드 이름을 다르게 하고 싶다면 어떻게 해야 할까?
- 예를 들어, `/site1`이라고 와도 page1()과 같은 메서드를 호출하고 싶다면 어떻게 해야 할까? 메서드 이름은 더 자세히 적고 싶을 수도 있잖아?
- 앞서 `/`와 같은 자바 메서드 이름으로 처리하기 어려운 URL은 어떻게 해결할 수 있을까?
- URL은 주로 `-`를 구분자로 활용한다. `/add-member`와 같은 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) {...}
}
만약, 리플렉션 같은 기술로 메서드 이름뿐만 아니라, 주석까지 읽어서 처리할 수 있다면 좋지 않을까? 그러면 해당 메서드에 있는 주석을 읽어서 URL 경로와 비교하면 된다. 그런데 주석은 코드가 아니다. 따라서 컴파일 시점에 모두 제거된다. 만약, 프로그램 실행 중에 읽어서 사용할 수 있는 주석이 있다면 어떨까? 그게 바로 애노테이션이다.
애노테이션 예제
애노테이션에 대해서 본격적으로 알아보기 전에, 간단한 예제를 통해 실제 우리가 고민한 문제를 애노테이션으로 어떻게 해결하는지 알아보자. 애노테이션에 대한 자세한 내용은 예제 이후에 설명하겠다.
SimpleMapping
package cwchoiit.annotation.mapping;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface SimpleMapping {
String value();
}
- 애노테이션은 @interface 키워드를 사용해서 만든다.
- @SimpleMapping이라는 애노테이션 하나를 만든다. 내부에는 String value라는 속성을 하나 가진다.
- @Retention은 뒤에서 설명한다. 지금은 필수로 사용해야 하는 값 정도로 생각하자.
TestController
package cwchoiit.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 cwchoiit.annotation.mapping;
import java.lang.reflect.Method;
public class TestControllerMain {
public static void main(String[] args) {
TestController testController = new TestController();
Class<? extends TestController> aClass = testController.getClass();
Method[] methods = aClass.getDeclaredMethods();
for (Method method : methods) {
System.out.println("method = " + method);
SimpleMapping simpleMapping = method.getAnnotation(SimpleMapping.class);
if (simpleMapping != null) {
System.out.println("[" + simpleMapping.value() + "] -> " + method);
}
}
}
}
- TestController 클래스의 선언된 메서드를 찾는다.
- 리플렉션이 제공하는 getAnnotation() 메서드를 사용하면, 붙어있는 애노테이션을 찾을 수 있다.
- Class, Method, Field, Constructor 클래스는 자신에게 붙은 애노테이션을 찾을 수 있는 getAnnotation()를 제공한다.
- 여기서는 Method.getAnnotation(SimpleMapping.class)을 사용했으므로, 해당 메서드에 붙은 @SimpleMapping 애노테이션을 찾을 수 있다.
- simpleMapping.value()를 사용해서 찾은 애노테이션에 지정된 값을 조회할 수 있다.
실행 결과
method = public void cwchoiit.annotation.mapping.TestController.home()
[/] -> public void cwchoiit.annotation.mapping.TestController.home()
method = public void cwchoiit.annotation.mapping.TestController.page1()
[/site1] -> public void cwchoiit.annotation.mapping.TestController.page1()
이 예제를 통해 리플렉션 서블릿에서 해결하지 못했던 문제들을 어떻게 해결해야 하는지 바로 이해가 될 것이다. 바로, 애노테이션의 속성값을 사용해서 그 값이 URL 경로와 같으면 현재 조회된 이 메서드를 사용하는 방식으로 진행하면 될 것 같다!
참고로, 애노테이션이라는 단어는, 자바 애노테이션의 영어 단어 "Annotation"은 일반적으로 "주석" 또는 "메모"를 의미한다. 애노테이션은 코드에 추가적인 정보를 주석처럼 제공한다. 하지만 일반 주석과 달리, 애노테이션은 컴파일러나 런타임에서 해석될 수 있는 메타데이터를 제공한다. 즉, 애노테이션은 코드에 메모를 달아놓는 것처럼 특정 정보나 지시를 추가하는 도구로, 코드에 대한 메타데이터를 표현하는 방법이다. 따라서, "애노테이션"이라는 이름은 코드에 대한 추가적인 정보를 주석처럼 달아놓는다는 뜻이다.
애노테이션 정의
앞서 만들었던, HTTP 서버 관련해서 애노테이션과 리플렉션을 활용한 코드로 리팩토링하기 전에 애노테이션의 정의와 사용방법에 대해 좀 더 진득하게 알아보자.
AnnoElement
package cwchoiit.annotation.basic;
import cwchoiit.util.MyLogger;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface AnnoElement {
String value();
int count() default 0;
String[] tags() default {};
// MyLogger data(); // 다른 타입은 적용 X
Class<? extends MyLogger> annoData() default MyLogger.class;
}
- 애노테이션은 @interface 키워드로 정의한다.
- 애노테이션은 속성을 가질 수 있는데, 인터페이스와 비슷하게 정의한다.
애노테이션 정의 규칙
데이터 타입
- 기본 타입 (int, float, boolean 등)
- String
- Class (메타데이터) 또는 인터페이스
- enum
- 다른 애노테이션 타입
- 위의 타입들의 배열
- 앞서 설명한 타입 외에는 정의할 수 없다. 쉽게 이야기해서 직접 만든 일반적인 클래스를 사용할 수 없다.
- 예) Member, User, Team, MyLogger, ...
default 값
- 요소에 default 값을 지정할 수 있다.
- 예) String value() default "기본 값을 적용합니다.";
요소 이름
- 메서드 형태로 정의된다.
- 괄호를 포함하되 매개변수는 없어야 한다.
반환 값
- void를 반환 타입으로 사용할 수 없다.
예외
- 예외를 선언할 수 없다.
특별한 요소 이름
- value 라는 이름의 요소를 하나만 가질 경우, 애노테이션 사용 시 요소 이름을 생략할 수 있다. 이건 뒤에 코드를 보면서 더 자세히 설명해보겠다.
애노테이션 사용
ElementData1
package cwchoiit.annotation.basic;
@AnnoElement(value = "data", count = 10, tags = {"t1", "t2"})
public class ElementData1 {
}
- 애노테이션은 이렇게 클래스에도 사용할 수 있고, 메서드나 필드에도 사용할 수 있다.
ElementDataMain
package cwchoiit.annotation.basic;
import java.util.Arrays;
public class ElementDataMain {
public static void main(String[] args) {
Class<ElementData1> elementData1Class = ElementData1.class;
AnnoElement annotation = elementData1Class.getAnnotation(AnnoElement.class);
String value = annotation.value();
System.out.println("value = " + value);
int count = annotation.count();
System.out.println("count = " + count);
String[] tags = annotation.tags();
System.out.println("tags = " + Arrays.toString(tags));
}
}
실행 결과
value = data
count = 10
tags = [t1, t2]
ElementData2
package cwchoiit.annotation.basic;
@AnnoElement(value = "data", tags = "t1")
public class ElementData2 {
}
- default 항목은 생략 가능하다. (count를 생략한 모습이다.)
- 배열의 항목이 하나라면 `{}` 생략 가능하다.
ElementData3
package cwchoiit.annotation.basic;
@AnnoElement("data")
public class ElementData3 {
}
- 입력 요소가 하나인 경우, value 키워드 생략 가능하다.
- value = "data"와 동일한 모습이다.
메타 애노테이션
애노테이션을 정의하는데 사용하는 특별한 애노테이션을 메타 애노테이션이라고 한다. 다음과 같은 메타 애노테이션이 있다. 하나씩 알아보자.
- @Retention
- RetentionPolicy.SOURCE
- RetentionPolicy.CLASS
- RetentionPolicy.RUNTIME
- @Target
- @Documented
- @Inherited
@Retention
애노테이션의 생존 기간을 지정한다.
package cwchoiit.annotation.basic;
import cwchoiit.util.MyLogger;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface AnnoElement {
String value();
int count() default 0;
String[] tags() default {};
// MyLogger data(); // 다른 타입은 적용 X
Class<? extends MyLogger> annoData() default MyLogger.class;
}
- 보통은 대부분 우리가 만든것처럼 RUNTIME을 사용한다.
public enum RetentionPolicy {
SOURCE,
CLASS,
RUNTIME
}
- RetentionPolicy.SOURCE: 소스 코드에만 남아있다. 컴파일 시점에 제거된다.
- RetentionPolicy.CLASS: 컴파일 후 class 파일까지는 남아있지만, 자바 실행 시점에 제거된다 (기본값)
- RetentionPolicy.RUNTIME: 자바 실행 중에도 남아있다. 대부분 이 설정을 사용한다.
실제로, 이 @Rentention을 CLASS 또는 SOURCE로 적용하고 실행하면 읽지 못하는 모습을 볼 수 있다.
@Retention(RetentionPolicy.SOURCE)
public @interface AnnoElement {...}
package cwchoiit.annotation.basic;
import java.util.Arrays;
public class ElementDataMain {
public static void main(String[] args) {
Class<ElementData1> elementData1Class = ElementData1.class;
AnnoElement annotation = elementData1Class.getAnnotation(AnnoElement.class);
String value = annotation.value();
System.out.println("value = " + value);
int count = annotation.count();
System.out.println("count = " + count);
String[] tags = annotation.tags();
System.out.println("tags = " + Arrays.toString(tags));
}
}
@Target
애노테이션을 적용할 수 있는 위치를 지정한다.
package cwchoiit.annotation.basic;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@Documented
public @interface AnnoMeta {
}
- 여러개를 사용할 수 있게 배열로 되어 있다. 주로 TYPE, FIELD, METHOD를 사용한다.
- TYPE은 클래스, 인터페이스 레벨에 붙일 수 있고, FIELD는 필드에 붙일 수 있고, METHOD는 메서드에 붙일 수 있게 허용하는 것이다.
public enum ElementType {
TYPE,
FIELD,
METHOD,
PARAMETER,
CONSTRUCTOR,
LOCAL_VARIABLE,
ANNOTATION_TYPE,
PACKAGE,
TYPE_PARAMETER,
TYPE_USE,
MODULE,
RECORD_COMPONENT;
}
@Documented
자바 API 문서를 만들 때, 해당 애노테이션이 함께 포함되는지 지정한다. 보통 함께 사용한다.
@Inherited
자식 클래스가 애노테이션을 상속 받을 수 있다. 이 애노테이션은 뒤에서 더 자세히 알아보자!
적용 예시
package cwchoiit.annotation.basic;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@Documented
public @interface AnnoMeta {
}
- @Retention: RUNTIME → 자바 실행 중에도 애노테이션 정보가 남아있다. 따라서, 런타임에 리플렉션을 통해서 읽을 수 있다. 만약 다른 설정을 적용한다면 자바 실행 시점에 애노테이션이 사라지므로 리플렉션을 통해서 읽을 수 없다.
- @Target: ElementType.METHOD, ElementType.TYPE → 메서드와 타입(클래스, 인터페이스, enum 등)에 @AnnoMeta 애노테이션을 달 수 있다. 다른 곳에 적용하면 컴파일 오류가 발생한다.
- @Documented: 자바 API 문서를 만들 때 해당 애노테이션이 포함된다.
적용한 모습
package cwchoiit.annotation.basic;
@AnnoMeta
public class MetaData {
// @AnnoMeta // 필드에 적용하면 컴파일 오류
private String id;
@AnnoMeta
public void call() {
}
public static void main(String[] args) throws NoSuchMethodException {
AnnoMeta annotation = MetaData.class.getAnnotation(AnnoMeta.class);
System.out.println("annotation = " + annotation);
AnnoMeta methodAnnotation = MetaData.class.getMethod("call").getAnnotation(AnnoMeta.class);
System.out.println("methodAnnotation = " + methodAnnotation);
}
}
- 타입과 메서드에 해당 애노테이션을 적용할 수 있다.
- 필드에 적용하면 컴파일 오류가 발생한다. 자바 언어는 컴파일 시점에 @Target 메타 애노테이션을 읽어서 지정한 위치가 맞는지 체크한다.
실행 결과
annotation = @cwchoiit.annotation.basic.AnnoMeta()
methodAnnotation = @cwchoiit.annotation.basic.AnnoMeta()
애노테이션과 상속
모든 애노테이션은 java.lang.annotation.Annotation 인터페이스를 묵시적으로 상속 받는다.
package java.lang.annotation;
public interface Annotation {
boolean equals(Object obj);
int hashCode();
String toString();
Class<? extends Annotation> annotationType();
}
- java.lang.annotation.Annotation 인터페이스는 개발자가 직접 구현하거나, 확장할 수 있는 것이 아니라, 자바 언어 자체에서 애노테이션을 위한 기반으로 사용된다. 이 인터페이스는 다음과 같은 메서드를 제공한다.
- 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
package cwchoiit.annotation.basic.inherited;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Inherited
@Retention(RetentionPolicy.RUNTIME)
public @interface InheritedAnnotation {
}
- InheritedAnnotation은 @Inherited 애노테이션을 가진다.
NoInheritedAnnotation
package cwchoiit.annotation.basic.inherited;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface NoInheritedAnnotation {
}
- NoInheritedAnnotation은 @Inherited를 가지지 않는다.
Parent
package cwchoiit.annotation.basic.inherited;
@InheritedAnnotation
@NoInheritedAnnotation
public class Parent {
}
- Parent에는 @InheritedAnnotation, @NoInheritedAnnotation 모두 붙어있다.
Child
package cwchoiit.annotation.basic.inherited;
public class Child extends Parent {
}
- Child는 @InheritedAnnotation 애노테이션을 상속 받는다.
- @Inherited 메타 애노테이션이 붙어있다.
- @NoInhertiedAnnotation은 상속받지 못한다.
- @Inherited 메타 애노테이션이 붙어있지 않다.
TestInterface
package cwchoiit.annotation.basic.inherited;
@InheritedAnnotation
@NoInheritedAnnotation
public interface TestInterface {
}
- TestInterface에는 @InheritedAnnotation, @NoInheritedAnnotation 모두 붙어있다.
TestInterfaceImpl
package cwchoiit.annotation.basic.inherited;
public class TestInterfaceImpl implements TestInterface {
}
- 인터페이스의 구현에서는 애노테이션을 상속받을 수 없다.
- 참고로, 인터페이스 부모와 인터페이스 자식의 관계에서도 애노테이션을 상속 받을 수 없다.
InheritedMain
package cwchoiit.annotation.basic.inherited;
import java.lang.annotation.Annotation;
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);
Annotation[] annotations = clazz.getAnnotations();
for (Annotation annotation : annotations) {
System.out.println(" - " + annotation.annotationType().getSimpleName());
}
System.out.println();
}
}
실행 결과
class: class cwchoiit.annotation.basic.inherited.Parent
- InheritedAnnotation
- NoInheritedAnnotation
class: class cwchoiit.annotation.basic.inherited.Child
- InheritedAnnotation
class: interface cwchoiit.annotation.basic.inherited.TestInterface
- InheritedAnnotation
- NoInheritedAnnotation
class: class cwchoiit.annotation.basic.inherited.TestInterfaceImpl
- Child: InheritedAnnotation 상속
- TestInterfaceImpl: 애노테이션을 상속받을 수 없음
@Inherited가 클래스 상속에만 적용되는 이유
1. 클래스 상속과 인터페이스 구현의 차이
- 클래스 상속은 자식 클래스가 부모 클래스의 속성과 메서드를 상속받는 개념이다. 즉, 자식 클래스는 부모 클래스의 특성을 이어받으므로, 부모 클래스에 정의된 애노테이션을 자식 클래스가 자동으로 상속받을 수 있는 논리적 기반이 있다.
- 인터페이스는 메서드의 시그니처만을 정의할 뿐, 상태나 행위를 가지지 않기 때문에, 인터페이스의 구현체가 애노테이션을 상속한다는 개념이 잘 맞지 않는다.
2. 인터페이스와 다중 구현, 다이아몬드 문제
- 인터페이스는 다중 구현이 가능하다. 만약, 인터페이스의 애노테이션을 구현 클래스에서 상속하게 되면, 여러 인터페이스의 애노테이션 간의 충돌이나 모호한 상황이 발생할 수 있다. 그 중에 하나가 Circular Dependency 일 수도 있고.
애노테이션 활용 - 검증기
이제 이 애노테이션을 어떤식으로 활용할 수 있는지 알아보기 위해 각종 클래스의 정보들을 검증하는 기능을 만들어보자.
Team
package cwchoiit.annotation.validator;
public class Team {
private String name;
private int memberCount;
public Team(String name, int memberCount) {
this.name = name;
this.memberCount = memberCount;
}
public String getName() {
return name;
}
public int getMemberCount() {
return memberCount;
}
}
User
package cwchoiit.annotation.validator;
public class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
이렇게 두 객체(팀과 유저)가 있다고 해보자. 이제 이것들을 가지고 사용하는 쪽에서 이 객체에 들어가있는 값이 적절한지를 확인하고 싶을때 아래와 같은 코드를 작성할 수 있을것이다.
ValidatorV1Main
package cwchoiit.annotation.validator;
import static cwchoiit.util.MyLogger.log;
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 사이가 아니다.");
}
}
}
- validateUser() → 유저의 이름과 나이를 검증하는 메서드
- validateTeam() → 팀의 이름과 회원수를 검증하는 메서드
실행 결과
13:58:01.418 [ main] === user 검증 ===
13:58:01.422 [ main] java.lang.RuntimeException: 나이가 1~100 사이가 아니다.
13:58:01.422 [ main] === team 검증 ===
13:58:01.422 [ main] java.lang.RuntimeException: 이름이 비었다.
여기서는 값이 비었는지 검증하는 부분과 숫자의 범위를 검증하는 2가지 부분이 있다. 코드를 잘 보면 뭔가 비슷한 것 같으면서도 User, Team이 서로 완전히 다른 클래스이기 때문에 재사용이 어렵다. 그리고 각각의 필드 이름도 서로 다르고, 오류 메시지도 다르다. 그리고 검증해야 할 값의 범위도 다르다. 심지어, 객체가 이렇게 평생 2개만 있는것도 아니고 점점 늘어날텐데 그때마다 이런 중복적인 코드를 작성해야 한다. 이런 문제를 애노테이션으로 깔끔하게 해결할 수 있다.
애노테이션 기반 검증기
@NotEmpty - 빈 값을 검증하는데 사용
package cwchoiit.annotation.validator;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface NotEmpty {
String message() default "The value is empty";
}
- message → 검증에 실패한 경우 보여줄 메시지
@Range - 숫자의 범위를 검증하는데 사용
package cwchoiit.annotation.validator;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Range {
int min();
int max();
String message() default "Out of range";
}
- min → 최소 값
- max → 최대 값
- message → 검증에 실패한 경우 출력할 오류 메시지
이렇게 애노테이션을 만들고, 이 애노테이션을 각 필드에 원하는대로 추가하는 것이다.
User - 검증 애노테이션을 추가
package cwchoiit.annotation.validator;
public class User {
@NotEmpty(message = "이름이 비었습니다.")
private String name;
@Range(min = 1, max = 100, message = "나이는 1과 100 사이어야 합니다.")
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
Team - 검증 애노테이션을 추가
package cwchoiit.annotation.validator;
public class Team {
@NotEmpty(message = "이름이 비었습니다.")
private String name;
@Range(min = 1, max = 999, message = "회원 수는 1과 999사이어야 합니다.")
private int memberCount;
public Team(String name, int memberCount) {
this.name = name;
this.memberCount = memberCount;
}
public String getName() {
return name;
}
public int getMemberCount() {
return memberCount;
}
}
이제 이렇게 만들어 둔 후, 검증기를 리플렉션을 활용해서 만들면 끝난다.
Validator
package cwchoiit.annotation.validator;
import java.lang.reflect.Field;
public class Validator {
public static void validate(Object object) throws Exception {
Field[] declaredFields = object.getClass().getDeclaredFields();
for (Field field : declaredFields) {
field.setAccessible(true);
if (field.isAnnotationPresent(NotEmpty.class)) {
String value = (String) field.get(object);
NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
if (value == null || value.isEmpty()) {
throw new RuntimeException(notEmpty.message());
}
}
if (field.isAnnotationPresent(Range.class)) {
long value = field.getLong(object);
Range range = field.getAnnotation(Range.class);
if (value < range.min() || value > range.max()) {
throw new RuntimeException(range.message());
}
}
}
}
}
- 전달된 객체에 선언된 필드를 모두 찾고, isAnnotationPresent()를 활용해서 @NotEmpty, @Range 애노테이션이 붙어있는지 확인한다.
- 애노테이션이 있는 경우, 각 애노테이션의 속성을 기반으로 검증 로직을 수행한다. 만약, 검증에 실패하면 애노테이션에 적용한 메시지를 예외에 담아서 던진다.
ValidatorV2Main
package cwchoiit.annotation.validator;
import static cwchoiit.util.MyLogger.log;
public class ValidatorV2Main {
public static void main(String[] args) {
User user = new User("user1", 0);
Team team = new Team("team1", 10);
try {
log("=== user 검증 ===");
Validator.validate(user);
} catch (Exception e) {
log(e);
}
try {
log("=== team 검증 ===");
Validator.validate(team);
} catch (Exception e) {
log(e);
}
}
}
실행 결과
14:04:48.593 [ main] === user 검증 ===
14:04:48.609 [ main] java.lang.RuntimeException: 나이는 1과 100 사이어야 합니다.
14:04:48.609 [ main] === team 검증 ===
검증용 애노테이션과 검증기를 사용한 덕분에, 어떤 객체든지 애노테이션으로 간단하게 검증할 수 있게 됐다. 앞으로 객체를 몇개를 더 새로 만들고 검증을 하던 이 애노테이션과 검증기만 사용한다면 중복 코드를 작성하지 않아도 된다!
- 예를 들어, @NotEmpty 애노테이션을 사용하면 필드가 비었는지 여부를 편리하게 검증할 수 있고, @Range(min=1, max=100)와 같은 애노테이션을 통해 숫자의 범위를 쉽게 제한할 수 있다. 이러한 애노테이션 기반 검증 방식은 중복되는 코드 작성 없이도 유연한 검증 로직을 적용할 수 있어 유지보수성을 높여준다.
- User 클래스와 Team 클래스에 각각의 필드 이름이나 메시지들이 다르더라도, 애노테이션의 속성 값을 통해 필드 이름을 지정하고, 오류 메시지도 일관되게 정의할 수 있다. 예를 들어, @NotEmpty(message = "이름은 비어 있을 수 없습니다") 처럼 명시적인 메시지를 작성할 수 있으며, 이를 통해 다양한 클래스에서 공통된 검증 로직을 재사용할 수 있게 됐다.
- 또한, 새로 추가되는 클래스나 필드에 대해서도 복잡한 로직을 별도로 구현할 필요 없이 적절한 애노테이션을 추가하는 것만으로도 검증 로직을 쉽게 확장할 수 있다. 이처럼 애노테이션 기반 검증을 도입하면 코드의 가독성과 확장성이 크게 향상되며, 일관된 규칙을 유지할 수 있어 전체적인 품질 관리에도 도움이 된다.
- 이제 클래스들이 서로 다르더라도, 일관되고 재사용 가능한 검증 방식을 사용할 수 있게 되었다.
참고로, 자바 진영에서는 애노테이션 기반 검증 기능을 Jakarta(Java) Bean Validation 이라는 이름으로 표준화했다. 다양한 검증 애노테이션과 기능이 있고, 스프링 프레임워크, JPA같은 기술들과도 함께 사용된다.
자바의 기본 애노테이션
@Override, @Deprecated, @SuppressWarnings와 같이 자바 언어가 기본으로 제공하는 애노테이션도 있다. 참고로 앞서 설명한 @Retention, @Target도 자바 언어가 기본으로 제공하는 애노테이션이지만, 이것은 애노테이션 자체를 정의하기 위한 메타 애노테이션이고, 지금 설명한 내용은 코드에 직접 사용하는 애노테이션이다.
@Override
package java.lang;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
- 메서드 재정의가 정확하게 잘 되었는지 컴파일러가 체크하는데 사용한다.
OverrideMain
package cwchoiit.annotation.java;
public class OverrideMain {
static class A {
public void call() {
System.out.println("A.call");
}
}
static class B extends A {
public void calll() {
System.out.println("B.calll");
}
}
public static void main(String[] args) {
A a = new B();
a.call();
}
}
- B 클래스는 A 클래스를 상속받았다.
- A.call() 메서드를 B 클래스가 재정의하려고 시도한다. 이때, 실수로 오타가 발생해서 재정의가 아니라 자식 클래스에 calll()이라는 새로운 메서드를 정의해버렸다.
- 개발자의 의도는 A.call() 메서드의 재정의였지만, 자바 언어는 이것을 알 길이 없다. 자바 문법상 그냥 B에 calll()이라는 새로운 메서드가 하나 만들어졌을 뿐이다.
실행 결과
A.call
이럴 때, @Override 애노테이션을 사용한다. 이 애노테이션을 붙이면 자바 컴파일러가 메서드 재정의 여부를 체크해준다. 만약, 문제가 있다면 컴파일을 통과하지 않는다! 개발자의 실수를 자바 컴파일러가 잡아주는 좋은 애노테이션이기 때문에, 사용을 강하게 권장한다.
@Override의 @Retention(RetentionPolicy.SOURCE) 부분을 보자.
- RetentionPolicy.SOURCE로 설정하면, 컴파일 이후에 @Override 애노테이션은 제거된다.
- @Override는 컴파일 시점에만 사용하는 애노테이션이다. 런타임에는 필요하지 않으므로 이렇게 설정되어 있다.
@Deprecated
@Deprecated는 더 이상 사용되지 않는다는 뜻이다. 이 애노테이션이 적용된 기능은 사용을 권장하지 않는다. 예를 들면 다음과 같은 이유이다.
- 해당 요소를 사용하면, 오류가 발생할 가능성이 있다.
- 호환되지 않게 변경되거나, 향후 버전에서 제거될 수 있다.
- 더 나은 최신 대체 요소로 대체되었다.
- 더 이상 사용되지 않는 기능이다.
package java.lang;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.*;
@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 cwchoiit.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: 더는 사용을 권장하지 않는 요소이다.
- since: 더 이상 사용하지 않게된 버전 정보
- forRemoval: 미래 버전에 코드가 제거될 예정인지에 대한 여부
- @Deprecated만 있는 코드를 사용할 경우, IDE에서 경고를 나타낸다.
- @Deprecated + forRemoval이 있는 경우 IDE는 빨간색으로 심각한 경고를 나타낸다.
실행 결과
DeprecatedMain.main
DeprecatedClass.call1
DeprecatedClass.call2
DeprecatedClass.call3
@Deprecated는 컴파일 시점에 경고를 나타내지만, 프로그램은 작동한다.
@SuppressWarnings
이름 그대로, 경고를 억제하는 애노테이션이다. 자바 컴파일러가 문제를 경고하지만, 개발자가 해당 문제를 잘 알고 있기 때문에, 더는 경고하지 말라고 지시하는 애노테이션이다.
package java.lang;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.*;
@Retention(RetentionPolicy.SOURCE)
public @interface SuppressWarnings {
String[] value();
}
SuppressWarningCase
package cwchoiit.annotation.java;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
public class SuppressWarningCase {
@SuppressWarnings("unused")
public void unusedWarning() {
// 사용되지 않는 변수 경고 억제
int unusedVariable = 10;
}
@SuppressWarnings("deprecation")
public void deprecatedMethod() {
Date date = new Date();
// deprecated method 경고 억제
int date1 = date.getDate();
}
@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();
// deprecated method 경고 억제
int date1 = date.getDate();
// 제네릭 타입 캐스팅 경고 억제, raw type 사용 경고
List list = new ArrayList();
// unchecked 경고
List<String> stringList = (List<String>) list;
}
}
@SuppressWarnings에 사용하는 대표적인 값들은 다음과 같다.
- all → 모든 경고를 억제
- deprecation → 사용이 권장되지 않는 (deprecated) 코드를 사용할 때 발생하는 경고를 억제
- unchecked → 제네릭 타입과 관련된 unchecked 경고를 억제
- serial → Serializable 인터페이스를 구현할 때, serialVersionUID 필드를 선언하지 않은 경우 발생하는 경고를 억제
- rawtypes → 제네릭 타입이 명시되지 않은(raw) 타입을 사용할 때 발생하는 경고를 억제
- unused → 사용되지 않는 변수, 메서드, 필드 등을 선언했을 때 발생하는 경고를 억제
정리를 하자면...
자바 백엔드 개발자가 되려면, 스프링, JPA 같은 기술은 필수로 배워야한다. 그런데 처음 스프링이나 JPA 같은 기술을 배우면 기존에 자바 문법으로는 잘 이해가 안가는 마법같은 일들이 벌어진다.
이러한 프레임워크들이 리플렉션과 애노테이션을 잘 활용해서 다음과 같은 마법 같은 기능들을 제공하기 때문이다.
- 의존성 주입 (DI): 스프링은 리플렉션을 사용하여, 객체의 필드나 생성자에 자동으로 의존성을 주입한다. 개발자는 단순히 @Autowired 애노테이션만 붙이면 된다.
- ORM: JPA는 애노테이션을 사용하여, 자바 객체와 데이터베이스 테이블 간의 매핑을 정의한다. 예를 들어, @Entity, @Table, @Column 등의 애노테이션으로 객체 - 테이블 관계를 설정한다.
- AOP: 스프링은 리플렉션을 사용하여, 런타임에 코드를 동적으로 주입하고, @Aspect, @Before, @After 등의 애노테이션으로 관점 지향 프로그래밍을 구현한다.
- 설정의 자동화: @Configuration, @Bean 등의 애노테이션을 사용하여 다양한 설정을 편리하게 적용한다.
- 트랜잭션 관리: @Transactional 애노테이션만으로 메서드 레벨의 DB 트랜잭션 처리가 가능해진다.
이러한 기능들은, 개발자가 비즈니스 로직에 집중할 수 있게 해주며, 보일러플레이트 코드를 크게 줄여준다. 하지만 이 "마법"의 이면에는 리플렉션과 애노테이션을 활용한 복잡한 메타프로그래밍이 숨어 있다. 프레임워크의 동작 원리를 깊이 이해하기 위해서는 리플렉션과 애노테이션에 대한 이해가 필수다. 이를 통해 프레임워크가 제공하는 편의성과 그 이면의 복잡성 사이의 균형을 잡을 수 있으며, 필요에 따라 프레임워크를 효과적으로 커스터마이징하거나 최적화할 수 있게 된다.
스프링이나 JPA 같은 프레임워크들은 이번에 학습한 리플렉션과 애노테이션을 극대화해서 사용한다. 리플렉션과 애노테이션을 배운 덕분에 이런 기술이 마법이 아니라, 리플렉션과 애노테이션을 활용한 고급 프로그래밍 기법이라는 것을 이해할 수 있을 것이다. 그리고 이러한 이해를 바탕으로, 프레임워크의 동작 원리를 더 깊이 파악하고 효과적으로 활용할 수 있게 될 것이다.
이제 애노테이션과 리플렉션도 전부 배워봤다. 그럼 이전에 만들었던 HTTP 서버를 이 애노테이션을 활용해서 화룡점정을 찍어보자.
HTTP 서버7 - 애노테이션 서블릿1
지금까지 학습한 애노테이션 내용을 바탕으로 애노테이션 기반의 컨트롤러와 서블릿을 만들어보자.
예를 들어, 다음과 같은 컨트롤러를 만들 예정이다.
package cwchoiit.was.v7;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SiteControllerV7 {
@RequestMapping("/site1")
public void site1(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Site1</h1>");
}
@RequestMapping("/site2")
public void site2(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
}
애노테이션부터 만들어보자.
RequestMapping
package cwchoiit.was.httpserver.servlet.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequestMapping {
String value();
}
AnnotationServlet
package cwchoiit.was.httpserver.servlet.annotation;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;
import cwchoiit.was.httpserver.PageNotFoundException;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
public class AnnotationServlet implements HttpServlet {
private final List<Object> controllers;
public AnnotationServlet(List<Object> controllers) {
this.controllers = controllers;
}
@Override
public void service(HttpRequest request, HttpResponse response) throws IOException {
String path = request.getPath();
for (Object controller : controllers) {
Class<?> aClass = controller.getClass();
Method[] methods = aClass.getDeclaredMethods();
for (Method method : methods) {
boolean isRequestMapping = method.isAnnotationPresent(RequestMapping.class);
if (!isRequestMapping) {
continue;
}
RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
if (path.equals(requestMapping.value())) {
invoke(request, response, controller, method);
return;
}
}
}
throw new PageNotFoundException("No mapping found for path = " + path);
}
private static void invoke(HttpRequest request,
HttpResponse response,
Object controller,
Method method){
try {
method.invoke(controller, request, response);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
}
- 리플렉션에서 사용한 코드와 비슷하다. 차이가 있다면 호출할 메서드를 찾을 때, 메서드의 이름을 비교하는 대신에 메서드에서 @RequestMapping 애노테이션을 찾고, 그곳의 value 값으로 비교한다는 점이다.
- 패키지 위치에 주의하자. 다른 프로젝트에서도 사용할 수 있다.
컨트롤러들을 만들어보자.
SearchControllerV7
package cwchoiit.was.v7;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SearchControllerV7 {
@RequestMapping("/search")
public void search(HttpRequest request, HttpResponse response) {
String query = request.getParameter("q");
response.writeBody("<h1>Search</h1>");
response.writeBody("<ul>");
response.writeBody("<li>query = " + query + "</li>");
response.writeBody("</ul>");
}
}
SiteControllerV7
package cwchoiit.was.v7;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SiteControllerV7 {
@RequestMapping("/site1")
public void site1(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Site1</h1>");
}
@RequestMapping("/site2")
public void site2(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
}
EtcControllerV7
package cwchoiit.was.v7;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class EtcControllerV7 {
@RequestMapping("/")
public void search(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Home</h1>");
response.writeBody("<ul>");
response.writeBody("<li><a href='/site1'>site1</a></li>");
response.writeBody("<li><a href='/site2'>site2</a></li>");
response.writeBody("<li><a href='/search?q=hello'>search</a></li>");
response.writeBody("</ul>");
}
@RequestMapping("/favicon.ico")
public void discardPath(HttpRequest request, HttpResponse response) {
// NOTHING
}
}
ServerMainV7
package cwchoiit.was.v7;
import cwchoiit.was.httpserver.HttpServer;
import cwchoiit.was.httpserver.ServletManager;
import cwchoiit.was.httpserver.servlet.annotation.AnnotationServlet;
import java.io.IOException;
import java.util.List;
public class ServerMainV7 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
List<Object> controllers = List.of(new SiteControllerV7(), new SearchControllerV7(), new EtcControllerV7());
AnnotationServlet annotationServlet = new AnnotationServlet(controllers);
ServletManager servletManager = new ServletManager();
servletManager.setDefaultServlet(annotationServlet);
new HttpServer(PORT, servletManager).start();
}
}
실행 결과
- 기존과 같다.
정리
애노테이션을 사용한 덕분에 매우 편리하고, 또 실용적으로 웹 애플리케이션을 만들 수 있게됐다. 현대의 웹 프레임워크들은 대부분 애노테이션을 사용해서 편리하게 호출 메서드를 찾을 수 있는 지금과 같은 방식을 제공한다. 자바 백엔드의 사실상 표준 기술인 스프링 프레임워크도 스프링 MVC를 통해 이런 방식의 기능을 제공한다.
HTTP 서버8 - 애노테이션 서블릿2 - 동적 바인딩
만든 애노테이션 기반 컨트롤러에서 아쉬운 부분이 있다. 예를 들어, 다음 site1(), site2()의 경우엔 HttpRequest request가 전혀 필요하지 않다. HttpResponse response만 있으면 된다.
SiteControllerV7
package cwchoiit.was.v7;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SiteControllerV7 {
@RequestMapping("/site1")
public void site1(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Site1</h1>");
}
@RequestMapping("/site2")
public void site2(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
}
컨트롤러의 메서드를 만들 때, HttpRequest request, HttpResponse response 중에 딱 필요한 메서드만 유연하게 받을 수 있도록 AnnotationServlet의 기능을 개선해보자.
AnnotationServletV2
package cwchoiit.was.httpserver.servlet.annotation;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;
import cwchoiit.was.httpserver.PageNotFoundException;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
public class AnnotationServletV2 implements HttpServlet {
private final List<Object> controllers;
public AnnotationServletV2(List<Object> controllers) {
this.controllers = controllers;
}
@Override
public void service(HttpRequest request, HttpResponse response) throws IOException {
String path = request.getPath();
for (Object controller : controllers) {
Class<?> aClass = controller.getClass();
Method[] methods = aClass.getDeclaredMethods();
for (Method method : methods) {
boolean isRequestMapping = method.isAnnotationPresent(RequestMapping.class);
if (!isRequestMapping) {
continue;
}
RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
if (path.equals(requestMapping.value())) {
invoke(request, response, controller, method);
return;
}
}
}
throw new PageNotFoundException("No mapping found for path = " + path);
}
private static void invoke(HttpRequest request,
HttpResponse response,
Object controller,
Method method){
try {
Class<?>[] parameterTypes = method.getParameterTypes();
Object[] args = new Object[parameterTypes.length];
for (int i = 0; i < parameterTypes.length; i++) {
if (parameterTypes[i] == HttpRequest.class) {
args[i] = request;
} else if (parameterTypes[i] == HttpResponse.class) {
args[i] = response;
} else {
throw new IllegalArgumentException("Invalid parameter type: " + parameterTypes[i]);
}
}
method.invoke(controller, args);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
}
- invoke() 부분을 보자.
- 메서드의 파라미터 타입을 확인한 후에, 각 타입에 맞는 값을 args[]에 담아서 메서드를 호출한다.
- Method.invoke()는 두번째 인자로 이 메서드를 실행하기 위한 파라미터로 `...args`를 받기 때문에 배열을 그대로 넣을수가 있다.
이제, 각 컨트롤러에서 필요한 파라미터만 받아서 사용하도록 컨트롤러를 바꿔보자.
EtcControllerV8
package cwchoiit.was.v8;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class EtcControllerV8 {
@RequestMapping("/")
public void search(HttpResponse response) {
response.writeBody("<h1>Home</h1>");
response.writeBody("<ul>");
response.writeBody("<li><a href='/site1'>site1</a></li>");
response.writeBody("<li><a href='/site2'>site2</a></li>");
response.writeBody("<li><a href='/search?q=hello'>search</a></li>");
response.writeBody("</ul>");
}
@RequestMapping("/favicon.ico")
public void discardPath() {
// NOTHING
}
}
SearchControllerV8
package cwchoiit.was.v8;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SearchControllerV8 {
@RequestMapping("/search")
public void search(HttpRequest request, HttpResponse response) {
String query = request.getParameter("q");
response.writeBody("<h1>Search</h1>");
response.writeBody("<ul>");
response.writeBody("<li>query = " + query + "</li>");
response.writeBody("</ul>");
}
}
SiteControllerV8
package cwchoiit.was.v8;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SiteControllerV8 {
@RequestMapping("/site1")
public void site1(HttpResponse response) {
response.writeBody("<h1>Site1</h1>");
}
@RequestMapping("/site2")
public void site2(HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
}
이제 실행해보면 실행 결과는 똑같지만, 동적 바인딩을 통해 파라미터는 딱 필요한 녀석들만 받을 수 있게 변경했다.
ServerMainV8
package cwchoiit.was.v8;
import cwchoiit.was.httpserver.HttpServer;
import cwchoiit.was.httpserver.ServletManager;
import cwchoiit.was.httpserver.servlet.annotation.AnnotationServletV2;
import java.io.IOException;
import java.util.List;
public class ServerMainV8 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
List<Object> controllers = List.of(new SiteControllerV8(), new SearchControllerV8(), new EtcControllerV8());
AnnotationServletV2 annotationServlet = new AnnotationServletV2(controllers);
ServletManager servletManager = new ServletManager();
servletManager.setDefaultServlet(annotationServlet);
new HttpServer(PORT, servletManager).start();
}
}
정리
AnnotationServletV2에서 호출할 컨트롤러 메서드의 매개변수를 먼저 확인한 다음, 매개변수에 필요한 값을 동적으로 만들어서 전달했다. 덕분에 컨트롤러의 메서드는 자신에게 필요한 값만 선언하고, 전달 받을 수 있다. 이런 기능을 확장하면 HttpRequest, HttpResponse뿐만 아니라 다양한 객체들도 전달할 수 있다. 참고로 스프링 MVC도 이런 방식으로 다양한 매개변수의 값을 동적으로 전달한다.
HTTP 서버9 - 애노테이션 서블릿3 - 성능 최적화
지금까지 만든 AnnotationServletV2는 2가지 아쉬운 점이 있다.
- 성능 최적화
- 중복 매핑 문제
문제1 - 성능 최적화
AnnotationServletV2 - 일부분
@Override
public void service(HttpRequest request, HttpResponse response) throws IOException {
String path = request.getPath();
for (Object controller : controllers) {
Class<?> aClass = controller.getClass();
Method[] methods = aClass.getDeclaredMethods();
for (Method method : methods) {
boolean isRequestMapping = method.isAnnotationPresent(RequestMapping.class);
if (!isRequestMapping) {
continue;
}
RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
if (path.equals(requestMapping.value())) {
invoke(request, response, controller, method);
return;
}
}
}
throw new PageNotFoundException("No mapping found for path = " + path);
}
- 모든 컨트롤러의 메서드를 하나하나 순서대로 찾는다. 이것은 결과적으로 O(n)의 성능을 보인다.
- 만약, 모든 컨트롤러의 메서드가 합쳐서 100개라면 최악의 경우 100번은 찾아야 한다.
- 저게 문제가 아니라 진짜 문제는, 고객의 요청때마다 이 로직이 호출된다는 점이다. 동시에 100명의 고객이 요청하면 최악의 경우, 100 * 100번 해당 로직이 호출될 수 있다.
- 이 부분의 성능을 O(n) → O(1)으로 변경하려면 어떻게 해야할까?
문제2 - 중복 매핑 문제
package cwchoiit.was.v8;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SiteControllerV8 {
@RequestMapping("/site1")
public void site1(HttpResponse response) {
response.writeBody("<h1>Site1</h1>");
}
@RequestMapping("/site2")
public void site2(HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
@RequestMapping("/site2")
public void page2(HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
}
- 개발자가 실수로 @RequestMapping에 같은`/site2`를 2개 정의하면 어떻게 될까?
이 경우, 현재 로직에서는 아무런 문제도 일으키지 않은 채 그냥 먼저 찾은 메서드가 호출된다. 개발에서 가장 나쁜 것은 모호한 것이다! 모호한 문제는 반드시 제거해야 한다! 그렇지 않으면 나중에 큰 재앙으로 다가온다.
최적화 구현
AnnotationServletV3
package cwchoiit.was.httpserver.servlet.annotation;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;
import cwchoiit.was.httpserver.PageNotFoundException;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class AnnotationServletV3 implements HttpServlet {
private final Map<String, ControllerMethod> pathMap;
public AnnotationServletV3(List<Object> controllers) {
this.pathMap = new HashMap<>();
initializePathMap(controllers);
}
private void initializePathMap(List<Object> controllers) {
for (Object controller : controllers) {
Method[] methods = controller.getClass().getDeclaredMethods();
for (Method method : methods) {
if (method.isAnnotationPresent(RequestMapping.class)) {
String path = method.getAnnotation(RequestMapping.class).value();
if (pathMap.containsKey(path)) {
ControllerMethod controllerMethod = pathMap.get(path);
throw new IllegalArgumentException("경로 중복 등록, path = " + path + ", method = " + method + ", 이미 등록된 메서드 = " + controllerMethod.method);
}
pathMap.put(path, new ControllerMethod(controller, method));
}
}
}
}
@Override
public void service(HttpRequest request, HttpResponse response) throws IOException {
String path = request.getPath();
ControllerMethod controllerMethod = pathMap.get(path);
if (controllerMethod == null) {
throw new PageNotFoundException("No mapping found for path = " + path);
}
controllerMethod.invoke(request, response);
}
private static class ControllerMethod {
private final Object controller;
private final Method method;
public ControllerMethod(Object controller, Method method) {
this.controller = controller;
this.method = method;
}
public void invoke(HttpRequest request, HttpResponse response) {
try {
Class<?>[] parameterTypes = method.getParameterTypes();
Object[] args = new Object[parameterTypes.length];
for (int i = 0; i < parameterTypes.length; i++) {
if (parameterTypes[i] == HttpRequest.class) {
args[i] = request;
} else if (parameterTypes[i] == HttpResponse.class) {
args[i] = response;
} else {
throw new IllegalArgumentException("Invalid parameter type: " + parameterTypes[i]);
}
}
method.invoke(controller, args);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
}
}
초기화
- AnnotationServletV3을 생성하는 시점에 @RequestMapping을 사용하는 컨트롤러의 메서드를 모두 찾아서 pathMap에 보관한다.
- 초기화가 끝나면 pathMap이 완성된다.
- ControllerMethod: @RequestMapping의 대상 메서드와 메서드가 있는 컨트롤러 객체를 캡슐화했다. 이렇게 하면 ControllerMethod 객체를 사용해서 편리하게 실제 메서드를 호출할 수 있다.
실행
- ControllerMethod controllerMethod = pathMap.get(path)를 사용해서, URL 경로에 매핑된 컨트롤러의 메서드를 찾아온다. 이 과정은 HashMap을 사용하므로 일반적으로 O(1)의 매우 빠른 성능을 제공한다.
- 그러니까 결과적으로 최초에 딱 한번만 O(n)의 시간복잡도를 가지는 행위를 하면, 그 이후부터는 O(1)의 성능으로 매우 빠르게 요청에 대한 응답을 줄 수가 있는 것이다.
중복 경로 체크
if (pathMap.containsKey(path)) {
ControllerMethod controllerMethod = pathMap.get(path);
throw new IllegalArgumentException("경로 중복 등록, path = " + path + ", method = " + method + ", 이미 등록된 메서드 = " + controllerMethod.method);
}
- pathMap에 이미 등록된 경로가 있다면 중복 경로이다. 이 경우 예외를 던져서 개발자에게 중복된 경로가 있음을 인지하게 한다.
ServerMainV8
package cwchoiit.was.v8;
import cwchoiit.was.httpserver.HttpServer;
import cwchoiit.was.httpserver.HttpServlet;
import cwchoiit.was.httpserver.ServletManager;
import cwchoiit.was.httpserver.servlet.annotation.AnnotationServletV2;
import cwchoiit.was.httpserver.servlet.annotation.AnnotationServletV3;
import java.io.IOException;
import java.util.List;
public class ServerMainV8 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
List<Object> controllers = List.of(new SiteControllerV8(), new SearchControllerV8(), new EtcControllerV8());
//AnnotationServletV2 annotationServlet = new AnnotationServletV2(controllers);
HttpServlet annotationServlet = new AnnotationServletV3(controllers);
ServletManager servletManager = new ServletManager();
servletManager.setDefaultServlet(annotationServlet);
new HttpServer(PORT, servletManager).start();
}
}
- V8을 그대로 사용하되, 일부만 수정하자. AnnotationServletV2 → AnnotationServletV3을 사용하도록 변경하자.
- 다른 코드는 변경할 부분이 없다.
실행 결과
- 기존과 같다.
중복 체크를 하기 위해 같은 @RequestMapping을 추가해보자.
package cwchoiit.was.v8;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SiteControllerV8 {
@RequestMapping("/site1")
public void site1(HttpResponse response) {
response.writeBody("<h1>Site1</h1>");
}
@RequestMapping("/site2")
public void site2(HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
@RequestMapping("/site2")
public void page2(HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
}
실행 결과
Exception in thread "main" java.lang.IllegalArgumentException: 경로 중복 등록, path = /site2, method = public void cwchoiit.was.v8.SiteControllerV8.page2(cwchoiit.was.httpserver.HttpResponse), 이미 등록된 메서드 = public void cwchoiit.was.v8.SiteControllerV8.site2(cwchoiit.was.httpserver.HttpResponse)
at cwchoiit.was.httpserver.servlet.annotation.AnnotationServletV3.initializePathMap(AnnotationServletV3.java:33)
at cwchoiit.was.httpserver.servlet.annotation.AnnotationServletV3.<init>(AnnotationServletV3.java:21)
at cwchoiit.was.v8.ServerMainV8.main(ServerMainV8.java:20)
- 서버를 실행하는 시점에 바로 오류가 발생하고, 서버 실행이 중단된다. 이렇게 서버 실행 시점에 발견할 수 있는 오류는 아주 좋은 오류이다. 만약, 이런 오류를 체크하지 않고, `/site2`가 2개 유지된 채로 작동한다면, 고객은 기대한 화면과 다른 화면을 보고 있을 수 있다.
3가지 오류
- 컴파일 오류: 가장 좋은 오류이다. 프로그램 실행 전에 개발자가 가장 빠르게 문제를 확인할 수 있다.
- 런타임 오류 - 시작 오류: 자바 프로그램이나 서버를 시작하는 시점에 발견할 수 있는 오류이다. 문제를 아주 빠르게 발견할 수 있기 때문에 좋은 오류이다. 고객이 문제를 인지하기 전에 수정하고 해결할 수 있다.
- 런타임 오류 - 작동 중 오류: 고객이 특정 기능을 작동할 때 발생하는 오류이다. 원인 파악과 문제 해결에 가장 많은 시간이 걸리고 가장 큰 피해를 주는 오류이다.
정리
드디어 성능, 유연성, 오류 체크 기능까지 강화한 쓸만한 AnnotationServletV3를 만들어냈다.
'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글
[Java 8] Stream API (0) | 2024.11.27 |
---|---|
[Java 8] 함수형 인터페이스와 람다 표현식 (0) | 2024.11.25 |
리플렉션 (6) | 2024.10.21 |
ServerSocket으로 HTTP 서버 만들기 (2) | 2024.10.18 |
Socket을 이용한 채팅 프로그램 만들기 (0) | 2024.10.18 |