728x90
반응형
SMALL

자바로 개발을 할때 이 지루한 코드들을 보거나 작성해 본 경험이 있으신가요?

package cwchoiit;

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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}
  • Getter, Setter의 작업은 굉장히 개발자로 하여금 지루한 코드 작성 요소라고 볼 수 있습니다.
  • 물론, 요즘에는 IDE의 도움을 받아 굉장히 편리하게 작성을 해주지만 그럼에도 불구하고 코드가 쓸데없이 방대해지고 길어지는 건 막을 수가 없죠.

 

여기서 우리의 위대한 선배 개발자님들은 이 과정에 지루함을 느꼈습니다. 이 과정이 자동화되면 좋겠다고 말이죠. 그렇게 탄생한 정말 개인적으로 엄청 위대한 라이브러리라고 생각되는 이 `Lombok`. 많이들 사용하시나요? 저는 필수로 사용중입니다. 

 

그래서 다음과 같이 위 코드를 대체할 수가 있죠.

package cwchoiit;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class Member {

    private String name;
    private int age;
}
  • 위 코드와 비교해서 얼마나 깔끔한가요? 쓸데없이 길게 늘어진 Getter, Setter가 없으니 훨씬 보기도 편하고, 여기에 의미있는 메서드나 도메인 주도 개발을 포커스로 개발하시는 분들한테는 더할 나위 없이 좋겠죠.

 

그런데 궁금하지 않으셨나요? 도대체 어떻게 이렇게만 하면 자동으로 위에 코드처럼 Getter, Setter가 만들어지고 사용할 수가 있는지? 전 이 부분이 너무 너무 궁금했습니다. 그래서 공부하고 찾아보게 됐습니다. Lombok의 원리!

 

Lombok의 핵심 키워드는! → '애노테이션 프로세싱'과 'AST 조작'

 

애노테이션 프로세싱

1. 애노테이션 프로세서(Annotation Processor)란?

  • Java의 애노테이션 프로세서는 컴파일 단계에서 애노테이션을 분석하고, 이를 기반으로 추가적인 코드를 생성하거나 컴파일러에 특정 작업을 지시하는 기능을 제공합니다.
  • 이는 Java Compiler API의 일부로, 애노테이션 기반의 코드 생성 및 검증을 지원하는 도구입니다. 
  • 애노테이션 프로세서는 `javax.annotation.processing` 패키지에 정의된 인터페이스와 클래스들을 통해 구현됩니다. 
  • 일반적으로 컴파일 타임에 실행되며, 런타임에 영향을 미치지 않습니다.

2. 애노테이션 프로세서(Annotation Processor)의 주요 역할

  • 애노테이션 처리: 소스 코드에 선언된 애노테이션을 감지하고, 이를 기반으로 처리 작업 수행
  • 소스 코드 생성: @Getter와 같은 애노테이션을 통해 Getter 메서드 자동 생성.
  • 애노테이션 검증: 애노테이션 사용이 올바른지 확인하고, 잘못된 경우 컴파일러 오류를 출력해준다. 예를 들어, 특정 필드에만 적용 가능한 애노테이션을 다른 위치에 사용했는지 검증할 수 있습니다.
  • 리소스 파일 생성: 컴파일 시점에 특정 리소스 파일 생성 (예: `.properties` 파일)

3. 애노테이션 프로세서(Annotation Processor)의 동작 원리

3-1. 컴파일러와의 통합

 

애노테이션 프로세서는 Java 컴파일러(Javac)의 플러그인 형태로 작동합니다.

컴파일러가 코드를 컴파일하면서 애노테이션 프로세서를 호출하여 필요한 작업을 수행합니다.

 

3-2. Annotation Processing API

 

애노테이션 프로세서를 작성하려면 `javax.annotation.processing` 패키지의 API를 사용해야 합니다.

핵심 클래스 및 인터페이스는 다음과 같습니다.

  • Processor 인터페이스
    • 모든 애노테이션 프로세서는 이 인터페이스를 구현해야 합니다. 대부분은 AbstractProcessor 클래스를 확장하여 구현합니다.
  • ProcessingEnvironment 인터페이스
    • 컴파일러와 애노테이션 프로세서 간의 통신을 위한 환경을 제공합니다.
    • 소스 코드 생성 도구(Filer)와 메시지 출력 도구(Messager)를 포함합니다.
  • RoundEnvironment 인터페이스
    • 컴파일러가 처리 중인 애노테이션 및 소스 코드 정보를 제공합니다.

 

 

바이트코드 조작

Lombok은 바이트코드를 직접 변경하는 것은 아니고, 컴파일러의 AST(Abstract Syntax Tree)를 조작하여 결과적으로 수정된 바이트코드를 생성합니다. 이게 무슨 말일까요?

 

Java에서 AST(Abstract Syntax Tree)는 컴파일러가 소스 코드를 분석할 때 생성하는 구조화된 트리 형태의 데이터 구조를 말합니다.

이 트리는 소스 코드의 문법 요소(클래스, 메서드, 변수 등)를 계층적으로 표현하며, 컴파일러는 이 AST를 기반으로 바이트코드(.class 파일)를 생성합니다. 

 

그러니까, Lombok이 "AST를 조작한다"는 말은 컴파일러가 생성한 이 구문 트리에 접근하여 수정하거나 요소를 추가한다는 의미겠죠. 예를 들면, @Getter 애노테이션이 붙은 필드에 대해 Getter 메서드 노드를 트리에 삽입하겠고 그렇게 조작된 AST를 컴파일러가 바이트코드(.class 파일)로 변환하면서 실제로 동작하는 코드가 만들어집니다.

 

말로만 얘기해서는 AST에 대해 이해하기가 조금 난해합니다. 다음 코드를 보시죠!

 

Java 코드

public class Member {
    private String name;

    public String getName() {
        return name;
    }
}

AST

- Class: Member
  - Field: name (Type: String, Modifier: private)
  - Method: getName (Type: String, Modifier: public)
    - Return: name
  • 이게 바로 AST입니다. 컴파일러는 이 AST를 사용하여 소스 코드의 문법 오류를 검증하고 바이트코드를 생성해 냅니다.

Lombok의 동작 방식: AST 조작

그러니까 엄밀히 따져서 Lombok은 바이트코드를 조작하는 것이 아니라, AST를 조작한다고 보면 되겠죠. Lombok은 애노테이션 프로세서로 동작하며, 컴파일러가 AST를 생성하는 과정에서 해당 트리를 수정합니다. 이를 통해 추가적인 코드를 자동으로 삽입하거나 수정합니다. 

 

그러니까, Lombok@Getter와 같은 애노테이션을 감지하고, 이를 기반으로 AST에서 필드에 대응하는 Getter 메서드 노드를 삽입합니다. 최종적으로 조작된 AST는 컴파일러가 다시 바이트코드로 변환하구요.

 

예를 들어볼까요? 다음 소스 코드를 보시죠.

package cwchoiit;

public class Member {
    @Getter
    private String name;
}
  • 컴파일러가 AST를 생성합니다. Member 클래스와 name 필드를 포함하는 트리 구조를 생성하겠죠. 바로 아래와 같이요.
- Class: Member
  - Field: name (Type: String, Modifier: private)
  • 그 후 Lombok 애노테이션 프로세서가 AST를 조작합니다. 
  • Lombok@Getter 애노테이션이 붙은 필드를 감지합니다. 그리고 그 필드에 대한 GettergetName() 메서드의 노드를 AST에 삽입합니다. 그래서 아래와 같이 조작된 AST가 만들어집니다!
- Class: Member
  - Field: name (Type: String, Modifier: private)
  - Method: getName (Type: String, Modifier: public)
    - Return: name
  • 이제 컴파일러가 다시 이 AST를 통해 바이트코드를 생성해 냅니다. 
  • 그래서, 결과 바이트코드는 getName() 메서드를 포함하게 되죠.

 

정리를 하자면

Lombok의 동작원리를 살펴보았습니다. 핵심 키워드는 [애노테이션 프로세싱]과 [AST 조작]이라고 할 수 있는데요. 이 과정을 통해 지루한 반복 코드를 깔끔하게 애노테이션으로 해결할 수가 있게 됐습니다. 물론, Lombok에 관련된 논란 거리도 있습니다만, 전 Lombok이 좋네요. 

 

 

나도 한번 만들어볼까?

이해를 해봤으니, 직접 다뤄볼까요? 간단하게라도 직접 만들어보기까지 한다면 조금 더 이 과정이 이해될 것 같습니다.

`chyonibok` 이라는 이름의 프로젝트를 하나 만들고 다음과 같이 코드를 작성했습니다.

package cwchoiit;

public class Member {
    @AutoGetter
    private String name;
}
  • 지금 저 @AutoGetter 애노테이션은 아무런 동작도 하지 않습니다. 심지어 컴파일 오류가 납니다. 이렇게 만든 Member라는 클래스가 @AutoGetter 애노테이션이 달린 필드를 보고 해당 필드의 Getter 메서드를 자동으로 만들어주는 그 작업을 한번 해보겠습니다.

 

그러기 위해 프로젝트를 하나 더 만들 생각입니다. 라이브러리 역할을 하는. 그래서 새로 만든 프로젝트의 이름을 `chyoniboklib`라고 하고 새로 만들었습니다. 그리고 다음과 같이 애노테이션을 하나 생성했습니다.

package cwchoiit;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.SOURCE)
public @interface AutoGetter {
}
  • RetentionPolicySOURCE일까요? 이 애노테이션이 런타임이나 바이트코드에 필요할까요? 아닙니다. 컴파일 시에 해당 애노테이션을 찾아 후처리를 하고 AST 조작을 하면 끝나기 때문에 런타임이나 바이트코드에 필요하지가 않습니다. 따라서 CLASS, RUNTIME이 아닌 SOURCE로 지정했습니다.

 

애노테이션 프로세서 구현

자, 이제 애노테이션 프로세서를 구현해야 합니다. 아까 위에서도 얘기했지만, Processor를 직접 구현해도 된다만, 이미 여러 기능이 있는 AbstractProcessor를 구현하는게 일반적입니다. 따라서 저도 그렇게 구현해보도록 하겠습니다.

package cwchoiit;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.element.TypeElement;
import java.util.Set;

public class AutoGetterProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }
}
  • 반드시 구현해야 할 메서드는 `process` 딱 하나입니다. 
  • 그 다음 두가지 애노테이션을 붙여주겠습니다. 아래와 같이요.
package cwchoiit;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import java.util.Set;

@SupportedAnnotationTypes("cwchoiit.AutoGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class AutoGetterProcessor extends AbstractProcessor {...}
  • 이 애노테이션 프로세서가 지원하는 애노테이션이 어떤 애노테이션인지를 알려주는 @SupportedAnnotationTypes 입니다. 당연히 위에서 만든 @AutoGetter입니다.
  • 그 다음은 지원하는 버전입니다. 최소 해당 버전 이상은 되어야 한다는 이야기고 여기서는 11버전을 작성했습니다. 참고로 이 애노테이션을 사용 안하면 AbstractProcessor가 가지고 있는 getSupportedSourceVersion()을 사용합니다.

 

자 그럼, 이제 애노테이션 프로세서를 직접 만들어 보겠습니다. 우선 구현해야 하는 메서드가 `process()`라고 했죠? 이건 뭐하는 앨까요?

이 메서드는 만약 `true`를 리턴하면, 여기서 이 애노테이션을 처리를 다 했다고 판단하고 다음 애노테이션 프로세서에게 넘기지 않습니다. 그러니까 만약, @AutoGetter를 처리하는 애노테이션 프로세서가 딱 이 하나라면 `true`를 리턴하면 되겠죠? 반면에, 또 다른 애노테이션 프로세서가 있고 그 프로세서 역시 @AutoGetter를 처리하는 또다른 애노테이션 프로세서라면 `false`를 리턴해서 두 프로세서 모두 통과하도록 해줘야 합니다. 그래서 저는 `true`를 리턴하도록 하겠습니다. 어차피 이거 하나만 만들거니까요.

package cwchoiit;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import java.util.Set;

@SupportedAnnotationTypes("cwchoiit.AutoGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class AutoGetterProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(AutoGetter.class);
        for (Element element : elements) {
            if (element.getKind() != ElementKind.FIELD) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@AutoGetter 애노테이션은 필드에만 사용할 수 있습니다. 현재 사용 위치:" + element);
            } else {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "@AutoGetter 처리 대상 필드: " + element.getSimpleName());
            }
        }

        return true;
    }
}
  • 우선, 처음으로 할 일은 이 애노테이션의 검증입니다. 위에서 애노테이션 프로세서가 하는 역할 중에 애노테이션의 검증도 있다고 했죠? 적절한 곳에 애노테이션이 위치했는지 파악을 해야 합니다. 따라서 먼저 ElementKind.FIELD가 아닌 경우 컴파일 오류를 뱉어내도록 합니다. 
  • 일단 이 상태로만 마치고 애노테이션 프로세서를 등록해볼까요? 하나씩 하나씩 나아가면 좋으니까요.

애노테이션 프로세서는 어떻게 등록할까요? 원래는 이렇게 해야합니다.

  • main 하위에 `resources` 폴더를 만들고, 해당 폴더 하위에 META-INF/services 폴더를 만들어서 그 안에 `javax.annotation.processing.Processor`라는 파일을 만들어야 합니다.
  • 그리고 이 파일 안에 지금 제가 만든 애노테이션 프로세서의 풀 패키지명을 작성해주면 됩니다. 다음과 같이요.
cwchoiit.AutoGetterProcessor

 

그런 다음에 이 상태에서 빌드를 해볼까요? 다음 명령어를 실행해봅니다.

mvn clean install

 

잘 될까요? 안 됩니다. 다음과 같이 에러를 마주하게 됩니다.

이 에러가 발생하는 이유는, 지금 이 Maven으로 빌드를 하는 과정중에 즉, 소스를 컴파일 하는 시점에 이 프로세서가 동작을 하려고 합니다. 왜냐하면, 제가 프로세서로 등록을 했으니까요. 그런데 지금 최초 빌드이죠? 그럼 이 애노테이션 프로세서가 등록이 안 된 상태겠죠? 그래서 없다고 하는겁니다. 그래서 이 경우에는 어떻게 해야하냐면, 먼저 저 resources에 등록한 애노테이션을 잠깐 주석 처리하고 빌드를 합니다. 그러면 빌드가 끝나면 애노테이션 프로세서가 만들어지겠죠? 그리고 다시 `mvn install`을 하는겁니다.

 

그래서 우선, 애노테이션 프로세서를 등록한 것을 주석 처리 후 빌드를 먼저 합니다. 그럼 다음과 같이 정상적으로 빌드가 끝납니다.

 

그리고 컴파일된 파일들을 보시면, 다음과 같이 `AutoGetterProcessor`가 만들어졌죠?

이 상태에서 `mvn install`을 하면 됩니다. 그런데, 굉장히 불편하죠? 그래서 이런 불편함을 해결해주기 위한 라이브러리가 하나 있습니다. 바로 구글에서 만든 `auto-service`인데요. 한번 사용해보겠습니다. 다음과 같이 의존성을 추가해 줍니다.

<dependency>
  <groupId>com.google.auto.service</groupId>
  <artifactId>auto-service</artifactId>
  <version>1.1.1</version>
</dependency>

 

그리고, 아까 만든 애노테이션 프로세서에 가서, 이런 애노테이션 `@AutoService(Processor.class)` 을 추가해줍니다.

@AutoService(Processor.class)
@SupportedAnnotationTypes("cwchoiit.AutoGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class AutoGetterProcessor extends AbstractProcessor {...}
  • 이 녀석을 추가하면, 아까 만든 `META-INF/services/javax.annotation.processing.Processor` 이 파일을 컴파일 하고 자동으로 만들어 주고, 그 파일 안에 이 애노테이션 프로세서를 등록해줍니다.

잘 되는지 확인하기 위해, 다시 `mvn clean install`을 해보면 정상적으로 빌드가 됩니다. 근데 눈으로 봐야 믿을 수 있잖아요 우리들은? 그래서 만들어진 `.jar`파일을 `.zip` 파일로 바꾼 다음에 안에 파일을 까볼까요? 

까보면, 이렇게 잘 만들어진 것을 확인할 수 있네요!

 

 

그럼 실제로 이 애노테이션 프로세서가 잘 동작하는지 확인해보겠습니다. 방금 다음 명령어를 입력했죠.

mvn clean install

이렇게 `install`을 하면 로컬 레포지토리에 해당 `.jar` 파일이 추가가 되잖아요? 

 

그 말은 다른 프로젝트에서 이 `.jar`파일을 다운받아 사용할 수 있다는 말입니다. 한번 아까 최초에 만든 `chyonibok`으로 다시 돌아가볼까요? 그 전에 pom.xml 파일에서 이 부분만 복사해서 돌아가죠. 내려받아야 하니까요.

 

그리고, 다시 돌아간 프로젝트에서 저 라이브러리를 추가해봅니다.

 

추가하면, 다음과 같이 외부 라이브러리에 잘 추가가 된 모습을 볼 수 있어요!

 

 

이제 아까 컴파일 오류가 났었던 @AutoGetter를 사용한 Member 클래스로 가볼까요? 이젠 컴파일 오류가 나지 않습니다!

그리고 이 상태에서 빌드를 해보시면 아무런 문제가 없을거에요. 왜냐하면 지금 애노테이션이 필드에 잘 붙어있으니까요. 그러나, 이 애노테이션을 클래스 레벨에 붙이면 이제 컴파일 오류를 보실 수 있습니다.

 

다음과 같이 클래스 레벨에 붙여주세요.

빌드를 하니 이런 예쁜 에러가 발생하네요! 😆

 

 

 

이제, 애노테이션 프로세서의 동작은 확인을 해봤습니다. 여기서 끝내면 안되죠?! 이제 실제로 Getter 메서드를 만들어 볼거예요!

Javapoet

이제 메서드를 동적으로 만들어야 합니다. 메서드나 클래스를 동적으로 만들어낼 때 자주 사용되는 라이브러리인 `javapoet`을 사용해볼게요.

package cwchoiit;

import com.google.auto.service.AutoService;
import com.squareup.javapoet.*;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.Diagnostic;
import java.io.IOException;
import java.util.Set;

@AutoService(Processor.class)
@SupportedAnnotationTypes("cwchoiit.AutoGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class AutoGetterProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(AutoGetter.class);
        for (Element element : elements) {
            if (element.getKind() != ElementKind.FIELD) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@AutoGetter 애노테이션은 필드에만 사용할 수 있습니다. 현재 사용 위치:" + element);
            } else {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "@AutoGetter 처리 대상 필드: " + element.getSimpleName());
            }

            VariableElement variableElement = (VariableElement) element; // 필드로 캐스팅
            TypeElement enclosingClass = (TypeElement) variableElement.getEnclosingElement(); // 부모 클래스
            String fieldName = variableElement.getSimpleName().toString(); // 필드 이름 
            TypeName fieldType = TypeName.get(variableElement.asType()); // 필드 타입

            String className = enclosingClass.getSimpleName().toString(); // 기존 클래스 명 
            String packageName = processingEnv.getElementUtils() // 기존 패키지 명
                    .getPackageOf(enclosingClass)
                    .getQualifiedName()
                    .toString();

            // Getter 메서드 생성
            MethodSpec getterMethod = MethodSpec.methodBuilder("get" + capitalize(fieldName))
                    .addModifiers(Modifier.PUBLIC)
                    .returns(fieldType) // 반환 타입 설정
                    .addStatement("return this.$L", fieldName) // 메서드 본문
                    .build();

            // 새 클래스 생성
            TypeSpec newClass = TypeSpec.classBuilder(className + "AutoGenerated")
                    .addModifiers(Modifier.PUBLIC)
                    .addField(fieldType, fieldName, Modifier.PRIVATE) // 기존 필드 추가
                    .addMethod(getterMethod) // Getter 메서드 추가
                    .build();

            Filer filer = processingEnv.getFiler();

            try {
                JavaFile.builder(packageName, newClass)
                        .build()
                        .writeTo(filer);
            } catch (IOException e) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Fatal: " + e.getMessage());
            }
        }

        return true;
    }

    private String capitalize(String name) {
        return Character.toUpperCase(name.charAt(0)) + name.substring(1);
    }
}
  • 우선, 해당 필드의 Getter 메서드를 만들어야겠죠?
MethodSpec getterMethod = MethodSpec.methodBuilder("get" + capitalize(fieldName))
                                    .addModifiers(Modifier.PUBLIC)
                                    .returns(fieldType) // 반환 타입 설정
                                    .addStatement("return this.$L", fieldName) // 메서드 본문
                                    .build();
  • 현재 필드의 필드 이름을 가지고 Getter 메서드를 만듭니다. MethodSpecjavapoet에서 제공해주는 메서드를 동적으로 만들어주는 녀석입니다. 이렇게 Getter 메서드를 동적으로 만들 수가 있습니다.

그런데, 사실 Lombok은 AST를 조작한다고 했잖아요? 이 AST를 조작해서 기존 클래스에 메서드를 추가하는 방식을 Lombok은 컴파일러 내부 API를 사용합니다. 그런데 말이죠. 컴파일러 내부 API는 공개 API가 아닙니다. 쉽게 말해 저희같은 일반인들은 가져다가 사용하는게 거의 불가능에 가깝죠. 과거 JDK8 이전에는 `tools.jar` 파일에 컴파일러 내부 API가 포함되어 있었다고 합니다. 그런데 JDK9 이상부터는 모듈로 전환이 되고, 해당 모듈은 기본적으로 공개되지 않습니다. 그래서, 기존 클래스에 추가하는 방식 말고 아예 새로운 클래스를 만들고 그 클래스에 Getter를 추가한 클래스를 만들어 볼게요. 결국 이 과정도 애노테이션 프로세서를 이용하는 것이니까요.

// 새 클래스 생성
TypeSpec newClass = TypeSpec.classBuilder(className + "AutoGenerated")
        .addModifiers(Modifier.PUBLIC)
        .addField(fieldType, fieldName, Modifier.PRIVATE) // 기존 필드 추가
        .addMethod(getterMethod) // Getter 메서드 추가
        .build();
  • `javapoet`으로 클래스를 만드려면 TypeSpec을 사용하면 됩니다. 기존 클래스명에 `AutoGenerated`라는 이름을 추가해볼게요.
  • 그리고 여기에 addMethod()로 아까 위에서 만든 메서드를 추가해주면 됩니다.
  • 여기까지만하면 실제로 만든건 아니고 메모리에 클래스와 메서드를 만들어 낸것까지 한거예요. 우리는 메모리에만 둥둥 떠있는게 아니라 실제 클래스로 만들어야겠죠?
Filer filer = processingEnv.getFiler();

try {
    JavaFile.builder(packageName, newClass)
            .build()
            .writeTo(filer);
} catch (IOException e) {
    processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Fatal: " + e.getMessage());
}
  • 그래서, JavaFile로 해당 클래스가 있던 패키지 위치에 새로 만든 클래스를 만들어 줍니다. 

 

이제, 다 끝났습니다. 이 소스를 다시 `install`해서 `chyonibok` 프로젝트에서 사용해볼게요. 다시 설치하면 사용했던 프로젝트에서도 다시 로드를 해줘야해요. 그래서 `chyonibok` 프로젝트에서 다음과 같이 리로드를 해주세요.

 

리로드가 잘 됐으면, 다시 다음 명령어를 실행해볼까요? 다시 `chyonibok` 프로젝트를 컴파일해서 애노테이션 프로세서가 동작해야겠죠?

mvn clean compile

 

컴파일한 후, 만들어진 `target` 폴더에 어떤 클래스가 있는지 보면 놀랄겁니다!

이렇게 `MemberAutoGenerated`라는 클래스가 생겨버렸어요! `chyoniboklib` 프로젝트로 만든 애노테이션 프로세서가 잘 동작해서 이 라이브러리를 가져다가 사용하는 `chyonibok` 프로젝트에서 컴파일 시 해당 애노테이션 프로세서가 동작한거예요! 이 코드 한번 볼까요? Getter가 아주 이쁘게 만들어졌습니다.

 

가져다가 사용하는 것도 당연히 되겠죠? 한번 해볼까요? 

  • 문제없이 잘 가져다가 사용할 수가 있습니다. (참고로, 이게 사용이 안된다면 IDE에서 Enable annotation processing을 체크해줘야 합니다)

 

정리

Lombok의 동작 원리와 실제로 비슷하게나마 Lombok의 구현을 해봤습니다. 사실 Lombok은 새로운 클래스를 만들지는 않습니다. 기존 클래스에 메서드를 붙여버립니다. 그 방식을 AST 조작을 통해서 해내는 것이구요. 이 포스팅에서는 AST 조작 대신 새로운 클래스를 만들어내고 그 클래스에 Getter 메서드를 만들어봤습니다. 저는 처음에 Lombok의 동작 원리를 이해해보니 이렇게까지 깊은 내용이구나.. 싶으면서도 또 다른 세계가 펼쳐진 것 같아서 재밌었습니다. 

 

그럼, AST 조작을 하는건 거의 불가능에 가깝잖아요? 자바 컴파일러 내부 API를 사용하니까요. 그럼 바이트코드 조작을 해서 기존 클래스에 Getter 메서드를 추가하는건 어떨까요?! 재밌을 것 같지 않나요?

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

[Java 8] CompletableFuture  (0) 2024.11.30
[Java 8] Optional  (0) 2024.11.27
[Java 8] Stream API  (0) 2024.11.27
[Java 8] 함수형 인터페이스와 람다 표현식  (0) 2024.11.25
애노테이션  (6) 2024.10.21
728x90
반응형
SMALL

이번 포스팅에서는 자바8에서 새로 도입된 CompletableFuture에 대해 알아보자. 이름에 Future가 들어가니까 Executors 프레임워크를 사용할 때 배워봤던 그 Future와 연관이 있나? 생각이 든다. 맞다.

 

CompletableFuture의 탄생 배경

자바에서 비동기 프로그래밍을 하려고 하면, Future를 사용해서도 어느정도는 가능했다만, 불편한 점들이 있다.

뭐가 불편했지?를 고민하기 전에 자바에서 비동기 프로그래밍이라는 건 어떤건지 먼저 감을 잡기 위해 아래 코드를 보자.

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newSingleThreadExecutor();

        Future<String> future = executorService.submit(new Task());

        // 이곳에서 future 를 기다리지 않고 계속 작업이 가능

        String futureResult = future.get();// 블로킹 메서드
        
        // 이곳에서 future 의 결과를 가지고 작업을 할 수 있음
        System.out.println(futureResult);

        executorService.shutdown();
        executorService.close();
    }

    static class Task implements Callable<String> {

        @Override
        public String call() throws Exception {
            System.out.println("Hello Callable");
            return "Im Callable";
        }
    }
}
  • 지금 코드를 보면, Executors 프레임워크를 사용해서 쓰레드 하나짜리 풀을 만들고 Callable을 수행한다.
  • 비동기 프로그래밍이란 건 Taskcall() 메서드를 실행하는 것을 기다리지 않고 내가 하고자하는 작업을 진행하는 걸 말한다.
  • 근데 여기서 불편한 점이 있다. future.get()을 호출하기 전에는 기다리지 않고 계속 무언가 작업이 가능하지만 get()을 호출하고 나서는 결국 저 Future의 작업이 끝날때까지 기다려야 하는 블로킹이 걸린다.
  • 그리고 나서 그 결과를 받아오고 나서 그 결과를 가지고 작업을 할 수 있다.

그러니까, Future를 이용해서 비동기 프로그래밍을 하려면 결국 get()을 호출하기 전에 최대한 열심히 무언가 작업을 해야하고 get()을 호출하고 나서 그 Future의 결과를 통해 어떤 작업을 수행할 수 있는 것이다. 

 

그런데 이제 이런 불편함이 생긴것이다.

→ 블로킹 메서드(get())을 호출하기 전에는 작업이 끝났을 때 콜백함수를 실행할 수 없다. 콜백함수를 미리 지정해 놓을 수 있다면 굉장히 편리할 것 같다.

 

CompletableFuture 사용

비동기 작업의 리턴값이 없는 경우: runAsync()

위와 같은 불편함을 해소하고자 이 CompletableFuture가 등장했다. 이 코드는 어떻게 사용하는지 한번 보자.

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) {
        CompletableFuture.runAsync(() -> System.out.println("Run" + Thread.currentThread().getName()));

        System.out.println("Hi " + Thread.currentThread().getName());
    }
}
  • 어떤 작업을 비동기적으로 실행만 하면 될때, CompletableFuturerunAsync()를 사용할 수 있다.
  • 이렇게 작성하면, CompletableFuture의 작업과는 아무런 영향없이 그 이후의 코드를 실행할 수 있다.

실행 결과

 

비동기 작업의 리턴값이 있는 경우: supplyAsync()

이번에는 위와 다르게 비동기 작업의 리턴값이 있는 경우에는 어떻게 하면 될까?

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        System.out.println("Hi " + Thread.currentThread().getName());
        System.out.println("Future return: " + future.get());
    }
}
  • 어떤 작업을 비동기적으로 수행하고 그 수행의 리턴값이 있는 경우에는 이렇게 supplyAsync()를 사용하면 된다.

실행 결과

 

 

그런데 지금까지는, Future를 사용하는 것과 별반 다른게 없다. 그래서 이제 Future와 어떤것이 확연히 다른지를 살펴보자.

CompletableFuturecallback

자, 만약 내가 어떤 작업을 비동기적으로 수행하고 그 결과를 통해 무언가를 또 하고 싶을때 어떻게 하면 될까? Future를 사용했을 땐, get()을 호출하고 결과를 받을때까지 블로킹 상태로 대기하다가 결과가 나오면 그때 무언갈 할 수 있었다.

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }).thenApply(s -> {
            System.out.println(s + " " + Thread.currentThread().getName());
            return s.toUpperCase();
        });

        System.out.println("Hi " + Thread.currentThread().getName());
        System.out.println("Future return: " + future.get());
    }
}
  • 코드를 보면, thenApply()를 호출한다. 이게 바로 어떤 비동기 작업의 결과를 통해 무언가를 실행하는 콜백 함수이다.
  • 그래서, 결과로 받은 문자열을 대문자로 전부 변경하고 그 값을 반환한다.
  • 실제로 future.get()을 호출해보면 결과는 다음과 같다.

 

물론, 콜백 함수가 어떤 반환값이 필요하지 않은 경우가 있을수도 있다.

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }).thenAccept(System.out::println);

        System.out.println("Hi " + Thread.currentThread().getName());
        System.out.println("Future return: " + future.get());
    }
}
  • 그럴때는 이렇게 thenAccept()를 호출하면 된다.
  • 그리고 이게 다 이전 포스팅을 배운 이유인게 thenAccept()는 인자로 무엇을 받냐면 Consumer를 받는다. Consumer는? 무언갈 받아서 소비만 하고 따로 리턴하는 게 없는 함수형 인터페이스다.

  • 반대로, 아까 콜백 함수를 사용하는데 반환값도 있었던 thenApply는 무엇을 받을까?

  • 이렇듯 Function을 받는다. 함수형 인터페이스 Function은 어떤값(T)를 받아 어떤값(U)로 반환한다.

 

그래서 이렇게 CompletableFuture를 사용하면, 비동기 프로그래밍을 훨씬 쉽고 편리하게 할 수가 있다.

그런데, 궁금한게 있다. Future를 사용했을 때는 Executors 프레임워크로 스레드 풀을 만들고 그 쓰레드 풀에서 스레드를 꺼내와서 사용했는데 여기서는 어떻게 된게 쓰레드 풀도 따로 안 만들고 어떻게 쓰레드가 생기고 하는 걸까?

 

실행해서 현재 쓰레드의 이름을 찍어보면 이런식으로 나온다.

ForkJoinPool.commonPool-worker-1

ForkJoinPool? 이 녀석은 자바7에서 추가된 병렬 작업을 처리하기 위한 효율적인 스레드 풀이다. 그리고 CompletableFuture는 기본적으로 ForkJoinPool.commonPool()을 사용한다. 그런데 이 풀은 기본적으로 CPU 코어 수에 비례하여 스레드 수를 제한한다. 근데 사실 그렇게 되면 CPU 코어 수가 아무리 많아봐야 20개가 안되는데 보통의 멀티쓰레드를 사용하는 애플리케이션은 쓰레드 1000개도 만들고 한다. 따라서 생각보다 이 ForkJoinPool이 비효율적 일수가 있는데 이럴때를 대비해 명시적으로 Executors에서 사용하는 스레드풀을 지정할 수가 있다. 아래 코드를 보자.

package executors;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newSingleThreadExecutor();

        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }, executorService);

        System.out.println(hello.get());
    }
}
  • supplyAsync()에는 두번째 파라미터로 Executor를 넘길 수가 있다. 그래서 내가 만든 ExecutorService를 넘겨주면 이 스레드 풀을 사용한다. 아 물론, supplyAsync()뿐 아니라 runAsync()도 이렇게 사용할 수 있다. 

 

CompletableFuture의 사용 2 - 조합

thenCompose()

이번에는 조금 더 깊게 들어가서, Future만을 사용했을 때 또 어떤 점이 불편했냐면, A라는 Future 하나와, B라는 Future 하나가 있을 때 이 두개를 이어서 하려면 A를 get()하고, B를 get()해서 이 두개의 결과를 가지고 이후 코드를 작성해야 했다.

 

