이번 포스팅에서는 도메인 모델 패턴과 트랜잭션 스크립트 패턴이 무엇인지 알아보고, 어떤 장단점이 있고, 무엇이 언제 더 좋은가?에 대해 고민해보는 포스팅이다.
도메인 모델 패턴
도메인 모델 패턴은, 애플리케이션의 비즈니스 로직을 객체 중심으로 구성하는 패턴이다. 이 패턴에서는 실세계의 개념을 반영한 객체(엔티티)를 만들어 각 객체가 비즈니스 로직과 상태를 자체적으로 관리하도록 한다. 예를 들어, 다음 코드를 보자.
Order
package cwchoiit.shoppingmall.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@Entity
@Getter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 생성 메서드를 만들었으면 그 메서드로만 인스턴스를 생성할 수 있도록 막아야 함
public class Order {
@Id
@GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status;
// ################################################################# //
// ######################## 연관관계 편의 메서드 ######################## //
// ################################################################# //
public void addRelationshipMember(Member member) {
this.member = member;
member.getOrders().add(this);
}
public void addRelationshipDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.addRelationshipOrder(this);
}
public void addRelationshipOrderItem(OrderItem orderItem) {
this.orderItems.add(orderItem);
orderItem.addRelationshipOrder(this);
}
// ################################################################# //
// ######################## 생성 메서드 ############################### //
// ################################################################# //
public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
Order order = new Order();
order.status = OrderStatus.ORDER;
order.orderDate = LocalDateTime.now();
order.addRelationshipMember(member);
order.addRelationshipDelivery(delivery);
Arrays.stream(orderItems).forEach(order::addRelationshipOrderItem);
return order;
}
public void cancel() {
if (delivery.getStatus() == DeliveryStatus.COMP) {
throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
}
status = OrderStatus.CANCEL;
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
// ################################################################# //
// ######################## 조회 메서드 ############################### //
// ################################################################# //
public int getTotalPrice() {
return orderItems.stream()
.mapToInt(OrderItem::getTotalPrice)
.sum();
}
}
이 코드의 하단부를 보면, 여러 메서드가 있지만 그 중 대표적인 예시로 getTotalPrice()로 설명을 해보겠다. 이 메서드는 주문 가격을 반환하는 메서드이고 주문 가격은 주문 수량 * 주문 상품의 개수의 결과값을 다 합친것이다. OrderItem 이라는 또 하나의 객체가 있지만 그 안에 있는 메서드가 그런 작업을 한다.
여튼, 이런 코드는 서비스 레이어에 주문 메서드에서 한 줄 한 줄 로직을 작성할 수도 있지만, 이렇게 객체 자체적으로 그 메서드를 가지고 있을 수도 있다. 즉, 객체 자체에 주문 생성과 관련된 로직이 포함되어 있다는 말이다.
또 하나의 예로, createOrder()와 같은 메서드도 서비스 레이어에 주문 관련 메서드안에 한 줄 한 줄 비즈니스 로직을 작성할 수도 있지만 이렇게 객체 자체적으로 그 기능을 가지고 있을 수도 있다.
이게 바로 도메인 모델 패턴이다. 즉, 관련 메서드는 해당 객체가 가지고 있는 것을 말한다.
또 다른 예시로는 다음 코드를 보자.
Mp3
package cwchoiit.shoppingmall.domain;
public class Mp3 {
private int volume;
private String name;
public Mp3(int volume, String name) {
this.volume = volume;
this.name = name;
}
public void play() {
System.out.println(name + " - " + volume + "dB");
}
public void volumeUp() {
volume++;
System.out.println("Volume up to " + volume + "dB");
}
public void volumeDown() {
volume--;
System.out.println("Volume down to " + volume + "dB");
}
}
Mp3 라는 객체가 있고 이 객체 안에는 name, volume 이라는 두 개의 필드가 있다.
이 객체의 볼륨을 줄이고 키우는 작업은 이 서비스 레이어에서 인스턴스를 만들고 세터를 사용해서 볼륨을 줄이고 키울수도 있겠지만 이렇게 객체 자체적으로 volumeUp(), volumeDown() 메서드를 제공해서 비즈니스 로직 자체를 객체 안에 포함시킬수도 있다.
이게 도메인 모델 패턴이다.
그리고 이렇게 하면 실세계와 가장 밀접한 객체 지향적 설계가 된다고 본다. Mp3를 사용하는 사용자가 볼륨을 줄이는 행위는 Mp3가 제공하는거지 사용자가 새로이 만들어내는 기술이 아니잖아.
도메인 모델 패턴의 장단점
장점
재사용성과 확장성: 객체 간의 관계와 상태가 명확하게 정의되어 있어, 새로운 비즈니스 요구사항이 추가될 때 확장이 쉽다.
유지보수성: 비즈니스 로직이 객체에 분산되어 있어서, 특정 기능을 수정해도 전체 애플리케이션에 미치는 영향이 적다.
객체 지향적: 실세계의 개념을 반영하여 객체를 통해 로직을 처리하므로 이해하기 쉽고, 자바 같은 객체 지향 언어에 적합하다.
단점
복잡성 증가: 복잡한 비즈니스 로직을 가진 대규모 애플리케이션에서는 객체 간의 관계와 상호작용이 복잡해질 수 있다.
초기 개발 비용: 모델을 설계하고 객체를 정의하는 데 시간이 상대적으로 많이 걸린다.
트랜잭션 스크립트 패턴
트랜잭션 스크립트 패턴은 비즈니스 로직을 절차적으로 처리하는 코드로 구성하는 방식이다. 이 패턴은 비즈니스 로직을 하나의 스크립트로 정의하여 각 요청에 대해 해당 스크립트를 실행하는 구조를 가진다. 예를 들어 OrderService와 같은 서비스 클래스에 메서드 형태로 비즈니스 로직을 구현하는 것이 일반적이다.
예를 들어 아래 코드를 보자.
Mp3
package cwchoiit.shoppingmall.domain;
public class Mp3 {
private int volume;
private String name;
public Mp3(int volume, String name) {
this.volume = volume;
this.name = name;
}
public int getVolume() {
return volume;
}
public void setVolume(int volume) {
this.volume = volume;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
Mp3 클래스가 있다. 이 클래스는 volume, name 필드를 가지고 있고 Getter, Setter를 가지고 있다.
Mp3Service
package cwchoiit.shoppingmall.service;
import cwchoiit.shoppingmall.domain.Mp3;
public class Mp3Service {
public void mp3() {
Mp3 mp3 = new Mp3(0, "ipod");
// 볼륨 증가
mp3.setVolume(1);
// 볼륨 증가 후 로직 처리
// ...
// 볼륨 감소
mp3.setVolume(0);
// ...
}
}
Mp3Service는 서비스 클래스이다. 이 클래스의 특정 메서드 안에서 Mp3 인스턴스를 만들고 그 인스턴스의 볼륨을 줄이고 키우고 그 사이에 어떤 비즈니스 로직들이 쭉 스크립트 작성하듯 절차적으로 진행된다.
이게 트랜잭션 스크립트 패턴이다.
트랜잭션 스크립트 패턴의 장단점
장점
간단하고 직관적: 코드가 직관적이고 간단해서 빠르게 개발하고 한눈에 코드를 알아보기 쉽다.
단점
재사용성 부족: 비즈니스 로직이 중복될 가능성이 높고, 코드 재사용이 불가능하다.
확장성 부족: 비즈니스 로직이 절차적으로 구성되어 있어 새로운 요구사항이 추가되면 코드가 복잡해지고 유지보수성이 떨어진다.
비즈니스 로직 분리 어려움: 코드가 길어지고 복잡해지면, 로직이 여기저기 흩어져 가독성이 떨어진다.
어떤 패턴이 더 좋은가요?
굳이 말하자면, 애플리케이션의 복잡도와 요구사항에 따라 달라진다고 본다. 더 좋고 나쁜 것은 없다고 생각한다.
복잡한 도메인 로직이 있는 경우: 도메인 모델 패턴이 더 적합하다. 객체 지향적인 구조와 비즈니스 로직이 함께 동작하기 때문에 확장성이 높고 유지보수도 쉽기 때문이다.
단순한 애플리케이션이나 빠른 개발이 필요한 경우: 트랜잭션 스크립트 패턴이 더 효율적일 수 있다. 절차적인 코드 구조로 인해 간단한 작업에는 빠르고 효율적이다.
내 개인적인 의견으로는, 도메인 모델 패턴이 자바의 장점도 살리면서 객체 지향적이고 장기적으로 볼 때 유지보수성과 확장성, 재사용성을 고려해 더 좋은 선택인 것 같다. 그런데 만약, 클래스 파일도 몇 개 없고 정말 간단한 애플리케이션이라면, 트랜잭션 스크립트 패턴을 사용해도 누가 뭐라고 하진 않을 것 같다.
이전 포스팅에서 리플렉션을 활용해서 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이 발생하면 자원을 정리하면 된다. 만약, 더 자세히 분류해야 하는 경우가 발생하면 그때 예외를 구분해서 처리하면 된다.
만약, 호스트 파일에 localhost가 없다면, 127.0.0.1 localhost를 추가하거나, 127.0.0.1과 같은 IP를 직접 사용하면 된다.
클라이언트 코드 분석
클라이언트와 서버의 연결은 Socket을 사용한다.
Socket socket = new Socket("localhost", PORT)
(지금은 서버도 클라이언트도 동일한 PC이므로) localhost를 통해, 자신의 컴퓨터에 있는 12345 포트에 TCP 접속을 시도한다.
localhost는 IP가 아니므로 해당하는 IP를 먼저 찾는다. 내부에서 InetAddress를 사용한다.
localhost는 127.0.0.1 이라는 IP에 매핑되어 있다.
127.0.0.1:12345에 TCP 접속을 시도한다.
연결이 성공적으로 완료되면, Socket 객체를 반환한다.
Socket은 서버와 연결되어 있는 연결점이라고 생각하면 된다.
Socket 객체를 통해서 서버와 통신할 수 있다.
클라이언트와 서버간의 데이터 통신은 Socket이 제공하는 스트림을 사용한다.
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
Socket은 서버와 데이터를 주고 받기 위한 스트림을 제공한다.
클라이언트 입장에서는,
InputStream: 서버에서 전달한 데이터를 클라이언트가 받을 때 사용한다.
OutputStream: 클라이언트에서 서버에 데이터를 전달할 때 사용한다.
InputStream, OutputStream을 그대로 사용하면 모든 데이터를 byte로 변환해서 전달해야 하기 때문에 번거롭다. 여기서는 DataInputStream, DataOutputStream이라는 보조 스트림을 사용해서, 자바 타입의 메시지를 편리하게 주고 받을 수 있도록 했다.
// 서버에게 문자 보내기
String toSend = "Hello";
output.writeUTF(toSend);
OutputStream을 통해 서버에 "Hello" 메시지를 전송한다.
// 서버로부터 문자 받기
String received = input.readUTF();
InputStream을 통해 서버가 전달한 메시지를 받을 수 있다.
클라이언트가 "Hello"를 전송하면 서버는 " World!"라는 문자를 붙여서 반환하므로 "Hello World!"라는 문자를 반환받는다.
사용이 끝나면 사용한 자원은 반드시 반납해야 한다. 지금은 간단하고 허술하게 자원 정리를 했지만, 뒤에서 자원 정리를 매우 자세히 다루겠다.
서버 코드 분석
서버 소켓
서버는 특정 포트를 열어두어야 한다. 그래야 클라이언트가 해당 포트를 지정해서 접속할 수 있다.
ServerSocket serverSocket = new ServerSocket(PORT);
서버는 서버 소켓(ServerSocket)이라는 특별한 소켓을 사용한다.
지정한 포트를 사용해서 서버 소켓을 생성하면, 클라이언트는 해당 포트로 서버에 연결할 수 있다.
클라이언트와 서버의 연결 과정을 그림으로 자세히 알아보자.
서버가 12345 포트로 서버 소켓을 열어둔다. 클라이언트는 이제 12345 포트로 서버에 접속할 수 있다.
클라이언트가 12345 포트에 연결을 시도한다.
이때 OS 계층에서 TCP 3 way handshake가 발생하고, TCP 연결이 완료된다.
TCP 연결이 완료되면 서버는 OS backlog queue라는 곳에 클라이언트와 서버의 TCP 연결 정보를 보관한다.
이 연결 정보를 보면 클라이언트의 IP, PORT 서버의 IP, PORT 정보가 모두 들어있다.
클라이언트와 랜덤 포트
TCP 연결시에는 클라이언트, 서버 모두 IP와 PORT 정보가 필요하다. 예제에서 사용된 IP 포트는 다음과 같다.
클라이언트 → localhost(127.0.0.1), 50000(랜덤 생성)
서버 → localhost(127.0.0.1), 12345
그런데 생각해보면, 클라이언트는 자신의 포트를 지정한 적이 없다. 서버의 경우 포트가 명확하게 지정되어 있어야 한다. 그래야 클라이언트에서 서버에 어떤 포트에 접속할지 알 수 있다. 반면에 서버에 접속하는 클라이언트의 경우에는 자신의 포트가 명확하게 지정되어 있지 않아도 된다. 클라이언트는 보통 포트를 생략하는데, 생략할 경우 클라이언트 PC에 남아있는 포트 중 하나가 랜덤으로 할당된다. 참고로 클라이언트의 포트도 명시적으로 할당할 수 있지만, 잘 사용하지 않는다.
accept()
Socket socket = serverSocket.accept();
서버 소켓은 단지 클라이언트와 서버의 TCP 연결만 지원하는 특별한 소켓이다.
실제 클라이언트와 서버가 정보를 주고 받으려면 Socket 객체가 필요하다. (서버 소켓이 아니다! 소켓이다!)
serverSocket.accept() 메서드를 호출하면 TCP 연결 정보를 기반으로, Socket 객체를 만들어서 반환한다.
accept() 호출 과정을 그림으로 자세히 알아보자.
accept()를 호출하면 backlog queue에서 TCP 연결 정보를 조회한다.
만약, TCP 연결 정보가 없다면, 연결 정보가 생성될 때까지 대기한다. (블로킹)
해당 정보를 기반으로 Socket 객체를 생성한다.
사용한 TCP 연결 정보는 backlog queue에서 제거된다.
Socket 생성 후 그림
클라이언트와 서버의 Socket은 TCP로 연결되어 있고, 스트림을 통해 메시지를 주고 받을 수 있다.
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
Socket은 클라이언트와 서버가 데이터를 주고 받기 위한 스트림을 제공한다.
InputStream: 서버 입장에서 보면 클라이언트가 전달한 데이터를 서버가 받을 때 사용한다.
OutputStream: 서버에서 클라이언트에 데이터를 전달할 때 사용된다.
// 클라이언트로부터 문자 받기
String received = input.readUTF();
클라이언트가 전달한 "Hello" 메시지를 전달 받는다.
// 클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
이 프로그램은 메시지를 하나만 주고 받으면 클라이언트와 서버가 모두 종료된다. 메시지를 계속 주고 받고, 원할 때 종료할 수 있도록 변경해보자.
네트워크 프로그램 2
이번엔 클라이언트와 서버가 메시지를 계속 주고 받다가, "exit" 라고 입력하면 클라이언트와 서버를 종료해보자.
ClientV2
package cwchoiit.network.tcp.v2;
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 ClientV2 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
log("클라이언트 시작");
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);
}
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
}
}
클라이언트와 서버가 메시지를 주고 받는 부분만 while로 반복하면 된다.
exit를 입력하면 클라이언트는 exit 메시지를 서버에 전송하고, 클라이언트는 while 문을 빠져나가면서 연결을 종료한다.
ServerV2
package cwchoiit.network.tcp.v2;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Arrays;
import java.util.Scanner;
import static cwchoiit.util.MyLogger.log;
public class ServerV2 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("서버 시작");
ServerSocket serverSocket = new ServerSocket(PORT);
log("서버 소켓 시작 - 리스닝 포트: " + PORT);
Socket socket = serverSocket.accept();
log("소켓 연결: " + 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);
}
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
serverSocket.close();
}
}
클라이언트와 서버가 메시지를 주고 받는 부분만 while로 반복하면 된다.
클라이언트로부터 exit 메시지가 전송되면, 서버는 while문을 빠져나가면서 연결을 종료한다.
실행 결과 - 클라이언트
16:03:41.259 [ main] 클라이언트 시작
16:03:41.275 [ main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=60687]
서버에게 보낼 문자를 입력하세요:hello
16:03:49.077 [ main] client -> server: hello
16:03:49.081 [ main] client <- server: hello World!
서버에게 보낼 문자를 입력하세요:hi
16:03:53.077 [ main] client -> server: hi
16:03:53.078 [ main] client <- server: hi World!
서버에게 보낼 문자를 입력하세요:exit
16:04:03.087 [ main] client -> server: exit
16:04:03.089 [ main] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=60687]
실행 결과 - 서버
16:03:34.700 [ main] 서버 시작
16:03:34.712 [ main] 서버 소켓 시작 - 리스닝 포트: 12345
16:03:41.275 [ main] 소켓 연결: Socket[addr=/127.0.0.1,port=60687,localport=12345]
16:03:49.077 [ main] client -> server: hello
16:03:49.080 [ main] client <- server: hello World!
16:03:53.077 [ main] client -> server: hi
16:03:53.078 [ main] client <- server: hi World!
16:04:03.088 [ main] client -> server: exit
16:04:03.089 [ main] 연결 종료: Socket[addr=/127.0.0.1,port=60687,localport=12345]
덕분에 클라이언트와 서버가 필요할 때까지 계속 매시지를 주고 받을 수 있다.
문제
서버는 하나의 클라이언트가 아니라, 여러 클라이언트의 연결을 처리할 수 있어야 한다. 여러 클라이언트가 하나의 서버에 접속하도록 해보자.
참고로, IntelliJ에서 같은 클라이언트를 동시에 실행하려면 다음과 같이 하면 된다.
Edit Configurations... 선택
Copy Configuration 클릭
새로 만든 ClientV2의 Configuration을 저장한다.
새로 만든 Configuration을 실행한다.
그렇게 실행하면 여러 클라이언트를 실행할 수 있다. 그런데 다음과 같이 정상적으로 수행되지 않는다.
16:12:03.789 [ main] 클라이언트 시작
16:12:03.804 [ main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=61271]
서버에게 보낼 문자를 입력하세요:hello
16:13:20.652 [ main] client -> server: hello
처음 접속한 클라이언트는 문제 없이 작동하는데, 두번째부터 새로 연결한 클라이언트는 소켓 연결과 보내는 것만 될 뿐, 돌아오지 않고 있다. 왜 이런 문제가 발생할까?
네트워크 프로그램 2 - 문제 분석
서버 소켓과 연결을 더 자세히 알아보자. 이번에는 여러 클라이언트가 서버에 접속한다고 가정해보자.
서버는 12345 서버 소켓을 열어둔다.
50000번 랜덤 포트를 사용하는 클라이언트가 먼저 12345 포트의 서버에 접속을 시도한다.
이때, OS 계층에서 TCP 3 handshake가 발생하고, TCP 연결이 완료된다.
TCP 연결이 완료되면, 서버는 OS backlog queue라는 곳에 클라이언트와 서버의 TCP 연결 정보를 보관한다.
여기서 중요한 점이 있는데, 이 시점에 TCP 3 handshake가 완료되었기 때문에, 클라이언트와 서버의 TCP 연결은 이미 완료되고, 클라이언트의 소켓 객체도 정상 생성된다. 참고로 이 시점에 아직 서버의 소켓 객체(서버 소켓 아님)는 생성되지 않았다.
이번에는 60000번 랜덤 포트를 사용하는 클라이언트가 12345 포트의 서버에 접속을 시도하고 연결을 완료한다.
50000번 클라이언트와 60000번 클라이언트 모두 서버와 연결이 완료되었고, 클라이언트의 소켓도 정상 생성된다.
서버가 클라이언트와 데이터를 주고 받으려면 소켓을 획득해야 한다.
ServerSocket.accept() 메서드를 호출하면 backlog 큐의 정보를 기반으로 소켓 객체를 하나 생성한다.
큐이므로 순서대로 데이터를 꺼낸다. 처음 50000번 클라이언트의 접속 정보를 기반으로 서버에 소켓이 하나 생성된다.
50000번 클라이언트와 서버는 소켓의 스트림을 통해 서로 데이터를 주고 받을 수 있다.
그림에서 60000번 클라이언트도 이미 서버와 TCP 연결은 되어 있다. OS 계층에서 TCP 3 handshake가 발생하고, TCP 연결이 완료되었다.
60000번 클라이언트도 서버와 TCP 연결이 되었기 때문에 서버로 메시지를 보낼 수 있다. 아직 서버에 Socket 객체가 없더라도, 메시지는 보낼 수 있다. 클라이언트는 Socket이 만들어진 상태고 TCP 연결은 이미 완료가 된 상태이니까.
그림을 보자. 소켓을 통해 스트림으로 메시지를 주고 받는다는 것은 이러한 과정을 거친다. 자바 애플리케이션은 소켓 객체의 스트림을 통해 서버와 데이터를 주고 받는다. 데이터를 주고 받는 과정은 다음과 같다.
클라이언트가 "Hello"라는 메시지를 서버에 전송할 때
클라이언트: 애플리케이션 → OS TCP 송신 버퍼 → 클라이언트 네트워크 카드
클라이언트가 보낸 메시지가 서버에 도착했을 때
서버: 서버 네트워크 카드 → OS TCP 수신 버퍼 → 애플리케이션
여기서, 60000번 클라이언트가 보낸 메시지는 서버 애플리케이션에서 아직 읽지 않았기 때문에, 서버 OS의 TCP 수신 버퍼에서 대기하게 된다. 여기서 핵심적인 내용이 있는데, 소켓 객체 없이 서버 소켓만으로도 TCP 연결은 완료된다는 점이다. (서버 소켓은 연결만 담당한다) 하지만 연결 이후에 서로 메시지를 주고 받으려면 소켓 객체가 필요하다.
accept()는 이미 연결된 TCP 연결 정보를 기반으로 서버 측에 소켓 객체를 생성한다. 그리고 이 소켓 객체가 있어야 스트림을 사용해서 메시지를 주고 받을 수 있다.
이렇게 소켓을 연결하면 소켓의 스트림을 통해 OS TCP 수신 버퍼에 있는 메시지를 읽을 수 있고, 또 전송할 수도 있다.
정상적으로 여러 클라이언트를 처리할 수 있는 서버라면, 이렇게 50000번, 60000번 클라이언트와 통신할 소켓 객체를 각각 만들어 통신할 수 있는 환경이 되면 다시 accept() 메서드가 블로킹 상태로 계속 대기해야 한다. 그래야 다음 클라이언트가 접속 시도를 할 때 정상적으로 소켓 객체를 만들 수 있으니까.
그럼 지금 겪고 있는 문제의 원인은 이렇다.
새로운 클라이언트가 접속하면?
새로운 클라이언트가 접속했을 때, 서버의 main 스레드는 aceept() 메서드를 절대로 호출할 수 없다! 왜냐하면 while 문으로 기존 클라이언트와 메시지를 주고 받는 부분만 반복하기 때문이다.
accept()를 호출해야 소켓 객체를 생성하고 새로운 클라이언트와 메시지를 주고 받을 수 있다.
2개의 블로킹 작업 - 핵심은 별도의 스레드가 필요하다!
accept() → 클라이언트와 서버의 연결을 처리하기 위해 대기
readUTF() 등 readXxx() → 클라이언트의 메시지를 받아서 처리하기 위해 대기
각각의 블로킹 작업은 별도의 스레드에서 처리해야 한다. 그렇지 않으면 다른 블로킹 메서드 때문에 계속 대기할 수 있다.
이제 여러 서버가 접속해도 문제없이 작동하는 것을 볼 수 있다. 그리고 각각의 연결이 별도의 스레드에서 처리되는 것도 확인할 수 있다.
서버 소켓을 통해 소켓을 연결하는 부분과 각 클라이언트와 메시지를 주고 받는 부분이 별도의 스레드로 나뉘어 있다. 블로킹 되는 부분은 이렇게 별도의 스레드로 나누어 실행해야 한다.
문제
여기서 실행 중인 클라이언트를 IntelliJ의 빨간색 Stop 버튼을 눌러서 직접 종료해보자.
클라이언트를 직접 종료한 경우 서버 로그
Exception in thread "Thread-0" java.lang.RuntimeException: java.io.EOFException
at cwchoiit.network.tcp.v3.SessionV3.run(SessionV3.java:42)
at java.base/java.lang.Thread.run(Thread.java:857)
Caused by: java.io.EOFException
at java.base/java.io.DataInputStream.readUnsignedShort(DataInputStream.java:337)
at java.base/java.io.DataInputStream.readUTF(DataInputStream.java:583)
Caused by: java.io.EOFException
at java.base/java.io.DataInputStream.readUTF(DataInputStream.java:558)
at cwchoiit.network.tcp.v3.SessionV3.run(SessionV3.java:26)
... 1 more
다음과 같이 에러가 발생했다. 에러가 어디서 발생했을까? 항상 에러 로그는 가장 아래서부터 봐야한다.
클라이언트의 연결을 직접 종료하면, 클라이언트 프로세스가 종료되면서, 클라이언트와 서버의 TCP 연결도 함께 종료된다. 이때 서버에서 readUTF()로 클라이언트가 보낸 메시지를 읽으려고 하면 EOFException이 발생한다. 소켓의 TCP 연결이 종료되었기 때문에 더는 읽을 수 있는 메시지가 없다는 뜻이다. EOF(파일의 끝)가 여기서는 전송의 끝이라는 뜻이다. 그런데 여기서 심각한 문제가 하나 있다. 이렇게 예외가 발생해버리면, 서버에서 자원 정리 코드를 호출하지 못한다는 점이다. 서버 로그를 보면 연결 종료 로그가 없는 것을 확인할 수 있다.
// 자원 정리
input.close();
output.close();
socket.close();
자바 객체는 GC가 되지만, 자바 외부의 자원은 자동으로 GC가 되는게 아니다. 따라서, 꼭! 정리를 해주어야 한다. (TCP 연결의 경우 운영체제가 어느정도 연결을 정리해주지만, 직접 연결을 종료할 때보다 더 많은 시간이 걸릴 수 있다) 결론은, 이 자원 정리 코드가 예외가 발생하면서 catch 구문으로 빠져버리니 자원 정리 코드를 호출하지 못하게 된다는 점이다.
자원 정리는 정말 반드시 중요하게 짚고 넘어가야 하는 부분이다. 서버는 한번 키고 끄는 일회성이 아니라, 항시 켜져있어야 한다. 따라서 사용하지 않는 자원은 반드시 정리해야 한다. 그래서 이제부터 자원 정리에 대해 알아보는 시간을 가져보자.
자원 정리1
자원 정리를 이해하기 위해 간단한 예제 코드를 만들어보자.
CallException
package cwchoiit.network.tcp.autocloseable;
public class CallException extends Exception {
public CallException(String message) {
super(message);
}
}
CloseException
package cwchoiit.network.tcp.autocloseable;
public class CloseException extends Exception {
public CloseException(String message) {
super(message);
}
}
ResourceV1
package cwchoiit.network.tcp.autocloseable;
public class ResourceV1 {
private String name;
public ResourceV1(String name) {
this.name = name;
}
public void call() {
System.out.println(name + " call");
}
public void callEx() throws CallException {
System.out.println(name + " callEx");
throw new CallException(name + " ex");
}
public void close() {
System.out.println(name + " close");
}
public void closeEx() throws CloseException {
System.out.println(name + " closeEx");
throw new CloseException(name + " ex");
}
}
call(): 정상 로직 호출
callEx(): 비정상 로직 호출 CallException을 던진다.
close(): 정상 종료
closeEx(): 비정상 종료, CloseException을 던진다.
ResourceCloseMainV1
package cwchoiit.network.tcp.autocloseable;
public class ResourceCloseMainV1 {
public static void main(String[] args) {
try {
logic();
} catch (CallException e) {
System.out.println("Call Exception 예외 처리");
throw new RuntimeException(e);
} catch (CloseException e) {
System.out.println("Close Exception 예외 처리");
throw new RuntimeException(e);
}
}
private static void logic() throws CallException, CloseException {
ResourceV1 resource1 = new ResourceV1("resource1");
ResourceV1 resource2 = new ResourceV1("resource2");
resource1.call();
resource2.callEx();
System.out.println("자원 정리");
resource2.closeEx();
resource1.close();
}
}
서로 관련된 자원은 나중에 생성한 자원을 먼저 정리해야 한다.
예를 들어서, resource1을 생성하고, resource1의 정보를 활용해서 resource2를 생성한다면, 닫을 때는 그 반대인 resource2를 먼저 닫고, 그 다음에 resource1을 닫아야 한다. 왜냐하면 resource2의 입장에서 resource1의 정보를 아직 참고하고 있기 때문이다.
이 예제에서는 두 자원이 서로 연관이 없기 때문에 생성과 종료 순서가 크게 상관이 없지만, resource1의 정보를 기반으로, resource2를 생성한다고 가정하겠다.
실행 결과
resource1 call
resource2 callEx
Call Exception 예외 처리
Exception in thread "main" java.lang.RuntimeException: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV1.main(ResourceCloseMainV1.java:9)
Caused by: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceV1.callEx(ResourceV1.java:16)
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV1.logic(ResourceCloseMainV1.java:21)
Caused by: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV1.main(ResourceCloseMainV1.java:6)
callEx()를 호출하면서 예외가 발생했다. 예외 때문에 자원 정리 코드가 정상 호출되지 않았다. 이 코드는 예외가 발생하면 자원이 정리되지 않는다는 문제가 있다.
resource1 call
resource2 callEx
ex cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
자원 정리
resource2 closeEx
Close Exception 예외 처리
Exception in thread "main" java.lang.RuntimeException: cwchoiit.network.tcp.autocloseable.CloseException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV2.main(ResourceCloseMainV2.java:12)
Caused by: cwchoiit.network.tcp.autocloseable.CloseException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceV1.closeEx(ResourceV1.java:25)
Caused by: cwchoiit.network.tcp.autocloseable.CloseException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV2.logic(ResourceCloseMainV2.java:32)
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV2.main(ResourceCloseMainV2.java:6)
이 코드는 비즈니스 로직에서 예외가 발생했을 때도 자원을 잘 정리하는 것 같지만(그렇지도 않지만) 다음과 같은 문제들이 있다.
null체크
이번에는 finally 코드 블록을 사용해서 자원을 닫는 코드가 항상 호출되도록 했다. 만약, resource2 객체를 생성하기 전에 예외가 발생하면 resource2는 null이 된다. 따라서 null 체크를 해야한다. resource1의 경우에도 resource1을 생성하는 중에 예외가 발생한다면 null 체크가 필요하다.
자원 정리 중에 예외가 발생하는 문제
finally 코드 블록은 항상 호출되기 때문에 자원이 잘 정리되는 것 같지만, 이번에는 자원을 정리하는 중에 finally 코드 블록 안에서 resource2.closeEx()를 호출하면서 예외가 발생한다. 결과적으로 resource1.close()는 호출되지 않는다.
핵심 예외가 바뀌는 문제
이 코드에서 발생한 핵심적인 예외는 CallException이다. 이 예외 때문에 문제가 된 것이다. 그런데 finally 코드 블록에서 자원을 정리하면서 CloseException 예외가 추가로 발생했다. 예외 때문에 자원을 정리하고 있는데, 자원 정리중에 또 예외가 발생한 것이다. 이 경우 logic()을 호출하는 쪽에서는 핵심 예외인 CallException이 아니라 finally 블록에서 새로 생성된 CloseException을 받게 된다. 핵심 예외가 사라진 것이다! 개발자가 원하는 예외는 당연히 핵심 예외다. 이 핵심 예외를 확인해야 제대로 된 문제를 찾을 수 있다. 자원을 닫는 중에 발생한 예외는 부가 예외일 뿐이다.
정리하면 이 코드는 다음과 같은 문제가 있다.
close() 시점에서 실수로 예외를 던지면, 이후 다른 자원을 닫을 수 없는 문제 발생
finally 블럭 안에서 자원을 닫을 때 예외가 발생하면, 핵심 예외가 finally에서 발생한 부가 예외로 바뀌어 버린다. 그리고 핵심 예외가 사라진다.
자원 정리3
이번에는 자원 정리의 코드에서 try-catch를 사용해서 자원 정리 중에 발생하는 예외를 잡아서 처리해보자.
finally 블럭에서 각각의 자원을 닫을 때도, 예외가 발생하면 예외를 잡아서 처리하도록 했다.
이렇게 하면 자원 정리 시점에 예외가 발생해도, 다음 자원을 닫을 수 있다.
자원 정리 시점에 발생한 예외는 잡아서 처리했기 때문에, 자원 정리 시점에 발생한 부가 예외가 핵심 예외를 가리지 않는다.
자원 정리 시점에 발생한 예외는 당장 더 처리할 수 있는 부분이 없다. 이 경우 로그를 남겨서 개발자가 인지할 수 있게 하는 정도면 충분하다.
실행 결과
resource1 call
resource2 callEx
ex cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
자원 정리
resource2 closeEx
close ex: cwchoiit.network.tcp.autocloseable.CloseException: resource2 ex
resource1 closeEx
close ex2: cwchoiit.network.tcp.autocloseable.CloseException: resource1 ex
Call Exception 예외 처리
Exception in thread "main" java.lang.RuntimeException: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV3.main(ResourceCloseMainV3.java:9)
Caused by: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceV1.callEx(ResourceV1.java:16)
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV3.logic(ResourceCloseMainV3.java:25)
Caused by: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV3.main(ResourceCloseMainV3.java:6)
이전에 발생했던 2가지 문제를 해결했다.
close() 시점에 예외가 발생하면, 이후 다른 자원을 닫을 수 없는 문제 발생
finally 블럭 안에서 자원을 닫을 때 예외가 발생하면, 핵심 예외가 finally에서 발생한 부가 예외로 바뀌어 버린다. 그리고 핵심 예외가 사라지는 현상
핵심적인 문제들은 해결되었지만, 코드 부분에서 보면 아쉬운 부분이 많다.
resource 변수를 선언하면서 동시에 할당할 수 없음(try, finally 코드 블록과 변수 스코프가 다른 문제)
catch 이후에 finally 호출, 자원 정리가 조금 늦어진다.
개발자가 실수로 close()를 호출하지 않을 가능성
개발자가 close() 호출 순서를 실수(보통 자원을 생성한 순서와 반대로 닫아야 하는데 순서를 실수할 경우)
지금까지 수많은 자바 개발자들이 자원 정리 때문에 고통을 받아왔다. 이런 문제를 한번에 해결하는 것이 바로 자바 중급1편에서 학습한 try-with-resource 구문이다.
자원 정리4
이 try-with-resource를 사용해서 자원 정리를 효율적으로 해보자.
ResourceV2
package cwchoiit.network.tcp.autocloseable;
public class ResourceV2 implements AutoCloseable {
private final String name;
public ResourceV2(String name) {
this.name = name;
}
public void call() {
System.out.println(name + " call");
}
public void callEx() throws CallException {
System.out.println(name + " callEx");
throw new CallException(name + " ex");
}
@Override
public void close() throws CloseException {
System.out.println(name + " close");
throw new CloseException(name + " ex");
}
}
AutoCloseable을 구현했다.
close()는 항상 CloseException을 던지도록 구현했다. (AutoCloseable을 구현한 객체가 close()가 호출될 때 예외가 발생하면 어떻게 행동하는지 알아보기 위함)
resource1 call
resource2 callEx
resource2 close
resource1 close
ex: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
Call Exception 예외 처리
Exception in thread "main" java.lang.RuntimeException: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV4.main(ResourceCloseMainV4.java:9)
Caused by: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceV2.callEx(ResourceV2.java:16)
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV4.logic(ResourceCloseMainV4.java:21)
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV4.main(ResourceCloseMainV4.java:6)
Suppressed: cwchoiit.network.tcp.autocloseable.CloseException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceV2.close(ResourceV2.java:22)
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV4.logic(ResourceCloseMainV4.java:17)
... 1 more
Suppressed: cwchoiit.network.tcp.autocloseable.CloseException: resource1 ex
Caused by: cwchoiit.network.tcp.autocloseable.CallException: resource2 ex
at cwchoiit.network.tcp.autocloseable.ResourceV2.close(ResourceV2.java:22)
at cwchoiit.network.tcp.autocloseable.ResourceCloseMainV4.logic(ResourceCloseMainV4.java:17)
... 1 more
try-with-resource는 단순하게 close()를 자동 호출해준다는 정도의 기능만 제공하는 것이 아니다. 고민한 6가지 문제를 모두 해결하는 장치이다.
2가지 핵심 문제
close() 시점에 예외가 던져지면, 이후 다른 자원을 닫을 수 없는 문제 발생
finally 블록 안에서 자원을 닫을 때 예외가 발생하면, 핵심 예외가 finally에서 발생한 부가 예외로 바뀌어 버린다. 그리고 핵심 예외가 사라진다.
4가지 부가 문제
resource 변수를 선언하면서 동시에 할당할 수 없음 (try, finally 코드 블록과 변수 스코프가 다른 문제)
catch 이후에 finally 호출, 자원 정리가 조금 늦어진다.
개발자가 실수로 close()를 호출하지 않을 가능성
개발자가 close() 호출 순서를 실수 (보통 자원을 생성한 순서와 반대로 닫아야 한다)
try-with-resource의 장점
리소스 누수 방지: 모든 리소스가 제대로 닫히도록 보장한다. 실수로 finally 블록을 적지 않거나, finally 블록 안에서 자원 해제 코드를 누락하는 문제들을 예방할 수 있다.
코드 간결성 및 가독성 향상: 명시적인 close() 호출이 필요 없어, 코드가 더 간결하고 읽기 쉬워진다.
스코프 범위 한정: 예를 들어, 리소스로 사용되는 resource1, 2 변수의 스코프가 try 블록 안으로 한정된다. 따라서 코드 유지보수가 더 쉬워진다.
조금 더 빠른 자원 해제: 기존에는 try → catch → finally로 catch 이후에 자원을 반납했다. try-with-resource 구문은 try 블럭이 끝나면 즉시 close()를 호출한다.
자원 정리 순서: 먼저 선언한 자원을 나중에 정리한다.
부가 예외 포함: 이 내용은 무엇이냐면, 아까 ResourceV2 만들때, AutoCloseable을 구현하게 했고, close() 메서드를 오버라이딩했다. 그리고 그 메서드 안에서 예외를 던지도록 코드를 수정했었다. 그럼 try-with-resource 구문에서 자원이 해제될 때, 해당 예외가 발생할테니까 이 try-with-resource도 과연 핵심 예외가 부가 예외로 변경되어 밖으로 던져질지가 궁금했다. 근데 그게 아니라 정확히 핵심 예외가 밖으로 던져졌고, 자원을 해제하는 중에 터진 예외는 Suppressed로 추가된다. 아래 더 자세히 설명했다.
try-with-resources 예외 처리와 부가 예외 포함
try-with-resources를 사용하는 중에 핵심 로직 예외와 자원을 정리하는 중에 발생하는 부가 예외가 모두 발생하면 어떻게 될까?
try-with-resources는 핵심 예외를 반환한다.
부가 예외는 핵심 예외안에 Suppressed로 담아서 반환한다.
개발자는 자원 정리 중에 발생한 부가 예외를 e.getSuppressed()를 통해 활용할 수 있다.
try-with-resources를 사용하면 핵심 예외를 반환하면서, 동시에 부가 예외도 필요하면 확인할 수 있다.
참고로 자바 예외는 e.addSuppressed(ex)라는 메서드가 있어서 예외 안에 참고할 예외를 담아둘 수 있다. 참고로 이 기능도 try-with-resources와 함께 등장했다.
File은 파일과 디렉토리를 둘 다 다룬다. 참고로 File 객체를 생성했다고 파일이나 디렉토리가 바로 만들어지는 것은 아니다. 메서드를 통해 생성해야 한다. File과 같은 클래스들은 학습해야 할 중요한 원리가 있는 것이 아니라, 다양한 기능의 모음을 제공한다. 이런 클래스의 기능들은 외우기 보다는 이런 것들이 있다 정도만 간단히 알아두고, 필요할 때 찾아서 사용하면 된다.
Files
File, Files의 역사
자바 1.0에서 File 클래스가 등장했다. 이후에 자바 1.7에서 File 클래스를 대체할 Files와 Path가 등장했다.
Files의 특징
성능과 편의성이 모두 개선되었다.
File은 과거에 호환을 유지하기 위해 남겨둔 기능이다. 이제는 Files 사용을 먼저 고려하자.
여기에는 수많은 유틸리티 기능이 있다. File 클래스는 물론이고, File과 관련된 스트림(FileInputStream, FileWriter)의 사용을 고민하기 전에 Files에 있는 기능을 먼저 찾아보자. 성능도 좋고, 사용하기도 더 편리하다.
기능이 너무 많기 때문에 주요 기능만 알아보고, 나머지는 필요할 때 검색하자.
이렇게 기능 위주의 클래스는 외우는 것이 아니다. 이런게 있다 정도의 주요 기능만 알아두고, 나머지는 필요할 때 검색하면 된다.
앞서 작성한 코드를 Files로 그대로 사용해보자. 참고로 Files를 사용할 때 파일이나, 디렉토리의 경로는 Path 클래스를 사용해야 한다.
package cwchoiit.io.file;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
public class NewFilesMain {
public static void main(String[] args) throws IOException {
Path file = Path.of("temp/example.txt");
Path directory = Path.of("temp/exampleDir");
// 1. exists(): 파일이나 디렉토리의 존재 여부를 확인
System.out.println("File exists: " + Files.exists(file));
// 2. createFile(): 새 파일을 생성
try {
Files.createFile(file);
System.out.println("File created");
} catch (FileAlreadyExistsException e) {
System.out.println(file + " already exists");
} catch (IOException e) {
throw new RuntimeException(e);
}
// 3. createDirectory(): 새 디렉토리를 생성
try {
Files.createDirectory(directory);
System.out.println("Directory created");
} catch (FileAlreadyExistsException e) {
System.out.println(directory + " already exists");
} catch (IOException e) {
throw new RuntimeException(e);
}
// 4. delete(): 파일이나 디렉토리를 삭제
/*try {
Files.delete(file);
System.out.println("File deleted");
} catch (IOException e) {
throw new RuntimeException(e);
}*/
// 5. isRegularFile(): 일반 파일인지 확인
System.out.println("is regular file: " + Files.isRegularFile(file));
// 6. isDirectory(): 디렉토리인지 확인
System.out.println("is directory: " + Files.isDirectory(directory));
// 7. getFileName(): 파일이나 디렉토리의 이름을 반환
System.out.println("File name: " + file.getFileName());
// 8. size(): 파일의 크기를 바이트 단위로 반환
System.out.println("File size: " + Files.size(file) + " bytes");
// 9. move(): 파일의 이름을 변경하거나 이동
Path newFile = Path.of("temp/newExample.txt");
Files.move(file, newFile, StandardCopyOption.REPLACE_EXISTING);
System.out.println("File moved");
// 10. getLastModifiedTime(): 마지막으로 수정된 시간을 반환
System.out.println("Last modified: " + Files.getLastModifiedTime(newFile));
// 추가: readAttributes(): 파일의 기본 속성들을 한 번에 읽기
BasicFileAttributes attrs = Files.readAttributes(newFile, BasicFileAttributes.class);
System.out.println("========= Attributes ========");
System.out.println("Creation time: " + attrs.creationTime());
System.out.println("Is directory: " + attrs.isDirectory());
System.out.println("Is regular file: " + attrs.isRegularFile());
System.out.println("Is symbolic link: " + attrs.isSymbolicLink());
System.out.println("Size: " + attrs.size());
}
}
Files는 직접 생성할 수 없고, static 메서드를 통해 기능을 제공한다.
실행 결과
File exists: false
File created
temp/exampleDir Directory already exists
Is regular file: true
Is directory: true
File name: example.txt
File size: 0 bytes
File moved/renamed
Last modified: 2024-09-03T06:32:09.182412076Z
===== Attributes =====
Creation time: 2024-09-03T06:32:09Z
Is directory: false
Is regular io.file: true
Is symbolic link: false
Size: 0
경로 표시
파일이나 디렉토리가 있는 경로는 크게 절대 경로와 정규 경로로 나눌 수 있다.
File을 사용한 경로 표시
package cwchoiit.io.file;
import java.io.File;
import java.io.IOException;
public class OldFilePath {
public static void main(String[] args) throws IOException {
// 앞에 아무것도 없고 "temp/.." 이렇게 되면 자바가 시작되는 지점부터다. (상대 경로)
File file = new File("temp/..");
System.out.println("path: " + file.getPath());
// 절대 경로
System.out.println("Absolute path: " + file.getAbsolutePath());
// 정규 경로 ("temp/.."에서 ..을 다 계산한 결과를 정규 경로라 한다)
System.out.println("Canonical path: " + file.getCanonicalPath());
File[] files = file.listFiles();
for (File f : files) {
System.out.println((f.isDirectory() ? "D" : "F") + " | " + f.getName());
}
}
}
실행 결과
path = temp/..
Absolute path = /Users/yh/study/inflearn/java/java-adv2/temp/..
Canonical path = /Users/yh/study/inflearn/java/java-adv2
D | temp
F | java-adv2-v1.iml
D | out
F | .gitignore
D | .idea
D | src
절대 경로(Absolute Path): 절대 경로는 경로의 처음부터 내가 입력한 모든 경로를 다 표현한다.
정규 경로(Canonical Path): 경로의 계산이 모두 끝난 경로이다. 정규 경로는 하나만 존재한다. 예제에서 `..`은 바로 위의 상위 디렉토리를 뜻한다. 이런 경로의 계산을 모두 처리하면 하나의 경로만 남는다.
path = temp/..
Absolute path = /Users/yh/study/inflearn/java/java-adv2/temp/..
Canonical path = /Users/yh/study/inflearn/java/java-adv2
D | temp
F | java-adv2-v1.iml
D | out
F | .gitignore
D | .idea
D | src
Files.list(path)
현재 경로에 있는 모든 파일 또는 디렉토리를 반환한다.
파일이면 F, 디렉토리면 D로 표현했다.
Files로 문자 파일 읽기
문자로된 파일을 읽고 쓸 때 과거에는 FileReader, FileWriter 같은 복잡한 스트림 클래스를 사용해야 했다. 거기에 모든 문자를 읽으려면 반복문을 사용해서 파일의 끝까지 읽어야 하는 과정을 추가해야 했다. 또 한 줄 단위로 파일을 읽으려면 BufferedReader와 같은 스트림 클래스를 추가해야 했다.
Files.readAllLines(path) → 파일을 한번에 다 읽고, 라인 단위로 List에 나누어 저장하고 반환한다.
근데 이런 경우, 모든 라인을 일단 다 읽어서 리스트에 저장하기 때문에 파일이 엄청 크면 메모리를 매우 많이 차지하거나, 아예 OOM이 터질수도 있다. 그래서 파일을 한 줄 단위로 나누어 읽고, 메모리 사용량을 줄이고 싶다면 다음 기능을 사용하면 된다. 다만, 이 기능을 제대로 이해하려면, 람다와 스트림을 알아야 한다.
이전 포스팅에서 여러가지 스트림을 배워봤다. 그리고 마지막 즈음에 말했던 DataStream은 파일에 자바 타입 그대로를 저장할 수 있는 꽤나 신기하지만? 어디에 이게 활용이 될까?싶은 스트림이 있었다. 이 포스팅에서는 이 녀석의 활용처를 알아보고자 한다.
일단 그러기에 앞서, 예제를 만들고 왜 이걸 사용하면 좋은지를 먼저 알아보자.
회원 관리 예제1 - 메모리
I/O를 사용해서 회원 데이터를 관리하는 예제를 만들어보자.
요구사항
회원 관리 프로그램을 작성해라.
회원의 속성은 다음과 같다
ID
Name
Age
회원을 등록하고, 등록한 회원의 목록을 조회할 수 있어야 한다.
프로그램 작동 예시
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 1
ID 입력: id1
Name 입력: name1
Age 입력: 20
회원이 성공적으로 등록되었습니다.
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 1
ID 입력: id2
Name 입력: name2
Age 입력: 30
회원이 성공적으로 등록되었습니다.
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 2
회원 목록:
[ID: id1, Name: name1, Age: 20]
[ID: id2, Name: name2, Age: 30]
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 3
프로그램을 종료합니다.
Member
package cwchoiit.io.member;
public class Member {
private String id;
private String name;
private Integer age;
public Member() {
}
public Member(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 "Member{" +
"id='" + id + '\'' +
", name='" + name + '\'' +
", age=" + age +
'}';
}
}
package cwchoiit.io.member.impl;
import cwchoiit.io.member.Member;
import cwchoiit.io.member.MemberRepository;
import java.util.ArrayList;
import java.util.List;
public class MemoryMemberRepository implements MemberRepository {
private final List<Member> members = new ArrayList<>();
@Override
public void add(Member member) {
members.add(member);
}
@Override
public List<Member> findAll() {
return members;
}
}
간단하게 메모리에 회원을 저장하고 관리하자.
회원을 저장하면 내부에 있는 members 리스트에 회원이 저장된다.
회원을 조회하면 members 리스트가 반환된다.
MemberConsoleMain
package cwchoiit.io.member;
import cwchoiit.io.member.impl.FileMemberRepository;
import cwchoiit.io.member.impl.MemoryMemberRepository;
import java.util.List;
import java.util.Scanner;
public class MemberConsoleMain {
// private static final MemberRepository repository = new MemoryMemberRepository();
private static final MemberRepository repository = new FileMemberRepository();
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("1. 회원 등록 | 2. 회원 목록 조회 | 3. 종료");
System.out.print("선택: ");
int choice = scanner.nextInt();
scanner.nextLine();
switch (choice) {
case 1:
// 회원 등록
registerMember(scanner);
break;
case 2:
// 회원 목록 조회
displayMembers();
break;
case 3:
System.out.println("프로그램을 종료합니다.");
return;
default:
System.out.println("잘못된 선택입니다. 다시 입력하세요.");
}
}
}
private static void registerMember(Scanner scanner) {
System.out.print("ID 입력:");
String id = scanner.nextLine();
System.out.print("Name 입력:");
String name = scanner.nextLine();
System.out.print("Age 입력:");
int age = scanner.nextInt();
scanner.nextLine();
Member member = new Member(id, name, age);
repository.add(member);
System.out.println("회원이 성공적으로 등록되었습니다.");
}
private static void displayMembers() {
List<Member> members = repository.findAll();
System.out.println("회원 목록");
for (Member member : members) {
System.out.printf("[ID: %s, Name: %s, Age: %d]\n", member.getId(), member.getName(), member.getAge());
}
}
}
콘솔을 통해 회원 등록, 목록 조회 기능을 제공한다.
실행 결과 - 회원 등록
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 1
ID 입력: id1
Name 입력: name1
Age 입력: 20
회원이 성공적으로 등록되었습니다.
실행 결과 - 목록 조회
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 2
회원 목록:
[ID: id1, Name: name1, Age: 20]
실행 결과 - 종료
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 3
프로그램을 종료합니다.
문제점
의도대로 잘 구현이 됐다. 그런데 이 프로그램의 문제가 있다. 어떤거냐면, 데이터를 메모리에 보관하기 때문에, 자바를 종료하면 모든 회원 종료가 사라진다. 따라서 프로그램을 다시 실행하면 모든 회원 데이터가 사라진다. 프로그램을 종료하고 다시 실행해도 회원 데이터가 영구 보존되어야 한다.
회원 관리 예제2 - 파일에 보관
회원 데이터를 영구 보존하려면 파일에 저장하면 된다. 다음과 같이 한 줄 단위로 회원 데이터를 파일에 저장해보자.
temp/members-txt.dat
id1,member1,20
id2,member2,30
여기서는 문자를 파일에 저장한다. 문자를 다루므로 Reader, Writer를 사용하는 것이 편리하다.
한 줄 단위로 처리할 때는 BufferedReader가 유용하므로, BufferedReader, BufferedWriter를 사용해보자.
FileMemberRepository
package cwchoiit.io.member.impl;
import cwchoiit.io.member.Member;
import cwchoiit.io.member.MemberRepository;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
import static java.nio.charset.StandardCharsets.UTF_8;
public class FileMemberRepository implements MemberRepository {
private static final String FILE_PATH = "temp/members-txt.dat";
private static final String DELIMITER = ",";
@Override
public void add(Member member) {
try (BufferedWriter bw = new BufferedWriter(new FileWriter(FILE_PATH, UTF_8, true))) {
bw.write(member.getId() + DELIMITER + member.getName() + DELIMITER + member.getAge());
bw.newLine();
} catch (IOException e) {
throw new RuntimeException("Fail to write member data to file.", e);
}
}
@Override
public List<Member> findAll() {
List<Member> members = new ArrayList<>();
try (BufferedReader br = new BufferedReader(new FileReader(FILE_PATH, UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
String[] memberData = line.split(DELIMITER);
members.add(new Member(memberData[0], memberData[1], Integer.valueOf(memberData[2])));
}
return members;
} catch (FileNotFoundException e) {
return new ArrayList<>();
} catch (IOException e) {
throw new RuntimeException("Fail to read member data from file.", e);
}
}
}
MemberRepository 인터페이스가 잘 정의되어 있으므로, 인터페이스를 기반으로 파일에 회원 데이터를 보관하는 구현체를 만들면 된다.
DELIMITER: 회원 데이터는 id1,member1,20과 같이 ,(쉼표)로 구분한다.
참고: 빈 컬렉션 반환
빈 컬렉션을 반환할 때는 new ArrayList()보다는 List.of()를 사용하는 것이 좋다. 그런데, 뒤에서 사용할 ObjectStream 부분과 내용을 맞추기 위해 빈 컬렉션에 new ArrayList()를 사용했다.
회원 객체의 데이터를 읽어서, String 문자로 변환한다. 여기서 write()는 String으로 입력받는다. 그리고 DELIMITER를 구분자로 사용한다.
bw.write("id1,member1,20")를 통해 저장할 문자가 전달된다.
회원 데이터를 문자로 변경한 다음에 파일에 보관한 것이다.
각 회원을 구분하기 위해 newLine()을 통해 다음 줄로 이동한다.
회원 조회
line = br.readLine() → line = "id1,member1,20"과 같이 하나의 회원 정보가 담긴 한 줄 문자가 입력된다.
String[] memberData = line.split(DELIMITER) → 회원 데이터를 DELIMITER 구분자로 구분해서 배열에 담는다.
members.add(new Member(memberData[0], memberData[1], Integer.valueOf(memberData[2])) → 파일에 읽은 데이터를 기반으로 회원 객체를 생성한다. id, name은 String이기 때문에 타입이 같다. age의 경우 문자 20으로 조회했기 때문에 숫자인 Integer로 변경해야 한다.
FileNotFoundException e → 회원 데이터가 하나도 없을 때는 temp/members-txt.dat 파일이 존재하지 않는다. 따라서 해당 예외가 발생하기 때문에 이 경우, 회원 데이터가 하나도 없는 것으로 보고 빈 리스트를 반환한다.
try-with-resources
try-with-resources 구문을 사용해서 자동으로 자원을 정리한다. try코드 블록이 끝나면 자동으로 close()가 호출되면서 자원을 정리한다.
try (BufferedWriter bw = new BufferedWriter(new FileWriter(FILE_PATH, UTF_8, true))) {...}
try (BufferedReader br = new BufferedReader(new FileReader(FILE_PATH, UTF_8))) {...}
MemberConsoleMain - FileMemberRepository 사용
package cwchoiit.io.member;
import cwchoiit.io.member.impl.FileMemberRepository;
import cwchoiit.io.member.impl.MemoryMemberRepository;
import java.util.List;
import java.util.Scanner;
public class MemberConsoleMain {
// private static final MemberRepository repository = new MemoryMemberRepository();
private static final MemberRepository repository = new FileMemberRepository();
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("1. 회원 등록 | 2. 회원 목록 조회 | 3. 종료");
System.out.print("선택: ");
int choice = scanner.nextInt();
scanner.nextLine();
switch (choice) {
case 1:
// 회원 등록
registerMember(scanner);
break;
case 2:
// 회원 목록 조회
displayMembers();
break;
case 3:
System.out.println("프로그램을 종료합니다.");
return;
default:
System.out.println("잘못된 선택입니다. 다시 입력하세요.");
}
}
}
private static void registerMember(Scanner scanner) {
System.out.print("ID 입력:");
String id = scanner.nextLine();
System.out.print("Name 입력:");
String name = scanner.nextLine();
System.out.print("Age 입력:");
int age = scanner.nextInt();
scanner.nextLine();
Member member = new Member(id, name, age);
repository.add(member);
System.out.println("회원이 성공적으로 등록되었습니다.");
}
private static void displayMembers() {
List<Member> members = repository.findAll();
System.out.println("회원 목록");
for (Member member : members) {
System.out.printf("[ID: %s, Name: %s, Age: %d]\n", member.getId(), member.getName(), member.getAge());
}
}
}
MemoryMemberRepository 대신에 FileMemberRepository를 사용하도록 코드를 수정하자.
MemberRepository 인터페이스를 사용한 덕분에 구현체가 변경되더라도 클라이언트의 다른 코드들은 변경하지 않아도 된다.
실행 결과
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 1
ID 입력: id1
Name 입력: name1
Age 입력: 20
회원이 성공적으로 등록되었습니다.
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 1
ID 입력: id2
Name 입력: name2
Age 입력: 30
회원이 성공적으로 등록되었습니다.
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 2
회원 목록:
[ID: id1, Name: name1, Age: 20]
[ID: id2, Name: name2, Age: 30]
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 3
프로그램을 종료합니다.
결과 - temp/members-txt.dat
id1,member1,20
id2,member2,30
문제점
파일에 회원 데이터를 저장한 덕분에, 자바를 다시 실행해도 저장한 회원이 잘 조회된다. 위 문제를 해결한 것이다.
그러나 아직도 남은 문제가 있다. 결국 파일에서 읽어오는 값은 문자열이기 때문에 멤버 객체에 나이를 저장할 때 문자를 숫자로 변환해줘야 한다. 예) Integer.valueOf(memberData[2])
이런 문제를 해결할 수 있는 좋은 방법은 DataStream을 사용하는 것이다.
회원 관리 예제3 - DataStream
앞서 배운 예시 중에 DataOutputStream, DataInputStream을 떠올려보자. 이 스트림들은 자바의 데이터 타입을 그대로 사용할 수 있다. 따라서 자바의 타입을 그대로 사용하면서 파일에 데이터를 저장하고 불러올 수 있고, 구분자도 사용하지 않아도 된다.
DataMemberRepository
package cwchoiit.io.member.impl;
import cwchoiit.io.member.Member;
import cwchoiit.io.member.MemberRepository;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class DataMemberRepository implements MemberRepository {
private static final String FILE_PATH = "temp/members-data.dat";
@Override
public void add(Member member) {
try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(FILE_PATH, true))) {
dos.writeUTF(member.getId());
dos.writeUTF(member.getName());
dos.writeInt(member.getAge());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public List<Member> findAll() {
List<Member> members = new ArrayList<>();
try (DataInputStream dis = new DataInputStream(new FileInputStream(FILE_PATH))) {
while (dis.available() > 0) {
Member member = new Member(dis.readUTF(), dis.readUTF(), dis.readInt());
members.add(member);
}
return members;
} catch (FileNotFoundException e) {
return new ArrayList<>();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
Member member = new Member(dis.readUTF(), dis.readUTF(), dis.readInt());
회원 데이터를 조회할 때는 회원 필드의 각 타입에 맞는 메서드를 사용해서 조회하면 된다.
MemberConsoleMain 수정 - DataMemberRepository 사용
public class MemberConsoleMain {
// private static final MemberRepository repository = new MemoryMemberRepository();
// private static final MemberRepository repository = new FileMemberRepository();
private static final MemberRepository repository = new DataMemberRepository();
...
}
실행 결과는 파일이 잘 보관되고, 파일에는 문자와 byte가 섞여있다. 그래서 파일을 사람이 직접 읽기는 힘들지만, 자바에서 데이터를 타입에 맞게 가져오기엔 아주 최적화되어 있다.
DataStream 원리
근데 궁금하다. 어떤 원리로 구분자나 한 줄 라인 없이 데이터를 저장하고 조회할 수 있을까?
그럼 분명 파일에는 `id1member1` 이렇게 저장이 될텐데, 어떻게 이후에 readUTF()를 호출하면 딱 id1이라는 3글자만 정확히 가져올 수 있을까? 사실은 writeUTF()는 UTF-8 형식으로 문자를 저장하는데, 저장할 때 2byte를 추가로 사용해서 앞에 글자의 길이를 저장해둔다. 그래서 실제로는 이렇게 저장이 된다.
3id1 (2byte(문자 길이) + 3byte(실제 문자 데이터))
따라서, readUTF()로 읽어들일 때 먼저 앞의 2byte로 글자의 길이를 확인하고 해당 길이만큼 글자를 읽어들인다.
이 경우 2byte를 사용해서 3이라는 문자의 길이를 숫자로 보관하고, 나머지 3byte로 실제 문자 데이터를 보관한다.
기타 타입
dos.writeInt(20);
dis.readInt();
자바의 int(Integer)는 4byte를 사용하기 때문에, 4byte를 사용해서 파일을 저장하고, 읽을 때도 4byte를 읽어서 복원한다.
이해를 돕기 위해 각 필드를 엔터로 구분했지만, 실제로는 엔터 없이 한 줄로 연결되어 있다.
저장된 파일은 실제로는 문자와 byte가 섞여있다.
정리
DataStream덕분에 자바의 타입도 그대로 사용하고, 구분자도 제거할 수 있었다. 추가로 모든 데이터를 문자로 저장할 때보다 저장 용량도 더 최적화할 수 있다. 예를 들어, 숫자 1,000,000,000(10억)을 문자로 저장하게 되면 총 10byte가 사용된다. 왜냐하면 숫자 하나하나를 문자로 저장해야 하기 때문에 ASCII 인코딩을 해도 각각 1byte가 사용된다. 하지만 이것을 자바의 int와 같이 4byte를 사용해서 저장한다면 4byte만 사용하게 된다. 여기서는 writeInt()를 사용하면 4byte를 사용해서 저장한다. 물론 이렇게 byte를 직접 저장하면, 문서 파일을 열어서 확인하고 수정하는 것이 어렵다는 단점도 있지만, 이러한 장점도 있다.
문제점
DataStream 덕분에 회원 데이터를 더 편리하게 저장할 수 있는것은 맞지만, 회원의 필드를 하나하나 다 조회해서 각 타입에 맞도록 따로따로 저장해야 한다. 이건 회원 객체를 저장한다기 보다는 회원 데이터 하나하나를 분류해서 따로 저장한 것이다.
package cwchoiit.io.member.impl;
import cwchoiit.io.member.Member;
import cwchoiit.io.member.MemberRepository;
import java.util.ArrayList;
import java.util.List;
public class MemoryMemberRepository implements MemberRepository {
private final List<Member> members = new ArrayList<>();
@Override
public void add(Member member) {
members.add(member);
}
@Override
public List<Member> findAll() {
return members;
}
}
자바 컬렉션에 회원 객체를 저장할 때는 복잡하게 회원의 필드를 하나하나 꺼내서 저장할 필요가 없었다. 단순하게 회원 객체를 그대로 자바 컬렉션에 보관하면 된다. 조회할 때도 마찬가지다. 이렇게 편리하게 회원 객체를 저장할 수 있는 방법은 없을까?
회원 관리 예제4 - ObjectStream
회원 인스턴스도 생각해보면 메모리 어딘가에 보관되어 있다. 이렇게 메모리에 보관되어 있는 객체를 읽어서 파일에 저장하기만 하면 아주 간단하게 회원 인스턴스를 저장할 수 있을 것 같다. ObjectStream을 사용하면 이렇게 메모리에 보관되어 있는 회원 인스턴스를 파일에 편리하게 저장할 수 있다. 마치 자바 컬렉션에 회원 객체를 보관하듯 말이다.
객체 직렬화
자바 객체 직렬화(Serialization)는 메모리에 있는 객체 인스턴스를 바이트 스트림으로 변환하여 파일에 저장하거나 네트워크를 통해 전송할 수 있도록 하는 기능이다. 이 과정에서 객체의 상태를 유지하여 나중에 역직렬화(Deserialization)를 통해 원래의 객체로 복원할 수 있다. 객체 직렬화를 사용하려면 직렬화하려는 클래스는 반드시 Serializable 인터페이스를 구현해야 한다.
package java.io;
public interface Serializable {
}
이 인터페이스는 아무런 기능이 없다. 단지 직렬화 가능한 클래스라는 것을 표시하기 위한 인터페이스일 뿐이다.
메서드 없이 단지 표시가 목적인 인터페이스를 마커 인터페이스라 한다.
Member - Serializable 추가
public class Member implements Serializable {
private String id;
private String name;
private Integer age;
public Member() {
}
public Member(String id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
...
}
package cwchoiit.io.member.impl;
import cwchoiit.io.member.Member;
import cwchoiit.io.member.MemberRepository;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class ObjectMemberRepository implements MemberRepository {
private static final String FILE_PATH = "temp/members-obj.dat";
@Override
public void add(Member member) {
List<Member> members = findAll();
members.add(member);
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_PATH))) {
oos.writeObject(members);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public List<Member> findAll() {
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_PATH))) {
Object findObject = ois.readObject();
return (List<Member>) findObject;
} catch (FileNotFoundException e) {
return new ArrayList<>();
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
}
ObjectOutputStream을 사용하면, 객체 인스턴스를 직렬화해서 byte로 변경할 수 있다.
우리는 회원 객체 하나가 아니라 회원 목록 전체를 파일에 저장해야 하므로, members 컬렉션을 직렬화해야 한다.
oos.writeObject(members)를 호출하면 members 컬렉션과 그 안에 포함된 Member를 모두 직렬화해서 byte로 변경한다. 그리고 oos와 연결되어 있는 FileOutputStream에 결과를 출력한다.
참고로, ArrayList도 java.io.Serializable을 구현하고 있어서 직렬화할 수 있다.
ObjectInputStream을 사용하면, byte를 역직렬화해서 객체 인스턴스로 만들 수 있다. ois.readObject()를 사용하면 역직렬화가 된다. 이때 반환 타입이 Object이므로 캐스팅해서 사용해야 한다.
그리고, 파일이 없는 경우 FileNotFoundException이 발생하기 때문에 빈 배열을 반환해줘야 하는데 이 경우 List.of()가 더 효율이 좋다. Immutable이기 때문에 ArrayList와 달리 가변 메모리를 사용하지 않기 때문에 메모리 최적화가 가능하기 때문이다. 그러나 여기서는 new ArrayList<>();를 반환했다. 그 이유는 add() 메서드에서 findAll()을 통해 빈 배열을 받은 경우든 아니든 새로운 멤버를 추가해줘야 하는데 Immutable이면 리스트에 요소를 추가할 수 없기 때문이다.
MemberConsoleMain 수정 - ObjectMemberRepository 사용
public class MemberConsoleMain {
// private static final MemberRepository repository = new MemoryMemberRepository();
// private static final MemberRepository repository = new FileMemberRepository();
// private static final MemberRepository repository = new DataMemberRepository();
private static final MemberRepository repository = new ObjectMemberRepository();
...
}
실행 결과 - temp/members-obj.dat
파일이 정상 보관된다. 문자와 byte가 섞여 있다.
정리
객체 직렬화 덕분에 객체를 매우 편리하게 저장하고 불러올 수 있었다. 객체 직렬화를 사용하면 객체를 바이트로 변환할 수 있어, 모든 종류의 스트림에 전달할 수 있다. 이는 파일에 저장하는 것은 물론, 네트워크를 통해 객체를 전송하는 것도 가능하게 한다. 이러한 특성 때문에 초기에는 분산 시스템에서 활용되었다. 그러나 객체 직렬화는 1990년대에 등장한 기술로, 초창기에는 인기가 있었지만 시간이 지나면서 여러 단점이 드러났다. 또한 대안 기술이 등장하면서 점점 그 사용이 줄어들게 되었다. 현재는 객체 직렬화를 거의 사용하지 않는다.
그래서, 결국 지금까지의 흐름을 살펴보면, 처음에는 스트림을 사용해서 문자를 바이트로 변환해서 파일에 저장했고, 그 과정에서 파일에 있는 데이터를 문자로 변환했을 때 객체로 변환하려면 타입을 변환해주어야 하고 구분자를 통해 각 필드를 뽑아내야 하는 번거로움이 있었다. 그래서 FileWriter, FileReader의 불편함을 극복하고자, DataStream을 사용하게 됐다. 이 스트림은 자바의 데이터 타입을 그대로 사용해서 파일에 저장할 수 있었다. 그래서 파일의 데이터를 읽어올 때 그 데이터 타입 그대로로 가져오기 때문에 형변환과 같은 작업이나 구분자가 따로 필요하지 않아 더 편리했지만, 객체를 저장하는 느낌이 아니라 객체의 각각 필드를 하나씩 나누어 저장하는 느낌이 강했다. 그래서 이것을 해결하고자 ObjectStream을 사용하게 됐다. 객체 인스턴스 그 자체를 바이트로 변환할 수 있게 직렬화 가능한 객체로 변환하고 그 오브젝트를 그대로 바이트로 변환해서 파일에 집어넣을 수 있었다.
그런데, 위에서 직렬화는 이제 거의 사용하지 않는다고 했는데 그 이유는 무엇이고 그럼 어떤 방식을 사용할까?
XML, JSON, 데이터베이스
회원 객체와 같은 구조화 된 데이터를 컴퓨터 간에 서로 주고 받을 때 사용하는 데이터 형식이 어떻게 발전해왔는지 알아보자.
객체 직렬화의 한계
버전 관리의 어려움: 클래스 구조가 변경되면 이전에 직렬화된 객체와의 호환성 문제가 발생한다.
플랫폼 종속성: 자바 직렬화는 자바 플랫폼에 종속적이어서, 다른 언어나 시스템과의 상호 운용성이 떨어진다.
JSON은 가볍고 간결하며, 자바스크립트와의 자연스러운 호환성 때문에 웹 개발자들 사이에서 빠르게 확산되었다. 2000년대 후반, 웹 API와 Restful 서비스가 대중화되면서 JSON은 표준 데이터 교환 포맷으로 자리잡았다. XML이 특정 영역에서 여전히 사용되고는 있으나, JSON이 현대 소프트웨어 개발의 주류로 자리 잡았다. 지금은 웹 환경에서 데이터를 교환할 때 JSON이 사실상 표준이다.
데이터베이스
앞서 설명한 것처럼 회원 객체같은 구조화된 데이터를 주고받을 때는 JSON 형식을 주로 사용한다. 그러나 어떤 형식이든 데이터를 저장할 때, 파일에 데이터를 직접 저장하는 방식은 몇가지 큰 한계가 있다.
첫째, 데이터의 무결성을 보장하기 어렵다. → 여러 사용자가 동시에 파일을 수정하거나 접근하려고 할 때, 데이터의 충돌이나 손상 가능성이 높아진다. 이러한 경우, 데이터의 일관성을 유지하는 것이 매우 어렵다.
둘째, 데이터 검색과 관리의 비효율성이다. → 파일에 저장된 데이터는 특정 형식 없이 단순히 저장될 수 있기 때문에, 필요한 데이터를 빠르게 찾는데 많은 시간이 소요될 수 있다. 특히 데이터의 양이 방대해질수록 검색 속도는 급격히 저하된다.
셋째, 보안 문제이다. → 파일 기반 시스템에서는 민감한 데이터를 안전하게 보호하기 위한 접근 제어와 암호화등이 충분히 구현되어 있지 않을 수 있다. 결과적으로 데이터의 유출이나 무단 접근의 위험이 커질 수 있다.
String을 byte로 변환할때는 String.getBytes(Charset)을 사용하면 된다.
이때 문자를 byte 숫자로 변경해야 하기 때문에 반드시 문자 집합(인코딩 셋)을 지정해야 한다.
여기서는 UTF-8로 인코딩 한다.
ABC를 인코딩하면 65, 66, 67이 된다.
이렇게 만든 byte[]을 FileOutputStream에 write()로 전달하면 65, 66, 67을 파일에 저장할 수 있다. 결과적으로 우리가 의도한 ABC 문자를 파일에 저장할 수 있다.
String readString = new String(readBytes, UTF-8)
반대의 경우도 비슷하다. String 객체를 생성할 때, 읽어들인 byte[]과 디코딩할 문자 집합을 전달하면 된다.
그러면 byte[]을 String 문자로 다시 복원할 수 있다.
여기서 핵심은 스트림은 byte만 사용할 수 있으므로, String과 같은 문자는 직접 전달할 수 없다는 점이다. 그래서 개발자가 번거롭게 다음과 같은 변환 과정을 직접 호출해주어야 한다.
String + 문자 집합 → byte[]
byte[] + 문자 집합 → String
이렇게 번거로운 변환 과정을 누군가 대신 처리해주면 더 편리하지 않을까? 우리가 이전에 배운 BufferedXxx와 같이 누군가가 이러한 변환 기능을 대신 처리해주면 좋지 안을까?
문자 다루기2 - 스트림을 문자로
OutputStreamWriter → 스트림에 byte 대신에 문자를 저장할 수 있게 지원한다.
InputStreamReader → 스트림에 byte 대신에 문자를 읽을 수 있게 지원한다.
package cwchoiit.io.text;
import java.io.*;
import static cwchoiit.io.text.TextConst.FILE_NAME;
import static java.nio.charset.StandardCharsets.UTF_8;
public class ReaderWriterMainV2 {
public static void main(String[] args) throws IOException {
String writeString = "ABC";
System.out.println("writeString = " + writeString);
// 파일에 쓰기
FileOutputStream fos = new FileOutputStream(FILE_NAME);
OutputStreamWriter osw = new OutputStreamWriter(fos, UTF_8);
osw.write(writeString);
osw.close();
// 파일에서 읽기
FileInputStream fis = new FileInputStream(FILE_NAME);
InputStreamReader isr = new InputStreamReader(fis, UTF_8);
StringBuilder sb = new StringBuilder();
int ch;
while ((ch = isr.read()) != -1) {
sb.append((char) ch);
}
isr.close();
System.out.println("sb = " + sb);
}
}
실행 결과
write String: ABC
read String: ABC
OutputStreamWriter
OutputStreamWriter는 문자를 입력 받고, 받은 문자를 내부적으로 인코딩해서 byte[]로 변환한다.
OutputStreamWriter는 변환한 byte[]을 전달할 OutputStream과 인코딩 문자 집합에 대한 정보가 필요하다. 따라서 두 정보를 생성자를 통해 전달해야 한다. 예) new OutputStreamWriter(fos, UTF_8)
osw.write(writeString)를 보면 String 문자를 직접 전달하는 것을 확인할 수 있다.
그림을 보면 OutputStreamWriter가 문자 인코딩을 통해 byte[]로 변환하고, 변환 결과를 FileOutputStream에 전달하는 것을 확인할 수 있다.
InputStreamReader
데이터를 읽을 때는 int ch = read()를 제공하는데, 여기서는 문자 하나인 char 형으로 데이터를 받게 된다. 그런데 실제 반환 타입은 int형이므로 char형으로 캐스팅해서 사용하면 된다.
자바의 char 형은 파일의 끝인 -1을 표현할 수 없으므로, 대신 int를 반환한다.
그림을 보면 데이터를 읽을 때 FileInputStream에서 byte[]을 읽은 것을 확인할 수 있다.
InputStreamReader는 이렇게 읽은 byte[]을 문자인 char로 변경해서 반환한다. 물론 byte를 문자로 변경할 때도 문자 집합이 필요하다. 그래서 생성자로 문자를 읽을, InputStream과 디코딩 문자 집합에 대한 정보가 필요하다. 예) new InputStreamReader(fis, UTF_8)
OutputStreamWriter, InputStreamReader 덕분에 매우 편리하게 문자를 byte[]로 변경하고, 그 반대도 가능하다. 덕분에 개발자는 쉽게 String 문자를 파일에 저장할 수 있다. 앞서 우리가 스트림을 배울 때 분명 byte 단위로 데이터를 읽고 쓰는 것을 확인했다. write()의 경우에도 byte 단위로 데이터를 읽고 썼다. 최상위 부모인 OutputStream의 경우 분명 write()가 byte 단위로 입력하도록 되어 있다. 그런데 OutputStreamWriter의 write()는 byte가 아니라 String이나 char를 사용한다. 어떻게 된 것일까?
항상 기억해야 할 대원칙은 결국 문자든, 파일이든, 네트워크든 컴퓨터는 무조건 byte 단위로 데이터를 읽고 쓴다는 것이다. 컴퓨터는 절대 문자 자체를 파일에 바로 쓰고 읽지 못한다. 언제나 컴퓨터가 읽을 수 있는 byte 단위로 변환한 후 파일에 쓰고, 반대도 파일에서 읽으면 그 값을 byte 단위로 변환해야 한다. 그리고 이때 변환을 위한 문자 집합이 필요한 것이다.
문자 다루기3 - Reader, Writer
자바는 byte를 다루는 I/O 클래스와 문자를 다루는 I/O 클래스를 둘로 나누어두었다. 이게 바로 위에서 제시한 의문의 해소점이다. OutputStreamWriter, InputStreamReader는 byte 단위를 사용하지 않고, String이나 char를 다루고 있다.
byte를 다루는 클래스
문자를 다루는 클래스
byte를 다루는 클래스는 OutputStream, InputStream의 자식이다.
부모 클래스의 기본 기능도 byte 단위를 다룬다.
클래스 이름 마지막에 보통 OutputSream, InputStream이 붙어있다.
문자를 다루는 클래스는 Writer, Reader의 자식이다.
부모 클래스의 기본 기능은 String, char 같은 문자를 다룬다.
클래스 이름 마지막에 보통 Writer, Reader가 붙어있다.
우리가 방금 본 OutputStreamWriter는 바로 문자를 다루는 Writer 클래스의 자식이다. 그래서 write(String)이 가능한 것이다. OutputStreamWriter는 문자를 받아서 byte로 변경한 다음에 byte를 다루는 OutputStream으로 데이터를 전달했던 것이다. (당연한 거지만)
다시 말하지만, 꼭 기억해야 할 대원칙은, 모든 데이터는 byte 단위(숫자)로 저장된다. 따라서 Writer가 아무리 문자를 다룬다고 해도 문자를 바로 저장할 수는 없다. 이 클래스에 문자를 전달하면 결과적으로 내부에서는 지정된 문자 집합을 사용해서 문자를 byte로 인코딩해서 저장한다.
그리고, 이 OutputStreamWriter, InputStreamReader를 파일 대상으로 하는 편리한 클래스가 있다. 바로 FileWriter, FileReader. 왜 만들어졌냐? 자주 사용하니까.
FileWriter, FileReader
Writer, Reader를 사용하는 아주 대표적인 예시다.
package cwchoiit.io.text;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import static cwchoiit.io.text.TextConst.FILE_NAME;
import static java.nio.charset.StandardCharsets.UTF_8;
public class ReaderWriterMainV3 {
public static void main(String[] args) throws IOException {
String writeString = "ABC";
System.out.println("writeString = " + writeString);
FileWriter fw = new FileWriter(FILE_NAME, UTF_8);
fw.write(writeString);
fw.close();
StringBuilder sb = new StringBuilder();
FileReader fr = new FileReader(FILE_NAME, UTF_8);
int ch;
while ((ch = fr.read()) != -1) {
sb.append((char) ch);
}
fr.close();
System.out.println("sb = " + sb);
}
}
실행 결과
writeString = ABC
sb = ABC
new FileWriter(FILE_NAME, UTF_8)
FileWriter에 파일명과 문자 집합(인코딩 셋)을 전달한다.
FileWriter는 사실 내부에서 스스로 FileOutputStream을 하나 생성해서 사용한다.
모든 데이터는 byte 단위로 저장된다는 사실을 다시 떠올려보자.
fw.write(writeString)
이 메서드를 사용하면, 문자를 파일에 직접 쓸 수 있다. (물론 내부적으로는 byte로 변환하는 과정이 당연히 있다)
이렇게 문자를 쓰면 FileWriter 내부에서는 인코딩 셋을 사용해서 문자를 byte로 변경하고, FileOutputStream을 사용해서 파일에 저장한다.
개발자가 느끼기에는 문자를 직접 파일에 쓰는것처럼 느껴지지만, 실제로는 내부에서 문자를 byte로 변환한다. 사실 이게 포인트다. 사용하는 사람은 편하게 사용하면 된다.
new FileReader(FILE_NAME, UTF_8)
앞서 설명한 FileWriter와 같은 방식으로 작동한다.
내부에서 FileInputSream을 생성해서 사용한다.
ch = fr.read()
데이터를 읽을 때도, 내부에서는 FileInputStream을 사용해서 데이터를 byte 단위로 읽어들인다. 그리고 문자 집합을 사용해서 byte[]을 char로 디코딩한다.
FileWriter와 OutputStreamWriter
FileWriter 코드와 앞서 작성한, OutputStreamWriter를 사용한 코드가 뭔가 비슷하다는 점을 알 수 있다. 딱 하나 차이점이 있다면 이전 코드에서는 FileOutputStream을 직접 생성했는데, FileWriter는 생성자 내부에서 대신 FileOutputStream을 생성해준다. 사실 FileWriter는 OutputStreamWriter를 상속한다. 그리고 다른 추가 기능도 없다. 딱 하나, 생성자에서 개발자 대신에 FileOutputStream을 생성해주는 일만 대신 처리해준다. 따라서 FileWriter는 OutputStreamWriter를 조금 더 편리하게 사용하도록 도와줄 뿐이다. 물론 FileReader도 마찬가지다.
정리
Writer, Reader 클래스를 사용하면 바이트 변환 없이 문자를 직접 다룰 수 있어서 편리하다. 하지만 실제로는 내부에서 byte로 변환해서 저장한다는 점을 꼭 기억하자. 모든 데이터는 바이트 단위로 다룬다! 문자를 직접 저장할 수는 없다! 그리고 반드시 기억하자. 문자를 byte로 변경하려면 항상문자 집합(인코딩 셋)이 필요하다. 참고로, 문자 집합을 생략하면 시스템 기본 문자 집합이 사용된다.
문자 다루기4 - BufferedReader
BufferedOutputStream, BufferedInputStream과 같이 Reader, Writer에도 버퍼 보조 기능을 제공하는 BufferedReader, BufferedWriter 클래스가 있다.
추가로 문자를 다룰 때는 한 줄(라인)단위로 다룰 때가 많다. BufferedReader는 한 줄 단위로 문자를 읽는 기능도 추가로 제공한다.
package cwchoiit.io.text;
import java.io.*;
import static cwchoiit.io.text.TextConst.BUFFER_SIZE;
import static cwchoiit.io.text.TextConst.FILE_NAME;
import static java.nio.charset.StandardCharsets.UTF_8;
public class ReaderWriterMainV4 {
public static void main(String[] args) throws IOException {
String writeString = "ABC\n가나다";
System.out.println("== Write String ==");
System.out.println(writeString);
FileWriter fw = new FileWriter(FILE_NAME, UTF_8);
BufferedWriter bw = new BufferedWriter(fw, BUFFER_SIZE);
bw.write(writeString);
bw.close();
StringBuilder sb = new StringBuilder();
FileReader fr = new FileReader(FILE_NAME, UTF_8);
BufferedReader br = new BufferedReader(fr, BUFFER_SIZE);
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
}
br.close();
System.out.println("== Read String ==");
System.out.println(sb);
}
}
파일의 끝(EOF)에 도달하면 반환 타입이 String이기 때문에 -1을 반환할 수 없다. -1이 아니라 null을 반환한다.
기타 스트림
지금까지 설명한 스트림 외에 수 많은 스트림들이 있다. 몇가지 유용한 부가 기능을 제공하는 PrintStream, DataOutputStream 보조 스트림을 알아보자.
PrintStream
PrintStream은 우리가 자주 사용해왔던 바로 System.out에서 사용되는 스트림이다. PrintStream과 FileOutputStream을 조합하면 마치 콘솔에 출력하듯이 파일에 출력할 수 있다.
package cwchoiit.io.streams;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.PrintStream;
public class PrintStreamEtcMain {
public static void main(String[] args) throws FileNotFoundException {
FileOutputStream fos = new FileOutputStream("temp/print.txt");
PrintStream printStream = new PrintStream(fos);
printStream.println("Hello World!");
printStream.println(123);
printStream.println(true);
printStream.printf("hello %s", "world!");
printStream.close();
}
}
실행 결과 - temp/print.txt
hello java!
10
true
hello world
PrintStream의 생성자에 FileOutputStream을 전달했다. 이제 이 스트림을 통해서 나가는 출력은 파일에 저장된다. 이 기능을 사용하면 마치 콘솔에 출력하는 것처럼 파일이나 다른 스트림에 문자를 출력할 수 있다.
DataOutputStream
DataOutputStream을 사용하면 자바의 String, int, double, boolean과 같은 데이터 형을 편리하게 다룰 수 있다. 이 스트림과 FileOutputStream을 조합하면 파일에 자바 데이터 형을 편리하게 저장할 수 있다.
package cwchoiit.io.streams;
import java.io.*;
public class DataStreamEtcMain {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/data.dat");
DataOutputStream dos = new DataOutputStream(fos);
dos.writeUTF("회원A");
dos.writeInt(20);
dos.writeDouble(30.5);
dos.writeBoolean(true);
dos.close();
FileInputStream fis = new FileInputStream("temp/data.dat");
DataInputStream dis = new DataInputStream(fis);
System.out.println(dis.readUTF());
System.out.println(dis.readInt());
System.out.println(dis.readDouble());
System.out.println(dis.readBoolean());
dis.close();
}
}
실행 결과
회원A
20
10.5
true
예제를 보면, 자바 데이터 타입을 사용하면서, 회원 데이터를 편리하게 저장하고 불러오는 것을 확인할 수 있다. 이 스트림을 사용할 때 주의할 점으로는 꼭! 지정한 순서대로 읽어야 한다는 것이다. 그렇지 않으면 잘못된 데이터가 조회될 수 있다. 저장한 data.dat 파일을 직접 열어보면 제대로 보이지 않는다. 왜냐하면 writeUTF()의 경우 UTF-8 형식으로 저장하지만, 나머지의 경우 문자가 아니라 각 타입에 맞는 byte 단위로 저장하기 때문이다. 예를 들어 자바에서는 int는 4byte를 묶어서 사용한다. 해당 byte가 그대로 저장되는 것이다. 텍스트 편집기는 자신의 문자 집합을 사용해서 byte를 문자로 표현하려고 시도하지만, 문자 집합에 없는 단어이거나 또는 전혀 예상하지 않은 문자로 디코딩 될 것이다.