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 애노테이션이 붙은 필드를 감지합니다. 그리고 그 필드에 대한 Getter인 getName() 메서드의 노드를 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`라고 하고 새로 만들었습니다. 그리고 다음과 같이 애노테이션을 하나 생성했습니다.
왜 RetentionPolicy가 SOURCE일까요? 이 애노테이션이 런타임이나 바이트코드에 필요할까요? 아닙니다. 컴파일 시에 해당 애노테이션을 찾아 후처리를 하고 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;
}
}
이 애노테이션 프로세서가 지원하는 애노테이션이 어떤 애노테이션인지를 알려주는 @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`인데요. 한번 사용해보겠습니다. 다음과 같이 의존성을 추가해 줍니다.
그리고, 아까 만든 애노테이션 프로세서에 가서, 이런 애노테이션 `@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 메서드를 만듭니다. MethodSpec은 javapoet에서 제공해주는 메서드를 동적으로 만들어주는 녀석입니다. 이렇게 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()로 아까 위에서 만든 메서드를 추가해주면 됩니다.
여기까지만하면 실제로 만든건 아니고 메모리에 클래스와 메서드를 만들어 낸것까지 한거예요. 우리는 메모리에만 둥둥 떠있는게 아니라 실제 클래스로 만들어야겠죠?
그래서, 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 메서드를 추가하는건 어떨까요?! 재밌을 것 같지 않나요?
이번 포스팅에서는 자바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을 수행한다.
비동기 프로그래밍이란 건 Task의 call() 메서드를 실행하는 것을 기다리지 않고 내가 하고자하는 작업을 진행하는 걸 말한다.
근데 여기서 불편한 점이 있다. 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());
}
}
어떤 작업을 비동기적으로 실행만 하면 될때, CompletableFuture의 runAsync()를 사용할 수 있다.
이렇게 작성하면, CompletableFuture의 작업과는 아무런 영향없이 그 이후의 코드를 실행할 수 있다.
그리고 이게 다 이전 포스팅을 배운 이유인게 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에서 사용하는 스레드풀을 지정할 수가 있다. 아래 코드를 보자.
supplyAsync()에는 두번째 파라미터로 Executor를 넘길 수가 있다. 그래서 내가 만든 ExecutorService를 넘겨주면 이 스레드 풀을 사용한다. 아 물론, supplyAsync()뿐 아니라 runAsync()도 이렇게 사용할 수 있다.
CompletableFuture의 사용 2 - 조합
thenCompose()
이번에는 조금 더 깊게 들어가서, Future만을 사용했을 때 또 어떤 점이 불편했냐면, A라는 Future 하나와, B라는 Future 하나가 있을 때 이 두개를 이어서 하려면 A를 get()하고, B를 get()해서 이 두개의 결과를 가지고 이후 코드를 작성해야 했다.
근데, 이제 CompletableFuture를 사용하면, 어떻게 편리하게 사용할 수 있을까?
CompleteableFuture에는 thenCompose()라는 게 있다. 이 녀석을 사용하면 어떤 Future의 결과를 받아서 새로운 Future를 이어갈 수 있다.
thenCompose()는 파라미터로 Function 함수형 인터페이스를 받는다. 즉, 어떤 값을 받아 어떤 값으로 변환해준다는 의미가 된다. 그리고 반환타입은 CompletableFuture<T>이다. 새로운 Future를 반환한다는 의미이다.
그래서, Future의 결과값을 전달해주고 새로운 Future를 만들어낸다.
그래서 thenCompose()를 사용하면 CompletableFuture를 이어서 실행할 수 있다.
thenCombine()
그런데 위의 케이스의 경우, 두 Future가 서로 연관관계가 있어서 어떤게 먼저 실행하고 그 다음걸 실행할 때 유용하게 사용할 수 있다. 코드도 보면 "Hello World"를 찍기 위해 "Hello"를 반환하는 Future를 먼저 실행하고 그 다음 "World"를 반환하는 Future를 실행한 것처럼 Future끼리 연관관계가 있을 때 Future끼리 이어 실행할 수 있게 하는게 thenCompose()였다면, 아무런 연관관계가 없지만 동시에 실행시키고 싶을 때도 있을 것이다.
이럴때 사용하는게 thenCombine()이다. 아무 연관관계는 없지만 동시에 실행시키고 그 두 Future의 결과값으로 새로운 것을 만들어낼 때 유용하다. 그래서 지금 hello, world 라는 두 CompletableFuture가 있을 때 얘네가 누가 먼저 실행되어야 하고 그런건 아니지만 이 두개의 결과를 가지고 무언가를 만들어낼때 사용하기 딱 좋은게 thenCombine()이다.
thenCombine()은 첫번째 인자로, CompletionStage를 받는다. 이건 CompletableFuture가 구현한 인터페이스라서 CompletableFuture가 들어갈 수 있다. 그리고 두번째 인자로 BiFunction을 받는다. 정말 딱 들어맞는 함수형 인터페이스 아닌가?
실행 결과
allOf()
이번에는 CompletableFuture가 2개 이상일때, 그 모든 CompletableFuture를 한번에 다 처리하고 어떤 작업을 진행할 수 있는 방법을 소개한다.
allOf()는 CompletableFuture의 배열을 받는다. 그래서 넘겨받은 모든 CompletableFuture의 모든 작업이 끝나면, thenApply()가 호출되는데 여기서 각 Future의 get()을 호출하면 되지만 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가 등장하고 이 녀석을 사용하면 작업이 다 끝난 후 콜백함수를 정의하여 매우 편하게 비동기 프로그래밍을 할 수 있게 됐다. 그에 대한 내용을 살펴보았다.
자바 프로그래밍을 하다가 왜 이 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을 생각하게 된다. 즉, 명시적으로 "이 값은 빈 값일수도 있어! 그러니까 체크해야해!" 이 말을 해주는 단어가 된다.
그래서 사용자는, 이 값이 있다면 어떤 처리를 하고, 없다면 어떤 처리를 할지 분기할 수가 있다.
그러니까, 명백하게 이 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);
이렇게 Optional로 primitive 타입을 받을 순 있다. 근데, 곰곰히 생각해보자. primitive 타입에 null이란게 존재하는가? 아니다. 그런건 없다. 근데 primitive 타입을 Optional로 만든다? 이건 뭔가 매우 어색하기도 하면서 이걸 처리하기 위해 Boxing을 한다. 즉, 이 반환값은 이렇다.
Optional<Integer> i = Optional.of(10);
그렇다. 타입이 Optional<Integer>로 변경된다. Primitive → Wrapper 클래스가 된다는 말이다. 이 과정에서 역시 리소스도 낭비되고 좋지 않다.
그래서, 이런게 있다.
OptionalInt optionalInt = OptionalInt.of(10);
OptionalInt가 있으면, OptionalDouble도 OptionalLong도 있다.
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로 감싸나? 그럴 이유가 없는데. Map은 get()을 통해 값이 있으면 가져오고 값이 없으면 null이다. 여기서 이미 판단을 할 수 있다. Collection, Optional도 마찬가지다. 이미 이 자체로도 빈 값인지 아닌지를 판단할수가 있는데 왜 Optional로 또 감싸는건가? 이러면 안된다.
정리
Optional을 제대로만 사용한다면, NullPointerException을 마주하는 경우를 굉장히 획기적으로 줄일수도 있고, 코드 자체의 가시성도 더 높일 수 있다. 개인적으로 좋아하는 방식이기도 하다. 그런데 이 Optional을 사용하는데 있어서 클라이언트 입장에서 매우 짜증나는 경우가 몇가지 있는데 그 경우를 여기에 정리했으니 이렇게 작성하는 것을 피하자!
스트림으로 변환한 후, 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>이기 때문에 더할 나위없이 완벽하다. 그래서 이렇게 작성할 수 있다.
앞으로, 역(Not)과 메서드 레퍼런스를 같이 사용하고 싶을때 이 Predicate<T>을 적극 활용하자!
flatMap
이 flatMap은 map의 추가 기능이라고 보면 된다. 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을 사용해서 이 리스트를 풀어 헤치는 것이다. 그래서 그 하나의 요소인 리스트 전체를 다 돌고, 그 다음 하나의 요소인 리스트 전체를 다 돌 수 있게 말이다.
추상 메서드를 딱 하나만 가지고 있는 인터페이스를 말한다. (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
위 패키지에서 여러가지 함수형 인터페이스가 있는데 대표적인 것들을 살펴보자.
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));
}
}
이렇게 compose로 Function과 Function을 조합하는 게 가능하다.
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>
이 BiFunction은 Function이랑 똑같은데, 인자값을 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를 사용해서 좀 더 간결하게 작성할 수가 있다.
그리고 이 UnaryOperator는 Function을 상속받는다. 그래서 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>를 배웠기 때문에 이게 뭔지 추측이 가능하다. "아, 두 개의 인자를 받아서 아무것도 반환은 안하고 T와 U를 가지고 뭔가를 하겠구나?" 맞다.
변수 캡처
이 부분은 꽤나 중요하다. 자세히 들여다보자. 함수형 인터페이스와 람다 표현식을 사용할때 주의할 점이 있다. 바로 이 변수 캡처에 대한 내용인데 다음 코드를 보자.
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.out의 println()을 호출하는 것 뿐이고 그런 경우에는 이렇게 메서드 레퍼런스로 매우 간결하게 출력할 수 있다.
당연히 출력하는 값은 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부터는 인터페이스에 기본 메서드를 만들 수 있도록 했다. 바로 이렇게 말이다.
예를 들어, 기본 메서드를 하나 만들었는데 파라미터로 이름을 받는다. 그리고 그 이름을 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를 상속받는 HelloExtends가 printHi를 다시 추상 메서드로 변경했다.
이제 이 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 이전에는 인터페이스가 이렇게 있었다면,
이전 포스팅에서 리플렉션을 활용해서 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이라는 애노테이션 하나를 만든다. 내부에는 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() 같은 코드를 호출해도 프로그램에는 아무런 영향을 주지 않는다. 마치 주석과 비슷하다고 이해하면 된다. 다만, 일반적인 주석이 아니라, 리플렉션 같은 기술로 실행 시점에 읽어서 활용할 수 있는 특별한 주석이다.
리플렉션이 제공하는 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 {
}
모든 애노테이션은 기본적으로, Annotation 인터페이스를 확장하며, 이로 인해 자바에서 애노테이션은 특별한 형태의 인터페이스로 간주된다. 하지만 자바에서 애노테이션을 정의할 때, 개발자가 명시적으로 Annotation 인터페이스를 상속하거나 구현할 필요는 없다. 애노테이션을 @interface 키워드를 통해 정의하면, 자바 컴파일러가 자동으로 Annotation 인터페이스를 확장하도록 처리해준다.
애노테이션 정의
public @interface MyCustomAnnotation {}
자바가 자동으로 처리
public interface MyCustomAnnotation extends java.lang.annotation.Annotation {}
애노테이션과 상속
애노테이션은 다른 애노테이션이나 인터페이스를 직접 상속할 수 없다.
오직 java.lang.annotation.Annotation 인터페이스만 상속한다.
따라서, 애노테이션 사이에는 상속이라는 개념이 존재하지 않는다.
@Inherited
애노테이션을 정의할 때 @Inherited 메타 애노테이션을 붙이면, 애노테이션을 적용한 클래스의 자식 클래스도 해당 애노테이션을 부여 받을 수 있다. 단, 주의할 점은! 이 기능은 클래스 상속에서만 작동하고, 인터페이스의 구현체에는 적용되지 않는다.
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());
}
}
}
}
}
애노테이션이 있는 경우, 각 애노테이션의 속성을 기반으로 검증 로직을 수행한다. 만약, 검증에 실패하면 애노테이션에 적용한 메시지를 예외에 담아서 던진다.
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도 자바 언어가 기본으로 제공하는 애노테이션이지만, 이것은 애노테이션 자체를 정의하기 위한 메타 애노테이션이고, 지금 설명한 내용은 코드에 직접 사용하는 애노테이션이다.
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 cwchoiit.annotation.java;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
public class SuppressWarningCase {
@SuppressWarnings("unused")
public void unusedWarning() {
// 사용되지 않는 변수 경고 억제
int unusedVariable = 10;
}
@SuppressWarnings("deprecation")
public void deprecatedMethod() {
Date date = new Date();
// deprecated method 경고 억제
int date1 = date.getDate();
}
@SuppressWarnings({"rawtypes", "unchecked"})
public void uncheckedCast() {
// 제네릭 타입 캐스팅 경고 억제, raw type 사용 경고
List list = new ArrayList();
// unchecked 경고
List<String> stringList = (List<String>) list;
}
@SuppressWarnings("all")
public void suppressAllWarning() {
Date date = new Date();
// deprecated method 경고 억제
int date1 = date.getDate();
// 제네릭 타입 캐스팅 경고 억제, raw type 사용 경고
List list = new ArrayList();
// unchecked 경고
List<String> stringList = (List<String>) list;
}
}
@SuppressWarnings에 사용하는 대표적인 값들은 다음과 같다.
all → 모든 경고를 억제
deprecation → 사용이 권장되지 않는 (deprecated) 코드를 사용할 때 발생하는 경고를 억제
unchecked → 제네릭 타입과 관련된 unchecked 경고를 억제
serial → Serializable 인터페이스를 구현할 때, serialVersionUID 필드를 선언하지 않은 경우 발생하는 경고를 억제
rawtypes → 제네릭 타입이 명시되지 않은(raw) 타입을 사용할 때 발생하는 경고를 억제
unused → 사용되지 않는 변수, 메서드, 필드 등을 선언했을 때 발생하는 경고를 억제
정리를 하자면...
자바 백엔드 개발자가 되려면, 스프링, JPA 같은 기술은 필수로 배워야한다. 그런데 처음 스프링이나 JPA 같은 기술을 배우면 기존에 자바 문법으로는 잘 이해가 안가는 마법같은 일들이 벌어진다.
이러한 프레임워크들이 리플렉션과 애노테이션을 잘 활용해서 다음과 같은 마법 같은 기능들을 제공하기 때문이다.
의존성 주입 (DI): 스프링은 리플렉션을 사용하여, 객체의 필드나 생성자에 자동으로 의존성을 주입한다. 개발자는 단순히 @Autowired 애노테이션만 붙이면 된다.
ORM: JPA는 애노테이션을 사용하여, 자바 객체와 데이터베이스 테이블 간의 매핑을 정의한다. 예를 들어, @Entity, @Table, @Column 등의 애노테이션으로 객체 - 테이블 관계를 설정한다.
AOP: 스프링은 리플렉션을 사용하여, 런타임에 코드를 동적으로 주입하고, @Aspect, @Before, @After 등의 애노테이션으로 관점 지향 프로그래밍을 구현한다.
설정의 자동화: @Configuration, @Bean 등의 애노테이션을 사용하여 다양한 설정을 편리하게 적용한다.
트랜잭션 관리: @Transactional 애노테이션만으로 메서드 레벨의 DB 트랜잭션 처리가 가능해진다.
이러한 기능들은, 개발자가 비즈니스 로직에 집중할 수 있게 해주며, 보일러플레이트 코드를 크게 줄여준다. 하지만 이 "마법"의 이면에는 리플렉션과 애노테이션을 활용한 복잡한 메타프로그래밍이 숨어 있다. 프레임워크의 동작 원리를 깊이 이해하기 위해서는 리플렉션과 애노테이션에 대한 이해가 필수다. 이를 통해 프레임워크가 제공하는 편의성과 그 이면의 복잡성 사이의 균형을 잡을 수 있으며, 필요에 따라 프레임워크를 효과적으로 커스터마이징하거나 최적화할 수 있게 된다.
스프링이나 JPA 같은 프레임워크들은 이번에 학습한 리플렉션과 애노테이션을 극대화해서 사용한다. 리플렉션과 애노테이션을 배운 덕분에 이런 기술이 마법이 아니라, 리플렉션과 애노테이션을 활용한 고급 프로그래밍 기법이라는 것을 이해할 수 있을 것이다. 그리고 이러한 이해를 바탕으로, 프레임워크의 동작 원리를 더 깊이 파악하고 효과적으로 활용할 수 있게 될 것이다.
이제 애노테이션과 리플렉션도 전부 배워봤다. 그럼 이전에 만들었던 HTTP 서버를 이 애노테이션을 활용해서 화룡점정을 찍어보자.
HTTP 서버7 - 애노테이션 서블릿1
지금까지 학습한 애노테이션 내용을 바탕으로 애노테이션 기반의 컨트롤러와 서블릿을 만들어보자.
예를 들어, 다음과 같은 컨트롤러를 만들 예정이다.
package cwchoiit.was.v7;
import cwchoiit.was.httpserver.HttpRequest;
import cwchoiit.was.httpserver.HttpResponse;
import cwchoiit.was.httpserver.servlet.annotation.RequestMapping;
public class SiteControllerV7 {
@RequestMapping("/site1")
public void site1(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Site1</h1>");
}
@RequestMapping("/site2")
public void site2(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>Site2</h1>");
}
}
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
}
}
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를 만들어냈다.
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);
}
}
이렇게 수정자는 이런 내용을 알려주는 것이다. 그리고 수정자는 접근 제어자와 비 접근 제어자 둘 다 다룬다고 했는데 만약, 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 매개변수가 있는 메서드를 찾는다.
여기서 메서드를 찾을 때, 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;
}
}
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 값을 다른 기본 값으로 모두 변경해야 한다.
String이 null이면 ""(빈 문자)로 변경한다.
Integer가 null이면 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 데이터를 모두 기본 값으로 변경해야 한다고 가정해보자.
String이 null이면 ""(빈 문자)로 변경한다.
Integer가 null이면 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 등등 수 많은 객체에 매우 편리하게 기본 값을 적용할 수 있게 되었다. 이처럼 리플렉션을 활용하면 기존 코드로 해결하기 어려운 공통 문제를 손쉽게 처리할 수도 있다.
생성자 탐색과 객체 생성
리플렉션을 활용하면 생성자를 탐색하고, 또 탐색한 생성자를 사용해서 객체를 생성할 수 있다.
이번 예제를 잘 보면, 클래스를 동적으로 찾아서 인스턴스를 생성하고, 메서드도 동적으로 호출했다. 코드 어디에도 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의 코드를 전혀 변경하지 않고 그대로 재사용하면서 기능을 추가하겠다.
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의 생성자로 넘겨준다.
ServletManager의 defaultServlet을 위에서 만든 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가 아래 메서드를 호출하면서, 서블릿을 실행했다.
그런데, 여기서 이제는 defaultServlet을 제외하고 아무런 서블릿도 등록하지 않았으니 저 ReflectionServlet만 실행되도록 한 것이다.
정리
기존 HTTP 서버의 코드를 전혀 변경하지 않고, 서블릿만 잘 구현해서 완전히 새로운 기능을 도입했다. 덕분에 앞서 커맨드 패턴으로 만든 서블릿의 단점을 해결할 수 있었다. 이렇게 리플렉션을 활용해서, 요청 URL과 동일한 메서드명을 통해 서블릿을 호출할 수 있게 된다.
남은 문제점
리플렉션 서블릿은 요청 URL과 메서드 이름이 같다면 해당 메서드를 동적으로 호출할 수 있다. 하지만 요청 이름과 메서드 이름을 다르게 하고 싶다면 어떻게 해야할까?
예를 들어, `/site1` 이라고 와도 page1()과 같은 다른 이름의 메서드를 호출하고 싶다면 어떻게 해야 할까? 예를 들어서 메서드 이름은 더 자세히 적고 싶을 수도 있잖아?
또한, 홈 경로인 `/`와, `/favicon.ico`와 같은 아이콘 처리 경로는 자바 메서드 이름으로 처리할 수 없었다. 이건 어떻게 해결 할까?
URL은 주로 `-`를 사용해서 문장을 이어간다. 예를 들면 `/add-member` 이렇게 말이다. 이런 URL은 어떻게 해결 할까?
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를 사용했다.
Stream을 Reader, 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://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 작업을 요청한다.
스레드 풀에 있는 스레드가 HttpRequestHandler의 run()을 수행한다.
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` 기능 검색 화면, 클라이언트에서 서버로 검색어를 전달할 수 있다.
응답 시 원칙적으로 헤더에 메시지 바디의 크기를 계산해서 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에 한글을 전달하려면 어떻게 해야할까?
한글을 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로 디코딩하면 "가"라는 글자를 얻을 수 있다.
% 인코딩, 디코딩 진행 과정
클라이언트: '가' 전송 희망
클라이언트 % 인코딩: %EA%B0%80
'가'를 UTF-8로 인코딩
'EA', 'B0', '80' (3byte) 획득
각 byte를 16진수 문자로 표현하고 각각의 앞에 %를 붙임
클라이언트 → 서버 전송 q=%EA%B0%80
서버: %EA%B0%80 ASCII 문자를 전달 받음
%가 붙은 경우 디코딩해야 하는 문자로 인식
EA, B0, 80을 byte로 변환, 3byte 획득
EA, B0, 80을 UTF-8로 디코딩 → 문자 '가' 획득
자바가 제공하는 % 인코딩
자바가 제공하는 URLEncoder.encode(), URLDecoder.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을 사용하면 이후에 편리하게 데이터를 조회할 수 있다.
만약, 다음과 같은 내용이 있다면 queryParameters의 Map에 저장되는 내용은 다음과 같다.
/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 서버와 관련된 부분은 거의 재사용하고 서비스 개발을 위한 로직만 추가하면 될 것 같다. 그리고 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 서버와 관련된 부분과 서비스 개발을 위한 로직을 분리하는데 도움이 된다.
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 패키지의 코드를 그대로 재사용하면 된다. 그리고 해당 서비스에 필요한 서블릿을 구현하고, 서블릿 매니저에 등록한 다음에 서버를 실행하면 된다. 여기서 중요한 부분은 새로운 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를 변경해도 기능 변경없이 구현한 서블릿들을 그대로 사용할 수 있다.
이번에는 지금까지 쭉 배워왔던 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");
}
}
ReadHandler는 Runnable 인터페이스를 구현하고, 별도의 스레드에서 실행한다.
서버의 메시지를 반복해서 받고, 콘솔에 출력하는 단순한 기능을 제공한다.
클라이언트 종료시 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();
}
}
Client의 Entry 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의 생성 시점에 sessionManager에 Session을 등록한다.
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(): 모든 세션에 등록된 사용자의 이름을 반환한다. 향후, 모든 사용자 목록을 출력할 때 사용된다.
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문으로 덕지덕지 추가하는 것이 영 마음에 들지 않는다. 새로운 기능이 추가되어도 기존 코드에 영향을 최소화하면서 기능을 추가할 수 있게 변경해보자.
참고로, 여러 스레드가 이 commands = new HashMap<>()을 동시에 접근해서 데이터를 조회한다. 접속자가 2명이면 2개의 스레드가, 3개면 3개의 스레드 쭉 말이다. 하지만 commands는 데이터 초기화 이후에는 데이터를 전혀 변경하지 않는다. 따라서 여러 스레드가 동시에 값을 조회해도 문제가 발생하지 않는다. 만약, commands의 데이터를 스레드들이 변경한다고 하면 동시성 문제를 고민해야 한다.
V4
이전 예제로도 충분히 잘 만들어진 상태지만, command가 없는 경우에 null을 체크하고 처리해야 하는 부분이 좀 지저분하다.
Command command = commands.get(key);
if (command == null) {
session.send("처리할 수 없는 명령어 입니다. " + totalMessage);
return;
}
지저분(?) 보다는 만약, 명령어가 항상 존재한다면 다음과 같이 명령어를 찾고 바로 실행하는 깔끔한 코드를 작성할 수 있을 것이다.
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문 몇개로 해결할 수 있는 문제에 복잡한 커맨드 패턴을 도입하는 것은 좋은 설계까 아닐 수 있다.
기능이 어느정도 있고, 각각의 기능이 명확하게 나누어질 수 있고, 향후 기능의 확장까지 고려해야 한다면 커맨드 패턴은 좋은 대안이 될 수 있다.
기본적인 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()만 호출하면 된다.
ServerV4는 ServerV3와 같은 코드이다. 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]
기존 코드의 문제는 클라이언트를 직접 종료하면, 서버의 Session에 EOFException이 발생하면서 자원을 제대로 정리하지 못했다.
변경한 코드에서는 서버에 접속한 클라이언트를 직접 종료해도 서버의 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
연결이 거절됐다는 뜻은, 우선은 네트워크를 통해 해당 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);
}
}
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 패킷을 받은 클라이언트의 소켓은 더는 서버를 통해 읽을 데이터가 없다는 의미로 -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 라는 패킷이 발생한다. 이 경우 연결을 즉시 종료해야 한다.
서버는 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이 발생하면 자원을 정리하면 된다. 만약, 더 자세히 분류해야 하는 경우가 발생하면 그때 예외를 구분해서 처리하면 된다.