근데, 이제 CompletableFuture를 사용하면, 어떻게 편리하게 사용할 수 있을까?

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        CompletableFuture<String> future = hello.thenCompose(Main::getWorld);
        System.out.println("future.get() = " + future.get());
    }

    private static CompletableFuture<String> getWorld(String word) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return word + " World";
        });
    }
}
  • CompleteableFuture에는 thenCompose()라는 게 있다. 이 녀석을 사용하면 어떤 Future의 결과를 받아서 새로운 Future를 이어갈 수 있다.
  • thenCompose()는 파라미터로 Function 함수형 인터페이스를 받는다. 즉, 어떤 값을 받아 어떤 값으로 변환해준다는 의미가 된다. 그리고 반환타입은 CompletableFuture<T>이다. 새로운 Future를 반환한다는 의미이다.
  • 그래서, Future의 결과값을 전달해주고 새로운 Future를 만들어낸다.

  • 그래서 thenCompose()를 사용하면 CompletableFuture를 이어서 실행할 수 있다.

thenCombine()

그런데 위의 케이스의 경우, 두 Future가 서로 연관관계가 있어서 어떤게 먼저 실행하고 그 다음걸 실행할 때 유용하게 사용할 수 있다. 코드도 보면 "Hello World"를 찍기 위해 "Hello"를 반환하는 Future를 먼저 실행하고 그 다음 "World"를 반환하는 Future를 실행한 것처럼 Future끼리 연관관계가 있을 때 Future끼리 이어 실행할 수 있게 하는게 thenCompose()였다면, 아무런 연관관계가 없지만 동시에 실행시키고 싶을 때도 있을 것이다. 

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "World";
        });

        CompletableFuture<String> future = hello.thenCombine(world, (h, w) -> h + " " + w);
        System.out.println("future.get() = " + future.get());
    }
}
  • 이럴때 사용하는게 thenCombine()이다. 아무 연관관계는 없지만 동시에 실행시키고 그 두 Future의 결과값으로 새로운 것을 만들어낼 때 유용하다. 그래서 지금 hello, world 라는 두 CompletableFuture가 있을 때 얘네가 누가 먼저 실행되어야 하고 그런건 아니지만 이 두개의 결과를 가지고 무언가를 만들어낼때 사용하기 딱 좋은게 thenCombine()이다.
  • thenCombine()은 첫번째 인자로, CompletionStage를 받는다. 이건 CompletableFuture가 구현한 인터페이스라서 CompletableFuture가 들어갈 수 있다. 그리고 두번째 인자로 BiFunction을 받는다. 정말 딱 들어맞는 함수형 인터페이스 아닌가? 

실행 결과

 

 

allOf()

이번에는 CompletableFuture가 2개 이상일때, 그 모든 CompletableFuture를 한번에 다 처리하고 어떤 작업을 진행할 수 있는 방법을 소개한다. 

package executors;

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "World";
        });

        CompletableFuture[] futures = new CompletableFuture[]{hello, world};
        CompletableFuture<List<Object>> results = CompletableFuture.allOf(futures)
                .thenApply(v -> Arrays.stream(futures).map(CompletableFuture::join).toList());

        results.get().forEach(System.out::println);
    }
}
  • allOf()CompletableFuture의 배열을 받는다. 그래서 넘겨받은 모든 CompletableFuture의 모든 작업이 끝나면, thenApply()가 호출되는데 여기서 각 Futureget()을 호출하면 되지만 get()은 체크 예외를 던지기 때문에 처리하기가 좀 난감해진다. 그렇기에 join()을 호출하면 동일하게 작업의 결과를 받아오지만 체크예외가 아닌 언체크예외를 던지기 때문에 예외처리를 위한 별도의 동작이 필요없어진다.
  • 물론, join() 대신 get()을 호출하고 예외처리를 직접 해주어도 상관은 없다. 어떤 것을 사용하든 각각의 Future의 결과값을 리스트로 변환하고 해당 리스트를 순회하면서 출력하는 코드이고 결과는 다음과 같다.

실행 결과

 

anyOf()

이번에는 모든 Future가 끝난 후 실행되는 것 말고 어떤 Future라도 제일 빨리 끝난게 생기면 무언가를 처리할 수 있는 anyOf()에 대해 알아보자. 

package executors;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "World";
        });

        CompletableFuture.anyOf(hello, world).thenAccept(System.out::println);
    }
}
  • 이렇게 anyOf()를 사용해서 여러 Future를 받고, thenAccept()를 호출해서 둘 중 먼저 실행이 끝난것을 받아 시스템 콘솔에 출력한다. 이때는 어떤게 먼저 실행될지에대한 보장은 없다. 그래서 실행할때마다 실행 결과가 달라진다.

실행 결과

 

예외 상황

이번에는 CompletableFuture를 실행하다가 예외가 발생한 경우 어떻게 다뤄야 하는지 알아보자.

package executors;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        boolean isError = true;

        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            if (isError) {
                throw new IllegalStateException("Oh shit Error!");
            }
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }).exceptionally(ex -> {
            System.out.println("Exception " + ex.getMessage());
            return "Error!";
        });

        System.out.println(hello.get());
    }
}
  • CompletableFuture로 어떤 작업을 처리하다보면 당연하게도 예외는 발생할 수 있다. 그럴때 예외가 터지면 exceptionally()를 사용해서 해당 예외를 받고 어떤 처리를 할 수 있다.
  • 그래서 이 코드를 실행해보면 다음과 같다.

실행 결과

 

그런데 이제, 이렇게 exceptionally()를 사용해서도 처리할 수 있지만, 이건 완전 예외를 위한거라면 handle()이라는 녀석도 있다.

package executors;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        boolean isError = true;

        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            if (isError) {
                throw new IllegalStateException("Oh shit Error!");
            }
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }).handle((result, error) -> {
            if (error != null) {
                System.out.println(error.getMessage());
                return "Error!";
            }
            return "Hello!";
        });

        System.out.println(hello.get());
    }
}
  • handle()은 정상케이스와 에러케이스 두 개를 모두 다룰 수 있다. 그리고 handle()의 파라미터를 보면 BiFunction이다. 두개를 받아 하나로 반환하는 함수형 인터페이스. 확실히 배우면 배울수록 함수형 인터페이스를 잘 배워놨다는 생각이 든다.

실행 결과

 

 

정리를 하자면

자바에서 멀티쓰레드를 다루는 방법의 아주 대표적인 프레임워크는 Executors 프레임워크다. 이 녀석을 사용해서 쓰레드 풀도 만들고 Callable을 실행하고 Future를 받아 처리한다. 아주 좋지만, Future의 단점 중 하나는 비동기 프로그래밍을 하기가 꽤나 까다롭다는 것이다. 그래서 자바8부터 CompletableFuture가 등장하고 이 녀석을 사용하면 작업이 다 끝난 후 콜백함수를 정의하여 매우 편하게 비동기 프로그래밍을 할 수 있게 됐다. 그에 대한 내용을 살펴보았다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Lombok은 어떻게 동작하는걸까?  (0) 2024.12.01
[Java 8] Optional  (0) 2024.11.27
[Java 8] Stream API  (0) 2024.11.27
[Java 8] 함수형 인터페이스와 람다 표현식  (0) 2024.11.25
애노테이션  (6) 2024.10.21
728x90
반응형
SMALL

자바8 이후에 또 아주 자주 사용되고 중요한 Optional에 대해 알아보자!

 

NullPointerException

자바 프로그래밍을 하다가 왜 이 NullPointerException이 종종 발생할까? 왜긴 왜야? null 체크를 깜빡 했으니까. 다음 코드를 보자.

 

Shop

public class Shop {
    private String name;
    private User host;
    private boolean isOpen;

    public Shop() {
    }

    public Shop(String name, boolean isOpen) {
        this.name = name;
        this.isOpen = isOpen;
    }

    public Shop(String name, User host, boolean isOpen) {
        this.name = name;
        this.host = host;
        this.isOpen = isOpen;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public User getHost() {
        return host;
    }

    public void setHost(User host) {
        this.host = host;
    }

    public boolean isOpen() {
        return isOpen;
    }

    public void setOpen(boolean open) {
        isOpen = open;
    }

    @Override
    public String toString() {
        return "Shop{" +
                "name='" + name + '\'' +
                ", isOpen=" + isOpen +
                '}';
    }
}

 

User

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 void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
  • Shop 이라는 클래스에는 세가지 필드가 있다. `name`, `host`, `isOpen`.
  • 그리고 `host`는 타입이 레퍼런스 타입(User)이기 때문에 이 필드에 무언갈 채우지 않았다면 그 값은 null이다.

OptionalMain

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop();
        shop.setName("Shop A");
        shop.setOpen(true);

        System.out.println(shop.getHost().getName());
    }
}
  • 이 코드를 실행하면 어떻게 될까? 이 코드를 실행하면 NullPointerException이 발생한다.
  • 왜냐? getHost()null인데, `null.xxx`를 하는 순간 해당 에러가 터지는 것이다.

 

그럼 이런 NullPointerException을 방지하려면 어떻게 코드를 짜야하냐? 자바8 이전에는 이렇게 했다.

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop("A", true);

        if (shop.getHost() != null) {
            System.out.println(shop.getHost().getName());
        }
    }
}
  • 해당 값이 null인지, null이 아닌지 체크를 먼저 하고 그 이후에 뭔가를 진행하는 코드를 작성했다.
  • 이때 문제는? 이 코드를 작성하는 건 '사람'이다. 사람은? null 체크를 까먹을 확률이 매우 크다. 

또 다른 방법으로는 이런 방법도 있겠다.

public User getHost() {
    if (host != null) {
        return host;
    }
    throw new IllegalStateException("this field null");
}
  • 이건 더 큰 문제가 있다. 
  • 우선 이것도 역시 사람이 작성하는 것이라 null 체크를 안 할 가능성이 있다.
  • 위 코드처럼 null 체크를 했다고 치자. null인 경우, IllegalStateException을 던진다. 예외를 던지는 것은 생각보다 비싼값을 치뤄야 한다. 왜냐? 스택 트레이스를 찍어야 하니까.
  • 그리고 예외를 던지면, 사용하는 클라이언트 코드는 이번엔 null 에서는 해방될지언정, 예외 처리를 해줘야 한다.

 

Optional의 등장

자바8 이후로 이 Optional이 등장하면서, Optional을 리턴할 수 있게 됐다.

Optional은 뭔데? → 오직 한 개의 값이 들어있을수도 안 들어 있을수도 있는 컨테이너.

 

다음 코드를 보자.

public Optional<User> getHost() {
    return Optional.ofNullable(host);
}
  • 이번엔 getHost()Optional<User>를 리턴한다.
  • 그리고 실제 반환값으로는 Optional.ofNullable(host); 이다. 이름 그대로 null일수도 있다는 뜻이다.

그럼 이 코드를 사용하는 클라이언트 쪽은 어떻게 하냐? 이렇게 한다.

import java.util.Optional;

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop();
        shop.setName("Shop A");
        shop.setOpen(true);

        Optional<User> host = shop.getHost();
        host.ifPresent(System.out::println);
    }
}
  • 이건 단지 예시 코드일 뿐이다. 보통은 메서드 체인 형태로 사용하겠지만 그냥 설명을 위해 이렇게 작성했다.
  • 클라이언트 코드는 이 타입이 Optional이라는 것을 확인하는 순간부터 무엇을 생각하게 되냐면 null을 생각하게 된다. 즉, 명시적으로 "이 값은 빈 값일수도 있어! 그러니까 체크해야해!" 이 말을 해주는 단어가 된다.
  • 그래서 사용자는, 이 값이 있다면 어떤 처리를 하고, 없다면 어떤 처리를 할지 분기할 수가 있다.
shop.getHost().orElseThrow(RuntimeException::new);
shop.getHost().orElse(new User("hello", 20));
shop.getHost().ifPresent(System.out::println);
shop.getHost().ifPresentOrElse(System.out::println, () -> { /*null 인 경우*/ });
  • 이렇게 말이다. 이 값이 없는 경우에 대한 처리를 사용자로부터 강제하게 된다.
  • 그러다 보니 조금이라도 null safety한 코드가 작성될 수 있을 것이다.

그러니까, 명백하게 이 Optional을 제대로만 사용한다면 NullPointerException 으로부터 꽤나 자유로워 질 수 있을것만 같다.

Optional 주의점

그런데 Optional을 사용할 때 주의점이 있다. 일단, 다음 코드를 보자.

1. null일 수 있는 값을 Optional.of(...)로 사용하지 말 것

public Optional<User> getHost() {
    return Optional.of(host);
}
  • 이 코드 매우 위험하다.
  • Optional.of(...)는 전달하는 인자가 null이면 안된다.
  • of()를 사용하면 그 값이 null이 아닌 상태여야 한다. 만약, null이라면 이 자체로 NullPointerException이 발생한다.

반드시 null일 수도 있는 값은 Optional.ofNullable(...)을 사용하자.

public Optional<User> getHost() {
    return Optional.ofNullable(host);
}

 

2. 리턴값으로만 사용하기를 권장한다.

이게 무슨말일까? 다음 코드를 보자.

public Optional<User> getHost() {
    return Optional.ofNullable(host);
}
  • 이게 바로 리턴값으로 사용한 Optional이다.
  • 이렇게만 사용하라는 이야기다.

 

메서드 매개변수 타입, 맵의 키 타입, 인스턴스 필드 타입으로 쓰는 경우가 있는데 그런 경우를 최대한 최대한 최대한 지양해라!

아래에서 하나씩 그 이유를 알아보자.

 

2-1. 메서드 매개변수 타입을 Optional로 사용하지 말 것

public String isHostNameEqualsShopName(Optional<User> host) {
        ...
}
  • 지금 이 코드가 메서드의 매개변수로 Optional을 사용한 것이다.
  • 이거 왜 사용하면 안되냐? 다음 코드를 보자.
public String isHostNameEqualsShopName(Optional<User> host) {
    host.ifPresent(u -> {
        if (u.getName().equalsIgnoreCase(this.name)) {
            System.out.println("is equals!");
        }
    });
}
  • 지금 Optional로 받은 파라미터 덕분에 ifPresent()를 사용하는데, 이거 굉장히 위험한 코드다. 왜냐?
import java.util.Optional;

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop();
        shop.setName("Shop A");
        shop.setOpen(true);

        shop.isHostNameEqualsShopName(null);
    }
}
  • 이렇게 파라미터에 null을 넘기는 순간? host.ifPresent(...)에서 NullPointerException이 발생한다.
  • 그러니까 메서드 매개변수로 Optional 사용하는거 하지말자!

 

2-2. 맵의 키로 Optional을 사용하지 말 것

맵의 키로 Optional을 사용하는 건 정말 뭐랄까..? 맵이라는 자료 구조의 컨벤션을 망가뜨리는 행위이다.

Map을 통해 어떤 키값이 있는지 찾고 있으면 있는거고 없으면 명확히 없는거지, 있을수도 있고 없을수도 있다? ...음.. 하지말자!

사용하는 사람 입장에서 얜 뭘까.. 싶은 코드다 정말 이건.

 

 

2-3. 인스턴스 필드 타입으로 Optional을 사용하지 말 것

import java.util.Optional;

public class Shop {
    private String name;
    private Optional<User> host;
    private boolean isOpen;
}
  • 이런 경우를 말한다. 필드에 Optional을 적용하는 것.
  • 이것도 굉장히 나쁜 코드이다. 

`host`라는 필드는 원래도 null이 될 수도 그렇지 않을 수도 있다. 그렇기 때문에 getHost()와 같은 메서드를 이렇게 작성하는 것이다.

public Optional<User> getHost() {
    return Optional.ofNullable(host);
}

 

그런데, 왜 필드 자체에 이 값이 있을 수도 있고 없을 수도 있다고 더 더 모호하게 상황을 만드는가? 하지 말자!

 

 

3. Primitive 타입의 Optional은 따로 존재한다.

Optional.of(10);
  • 이렇게 Optionalprimitive 타입을 받을 순 있다. 근데, 곰곰히 생각해보자. primitive 타입에 null이란게 존재하는가? 아니다. 그런건 없다. 근데 primitive 타입을 Optional로 만든다? 이건 뭔가 매우 어색하기도 하면서 이걸 처리하기 위해 Boxing을 한다. 즉, 이 반환값은 이렇다.
Optional<Integer> i = Optional.of(10);
  • 그렇다. 타입이 Optional<Integer>로 변경된다. Primitive → Wrapper 클래스가 된다는 말이다. 이 과정에서 역시 리소스도 낭비되고 좋지 않다.

 

그래서, 이런게 있다.

OptionalInt optionalInt = OptionalInt.of(10);
  • OptionalInt가 있으면, OptionalDoubleOptionalLong도 있다.

 

4. Optional을 반환 타입으로 사용할 때 null을 리턴하지 마라.

진짜 정말 안 좋은 코드이다. 바로 보자.

public Optional<User> getHost() {
    return null;
}
  • 반환 타입은 Optional<User>이다. 리턴 타입으로 사용하고 있으니 위에서 말한 주의사항엔 걸리지 않는다.
  • 그런데 이 메서드의 리턴값이 null이면 어떤 문제가 발생하냐면, 
import java.util.Optional;

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop();

        Optional<User> host = shop.getHost();
        host.ifPresent(System.out::println);
    }
}
  • 이걸 사용하는 클라이언트 코드는, 이 메서드를 호출했을 때 반환값이 Optional이기 때문에 "어 Optional이네, 있는지 없는지 체크해야겠다."라고 생각하고 .ifPresent(...)를 호출하는 순간 NullPointerException이다.

그래서 이러면 안되고, 다음과 같이 해야 한다.

public Optional<User> getHost() {
    return Optional.empty();
}
  • Optional.empty()를 사용하면 된다.

 

5. Collection, Map, Optional 등 이미 이 자체로 빈 값이 될 수 있다고 표현하는 것들을 Optional로 또 감싸지 마라.

위에서 Map의 키를 Optional로 감싸지 마라. 라는 내용과 유사한데, 이미 이 자체로 빈 값인지 아닌지 판단할 수 있는 컨테이너들을 왜 Optional로 감싸나? 그럴 이유가 없는데. Mapget()을 통해 값이 있으면 가져오고 값이 없으면 null이다. 여기서 이미 판단을 할 수 있다. Collection, Optional도 마찬가지다. 이미 이 자체로도 빈 값인지 아닌지를 판단할수가 있는데 왜 Optional로 또 감싸는건가? 이러면 안된다.

 

 

 

정리

Optional을 제대로만 사용한다면, NullPointerException을 마주하는 경우를 굉장히 획기적으로 줄일수도 있고, 코드 자체의 가시성도 더 높일 수 있다. 개인적으로 좋아하는 방식이기도 하다. 그런데 이 Optional을 사용하는데 있어서 클라이언트 입장에서 매우 짜증나는 경우가 몇가지 있는데 그 경우를 여기에 정리했으니 이렇게 작성하는 것을 피하자! 

728x90
반응형
LIST
728x90
반응형
SMALL

Java 8 이후 가장 관심을 많이 받은 건 아마도 Stream API가 아닐까 싶을 정도로 굉장히 이제는 중요하고 모르면 절대 안되는 이 녀석에 대해 공부해보자.

 

Stream

  • 데이터를 담고 있는 저장소(컬렉션)가 아니다.
  • 스트림이 처리하는 데이터 소스를 변경하는 게 아니다.
  • 스트림으로 처리하는 데이터는 오직 한번만 가능하다.
  • 중개 오퍼레이션은 근본적으로 Lazy하다.
  • 손쉽게 병렬 처리를 할 수 있다.

이게 다 무슨말일까? 하나씩 차근 차근 알아가보자. 

 

데이터를 담고 있는 저장소가 아니다.

이건 말 그대로 스트림으로 처리하는 데이터는 컬렉션이 아니라는 말이다.

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Stream<String> stream = friends.stream();
    }
}
  • 위 코드를 보면, `friends`라는 Listfriends.stream()의 반환값은 명확히 Stream으로 다르다. 
  • 쉽게 말해 스트림은 저장소 역할을 하는게 아니라, 특정 자료구조에 대해 어떤 처리를 할 수 있는 일회성 공간이라고 생각해야 한다.

 

스트림이 처리하는 데이터 소스를 변경하는 게 아니다.

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        List<String> upper = friends.stream()
                .map(String::toUpperCase)
                .toList();

        System.out.println(friends);
        System.out.println(upper);
    }
}
  • 위 코드를 보면, `friends`를 스트림으로 각 요소별로 대문자로 변경하여 새로운 리스트를 만들어냈다.
  • 그럼 이건 기존의 `friends`도 변경하는 걸까? 아니다! 기존의 데이터 소스를 변경하는 게 아니다.
  • 다음 실행 결과를 보면, 기존의 데이터 소스는 그대로이고 스트림으로 처리한 데이터를 새로운 리스트를 반환하는 것이다.

실행 결과

 

스트림으로 처리한 데이터는 오직 한번만 가능하다.

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Stream<String> friendsStream = friends.stream();

        List<String> upperFriends = friendsStream.map(String::toUpperCase).toList();
        System.out.println(upperFriends);

        List<String> lowerFriends = friendsStream.map(String::toLowerCase).toList();
        System.out.println(lowerFriends);
    }
}
  • 위 코드를 보면, `friends`라는 리스트를 스트림으로 변환한 `friendsStream`이 있다. 
  • 이 녀석으로 각 요소에 대해 toUpperCase()를 처리한 새로운 리스트를 만들었다.
  • 그 이후에 다시 이 `friendsStream`을 이용할 수 있을까? 그렇지 않다. 위 코드처럼 다시 `friendsStream`을 통해 각 요소에 대해 toLowerCase()를 처리한 새로운 리스트를 만드려고 한다면 다음과 같은 에러를 마주하게 된다.

 

 

중개 오퍼레이션은 근본적으로 Lazy하다.

이게 조금 중요한 말인데, 보통은 스트림을 사용할 때 다음과 같이 사용하는 사례를 많이 마주할 것이다.

customFieldManager.getCustomFieldObjects().stream()
        .filter(customField -> customField.getCustomFieldType().getName().equals("Date Picker"))
        .map(SimpleCustomFieldDTO::new)
        .collect(Collectors.toList());
  • 위 코드는 예시 코드이다. 
  • 스트림으로 변환한 후, filter()를 거치고, map()을 거쳐서, 리스트로 최종적으로 반환한다.
  • 이렇듯, 스트림은 중개 오퍼레이션과 종료 오퍼레이션이 나뉘어져 있다.
  • 여기서 중개 오퍼레이션은 filter(), map()이고, 종료 오퍼레이션은 collect()이다.
  • 쉽게 생각하면, 중개 오퍼레이션은 반환값이 그대로 Stream이다. 반대로 종료 오퍼레이션은 반환값을 Stream으로 주지 않는다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Stream<String> stringStream = friends.stream().filter(friend -> friend.equals("John"));
        Stream<String> stringStream1 = friends.stream().map(String::toUpperCase);
    }
}
  • 보면 filter(), map() 까지만 실행한 반환값은 모두 Stream이다. 즉, 이 두개는 중개 오퍼레이션이라는 말이다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        List<String> friendsList = friends.stream()
                .filter(friend -> friend.equals("John"))
                .toList();
    }
}
  • 반면, toList()를 호출한 반환값은 Stream이 아니라 List이다. 즉, toList()는 종료 오퍼레이션이라는 말이다.
  • 참고로, forEach()도 종료 오퍼레이션이다. 얘는 반환값이 없기 때문에 헷갈릴 수 있어서 이렇게 꼭 집어서 말해봤다.

 

이제 중개 오퍼레이션과 종료 오퍼레이션의 차이를 알았다. 그럼 여기서 이제 "중개 오퍼레이션은 근본적으로 Lazy하다"라는 말은 무엇이냐면, 중개 오퍼레이션은 종료 오퍼레이션을 만나기 전까지 실제적으로 실행되지가 않는다! 다음 코드를 보자!

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.stream()
                .filter(friend -> {
                    System.out.println("friend: " + friend);
                    return friend.equals("John");
                });
    }
}
  • 자, filter()는 중개 오퍼레이션이다. 이 filter() 이후로 종료 오퍼레이션은 없다. 즉, 여전히 이 녀석은 타입이 Stream인 상태이다. 그런데 내가 filter() 안에 각 요소들을 찍어보고 싶어서 `System.out.println("friend: " + friend)`를 작성한다고 이게 출력될까? 

실행 결과

위 실행 결과처럼 아무것도 출력하지 않는다. 디버깅해서 브레이크 포인트 걸어봐도 안 걸린다! 그래서! 정말 중요한 내용이다! 중개 오퍼레이션은 근본적으로 Lazy하다는 말은, 종료 오퍼레이션을 만나기 전까지 중개 오퍼레이션의 작업은 진행되지가 않는다는 말이다! 그럼 내가 저 코드에 종료 오퍼레이션을 넣고 실행하면 결과는 어떻게 될까?

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.stream()
                .filter(friend -> {
                    System.out.println("friend: " + friend);
                    return friend.equals("John");
                }).toList();
    }
}
  • 딱 한 부분, toList()를 붙이는 것 말고 한 게 없다.

 실행 결과

 

정리

스트림 파이프라인은 0 또는 다수의 중개 오퍼레이션과 한개의 종료 오퍼레이션으로 구성되고, 스트림의 데이터 소스는 오직 종료 오퍼레이션을 실행할 때만 처리한다. 

 

 

손쉽게 병렬 처리를 할 수 있다.

이건 무슨말이냐면 이런 의문이 생길 것이다. "아니 뭐 굳이 스트림을 써? 그냥 for 루프 사용하면 되는거 아니야?" 맞다. 상관없다. 단순한 코드는 어떤걸 사용하더라도 뭐 크게 가시성이 달라지지도 않고 둘 다 읽기 편할 수 있다. 아래 코드를 보자.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.stream()
                .filter(friend -> friend.equals("John"))
                .forEach(System.out::println);

        for (String friend : friends) {
            if (friend.equals("John")) {
                System.out.println(friend);
            }
        }
    }
}
  • 하나는 스트림을 사용했고, 하나는 for 루프를 사용했다. 뭐 둘 다 읽기 편하고 아무런 문제도 없다. 
  • 실행 결과도 동일할 것이다.

 

그런데, 이런 경우가 있다. for 루프는 병렬 처리를 하기가 쉽지 않다. 위에 저 for 루프 안에서 요소 하나하나씩 순회하며 처리하지 저걸 병렬로 처리하고 있지 않는단 말이다. 그런데 스트림은 이걸 병렬로 처리하는게 굉장히 수월하다. 스트림은 요소 하나하나를 직렬로 어떤 행위를 처리할수도 있고 병렬로 어떤 행위를 처리할수도 있다. 

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.parallelStream()
                .filter(friend -> friend.equals("John"))
                .forEach(System.out::println);
    }
}
  • 이렇게 stream() 대신에 parallelStream()을 사용하면 끝난다. 
  • 이럼 요소를 병렬로 처리하게 된다.

근데 또 눈으로 봐야만 믿는 사람들을 위해(나 포함) 아래와 같이 코드를 조금만 더 수정해보자.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.parallelStream()
                .filter(friend -> {
                    System.out.println("friend: " + friend + "||" + Thread.currentThread().getName());
                    return friend.equals("John");
                })
                .forEach(System.out::println);
    }
}
  • 실행하고 있는 쓰레드의 이름을 한번 찍어보자.

실행 결과

이 실행결과를 보면 알겠지만, 쓰레드의 이름이 다르다. 즉, 병렬처리가 제대로 되고 있다는 뜻이다. 이게 이제 손쉽게 병렬 처리를 할 수 있다는 말이다.

 

근데 손쉽게 병렬 처리를 한다고 마냥 좋을까?

지금 저 코드에서 병렬 처리를 하면 더 빠를까? 내가 볼땐 아니다. 이것도 눈으로 한번 확인해보자.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        withParallelStream(friends);
        withForLoop(friends);
    }

    private static void withParallelStream(List<String> friends) {
        long startTime = System.currentTimeMillis();
        friends.parallelStream()
                .filter(friend -> friend.equals("John"))
                .forEach(System.out::println);
        long endTime = System.currentTimeMillis();
        System.out.println("parallelStream elapsed time: " + (endTime - startTime) + "ms");
    }

    private static void withForLoop(List<String> friends) {
        long startTime = System.currentTimeMillis();
        for (String friend : friends) {
            if (friend.equals("John")) {
                System.out.println(friend);
            }
        }
        long endTime = System.currentTimeMillis();
        System.out.println("for loop elapsed time: " + (endTime - startTime) + "ms");
    }
}
  • 두개를 실행해보자, 하나는 스트림으로 병렬처리를, 하나는 그냥 for 루프를 사용해서 동일한 작업을 하는데 시간을 측정해봤다.

실행 결과

엥?! 오히려 하나의 쓰레드로만 실행한게 더 빠르다. 왜 그럴까? 

 

→ 쓰레드를 여러개 사용하는데는 컨텍스트 스위칭 비용이 들기 때문이다. 또한, 쓰레드를 만들어내는 것도 리소스를 사용하는 것이기에 그렇다. 단순하게 생각해서 지금 저 `friends`라는 리스트는 그래봐야 개수가 4개밖에 없는 정말 작은 리스트이다. 그리고 요즘 컴퓨터는 1초에 연산을 몇번이나 할까? 수십억번을 한다. 수십억번. 4개 돌리는거? 일도 아니다. 그러니까 실제로 0ms가 걸린것이고. 그런데 4개 돌리는 그 와중에도 쓰레드를 3개를 더 만들어 총 4개를 사용하고, 그 4개를 돌려가며 사용하는 컨텍스트 스위칭 비용이 오히려 성능에 악화를 시키는 것이다. 그러니까 병렬 처리를 스트림으로 단순하게 할 수 있다고 해서 반드시 좋다고 생각하면 큰 오산이다. 이 경우가 그럼 언제 효율적일까? 데이터가 정말 많을때나 그 데이터로 처리하는 로직이 굉장히 복잡해서 하나의 쓰레드가 다 하는것보다 여러개의 쓰레드가 나눠 하는게 누가봐도 더 효율적일때 이 병렬 처리를 고려하면 좋다!

 

 

여러가지 Stream API

결론부터 말하면, 이거 외우는거 아니다. 쓰다보면 외워져서 바로 이거 쓰면 되겠구나! 싶은게 생길것이고, 모르면 이런거 있나? 찾아보면 된다. 진짜 여기서는 딱 두개만 해봐야겠다.

filter

말 그대로 뭔가 걸러내는 중개 오퍼레이션이다. 일단 코드로 바로 보자.

public class Shop {
    private String name;
    private boolean isOpen;

    public Shop() {
    }

    public Shop(String name, boolean isOpen) {
        this.name = name;
        this.isOpen = isOpen;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public boolean isOpen() {
        return isOpen;
    }

    public void setOpen(boolean open) {
        isOpen = open;
    }

    @Override
    public String toString() {
        return "Shop{" +
                "name='" + name + '\'' +
                ", isOpen=" + isOpen +
                '}';
    }
}
  • 우선 아주 간단한 클래스를 하나 만들었다. 가게에 대한 클래스고 가게 이름과 현재 열었는지 안 열었는지에 대한 필드들이 있다.
import java.util.ArrayList;
import java.util.List;

public class ShopMain {
    public static void main(String[] args) {
        Shop shopA = new Shop("A", true);
        Shop shopB = new Shop("B", true);
        Shop shopC = new Shop("C", false);
        Shop shopD = new Shop("D", true);

        List<Shop> shops = new ArrayList<>();
        shops.add(shopA);
        shops.add(shopB);
        shops.add(shopC);
        shops.add(shopD);

        shops.stream()
                .filter(Shop::isOpen)
                .forEach(System.out::println);
    }
}
  • 그리고 이렇게, 현재 오픈한 가게가 있는지를 찾아보는 간단한 스트림 API
  • 메서드 레퍼런스를 사용해서 매우매우 깔끔하게 작성했다.

실행 결과

 

 

근데, 이러고 싶을때가 있다. 안 열은 가게를 알고 싶은 경우가 있다. 이때는 역(Not)을 사용해야 하는데 그럼 이게 뭐가 살짝 불편하냐면 메서드 레퍼런스를 못쓴다. 다음과 같이 컴파일 오류가 난다. 

 

이럴때, 조금 더 이쁘게 작성할 수가 있는데, 이전 포스팅에서 배운 Predicate<T>을 사용하는 것이다! 왜냐하면 filter가 받는 타입 자체가 Predicate<T>이기 때문에 더할 나위없이 완벽하다. 그래서 이렇게 작성할 수 있다.

shops.stream()
        .filter(Predicate.not(Shop::isOpen))
        .forEach(System.out::println);

 

앞으로, 역(Not)과 메서드 레퍼런스를 같이 사용하고 싶을때 이 Predicate<T>을 적극 활용하자!

 

flatMap

flatMapmap의 추가 기능이라고 보면 된다. map이 뭐냐? 인풋을 받아 아웃풋으로 돌려준다. 그래서 인자의 타입도 우리가 배운 Function<T, R>이다. flatMap도 어떤 값(A)을 받아 어떤 값(B)로 만들어준다. 그런데 앞에 flat이 붙었다. 이건 뭐냐면, 리스트를 받아서 그 리스트의 요소를 다 풀어버리는 것이다. 코드로 보자.

 

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

public class ShopMain {
    public static void main(String[] args) {
        Shop shopA = new Shop("A", true);
        Shop shopB = new Shop("B", true);
        Shop shopC = new Shop("C", true);
        Shop shopD = new Shop("D", true);

        List<Shop> openedShops = new ArrayList<>();
        openedShops.add(shopA);
        openedShops.add(shopB);
        openedShops.add(shopC);
        openedShops.add(shopD);

        Shop shopE = new Shop("E", false);
        Shop shopF = new Shop("F", false);
        Shop shopG = new Shop("G", false);
        Shop shopH = new Shop("H", false);

        List<Shop> closedShops = new ArrayList<>();
        closedShops.add(shopE);
        closedShops.add(shopF);
        closedShops.add(shopG);
        closedShops.add(shopH);

        List<List<Shop>> allShops = new ArrayList<>();
        allShops.add(openedShops);
        allShops.add(closedShops);

        allShops.stream()
                .flatMap(shops -> shops.stream())
                .forEach(shop -> System.out.println(shop.getName()));
    }
}
  • openedShops, closedShops 두 개의 리스트가 있다.
  • 그리고 이 각각 리스트를 리스트로 담은 allShops가 있다.
  • 그럼 allShops는 요소가 각각이 리스트이다.
  • allShops를 가지고 스트림을 돌리면 각 요소 하나하나가 리스트인데 flatMap을 사용해서 이 리스트를 풀어 헤치는 것이다. 그래서 그 하나의 요소인 리스트 전체를 다 돌고, 그 다음 하나의 요소인 리스트 전체를 다 돌 수 있게 말이다.

실행 결과

 

요 녀석을 더 깔끔하게 이렇게 바꿀 수도 있다.

//allShops.stream()
//        .flatMap(shops -> shops.stream())
//        .forEach(shop -> System.out.println(shop.getName()));
        
        
allShops.stream()
        .flatMap(Collection::stream)
        .forEach(shop -> System.out.println(shop.getName()));

 

 

이 정도만 알아보고 나머지 여러 기능들은 공식 문서를 통해 필요할 때 찾아서 사용하면 된다. 다시 말하지만 이걸 외우는거 아니다! 안 외워도 자주 사용하는건 저절로 외워지기도 하고 필요한게 생기면 찾아서 사용하면 된다! 다음 공식 문서에서.

 

Stream (Java Platform SE 8 )

A sequence of elements supporting sequential and parallel aggregate operations. The following example illustrates an aggregate operation using Stream and IntStream: int sum = widgets.stream() .filter(w -> w.getColor() == RED) .mapToInt(w -> w.getWeight())

docs.oracle.com

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

[Java 8] CompletableFuture  (0) 2024.11.30
[Java 8] Optional  (0) 2024.11.27
[Java 8] 함수형 인터페이스와 람다 표현식  (0) 2024.11.25
애노테이션  (6) 2024.10.21
리플렉션  (6) 2024.10.21
728x90
반응형
SMALL

자바8부터 나타난 함수형 인터페이스에 대해 알아보는 시간을 가져보자.

 

함수형 인터페이스 (Functional Interface)

  • 추상 메서드를 딱 하나만 가지고 있는 인터페이스를 말한다. (SAM 인터페이스라고도 한다) (SAM = Single Abstract Method)
  • @FunctionalInterface 애노테이션을 가지고 있는 인터페이스를 말한다.

다음 코드를 보자.

@FunctionalInterface
public interface Hello {
    void hello();
}
  • 위 코드처럼 추상 메서드가 딱 하나만 있을때 이 인터페이스를 함수형 인터페이스라고 한다.
  • 그리고 함수형 인터페이스를 정의할 일이 있다면 @FunctionalInterface 애노테이션을 사용해서 이 인터페이스가 함수형 인터페이스임을 명확히 하는게 좋다. 왜냐하면 추상 메서드가 한개가 아닌 경우 아래 사진처럼 컴파일 에러를 만들어주기 때문이다. 

 

 

이런 경우도 함수형 인터페이스이다.

@FunctionalInterface
public interface Hello {
    void hello();
    
    int h = 100;

    static void howAreYou() {
        System.out.println("how are you");
    }

    default void hi() {
        System.out.println("Hello");
    }
}
  • 자바8부터 인터페이스에 이렇게 메서드를 직접 정의할수도 있게 됐는데 그런것이 몇 개가 있던 결국 가장 중요한건 추상 메서드가 딱 한개이냐 아니냐로 함수형 인터페이스는 정의된다.
  • 여기서도 마찬가지로 abstract void hello(); 하나뿐이므로 이 인터페이스는 함수형 인터페이스이다.
  • 참고로, 인터페이스에서 abstract는 생략 가능하다.

 

그럼 이렇게 정의한 함수형 인터페이스를 어떻게 사용하면 되느냐? 다음 코드를 보자.

public class FIMain {
    public static void main(String[] args) {
        Hello h = new Hello() {
            @Override
            public void hello() {
                System.out.println("hello");
            }
        };

        h.hello();
    }
}
  • 자바8 이전에는 이런식으로 사용했다. 이거를 익명 내부 클래스라고 했었다. 굳이 구현체를 클래스로 따로 만들지 않아도 이렇게 작성하면 되니까 익명 내부 클래스라는 이름이 붙은 것 같다.
  • 지금도 당연히 이렇게 사용할 수 있다.

그런데 이 코드를 획기적으로 줄일 수가 있다. 다음 코드를 보자.

public class FIMain {
    public static void main(String[] args) {
        Hello h = () -> System.out.println("hello");

        h.hello();
    }
}
  • 단 한줄로 변경된 모습이 보이는가? 이게 함수형 인터페이스를 람다로 표현한다고 한다. 
  • 물론, 실행 부분이 저렇게 딱 한줄일때 이렇게 사용할 수 있고 만약 한 줄 이상이라면 다음과 같이 사용하면 된다.
public class FIMain {
    public static void main(String[] args) {
        Hello h = () -> {
            System.out.println("hello");
            System.out.println("Hi");
        };

        h.hello();
    }
}
  • 그럼에도 불구하고 익명 내부 클래스보단 획기적으로 코드양이 줄어 보인다.
  • 이런 표현식을 람다 표현식이라고 한다.

 

자바에서 제공하는 함수형 인터페이스

개발자가 직접 구현하지 않고 자바에서 자체적으로 제공해주는 함수형 인터페이스가 있다. 바로 다음 패키지에 있는 함수형 인터페이스들이다.

java.util.function

 

 

Java Platform SE 8

 

docs.oracle.com

 

위 패키지에서 여러가지 함수형 인터페이스가 있는데 대표적인 것들을 살펴보자.

  • Function<T, R>
  • BiFunction<T, U, R>
  • Consumer<T>
  • Supplier<T>
  • Predicate<T>
  • UnaryOperator<T>
  • BinaryOperator<T>

 

Function<T, R>

이 함수형 인터페이스는 인풋 T를 받고, 아웃풋 R을 반환한다. 코드로 바로 알아보자.

import java.util.function.Function;

public class FIMain {
    public static void main(String[] args) {
        Function<Integer, Integer> plus = (number) -> number + 1;
        
        System.out.println(plus.apply(2));
    }
}
  • Integer를 받아서 Integer를 반환하는 plus 라는 이름의 함수형 인터페이스 Function을 구현했다.
  • 이 함수형 인터페이스는 apply라는 추상 메서드를 구현해야 하고 그것을 우리는 받은 인자에 + 1을 한 값을 리턴하는 것으로 구현했다.
  • 이 녀석의 apply(2)를 실행하면 결과는 3이 나오게 된다.

실행 결과

 

 

이런식으로도 작성할 수 있겠다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> plusWithOne = (x) -> x + 1;
        Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;

        System.out.println(multiplyWithTwo.apply(2));
    }
}
  • 인자값을 받아 2를 곱하는 함수형 인터페이스 Function을 구현했다.
  • 실행하면 당연히 결과는 4가 나온다.

실행 결과

 

 

그런데, 이 두개의 Function을 조합할수도 있다. Function에서 제공하는 compose()라는 메서드가 있다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> plusWithOne = (x) -> x + 1;
        Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;
        
        Function<Integer, Integer> compose = plusWithOne.compose(multiplyWithTwo);
        System.out.println(compose.apply(2));
    }
}
  • 이렇게 composeFunctionFunction을 조합하는 게 가능하다.
  • compose는 인자로 받은 Function을 먼저 apply 수행하고, 그 결과값을 다시 apply해서 결과를 반환한다.
  • 그러니까, compose.apply(2)를 하면, 먼저 multiplyWithTwo를 실행해서 4를 만들고, 그 결과값을 plusWithOne.apply()에 인자로 넣어 실행하게 된다. 그래서 결과는 5가 나온다.

실행 결과

 

 

이번에는 이런 것도 있다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> plusWithOne = (x) -> x + 1;
        Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;

        Function<Integer, Integer> andThen = plusWithOne.andThen(multiplyWithTwo);
        System.out.println(andThen.apply(5));
    }
}
  • andThen(). 이것도 역시 두 Function을 조합하는데, 이건 받은 Function이 뒤에 실행된다.
  • 그러니까, 먼저 plusWithOne.apply()가 실행되고, 그 실행된 결과값을 multiplyWithTwo.apply()인자로 받아 실행된 결과값을 반환한다. 그래서 결과는 12가 나온다.

실행 결과

 

BiFunction<T, U, R>

BiFunctionFunction이랑 똑같은데, 인자값을 2개(T, U) 받는다. 그래서 R을 반환한다.

import java.util.function.BiFunction;

public class Main {
    public static void main(String[] args) {
        BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
        System.out.println(add.apply(1, 2));
    }
}
  • 이런식으로 작성할 수 있고, 결과는 3이 나오게 된다.

실행 결과

 

 

Consumer<T>

이건 반환값이 없다. 추상 메서드의 반환 타입이 void다. 이름 그대로 소비자의 역할을 한다고 보면 된다.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        Consumer<Integer> printer = (x) -> System.out.println(x);
        printer.accept(1);
    }
}
  • 얘는 추상 메서드 명이 accept()이다. 그리고 위와 같이 반환값이 없다. 간단하게 받은 파라미터를 출력하도록 작성했다.

실행 결과

 

Supplier<T>

이 녀석은 반대다. 받는게 없고 반환만 한다. 이름 그대로 공급자의 역할을 한다고 보면 된다.

import java.util.function.Supplier;

public class Main {
    public static void main(String[] args) {
        Supplier<Integer> get10 = () -> 10;
        System.out.println(get10.get());
    }
}
  • 얘는 추상 메서드 명이 get()이다. 공급자라는 이름에 아주 걸맞는 메서드명이다.
  • 위 코드처럼 받는것은 없고 반환만 한다. 

 

Predicate<T>

이 녀석은, 어떤 인자값을 받아 boolean 타입을 반환한다.

import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isOne = (x) -> x == 1;
        System.out.println(isOne.test(1));
        System.out.println(isOne.test(2));
    }
}
  • 위 코드와 같이 인자값을 받아서 그 인자값이 1인지 확인하는 뭐 이런 코드를 작성할 수 있겠다.
  • 추상 메서드의 이름은 test()이다.

실행 결과

 

 

그리고 이 녀석은 반환값이 boolean이기 때문에 이런 조합 메서드를 제공한다.

  • and()
  • or()
  • negate()
import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isOne = (x) -> x == 1;
        Predicate<Integer> isOdd = (x) -> x % 2 != 0;

        Predicate<Integer> andPredicate = isOne.and(isOdd);
        System.out.println(andPredicate.test(1));
    }
}
  • Predicate을 조합하는데 AND 조건으로 조합해서 결과를 반환한다. 
  • 위 코드를 보면 하나는 받은 인자가 1인지, 하나는 받은 인자가 홀수인지를 체크하는 Predicate이다.
  • 이 두개를 AND로 조합해서 둘 다 true를 반환하면 true를 반환하고 둘 중 하나라도 false라면 false를 반환한다.

실행 결과

 

 

그럼 or(), negate()은 예측이 가능하다. 

import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isOne = (x) -> x == 1;
        Predicate<Integer> isOdd = (x) -> x % 2 != 0;

        Predicate<Integer> orPredicate = isOne.or(isOdd);;
        System.out.println(orPredicate.test(3));
    }
}
  • 하나는 1인지, 하나는 홀수인지를 체크하는 Predicate인데 이 두 개를 or()로 연결했다.
  • 둘 중 하나라도 true라면 true를 반환하고, 둘 중 하나라도 false라면 false를 반환할 것이다.

실행 결과

 

import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isOne = (x) -> x == 1;
        Predicate<Integer> isOdd = (x) -> x % 2 != 0;

        Predicate<Integer> negate = isOne.negate();
        System.out.println(negate.test(1));
    }
}
  • negate()은 역이다. true라면 false를, false라면 true를 반환한다.

실행 결과

 

 

UnaryOperator<T> 

이 녀석은 편리함을 제공해주는 녀석이다. 다음 코드를 보자.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> plusWithOne = (x) -> x + 1;
    }
}
  • 위 코드처럼 입력값과 반환값의 타입이 동일한 경우에 다음과 같이 사용할 수가 있다.
import java.util.function.UnaryOperator;

public class Main {
    public static void main(String[] args) {
        // Function<Integer, Integer> plusWithOne = (x) -> x + 1;
        UnaryOperator<Integer> plusWithOne = (x) -> x + 1;
    }
}
  • 입력값과 반환값이 같은 경우, UnaryOperator를 사용해서 좀 더 간결하게 작성할 수가 있다.
  • 그리고 이 UnaryOperatorFunction을 상속받는다. 그래서 Function이 제공하는 조합 메서드(compose(), andThen(), ...)을 사용할 수 있다.

 

 

BinaryOperator<T>

이 녀석은 BiFunction<T, U, R>의 간편 메서드라고 보면 된다. 이 BiFunction은 두 개의 인자를 받아 R타입을 반환하는 녀석이다. 근데 이 세 개의 타입(T, U, R)이 모두 같은 경우 이 BinaryOperator<T>를 사용할 수가 있다.

import java.util.function.BinaryOperator;

public class Main {
    public static void main(String[] args) {
        // BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
        BinaryOperator<Integer> add = (x, y) -> x + y;
    }
}

 

 

이렇게 대표적인 것들을 알아봤는데 직접 저 패키지 안으로 들어가보면, 이 말고도 굉장히 뭐가 많다. 근데 지금까지 배운것들의 응용이라고 생각하면 된다. 이름만 봐도 "아 이건 이거겠구나!"를 추측할 수 있을 것이다. 예를 들어, 이런 거다.

직접 이 패키지에 뭐가 있는지 쭉 보면, 맨 위에 BiConsumer<T, U>가 있다. 그럼 우린 Consumer<T>를 배웠기 때문에 이게 뭔지 추측이 가능하다. "아, 두 개의 인자를 받아서 아무것도 반환은 안하고 TU를 가지고 뭔가를 하겠구나?" 맞다. 

 

변수 캡처

이 부분은 꽤나 중요하다. 자세히 들여다보자. 함수형 인터페이스와 람다 표현식을 사용할때 주의할 점이 있다. 바로 이 변수 캡처에 대한 내용인데 다음 코드를 보자.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        final int number = 10;

        // 로컬 클래스
        class LocalClass {
            void local() {
                System.out.println(number);
            }
        }

        // 익명 내부 클래스
        Consumer<Integer> consumer = new Consumer<Integer>() {
            @Override
            public void accept(Integer integer) {
                System.out.println(number);
            }
        };

        // 람다
        Consumer<Integer> lambda = (x) -> System.out.println(x + number);
        lambda.accept(number);
    }
}
  • 로컬 클래스든, 익명 내부 클래스든, 람다 표현식이든 변수를 참조를 할수가 있다. 근데 자바8 이전에는 그 변수는 반드시 final 키워드가 붙어야 했다. 즉, 선언 이후 절대로 변경되지 않을 변수만 참조가 가능하다.
  • 그런데, 자바8 이후부터는 이런 경우에 final을 붙이지 않아도 참조할 수 있다. 다음 코드를 보자. 
import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        int number = 10;

        // 로컬 클래스
        class LocalClass {
            void local() {
                System.out.println(number);
            }
        }

        // 익명 내부 클래스
        Consumer<Integer> consumer = new Consumer<Integer>() {
            @Override
            public void accept(Integer integer) {
                System.out.println(number);
            }
        };

        // 람다
        Consumer<Integer> lambda = (x) -> System.out.println(x + number);
        lambda.accept(number);
    }
}
  • 이 역시 문법적으로 아무런 문제도 되지 않는 올바른 코드이다. 이게 이제 가능한데 이거를 "사실상 final" 이라고 표현한다.
  • "사실상 final" 이란 말은, final 이라는 키워드는 붙지 않았지만, 이 값이 final처럼 취급되는 경우를 말한다.
  • 여기서 만약, 이후에 값을 변경하려고 하면 어떻게 될까? 아래와 같이 컴파일 오류가 발생한다.

즉, 선언한 후 값이 변경되지 않는 변수에 대해서는 로컬 클래스든, 익명 내부 클래스든, 람다든 변수를 참조할 수 있는데 이것을 변수 캡처라고 한다. 그런데 여기서 더 중요한 사실이 있다. [로컬 클래스, 익명 내부 클래스]와 람다는 차이가 있다.

 

다음 코드를 보자.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        final int number = 10;

        // 로컬 클래스
        class LocalClass {
            final int number = 55;
            void local() {
                int number = 30;
                System.out.println(number);
            }
        }

        new LocalClass().local();

        // 익명 내부 클래스
        Consumer<Integer> consumer = new Consumer<Integer>() {
            final int number = 40;
            @Override
            public void accept(Integer integer) {
                System.out.println(number + integer);
            }
        };
        consumer.accept(4);

        // 람다
        Consumer<Integer> lambda = (x) -> System.out.println(x + number);
        lambda.accept(5);
    }
}
  • 벌써 어지럽다. 근데, 결론적으로 로컬 클래스와 익명 내부 클래스는 로컬 변수를 가질 수가 있다.
  • 위 코드를 보면 바깥에 있는 main 메서드에 number라는 변수를 선언했는데, 로컬클래스에서 local() 메서드 안에 또 다른 로컬 변수 number를 선언했다.
  • 위 코드를 보면 바깥에 있는 main 메서드에 number라는 변수를 선언했는데, 익명 내부 클래스 안에서 또 다른 로컬 필드인 number를 선언했다. 
  • 이 경우에는 Scope이 어떻게 결정될까? 실행 결과를 보자.

  • 이러한 결과가 나왔다. 어찌보면 당연한 것이다. 물론, 익명 내부 클래스도 accept() 메서드 안에 로컬 변수로 선언해도 상관없다. 로컬 클래스와 같이 더 가까이에 있는게 적용된다. 

 

그런데, 람다는 아니다! 람다는 Scope이 람다만의 Scope은 없다. main 메서드와 Scope을 같이한다. 즉, 람다를 감싸고 있는 녀석과 같다는 말이다. 그러니까 아래 사진을 보자. 컴파일 오류가 난다. 당연하다. 왜냐? 같은 Scope에 동일한 이름의 변수를 만들 수 없는건 너무 당연하니까!

 

메서드 레퍼런스

메서드 레퍼런스는 뭘까? 다시 한번 아래 코드를 보자.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        Consumer<Integer> lambda = (x) -> System.out.println(x);
        lambda.accept(5);
    }
}
  • Consumer를 람다 표현식으로 만들었다. 그리고 바디에서 하는 일은 입력값을 그대로 시스템 콘솔에 출력한다.
  • 그런데 이렇게 람다의 바디에서 하는 일이 기존 메서드 또는 생성자를 호출하는 거라면, 메서드 레퍼런스를 사용해서 매우 간결하게 표현할수가 있는데 다음 코드를 보자.
import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        Consumer<Integer> lambda = System.out::println;
        lambda.accept(5);
    }
}
  • 이게 바로 메서드 레퍼런스이다. 훨씬 더 깔끔해졌다. 그냥 System.outprintln()을 호출하는 것 뿐이고 그런 경우에는 이렇게 메서드 레퍼런스로 매우 간결하게 출력할 수 있다. 
  • 당연히 출력하는 값은 Consumer는 입력값을 받기 때문에 입력값을 그대로 출력한다.

실행 결과

 

 

굳이 따지자면, 이렇게 4가지가 가능하다.

  • 타입::스태틱 메서드
  • 객체 레퍼런스::인스턴스 메서드
  • 타입::인스턴스 메서드
  • 타입::new

타입::스태틱 메서드

public class MethodRef {

    public static String staticMethod(String s) {
        return "Hello " + s;
    }
}

자, 위와 같은 클래스가 있다고 해보자.

 

import java.util.function.UnaryOperator;

public class Main {
    public static void main(String[] args) {
        UnaryOperator<String> lambda = MethodRef::staticMethod;
        System.out.println(lambda.apply("World"));
    }
}
  • 똑같이 String을 받아서 String을 반환한다면, 타입::스태틱 메서드 형태로 표현할 수 있다.

실행 결과

 

객체 레퍼런스::인스턴스 메서드

public class MethodRef {

    public String instanceMethod(String s) {
        return "Hello " + s;
    }

    public static String staticMethod(String s) {
        return "Hello " + s;
    }
}

 

 

import java.util.function.UnaryOperator;

public class Main {
    public static void main(String[] args) {
        MethodRef methodRef = new MethodRef();

        UnaryOperator<String> lambda = methodRef::instanceMethod;
        System.out.println(lambda.apply("World"));
    }
}
  • 위 코드처럼 인스턴스가 있고, 인스턴스의 메서드를 가지고 람다 표현식을 깔끔하게 쓸 수 있다.

실행 결과

 

 

타입::new

public class MethodRef {

    private String s;

    public MethodRef() {
    }

    public MethodRef(String s) {
        this.s = s;
    }

    public String getS() {
        return s;
    }

    public String instanceMethod(String s) {
        return "Hello " + s;
    }

    public static String staticMethod(String s) {
        return "Hello " + s;
    }
}

자, 이번엔 필드하나를 추가하고 생성자 두개를 추가했다.

 

import java.util.function.Supplier;

public class Main {
    public static void main(String[] args) {
        Supplier<MethodRef> methodRef = MethodRef::new;
        MethodRef methodRefBySupplier = methodRef.get();
    }
}
  • 이런게 가능하다. 생성자를 호출할 수도 있다.
  • 여기서 호출한 생성자는 뭘까 그럼? Supplier는 아무런 인자도 받지 않는다. 그리고 반환하는 것이 MethodRef다. 이런 경우엔? 저기 위에서 선언한 기본생성자를 호출하는 것이다.
import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<String, MethodRef> methodRef = MethodRef::new;
        MethodRef hello = methodRef.apply("hello");

        System.out.println(hello.getS());
    }
}
  • 이번에도 역시 생성자를 호출하는데, 지금 보면 Function으로 되어 있다. 즉, 받는 값이 있고 반환하는 값이 있는건데, 받는 값이 String이고 반환값이 MethodRef다. 이 경우에는 당연히 기본 생성자가 아니라 String을 받는 생성자를 호출하는 것이다!
  • 실제로 apply("hello")를 호출했을때, 필드 `s`에 저 값이 들어가는지 확인해보면 다음과 같다.

실행 결과

 

 

타입::인스턴스 메서드

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        String[] fruit = {"apple", "tomato", "orange"};
        Arrays.sort(fruit, String::compareToIgnoreCase);
    }
}
  • 이런게 타입::인스턴스 메서드이다. 
  • 보면, Arrays.sort()를 사용해서 배열을 받고 정렬을 하고 있다.
  • 정렬할때 두번째 인자로, Comparator를 넣어줘야 하는데, 보통은 이제 아래와 같이 사용을 할 것이다.
import java.util.Arrays;
import java.util.Comparator;

public class Main {
    public static void main(String[] args) {
        String[] fruit = {"apple", "tomato", "orange"};
        
        Comparator<String> comparator = (o1, o2) -> o1.compareTo(o2);
        Arrays.sort(fruit, comparator);
    }
}
  • 그런데 이제, 이렇게 사용이 가능하다는 것이다.
Arrays.sort(fruit, String::compareToIgnoreCase);
  • 이건 이제, fruit 안에 있는 문자열 두개를 가져와서 서로 비교를 하고 있는건데, String 이라는 타입의 "apple", "tomato", "orange"라는 인스턴스의 메서드인 compareToIgnore()을 사용하고 있는 타입::인스턴스 메서드이다.

 

인터페이스 기본 메서드와 스태틱 메서드

또한, 자바8부터 제공되는 인터페이스의 기본 메서드와 스태틱 메서드도 알아보자. 자 다음 코드를 보자.

public interface Hello {
    void printHello();
}
  • Hello 라는 인터페이스 하나가 있다.
public class HelloImpl implements Hello {
    @Override
    public void printHello() {

    }
}
  • Hello 를 구현한 HelloImpl 클래스가 있다.

 

그런데 만약 이때, Hello를 구현하는 모든 구현체는 다 동일하게 어떤 메서드를 가지고 싶게 하려면 어떻게 하면 될까? 모두 같은 기능을 하는 메서드인데 인터페이스에 또 추상 메서드를 하나 선언해버리면? 다음과 같은 컴파일 오류가 날 것이다.

public interface Hello {
    void printHello();

    void printHi();
}

 

이런 경우에 자바8부터는 인터페이스에 기본 메서드를 만들 수 있도록 했다. 바로 이렇게 말이다.

public interface Hello {
    void printHello();

    default void printHi() {
        System.out.println("Hi");
    }
}
  • 이렇게 default 라는 키워드를 사용하여 메서드를 직접 인터페이스에서 구현할 수 있게 했고, 이때 이 인터페이스를 구현하는 구현체는 모두 동일하게 이 메서드를 사용할 수 있다.
public class HelloImpl implements Hello {
    @Override
    public void printHello() {
        printHi();
    }
}
  • 조금 모양새가 웃기긴 하지만, printHello() 메서드 안에 기본 메서드인 printHi()를 호출하고 있다.

 

기본 메서드의 탄생 취지

→ 해당 인터페이스를 구현한 클래스를 깨뜨리지 않고 모든 구현한 클래스에 동일한 새 기능을 추가할 수 있다.

 

그런데, 이 기본 메서드는 구현체가 모르게 추가된 기능이기 때문에 그만큼 리스크가 있다. 어떤 리스크가 있을까? 

기본 메서드의 리스크

public interface Hello {
    void printHello();

    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hello!");
    }
}
  • 예를 들어, 기본 메서드를 하나 만들었는데 파라미터로 이름을 받는다. 그리고 그 이름을 toUpperCase()를 호출하는데 만약 넘겨진 이름이 null 이라면? 런타임 오류가 발생할 수 있다.
  • 그래서, 최대한 이런 예측 못한 에러를 방지할 수 있도록 다음과 같이 문서화 하는 것을 꼭 염두하자. 
public interface Hello {
    void printHello();

    /**
     * @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
     * @param yourName 이름 
     */
    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hello!");
    }
}

 

기본 메서드의 여러가지 규칙들

기본 메서드를 만들 때, Object가 제공하는 기능(toString(), equals(), ...)은 기본 메서드로 제공할 수 없다. 기본 메서드로 선언하는 순간 다음과 같은 컴파일 오류가 발생한다.

 

인터페이스를 상속받는 인터페이스에서 다시 추상 메서드로 변경도 가능하다.

public interface Hello {
    void printHello();

    /**
     * @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
     * @param yourName 이름
     */
    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hello!");
    }
}

 

public interface HelloExtends extends Hello {

    void printHi(String yourName);
}
  • Hello를 상속받는 HelloExtendsprintHi를 다시 추상 메서드로 변경했다.
  • 이제 이 HelloExtends를 구현하는 구현체는 반드시 printHi(String yourName)을 구현해야 한다.

 

두 개의 인터페이스가 같은 시그니처를 갖는 기본 메서드를 제공할 경우에는 그 두 인터페이스를 둘 다 구현하는 구현체는 무조건 동일한 시그니처를 갖는 기본 메서드를 재정의해야 한다.

예를 들어 아래 코드를 보자.

public interface Hello {
    void printHello();

    /**
     * @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
     * @param yourName 이름
     */
    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hello!");
    }
}

 

public interface Hi {
    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hi!");
    }
}

 

  • Hello, Hi를 둘 다 구현하는 HelloHiImpl 클래스는 오류가 발생한다. 왜냐하면, 완전히 동일한 시그니처를 가지는 기본 메서드가 둘 다 있기 때문에 어떤걸 사용해야 할지 컴파일러는 애매하기 때문이다. 그래서 이런 경우에는 반드시 해당 기본 메서드를 재정의 해야 한다. 아래와 같이.
public class HelloHiImpl implements Hello, Hi {
    @Override
    public void printHello() {

    }

    @Override
    public void printHi(String yourName) {
        System.out.println("HH");
    }
}

 

 

이제 스태틱 메서드를 알아볼건데 이건 뭐 없다. 그냥 인터페이스에 스태틱 메서드를 만들 수 있다가 끝이다.

public interface Hello {

    static String returnString(String a) {
        return a + "string";
    }
}
  • 이렇게 인터페이스에도 스태틱 메서드를 만들 수가 있다. 
public class Main {
    public static void main(String[] args) {
        String gg = Hello.returnString("gg");
        System.out.println(gg);
    }
}
  • 사용하는 것도 스태틱 메서드 사용하는것 그대로 동일하다.

인터페이스의 기본 메서드가 가져온 새로운 혁신

인터페이스의 기본 메서드가 생기고 나서부터 엄청난 혁신이 생기게 됐다. 자바8 이전에는 인터페이스가 이렇게 있었다면,

public interface Something {
    void a();
    void b();
    void c();
}

이 인터페이스를 구현하는 구현체 하나를 두기도 했다.

public abstract class SomethingAbstract implements Something {
    @Override
    public void a() {
        
    }

    @Override
    public void b() {

    }

    @Override
    public void c() {

    }
}
  • 왜 이랬을까? 저 인터페이스를 구현하는 구현체에게 편리함을 제공하기 위해서다.
  • 자바8 이전에는 저 인터페이스를 구현하는 구현체는 좋든 싫든 a(), b(), c()를 모두 구현해야만 했다.
  • 그게 너무 싫으니, 이렇게 아무것도 없는 껍데기뿐인 Abstract 클래스를 하나 만들고 인터페이스를 구현하게 하고 이 클래스를 상속받는 클래스를 만들어서 본인이 원하는 메서드만 구현하게 했던 것이다.
public class SomethingA extends SomethingAbstract {
    @Override
    public void a() {
        System.out.println("SomethingA");
    }
}
  • 이렇게 말이다. 
  • 그런데 이제는 인터페이스에서 기본 메서드를 구현할 수 있으니 이런 불편함을 없애는것과 동시에 혁신적 혁명이 일어난다.
  • 왜 혁명일까? 인터페이스는 상속이 아니라 구현이기 때문에 아무리 많이 구현해도 상관이 없고 상속의 강제화에서 벗어날 수 있기 때문이다. 

 

이러한 이유로 인터페이스의 기본 메서드는 라이브러리나 프레임워크를 만들때 굉장히 자주 빈번하게 사용되는 것을 볼 수 있다. 스프링도 그렇다. 

 

 

자바 API 기본 메서드

자바8부터 굉장히 많은 것들이 추가가 됐다. 그 중 대표적인 게 Stream API인데, 이건 이제는 너무 중요하기도 하고 자주 사용되기 때문에 아예 포스팅 하나를 새로 만들어서 이것만 다뤄보기로 할것이고 여기서는 맛보기 정도를 해보자.

 

Iterable의 기본 메서드

  • forEach()
  • spliterator()

이 두개를 한번 맛보자. forEach()는 순회하는 기능인데, 이거 보면 재밌다.

  • forEach()가 인자로 무엇을 받고 있나? 바로 Consumer다. 위에서 배운 Consumer.
  • Consumer를 받는다는 것은 람다로 표현할 수 있다는 뜻이고, 인자를 받지만 반환은 하지 않는 그런 함수형 인터페이스이다.
  • 그래서 다음과 같이 사용할 수 있다.
friends.forEach(friend -> System.out.println(friend));
  • Consumer를 배우니까 이게 어떤것인지 너무나 명확하게 이해가 된다.
  • 순회하는 각 요소를 Consumer의 인자로 주고 그 안에서 무언가를 하지만 반환은 하지 않는 것이다.
  • 그리고, 저렇게 작성하면? 그렇다. 메서드 레퍼런스를 사용해서 더 간단하게 축약할 수 있다.
friends.forEach(System.out::println);

 

spliterator()는 자주 사용되는 것은 아니지만, 알아둬서 나쁠건 없다. 이 녀석 역시 순회를 하는데 이 녀석도 재밌다.

import java.util.ArrayList;
import java.util.List;
import java.util.Spliterator;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Spliterator<String> spliterator = friends.spliterator();
        while (spliterator.tryAdvance(System.out::println));
    }
}
  • 역시 마찬가지로 순회를 하는데 얘는 꼭 next()를 호출하는 것 같은 생김새다.
  • 이 친구는 next() 대신 tryAdvance()를 사용하는데 그 안에 어떤 작업을 할지도 작성할 수 있다.
  • 그리고 이름에 split이 있는거 보니, 쪼갤 수도 있는 것 같다. 맞다. 
import java.util.ArrayList;
import java.util.List;
import java.util.Spliterator;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Spliterator<String> spliterator = friends.spliterator();
        Spliterator<String> stringSpliterator1 = spliterator.trySplit();

        while (stringSpliterator1.tryAdvance(System.out::println));
        System.out.println("=================================");
        while (spliterator.tryAdvance(System.out::println));
    }
}
  • 이렇게 반으로 쪼갤수도 있다. 그리고 각각을 순회시켜서 출력해보면 결과는 다음과 같다.

 

그 외 여러가지 API가 있는데, 이후에 작성할 포스팅에서 Stream API를 배워보면서 자세하게 배워보자!

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

[Java 8] Optional  (0) 2024.11.27
[Java 8] Stream API  (0) 2024.11.27
애노테이션  (6) 2024.10.21
리플렉션  (6) 2024.10.21
ServerSocket으로 HTTP 서버 만들기  (2) 2024.10.18
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션 강의 | 김영한 - 인프런

김영한 | I/O, 네트워크, 리플렉션, 애노테이션을 기초부터 실무 레벨까지 깊이있게 학습합니다. 웹 애플리케이션 서버(WAS)를 자바로 직접 만들어봅니다., 국내 개발 분야 누적 수강생 1위, 제대로

www.inflearn.com

 

이 포스팅은 바로 이전 포스팅인 리플렉션과 굉장히 밀접한 관계가 있다. 그래서 이전 포스팅을 먼저 정독하고 이 포스팅을 보아야 한다.

2024.10.21 - [JAVA의 가장 기본이 되는 내용] - 리플렉션

 

리플렉션

참고자료 김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션 강의 | 김영한 - 인프런김영한 | I/O, 네트워크, 리플렉션, 애노테이션을 기초부터 실무 레벨까지 깊이있게 학습합니다. 웹 애플

cwchoiit.tistory.com

 

애노테이션이 필요한 이유

이전 포스팅에서 리플렉션을 활용해서 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: 자바 실행 중에도 남아있다. 대부분 이 설정을 사용한다.

실제로, 이 @RententionCLASS 또는 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 경고를 억제
  • serialSerializable 인터페이스를 구현할 때, serialVersionUID 필드를 선언하지 않은 경우 발생하는 경고를 억제
  • rawtypes → 제네릭 타입이 명시되지 않은(raw) 타입을 사용할 때 발생하는 경고를 억제
  • unused → 사용되지 않는 변수, 메서드, 필드 등을 선언했을 때 발생하는 경고를 억제

 

정리를 하자면...

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

 

이러한 프레임워크들이 리플렉션과 애노테이션을 잘 활용해서 다음과 같은 마법 같은 기능들을 제공하기 때문이다.

  1. 의존성 주입 (DI): 스프링은 리플렉션을 사용하여, 객체의 필드나 생성자에 자동으로 의존성을 주입한다. 개발자는 단순히 @Autowired 애노테이션만 붙이면 된다.
  2. ORM: JPA는 애노테이션을 사용하여, 자바 객체와 데이터베이스 테이블 간의 매핑을 정의한다. 예를 들어, @Entity, @Table, @Column 등의 애노테이션으로 객체 - 테이블 관계를 설정한다. 
  3. AOP: 스프링은 리플렉션을 사용하여, 런타임에 코드를 동적으로 주입하고, @Aspect, @Before, @After 등의 애노테이션으로 관점 지향 프로그래밍을 구현한다.
  4. 설정의 자동화: @Configuration, @Bean 등의 애노테이션을 사용하여 다양한 설정을 편리하게 적용한다.
  5. 트랜잭션 관리: @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를 만들어냈다.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션 강의 | 김영한 - 인프런

김영한 | I/O, 네트워크, 리플렉션, 애노테이션을 기초부터 실무 레벨까지 깊이있게 학습합니다. 웹 애플리케이션 서버(WAS)를 자바로 직접 만들어봅니다., 국내 개발 분야 누적 수강생 1위, 제대로

www.inflearn.com

 

리플렉션이 필요한 이유

이전 포스팅에서 배운 HTTP 서버를 만들면서, 커맨드 패턴을 도입하고 사용해봤던 서블릿은 아주 유용하지만, 몇가지 단점이 있다.

  • 하나의 클래스에 하나의 기능만 만들 수 있다.
  • 새로 만든 클래스를 URL 경로와 항상 매핑해야 한다.

 

단점1 - 하나의 클래스에 하나의 기능만 만들 수 있다.

기능 하나를 만들때마다 각각 별도의 클래스를 만들고, 구현해야 한다. 이것은 복잡한 기능에서는 효과적이지만, 간단한 기능을 만들 때는 클래스가 너무 많이 만들어지기 때문에 부담스럽다.

 

HomeServlet

package cwchoiit.was.v5.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class HomeServlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        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>");
    }
}

 

Site1Servlet

package cwchoiit.was.v5.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class Site1Servlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        response.writeBody("<h1>Site1</h1>");
    }
}

 

Site2Servlet

package cwchoiit.was.v5.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class Site2Servlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        response.writeBody("<h1>Site2</h1>");
    }
}

 

이런식으로 거의 같은 기능을 하는데도 클래스를 하나씩 만들어줘야 한다. 이런 문제를 해결할 방법이 없을까?

바로 아래 코드처럼 하나의 클래스 안에서 다양한 기능을 처리하는 것이다.

public class ReflectController {
      
      public void site1(HttpRequest request, HttpResponse response) {
          response.writeBody("<h1>site1</h1>");
      }
      
      public void site2(HttpRequest request, HttpResponse response) {
          response.writeBody("<h1>site2</h1>");
      }
      
      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>");
      } 
}

 

물론, 필요하면 클래스를 나눌 수 있게 해도 된다. 예를 들어, 이렇게 말이다.

  • SiteController
  • SearchController

이렇게 하면 비슷한 기능을 한 곳에 모을 수 있는 장점도 있고, 작은 기능 하나를 추가할 때마다 클래스를 계속 만들지 않아도 된다.

 

단점2 - 새로 만든 클래스를 URL 경로와 항상 매핑해야 한다.

  servletManager.add("/site1", new Site1Servlet());
  servletManager.add("/site2", new Site2Servlet());
  servletManager.add("/search", new SearchServlet());
  • 새로운 기능을 하나 추가할 때마다 이런 매핑 작업도 함께 추가해야 한다.

그런데 여기서 앞서 본 ReflectController 예시를 보자. URL 경로의 이름과 메서드 이름이 같다.

  • `/site1` → site1()
  • `/site2` → site2()
  • `/search` → search()

만약, URL 경로의 이름과 같은 이름의 메서드를 찾아서 호출할 수 있다면? 예를 들어, `/site1`이 입력되면 site1()이라는 메서드를 이름으로 찾아서 호출하는 것이다. 클래스에 있는 메서드의 이름을 찾아서 이렇게 호출할 수 있다면, 번거로운 매핑 작업을 제거할 수 있을 것이다. 자바 프로그램 실행 중에 이름으로 메서드를 찾고, 또 찾은 메서드를 호출하려면 자바의 리플렉션 기능을 먼저 알아야 한다. 리플렉션 기능을 먼저 학슴하고, 또 학습한 내용을 기반으로 ReflectController 같은 기능을 만들어서 적용해보자.

 

클래스와 메타데이터

클래스가 제공하는 다양한 정보를 동적으로 분석하고 사용하는 기능을 리플렉션(Reflection)이라고 한다. 리플렉션을 통해 프로그램 실행 중에 클래스, 메서드, 필드 등에 대한 정보를 얻거나, 새로운 객체를 생성하고 메서드를 호출하며, 필드의 값을 읽고 쓸 수 있다. 

 

리플렉션을 통해 얻을 수 있는 정보는 다음과 같다.

  • 클래스의 메타데이터: 클래스 이름, 접근 제어자, 부모 클래스, 구현된 인터페이스 등
  • 필드 정보: 필드의 이름, 타입, 접근 제어자를 확인하고, 해당 필드의 값을 읽거나 수정할 수 있다.
  • 메서드 정보: 메서드 이름, 반환 타입, 매개변수 정보를 확인하고, 실행 중에 동적으로 메서드를 호출할 수 있다.
  • 생성자 정보: 생성자의 매개변수 타입과 개수를 확인하고, 동적으로 객체를 생성할 수 있다.
참고로, 리플렉션(Reflection)이라는 용어는 영어 단어 'reflect'에서 유래된 것으로, "반사하다" 또는 "되돌아보다"라는 의미를 가지고 있다. 리플렉션은 프로그램이 실행 중에 자기 자신의 구조를 들여다보고, 그 구조를 변경하거나 조작할 수 있는 기능을 의미한다. 쉽게 말해, 리플렉션을 통해 클래스, 메서드, 필드 등의 메타데이터를 런타임에 동적으로 조사하고 사용할 수 있다. 이는 마치 거울에 비친 자신을 보는 것과 같이, 프로그램이 자기 자신의 내부를 반사(reflect)하여 들여다본다는 의미이다.

 

 

리플렉션으로 클래스 메타데이터 조회 

package cwchoiit.reflection.data;

public class BasicData {

    public String publicField;
    private int privateField;

    public BasicData() {
        System.out.println("BasicData.BasicData");
    }

    private BasicData(String data) {
        System.out.println("BasicData.BasicData:" + data);
    }

    public void call() {
        System.out.println("BasicData.call");
    }

    public String hello(String string) {
        System.out.println("BasicData.hello");
        return string + " hello";
    }

    private void privateMethod() {
        System.out.println("BasicData.privateMethod");
    }

    void defaultMethod() {
        System.out.println("BasicData.defaultMethod");
    }

    protected void protectedMethod() {
        System.out.println("BasicData.protectedMethod");
    }
}
  • 예제를 위한 기본 클래스이다.

BasicV1

package cwchoiit.reflection;

import cwchoiit.reflection.data.BasicData;

public class BasicV1 {

    public static void main(String[] args) throws ClassNotFoundException {
        // 클래스 메타데이터 조회 방법 3가지

        // 1. 클래스에서 찾기
        Class<BasicData> basicDataClass1 = BasicData.class;
        System.out.println("basicDataClass1 = " + basicDataClass1);

        // 2. 인스턴스에서 찾기
        BasicData basicInstance = new BasicData();
        Class<? extends BasicData> basicDataClass2 = basicInstance.getClass();
        System.out.println("basicDataClass2 = " + basicDataClass2);

        // 3. 문자로 찾기
        String className = "cwchoiit.reflection.data.BasicData"; // 패키지 명 주의
        Class<?> basicDataClass3 = Class.forName(className);
        System.out.println("basicDataClass3 = " + basicDataClass3);
    }
}
  • 클래스의 메타데이터는 Class라는 클래스로 표현된다.
  • 그리고 이 Class라는 클래스를 획득하는 방법에는 3가지가 있다. 
    • 클래스에서 찾기
    • 인스턴스에서 찾기
    • 문자로 찾기

클래스에서 찾기

Class<BasicData> basicDataClass1 = BasicData.class

클래스명에 .class를 사용하면 획득할 수 있다.

 

 

인스턴스에서 찾기

  BasicData helloInstance = new BasicData();
  Class<? extends BasicData> basicDataClass2 = helloInstance.getClass();

인스턴스에서 .getClass() 메서드를 호출하면 획득할 수 있다. 반환 타입을 보면 Class<? extends BasicData>로 표현되는데, 실제 인스턴스가 BasicData 타입일 수도 있지만, 그 자식 타입일수도 있기 때문이다. 

 

이해를 돕기 위해 다음 예를 보자.

  Parent parent = new Child();
  Class<? extends Parent> parentClass = parent.getClass();

Parent 타입을 통해 getClass()를 호출했지만, 실제 인스턴스는 Child이다. 따라서 제네릭에서 자식 타입도 허용할 수 있도록 ? extends Parent를 사용한다.

 

문자로 찾기

String className = "reflection.data.BasicData"; // 패키지명 주의
Class<?> basicData3 = Class.forName(className);

이 부분이 가장 흥미로운데, 단순히 문자로 클래스의 메타데이터를 조회할 수 있다. 예를 들어서 콘솔에서 사용자 입력으로 원하는 클래스를 동적으로 찾을 수 있다는 뜻이다. 

 

기본 정보 탐색

이렇게 찾은 클래스 메타데이터로 어떤 일들을 할 수 있는지 알아보자.

BasicV2

package cwchoiit.reflection;

import cwchoiit.reflection.data.BasicData;

import java.lang.reflect.Modifier;
import java.util.Arrays;

public class BasicV2 {

    public static void main(String[] args) {
        Class<BasicData> basicDataClass = BasicData.class;

        System.out.println("basicDataClass.getName() = " + basicDataClass.getName());
        System.out.println("basicDataClass.getSimpleName() = " + basicDataClass.getSimpleName());
        System.out.println("basicDataClass.getPackage() = " + basicDataClass.getPackage());

        System.out.println("basicDataClass.getSuperclass() = " + basicDataClass.getSuperclass());
        System.out.println("basicDataClass.getInterfaces() = " + Arrays.toString(basicDataClass.getInterfaces()));

        System.out.println("basicDataClass.isInterface() = " + basicDataClass.isInterface());
        System.out.println("basicDataClass.isEnum() = " + basicDataClass.isEnum());
        System.out.println("basicDataClass.isAnnotation() = " + basicDataClass.isAnnotation());

        int modifiers = basicDataClass.getModifiers();
        System.out.println("basicData.getModifiers() = " + modifiers);
        System.out.println("isPublic = " + Modifier.isPublic(modifiers));
        System.out.println("Modifier.toString() = " + Modifier.toString(modifiers));
    }
}

실행 결과

basicDataClass.getName() = cwchoiit.reflection.data.BasicData
basicDataClass.getSimpleName() = BasicData
basicDataClass.getPackage() = package cwchoiit.reflection.data
basicDataClass.getSuperclass() = class java.lang.Object
basicDataClass.getInterfaces() = []
basicDataClass.isInterface() = false
basicDataClass.isEnum() = false
basicDataClass.isAnnotation() = false
basicData.getModifiers() = 1
isPublic = true
Modifier.toString() = public
  • 클래스 이름, 패키지, 부모 클래스, 구현한 인터페이스, 수정자 정보 등 다양한 정보를 획득할 수 있다.

참고로 수정자는 접근 제어자와 비 접근 제어자(기타 수정자)로 나눌 수 있다.

  • 접근 제어자: public, protected, default, private
  • 비 접근 제어자: static, final, abstract, synchronized, volatile

getModifiers()를 통해 수정자가 조합된 숫자를 얻고, Modifier를 사용해서 실제 수정자 정보를 확인할 수 있다.

조금만 더 자세히 얘기를 해보자면, 다음 코드를 보자.

int modifiers = basicDataClass.getModifiers();
System.out.println("basicData.getModifiers() = " + modifiers);

이것의 결과로 1이 찍힌것이다. 1Modifier.PUBLIC을 의미하고 이 상수값이 1로 정의되어 있다. 그리고 해당 클래스의 수정자가 1이라는 것은 이 클래스는 public으로 선언된 클래스라는 것을 말하는 것이다. 

 

그럼, 메서드들의 수정자도 한번 찍어보자.

Method[] methods = basicDataClass.getDeclaredMethods();
for (Method method : methods) {
    System.out.println(method.getModifiers());
}

실행 결과

1
1
2
0
4

 

이 클래스가 가지고 있는 모든 declaredMethod를 순회하면서 수정자를 찍어보니 이렇게 나왔다. 이후에 메서드에 대한 얘기도 할테니 가볍게만 이야기 하자면, declaredMethods는 해당 클래스에서 선언된 모든 메서드들을 가져온다.

 

1은 public, 2는 private, 0은 default, 4는 protected이다. 그리고 실제로 그런지 보자.

public void call() {
    System.out.println("BasicData.call");
}

public String hello(String string) {
    System.out.println("BasicData.hello");
    return string + " hello";
}

private void privateMethod() {
    System.out.println("BasicData.privateMethod");
}

void defaultMethod() {
    System.out.println("BasicData.defaultMethod");
}

protected void protectedMethod() {
    System.out.println("BasicData.protectedMethod");
}

 

이렇게 수정자는 이런 내용을 알려주는 것이다. 그리고 수정자는 접근 제어자와 비 접근 제어자 둘 다 다룬다고 했는데 만약, final 키워드를 public 메서드에 붙이면 어떻게 나올까?

public final void call() {
    System.out.println("BasicData.call");
}

 

이렇게 하고 다시 돌려보면 17이라는 숫자가 나온다. 1(public) + 16(final) = 17이 나온것이다. 이런것이 getModifiers()다.

 

 

메서드 탐색과 동적 호출

클래스 메타데이터를 통해 클래스가 제공하는 메서드의 정보를 확인해보자.

MethodV1

package cwchoiit.reflection;

import cwchoiit.reflection.data.BasicData;

import java.lang.reflect.Method;

public class MethodV1 {
    public static void main(String[] args) {
        Class<BasicData> basicDataClass = BasicData.class;

        // 해당 클래스와 상위 클래스에 있는 public 메서드들
        System.out.println("======= methods() ========");
        Method[] methods = basicDataClass.getMethods();
        for (Method method : methods) {
            System.out.println("method = " + method);
        }

        // 해당 클래스에서 선언한 모든 메서드들
        System.out.println("======= declaredMethods() ========");
        Method[] declaredMethods = basicDataClass.getDeclaredMethods();
        for (Method declaredMethod : declaredMethods) {
            System.out.println("declaredMethod = " + declaredMethod);
        }
    }
}
  • Class.getMethods() 또는 Class.getDeclaredMethods()를 호출하면, Method라는 타입으로 메서드의 메타데이터를 얻을 수 있다. 이 클래스는 메서드의 모든 정보를 가지고 있다.

getMethods() vs getDeclaredMethods()

  • getMethods(): 해당 클래스와 상위 클래스에서 상속된 모든 public 메서드를 반환
  • getDeclaredMethods(): 해당 클래스에서 선언된 모든 메서드를 반환하며, 접근 제어자에 관계없이 반환. 상속된 메서드는 포함되지 않음.

실행 결과

======= methods() ========
method = public void cwchoiit.reflection.data.BasicData.call()
method = public final native java.lang.Class java.lang.Object.getClass()
method = public final void java.lang.Object.wait() throws java.lang.InterruptedException
method = public final void java.lang.Object.wait(long) throws java.lang.InterruptedException
method = public final void java.lang.Object.wait(long,int) throws java.lang.InterruptedException
method = public int java.lang.Object.hashCode()
method = public boolean java.lang.Object.equals(java.lang.Object)
method = public final native void java.lang.Object.notifyAll()
method = public java.lang.String java.lang.Object.toString()
method = public java.lang.String cwchoiit.reflection.data.BasicData.hello(java.lang.String)
method = public final native void java.lang.Object.notify()
======= declaredMethods() ========
declaredMethod = public void cwchoiit.reflection.data.BasicData.call()
declaredMethod = public java.lang.String cwchoiit.reflection.data.BasicData.hello(java.lang.String)
declaredMethod = private void cwchoiit.reflection.data.BasicData.privateMethod()
declaredMethod = void cwchoiit.reflection.data.BasicData.defaultMethod()
declaredMethod = protected void cwchoiit.reflection.data.BasicData.protectedMethod()

 

 

메서드 동적 호출

Method 객체를 사용하면 메서드를 직접 호출할 수도 있다.

MethodV2

package cwchoiit.reflection;

import cwchoiit.reflection.data.BasicData;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class MethodV2 {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        // 정적 메서드 호출 - 일반적인 메서드 호출
        BasicData basicData = new BasicData();
        basicData.call();

        // 동적 메서드 호출 - 리플렉션 사용
        Class<? extends BasicData> basicDataClass = basicData.getClass();
        String methodName = "hello";

        // 메서드 이름을 변수로 변경할 수 있다.
        Method method1 = basicDataClass.getDeclaredMethod(methodName, String.class);
        Object returnValue = method1.invoke(basicData, "hi");
        System.out.println("returnValue = " + returnValue);
    }
}
  • 리플렉션을 사용하면 매우 다양한 체크 예외가 발생한다.

실행 결과

BasicData.BasicData
BasicData.call
BasicData.hello
returnValue = hi hello

 

일반적인 메서드 호출 - 정적

인스턴스 참조를 통해 메서드를 호출하는 방식이 일반적인 메서드 호출 방식이다. 이 방식은 코드를 변경하지 않는 이상 다른 메서드로 변경하는 것이 불가능하다.

// 정적 메서드 호출 - 일반적인 메서드 호출
BasicData basicData = new BasicData();
basicData.call();

이렇게 되어 있는 코드에서 call() 메서드를 바꾸지 않는 이상 call()가 호출되는것을 바꾸는 것은 불가능하다는 말이다.

 

메서드 동적 호출 - 리플렉션 사용

// 동적 메서드 호출 - 리플렉션 사용
Class<? extends BasicData> basicDataClass = basicData.getClass();
String methodName = "hello";

// 메서드 이름을 변수로 변경할 수 있다.
Method method1 = basicDataClass.getDeclaredMethod(methodName, String.class);
Object returnValue = method1.invoke(basicData, "hi");
System.out.println("returnValue = " + returnValue);

위 코드처럼, 리플렉션을 사용하면 동적으로 메서드를 호출할 수 있다.

  • 클래스 메타데이터가 제공하는 getMethod() 또는 getDeclaredMethod()에 메서드 이름과, 사용하는 매개변수의 타입을 전달하면 원하는 메서드를 찾을 수 있다. 
  • 이 예제에선 'hello' 라는 메서드 이름에, String 매개변수가 있는 메서드를 찾는다.
Object returnValue = method1.invoke(basicData, "hi");
  • Method.invoke() 메서드에 실행할 인스턴스와 인자를 전달하면, 해당 인스턴스에 있는 메서드를 전달받은 인자를 사용해 실행할 수 있다.
  • 여기서는 BasicData basicData = new BasicData() 인스턴스에 있는 hello(String) 메서드를 호출한다. 그러니까 아래 메서드.
public String hello(String string) {
    System.out.println("BasicData.hello");
    return string + " hello";
}

 

여기서 메서드를 찾을 때, basicDataClass.getDeclaredMethod(methodName, String.class) 에서 methodName 부분이 String 변수로 되어 있는 것을 확인할 수 있다. methodName은 변수이므로 예를 들어, 사용자 콘솔 입력을 통해서 얼마든지 호출할 methodName을 변경할 수 있다. 따라서, 여기서 호출할 메서드 대상은 정적으로 딱 코드에 의해 정해진 것이 아니라, 언제든지 동적으로 변경할 수 있다. 그래서 동적 메서드 호출이라 한다. 

 

메서드 동적 호출 - 예시

package cwchoiit.reflection.data;

public class Calculator {

    public int add(int a, int b) {
        return a + b;
    }

    public int subtract(int a, int b) {
        return a - b;
    }
}

 

MethodV3

package cwchoiit.reflection;

import cwchoiit.reflection.data.Calculator;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Scanner;

public class MethodV3 {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        Scanner scanner = new Scanner(System.in);
        System.out.println("호출 메서드: ");
        String methodName = scanner.nextLine();

        System.out.println("숫자1: ");
        int num1 = scanner.nextInt();
        System.out.println("숫자2: ");
        int num2 = scanner.nextInt();

        Calculator calculator = new Calculator();
        Class<? extends Calculator> calculatorClass = calculator.getClass();

        Method method = calculatorClass.getMethod(methodName, int.class, int.class);

        Object returnValue = method.invoke(calculator, num1, num2);
        System.out.println("returnValue = " + returnValue);

    }
}

실행 결과 1

호출 메서드: 
add
숫자1: 
3
숫자2: 
2
returnValue = 5

 

실행 결과 2

호출 메서드: 
subtract
숫자1: 
2
숫자2: 
4
returnValue = -2

 

물론, 시그니쳐가 잘못된 경우 에러가 발생하는건 당연하다.

실행 결과 3 - 잘못된 메서드명을 입력

 

필드 탐색과 값 변경

리플렉션을 활용해서, 필드를 탐색하고 또 필드의 값을 변경하도록 활용해보자.

FieldV1

package cwchoiit.reflection;

import cwchoiit.reflection.data.BasicData;

import java.lang.reflect.Field;

public class FieldV1 {
    public static void main(String[] args) {
        Class<BasicData> basicDataClass = BasicData.class;

        System.out.println("========= fields() =========");
        Field[] fields = basicDataClass.getFields();
        for (Field field : fields) {
            System.out.println("field = " + field);
        }

        System.out.println("========= declaredFields() =========");
        Field[] declaredFields = basicDataClass.getDeclaredFields();
        for (Field declaredField : declaredFields) {
            System.out.println("declaredField = " + declaredField);
        }
    }
}

실행 결과

========= fields() =========
field = public java.lang.String cwchoiit.reflection.data.BasicData.publicField
========= declaredFields() =========
declaredField = public java.lang.String cwchoiit.reflection.data.BasicData.publicField
declaredField = private int cwchoiit.reflection.data.BasicData.privateField

 

fields() vs declaredFields()

앞서 설명한 getMethods() vs getDeclaredMethods()와 같다. 

  • fields(): 해당 클래스와 상위 클래스에서 상속된 모든 public 필드를 반환
  • declaredFields(): 해당 클래스에서 선언된 모든 필드를 반환하며, 접근 제어자에 관계없이 반환. 상속된 필드는 포함하지 않음.

 

필드 값 변경

필드 값 변경 예제를 위해 간단한 사용자 데이터를 만들어보자.

User

package cwchoiit.reflection.data;

public class User {

    private String id;
    private String name;
    private Integer age;

    public User() {
    }

    public User(String id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{" +
                "id='" + id + '\'' +
                ", name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
  • 여기서 모든 필드가 private 접근 제어자라는 점을 주의해서 살펴보자.

FieldV2

package cwchoiit.reflection;

import cwchoiit.reflection.data.User;

import java.lang.reflect.Field;

public class FieldV2 {
    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {

        User user = new User("id1", "userA", 20);

        System.out.println("기존 이름 = " + user.getName());

        Class<? extends User> aClass = user.getClass();
        Field nameField = aClass.getDeclaredField("name");

        nameField.set(user, "userB");
        System.out.println("변경된 이름 = " + user.getName());
    }
}
  • 사용자의 이름이 userA인데, 리플렉션을 사용해서 name 필드에 직접 접근한 다음에 userB로 이름을 변경해보자.
Field nameField = aClass.getDeclaredField("name");
  • name 이라는 필드를 조회한다.
  • 그런데, name 필드는 private 접근 제어자를 사용한다. 따라서 직접 접근해서 값을 변경하는 것이 불가능하다.

 

그런데, 리플렉션은 private 필드에 접근할 수 있는 특별한 기능을 제공한다. 

nameField.setAccessible(true);
  • 이렇게 setAccessible(true) 설정해버리면, private 필드에 그냥 접근이 가능해진다. 
  • 참고로, 이 setAccessible(true)는, Method도 제공한다. 따라서 private 메서드를 호출할 수도 있다.
// private 필드에 접근 허용, 메서드도 또한 마찬가지
nameField.setAccessible(true);
nameField.set(user, "userB");
System.out.println("변경된 이름 = " + user.getName());

그래서 이렇게 해서 실행해버리면 변경이 가능해진다.

 

실행 결과

기존 이름 = userA
변경된 이름 = userB

 

 

리플렉션과 주의사항

본 것처럼, 리플렉션을 활용하면 private 접근 제어자에도 직접 접근해서 값을 변경할 수 있다. 하지만, 이는 객체 지향 프로그래밍의 원칙을 위반하는 행위로 간주될 수 있다. private 접근 제어자는 클래스 내부에서만 데이터를 보호하고,  외부에서의 직접적인 접근을 방지하기 위해 사용된다. 리플렉션을 통해 이러한 접근 제한을 무시하는 것은 캡슐화 및 유지보수성에 악영향을 미칠 수 있다. 예를 들어, 클래스의 내부 구조나 구현 세부 사항이 변경될 경우 리플렉션을 사용한 코드는 쉽게 깨질수 있으며, 이는 예상치 못한 버그를 초래할 수 있다. 따라서, 리플렉션을 사용할 때는 반드시 신중하게 접근해야 하며, 가능한 경우 접근 메서드(예: Getter, Setter)를 사용하는 것이 바람직하다. 리플렉션은 주로 테스트나 라이브러리 개발 같은 특별한 상황에서 유용하게 사용되지만, 일반적인 애플리케이션 코드에서는 권장되지 않는다. 이를 무분별하게 사용하면 코드의 가독성과 안정성을 크게 저하시킬 수 있다. 

 

그럼 어떤 경우에 사용하면 좋은걸까?

 

리플렉션 - 활용 예제

프로젝트에서 데이터를 저장해야 하는데, 저장할 때는 반드시 null을 사용하면 안된다고 가정해보자. 이 경우, null 값을 다른 기본 값으로 모두 변경해야 한다. 

  • Stringnull이면 ""(빈 문자)로 변경한다.
  • Integernull이면 0으로 변경한다.

활용 예시를 위해 Team 클래스를 하나 만들자.

Team

package cwchoiit.reflection.data;

public class Team {
    private String id;
    private String name;

    public Team() {
    }

    public Team(String id, String name) {
        this.id = id;
        this.name = name;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "Team{" +
                "id='" + id + '\'' +
                ", name='" + name + '\'' +
                '}';
    }
}

 

FieldV3

package cwchoiit.reflection;

import cwchoiit.reflection.data.Team;
import cwchoiit.reflection.data.User;

public class FieldV3 {
    public static void main(String[] args) {
        User user = new User("id1", null, null);
        Team team = new Team("team1", null);

        System.out.println("==== before ====");
        System.out.println("user = " + user);
        System.out.println("team = " + team);

        if (user.getId() == null) {
            user.setId("");
        }

        if (user.getName() == null) {
            user.setName("");
        }

        if (user.getAge() == null) {
            user.setAge(0);
        }

        if (team.getId() == null) {
            team.setId("");
        }

        if (team.getName() == null) {
            team.setName("");
        }

        System.out.println("==== after ====");
        System.out.println("user = " + user);
        System.out.println("team = " + team);
    }
}

 

실행 결과

==== before ====
user = User{id='id1', name='null', age=null}
team = Team{id='team1', name='null'}
==== after ====
user = User{id='id1', name='', age=0}
team = Team{id='team1', name=''}
  • User, Team 객체에 입력된 정보 중에 null 데이터를 모두 기본 값으로 변경해야 한다고 가정해보자.
  • Stringnull이면 ""(빈 문자)로 변경한다.
  • Integernull이면 0으로 변경한다. 

이 문제를 해결하려면 위 코드처럼 각각의 객체에 들어있는 데이터를 직접 다 찾아서 값을 입력해야 한다. 만약, User, Team 뿐만 아니라 Order, Cart, Delivery 등등 수 많은 객체에 해당 기능을 적용해야 한다면 매우 많은 번거로운 코드를 작성해야 할 것이다.

 

이번에는 이 무식한 코드를 리플렉션을 활용해서 깔끔하게 해결해보자.

FieldUtil

package cwchoiit.reflection;

import java.lang.reflect.Field;

public class FieldUtil {

    public static void nullFieldToDefault(Object target) throws IllegalAccessException {
        Class<?> aClass = target.getClass();

        Field[] declaredFields = aClass.getDeclaredFields();
        for (Field declaredField : declaredFields) {
            declaredField.setAccessible(true);
            if (declaredField.get(target) != null) {
                continue;
            }

            if (declaredField.getType() == String.class) {
                declaredField.set(target, "");
            } else if (declaredField.getType() == Integer.class) {
                declaredField.set(target, 0);
            }
        }
    }
}
  • 어떤 객체든 받아서 기본 값을 적용하는 유틸리티 클래스를 만들어보자.

이 유틸리티 필드의 값을 조사한 다음에 null이면 기본 값을 적용한다.

  • String null이면 ""(빈 문자)로 변경한다.
  • Integer null이면 0으로 변경한다. 

FieldV4

package cwchoiit.reflection;

import cwchoiit.reflection.data.Team;
import cwchoiit.reflection.data.User;

public class FieldV4 {
    public static void main(String[] args) throws IllegalAccessException {

        User user = new User("id1", null, null);
        Team team = new Team("team1", null);

        System.out.println("==== before ====");
        System.out.println("user = " + user);
        System.out.println("team = " + team);

        FieldUtil.nullFieldToDefault(user);
        FieldUtil.nullFieldToDefault(team);
        System.out.println("==== after ====");
        System.out.println("user = " + user);
        System.out.println("team = " + team);
    }
}

실행 결과

==== before ====
user = User{id='id1', name='null', age=null}
team = Team{id='team1', name='null'}
==== after ====
user = User{id='id1', name='', age=0}
team = Team{id='team1', name=''}

리플렉션을 사용한 덕분에 User, Team 뿐만 아니라 Order, Cart, Delivery 등등 수 많은 객체에 매우 편리하게 기본 값을 적용할 수 있게 되었다. 이처럼 리플렉션을 활용하면 기존 코드로 해결하기 어려운 공통 문제를 손쉽게 처리할 수도 있다. 

 

생성자 탐색과 객체 생성

리플렉션을 활용하면 생성자를 탐색하고, 또 탐색한 생성자를 사용해서 객체를 생성할 수 있다.

 

생성자 탐색

 

ConstructV1

package cwchoiit.reflection;

import java.lang.reflect.Constructor;

public class ConstructV1 {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> aClass = Class.forName("cwchoiit.reflection.data.BasicData");

        System.out.println("==== constructors() ====");
        Constructor<?>[] constructors = aClass.getConstructors();
        for (Constructor<?> constructor : constructors) {
            System.out.println("constructor = " + constructor);
        }

        System.out.println("==== declaredConstructors() ====");
        Constructor<?>[] declaredConstructors = aClass.getDeclaredConstructors();
        for (Constructor<?> declaredConstructor : declaredConstructors) {
            System.out.println("declaredConstructor = " + declaredConstructor);
        }
    }
}

실행 결과

==== constructors() ====
constructor = public cwchoiit.reflection.data.BasicData()
==== declaredConstructors() ====
declaredConstructor = public cwchoiit.reflection.data.BasicData()
declaredConstructor = private cwchoiit.reflection.data.BasicData(java.lang.String)

 

 

생성자 활용

 

ConstructV2

package cwchoiit.reflection;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class ConstructV2 {

    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {

        Class<?> aClass = Class.forName("cwchoiit.reflection.data.BasicData");

        Constructor<?> declaredConstructors = aClass.getDeclaredConstructor(String.class);
        declaredConstructors.setAccessible(true);
        Object instance = declaredConstructors.newInstance("hello");
        System.out.println("instance = " + instance);

        Method method = aClass.getDeclaredMethod("call");
        method.invoke(instance);
    }
}

실행 결과

BasicData.BasicData:hello
instance = cwchoiit.reflection.data.BasicData@b360e9ca
BasicData.call

 

Class.forName("cwchoiit.reflection.data.BasicData")를 사용해서 클래스 정보를 동적으로 조회했다.

 

getDeclaredConstructor(String.class): 생성자를 조회한다.

  • 여기서는 매개변수로 String을 사용하는 생성자를 조회한다.
  • 근데 그 생성자는 private 접근 제어자다! 그럼에도 불구하고 사용할 수가 있다.

declaredConstructors.setAccessible(true)를 사용해서 private 생성자를 접근 가능하게 만들었다.

다시봐도 이 String 매개변수를 가지는 생성자는 private이다.

private BasicData(String data) {
    System.out.println("BasicData.BasicData:" + data);
}

 

Object instance = declaredConstructors.newInstance("hello");

찾은 생성자를 사용해서 객체를 생성까지 한다. 여기서는 "hello"라는 인자를 넘겨준다.

 

Method method = aClass.getDeclaredMethod("call");
method.invoke(instance);

앞서 생성한 인스턴스에 call 이라는 이름의 메서드를 동적으로 찾아서 호출한다.

 

이번 예제를 잘 보면, 클래스를 동적으로 찾아서 인스턴스를 생성하고, 메서드도 동적으로 호출했다. 코드 어디에도 BasicData의 타입이나 call() 메서드를 직접 호출하는 부분을 코딩하지 않았다. 클래스를 찾고 생성하는 방법도, 그리고 생성한 클래스의 메서드를 호출하는 방법도 모두 동적으로 처리한 것이다. 

 

참고로, 스프링 프레임워크나 다른 프레임워크 기술들을 사용해보면, 내가 만든 클래스를 프레임워크가 대신 생성해줄 때가 있다. 그때가 되면 방금 학습한 리플렉션과 동적 객체 생성 방법들이 떠오를 것이다.

 

 

HTTP 서버6 - 리플렉션 서블릿

이전 포스팅에서 커맨드 패턴으로 만든 서블릿은 아주 유용하지만, 몇가지 단점이 있다.

  • 하나의 클래스에 하나의 기능만 만들 수 있다.
  • 새로 만든 클래스를 URL 경로와 항상 매핑해야 한다.

이제 리플렉션의 기본 기능을 학습했으니, 처음에 설명한 리플렉션을 활용한 서블릿 기능을 만들어보자. 개발자는 다음과 같이 간단한 기능만 만들면 된다.

  public class ReflectController {
      public void site1(HttpRequest request, HttpResponse response) {
          response.writeBody("<h1>site1</h1>");
      }
      public void site2(HttpRequest request, HttpResponse response) {
          response.writeBody("<h1>site2</h1>");
      }
      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>");
      } 
}
  • 개발자는 이런 Xxx컨트롤러라는 기능만 개발하면 된다.
  • 이러한 컨트롤러의 메서드를 리플렉션으로 읽고 호출할 것이다.
참고로, 컨트롤러라는 용어는, 애플리케이션의 제어 흐름을 제어(control)한다. 요청을 받아 적절한 비즈니스 로직을 호출하고, 그 결과를 뷰에 전달하는 등의 작업을 수행한다.

 

URL 경로의 이름과 메서드의 이름이 같다.

  • /site1 → site1()
  • /site2 → site2()
  • /search → search()

리플렉션을 활용하면 메서드 이름을 알 수 있다. 예를 들어, /site1이 입력되면, site1() 이라는 메서드를 이름으로 찾아서 호출하는 것이다. 이렇게 하면 번거로운 매핑 작업을 제거할 수 있다. 

 

물론 필요하면 서로 관련된 기능은 하나의 클래스로 모으도록, 클래스를 나눌 수 있게 해도 된다. 

  • SiteController
    • site1()
    • site2()
  • SearchController
    • search()

리플렉션을 처리하는 서블릿 구현

앞서 설명했듯, 서블릿은 자바 진영에서 이미 표준으로 사용하는 기술이다. 따라서 서블릿은 그대로 사용하면서 새로운 기능을 구현해보자. 앞서 만든 HTTP 서버에서 was.httpserver 패키지는 다른 곳에서 제공하는 변경할 수 없는 라이브러리라고 가정하자. 우리는 was.httpserver의 코드를 전혀 변경하지 않고 그대로 재사용하면서 기능을 추가하겠다. 

 

 

v5 코드

  • HTTP 서버와 관련된 부분 - was.httpserver 패키지
    • HttpServer, HttpRequestHandler, HttpRequest, HttpResponse
    • HttpServlet, HttpServletManager
    • InternalErrorServlet, NotFoundServlet (was.httpserver.servlet 패키지)
  • 서비스 개발을 위한 로직 - v5.servlet 패키지
    • HomeServlet
    • Site1Servlet
    • Site2Servlet
    • SearchServlet

 

컨트롤러

SearchControllerV6

package cwchoiit.was.v6;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;

public class SearchControllerV6 {

    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>");
    }
}
  • `/search`를 처리한다.

SiteControllerV6

package cwchoiit.was.v6;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;

public class SiteControllerV6 {

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

    public void site2(HttpRequest request, HttpResponse response) {
        response.writeBody("<h1>Site2</h1>");
    }
}
  • `/site1`, `/site2`를 처리한다.

참고로, XxxController에서 호출 대상이 되는 메서드는 반드시 HttpRequest, HttpResponse를 인자로 받아야 한다. 이 부분은 뒤에서 설명한다. 기존 코드에서 이런 컨트롤러들을 어떻게 호출하도록 만들 수 있을까? 

 

리플렉션 서블릿

ReflectionServlet

package cwchoiit.was.httpserver.servlet.reflection;

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 ReflectionServlet implements HttpServlet {

    private final List<Object> controllers;

    public ReflectionServlet(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) {
                String name = method.getName();
                if (path.equals("/" + name)) {
                    invoke(request, response, controller, method);
                    return;
                }
            }
        }
        throw new PageNotFoundException("path = " + path);
    }

    private void invoke(HttpRequest request, HttpResponse response, Object controller, Method method) {
        try {
            method.invoke(controller, request, response);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }
}
  • was.httpserver.servlet.reflection 패키지 위치를 주의하자. 다른 프로젝트에서도 필요하면 사용할 수 있다.
  • List<Object> controllers: 생성자를 통해 여러 컨트롤러들을 보관할 수 있다.

이 서블릿은 요청이 오면 모든 컨트롤러를 순회한다. 그리고 선언된 메서드 정보를 통해, URL의 요청 경로와 메서드 이름이 맞는지 확인한다. 만약, 메서드 이름이 맞다면 invoke()를 통해 해당 메서드를 동적으로 호출한다. 이때, HttpRequest, HttpResponse 정보도 함께 넘겨준다. 따라서 대상 메서드는 반드시 HttpRequest, HttpResponse를 인자로 받아야 한다. 

 

이미 앞서 리플렉션에서 학습한 내용들이라 이해하기 어렵지는 않을 것이다.

 

ServerMainV6

package cwchoiit.was.v6;

import cwchoiit.was.httpserver.HttpServer;
import cwchoiit.was.httpserver.ServletManager;
import cwchoiit.was.httpserver.servlet.DiscardServlet;
import cwchoiit.was.httpserver.servlet.reflection.ReflectionServlet;
import cwchoiit.was.v5.servlet.HomeServlet;

import java.io.IOException;
import java.util.List;

public class ServerMainV6 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        List<Object> controllers = List.of(new SiteControllerV6(), new SearchControllerV6());

        ReflectionServlet reflectionServlet = new ReflectionServlet(controllers);

        ServletManager servletManager = new ServletManager();
        servletManager.setDefaultServlet(reflectionServlet);

        servletManager.add("/", new HomeServlet());
        servletManager.add("/favicon.ico", new DiscardServlet());

        new HttpServer(PORT, servletManager).start();
    }
}

실행 결과

  • 실행 결과는 기존과 같다.

 

  • 만든 컨트롤러(SiteController, SearchController)들을 리스트로 담고, ReflectionServlet의 생성자로 넘겨준다.
  • ServletManagerdefaultServlet을 위에서 만든 ReflectionServlet으로 지정한다. 이 부분이 중요하다. 리플렉션 서블릿을 기본 서블릿으로 등록하는 것이다. 이렇게 되면 다른 서블릿에서 경로를 찾지 못할 때 우리가 만든 리플렉션 서블릿이 항상 호출된다. 그리고 다른 서블릿은 등록하지 않는다. 따라서 항상 리플렉션 서블릿이 호출된다. 
    • 아쉽게도 HomeServlet을 처리하는 `/` 경로와 `/favicon.ico` 경로를 처리하는 DiscardServlet()은 등록해줘야 한다. 이 두 경로는 메서드명을 `/()`로 할 수 없고 `favicon.ico()` 이렇게 못만드니까.

기존에는 어떻게 서블릿을 URL 매핑을 했나? 바로 이렇게 했다.

servletManager.add("/", new HomeServlet());
servletManager.add("/site1", new Site1Servlet());
servletManager.add("/site2", new Site2Servlet());
servletManager.add("/search", new SearchServlet());
servletManager.add("/favicon.ico", new DiscardServlet());

 

이렇게 각 URL 별로 서블릿 하나씩을 다 만들어서 ServletManager에 등록하고 이 ServletManager가 아래 메서드를 호출하면서, 서블릿을 실행했다.

public void execute(HttpRequest request, HttpResponse response) throws IOException {
    try {

        HttpServlet servlet = servletMap.getOrDefault(request.getPath(), defaultServlet);
        if (servlet == null) {
            throw new PageNotFoundException("Page not found: " + request.getPath());
        }

        servlet.service(request, response);

    } catch (PageNotFoundException e) {
        e.printStackTrace();
        notFoundErrorServlet.service(request, response);
    } catch (Exception e) {
        e.printStackTrace();
        internalErrorServlet.service(request, response);
    }
}

그런데, 여기서 이제는 defaultServlet을 제외하고 아무런 서블릿도 등록하지 않았으니 저 ReflectionServlet만 실행되도록 한 것이다.

 

 

정리

기존 HTTP 서버의 코드를 전혀 변경하지 않고, 서블릿만 잘 구현해서 완전히 새로운 기능을 도입했다. 덕분에 앞서 커맨드 패턴으로 만든 서블릿의 단점을 해결할 수 있었다. 이렇게 리플렉션을 활용해서, 요청 URL과 동일한 메서드명을 통해 서블릿을 호출할 수 있게 된다.

 

남은 문제점

  • 리플렉션 서블릿은 요청 URL과 메서드 이름이 같다면 해당 메서드를 동적으로 호출할 수 있다. 하지만 요청 이름과 메서드 이름을 다르게 하고 싶다면 어떻게 해야할까?
  • 예를 들어, `/site1` 이라고 와도 page1()과 같은 다른 이름의 메서드를 호출하고 싶다면 어떻게 해야 할까? 예를 들어서 메서드 이름은 더 자세히 적고 싶을 수도 있잖아? 
  • 또한, 홈 경로인 `/`와, `/favicon.ico`와 같은 아이콘 처리 경로는 자바 메서드 이름으로 처리할 수 없었다. 이건 어떻게 해결 할까?
  • URL은 주로 `-`를 사용해서 문장을 이어간다. 예를 들면 `/add-member` 이렇게 말이다. 이런 URL은 어떻게 해결 할까?

 

 

이 문제들을 해결하는 방법은 애노테이션을 사용하는 것이다! 다음 포스팅은 애노테이션이다.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션 강의 | 김영한 - 인프런

김영한 | I/O, 네트워크, 리플렉션, 애노테이션을 기초부터 실무 레벨까지 깊이있게 학습합니다. 웹 애플리케이션 서버(WAS)를 자바로 직접 만들어봅니다., 국내 개발 분야 누적 수강생 1위, 제대로

www.inflearn.com

 

HTTP 서버1 - 시작

HTTP 서버를 직접 만들어보자. 웹 브라우저에서 우리 서버에 접속하면, 다음과 같은 HTML을 응답하는 것이다.

<h1>Hello World</h1>

그러면 웹 브라우저가 "Hello World"를 크게 보여줄 것이다.

 

참고로, HTML은 <html>, <head>, <body>와 같은 기본 태그를 가진다. 원래는 이런 태그도 함께 포함해서 전달해야 하지만, 예제를 단순하게 만들기 위해 최소한의 태그만 사용하겠다.

 

HttpServerV1

package cwchoiit.was.v1;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;
import static java.nio.charset.StandardCharsets.*;

public class HttpServerV1 {

    private final int port;

    public HttpServerV1(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        ServerSocket serverSocket = new ServerSocket(port);
        log("서버 시작 port: " + port);

        while (true) {
            Socket socket = serverSocket.accept();
            process(socket);
        }
    }

    private void process(Socket socket) throws IOException {
        try (socket;
             BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
             PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {

            String requestString = requestToString(reader);

            if (requestString.contains("/favicon.ico")) {
                log("favicon 요청");
                return;
            }

            log("HTTP 요청 정보 출력:");
            System.out.println(requestString);

            log("HTTP 응답 생성 중...");
            sleep(5000);
            responseToClient(writer);
        }
    }

    private static void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    private void responseToClient(PrintWriter writer) {
        String body = "<h1>Hello World</h1>";
        int length = body.getBytes(UTF_8).length;

        StringBuilder sb = new StringBuilder();
        sb.append("HTTP/1.1 200 OK\r\n");
        sb.append("Content-Type: text/html\r\n");
        sb.append("Content-Length: ").append(length).append("\r\n");
        sb.append("\r\n");
        sb.append(body);

        log("HTTP 응답 정보 출력");
        System.out.println(sb);

        writer.println(sb);
        writer.flush();
    }

    private String requestToString(BufferedReader reader) throws IOException {
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            if (line.isEmpty()) {
                break;
            }
            sb.append(line).append("\n");
        }

        return sb.toString();
    }
}
  • HTTP 메시지의 주요 내용들은 문자로 읽고 쓰게 된다.
  • 따라서, 여기서는 BufferedReader, PrintWriter를 사용했다.
  • StreamReader, Writer로 변경할 때는 항상 인코딩을 확인하자.

 

AutoFlush

  • new PrintWriter(socket.getOutputStream(), false, UTF_8)
  • PrintWriter의 두번째 인자는 autoFlush 여부이다.
  • 이 값을 true로 설정하면 println()으로 출력할 때마다 자동으로 플러시된다.
  • 그러면 첫 내용을 빠르게 전송할 수 있지만, 네트워크 전송이 자주 발생된다.
  • 이 값을 false로 설정하면, flush()를 직접 호출해주어야 데이터를 전송한다.
  • 데이터를 모아서 전송하므로, 네트워크 전송 횟수를 효과적으로 줄일 수 있다. 한 패킷에 많은 양의 데이터를 담아서 전송할 수 있다.
  • 여기서는 false로 설정했으므로, 마지막에 꼭 writer.flush()를 호출해야 한다.

requestToString()

  • HTTP 요청을 읽어서 String으로 반환한다.
  • HTTP 요청의 시작 라인, 헤더까지 읽는다.
  • line.isEmpty()이면, HTTP 메시지 헤더의 마지막으로 인식하고 메시지 읽기를 종료한다.
  • HTTP 메시지 헤더의 끝은 빈 라인으로 구분할 수 있다. 빈 라인 이후에는 메시지 바디가 나온다.
  • 참고로 여기서는 메시지 바디를 전달하지 않으므로, 메시지 바디의 정보는 읽지 않는다.

/favicon.ico

  • 웹 브라우저에서 해당 사이트의 작은 아이콘을 추가로 요청할 수 있다. 여기서는 사용하지 않으므로 무시한다.

sleep(5000); // 서버 처리 시간

  • 예제가 단순해서 응답이 너무 빠르다. 서버에서 요청을 처리하는데 약 5초의 시간이 걸린다고 가정하는 코드이다.

responseToClient()

  • HTTP 응답 메시지를 생성해서 클라이언트에 전달한다.
  • 시작라인, 헤더, HTTP 메시지 바디를 전달한다.
  • HTTP 공식 스펙에서 다음 라인은 \r\n(캐리지 리턴 + 라인 피드)로 표현한다. 참고로 \n만 사용해도 대부분의 웹 브라우저는 문제없이 작동한다. (캐리지 리턴은 커서가 현재 문장에 가장 앞으로 가는 이스케이프 문자)
  • 마지막에 writer.flush()를 호출해서 데이터를 전송한다.

 

ServerMain

package cwchoiit.was.v1;

import java.io.IOException;

public class ServerMain {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        HttpServerV1 server = new HttpServerV1(PORT);
        server.start();
    }
}
  • 웹 브라우저를 실행하고 다음 사이트에 접속해보자. 5초간 기다려야 한다. 
    • http://localhost:12345 
    • http://127.0.0.1:12345

 

 

이러한 문장이 5초 뒤에 딱 나왔다.

 

실행 결과

17:57:48.634 [     main] 서버 시작 port: 12345
17:57:57.123 [     main] HTTP 요청 정보 출력:
GET / HTTP/1.1
Host: localhost:12345
Connection: keep-alive
sec-ch-ua: "Brave";v="129", "Not=A?Brand";v="8", "Chromium";v="129"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Sec-GPC: 1
Accept-Language: en-US,en
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Cookie: sr-selectedScriptRoot=/Users/choichiwon/Atlassian/scriptrunner-skeleton/jira/target/jira/home/scripts; screenResolution=2560x1440; wordpress_test_cookie=WP%20Cookie%20check; wp-settings-time-1=1723983516

17:57:57.124 [     main] HTTP 응답 생성 중...
17:58:02.127 [     main] HTTP 응답 정보 출력
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 20

<h1>Hello World</h1>
17:58:02.185 [     main] favicon 요청

요청 부분

HTTP 요청 메시지

GET / HTTP/1.1
Host: localhost:12345
Connection: keep-alive
sec-ch-ua: "Brave";v="129", "Not=A?Brand";v="8", "Chromium";v="129"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Sec-GPC: 1
Accept-Language: en-US,en
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Cookie: sr-selectedScriptRoot=/Users/choichiwon/Atlassian/scriptrunner-skeleton/jira/target/jira/home/scripts; screenResolution=2560x1440; wordpress_test_cookie=WP%20Cookie%20check; wp-settings-time-1=1723983516
  • http://localhost:12345를 요청하면, 웹 브라우저가 HTTP 요청 메시지를 만들어서 서버에 전달한다.

시작 라인

  • GET: GET 메서드
  • /: 요청 경로, 별도의 요청 경로가 없으면 /를 사용한다.
  • HTTP/1.1: HTTP 버전

헤더

  • Host: 접속하는 서버 정보
  • User-Agent: 웹 브라우저의 정보
  • Accept: 웹 브라우저가 전달 받을 수 있는 HTTP 응답 메시지 바디 형태
  • Accept-Encoding: 웹 브라우저가 전달 받을 수 있는 인코딩 형태
  • Accept-Language: 웹 브라우저가 전달 받을 수 있는 언어 형태

응답 부분

HTTP 응답 메시지

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 20

<h1>Hello World</h1>

 

시작 라인

  • HTTP/1.1: HTTP 버전
  • 200: HTTP 상태 코드(성공)
  • OK: 200에 대한 설명

헤더

  • Content-Type: HTTP 메시지 바디의 데이터 형태, 여기서는 HTML을 사용
  • Content-Length: HTTP 메시지 바디의 데이터 길이

바디

<h1>Hello World</h1>

 

 

솔직히, 신기하다. 스프링으로만 웹 서버를 개발하느라 단 한번도 이렇게 서버를 띄워본 적이 없었는데.. 이런 기조가 깔려있었구나.

지금 우리 서버의 문제는..

서버는 동시에 수 많은 사용자의 요청을 처리할 수 있어야 한다. 현재 서버는 한 번에 하나의 요청만 처리할 수 있다는 문제가 있다. 다른 웹 브라우저를 2개를 동시에 열어서 사이트를 실행해보자. 첫번째 요청이 모두 처리되고 나서 두번째 요청이 처리되는것을 알 수 있다. 당연히 코드 상에 이렇게 되어 있으니까.

while (true) {
    Socket socket = serverSocket.accept();
    process(socket);
}
  • accept()는 웹 브라우저에서 요청이 들어오면 그때 소켓을 반환하고 process(socket)을 실행한다. 이 상태에서 아무리 요청을 해도 process(socket)이 끝나야 다시 accept()로 돌아갈 수 있다.

 

HTTP 서버2 - 동시 요청

스레드를 사용해서 동시에 여러 요청을 처리할 수 있도록 서버를 개선해보자.

 

HttpRequestHandler

package cwchoiit.was;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;
import static java.nio.charset.StandardCharsets.UTF_8;

public class HttpRequestHandler implements Runnable {

    private final Socket socket;

    public HttpRequestHandler(Socket socket) throws IOException {
        this.socket = socket;
    }

    @Override
    public void run() {
        try (socket;
             BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
             PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {

            String requestString = requestToString(reader);
            if (requestString.contains("/favicon.ico")) {
                log("favicon 요청");
                return;
            }

            log("HTTP 요청 정보 출력:");
            System.out.println(requestString);

            log("HTTP 응답 생성 중...");
            sleep(5000);
            responseToClient(writer);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    private void responseToClient(PrintWriter writer) {
        String body = "<h1>Hello World</h1>";
        int length = body.getBytes(UTF_8).length;

        StringBuilder sb = new StringBuilder();
        sb.append("HTTP/1.1 200 OK\r\n");
        sb.append("Content-Type: text/html\r\n");
        sb.append("Content-Length: ").append(length).append("\r\n");
        sb.append("\r\n");
        sb.append(body);

        log("HTTP 응답 정보 출력");
        System.out.println(sb);

        writer.println(sb);
        writer.flush();
    }

    private String requestToString(BufferedReader reader) throws IOException {
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            if (line.isEmpty()) {
                break;
            }
            sb.append(line).append("\n");
        }

        return sb.toString();
    }
}
  • 클라이언트의 HttpRequestHandler는 이름 그대로 클라이언트가 전달한 HTTP 요청을 처리한다.
  • 동시에 요청한 수만큼 별도의 스레드에서 HttpRequestHandler가 수행된다.

 

HttpServerV2

package cwchoiit.was.v2;

import cwchoiit.was.HttpRequestHandler;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static cwchoiit.util.MyLogger.log;

public class HttpServerV2 {
    private final ExecutorService es = Executors.newFixedThreadPool(10);
    private final int port;

    public HttpServerV2(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        ServerSocket serverSocket = new ServerSocket(port);
        log("Starting HttpServerV2 server on port " + port);

        while (true) {
            Socket socket = serverSocket.accept();
            es.submit(new HttpRequestHandler(socket));
        }
    }
}
  • ExecutorService: 스레드 풀을 사용한다. 여기서는 newFixedThreadPool(10)을 사용해서 최대 동시에 10개의 스레드를 사용할 수 있도록 했다. 결과적으로 10개의 요청을 동시에 처리할 수 있는것이다.
  • 참고로 실무에서는 상황에 따라 다르겠지만, 보통 수백개 정도의 스레드를 사용한다.
  • es.submit(new HttpRequestHandler(socket)): 스레드 풀에 HttpRequestHandler 작업을 요청한다.
  • 스레드 풀에 있는 스레드가 HttpRequestHandlerrun()을 수행한다. 

 

HttpServerMainV2

package cwchoiit.was.v2;

import java.io.IOException;

public class HttpServerMainV2 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        new HttpServerV2(PORT).start();
    }
}

 

HTTP 서버3 - 기능 추가

HTTP 서버가 어떻게 작동하는지 이해했다면, 이제 기능을 추가해보자.

HTTP 서버들은 URL 경로를 사용해서 각각의 기능을 제공한다. 이제 URL에 따른 다양한 기능을 제공해보자.

 

다음 사진은 `http://localhost:12345`로 진입했을 때 보여지는 화면이다.

  • home: `/` 첫 화면
  • site1: `/site1` 페이지 화면1
  • site2: `/site2` 페이지 화면2
  • search: `/search` 기능 검색 화면, 클라이언트에서 서버로 검색어를 전달할 수 있다.
  • notFound: 잘못된 URL을 호출했을 때 전달하는 화면

HttpRequestHandlerV3 

package cwchoiit.was.v3;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

import static cwchoiit.util.MyLogger.log;
import static java.nio.charset.StandardCharsets.UTF_8;

public class HttpRequestHandlerV3 implements Runnable {

    private final Socket socket;

    public HttpRequestHandlerV3(Socket socket) throws IOException {
        this.socket = socket;
    }

    @Override
    public void run() {
        try (socket;
             BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
             PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {

            String requestString = requestToString(reader);
            if (requestString.contains("/favicon.ico")) {
                log("favicon 요청");
                return;
            }

            log("HTTP 요청 정보 출력:");
            System.out.println(requestString);

            log("HTTP 응답 생성 중...");
            if (requestString.startsWith("GET /site1")) {
                site1(writer);
            } else if (requestString.startsWith("GET /site2")) {
                site2(writer);
            } else if (requestString.startsWith("GET /search")) {
                search(writer, requestString);
            } else if (requestString.startsWith("GET / ")) {
                home(writer);
            } else {
                notFound(writer);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void site1(PrintWriter writer) {
        writer.println("HTTP/1.1 200 OK");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();

        writer.println("<h1>Site1</h1>");
        writer.flush();
    }

    private void site2(PrintWriter writer) {
        writer.println("HTTP/1.1 200 OK");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();

        writer.println("<h1>Site2</h1>");
        writer.flush();
    }

    private void search(PrintWriter writer, String requestString) {
        int startIndex = requestString.indexOf("q=");
        int endIndex = requestString.indexOf(" ", startIndex + 2);

        String query = requestString.substring(startIndex + 2, endIndex);

        String decode = URLDecoder.decode(query, UTF_8);

        writer.println("HTTP/1.1 200 OK");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();

        writer.println("<h1>Search</h1>");
        writer.println("<ul>");
        writer.println("<li>query = " + query + "</li>");
        writer.println("<li>decode = " + decode + "</li>");
        writer.println("</ul>");
        writer.flush();
    }

    private void home(PrintWriter writer) {
        writer.println("HTTP/1.1 200 OK");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();

        writer.println("<h1>Home</h1>");
        writer.println("<ul>");
        writer.println("<li><a href='/site1'>site1</a></li>");
        writer.println("<li><a href='/site2'>site2</a></li>");
        writer.println("<li><a href='/search?q=hello'>search</a></li>");
        writer.println("</ul>");

        writer.flush();
    }

    private void notFound(PrintWriter writer) {
        writer.println("HTTP/1.1 404 Not Found");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();

        writer.println("<h1>404 페이지를 찾을 수 없습니다.</h1>");
        writer.flush();
    }

    private String requestToString(BufferedReader reader) throws IOException {
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            if (line.isEmpty()) {
                break;
            }
            sb.append(line).append("\n");
        }

        return sb.toString();
    }
}
  • HTTP 요청 메시지의 시작 라인을 파싱하고, 요청 URL에 맞추어 응답을 전달한다.
    • GET / → home() 호출
    • GET /site1 → site1() 호출
  • 응답 시 원칙적으로 헤더에 메시지 바디의 크기를 계산해서 Content-Length를 전달해야 하지만, 예제를 단순화하기 위해 생략했다.

검색 시 다음과 같은 형식으로 요청이 온다.

  • GET /search?q=hello
  • URL에서 `?` 이후의 부분에 key1=value1&key2=value2 포맷으로 서버에 데이터를 전달할 수 있다.
  • 이 부분을 파싱하면 요청하는 검색어를 알 수 있다.
  • 예제에서는 실제로 검색을 하는 것은 아니고, 요청하는 검색어를 간단히 출력한다.
  • URLDecoder는 바로 뒤에서 설명한다.

 

HttpServerV3

package cwchoiit.was.v3;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static cwchoiit.util.MyLogger.log;

public class HttpServerV3 {
    private final ExecutorService es = Executors.newFixedThreadPool(10);
    private final int port;

    public HttpServerV3(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        ServerSocket serverSocket = new ServerSocket(port);
        log("Starting HttpServerV2 server on port " + port);

        while (true) {
            Socket socket = serverSocket.accept();
            es.submit(new HttpRequestHandlerV3(socket));
        }
    }
}

 

HttpServerMainV3

package cwchoiit.was.v3;

import java.io.IOException;

public class HttpServerMainV3 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        new HttpServerV3(PORT).start();
    }
}

 

실행 결과

 

그럼 뒤에서 설명하겠다고 한 URLDecoder를 알아보자.

URL 인코딩

URL은 ASCII 문자집합을 사용한다.

 

URL이 ASCII를 사용하는 이유?

HTTP 메시지에서 시작 라인(URL을 포함)과 HTTP 헤더의 이름은 항상 ASCII를 사용해야 한다. HTTP 메시지 바디는 UTF-8과 같은 다른 인코딩을 사용할 수 있다. 지금처럼 UTF-8이 표준화된 시대에 왜 URL은 ASCII만 사용할 수 있을까? 

  • 인터넷이 처음 설계되던 시기(1980 - 1990년대)에 대부분의 컴퓨터 시스템은 ASCII 문자 집합을 사용했다. 
  • 전 세계에서 사용하는 다양한 컴퓨터 시스템과 네트워크 장비 간의 호환성을 보장하기 위해, URL은 단일한 문자 인코딩 체계를 사용해야 했다. 그 당시 모든 시스템이 비 ASCII 문자를 처리할 수 없었기 때문에, ASCII는 가장 보편적이고 일관된 선택이었다.
  • HTTP URL이 ASCII만을 지원하는 이유는 초기 인터넷의 기술적 제약과 전 세계적인 호환성을 유지하기 위한 선택이다.
  • 순수한 UTF-8로 URL을 표현하려면, 전 세계 모든 네트워크 장비, 서버, 클라이언트 소프트웨어가 이를 지원해야 한다. 그러나, 여전히 많은 시스템에서 ASCII 기반 표준에 의존하고 있기 때문에 순수한 UTF-8 URL을 사용하면 호환성 문제가 발생할 수 있다. 
  • HTTP 스펙은 매우 보수적이고, 호환성을 가장 우선시한다.

그렇다면 검색어로 사용하는 `/search?q=hello`를 사용할 때 `q=가나다` 같이 URL에 한글을 전달하려면 어떻게 해야할까?

우선 웹 브라우저 URL 입력창에 다음 내용을 입력해보자.

`http://localhost:12345/search?q=가나다`

 

Search
• query: %EA%B0%80%EB%82%98%EB%8B%A4
• decode: 가나다

 

퍼센트(%) 인코딩

한글을 UTF-8 인코딩으로 표현하려면 한 글자에 3byte의 데이터를 사용한다. 가, 나, 다를 UTF-8 인코딩의 16진수로 표현하면 다음과 같다. 

  • 가: EA, B0, 80 (3byte)
  • 나: EB, 82, 98 (3byte)
  • 다: EB, 8B, A4 (3byte)
참고로, 2진수는 (0, 1), 10진수는 (0-9), 16진수는(0-9, A, B, C, D, E, F) 로 표현한다.

 

URL은 ASCII 문자만 표현할 수 있으므로, UTF-8 문자를 표현할 수 없다. 그래서 한글 '가'를 예로 들면, '가'를 UTF-8의 16진수로 표현한 각각의 바이트 문자 앞에 %(퍼센트)를 붙이는 것이다. 

  • q=가
  • q=%EA%B0%80

이렇게 하면 약간 억지스럽긴 하지만, ASCII 문자를 사용해서 16진수로 표현된 UTF-8을 표현할 수 있다. 그리고 %EA%B0%80은 모두 ASCII에 포함되는 문자이다. 이렇게 각각의 16진수 byte를 문자로 표현하고, 해당 문자 앞에 %를 붙이는 것을 퍼센트(%) 인코딩이라고 한다. 

 

% 인코딩 후에 클라이언트에서 서버로 데이터를 전달하면, 서버는 각각의 %를 제거하고 EA, B0, 80이라는 각 문자를 얻는다. 그리고 이렇게 얻은 3개의 byte를 모아서 UTF-8로 디코딩하면 "가"라는 글자를 얻을 수 있다.

 

% 인코딩, 디코딩 진행 과정

  1. 클라이언트: '가' 전송 희망
  2. 클라이언트 % 인코딩: %EA%B0%80
    1. '가'를 UTF-8로 인코딩
    2. 'EA', 'B0', '80' (3byte) 획득
    3. 각 byte를 16진수 문자로 표현하고 각각의 앞에 %를 붙임
  3. 클라이언트 → 서버 전송 q=%EA%B0%80
  4. 서버: %EA%B0%80 ASCII 문자를 전달 받음
    1. %가 붙은 경우 디코딩해야 하는 문자로 인식
    2. EA, B0, 80을 byte로 변환, 3byte 획득
    3. EA, B0, 80을 UTF-8로 디코딩 → 문자 '가' 획득

 

자바가 제공하는 % 인코딩

자바가 제공하는 URLEncoder.encode(), URLDecoder.decode()를 사용하면, % 인코딩, 디코딩을 처리할 수 있다.

PercentEncodingMain

package cwchoiit.was.v3;

import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

public class PercentEncodingMain {
    public static void main(String[] args) {

        String encode = URLEncoder.encode("가", StandardCharsets.UTF_8);
        System.out.println("encode = " + encode);

        String decode = URLDecoder.decode(encode, StandardCharsets.UTF_8);
        System.out.println("decode = " + decode);
    }
}

실행 결과

encode = %EA%B0%80
decode = 가

 

% 인코딩 정리

  • % 인코딩은 데이터 크기에서 보면 효율이 떨어진다. 문자 '가'는 단지 3byte만 필요하다. 그런데 % 인코딩을 사용하면 %EA%B0%80 무려 9byte가 사용된다. 
  • 그럼에도 불구하고, HTTP는 매우 보수적이다. 호환성을 최우선으로 한다. 그렇기에 이 방식을 여전히 사용중이다.

 

HTTP 서버4 - 요청, 응답

HTTP 요청 메시지와 응답 메시지는 각각 정해진 규칙이 있다.

  • GET, POST 같은 메서드
  • URL
  • 헤더
  • HTTP 버전, Content-Type, Content-Length

HTTP 요청 매시지와 응답 메시지는 규칙이 있으므로, 각 규칙에 맞추어 객체로 만들면, 단순히 String 문자로 다루는 것보다 훨씬 더 구조적이고 객체지향적인 편리한 코드를 만들 수 있다. HTTP 요청 메시지와 응답 메시지를 객체로 만들고, 이전에 작성한 코드도 리팩토링 해보자.

 

 

 

HttpRequest (HTTP 요청 메시지)

package cwchoiit.was.httpserver;

import java.io.BufferedReader;
import java.io.IOException;
import java.net.URLDecoder;
import java.util.HashMap;
import java.util.Map;

import static cwchoiit.util.MyLogger.log;
import static java.nio.charset.StandardCharsets.UTF_8;

public class HttpRequest {

    private String method;
    private String path;

    private final Map<String, String> queryParameters = new HashMap<>();
    private final Map<String, String> headers = new HashMap<>();

    public HttpRequest(BufferedReader reader) throws IOException {
        parseRequestLine(reader);
        parseHeaders(reader);
        parseBody(reader);
    }

    // GET /search?q=hello HTTP/1.1
    private void parseRequestLine(BufferedReader reader) throws IOException {
        String requestLine = reader.readLine();
        if (requestLine == null) {
            throw new IOException("EOF: No request line received");
        }
        String[] parts = requestLine.split(" ");
        if (parts.length != 3) {
            throw new IOException("Invalid request line format: " + requestLine);
        }

        method = parts[0].toUpperCase();
        String[] pathParts = parts[1].split("\\?");
        path = pathParts[0];

        if (pathParts.length > 1) {
            parseQueryParameters(pathParts[1]);
        }
    }

    // q=hello
    // key1=value1&key2=value2&key3=value3
    private void parseQueryParameters(String queryString) {
        for (String param : queryString.split("&")) {
            String[] keyValue = param.split("=");

            String key = URLDecoder.decode(keyValue[0], UTF_8);
            String value = keyValue.length > 1 ? URLDecoder.decode(keyValue[1], UTF_8) : "";

            queryParameters.put(key, value);
        }
    }
    private void parseHeaders(BufferedReader reader) throws IOException {
        String line;
        while (!(line = reader.readLine()).isEmpty()) {
            String[] headerParts = line.split(":");
            headers.put(headerParts[0].trim(), headerParts[1].trim());
        }
    }

    private void parseBody(BufferedReader reader) throws IOException {
        if (!headers.containsKey("Content-Length")) {
            return;
        }

        int contentLength = Integer.parseInt(headers.get("Content-Length"));
        char[] bodyChars = new char[contentLength];

        int read = reader.read(bodyChars);
        if (read != contentLength) {
            throw new IOException("Failed to read entire body. Expected " + contentLength + " bytes, but got " + read);
        }
        String body = new String(bodyChars);
        log("HTTP Message Body: " + body);

        String contentType = headers.get("Content-Type");
        if ("application/x-www-form-urlencoded".equals(contentType)) {
            parseQueryParameters(body);
        }
    }

    public String getMethod() {
        return method;
    }

    public String getPath() {
        return path;
    }

    public String getParameter(String name) {
        return queryParameters.get(name);
    }

    public String getHeader(String name) {
        return headers.get(name);
    }

    @Override
    public String toString() {
        return "HttpRequest{" +
                "method='" + method + '\'' +
                ", path='" + path + '\'' +
                ", queryParameters=" + queryParameters +
                ", headers=" + headers +
                '}';
    }
}
  • reader.readLine() → 클라이언트가 연결만 하고, 데이터 전송 없이 연결을 종료하는 경우 null이 반환된다. 이 경우 간단히 throw new IOException("EOF") 예외를 던지자.
    • 일부 브라우저의 경우 성능 최적화를 위해 TCP 연결을 추가로 하나 더 하는 경우가 있다.
    • 이때, 추가 연결을 사용하지 않고, 그대로 종료하면 TCP 연결은 하지만 데이터는 전송하지 않고, 연결을 끊게 된다. (참고만 하자)

HTTP 요청 메시지는 다음과 같다.

  GET /search?q=hello HTTP/1.1
  Host: localhost:12345

 

시작 라인을 통해 method, path, queryParameters를 구할 수 있다.

  • method: GET
  • path: /search
  • queryParameters: [q=hello]

queryString, header의 경우, key=value 형식이기 때문에, Map을 사용하면 이후에 편리하게 데이터를 조회할 수 있다.

만약, 다음과 같은 내용이 있다면 queryParametersMap에 저장되는 내용은 다음과 같다.

  • /search?q=hello&type=text
  • queryParameters: [q=hello, type=text]

(%)퍼센트 디코딩도 URLDecoder.decode()를 사용해서 처리한 다음에 Map에 보관한다. 따라서 HttpRequest 객체를 사용하는 쪽에서는 퍼센트 디코딩을 고민하지 않아도 된다. 

  • /search?q=%EA%B0%80
  • queryParameters: [q=가]

HTTP 명세에서 헤더가 끝나는 부분은 빈 라인으로 구분한다.

  • while (!(line = reader.readLine()).isEmpty())

이렇게 하면 시작 라인의 다양한 정보와 헤더를 객체로 구조화할 수 있다. 참고로 메시지 바디 부분은 아직 파싱하지 않았는데 뒤에서 설명한다. 그건 그렇고 이거 어디서 많이 본거 같다. 맞다. HttpServletRequest가 이런식으로 만들어진 것이다.

 

HttpResponse (HTTP 응답 메시지)

package cwchoiit.was.httpserver;

import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;

import static java.nio.charset.StandardCharsets.*;

public class HttpResponse {
    private final PrintWriter writer;
    private int statusCode;
    private final StringBuilder bodyBuilder = new StringBuilder();
    private String contentType = "text/html; charset=UTF-8";

    public HttpResponse(PrintWriter writer) {
        this.writer = writer;
    }

    public void setStatusCode(int statusCode) {
        this.statusCode = statusCode;
    }

    public void setContentType(String contentType) {
        this.contentType = contentType;
    }

    public void writeBody(String body) {
        bodyBuilder.append(body);
    }

    public void flush() {
        int contentLength = bodyBuilder.toString().getBytes(UTF_8).length;
        writer.println("HTTP/1.1 " + statusCode + " " + getReasonPhrase(statusCode));
        writer.println("Content-Type: " + contentType);
        writer.println("Content-Length: " + contentLength);
        writer.println();

        writer.println(bodyBuilder);
        writer.flush();
    }

    private String getReasonPhrase(int statusCode) {
        return switch (statusCode) {
            case 200 -> "OK";
            case 404 -> "Not Found";
            case 500 -> "Internal Server Error";
            default -> "Unknown";
        };
    }
}

 

HTTP 응답 메시지는 다음과 같다.

  HTTP/1.1 200 OK
  Content-Type: text/html
  Content-Length: 20
  
  <h1>Hello World</h1>

 

시작 라인

  • HTTP 버전: HTTP/1.1
  • 응답 코드: 200
  • 응답 코드의 간단한 설명: OK

응답 헤더

  • Content-Type: HTTP 메시지 바디에 들어있는 내용의 종류
  • Content-Length: HTTP 메시지 바디의 길이

 

HTTP 응답을 객체로 만들면 시작 라인, 응답 헤더를 구성하는 내용을 반복하지 않고 편리하게 사용할 수 있다.

 

HttpRequestHandlerV4

package cwchoiit.was.v4;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;
import static java.nio.charset.StandardCharsets.UTF_8;

public class HttpRequestHandlerV4 implements Runnable {

    private final Socket socket;

    public HttpRequestHandlerV4(Socket socket) throws IOException {
        this.socket = socket;
    }

    @Override
    public void run() {
        try (socket;
             BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
             PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {

            HttpRequest httpRequest = new HttpRequest(reader);
            HttpResponse httpResponse = new HttpResponse(writer);

            if (httpRequest.getPath().equals("/favicon.ico")) {
                log("favicon 요청");
                return;
            }

            log("HTTP 요청 정보 출력:");
            System.out.println(httpRequest);

            log("HTTP 응답 생성 중...");
            if (httpRequest.getPath().equals("/site1")) {
                site1(httpResponse);
            } else if (httpRequest.getPath().equals("/site2")) {
                site2(httpResponse);
            } else if (httpRequest.getPath().equals("/search")) {
                search(httpRequest, httpResponse);
            } else if (httpRequest.getPath().equals("/")) {
                home(httpResponse);
            } else {
                notFound(httpResponse);
            }

            httpResponse.flush();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void site1(HttpResponse response) {
        response.writeBody("<h1>Site1</h1>");
    }

    private void site2(HttpResponse response) {
        response.writeBody("<h1>Site2</h1>");
    }

    private 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>");
    }

    private void home(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>");
    }

    private void notFound(HttpResponse response) {
        response.setStatusCode(404);
        response.writeBody("<h1>404 페이지를 찾을 수 없습니다.</h1>");
    }
}
  • 클라이언트의 요청이 들어오면 요청 정보를 기반으로 HttpRequest 객체를 만들어둔다. 이때, HttpResponse도 함께 만든다.
  • HttpRequest를 통해서 필요한 정보를 편리하게 찾을 수 있다.
  • /search의 경우, 퍼센트 디코딩을 고민하지 않아도 된다. 이미 HttpRequest에서 다 처리해두었다.
  • 응답의 경우 HttpResponse를 사용하고, HTTP 메시지 바디에 출력할 부분만 적어주면 된다. 나머지는 HttpResponse 객체가 알아서 대신 처리해준다.
  • response.flush()는 꼭 호출해줘야 한다. 그래야 실제 응답이 클라이언트에 전달된다.

 

HttpServerV4

package cwchoiit.was.v4;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static cwchoiit.util.MyLogger.log;

public class HttpServerV4 {
    private final ExecutorService es = Executors.newFixedThreadPool(10);
    private final int port;

    public HttpServerV4(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        ServerSocket serverSocket = new ServerSocket(port);
        log("Starting HttpServerV2 server on port " + port);

        while (true) {
            Socket socket = serverSocket.accept();
            es.submit(new HttpRequestHandlerV4(socket));
        }
    }
}

 

HttpServerMainV4

package cwchoiit.was.v4;

import java.io.IOException;

public class HttpServerMainV4 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        new HttpServerV4(PORT).start();
    }
}

 

실행 결과

  • 실행 결과는 기존과 동일하다.

 

정리

HttpRequest, HttpResponse 객체가 HTTP 요청과 응답을 구조화한 덕분에 많은 중복을 제거하고 또 코드도 매우 효과적으로 리팩토링 할 수 있었다. 지금까지 학습한 내용을 잘 생각해보면, 전체적인 코드가 크게 2가지로 분류되는 것을 확인할 수 있다. 

  • HTTP 서버와 관련된 부분
    • HttpServer, HttpRequestHandler, HttpRequest, HttpResponse
  • 서비스 개발을 위한 로직 
    • home(), site1(), site2(), search(), notFound()

만약, 웹을 통한 회원 관리 프로그램 같은 서비스를 새로 만들어야 한다면, 기존 코드에서 HTTP 서버와 관련된 부분은 거의 재사용하고 서비스 개발을 위한 로직만 추가하면 될 것 같다. 그리고 HTTP 서버와 관련된 부분을 정말 잘 만든다면 HTTP 서버와 관련된 부분은 다른 개발자들이 사용할 수 있도록 오픈소스로 만들거나 따로 판매를 해도 될 것이다.

 

 

HTTP 서버5 - 커맨드 패턴 ⭐️

난 이 부분을 보고, 머리가 얼얼했다. 이렇게 만들어진 거구나.. 싶었기 때문이다.

 

 

우선, 이전까지 ServerSocket을 활용해서 HTTP 서버를 열심히 만들어봤다. 열심히 만들고 나니, HTTP 서버와 관련된 부분을 본격적으로 구조화하고 싶어진다. 그래서 서비스 개발을 위한 로직과 명확하게 분리해보자. 여기서 핵심은 HTTP 서버와 관련된 부분은 코드 변경없이 재사용 가능해야 한다는 점이다. HTTP 서버와 관련된 부분은 was.httpserver 패키지에 넣어두자. 이 패키지에는 현재 HttpRequest, HttpResponse가 들어가 있다. 이후에 HttpServer, HttpRequestHandler도 잘 정리해서 추가해보자.

 

커맨드 패턴 도입

앞에서 다음 코드를 보고, 아마도 커맨드 패턴을 도입하면 좋을 것이라 생각했을 것이다.

  if (request.getPath().equals("/site1")) {
      site1(response);
  } else if (request.getPath().equals("/site2")) {
      site2(response);
  } else if (request.getPath().equals("/search")) {
      search(request, response);
  } else if (request.getPath().equals("/")){
      home(response);
  } else {
      notFound(response);
  }

커맨드 패턴을 사용하면 확장성이라는 장점도 있지만, HTTP 서버와 관련된 부분과 서비스 개발을 위한 로직을 분리하는데 도움이 된다.

 

커맨드 패턴을 도입해보자.

HttpServlet

package cwchoiit.was.httpserver;

import java.io.IOException;

public interface HttpServlet {
    void service(HttpRequest request, HttpResponse response) throws IOException;
}
  • HttpServlet 이라는 이름의 인터페이스를 만들었다.
    • HTTP, Server, Applet의 줄임말이다. (HTTP 서버에서 실행되는 작은 자바 프로그램 (애플릿))
    • 이 인터페이스의 service() 메서드가 있는데, 여기에 서비스 개발과 관련된 부분을 구현하면 된다.
    • 매개변수로 HttpRequest, HttpResponse가 전달된다.
    • HttpRequest를 통해서 HTTP 요청 정보를 꺼내고, HttpResponse를 통해서 필요한 응답을 할 수 있기 때문에 이 정도면 충분하다. 

HttpServlet을 구현해서 서비스의 각 기능을 구현해보자.

 

서비스 서블릿들

 

HomeServlet

package cwchoiit.was.v5.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class HomeServlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        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>");
    }
}

Site1Servlet

package cwchoiit.was.v5.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class Site1Servlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        response.writeBody("<h1>Site1</h1>");
    }
}

Site2Servlet

package cwchoiit.was.v5.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class Site2Servlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        response.writeBody("<h1>Site2</h1>");
    }
}

SearchServlet

package cwchoiit.was.v5.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class SearchServlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        String query = request.getParameter("q");

        response.writeBody("<h1>Search</h1>");
        response.writeBody("<ul>");
        response.writeBody("<li>query = " + query + "</li>");
        response.writeBody("</ul>");
    }
}
  • HomeServlet, Site1Servlet, Site2Servlet, SearchServlet은 현재 프로젝트에서만 사용하는 개별 서비스를 위한 로직이다. 따라서 was.v5.servlet 패키지를 사용했다.

 

공용 서블릿들

 

NotFoundServlet

package cwchoiit.was.httpserver.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class NotFoundServlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        response.setStatusCode(404);
        response.writeBody("<h1>404 페이지를 찾을 수 없습니다.</h1>");
    }
}

InternalErrorServlet

package cwchoiit.was.httpserver.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class InternalErrorServlet implements HttpServlet {

    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        response.setStatusCode(500);
        response.writeBody("<h1>Internal Error</h1>");
    }
}

DiscardServlet

package cwchoiit.was.httpserver.servlet;

import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.HttpServlet;

import java.io.IOException;

public class DiscardServlet implements HttpServlet {
    @Override
    public void service(HttpRequest request, HttpResponse response) throws IOException {
        // empty
    }
}
  • NotFoundServlet, InternalErrorServlet, DiscardServlet은 여러 프로젝트에서 공용으로 사용하는 서블릿이다. 따라서, was.httpserver.servlet 패키지를 사용했다.
  • NotFoundServlet은 페이지를 찾을 수 없을 때 사용하는 서블릿이다.
  • InternalErrorServlet은 서버 내부에 오류가 있을 때, 500 응답을 보여주기 위한 서블릿이다.
  • DiscardServlet은 `/favicon.ico`와 같은 요청을 무시할 때 사용하는 서블릿이다.

 

이제 HttpServlet을 관리하고 실행시키는 ServletManager 클래스를 만들어보자.

 

그 전에 페이지를 찾지 못했을 때 터뜨리는 예외 클래스 하나를 만들어주자.

PageNotFoundException

package cwchoiit.was.httpserver;

public class PageNotFoundException extends RuntimeException {
    public PageNotFoundException(String message) {
        super(message);
    }
}

 

ServletManager

package cwchoiit.was.httpserver;

import cwchoiit.was.httpserver.servlet.InternalErrorServlet;
import cwchoiit.was.httpserver.servlet.NotFoundServlet;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class ServletManager {
    private final Map<String, HttpServlet> servletMap = new HashMap<>();
    private HttpServlet defaultServlet;
    private HttpServlet notFoundErrorServlet = new NotFoundServlet();
    private HttpServlet internalErrorServlet = new InternalErrorServlet();

    public ServletManager() {
    }

    public void add(String path, HttpServlet servlet) {
        servletMap.put(path, servlet);
    }

    public void setDefaultServlet(HttpServlet defaultServlet) {
        this.defaultServlet = defaultServlet;
    }

    public void setNotFoundErrorServlet(HttpServlet notFoundErrorServlet) {
        this.notFoundErrorServlet = notFoundErrorServlet;
    }

    public void setInternalErrorServlet(HttpServlet internalErrorServlet) {
        this.internalErrorServlet = internalErrorServlet;
    }

    public void execute(HttpRequest request, HttpResponse response) throws IOException {
        try {

            HttpServlet servlet = servletMap.getOrDefault(request.getPath(), defaultServlet);
            if (servlet == null) {
                throw new PageNotFoundException("Page not found: " + request.getPath());
            }

            servlet.service(request, response);

        } catch (PageNotFoundException e) {
            e.printStackTrace();
            notFoundErrorServlet.service(request, response);
        } catch (Exception e) {
            e.printStackTrace();
            internalErrorServlet.service(request, response);
        }
    }
}
  • ServletManager는 설정을 쉽게 변경할 수 있도록, 유연하게 설계되어 있다.
  • was.httpserver 패키지에서 공용으로 사용한다.
  • servletMap 
    • ["/" = HomeServlet, "/site1" = Site1Servlet, ...]과 같이 key/value 형식으로 구성되어 있다. URL의 요청 경로가 Key이다.
  • defaultServlet: HttpServlet을 찾지 못할 때 기본으로 실행된다.
  • notFoundErrorServlet: PageNotFoundException이 발생할 때 실행한다.  URL 요청 경로를 servletMap에서 찾을 수 없고, defaultServlet도 없는 경우 PageNotFoundException을 던진다.
  • InternalErrorServlet: 처리할 수 없는 예외가 발생하는 경우 실행된다.

 

HttpRequestHandler

package cwchoiit.was.httpserver;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;
import static java.nio.charset.StandardCharsets.UTF_8;

public class HttpRequestHandler implements Runnable {

    private final Socket socket;
    private final ServletManager servletManager;

    public HttpRequestHandler(Socket socket, ServletManager servletManager) throws IOException {
        this.socket = socket;
        this.servletManager = servletManager;
    }

    @Override
    public void run() {
        try (socket;
             BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
             PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {

            HttpRequest httpRequest = new HttpRequest(reader);
            HttpResponse httpResponse = new HttpResponse(writer);

            log("HTTP 요청: " + httpRequest);
            servletManager.execute(httpRequest, httpResponse);
            httpResponse.flush();
            log("HTTP 응답 완료");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
  • was.httpserver 패키지에서 공용으로 사용한다.
  • HttpRequestHandler의 역할이 단순해졌다.
  • HttpRequest, HttpResponse를 만들고, servletManager에 전달하면 된다.

 

HttpServer

package cwchoiit.was.httpserver;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static cwchoiit.util.MyLogger.log;

public class HttpServer {
    private final ExecutorService es = Executors.newFixedThreadPool(10);
    private final int port;
    private final ServletManager servletManager;

    public HttpServer(int port, ServletManager servletManager) {
        this.port = port;
        this.servletManager = servletManager;
    }

    public void start() throws IOException {
        ServerSocket serverSocket = new ServerSocket(port);
        log("Starting HttpServer on listening port " + port);

        while (true) {
            Socket socket = serverSocket.accept();
            es.submit(new HttpRequestHandler(socket, servletManager));
        }
    }
}
  • was.httpserver 패키지에서 공용으로 사용한다.
  • 기존과 거의 같지만, HttpRequestHandler가 이제 생성자로 ServletManager를 받기 때문에 그 부분만 추가됐다.

ServerMainV5

package cwchoiit.was.v5;

import cwchoiit.was.httpserver.HttpServer;
import cwchoiit.was.httpserver.ServletManager;
import cwchoiit.was.httpserver.servlet.DiscardServlet;
import cwchoiit.was.v5.servlet.HomeServlet;
import cwchoiit.was.v5.servlet.SearchServlet;
import cwchoiit.was.v5.servlet.Site1Servlet;
import cwchoiit.was.v5.servlet.Site2Servlet;

import java.io.IOException;

public class ServerMainV5 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {

        ServletManager servletManager = new ServletManager();

        servletManager.add("/", new HomeServlet());
        servletManager.add("/site1", new Site1Servlet());
        servletManager.add("/site2", new Site2Servlet());
        servletManager.add("/search", new SearchServlet());
        servletManager.add("/favicon.ico", new DiscardServlet());

        new HttpServer(PORT, servletManager).start();
    }
}
  • 먼저 필요한 서블릿(HttpServlet)들을 서블릿 매니저에 등록하자. 이 부분이 바로 서비스 개발을 위한 로직들이다. 그리고 HttpServer를 생성하면서, 서블릿 매니저를 전달하면 된다.
  • `/favicon.ico`의 경우 아무일도 하지 않고 요청을 무시하는 DiscardServlet을 사용했다.

 

실행 결과

  • 기존과 실행 결과는 같다.

 

정리

이제 HTTP 서버와 서비스 개발을 위한 로직이 명확하게 분리되어 있다.

 

HTTP 서버와 관련된 부분 - was.httpserver 패키지

  • HttpServer, HttpRequestHandler, HttpRequest, HttpResponse
  • HttpServlet, ServletManager
  • was.httpserver.servlet 패키지 (공통으로 사용되는 서블릿들)
    • InternalErrorServlet, NotFoundServlet, DiscardServlet

서비스 개발을 위한 로직 - v5.servlet 패키지

  • HomeServlet
  • Site1Servlet
  • Site2Servlet
  • SearchServlet

 

이후, 다른 HTTP 기반의 프로젝트를 시작해야 한다면, HTTP 서버와 관련된 was.httpserver 패키지의 코드를 그대로 재사용하면 된다. 그리고 해당 서비스에 필요한 서블릿을 구현하고, 서블릿 매니저에 등록한 다음에 서버를 실행하면 된다. 여기서 중요한 부분은 새로운 HTTP 서비스(프로젝트)를 만들어도, was.httpserver 부분의 코드를 그대로 재사용할 수 있고 또 전혀 변경하지 않아도 된다는 점이다.

 

이 부분을 공부하고 얼얼했던 이유는, Servlet, HttpServletRequest, HttpServletResponse가 이런 구조로 만들어졌구나를 깨달았기 때문이다. 아마 위 코드에서 서블릿 매니저에 서블릿을 등록하는 것은 애노테이션으로 Path를 넣어주면 그 애노테이션을 쭉 훑으면서 저 부분을 채워줄 것으로 예상한다. 너무 신기하고 재밌다. 

 

웹 애플리케이션 서버의 역사

만든 was.httpserver 패키지를 사용하면 누구나 손쉽게 HTTP 서비스를 개발할 수 있다. 복잡한 네트워크, 멀티스레드, HTTP 메시지 파싱에 대한 부분을 모두 여기서 해결해준다. was.httpserver 패키지를 사용하는 개발자들은 단순히 HttpServlet의 구현체만 만들면, 필요한 기능을 손쉽게 구현할 수 있다.

 

was.httpserver 패키지의 코드를 다른 사람들이 사용할 수 있게 오픈소스로 공개한다면, 많은 사람들이 HTTP 기반의 프로젝트를 손쉽게 개발할 수 있을 것이다. HTTP 서버와 관련된 코드를 정말 잘 만들어서, 이 부분을 상업용으로 판매할 수도 있을것이다. 그리고 이런 패키지가 바로 우리가 잘 알고 있는 Servlet이다.

 

서블릿과 웹 애플리케이션 서버

서블릿은 Servlet, HttpServlet, ServletRequest, ServletResponse를 포함한 많은 표준을 제공한다. 지금까지 was.httpserver 패키지에서 만든 것들과 다 유사한 기능을 가지는 것들이다. 서블릿을 제공하는 주요 자바 웹 애플리케이션 서버(WAS)는 다음과 같다.

  • 오픈소스
    • Apache Tomcat
    • Jetty
    • Undertow
  • 상용
    • IBM WebSphere
    • Oracle WebLogic
참고로, 보통 자바 진영에서 웹 애플리케이션 서버라고 하면 서블릿 기능을 포함하는 서버를 뜻한다.

 

 

표준화의 장점

HTTP 서버를 만드는 회사들이 서블릿을 기반으로 기능을 제공한 덕분에, 개발자는 jakarta.servlet.Servlet 인터페이스를 구현하면 된다. 그리고 Apache Tomcat 같은 애플리케이션 서버에서 작성한 Servlet 구현체를 실행할 수 있다. 그러다가 만약, 성능이나 부가 기능이 더 필요해서 상용 WAS로 변경하거나, 또는 다른 오픈소스로 WAS를 변경해도 기능 변경없이 구현한 서블릿들을 그대로 사용할 수 있다.

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

애노테이션  (6) 2024.10.21
리플렉션  (6) 2024.10.21
Socket을 이용한 채팅 프로그램 만들기  (0) 2024.10.18
네트워크 2 (Socket)  (4) 2024.10.16
네트워크 1 (Socket)  (2) 2024.10.15
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션 강의 | 김영한 - 인프런

김영한 | I/O, 네트워크, 리플렉션, 애노테이션을 기초부터 실무 레벨까지 깊이있게 학습합니다. 웹 애플리케이션 서버(WAS)를 자바로 직접 만들어봅니다., 국내 개발 분야 누적 수강생 1위, 제대로

www.inflearn.com

 

이번에는 지금까지 쭉 배워왔던 Socket을 사용한 자바 네트워크 프로그램을 토대로 채팅 프로그램을 만들어보자.

 

채팅 프로그램 설계

요구사항은 다음과 같다.

  • 서버에 접속한 사용자는 모두 대화할 수 있어야 한다.
  • 다음과 같은 채팅 명령어가 있어야 한다.
    • 입장: /join|{name}
      • 처음 채팅 서버에 접속할 때 사용자의 이름을 입력해야 한다.
    • 메시지: /message|{내용}
      • 모든 사용자에게 메시지를 전달한다.
    • 이름 변경: /change|{name}
      • 사용자의 이름을 변경한다.
    • 전체 사용자: /users
      • 채팅 서버에 접속한 전체 사용자 목록을 출력한다.
    • 종료: /exit
      • 채팅 서버의 접속을 종료한다.

 

Client

 

ReadHandler

package cwchoiit.network.chat.v2.client;

import java.io.DataInputStream;
import java.io.IOException;

import static cwchoiit.util.MyLogger.log;

public class ReadHandler implements Runnable {

    private final DataInputStream input;
    private final Client client;
    public boolean closed = false;

    public ReadHandler(DataInputStream input, Client client) {
        this.input = input;
        this.client = client;
    }

    @Override
    public void run() {
        try {
            while (true) {
                String received = input.readUTF();
                System.out.println(received);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            client.close();
        }
    }

    public synchronized void close() {
        if (closed) {
            return;
        }

        closed = true;
        log("readHandler closed");
    }
}
  • ReadHandlerRunnable 인터페이스를 구현하고, 별도의 스레드에서 실행한다.
  • 서버의 메시지를 반복해서 받고, 콘솔에 출력하는 단순한 기능을 제공한다. 
  • 클라이언트 종료시 ReadHandler도 종료된다. 중복 종료를 막기 위해 동기화 코드와 closed 플래그를 사용했다.
  • 참고로 예제 코드는 단순하므로 중요한 종료 로직이 없다.
  • IOException 예외가 발생하면 끝난것이다. 더 이상 소켓을 통한 통신이 불가능한 상태라는 의미다. 그래서 자원을 다 정리해줘야 한다. client.close()를 통해 클라이언트를 종료하고, 전체 자원을 정리한다.

 

WriteHandler

package cwchoiit.network.chat.v2.client;

import java.io.DataOutputStream;
import java.io.IOException;
import java.util.NoSuchElementException;
import java.util.Scanner;

import static cwchoiit.util.MyLogger.log;

public class WriteHandler implements Runnable {

    private static final String DELIMITER = "|";
    private final DataOutputStream output;
    private final Client client;
    private boolean closed = false;

    public WriteHandler(DataOutputStream output, Client client) {
        this.output = output;
        this.client = client;
    }

    @Override
    public void run() {
        Scanner scanner = new Scanner(System.in);

        try {
            String username = inputUsername(scanner);
            output.writeUTF("/join" + DELIMITER + username);

            while (true) {
                String toSend = scanner.nextLine();
                if (toSend.isEmpty()) {
                    continue;
                }
                if (toSend.equals("/exit")) {
                    output.writeUTF(toSend);
                    break;
                }
                if (toSend.startsWith("/")) {
                    output.writeUTF(toSend);
                } else {
                    output.writeUTF("/message" + DELIMITER + toSend);
                }
            }
        } catch (IOException | NoSuchElementException e) {
            log(e);
        } finally {
            client.close();
        }
    }

    private static String inputUsername(Scanner scanner) {
        System.out.println("이름을 입력하세요.");
        String username;
        do {
            username = scanner.nextLine();
        } while (username.isEmpty());
        return username;
    }

    public synchronized void close() {
        if (closed) {
            return;
        }

        try {
            System.in.close(); // Scanner 입력 중지 (사용자의 입력을 닫음)
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        closed = true;
        log("writeHandler closed");
    }
}
  • WriterHandler는 사용자의 콘솔의 입력을 받아서 서버로 메시지를 전송한다.
  • 처음 시작시 inputUsername()을 통해 사용자의 이름을 입력 받는다. 
  • 처음 채팅 서버에 접속하면 /join|{name}을 전송한다. 이 메시지를 통해 입장했다는 정보와 사용자의 이름을 서버에 전달한다.
  • 메시지는 다음과 같이 설계된다.
    • 입장: /join|{name}
    • 메시지: /message|{내용}
    • 종료: /exit
  • 만약, 콘솔 입력 시 `/`로 시작하면 /join, /exit과 같은 특정 명령어를 수행한다고 가정한다.
  • `/`를 입력하지 않으면 일반 메시지로 보고, /message에 내용을 추가해서 서버에 전달한다.

close()를 호출하면, System.in.close()를 통해 사용자의 콘솔 입력을 닫는다. 이렇게 하면 Scanner를 통한 콘솔 입력인 scanner.nextLine() 코드에서 대기하는 스레드에 다음 예외가 발생하면서, 대기 상태에서 빠져나올 수 있다.

java.util.NoSuchElementException: No line found

서버가 연결을 끊은 경우에 클라이언트의 자원이 정리되는데, 이때 유용하게 사용된다.

 

IOException 예외가 발생하면 끝난것이다. 더 이상 소켓을 통한 통신이 불가능한 상태라는 의미다. 그래서 자원을 다 정리해줘야 한다. client.close()를 통해 클라이언트를 종료하고, 전체 자원을 정리한다.

 

Client

package cwchoiit.network.chat.v2.client;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;
import static cwchoiit.util.SocketCloseUtil.closeAll;

public class Client {

    private final String host;
    private final int port;

    private Socket socket;
    private DataInputStream input;
    private DataOutputStream output;

    private ReadHandler readHandler;
    private WriteHandler writeHandler;
    private boolean closed = false;

    public Client(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public void start() throws IOException {
        log("클라이언트 시작");

        socket = new Socket(host, port);
        input = new DataInputStream(socket.getInputStream());
        output = new DataOutputStream(socket.getOutputStream());

        readHandler = new ReadHandler(input, this);
        writeHandler = new WriteHandler(output, this);

        Thread readThread = new Thread(readHandler, "readHandler");
        Thread writeThread = new Thread(writeHandler, "writeHandler");

        readThread.start();
        writeThread.start();
    }

    public synchronized void close() {
        if (closed) {
            return;
        }

        writeHandler.close();
        readHandler.close();
        closeAll(socket, input, output);
        closed = true;

        log("Connection closed");
    }
}
  • 클라이언트 전반을 관리하는 클래스이다.
  • Socket, ReadHandler, WriteHandler를 모두 생성하고 관리한다.
  • close() 메서드를 통해 전체 자원을 정리하는 기능도 제공한다.

ClientMain

package cwchoiit.network.chat.v2.client;

import java.io.IOException;

public class ClientMain {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {

        Client client = new Client("localhost", PORT);
        client.start();
    }
}
  • ClientEntry Point.

 

Server

Session

package cwchoiit.network.chat.v2.server;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;
import static cwchoiit.util.SocketCloseUtil.closeAll;

public class Session implements Runnable {

    private final Socket socket;
    private final DataInputStream input;
    private final DataOutputStream output;
    private final CommandManager commandManager;
    private final SessionManager sessionManager;

    private boolean closed = false;
    private String username;

    public Session(Socket socket, SessionManager sessionManager, CommandManager commandManager) throws IOException {
        this.socket = socket;
        this.input = new DataInputStream(socket.getInputStream());
        this.output = new DataOutputStream(socket.getOutputStream());
        this.sessionManager = sessionManager;
        this.commandManager = commandManager;
        this.sessionManager.add(this);
    }

    @Override
    public void run() {
        try {
            while (true) {
                String received = input.readUTF();
                log("client -> server: " + received);

                commandManager.execute(received, this);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            sessionManager.remove(this);
            sessionManager.sendAll(username + "님이 퇴장했습니다.");
            close();
        }
    }

    public void send(String message) throws IOException {
        log("server -> client:" + message);
        output.writeUTF(message);
    }

    public void close() {
        if (closed) {
            return;
        }

        closeAll(socket, input, output);
        closed = true;
        log("연결 종료" + socket);
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getUsername() {
        return username;
    }
}
  • CommandManager는 명령어를 처리하는 기능을 제공한다. 바로 뒤에서 설명한다.
  • Session의 생성 시점에 sessionManagerSession을 등록한다.
  • username을 통해 클라이언트의 이름을 등록할 수 있다. 사용자의 이름을 사용하는 기능은 뒤에 추가하겠다. 지금은 값이 없으니 null로 사용된다.

run()

  • 클라이언트로부터 메시지를 전송받는다.
  • 전송 받은 메시지를 commandManager.execute()를 사용해서 실행한다.
  • 예외가 발생하면 세션 매니저에서 세션을 제거하고, 나머지 클라이언트에게 퇴장 소식을 알린다. 그리고 자원을 정리한다.

send(String message)

  • 이 메서드를 호출하면 해당 세션의 클라이언트에게 메시지를 보낸다.

SessionManager

package cwchoiit.network.chat.v2.server;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import static cwchoiit.util.MyLogger.log;

public class SessionManager {
    private final List<Session> sessions = new ArrayList<>();

    public synchronized void add(Session session) {
        sessions.add(session);
    }

    public synchronized void remove(Session session) {
        sessions.remove(session);
    }

    public synchronized void closeAll() {
        for (Session session : sessions) {
            session.close();
        }
        sessions.clear();
    }

    public synchronized void sendAll(String message) {
        for (Session session : sessions) {
            try {
                session.send(message);
            } catch (IOException e) {
                log(e);
            }
        }
    }

    public synchronized List<String> getAllUsername() {
        List<String> usernames = new ArrayList<>();
        for (Session session : sessions) {
            if (session.getUsername() != null) {
                usernames.add(session.getUsername());
            }
        }
        return usernames;
    }
}
  • 세션을 관리한다.
  • closeAll(): 모든 세션을 종료하고, 세션 관리자에서 제거한다.
  • sendAll(): 모든 세션에 메시지를 전달한다. 이때, 각 세션의 send()가 호출된다.
  • getAllUsername(): 모든 세션에 등록된 사용자의 이름을 반환한다. 향후, 모든 사용자 목록을 출력할 때 사용된다.

CommandManager

package cwchoiit.network.chat.v2.server;

import java.io.IOException;

public interface CommandManager {
    void execute(String totalMessage, Session session) throws IOException;
}
  • 클라이언트에게 전달받은 메시지를 처리하는 인터페이스이다.
  • 인터페이스를 사용하는 이유는 향후 구현체를 점진적으로 변경하기 위해서이다.
  • totalMessage: 클라이언트에게 받은 메시지
  • session: 현재 세션

CommandManagerV1

package cwchoiit.network.chat.v2.server;

import java.io.IOException;

public class CommandManagerV1 implements CommandManager {

    private final SessionManager sessionManager;

    public CommandManagerV1(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String totalMessage, Session session) throws IOException {
        if (totalMessage.startsWith("/exit")) {
            throw new IOException("exit");
        }

        sessionManager.sendAll(totalMessage);
    }
}
  • 클라이언트에게 일반적인 메시지를 전달 받으면, 모든 클라이언트에게 같은 메시지를 전달해야 한다.
  • sessionManager.sendAll(totalMessage)를 사용해서 해당 기능을 처리한다.
  • /exit가 호출되면, IOException을 던진다. 세션은 해당 예외를 잡아서 세션을 종료한다.
  • CommandManagerV1은 최소한의 메시지 전달 기능만 구현했다. 복잡한 나머지 기능들은 다음 버전에 추가할 예정이다.

Server

package cwchoiit.network.chat.v2.server;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class Server {
    private final int port;
    private final CommandManager commandManager;
    private final SessionManager sessionManager;

    private ServerSocket serverSocket;

    public Server(int port, CommandManager commandManager, SessionManager sessionManager) {
        this.port = port;
        this.commandManager = commandManager;
        this.sessionManager = sessionManager;
    }

    public void start() throws IOException {
        log("서버 시작:" + commandManager.getClass());
        serverSocket = new ServerSocket(port);

        log("서버 소켓 시작 - 리스닝 포트: " + port);

        addShutdownHook();

        running();
    }

    private void addShutdownHook() {
        ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
        Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));
    }

    private void running() {
        try {
            while (true) {
                Socket socket = serverSocket.accept();
                log("소켓 연결: " + socket);

                Session session = new Session(socket, sessionManager, commandManager);
                Thread thread = new Thread(session);
                thread.start();
            }
        } catch (IOException e) {
            log("서버 소켓 종료: " + e);
        }
    }

    static class ShutdownHook implements Runnable {

        private final ServerSocket serverSocket;
        private final SessionManager sessionManager;

        public ShutdownHook(ServerSocket serverSocket, SessionManager sessionManager) {
            this.serverSocket = serverSocket;
            this.sessionManager = sessionManager;
        }

        @Override
        public void run() {
            log("shutdownHook 실행");
            try {
                sessionManager.closeAll();
                serverSocket.close();

                Thread.sleep(1000); // 자원 정리 대기
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println("e = " + e);
            }
        }
    }
}
  • 앞서 해왔던 네트워크 프로그램과 거의 같다.
  • addShutdownHook(): 셧다운 훅을 등록한다.
  • running(): 클라이언트의 연결을 처리하고 세션을 생성한다.

ServerMain

package cwchoiit.network.chat.v2.server;

import java.io.IOException;

public class ServerMain {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        SessionManager sessionManager = new SessionManager();
        CommandManager commandManager = new CommandManagerV1(sessionManager);

        Server server = new Server(PORT, commandManager, sessionManager);
        server.start();
    }
}
  • Server는 생성자로 SessionManager, CommandManager가 필요하다.
  • 여기서 CommandManager의 구현체는 점진적으로 변경할 예정이다.

 

여러 버전의 CommandManager

V2

왜 버전업이 필요하냐면, 지금 상태는 필요한 기능이 다 구현된 상태가 아니라 최소한의 메시지 전송만 가능한 상태이다. 그래서 점진적으로 버전업을 해보자.

CommandManagerV2

package cwchoiit.network.chat.v2.server;

import java.io.IOException;
import java.util.List;

public class CommandManagerV2 implements CommandManager {

    private final static String DELIMITER = "\\|";
    private final SessionManager sessionManager;

    public CommandManagerV2(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String totalMessage, Session session) throws IOException {
        if (totalMessage.startsWith("/join")) {
            String[] split = totalMessage.split(DELIMITER);
            String username = split[1];

            session.setUsername(username);
            sessionManager.sendAll(username + "님이 입장했습니다.");
        } else if (totalMessage.startsWith("/message")) {
            String[] split = totalMessage.split(DELIMITER);
            String message = split[1];
            sessionManager.sendAll("[" + session.getUsername() + "] " + message);
        } else if (totalMessage.startsWith("/change")) {
            String[] split = totalMessage.split(DELIMITER);
            String changeName = split[1];
            sessionManager.sendAll(session.getUsername() + " 님이 " + changeName + " (으)로 이름을 변경했습니다.");
            session.setUsername(changeName);
        } else if (totalMessage.startsWith("/users")) {
            List<String> usernames = sessionManager.getAllUsername();
            StringBuilder sb = new StringBuilder();
            sb.append("전체 접속자: ").append(usernames.size()).append("\n");
            for (String username : usernames) {
                sb.append(" - ").append(username).append("\n");
            }
            session.send(sb.toString());
        } else if (totalMessage.startsWith("/exit")) {
            throw new IOException("exit");
        } else {
            session.send("알수 없는 메시지: " + totalMessage);
        }
    }
}
  • 이번엔 최초 요구사항에서 필요했던 기능들을 전부 다 구현한 V2가 만들어졌다.

그런데, 지금 버전의 문제는 다음과 같다.

서비스가 잘되면 앞으로 새로운 기능이 계속 추가될 수도 있다.그런데 각각의 개발 기능을 CommandManager안에 if문으로 덕지덕지 추가하는 것이 영 마음에 들지 않는다. 새로운 기능이 추가되어도 기존 코드에 영향을 최소화하면서 기능을 추가할 수 있게 변경해보자.

 

V3

 

Command

package cwchoiit.network.chat.v2.server.command;

import cwchoiit.network.chat.v2.server.Session;

import java.io.IOException;

public interface Command {
    void execute(String[] args, Session session) throws IOException;
}
  • 각각의 명령어를 하나의 Command로 보고 인터페이스와 구현체로 구분해보자.
  • 총 5개의 기능이 있으므로, 5개의 기능을 각각의 Command로 구현해보자.

JoinCommand

package cwchoiit.network.chat.v2.server.command;

import cwchoiit.network.chat.v2.server.Session;
import cwchoiit.network.chat.v2.server.SessionManager;

public class JoinCommand implements Command {

    private final SessionManager sessionManager;

    public JoinCommand(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String[] args, Session session) {
        String username = args[1];
        session.setUsername(username);
        sessionManager.sendAll(username + "님이 입장했습니다.");
    }
}

ChangeCommand

package cwchoiit.network.chat.v2.server.command;

import cwchoiit.network.chat.v2.server.Session;
import cwchoiit.network.chat.v2.server.SessionManager;

import java.io.IOException;

public class ChangeCommand implements Command {

    private final SessionManager sessionManager;

    public ChangeCommand(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String[] args, Session session) throws IOException {
        String changeName = args[1];
        sessionManager.sendAll(session.getUsername() + " 님이 " + changeName + " (으)로 이름을 변경했습니다.");
        session.setUsername(changeName);
    }
}

MessageCommand

package cwchoiit.network.chat.v2.server.command;

import cwchoiit.network.chat.v2.server.Session;
import cwchoiit.network.chat.v2.server.SessionManager;

import java.io.IOException;

public class MessageCommand implements Command {

    private final SessionManager sessionManager;

    public MessageCommand(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String[] args, Session session) throws IOException {
        String message = args[1];
        sessionManager.sendAll("[" + session.getUsername() + "] " + message);
    }
}

UsersCommand

package cwchoiit.network.chat.v2.server.command;

import cwchoiit.network.chat.v2.server.Session;
import cwchoiit.network.chat.v2.server.SessionManager;

import java.io.IOException;
import java.util.List;

public class UsersCommand implements Command {

    private final SessionManager sessionManager;

    public UsersCommand(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String[] args, Session session) throws IOException {
        List<String> usernames = sessionManager.getAllUsername();
        StringBuilder sb = new StringBuilder();
        sb.append("전체 접속자: ").append(usernames.size()).append("\n");
        for (String username : usernames) {
            sb.append(" - ").append(username).append("\n");
        }
        session.send(sb.toString());
    }
}

ExitCommand

package cwchoiit.network.chat.v2.server.command;

import cwchoiit.network.chat.v2.server.Session;

import java.io.IOException;

public class ExitCommand implements Command {
    @Override
    public void execute(String[] args, Session session) throws IOException {
        throw new IOException("Exit");
    }
}

 

CommandManagerV3

package cwchoiit.network.chat.v2.server;

import cwchoiit.network.chat.v2.server.command.*;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class CommandManagerV3 implements CommandManager {

    private final static String DELIMITER = "\\|";
    private final Map<String, Command> commands = new HashMap<>();

    public CommandManagerV3(SessionManager sessionManager) {
        commands.put("/join", new JoinCommand(sessionManager));
        commands.put("/message", new MessageCommand(sessionManager));
        commands.put("/change", new ChangeCommand(sessionManager));
        commands.put("/users", new UsersCommand(sessionManager));
        commands.put("/exit", new ExitCommand());
    }

    @Override
    public void execute(String totalMessage, Session session) throws IOException {

        String[] args = totalMessage.split(DELIMITER);
        String key = args[0];

        Command command = commands.get(key);
        if (command == null) {
            session.send("처리할 수 없는 명령어 입니다. " + totalMessage);
            return;
        }
        command.execute(args, session);
    }
}
  • 명령어를 관리하고 찾아서 실행하는 V3를 만들었다.

Map<String, Command> commands

  • 명령어는 Map에 보관한다. 명령어 자체를 Key로 사용하고, 각 Key에 해당하는 Command 구현체를 저장해둔다.

execute()

  • Command command = command.get(key) → 명령어를 처리할 Command 구현체를 commands에서 찾아서 실행한다.
  • 예를 들어, `/join`이라는 메시지가 들어왔다면, JoinCommand 인스턴스가 반환된다. 
  • command를 찾았다면, 다형성을 활용해서 구현체의 execute() 메서드를 호출한다. 
  • 만약, 찾을 수 없다면 처리할 수 없는 명령어입니다. 라는 메시지를 전달한다.

 

참고로, 여러 스레드가 이 commands = new HashMap<>()을 동시에 접근해서 데이터를 조회한다. 접속자가 2명이면 2개의 스레드가, 3개면 3개의 스레드 쭉 말이다. 하지만 commands는 데이터 초기화 이후에는 데이터를 전혀 변경하지 않는다. 따라서 여러 스레드가 동시에 값을 조회해도 문제가 발생하지 않는다. 만약, commands의 데이터를 스레드들이 변경한다고 하면 동시성 문제를 고민해야 한다.

 

 

V4

이전 예제로도 충분히 잘 만들어진 상태지만, command가 없는 경우에 null을 체크하고 처리해야 하는 부분이 좀 지저분하다.

Command command = commands.get(key);
if (command == null) {
    session.send("처리할 수 없는 명령어 입니다. " + totalMessage);
    return;
}

 

지저분(?) 보다는 만약, 명령어가 항상 존재한다면 다음과 같이 명령어를 찾고 바로 실행하는 깔끔한 코드를 작성할 수 있을 것이다.

Command command = commands.get(key);
command.execute(args, session);

 

이 문제의 해결 방안은 의외로 간단하다. 바로 null을 처리할 객체를 만들면 된다.

DefaultCommand

package cwchoiit.network.chat.v2.server.command;

import cwchoiit.network.chat.v2.server.Session;

import java.io.IOException;
import java.util.Arrays;

public class DefaultCommand implements Command {
    @Override
    public void execute(String[] args, Session session) throws IOException {
        session.send("처리할 수 없는 명령어 입니다." + Arrays.toString(args));
    }
}

 

CommandManagerV4

package cwchoiit.network.chat.v2.server;

import cwchoiit.network.chat.v2.server.command.*;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class CommandManagerV4 implements CommandManager {

    private final static String DELIMITER = "\\|";
    private final Map<String, Command> commands = new HashMap<>();
    private final DefaultCommand defaultCommand = new DefaultCommand();

    public CommandManagerV4(SessionManager sessionManager) {
        commands.put("/join", new JoinCommand(sessionManager));
        commands.put("/message", new MessageCommand(sessionManager));
        commands.put("/change", new ChangeCommand(sessionManager));
        commands.put("/users", new UsersCommand(sessionManager));
        commands.put("/exit", new ExitCommand());
    }

    @Override
    public void execute(String totalMessage, Session session) throws IOException {

        String[] args = totalMessage.split(DELIMITER);
        String key = args[0];

        Command command = commands.getOrDefault(key, defaultCommand);
        command.execute(args, session);
    }
}
  • Map에는 getOrDefault(key, defaultObject)라는 메서드가 있다. 
  • 만약, key를 사용해서 객체를 찾을 수 있다면 찾고, 찾을 수 없다면 옆에 있는 defaultObject를 반환한다.
  • 이 기능을 사용하면 null을 받지 않고 항상 Command 객체를 받아서 처리할 수 있다.
  • 여기서는 key를 찾을 수 없다면 DefaultCommand의 인스턴스를 반환한다. 

 

Null Object Pattern

이와 같이 null을 객체(Object)처럼 처리하는 방법을 Null Object Pattern이라 한다. 이 디자인 패턴은 null 대신 사용할 수 있는 특별한 객체를 만들어, null로 인해 발생할 수 있는 예외 상황을 방지하고 코드의 간결성을 높이는데 목적이 있다. Null Object Pattern을 사용하면 null값 대신 특정 동작을 하는 객체를 반환하게 되어, 클라이언트 코드에서 null 체크를 할 필요가 없어진다. 이 패턴은 코드에서 불필요한 조건문을 줄이고 객체의 기본 동작을 정의하는 데 유용하다.

 

Command Pattern

지금까지 작성한 Command 인터페이스와 그 구현체들이 바로 커맨드 패턴을 사용한 것이다. 커맨드 패턴은 디자인 패턴 중 하나로, 요청을 독립적인 객체로 변환해서 처리한다. 커맨드 패턴의 특징은 다음과 같다.

  • 분리: 작업을 호출하는 객체와 작업을 수행하는 객체를 분리한다.
  • 확장성: 기존 코드를 변경하지 않고 새로운 명령을 추가할 수 있다.

커맨드 패턴의 장점

  • 이 패턴의 장점은 새로운 커맨드를 쉽게 추가할 수 있다는 점이다. 예를 들어, 새로운 커맨드를 추가하고 싶으면 새로운 Command 구현체만 만들면 된다. 그리고 기존 코드를 대부분 변경할 필요가 없다. 
  • 작업을 호출하는 객체와 작업을 수행하는 객체가 분리되어 있다. 이전 코드에서는 작업을 호출하는 if문이 각 작업마다 등장했는데 커맨드 패턴에서는 이런 부분을 하나로 모아서 처리할 수 있다.
  • 각각의 기능이 명확하게 분리된다. 개발자가 어떤 기능을 수정해야 할 때, 수정해야 하는 클래스가 아주 명확해진다.

커맨드 패턴의 단점

  • 복잡성 증가: 간단한 작업을 수행하는 경우에도 Command 인터페이스와 구현체들, Command 객체를 호출하고 관리하는 클래스 등 여러 클래스를 생성해야 하기 때문에 코드의 복잡성이 증가할 수 있다. 
  • 모든 설계에는 trade-off가 있다. 예를 들어, 단순한 if문 몇개로 해결할 수 있는 문제에 복잡한 커맨드 패턴을 도입하는 것은 좋은 설계까 아닐 수 있다.
  • 기능이 어느정도 있고, 각각의 기능이 명확하게 나누어질 수 있고, 향후 기능의 확장까지 고려해야 한다면 커맨드 패턴은 좋은 대안이 될 수 있다. 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

리플렉션  (6) 2024.10.21
ServerSocket으로 HTTP 서버 만들기  (2) 2024.10.18
네트워크 2 (Socket)  (4) 2024.10.16
네트워크 1 (Socket)  (2) 2024.10.15
File, Files  (0) 2024.10.14
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션 강의 | 김영한 - 인프런

김영한 | I/O, 네트워크, 리플렉션, 애노테이션을 기초부터 실무 레벨까지 깊이있게 학습합니다. 웹 애플리케이션 서버(WAS)를 자바로 직접 만들어봅니다., 국내 개발 분야 누적 수강생 1위, 제대로

www.inflearn.com

 

네트워크 프로그램4 - 자원 정리1

이전 포스팅에서 배운 자원 정리와 네트워크 프로그램을 활용해서, 깔끔하게 자원을 정리하는 코드로 변경해보자.

먼저 finally를 사용해서 자원을 정리해보고, 이후에 try-with-resources를 도입해보자. 참고로 뒤에서 설명하겠지만, try-with-resources를 항상 사용할 수 있는 것은 아니고, finally에서 직접 자원을 정리해야 하는 경우가 많이 있다. 

 

우선, 소켓과 스트림을 종료하기 위해 간단한 유틸리티 클래스를 하나 만들자.

SocketCloseUtil

package cwchoiit.network.tcp;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class SocketCloseUtil {

    public static void closeAll(Socket socket, InputStream input, OutputStream output) {
        closeInput(input);
        closeOutput(output);
        closeSocket(socket);
    }

    public static void closeInput(InputStream input) {
        if (input != null) {
            try {
                input.close();
            } catch (IOException e) {
                log(e.getMessage());
            }
        }
    }

    public static void closeOutput(OutputStream output) {
        if (output != null) {
            try {
                output.close();
            } catch (IOException e) {
                log(e.getMessage());
            }
        }
    }

    public static void closeSocket(Socket socket) {
        if (socket != null) {
            try {
                socket.close();
            } catch (IOException e) {
                log(e.getMessage());
            }
        }
    }
}
  • 기본적인 null 체크와 자원 종료시 예외를 잡아서 처리하는 코드가 들어가 있다. 참고로 자원 정리 과정에서 문제가 발생해도 코드에서 직접 대응할 수 있는 부분은 거의 없다. 이 경우, 간단한 로그를 남겨서 이후에 개발자가 인지할 수 있는 정도면 충분하다.
  • 각각의 예외를 잡아서 처리했기 때문에 Socket, InputStream, OutputStream 중 하나를 닫는 과정에서 예외가 발생해도 다음 자원을 닫을 수 있다.
  • Socket을 먼저 생성하고, Socket을 기반으로 InputStream, OutputStream을 생성하기 때문에 닫을 때는 InputStream → OutputStream → Socket 순서로 닫아야 한다. 아 물론, InputStream, OutputStream 이들은 닫는 순서가 상관 없다.

 

클라이언트 코드를 먼저 수정해보자.

ClientV4

package cwchoiit.network.tcp.v4;

import cwchoiit.network.tcp.SocketCloseUtil;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.Scanner;

import static cwchoiit.network.tcp.SocketCloseUtil.*;
import static cwchoiit.util.MyLogger.log;

public class ClientV4 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        log("클라이언트 시작");

        Socket socket = null;
        DataInputStream input = null;
        DataOutputStream output = null;
        Scanner scanner = new Scanner(System.in);

        try {
            socket = new Socket("localhost", PORT);
            input = new DataInputStream(socket.getInputStream());
            output = new DataOutputStream(socket.getOutputStream());
            log("소켓 연결: " + socket);

            while (true) {
                System.out.print("서버에게 보낼 문자를 입력하세요:");
                String message = scanner.nextLine();

                output.writeUTF(message);
                log("client -> server: " + message);

                if ("exit".equalsIgnoreCase(message)) {
                    break;
                }

                // 서버로부터 문자 받기
                String received = input.readUTF();
                log("client <- server: " + received);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            closeAll(socket, input, output);
            log("연결 종료: " + socket);
        }
    }
}
  • 자원 정리시 finally 코드 블럭에서 SocketCloseUtil.closeAll()만 호출하면 된다.

 

이번에는 서버 코드를 수정하자.

 

SessionV4

package cwchoiit.network.tcp.v4;

import cwchoiit.network.tcp.SocketCloseUtil;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

import static cwchoiit.network.tcp.SocketCloseUtil.*;
import static cwchoiit.util.MyLogger.log;

public class SessionV4 implements Runnable {

    private final Socket socket;

    public SessionV4(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        DataInputStream input = null;
        DataOutputStream output = null;

        try {
            input = new DataInputStream(socket.getInputStream());
            output = new DataOutputStream(socket.getOutputStream());

            while (true) {
                // 클라이언트로부터 문자 받기
                String received = input.readUTF();
                log("client -> server: " + received);

                if (received.equalsIgnoreCase("exit")) {
                    break;
                }

                String toSend = received + " World!";
                output.writeUTF(toSend);
                log("client <- server: " + toSend);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            closeAll(socket, input, output);
            log("연결 종료 " + socket);
        }
    }
}
  • 자원 정리시 finally 코드 블럭에서 SocketCloseUtil.closeAll()만 호출하면 된다.

 

ServerV4ServerV3와 같은 코드이다. SessionV4만 적용하면 된다.

ServerV4

package cwchoiit.network.tcp.v4;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class ServerV4 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        log("서버 시작");

        ServerSocket serverSocket = new ServerSocket(PORT);
        log("서버 소켓 시작 - 리스닝 포트: " + PORT);

        while (true) {
            Socket socket = serverSocket.accept();

            SessionV4 session = new SessionV4(socket);
            Thread thread = new Thread(session);
            thread.start();
        }
    }
}

 

실행 결과 - 클라이언트 직접 종료시 서버 로그

16:26:45.153 [     main] 서버 시작
16:26:45.171 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
16:26:58.882 [ Thread-0] client -> server: Hello
16:26:58.887 [ Thread-0] client <- server: Hello World!
16:27:11.037 [ Thread-0] java.io.EOFException
16:27:11.039 [ Thread-0] 연결 종료 Socket[addr=/127.0.0.1,port=52386,localport=12345]
  • 기존 코드의 문제는 클라이언트를 직접 종료하면, 서버의 SessionEOFException이 발생하면서 자원을 제대로 정리하지 못했다.
  • 변경한 코드에서는 서버에 접속한 클라이언트를 직접 종료해도 서버의 Session이 "연결 종료"라는 메시지를 남기면서 자원을 잘 정리하는 것을 확인할 수 있다.

이렇게 finally로 자원을 종료하는 방법을 적용해봤다. 이제는 try-with-resources로 자원을 종료해보도록 코드를 수정해보자.

 

네트워크 프로그램5 - 자원 정리2

이번에는 자원 정리에 try-with-resources를 적용해보자.

 

ClientV5

package cwchoiit.network.tcp.v5;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.Scanner;

import static cwchoiit.util.MyLogger.log;

public class ClientV5 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        log("클라이언트 시작");
        Scanner scanner = new Scanner(System.in);

        try (Socket socket = new Socket("localhost", PORT);
             DataInputStream input = new DataInputStream(socket.getInputStream());
             DataOutputStream output = new DataOutputStream(socket.getOutputStream())) {

            log("소켓 연결: " + socket);

            while (true) {
                System.out.print("서버에게 보낼 문자를 입력하세요:");
                String message = scanner.nextLine();

                output.writeUTF(message);
                log("client -> server: " + message);

                if ("exit".equalsIgnoreCase(message)) {
                    break;
                }

                // 서버로부터 문자 받기
                String received = input.readUTF();
                log("client <- server: " + received);
            }
        } catch (IOException e) {
            log(e);
        }
    }
}
  • 클라이언트에 try-with-resources를 적용했다.
  • 자원 정리시 try-with-resources에 선언되는 순서의 반대로 자원 정리가 적용되기 때문에 여기서는 output → input → socket 순으로 close()가 호출된다.
  • 참고로 OutputStream, InputStream, Socket 모두 AutoCloseable을 구현하고 있다.

SessionV5

package cwchoiit.network.tcp.v5;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class SessionV5 implements Runnable {

    private final Socket socket;

    public SessionV5(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {

        try (socket;
             DataInputStream input = new DataInputStream(socket.getInputStream());
             DataOutputStream output = new DataOutputStream(socket.getOutputStream())) {

            while (true) {
                // 클라이언트로부터 문자 받기
                String received = input.readUTF();
                log("client -> server: " + received);

                if (received.equalsIgnoreCase("exit")) {
                    break;
                }

                String toSend = received + " World!";
                output.writeUTF(toSend);
                log("client <- server: " + toSend);
            }
        } catch (IOException e) {
            log(e);
        }

        log("연결 종료: " + socket + " isClosed: " + socket.isClosed());
    }
}
  • Socket 객체의 경우 Session에서 직접 생성하는 것이 아니라, 외부에서 받아오는 객체이다. 이 경우 try 선언부에 예제와 같이 객체의 참조를 넣어두면 자원 정리 시점에 AutoCloseable이 호출된다.
  • AutoCloseable이 호출되어서 정말 소켓의 close() 메서드가 호출되었는지 확인하기 위해 마지막에 socket.isClosed()를 호출하는 코드를 넣어두었다.

 

ServerV5

package cwchoiit.network.tcp.v5;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class ServerV5 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        log("서버 시작");

        ServerSocket serverSocket = new ServerSocket(PORT);
        log("서버 소켓 시작 - 리스닝 포트: " + PORT);

        while (true) {
            Socket socket = serverSocket.accept();

            SessionV5 session = new SessionV5(socket);
            Thread thread = new Thread(session);
            thread.start();
        }
    }
}
  • 기존 코드와 같다. SessionV5를 사용하는 것으로만 바꿔주면 된다.

 

실행 결과 - 클라이언트 직접 종료 시 서버의 로그

16:48:58.824 [     main] 서버 시작
16:48:58.836 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
16:49:08.889 [ Thread-0] client -> server: Hello
16:49:08.891 [ Thread-0] client <- server: Hello World!
16:49:13.845 [ Thread-0] java.io.EOFException
16:49:13.869 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=52610,localport=12345] isClosed: true

 

네트워크 프로그램6 - 자원 정리3

아직 하나 남은게 있다. 바로 서버 코드에 있는 ServerSocket을 정리하는 것. 그래서 서버를 종료할때 서버 소켓과 연결된 모든 소켓 자원을 다 반납하고 서버를 안정적으로 종료하는 방법을 알아보자. 서버를 종료하려면 서버에 종료라는 신호를 전달해야 한다. 예를 들어, 서버도 콘솔창을 통해서 입력을 받도록 만들고 "종료"라는 메시지를 입력하면 모든 자원을 정리하면서 서버가 종료되도록 하면 된다. 하지만 보통 서버에서 콘솔 입력은 잘 하지 않으므로, 이번에는 서버를 직접 종료하면서 자원도 함께 정리하는 방법을 알아보자.

 

셧다운 훅(Shutdown Hook)

자바는 프로세스가 종료될 때, 자원 정리나 로그 기록과 같은 종료 작업을 마무리 할 수 있는 셧다운 훅이라는 기능을 지원한다. 프로세스 종료는 크게 2가지로 분류할 수 있다. 

  • 정상 종료
    • 모든 non 데몬 스레드의 실행 완료로 자바 프로세스 정상 종료
    • 사용자가 Ctrl + C를 눌러서 프로그램을 중단
    • kill 명령 전달 (kill -9 제외)
    • IntelliJ의 Stop 버튼
  • 강제 종료
    • 운영체제에서 프로세스를 더 이상 유지할 수 없다고 판단할 때 사용
    • 리눅스/유닉스의 kill -9 나 윈도우의 taskkill /F

정상 종료의 경우에는 셧다운 훅이 작동해서 프로세스 종료 전에 필요한 후처리를 할 수 있다. 반면에 강제 종료의 경우에는 셧다운 훅이 작동하지 않는다. 셧다운 훅의 사용법을 코드를 통해서 알아보고, 서버 종료 시 자원도 함께 정리해보자.

 

클라이언트 코드는 기존과 같다. 이름만 ClientV6로 복사해서 만들자.

ClientV6

package cwchoiit.network.tcp.v6;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.Scanner;

import static cwchoiit.util.MyLogger.log;

public class ClientV6 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        log("클라이언트 시작");
        Scanner scanner = new Scanner(System.in);

        try (Socket socket = new Socket("localhost", PORT);
             DataInputStream input = new DataInputStream(socket.getInputStream());
             DataOutputStream output = new DataOutputStream(socket.getOutputStream())) {

            log("소켓 연결: " + socket);

            while (true) {
                System.out.print("서버에게 보낼 문자를 입력하세요:");
                String message = scanner.nextLine();

                output.writeUTF(message);
                log("client -> server: " + message);

                if ("exit".equalsIgnoreCase(message)) {
                    break;
                }

                // 서버로부터 문자 받기
                String received = input.readUTF();
                log("client <- server: " + received);
            }
        } catch (IOException e) {
            log(e);
        }
    }
}

 

서버가 정상적으로 종료되려면, 서버가 만든 Session 객체와 그 객체 안에서 살고있는 Socket, InputStream, OutputStream을 모두 종료해야한다. 그런데 Session 객체는 클라이언트가 생성될때마다 하나씩 생성되기 때문에 그것을 관리하는 누군가가 필요하다. 그래서 여기서는 SessionManager라는 객체를 하나 새로 만들자.

SessionManagerV6

package cwchoiit.network.tcp.v6;

import java.util.ArrayList;
import java.util.List;

public class SessionManagerV6 {

    private final List<SessionV6> sessions = new ArrayList<>();

    public synchronized void add(SessionV6 session) {
        sessions.add(session);
    }

    public synchronized void remove(SessionV6 session) {
        sessions.remove(session);
    }

    public synchronized void closeAll() {
        for (SessionV6 session : sessions) {
            session.close();
        }
        sessions.clear();
    }
}

 

각 세션은 소켓과 연결 스트림을 가지고 있다. 따라서 서버를 종료할 때 사용하는 세션들도 함께 종료해야 한다. 모든 세션들을 찾아서 종료하려면 생성한 세션을 보관하고 관리할 객체가 필요하다. 

  • add() → 클라이언트의 새로운 연결을 통해, 세션이 새로 만들어지는 경우 add()를 호출해서 세션 매니저에 세션을 추가한다.
  • remove() → 클라이언트의 연결이 끊어지면 세션도 함께 정리된다. 이 경우, remove()를 호출해서 세션 매니저에서 세션을 제거한다.
  • closeAll() → 서버를 종료할 때 사용하는 세션들도 모두 닫고, 정리한다.

참고로, 클라이언트가 동시에 두명이 접속한다고 하면 세션도 동시에 두개가 만들어지고, 그 세션들을 동시에 두개를 세션매니저가 추가할 경우가 있다. 즉, 동기화 코드가 필요하다는 의미이고 그래서 각 메서드마다 synchronized 키워드를 사용했다.

 

SessionV6

package cwchoiit.network.tcp.v6;

import cwchoiit.network.tcp.SocketCloseUtil;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

import static cwchoiit.network.tcp.SocketCloseUtil.*;
import static cwchoiit.util.MyLogger.log;

public class SessionV6 implements Runnable {

    private final Socket socket;
    private final DataInputStream input;
    private final DataOutputStream output;
    private final SessionManagerV6 sessionManager;
    private boolean closed = false;

    public SessionV6(Socket socket, SessionManagerV6 sessionManager) throws IOException {
        this.socket = socket;
        this.input = new DataInputStream(socket.getInputStream());
        this.output = new DataOutputStream(socket.getOutputStream());
        this.sessionManager = sessionManager;
        this.sessionManager.add(this);
    }

    @Override
    public void run() {

        try {
            while (true) {
                // 클라이언트로부터 문자 받기
                String received = input.readUTF();
                log("client -> server: " + received);

                if (received.equalsIgnoreCase("exit")) {
                    break;
                }

                String toSend = received + " World!";
                output.writeUTF(toSend);
                log("client <- server: " + toSend);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            sessionManager.remove(this);
            close();
        }
    }


    public synchronized void close() {
        if (closed) {
            return;
        }

        closeAll(socket, input, output);
        log("연결 종료: " + socket);
        closed = true;
    }
}

아쉽지만 Session은 이제 try-with-resources를 사용할 수 없다. 왜냐하면 서버를 종료하는 시점에도 Session의 자원을 정리해야 하기 때문이다. try-with-resources는 사용과 해제를 함께 묶어서 처리할 때 사용한다. 다시 말해, try 선언부에서 사용한 자원을 try가 끝나는 시점에 정리한다. 따라서 try에서 자원의 선언과 자원 정리를 묶어서 처리할 때 사용할 수 있다. 하지만 지금은 서버를 종료하는 시점에도 Session이 사용하는 자원을 정리해야 한다. 서버를 종료하는 시점에 자원을 정리하는 것은 Session안에 있는 try-with-resources를 통해 처리할 수 없다. try구문이랑 상관없이 서버를 꺼버릴 수 있기 때문이다.

 

동시성 문제

public synchronized void close() {...}
  • Session 객체에서도 close() 메서드에는 synchronized 라는 키워드가 붙었다. 왜 그럴까? 동시에 2곳에서 호출될 수 있기 때문이다. 
    • 클라이언트와 연결이 종료되었을 때 (클라이언트가 연결을 종료해서 try 구문안에 readUTF()EOFException을 터뜨리고, finally 구문이 실행되어 자원을 정리하려 들때)
    • 서버를 종료할 때 (서버가 종료되어, SessionManager에서 closeAll()을 호출할 때)
  • 따라서, close()가 다른 스레드에서 동시에 중복 호출될 가능성이 있고 그렇기에 synchronized 키워드를 사용해서 동시성 문제를 해결하자. 이렇게 되면 이제 여러 스레드가 동시에 접근하려고 해도 딱 하나의 스레드만 점유할 수 있다. 그런데 그건 그거고 하나의 스레드가 자기 할 일을 다 하고 나오면 자원이 정리됐는데도 또 이 close()가 호출되는건 막을 수 없다. 동시에 두개의 스레드가 close() 메서드를 실행하려고 하면 하나가 끝나면 다른 하나가 실행할테니. 그래서 이미 자원을 종료했는데 또 자원을 종료하려고 시도하는 것을 방지하기 위해 closed 라는 플래그를 하나 만들어서 이 플래그가 true라면 빠져나오도록 구현했다.

ServerV6

package cwchoiit.network.tcp.v6;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class ServerV6 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        log("서버 시작");
        SessionManagerV6 sessionManager = new SessionManagerV6();

        ServerSocket serverSocket = new ServerSocket(PORT);
        log("서버 소켓 시작 - 리스닝 포트: " + PORT);

        ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
        // 셧다운 훅 등록 (이렇게 셧다운 훅을 등록하면, 자바가 프로세스를 종료하기 전에 이 셧다운 훅을 실행하고 종료한다)
        Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));

        try {
            while (true) {
                Socket socket = serverSocket.accept();

                log("소켓 연결: " + socket);

                SessionV6 session = new SessionV6(socket, sessionManager);
                Thread thread = new Thread(session);
                thread.start();
            }
        } catch (IOException e) {
            // serverSocket.close()를 호출하면, serverSocket.accept()가 예외를 터트린다.
            log("서버 소켓 종료: " + e);
        }
    }

    static class ShutdownHook implements Runnable {

        private final ServerSocket serverSocket;
        private final SessionManagerV6 sessionManager;

        public ShutdownHook(ServerSocket serverSocket, SessionManagerV6 sessionManager) {
            this.serverSocket = serverSocket;
            this.sessionManager = sessionManager;
        }

        @Override
        public void run() {
            log("shutdownHook 실행");
            try {
                sessionManager.closeAll();
                serverSocket.close();

                Thread.sleep(1000); // 자원 정리 대기
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println("e = " + e);
            }
        }
    }
}

 

셧다운 훅 등록

ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
// 셧다운 훅 등록 (이렇게 셧다운 훅을 등록하면, 자바가 프로세스를 종료하기 전에 이 셧다운 훅을 실행하고 종료한다)
Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));
  • Runtime.getRuntime().addShutdownHook()을 사용하면 자바 종료시 호출되는 셧다운 훅을 등록할 수 있다. 
  • 여기에 셧다운이 발생했을 때 처리할 작업과 스레드를 등록하면 된다.

셧다운 훅 실행 코드

static class ShutdownHook implements Runnable {

        private final ServerSocket serverSocket;
        private final SessionManagerV6 sessionManager;

        public ShutdownHook(ServerSocket serverSocket, SessionManagerV6 sessionManager) {
            this.serverSocket = serverSocket;
            this.sessionManager = sessionManager;
        }

        @Override
        public void run() {
            log("shutdownHook 실행");
            try {
                sessionManager.closeAll();
                serverSocket.close();

                Thread.sleep(1000); // 자원 정리 대기
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println("e = " + e);
            }
        }
    }

  • 셧다운 훅이 실행될 때 모든 자원을 정리한다.
  • sessionManager.closeAll() → 모든 세션이 사용하는 자원(Socket, InputStream, OutputStream)을 정리한다.
  • serverSocket.close() → 서버 소켓을 닫는다.

자원 정리 대기 이유

Thread.sleep(1000); // 자원 정리 대기
  • 보통 모든 non 데몬 스레드의 실행이 완료되면 자바 프로세스가 정상 종료된다. 하지만, 다음과 같은 종료도 있다.
    • 사용자가 Ctrl + C를 눌러서 프로그램을 중단
    • kill 명령 전달 (kill -9 제외)
    • IntelliJ의 Stop 버튼

이런 경우에는 non 데몬 스레드의 종료 여부와 관계없이 자바 프로세스가 종료된다. 단, 셧다운 훅의 실행이 끝날 때까지는 기다려준다. 셧다운 훅의 실행이 끝나면 non 데몬 스레드의 실행 여부와 상관없이 자바 프로세스는 종료된다. 따라서, 다른 스레드가 자원을 정리하거나 필요한 로그를 남길 수 있도록 셧다운 훅의 실행을 잠시 대기한다.

 

실행 결과 - 서버 종료 결과

(참고로, 셧다운 훅이 실행되지 않는 경우엔, Build and Run 옵션이 Gradle로 되어 있지는 않은지 확인해보자!, IntelliJ로 자바를 실행해야한다. IntelliJ에서 Stop 버튼을 누를꺼니까.)

18:39:22.337 [     main] 서버 시작
18:39:22.345 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
18:39:38.966 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=53510,localport=12345]
18:39:50.596 [ shutdown] shutdownHook 실행
18:39:50.599 [ shutdown] 연결 종료: Socket[addr=/127.0.0.1,port=53510,localport=12345]
18:39:50.599 [ Thread-0] java.net.SocketException: Socket closed
18:39:50.599 [     main] 서버 소켓 종료: java.net.SocketException: Socket closed

서버를 종료하면, shutdown 스레드가 shutdownHook을 실행하고, 세션의 Socket의 연결을 close()로 닫는다. 

  • [Thread-0] Socket closed
  • Sessioninput.readUTF()에서 입력을 대기하는 Thread-0 스레드는, SocketException 예외를 받고 종료된다. 참고로 이 예외는 자신의 소켓을 닫았을 때 발생한다.

shutdown 스레드는 서버 소켓을 close()로 닫는다.

  • [main] 서버 소켓 종료
  • serverSocket.accept()에서 대기하고 있던 main 스레드는 SocketException 예외를 받고 종료된다. 

 

정리

드디어, 자원 정리까지 깔끔하게 해결한 서버 프로그램이 완성됐다.

 

네트워크 예외1 - 연결 예외

네트워크 연결시 발생할 수 있는 예외들을 정리해보자.

ConnectMain

package cwchoiit.network.exception.connect;

import java.io.IOException;
import java.net.ConnectException;
import java.net.Socket;
import java.net.UnknownHostException;

public class ConnectMain {
    public static void main(String[] args) throws IOException {
        unknownHostEx1();
        unknownHostEx2();
        connectRefused();
    }

    private static void unknownHostEx1() throws IOException {
        try {
            Socket socket = new Socket("999.999.999.999", 80);
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
    }

    private static void unknownHostEx2() throws IOException {
        try {
            Socket socket = new Socket("google.gogo", 80);
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
    }

    private static void connectRefused() throws IOException {
        try {
            Socket socket = new Socket("localhost", 45678);
        } catch (ConnectException e) {
            e.printStackTrace();
        }
    }
}

실행 결과

  java.net.UnknownHostException: 999.999.999.999
  ...
  java.net.UnknownHostException: google.gogo
  ...
  java.net.ConnectException: Connection refused
  ...

 

java.net.UnknownHostException

  • 호스트를 알 수 없음
  • 999.999.999.999 → 이런 IP는 존재하지 않는다.
  • google.gogo → 이런 도메인은 존재하지 않는다.

 

java.net.ConnectException

  • Connection refused 메시지는 연결이 거절됐다는 뜻이다.
  • 연결이 거절됐다는 뜻은, 우선은 네트워크를 통해 해당 IP의 서버 컴퓨터에 접속은 했다는 뜻이다.
  • 그런데 해당 서버 컴퓨터가 45678 포트를 사용하지 않기 때문에 TCP 연결을 거절한다.
  • IP에 해당하는 서버는 켜져있지만, 사용하는 PORT가 없을 때 주로 발생한다.
  • 네트워크 방화벽 등에서 무단 연결로 인지하고 연결을 막을 때도 발생한다.
  • 서버 컴퓨터의 OS는 이때 TCP RST(Reset)라는 패킷을 보내서 연결을 거절한다.
  • 클라이언트가 연결 시도 중에 RST 패킷을 받으면 이 예외가 발생한다.

TCP RST(Reset) 패킷

TCP 연결에 문제가 있다는 뜻이다. 이 패킷을 받으면 받은 대상은 바로 연결을 해제해야 한다.

 

네트워크 예외2 - 타임아웃

네트워크 연결을 시도해서 서버 IP에 연결 패킷을 전달했지만, 응답이 없는 경우 어떻게 될까?

 

TCP 연결 타임아웃 - OS 기본

package cwchoiit.network.exception.connect;

import java.io.IOException;
import java.net.ConnectException;
import java.net.Socket;

public class ConnectTimeoutMain1 {
    public static void main(String[] args) throws IOException {
        long startTime = System.currentTimeMillis();

        try {
            Socket socket = new Socket("192.168.1.250", 45678);
        } catch (ConnectException e) {
            e.printStackTrace();
        }

        long endTime = System.currentTimeMillis();
        System.out.println("end = " + (endTime - startTime) + " ms");
    }
}
  • 사설 IP 대역(주로 공유기에서 사용하는 IP 대역)의 192.168.1.250을 사용했다. 혹시 해당 IP로 무언가 연결되어 있다면 다른 결과가 나올 수 있다. 이 경우 마지막 3자리를 변경해보자.
  • 해당 IP로 연결 패킷을 보내지만, IP를 사용하는 서버가 없으므로 TCP 응답이 오지 않는다.
  • 또는 해당 IP로 연결 패킷을 보내지만 해당 서버가 너무 바쁘거나 문제가 있어서 연결 응답 패킷을 보내지 못하는 경우도 있다. 그렇다면 무한정 기다려야 할까?

OS 기본 대기 시간

TCP 연결을 시도했는데 연결 응답이 없다면, OS에는 연결 대기 타임아웃이 설정되어 있다.

  • Windows: 약 21초
  • Linux: 약 75초에서 180초 사이
  • Mac: 75초

해당 시간이 지나면 java.net.ConnectException: Operation timed out이 발생한다.

실행 결과

  java.net.ConnectException: Operation timed out
      at java.base/sun.nio.ch.Net.connect0(Native Method)
      at java.base/sun.nio.ch.Net.connect(Net.java:589)
      at java.base/sun.nio.ch.Net.connect(Net.java:578)
      at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:583)
      at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
      at java.base/java.net.Socket.connect(Socket.java:752)
      at java.base/java.net.Socket.connect(Socket.java:687)
      at java.base/java.net.Socket.<init>(Socket.java:556)
      at java.base/java.net.Socket.<init>(Socket.java:325)
      at
  network.exception.connect.ConnectTimeoutMain.connectionWait1(ConnectTimeoutMai
  n.java:22)
      at
  network.exception.connect.ConnectTimeoutMain.main(ConnectTimeoutMain.java:12)
  end = 75008

TCP 연결을 클라이언트가 이렇게 오래 대기하는 것은 좋은 방법이 아니다.

연결이 안되면, 고객에게 빠르게 현재 연결에 문제가 있다고 알려주는 것이 더 나은 방법이다.

 

 

TCP 연결 타임아웃 - 직접 설정

TCP 연결 타임아웃 시간을 직접 설정해보자.

package cwchoiit.network.exception.connect;

import java.io.IOException;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;

public class ConnectTimeoutMain2 {
    public static void main(String[] args) throws IOException {
        long startTime = System.currentTimeMillis();

        try {
            Socket socket = new Socket(); // 객체만 생성하고 연결은 아직 안한 상태
            socket.connect(new InetSocketAddress("192.168.1.250", 45678), 1000);
        } catch (SocketTimeoutException e) {
            e.printStackTrace();
        }

        long endTime = System.currentTimeMillis();
        System.out.println("end = " + (endTime - startTime) + " ms");
    }
}
  • new Socket()
    • Socket 객체를 생성할 때 인자로 IP, PORT를 모두 전달하면, 생성자에서 바로 TCP 연결을 시도한다. 하지만, IP, PORT를 모두 빼고 객체를 생성하면 객체만 생성되고 아직 연결은 시도하지 않는다. 추가적으로 필요한 설정을 더 한 다음에 socket.connect()를 호출하면 그때 TCP 연결을 시도한다. 이 방식을 사용하면 추가적인 설정을 더 할 수 있는데, 대표적으로 타임아웃을 설정할 수 있다. 
  public void connect(SocketAddress endpoint, int timeout) throws IOException {...}
  • InetSocketAddress: SocketAddress의 자식이다. IP, PORT 기반의 주소를 객체로 제공한다.
  • timeout: 밀리초 단위로 연결 타임아웃을 지정할 수 있다.

타임아웃 시간이 지나도 연결이 되지 않으면 다음 예외가 발생한다. 

java.net.SocketTimeoutException: Connect timed out

 

실행 결과

  java.net.SocketTimeoutException: Connect timed out
      at java.base/
  sun.nio.ch.NioSocketImpl.timedFinishConnect(NioSocketImpl.java:546)
      at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:592)
      at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
      at java.base/java.net.Socket.connect(Socket.java:752)
      at
  network.exception.connect.ConnectTimeoutMain2.main(ConnectTimeoutMain2.java:13
  )

실행해보면 설정한 시간인 1초가 지난 후 타임아웃 예외가 발생하는 것을 볼 수 있다.

 

TCP 소켓 타임아웃 - read 타임아웃

타임아웃 중에 또 하나 중요한 타임아웃이 있다. 바로 소켓 타임아웃 또는 read 타임 아웃이라고 부르는 타임아웃이다.

앞에서 설명한 연결 타임아웃은 TCP 연결과 관련이 있다. 연결이 잘 된 이후에 클라이언트가 서버에 어떤 요청을 했다고 가정하자. 그런데 서버가 계속 응답을 주지 않는다면, 무한정 기다려야 하는 것일까? 서버에 사용자가 폭주하고 매우 느려져서 응답을 계속 주지 못하는 상황이라면 어떻게 해야할까? 이런 경우에 사용하는 것이 바로 소켓 타임아웃(read 타임아웃)이다.

 

SoTimeoutServer

package cwchoiit.network.exception.connect;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class SoTimeoutServer {
    public static void main(String[] args) throws InterruptedException, IOException {
        ServerSocket serverSocket = new ServerSocket(12345);
        Socket socket = serverSocket.accept();

        Thread.sleep(100000000);
    }
}
  • 서버는 소켓을 연결은 하지만, 아무런 응답을 주지 않는다.

SoTimeoutClient

package cwchoiit.network.exception.connect;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

public class SoTimeoutClient {
    public static void main(String[] args) throws IOException {

        Socket socket = new Socket("localhost", 12345);
        InputStream inputStream = socket.getInputStream();

        try {
            socket.setSoTimeout(3000);
            int read = inputStream.read();
            System.out.println("read: " + read);
        } catch (Exception e) {
            e.printStackTrace();
        }
        socket.close();
    }
}
  • socket.setSoTimeout()을 사용하면 밀리초 단위로 타임아웃 시간을 설정할 수 있다. 여기서는 3초를 설정했다.

3초가 지나면 다음 예외가 발생한다.

java.net.SocketTimeoutException: Read timed out

 

타임아웃 시간을 설정하지 않으면 read()는 응답이 올 때까지 무한정 대기한다.

 

클라이언트 실행 결과

  java.net.SocketTimeoutException: Read timed out
      at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:278)
      at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:304)
      at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:346)
      at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:796)
      at java.base/java.net.Socket$SocketInputStream.implRead(Socket.java:1108)
      at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1095)
      at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1089)
      at network.exception.connect.SoTimeoutClient.main(SoTimeoutClient.java:15)

 

 

왜 이 타임아웃이 중요할까? 정말 정말 중요하다. 근데 왜 그럴까?

 

실무 이야기

실무에서 자주 발생하는 장애 원인 중 하나가 바로 연결 타임아웃, 소켓 타임아웃(read 타임아웃)을 누락하기 때문에 발생한다. 서버도 외부에 존재하는 데이터를 네트워크를 통해 불러와야 하는 경우가 있다. 예를 들어서 주문을 처리하는 서버가 있는데, 주문 서버는 외부에 있는 서버를 통해 고객의 신용카드 결제를 처리해야 한다고 가정해보자. 

 

신용카드를 처리하는 회사가 3개가 있다고 가정하자.

  • 고객 → 주문 서버 → 신용카드A 회사 서버(정상)
  • 고객 → 주문 서버 → 신용카드B 회사 서버(정상)
  • 고객 → 주문 서버 → 신용카드C 회사 서버(문제)

신용카드 A, 신용카드 B, 서버는 문제가 없고, 신용카드 C 회사 서버에 문제가 발생해서 응답을 주지 못하는 상황이라고 가정해보자. 주문 서버는 계속 신용카드 C 회사 서버의 응답을 기다리게 된다. 여기서 문제는 신용카드 C의 결제에 대해서 주문 서버도 고객에게 응답을 주지 못하고 계속 대기하게 된다. 신용카드 C로 주문하는 고객이 누적될수록 주문 서버의 요청은 계속 쌓이게 되고, 신용카드 C 회사 서버의 응답을 기다리는 스레드도 점점 늘어난다. 결국 주문 서버에 너무 많은 사용자가 접속하게 되면서 주문 서버에 장애가 발생하게 된다. 결과적으로 신용카드 C 때문에 신용카드A, B를 사용하는 고객까지 모두 주문을 할 수 없는 사태가 벌어진다. 

 

이런 장애는 신용카드 C 회사의 문제일까? 아니면 주문 서버 개발자의 문제일까?

만약, 주문 서버에 연결, 소켓 타임아웃을 적절히 설정했다면, 신용카드 C 회사 서버가 연결이 오래 걸리거나 응답을 주지 않을 때 타임아웃으로 처리할 수 있다. 이렇게 되면 요청이 쌓이지 않기 때문에, 주문 서버에 장애가 발생하지 않는다. 타임아웃이 발생하는 신용카드 C 사용자에게는 현재 문제가 있다는 안내를 하면 된다. 나머지 신용카드 A, B는 정상적으로 작동한다.

 

결론은 외부 서버와 통신을 하는 경우, 반드시 연결 타임아웃과 소켓 타임아웃을 지정하자.

 

네트워크 예외3 - 정상 종료

TCP에는 2가지 종류의 종료가 있다.

  • 정상 종료
  • 강제 종료

정상 종료

TCP에서 A, B가 서로 통신한다고 가정해보자. TCP 연결을 종료하려면 서로 FIN 메시지를 보내야 한다.

  • A → (FIN) → B: A가 B로 FIN 메시지를 보낸다.
  • A ← (FIN) ← B: FIN 메시지를 받은 B도 A에게 FIN 메시지를 보낸다.

socket.close()를 호출하면, TCP에서 종료의 의미인 FIN 패킷을 상대방에게 전달한다. FIN 패킷을 받으면 상대방도 socket.close()를 호출해서 FIN 패킷을 상대방에게 전달해야 한다.

 

  • 클라이언트와 서버가 연결되어 있다.
  • 서버가 연결 종료를 위해 socket.close()를 호출한다. 서버는 클라이언트에 FIN 패킷을 전달한다.
  • 클라이언트는 FIN 패킷을 받는다. 
    • 클라이언트의 OS에서 FIN에 대한 ACK 패킷을 전달한다.
  • 클라이언트도 종료를 위해 socket.close()를 호출한다.
    • 클라이언트는 서버에 FIN 패킷을 전달한다.
    • 서버의 OS는 FIN 패킷에 대한 ACK 패킷을 전달한다.

 

예제를 통해 서버와 클라이언트 간 정상 종료에 대해 알아보자.

Server

package cwchoiit.network.exception.close;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class NormalCloseServer {
    public static void main(String[] args) throws IOException, InterruptedException {
        ServerSocket serverSocket = new ServerSocket(12345);
        Socket socket = serverSocket.accept();
        log("소켓 연결:" + socket);

        Thread.sleep(1000);
        socket.close();
        log("소켓 종료");
    }
}
  • 서버는 소켓이 연결되면 1초 뒤에 연결을 종료한다.
  • 서버에서 socket.close()를 호출하면 클라이언트에 FIN 패킷을 보낸다.

Client

package cwchoiit.network.exception.close;

import java.io.*;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class NormalCloseClient {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 12345);
        log("소켓 연결: " + socket);

        InputStream input = socket.getInputStream();

        readByInputStream(input, socket);
        readByBufferedReader(input, socket);
        readByDataInputStream(input, socket);

        log("연결 종료" + socket.isClosed());
    }

    private static void readByDataInputStream(InputStream input, Socket socket) throws IOException {
        DataInputStream dis = new DataInputStream(input);

        try {
            dis.readUTF();
        } catch (EOFException e) {
            log("EOFException: " + e.getMessage());
        } finally {
            dis.close();
            socket.close();
        }
    }

    private static void readByBufferedReader(InputStream input, Socket socket) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(input));
        String readString = br.readLine();
        log("readString: " + readString);
        if (readString == null) {
            br.close();
            socket.close();
        }
    }

    private static void readByInputStream(InputStream input, Socket socket) throws IOException {
        int read = input.read();
        log("read = " + read);
        if (read == -1) {
            input.close();
            socket.close();
        }
    }
}
  • 클라이언트는 서버의 메시지를 3가지 방법으로 읽는다.
    • read(): 1byte 단위로 읽음
    • readLine(): 라인 단위로 String으로 읽음
    • readUTF(): DataInputStream을 통해 String 단위로 읽음

실행 결과

15:10:29.677 [     main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=60642]
15:10:30.716 [     main] read = -1
15:10:30.718 [     main] readString: null
15:10:30.718 [     main] EOFException: null
15:10:30.718 [     main] 연결 종료true

 

전체 과정

  • 클라이언트가 서버에 접속한다.
  • 클라이언트는 input.read()로 서버의 데이터를 읽기 위해 대기한다.
  • 그런데 1초 뒤에 서버에서 연결을 종료한다.
    • 서버에서 socket.close()를 호출하면 클라이언트에 FIN 패킷을 보낸다.
  • 클라이언트는 FIN 패킷을 받는다.
  • 서버가 소켓을 종료했다는 의미이므로 클라이언트는 더는 읽을 데이터가 없다.
  • FIN 패킷을 받은 클라이언트의 소켓은 더는 서버를 통해 읽을 데이터가 없다는 의미로 -1(EOF)를 반환한다.

여기서 각각의 상황에 따라 EOF를 해석하는 방법이 다르다.

  • read() → -1
  • BufferedReader().readLine()null
    • BufferedReader()는 문자 String을 반환한다. 따라서 -1을 표현할 수 없다. 대신에 null을 반환
  • DataInputStream.readUTF()EOFException
    • DataInputStream은 이 경우 EOFException을 던진다. 

여기서 중요한 점은 EOF가 발생하면 상대방이 FIN 메시지를 보내면서 소켓 연결을 끊었다는 뜻이다. 이 경우 소켓에 다른 작업을 하면 안되고, FIN 메시지를 받은 클라이언트도 close()를 호출해서 상대방에 FIN 메시지를 보내고 소켓 연결을 끊어야 한다. 이렇게 하면 서로 FIN 메시지를 주고 받으면서 TCP 연결이 정상 종료된다.

 

 

네트워크 예외4 - 강제 종료

강제 종료

TCP 연결 중에 문제가 발생하면 RST 라는 패킷이 발생한다. 이 경우 연결을 즉시 종료해야 한다.

예제를 통해 알아보자.

 

Server

package cwchoiit.network.exception.close.reset;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

import static cwchoiit.util.MyLogger.log;

public class ResetCloseServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(12345);
        Socket socket = serverSocket.accept();
        log("소켓 연결: " + socket);

        socket.close();
        serverSocket.close();
        log("소켓 종료");
    }
}
  • 서버는 소켓이 연결되면 단순히 연결을 종료한다.

Client

package cwchoiit.network.exception.close.reset;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.SocketException;

import static cwchoiit.util.MyLogger.log;

public class ResetCloseClient {
    public static void main(String[] args) throws IOException, InterruptedException {
        Socket socket = new Socket("localhost", 12345);
        log("소켓 연결: " + socket);

        InputStream input = socket.getInputStream();
        OutputStream output = socket.getOutputStream();

        // client <- server: FIN
        Thread.sleep(1000); // 서버가 close() 호출할때까지 잠시 대기

        // client -> server: PUSH[1]
        output.write(1);

        // client <- server: RST
        Thread.sleep(1000); // RST 메시지 대기
        try {
            int read = input.read();
            System.out.println("read = " + read);
        } catch (SocketException e) {
            e.printStackTrace();
        }

        try {
            output.write(1);
        } catch (SocketException e) {
            e.printStackTrace();
        }
    }
}

 

실행 결과

17:01:12.870 [     main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=61714]
java.net.SocketException: Connection reset
	...
java.net.SocketException: Broken pipe
	...

  • 클라이언트와 서버가 연결되어 있다.
  • 서버는 종료를 위해 socket.close()를 호출한다.
    • 서버는 클라이언트에 FIN 패킷을 전달한다.
  • 클라이언트는 FIN 패킷을 받는다.
    • 클라이언트의 OS에서 FIN에 대한 ACK 패킷을 전달한다.
  • 클라이언트는 output.write(1)을 통해, 서버에 메시지를 전달한다.
    • 데이터를 전송하는 PUSH 패킷이 서버에 전달된다.
  • 서버는 이미 FIN으로 종료를 요청했는데, PUSH 패킷으로 데이터가 전송되었다.
    • 서버가 기대하는 값은 FIN 패킷이다.
  • 서버는 TCP 연결에 문제가 있다고 판단하고 즉각 연결을 종료하는 RST 패킷을 클라이언트에 전송한다.

RST 패킷이 도착했다는 것은 현재 TCP 연결에 심각한 문제가 있으므로 해당 연결을 더는 사용하면 안된다는 의미이다.

 

RST 패킷이 도착하면 자바는 read()로 메시지를 읽을 때 다음 예외를 던진다.

java.net.SocketException: Connection reset

 

RST 패킷이 도착하면 자바는 write()로 메시지를 전송할 때 다음 예외를 던진다.

java.net.SocketException: Broken pipe

 

참고 - RST(Reset)

  • TCP에서 RST 패킷은 연결 상태를 초기화(리셋)해서 더 이상 현재의 연결을 유지하지 않겠다는 의미를 전달한다.
  • 여기서 Reset은 현재의 세션을 강제로 종료하고, 연결을 무효화하라는 뜻이다.
  • RST 패킷은 TCP 연결에 문제가 있는 다양한 상황에 발생한다. 예를 들어, 다음과 같은 경우들이 있다.
    • TCP 스펙에 맞지 않는 순서로 메시지가 전달될 때
    • TCP 버퍼에 있는 데이터를 아직 다 읽지 않았는데, 연결을 종료할 때
    • 방화벽 같은 곳에서 연결을 강제로 종료할 때도 발생한다.

참고 - java.net.SocketException: Socket is closed

  • 자기 자신의 소켓을 닫은 이후에 read(), write()를 호출할 때 발생한다.

 

정리

  • 상대방이 연결을 종료한 경우, 데이터를 읽으면 EOF가 발생한다.
    • -1, null, EOFException등이 발생한다.
    • 이 경우 연결을 끊어야 한다.
  • java.net.SocketException: Connection reset
    • RST 패킷을 받은 이후에 read() 호출
  • java.net.SocketException: Broken pipe
    • RST 패킷을 받은 이후에 write() 호출
  • java.net.SocketException: Socket is closed
    • 자신이 소켓을 닫은 이후에 read(), write() 호출

 

네트워크 종료와 예외 정리

네트워크에서 이런 예외들을 다 따로따로 이해하고 다루어야 할까? 사실 어떤 문제가 언제 발생할지 자세하게 다 구분해서 처리하기는 어렵다. 따라서, 기본적으로 정상 종료, 강제 종료 모두 자원을 정리하고 닫도록 설계하면 된다. 예를 들어서, SocketException, EOFException 모두 IOException의 자식이다. 따라서 IOException이 발생하면 자원을 정리하면 된다. 만약, 더 자세히 분류해야 하는 경우가 발생하면 그때 예외를 구분해서 처리하면 된다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

ServerSocket으로 HTTP 서버 만들기  (2) 2024.10.18
Socket을 이용한 채팅 프로그램 만들기  (0) 2024.10.18
네트워크 1 (Socket)  (2) 2024.10.15
File, Files  (0) 2024.10.14
IO 활용  (2) 2024.10.13

+ Recent posts