728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 2편 | 김영한 - 인프런

김영한 | 자바 제네릭과 컬렉션 프레임워크를 실무 중심으로 깊이있게 학습합니다. 자료 구조에 대한 기본기도 함께 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전

www.inflearn.com

 

제네릭은 왜 필요한지부터 들어가보자. 다음과 같은 코드가 있다고 가정해보자.

 

IntegerBox

package org.example.generic;

public class IntegerBox {

    private Integer value;

    public void set(Integer value) {
        this.value = value;
    }

    public Integer get() {
        return value;
    }
}

 

이건 단순히 정수형 타입의 값을 받아 저장할 수 있는 클래스이다.

 

StringBox

package org.example.generic;

public class StringBox {
    private String value;

    public String get() {
        return value;
    }

    public void set(String value) {
        this.value = value;
    }
}

이건 단순히 문자열 타입의 값을 받아 저장할 수 있는 클래스이다.

 

얘네를 호출해서 사용해보는 코드를 보자.

BoxMain1

package org.example.generic;

public class BoxMain1 {
    public static void main(String[] args) {
        IntegerBox integerBox = new IntegerBox();
        integerBox.set(10);
        Integer i = integerBox.get();
        System.out.println("i = " + i);

        StringBox stringBox = new StringBox();
        stringBox.set("hello");
        String s = stringBox.get();
        System.out.println("s = " + s);
    }
}

 

실행결과

i = 10
s = hello

 

원하는 대로 코드가 동작했지만, 마음에 들지 않는다. 우선 두 클래스는 정확히 필드로 받는 값의 타입만 다르고 완전 동일하게 동작한다. 그럼 이거를 DoubleBox, BooleanBox, ... 해서 계속 만들어내기엔 너무 비효율적인거 같고 그렇다. 그래서 이걸 하나로 합칠 수 있는 가장 간단한 방법이 그 모든 타입의 부모인 Object이므로 한번 이렇게 코드를 작성해보자.

 

ObjectBox

package org.example.generic;

public class ObjectBox {
    private Object value;

    public Object get() {
        return value;
    }

    public void set(Object value) {
        this.value = value;
    }
}

 

이제는 어떤 타입으로 들어와도 모두 받아줄 수 있다. 다형성이 이렇게 위대하다.

 

BoxMain2

package org.example.generic;

public class BoxMain2 {
    public static void main(String[] args) {
        ObjectBox integerBox = new ObjectBox();
        integerBox.set(10);
        Integer integer = (Integer) integerBox.get();
        System.out.println("integer = " + integer);

        ObjectBox stringBox = new ObjectBox();
        stringBox.set("hello");
        String o = (String) stringBox.get();
        System.out.println("o = " + o);
    }
}

이렇게 ObjectBox를 사용하는 방법은 어떤 타입으로 들어와도 그 타입으로 다운캐스팅을 해서 사용하면 된다. 좋아보이지만 우선 두가지 문제가 있다.

 

  • 다운캐스팅이 귀찮다.
  • 잘못된 타입으로 캐스팅하는 경우 에러가 발생한다.

귀찮은거야 그렇다치고 이런 문제가 있을 수 있다.

 

문제가 되는 코드

package org.example.generic;

public class BoxMain2 {
    public static void main(String[] args) {
        ObjectBox integerBox = new ObjectBox();
        integerBox.set(10);
        Integer integer = (Integer) integerBox.get();
        System.out.println("integer = " + integer);

		// ... 엄청나게 긴 코드 라인 ... 

        integerBox.set("100");
        Integer integer2 = (Integer) integerBox.get();
        System.out.println("integer2 = " + integer2);
    }
}

보면, 엄청나게 긴 코드가 지난 후 누군가가 integerBox에 숫자가 아닌 문자를 집어넣었다. 근데 이름이 integerBox니까 또 다른 누군가는 당연하게 "이건 Integer로 다운캐스팅해야겠다!"라는 생각으로 Integer 타입으로 다운캐스팅 하는 순간 에러가 터져버린다. 

 

즉, 코드의 재사용성은 높였지만 코드의 타입 안정성이 많이 떨어져 버린것이다.

그렇다고 IntegerBox, StringBox를 사용하자니 코드의 타입 안정성이 높아지는 대신 코드의 재사용성이 너무 떨어지고 딜레마에 빠지는것이다. 

 

이 딜레마를 완벽하게 해결해주는 것이 바로 '제네릭'이다. 제네릭은 이런 이유로 탄생했다.

 

제네릭 사용

GenericBox

package org.example.generic;

public class GenericBox<T> {
    private T value;

    public T getValue() {
        return value;
    }

    public void setValue(T value) {
        this.value = value;
    }
}

 

이 코드가 제네릭이다. 저렇게 `<T>` 라고 입력해주면 T로 들어오는 타입에 따라 필드 `value`의 타입이 결정된다. 이걸 사용할 땐 이렇게 사용하면 된다.

 

BoxMain3

package org.example.generic;

public class BoxMain3 {
    public static void main(String[] args) {
        GenericBox<Integer> integerBox = new GenericBox<>(); // 생성 시점에 타입이 결정
        integerBox.setValue(100);
        Integer integerValue = integerBox.getValue();
        System.out.println("value = " + integerValue);

        GenericBox<String> stringBox = new GenericBox<>();
        stringBox.setValue("Hello");
        String stringValue = stringBox.getValue();
        System.out.println("stringValue = " + stringValue);

        GenericBox<Double> doubleBox = new GenericBox<>();
        doubleBox.setValue(100.0);
        Double doubleValue = doubleBox.getValue();
        System.out.println("doubleValue = " + doubleValue);
    }
}

GenericBox 객체를 생성할 때 `GenericBox<Integer>`, `GenericBox<String>`, `GenericBox<Double>` 이렇게 작성해주면 이 객체의 `value` 필드의 타입이 결정된다. 

 

그러면 `setValue()` 메서드를 사용할 때 해당 타입이 아니면 컴파일 에러가 발생한다. 즉, 코드의 재사용성과 코드의 타입 안정성을 둘 다 해결했다. 제네릭의 핵심은 사용할 타입을 미리 결정하지 않는다는 점이다.

 

제네릭 용어

제네릭의 핵심은 사용할 타입을 미리 결정하지 않는것이라고 했다. 클래스 내부에서 사용하는 타입을 클래스를 정의하는 시점에 결정하는 것이 아니라 실제 사용하는 생성 시점에 타입을 결정하는 것이다. 이걸 쉽게 비유하자면 메서드의 매개변수와 인자의 관계와 비슷하다.

void customPrint(String param) {
	println(param);
}

메서드에서 필요한 값을 메서드를 정의하는 시점에 미리 결정하는 게 아니다. 실제 사용하는 시점으로 미룬다.

void main() {
    customPrint("hello");
    customPrint("hi");
}

 

그래서 위 코드처럼 실제 실행하는 시점에 "hello""hi"같은 값을 전달한다.

여기서 매개변수(Parameter)인자(Argument)의 차이를 좀 이해하면 좋겠다.

void customPrint(String param) {  <- 매개변수(Parameter)
	println(param);
}

void main() {
	customPrint("hello"); <- 인자(Argument)
}

 

  • 인자 또는 인수는 매개변수에 전달할 값을 말한다.
  • 매개변수는 메서드에서 사용할 값을 받을 때 받아줄 변수를 말한다.

 

제네릭도 앞서 말한 메서드의 매개변수와 인자의 관계와 비슷하게 작동한다. 제네릭 클래스를 정의할 때 내부에서 사용할 타입을 미리 결정하는 것이 아니라, 해당 클래스를 실제 사용하는 생성 시점에 내부에서 사용할 타입을 결정하는 것이다. 

 

대신 제네릭은 타입 매개변수타입 인자(인수)라는 표현을 사용하면 된다.

  • 타입 매개변수: GenericBox<T>에서 T
  • 타입 인자: GenericBox<Integer> integerBox = new GenericBox<>(); 에서 Integer

 

여기서 T는 정해진게 아니라 어떤 문자를 사용해도 상관은 없다. GenericBox<Hello>도 가능하다. 근데 일반적인 관례가 있다.

  • E - Element
  • K - Key
  • N - Number
  • T - Type
  • V - Value

그리고 아래처럼 한번에 여러 타입 매개변수를 선언할 수 있다.

class Data<K, V> {...}

 

타입인자로 기본형은 사용 불가하다.

타입 인자로 int, double, boolean 이런 기본형은 사용할 수 없다. 대신에 래퍼 클래스를 사용하면 된다.

 

제네릭 Raw Type

결론을 먼저 말하면 "제네릭을 사용해놓고 Raw Type을 사용하지마라"이다. Raw Type이 뭐냐면 다음 코드를 보자.

GenericBox rawBox = new GenericBox();
rawBox.setValue(100);

 

이렇게 <>로 타입 인자를 작성하지 않는 경우를 Raw Type이라고 한다. 

제네릭 타입을 사용할 때는 항상 <>를 사용해서 사용 시점에 타입을 지정해야 한다. 그럼 왜 저런 Raw Type을 지원하는 걸까? 자바의 제네릭이 자바가 처음 등장할 때부터 있던것이 아니라 자바가 오랜기간 사용된 이후에 등장했기 때문에 제네릭이 없던 시절의 과거 코드와의 호환이 필요했다. 그래서 어쩔 수 없이 이런 Raw Type을 지원한다.

 

 

그럼 이제 제네릭도 알았고 막 여기저기 제네릭을 써서 좀 더 재활용 가능하고 중복을 제거하게 코드를 막 작성하고 싶은 욕구가 생긴다.

과연 순탄할까?

 

제네릭의 문제

동물 병원을 만들어보자.

 

Animal

package org.example.generic.animal;

public class Animal {
    private String name;
    private int size;

    public Animal(String name, int size) {
        this.name = name;
        this.size = size;
    }

    public String getName() {
        return name;
    }

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

    public int getSize() {
        return size;
    }

    public void setSize(int size) {
        this.size = size;
    }

    public void sound() {
        System.out.println("동물 울음 소리");
    }

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

 

Dog

package org.example.generic.animal;

public class Dog extends Animal {

    public Dog(String name, int size) {
        super(name, size);
    }

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}

 

Cat

package org.example.generic.animal;

public class Cat extends Animal {

    public Cat(String name, int size) {
        super(name, size);
    }

    @Override
    public void sound() {
        System.out.println("냐옹");
    }
}

 

이렇게 Animal, Dog, Cat 클래스가 있다. Animal은 부모 클래스이고 Dog, Cat이 각각 자식 클래스로 만들어졌다.

이제 개 전용 병원과 고양이 전용 병원을 만들어보자.

 

DogHospital

package org.example.generic.ex3;

import org.example.generic.animal.Dog;

public class DogHospital {
    private Dog animal;

    public void set(Dog animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public Dog bigger(Dog target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

 

CatHospital

package org.example.generic.ex3;

import org.example.generic.animal.Cat;

public class CatHospital {
    private Cat animal;

    public void set(Cat animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public Cat bigger(Cat target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

 

DogHospital, CatHospitalDogCat이냐의 차이만 있고 코드가 모두 똑같이 생겼다. 벌써 막 중복을 제거하고 싶다. 일단 참고 진행해보자.

 

AnimalHospitalMainV0

package org.example.generic.ex3;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class AnimalHospitalMainV0 {
    public static void main(String[] args) {
        DogHospital dogHospital = new DogHospital();
        CatHospital catHospital = new CatHospital();

        Dog dog = new Dog("멍멍1", 100);
        Cat cat = new Cat("냐옹1", 300);

        dogHospital.set(dog);
        dogHospital.checkup();

        catHospital.set(cat);
        catHospital.checkup();

        Dog biggerDog = dogHospital.bigger(new Dog("멍멍2", 200));
        System.out.println("biggerDog = " + biggerDog);
    }
}

 

이렇게 DogHospital, CatHospital을 나눠 만들었기 때문에 혹여나 DogHospitalCat 타입을 넣으려고 해도 컴파일 에러가 발생하고 반대도 마찬가지다. 즉, 코드의 타입 안정성이 뛰어난 코드이다. 그러나, 재사용성이 너무 떨어진다. 두 병원은 어떤 동물을 받느냐의 차이만 있고 나머지가 완전히 동일하다.

 

그럼 이 중복을 해결하기 위해 다형성을 활용해보자. Dog, Cat 모두 Animal을 상속받기 때문에 가능해보인다.

 

AnimalHospitalV1

package org.example.generic.ex3;

import org.example.generic.animal.Animal;

public class AnimalHospitalV1 {
    private Animal animal;

    public void set(Animal animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public Animal bigger(Animal target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

 

AnimalHospitalMainV1

package org.example.generic.ex3;

import org.example.generic.animal.Animal;
import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class AnimalHospitalMainV1 {
    public static void main(String[] args) {
        AnimalHospitalV1 dogHospital = new AnimalHospitalV1();
        AnimalHospitalV1 catHospital = new AnimalHospitalV1();

        Dog dog = new Dog("멍멍1", 100);
        Cat cat = new Cat("냐옹1", 300);

        dogHospital.set(dog);
        dogHospital.checkup();

        catHospital.set(cat);
        catHospital.checkup();

        // dogHospital.set(cat); 타입 안정성 실패

        // Dog biggerDog = dogHospital.bigger(new Dog("멍멍2", 200)); 컴파일 에러
        Dog biggerDog = (Dog) dogHospital.bigger(new Dog("멍멍2", 200));
        System.out.println("biggerDog = " + biggerDog);
    }
}

 

이렇게 두 동물을 모두 받을 수 있는 AnimalHospital을 만들었다. 중복은 제거됐지만 문제가 있다.

  • 개병원에는 개만 받고 싶지만 고양이를 받을 수 있다. dogHospital.set(cat);
  • 개병원에서 더 큰 개를 찾아서 Dog로 반환할 수 없고 Animal로 반환해야 하니 다운캐스팅이 필요하다.

다운캐스팅은 하면 된다 치더라도 만약 누군가 개병원에 고양이를 받게 했을 땐 캐스팅 에러가 발생한다.

 

그럼 이거 제네릭을 사용해보면 어떻게 될까?

 

AnimalHospitalV2

package org.example.generic.ex3;

public class AnimalHospitalV2<T> {
    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkup() {
        //System.out.println("동물 이름: " + animal.getName()); 컴파일 에러
        //System.out.println("동물 크기: " + animal.getSize()); 컴파일 에러
        //animal.sound(); 컴파일 에러
        animal.toString();
    }

    public T bigger(T target) {
        // return animal.getSize() > target.getSize() ? animal : target; 컴파일 에러
        return null;
    }
}

 

더 심각한 문제가 발생했다. 제네릭을 사용하니까 컴파일러는 타입 T가 어떤 타입인지 알 수 없다.

즉, Animal이 들어올지 아닐지 알 수 없고 Animal 타입이 아니라면 getName(), getSize()같은 메서드를 사용할 수 없다.

 

그리고 심지어 이런 문제도 있다.

AnimalHospitalMainV2

package org.example.generic.ex3;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class AnimalHospitalMainV2 {
    public static void main(String[] args) {
        AnimalHospitalV2<Dog> dogHospital = new AnimalHospitalV2<>();
        AnimalHospitalV2<Cat> catHospital = new AnimalHospitalV2<>();
        AnimalHospitalV2<Integer> integerHospital = new AnimalHospitalV2<>();
        AnimalHospitalV2<Double> doubleHospital = new AnimalHospitalV2<>();
    }
}

Integer, Double과 같은 타입도 이 병원이 받아들이고 있다. 왜? 제네릭이니까. 논리적으로 문제가 발생중이다.

 

그럼 결국 제네릭은 사용할 수가 없나? 제네릭을 사용하니 다음과 같은 문제가 발생한다.

  • Dog, Cat 타입도 받아들일 수 있지만, 말도 안되는 타입(Integer, Double, Boolean, ..)도 받아들일 수 있다.
  • 컴파일러 입장에선 클래스만 놓고 봤을 때 타입 T의 정보를 알 수 없으니 Animal 클래스가 가지는 메서드를 사용할 수 없다.

그럼 필드 타입이 AnimalAnimalHospital이 최선일까? 다운캐스팅도 해야하고, 원치 않는 타입의 동물이 들어와도 가능한데 그럴경우 다운 캐스팅시 문제가 발생할 수도 있는 이게 최선일까? 아닌거 같다.

 

저 두개의 문제를 해결하려면 T의 타입을 Animal로 한정지으면 모든 문제가 해결된다.

 

제네릭의 문제 돌파

AnimalHospitalV3

package org.example.generic.ex3;

import org.example.generic.animal.Animal;

public class AnimalHospitalV3<T extends Animal> {
    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public T bigger(T target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

 

위 코드처럼 T라는 타입 매개변수에게 제한을 걸어두는 것이다. "넌 AnimalAnimal 하위의 타입으로만 가능하다"라고.

그럼 컴파일 시점에 T타입을 명확하게 구분짓지 않아도 적어도 Animal 클래스가 가지는 메서드들은 사용할 수가 있는것이다.

 

AnimalHospitalMainV3

package org.example.generic.ex3;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class AnimalHospitalMainV3 {
    public static void main(String[] args) {
        AnimalHospitalV3<Dog> dogHospital = new AnimalHospitalV3<>();
        AnimalHospitalV3<Cat> catHospital = new AnimalHospitalV3<>();

        Dog dog = new Dog("멍멍1", 100);
        Cat cat = new Cat("냐옹1", 300);

        dogHospital.set(dog);
        dogHospital.checkup();

        catHospital.set(cat);
        catHospital.checkup();

        Dog biggerDog = dogHospital.bigger(new Dog("멍멍2", 200));
        System.out.println("biggerDog = " + biggerDog);
    }
}

 

모든 문제가 다 해결됐다.

  • 이제 개 병원에 고양이를 넣을 수 없다. 개 병원은 제네릭의 타입 매개변수가 Dog이기 때문에 Cat 타입을 넣을 수 없다.
  • 개 병원으로 더 큰 개가 누구인지 찾는 메서드에서도 제네릭의 타입 매개변수가 Dog이기 때문에 Dog를 반환하게 된다. 그러므로 다운캐스팅이 필요없다.
  • 제네릭의 타입 매개변수가 Animal 하위로 한정됐기 때문에 Integer, Double 타입을 받을 수 없다. 
이것을 '제네릭의 타입 매개변수 제한'이라고 한다.

 

 

제네릭 메서드

지금까지 살펴본 건 제네릭 타입이었고 이제는 제네릭 메서드를 알아보자. 

참고로 제네릭 타입은 클래스나 인터페이스를 제네릭으로 선언하면 그게 제네릭 타입이다.

 

GenericMethod

package org.example.generic.ex4;

public class GenericMethod {

    public static Object objMethod(Object object) {
        System.out.println("object print = " + object);
        return object;
    }

    public static <T> T genericMethod(T t) {
        System.out.println("generic print = " + t);
        return t;
    }

    public static <T extends Number> T genericNumberMethod(T t) {
        System.out.println("bound generic print = " + t);
        return t;
    }
}

 

이 코드가 바로 제네릭 메서드이다. 제네릭 메서드는 반환타입 앞에 <>가 붙는다. 반환타입 앞에 <>가 붙어서 "전 제네릭 메서드입니다"를 알려준다. 그리고 그 다음 반환타입이 나오는데, 위 코드에선 반환타입을 전부 제네릭으로 선언한 타입 T로 결정한다. 그리고 파라미터도 T 타입의 매개변수를 받는다. 

 

이제 저 메서드를 사용하는 쪽에선 어떻게 해야할까?

MethodMain1

package org.example.generic.ex4;

public class MethodMain1 {

    public static void main(String[] args) {
        Object o = GenericMethod.objMethod(1);

        GenericMethod.<Integer>genericMethod(1);
        Integer integerValue = GenericMethod.<Integer>genericNumberMethod(1);
        Double doubleValue = GenericMethod.<Double>genericNumberMethod(1.0);
    }
}

 

이런식으로 호출할 때 타입을 알려주면 된다. 호출할 때 <>로 어떤 타입인지를 전달하는데 이걸 명시적 타입 인자 전달이라고 한다. (근데 이것도 나중엔 필요없다.) 

 

 

제네릭 메서드는 인스턴스 메서드, static 메서드 모두 가능하다.

package org.example.generic.ex4;

public class Box<T> {
    private T value;
    
    private <V> V instanceMethod(V v) {
        return v;
    }

    private static <Z> Z staticMethod(Z z) {
        return z;
    }
}

 

근데 제네릭 타입(클래스 옆에 붙은 제네릭)은 static 메서드에 타입 매개변수로 사용할 수가 없다. 그 이유는 제네릭 타입은 객체를 생성하는 시점에 타입이 정해진다. 그런데 static 메서드는 인스턴스 단위가 아니라 클래스 단위로 작동하기 때문에 제네릭 타입과는 무관하다. 따라서 static 메서드에 제네릭을 도입하려면 제네릭 메서드를 사용해야 한다. 아래 코드를 보자.
class Box<T> {
    T instanceMethod(T t) {} //가능
    static T staticMethod(T t) {} //불가능
    static <Z> Z staticGenericMethod(Z z) {} //가능 (제네릭 메서드)
}

 

 

제네릭 메서드 타입 추론

이건 뭐냐면 위에서 명시적 타입 인자 전달을 말하면서 제네릭 메서드를 호출할 때 타입을 알려주는데 사실 이게 여간 귀찮다. 그래서 생략해도 된다. 생략해도 컴파일러가 들어오는 값으로 타입을 추론해낸다. 다음 코드처럼.

package org.example.generic.ex4;

public class MethodMain1 {

    public static void main(String[] args) {
        Object o = GenericMethod.objMethod(1);

        GenericMethod.genericMethod(1);
        Integer integerValue = GenericMethod.genericNumberMethod(1);
        Double doubleValue = GenericMethod.genericNumberMethod(1.0);
    }
}

 

 

제네릭 타입과 제네릭 메서드의 우선순위

static 메서드는 제네릭 메서드만 적용할 수 있지만, 인스턴스 메서드는 제네릭 타입도 제네릭 메서드도 둘 다 적용할 수 있다. 여기에 제네릭 타입과 제네릭 메서드의 타입 매개변수를 같은 이름으로 사용하면 어떻게 될까?

 

ComplexBox

package org.example.generic.ex4;

import org.example.generic.animal.Animal;

public class ComplexBox<T extends Animal> {
    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public T get() {
        return animal;
    }

    public <T> T printAndReturn(T t) {
        System.out.println("animal.className = " + animal.getClass().getName());
        System.out.println("t.className: " + t.getClass().getName());
        return t;
    }
}

 

ComplexBox라는 클래스는 제네릭 타입 <T extends Animal>이다. 

여기서 인스턴스 메서드 printAndReturn()은 제네릭 메서드로 선언했다. 근데 제네릭 타입과 제네릭 메서드의 타입 매개변수가 같은 T로 이루어져있다. 그럼 우선순위는 누가 더 높은지를 알고자 하는 것이다.

 

MethodMain3

package org.example.generic.ex4;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class MethodMain3 {
    public static void main(String[] args) {
        Dog dog = new Dog("멍멍", 30);
        Cat cat = new Cat("냐옹", 20);

        ComplexBox<Dog> complexBox = new ComplexBox<>();
        complexBox.set(dog);

        complexBox.get().sound();

        complexBox.printAndReturn(cat);
    }
}

실행결과

멍멍
animal.className = org.example.generic.animal.Dog
t.className: org.example.generic.animal.Cat

 

ComplexBox는 제네릭 타입이고 타입 매개변수 TDog로 선언했다. 그런데 printAndReturn은 제네릭 메서드이다. 제네릭 메서드의 타입 매개변수 T는 제네릭 타입의 타입 매개변수 T와 아무런 상관이 없다. 즉, 우선순위는 제네릭 메서드의 타입 매개변수가 더 높은것이다.

 

개발 세상에서는 느낀게 항상 우선순위는 더 디테일한 쪽이 우선순위가 높다. 

 

그래서 실행결과는 당연하게도 인자로 넘긴 Cat 타입으로 찍힌다. 근데 저렇게 모호하게 같은 T로 작성하지 말자. 혼란을 주는 코드는 나쁜코드!

 

와일드카드

제네릭에서 와일드카드란, 이미 만들어진 제네릭 타입을 활용할 때 사용한다. 와일드카드는 제네릭 타입이나 제네릭 메서드를 선언하는게 아니다. 

WildCardEx

package org.example.generic.ex5;

import org.example.generic.animal.Animal;

public class WildCardEx {

    static <T> void printGenericV1(Box<T> box) {
        System.out.println("T = " + box.getValue());
    }

    static void printWildCardV1(Box<?> box) {
        System.out.println("? = " + box.getValue());
    }

    static <T extends Animal> void printGenericV2(Box<T> box) {
        T value = box.getValue();
        System.out.println("이름 = " + value.getName());
    }

    static void printWildCardV2(Box<? extends Animal> box) {
        Animal value = box.getValue();
        System.out.println("이름 = " + value.getName());
    }

    static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
        T value = box.getValue();
        System.out.println("이름 = " + value.getName());
        return value;
    }

    static Animal printAndReturnWildCard(Box<? extends Animal> box) {
        Animal value = box.getValue();
        System.out.println("이름 = " + value.getName());
        return value;
    }
}

저기서 <?>로 작성한 3가지 메서드가 와일드 카드를 사용하고 있다. 3가지 메서드들을 보면 알겠지만 제네릭 메서드를 선언한 게 아니다.

`?`로 어떤 타입이든 받을 수 있는 와일드카드를 사용해서 타입의 인자가 정해진 제네릭 타입을 전달 받아 활용할 때 사용한다.

 

여기서 단순히 <?> 말고 <? extends Animal>이 있는데 이 때 <?>비제한 와일드카드라고 한다.

정말 모든 타입을 다 받아줄 수 있는 것. Box<Integer>, Box<Dog>, Box<Object> 처럼 말이다. 

 

WildCardMain1

package org.example.generic.ex5;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class WildCardMain1 {
    public static void main(String[] args) {
        Box<Object> oBox = new Box<>();
        Box<Dog> dogBox = new Box<>();
        Box<Cat> catBox = new Box<>();

        dogBox.setValue(new Dog("멍멍", 100));

        WildCardEx.printGenericV1(dogBox);
        WildCardEx.printWildCardV1(dogBox);

        WildCardEx.printGenericV2(dogBox);
        WildCardEx.printWildCardV2(dogBox);

        WildCardEx.printAndReturnGeneric(dogBox);
        WildCardEx.printAndReturnWildCard(dogBox);
    }
}

실행결과

T = Animal{name='멍멍', size=100}
? = Animal{name='멍멍', size=100}
이름 = 멍멍
이름 = 멍멍
이름 = 멍멍
이름 = 멍멍

 

상한 와일드카드

비제한 와일드카드 말고 상한 와일드카드가 있다. 말 그대로 상한을 두는것이다. 위에서 이미 봤다.

`<? extends Animal>`이게 상한 와일드카드이다.

 

들어오는 여러 타입중에 Animal 하위까지만 가능하게 설정하는 것이다.

 

 

그럼 제네릭도 있고 뭐 와일드카드도 있고 뭘 사용하는게 좋을까?

제네릭을 반드시 사용해야 하는 경우라면 제네릭 타입이나 제네릭 메서드를 사용하고, 그게 아니라면 와일드카드를 사용하는 것을 권장한다. 와일드카드는 제네릭 타입이나 제네릭 메서드를 선언하는게 아니기 때문에 타입 추론을 하고 등등의 복잡한 과정이 전혀 필요가 없다. 그저 이미 타입이 지정이 된 제네릭을 활용할뿐이다.

 

근데, 제네릭을 반드시 사용해야 하는 경우라는건 어떤거냐면 이런거다.

static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
    T value = box.getValue();
    System.out.println("이름 = " + value.getName());
    return value;
}

static Animal printAndReturnWildCard(Box<? extends Animal> box) {
    Animal value = box.getValue();
    System.out.println("이름 = " + value.getName());
    return value;
}

첫번째 메서드는 제네릭 메서드이고 반환타입이 T이다. 그리고 받는 파라미터도 Box<T>이다. 그럼 이걸 호출하는 쪽에서는 내가 어떤 타입을 받을지 명확하게 명시할 수 있다. 더 정확히는 명확하게 명시하는 것에서 끝이아니라 그 타입이 아니면 아예 컴파일 에러가 발생한다.

Dog dog = WildCardEx.printAndReturnGeneric(dogBox);
Cat cat = WildCardEx.printAndReturnGeneric(catBox);

 

 

근데 두번째 메서드는 제네릭 메서드가 아니라서 반환 타입은 딱 하나로 지정될 수 밖에 없다. 동적으로 바뀌는게 아니다.

그래서 다음과 같이 Animal 타입의 반환값이 나오고 이것을 딱 원하는 DogCat으로 사용하기 위해선 다운캐스팅이 필요하다.

Animal dog = WildCardEx.printAndReturnWildCard(dogBox);
Animal cat = WildCardEx.printAndReturnWildCard(catBox);

 

 

하한 와일드카드

상한이 있다면 하한 와일드카드도 있다. 아래 코드가 그렇다.

static void writeBox(Box<? super Animal> box) {
    System.out.println("Hello");
}

저렇게 `<? super Animal>`이렇게 작성하면, ?Animal 포함 그 이상으로만 가능하다.

그래서 Dog, Cat 둘 다 올 수 없다. 

참고로 이 하한 와일드카드는 제네릭에는 사용할 수 없다. 말도 하한 와일드카드니까 그냥 참고로 알아두면 좋다.

 

타입 이레이저

제네릭이 결국 컴파일러에 의해 컴파일되면 어떤 모양을 할까? 제네릭은 자바 컴파일 단계에서만 사용되고 컴파일 이후에는 제네릭 정보가 삭제된다. 쉽게 말해서 컴파일 전인 .java 파일에는 제네릭의 타입 매개변수가 존재하지만, 컴파일 이후인 자바 바이트코드 .class 파일에는 타입 매개변수가 존재하지 않게 된다.

 

Box.java

package org.example.generic.ex5;

public class Box<T> {
    private T value;

    public T getValue() {
        return value;
    }

    public void setValue(T value) {
        this.value = value;
    }
}

 

이렇게 선언한 제네릭 타입을 사용할 때 이렇게 사용한다.

BoxEraserMain.java

package org.example.generic.ex5;

public class BoxEraserMain {
    public static void main(String[] args) {
        Box<Integer> integerBox = new Box<>();
        integerBox.setValue(10);
        Integer value = integerBox.getValue();
    }
}

 

이렇게 하면 자바 컴파일러는 컴파일 시점에 타입 매개변수와 타입 인자를 포함한 제네릭 정보를 활용해서 new Box<Integer>()에 대해 다음과 같이 이해한다. 

package org.example.generic.ex5;

public class Box<Integer> {
    private Integer value;

    public Integer getValue() {
        return value;
    }

    public void setValue(Integer value) {
        this.value = value;
    }
}

 

컴파일이 끝나면 자바는 제네릭과 관련된 정보를 삭제한다. 그래서 .class 파일에 생성된 정보는 다음과 같다.

Box.class

package org.example.generic.ex5;

public class Box {
    private Object value;

    public Object getValue() {
        return value;
    }

    public void setValue(Object value) {
        this.value = value;
    }
}

 

선언한 타입 매개변수는 전부 Object로 변환된다. 그리고 이것을 호출한 Main쪽은 이렇게 변한다.

BoxEraserMain.class

package org.example.generic.ex5;

public class BoxEraserMain {
    public static void main(String[] args) {
        Box integerBox = new Box();
        integerBox.setValue(10);
        Integer value = (Integer) integerBox.getValue(); // 컴파일러가 캐스팅 추가
    }
}

 

값을 반환 받는 부분을 자바 컴파일러가 자동으로 캐스팅을 해준다. 이렇게 추가된 코드는 자바 컴파일러가 이미 컴파일 시점에 검증을 했고 완벽하다고 판단했기 때문에 문제가 발생하지 않는다.

 

타입 매개변수 제한의 경우는 어떨까?

 

컴파일 전엔 이렇게 생긴 코드이다.

AnimalHospitalV3.java

package org.example.generic.ex3;

import org.example.generic.animal.Animal;

public class AnimalHospitalV3<T extends Animal> {
    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public T bigger(T target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

 

컴파일 후엔 이렇게 변한다.

AnimalHospitalV3.class

package org.example.generic.ex3;

import org.example.generic.animal.Animal;

public class AnimalHospitalV3 {
    private Animal animal;

    public void set(Animal animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public Animal bigger(Animal target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}


T의 타입 정보가 제거됐지만 상한으로 지정한 Animal 타입으로 대체되기 때문에 Animal 타입의 메서드를 사용하는데는 아무런 문제가 없다. 그럼 이거를 가져다가 사용하는 Main 코드는 어떻게 변할까?

 

컴파일 전

Main.java

AnimalHospitalV3<Dog> animalHospitalV3 = new AnimalHospitalV3<>();
Dog dog = animalHospitalV3.getBigger(new Dog());

 

컴파일 후

Main.class

AnimalHospitalV3 animalHospitalV3 = new AnimalHospitalV3();
...
Dog dog = (Dog) animalHospitalV3.getBigger(new Dog());

우선 제네릭 타입을 넣는 것은 사라지고 일반적인 객체를 생성하는 코드와 똑같아진다. 그리고 이렇게 생성을 하고 getBigger()Dog 타입이 파라미터로 들어가면 Animal 타입으로 리턴하는 게 아니라 Dog로 컴파일러가 캐스팅을 해준다. 캐스팅을 해도 아무런 문제가 되지 않는것은 위에서 말한 이유랑 동일하다. 이미 자바 컴파일러가 컴파일 시점에 코드에 문제가 없는지 완벽하게 검증하기 때문에 다운 캐스팅을 해도 아무런 문제가 발생하지 않는다.

 

이게 결론이다. 이거 말하려고 여기까지 왔다.

그래서! 이렇게 자바 컴파일러가 타입 이레이저라는 기술을 사용하기 때문에 제네릭의 타입 정보가 컴파일 이후에는 존재하지 않는다. .class로 자바를 실행하는 런타임에는 지정한 Box<Integer>, Box<String> 의 타입 정보가 모두 제거가 된다는 말이다.

따라서 런타임에 타입을 활용하는 다음과 같은 코드는 작성할 수 없다.

Box.java

class Box<T> {
    public boolean instanceCheck(Object param) {
    	return param instanceof T; // 오류
    }	
    public void create() {
    	return new T(); // 오류
    }
}

 

왜냐? 이 코드가 컴파일 된 이후 .class 파일은 이렇게 생겼기 때문이다.

Box.class

class Box {
    public boolean instanceCheck(Object param) {
    	return param instanceof Object; // 오류
    }	
    public void create() {
    	return new Object(); // 오류
    }
}

 

  • 이런 코드라면 instanceCheck()는 어떤 param이 들어와도 참이된다. 개발자는 이런 결과를 원하진 않았을 것이다.
  • new T()는 항상 new Object()가 되버린다. 이것 역시 개발자는 원하지 않았을 것이다.

그래서, 제네릭에서는 타입 매개변수에 `instanceof`와 `new`를 허용하지 않는다.

 

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

참고자료:

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런

김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습

www.inflearn.com

 

이제 스프링 MVC의 핵심 구조를 하나하나 직접 구현해보면서 이해해보자.

핵심은 바로 FrontController.

 

결국 공통으로 처리되어야 할 부분들을 앞에서 모두 처리하고 필요한 컨트롤러만 찾아서 호출해주는 것이다.

기존 코드를 이 FrontController를 도입해서 하나씩 바꿔가보자.

 

우선, ControllerV1 이라는 인터페이스를 만든다. FrontController에서 가장 중요한 것 중 하나는 '다형성'이다.

 

ControllerV1

package org.example.servlet.web.frontcontroller.v1;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public interface ControllerV1 {
    void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

 

이 인터페이스를 구현하는 각각의 컨트롤러(Save, Form, List)를 만들자.

 

MemberFormControllerV1

package org.example.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.v1.ControllerV1;

import java.io.IOException;

public class MemberFormControllerV1 implements ControllerV1 {
    @Override
    public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String viewPath = "/WEB-INF/views/new-form.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

MemberListControllerV1

package org.example.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.v1.ControllerV1;

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

public class MemberListControllerV1 implements ControllerV1 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        // Model에 보관
        request.setAttribute("members", members);

        String viewPath = "/WEB-INF/views/members.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

MemberSaveControllerV1

package org.example.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.v1.ControllerV1;

import java.io.IOException;

public class MemberSaveControllerV1 implements ControllerV1 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        // Model에 데이터를 보관
        request.setAttribute("member", member);

        String viewPath = "/WEB-INF/views/save-result.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

이제 이 각각의 컨트롤러의 앞에서 공통 부분을 처리하고 필요한 컨트롤러를 호출해주는 FrontController를 만들면 된다.

FrontControllerServletV1

package org.example.servlet.web.frontcontroller.v1;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.v1.controller.MemberFormControllerV1;
import org.example.servlet.web.frontcontroller.v1.controller.MemberListControllerV1;
import org.example.servlet.web.frontcontroller.v1.controller.MemberSaveControllerV1;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {

    private final Map<String, ControllerV1> controllerMap = new HashMap<>();

    public FrontControllerServletV1() {
        controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
        controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
        controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();

        ControllerV1 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        controller.process(req, resp);
    }
}

 

이 FrontController는 Servlet이다. 이 서블릿의 URL Pattern은 "/front-controller/v1/*"이다. 이게 의미하는 건 /front-controller/v1/으로 시작하는 모든 URL에 대해 이 서블릿이 처리하겠다는 의미가 된다. 그리고 이 서블릿은 Map을 가진다. 이 Map엔 각 URL에 상응하는 컨트롤러가 담겨있고 이 컨트롤러들은 모두 타입이 ControllerV1이다. 왜냐? 다형성 덕분에 가능하다.

 

그래서 이 Front Controller는 받은 요청의 URI를 통해 어떤 컨트롤러를 호출할지 찾아내서 그 컨트롤러가 구현한 메서드인 process()를 호출한다. 

 

"어? 더 불편해 보이는데요..?"

맞다. 여전히 지금은 각 컨트롤러마다 어떤 뷰를 보여줘야 하는지에 대한 코드가 중복으로 남아있다. 이런 코드들.

String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);

 

이제 이 부분을 공통으로 처리하게 해보자. 그럼 구조는 이렇게 생기게 된다.

 

바뀌는 부분은 FrontController가 요청으로 들어온 URI를 보고 Controller를 찾는데, 찾고 그 Controller가 가진 process() 메서드를 호출하고 끝나는게 아니라 호출하면 반환하는 MyView라는 객체의 render() 메서드를 호출하는 것까지 FrontController가 하게 된다.

 

위에서도 말했지만, 공통으로 처리될 부분을 앞에서 다 해주는 것이 원래 기대값이다. 그 방향으로 하나씩 나아가는 중인 것.

 

우선 MyView 라는 뷰를 렌더링 하는것을 담당하는 클래스를 만든다.

 

MyView

package org.example.servlet.web.frontcontroller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public class MyView {
    private final String viewPath;

    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }

    public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

이 클래스는 viewPath 필드를 가지고 있다. 컨트롤러에서 이 객체를 만들 때 viewPath를 넣어주면 이 객체의 render() 메서드는 viewPath를 통해 JSP를 호출한다.

 

이번엔 컨트롤러들의 인터페이스를 만든다.

ControllerV2

package org.example.servlet.web.frontcontroller.v2;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;

import java.io.IOException;

public interface ControllerV2 {
    MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

 

이전 V1과 달라지는 것은 반환하는 MyView 객체가 있다는 것.

 

이제 ControllerV2를 구현할 세가지의 컨트롤러(Form, Save, List)를 만든다.

 

MemberFormControllerV2

package org.example.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;

public class MemberFormControllerV2 implements ControllerV2 {
    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) throws
            ServletException, IOException {
        return new MyView("/WEB-INF/views/new-form.jsp");
    }
}

 

MemberListControllerV2

package org.example.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;

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

public class MemberListControllerV2 implements ControllerV2 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) throws
            ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        // Model에 보관
        request.setAttribute("members", members);
        return new MyView("/WEB-INF/views/members.jsp");
    }
}

 

MemberSaveControllerV2

package org.example.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;

public class MemberSaveControllerV2 implements ControllerV2 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) throws
            ServletException, IOException {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        // Model에 데이터를 보관
        request.setAttribute("member", member);

        return new MyView("/WEB-INF/views/save-result.jsp");
    }
}

 

보다시피 이제 뷰에 대한 중복 코드가 컨트롤러에서 사라지고 깔끔하게 정리됐다. 이 작업만으로도 컨트롤러의 코드가 더 깔끔하고 보기 좋아졌다. 이제 이 컨트롤러의 process() 메서드를 호출하는 FrontController를 만들어야한다. 왜냐하면 이제 이것만 호출하고 끝이 아니라 얘가 반환하는 MyView 객체를 가지고 render() 메서드를 호출해줘야 한다.

 

FrontControllerServletV2

package org.example.servlet.web.frontcontroller.v2;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.controller.MemberFormControllerV2;
import org.example.servlet.web.frontcontroller.v2.controller.MemberListControllerV2;
import org.example.servlet.web.frontcontroller.v2.controller.MemberSaveControllerV2;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV2", urlPatterns = "/front-controller/v2/*")
public class FrontControllerServletV2 extends HttpServlet {

    private final Map<String, ControllerV2> controllerMap = new HashMap<>();

    public FrontControllerServletV2() {
        controllerMap.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
        controllerMap.put("/front-controller/v2/members/save", new MemberSaveControllerV2());
        controllerMap.put("/front-controller/v2/members", new MemberListControllerV2());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();

        ControllerV2 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyView view = controller.process(req, resp);
        view.render(req, resp);
    }
}

 

이제 이 FrontController는 꽤나 어깨가 무거워졌다. URI에 따라 어떤 컨트롤러를 호출할지 정해줘야하고, 컨트롤러가 호출해서 돌려준 MyView 객체로 뷰를 요청에 대한 응답으로 돌려줘야한다. 그래도 한 곳에서 관리하기 때문에 중복이 많이 제거됐다.

 

그렇지만, 여전히 뭔가 아쉽다. 예를 들면 다음 코드.

new MyView("/WEB-INF/views/save-result.jsp");

저 경로의 위치가 컨트롤러마다 전부 중복으로 쓰여지고 있다. 그리고 HttpServletRequest, HttpServletResponse가 필요가 없는 컨트롤러도 있다. MemberFormControllerV2를 보면 아예 사용자체가 안되지만 파라미터로 넘겨받고 있다.

 

그리고 다른 컨트롤러도 파라미터 가져오거나, setAttribute() 메서드로 모델에 데이터를 담는 작업 외엔 하는것도 없다. 그러니까 HttpServletResponse는 진짜로 하는게 없다. 그래서, 아예 HttpServletRequest, HttpServletResponse를 사용하지 말아보자.

필요한 파라미터나 모델을 담는것은 서블릿에 종속적이지 않아도 될 것 같다.

 

V3

그래서 ModelView라는 클래스를 만들자. 이 클래스는 모델과 뷰를 동시에 다루는 클래스이다.

어떤 뷰를 보여줄지에 대한 정보와 그 뷰에서 사용할 모델을 동시에 가지고 있는 것이다.

 

ModelView

package org.example.servlet.web.frontcontroller;

import lombok.Getter;
import lombok.Setter;

import java.util.HashMap;
import java.util.Map;

@Getter
@Setter
public class ModelView {
    private String viewName;
    private Map<String, Object> model = new HashMap<>();

    public ModelView(String viewName) {
        this.viewName = viewName;
    }
}

어떤 뷰를 보여줘야 하는지 알고 있는 viewName, 그 뷰에서 사용될 데이터를 담은 model이 있다.

이제 이 클래스를 반환타입으로 컨트롤러가 사용하면 된다.

 

ControllerV3

package org.example.servlet.web.frontcontroller.v3;

import org.example.servlet.web.frontcontroller.ModelView;

import java.util.Map;

public interface ControllerV3 {

    ModelView process(Map<String, String> paramMap);
}

이번엔 HttpServletRequest, HttpServletResponse가 필요가 없다. 받는 파라미터는 그저 해당 뷰에서 사용될 파라미터 정보뿐이다. 

 

이 ControllerV3를 구현할 세 가지 컨트롤러(Form, Save, List)를 만들자

 

MemberFormControllerV3

package org.example.servlet.web.frontcontroller.v3.controller;

import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;

import java.util.Map;

public class MemberFormControllerV3 implements ControllerV3 {
    @Override
    public ModelView process(Map<String, String> paramMap) {
        return new ModelView("new-form");
    }
}

 

MemberSaveControllerV3

package org.example.servlet.web.frontcontroller.v3.controller;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;

import java.util.Map;

public class MemberSaveControllerV3 implements ControllerV3 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public ModelView process(Map<String, String> paramMap) {
        String username = paramMap.get("username");
        int age = Integer.parseInt(paramMap.get("age"));

        Member member = new Member(username, age);

        memberRepository.save(member);

        ModelView mv = new ModelView("save-result");
        mv.getModel().put("member", member);

        return mv;
    }
}

 

MemberListControllerV3

package org.example.servlet.web.frontcontroller.v3.controller;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;

import java.util.List;
import java.util.Map;

public class MemberListControllerV3 implements ControllerV3 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public ModelView process(Map<String, String> paramMap) {
        List<Member> members = memberRepository.findAll();

        ModelView mv = new ModelView("members");
        mv.getModel().put("members", members);

        return mv;
    }
}

 

이제는 각각의 컨트롤러가 서블릿에 대한 종속이 전혀 없다. 보다시피 파라미터로 HttpServletRequest, HttpServletResponse를 받지도 않고 있다. 그리고 ModelView라는 직접 만든 클래스를 사용해서 보여줄 뷰의 이름과 그 뷰에서 사용할 모델을 처리한 후 이 ModelView 객체를 반환한다.

 

그럼 FrontController는 이제 이 반환값을 가지고 공통적으로 또 처리해 줄 것들을 처리하면 된다.

 

FrontControllerServletV3

package org.example.servlet.web.frontcontroller.v3;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {

    private final Map<String, ControllerV3> controllerMap = new HashMap<>();

    public FrontControllerServletV3() {
        controllerMap.put("/front-controller/v3/members/new-form", new MemberFormControllerV3());
        controllerMap.put("/front-controller/v3/members/save", new MemberSaveControllerV3());
        controllerMap.put("/front-controller/v3/members", new MemberListControllerV3());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();

        ControllerV3 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        Map<String, String> paramMap = createParamMap(req);

        ModelView mv = controller.process(paramMap);

        MyView view = viewResolver(mv);

        view.render(mv.getModel(), req, resp);
    }

    private static MyView viewResolver(ModelView mv) {
        return new MyView("/WEB-INF/views/" + mv.getViewName() + ".jsp");
    }

    private static Map<String, String> createParamMap(HttpServletRequest req) {
        Map<String, String> paramMap = new HashMap<>();
        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName ->
                        paramMap.put(paramName, req.getParameter(paramName))
                );
        return paramMap;
    }
}

 

FrontController는 두 가지 공통작업을 처리한다.

  • 각 컨트롤러가 필요한 파라미터에 대한 작업 (createParamMap())
  • 각 컨트롤러가 보여줄 뷰에 대한 경로 작업 (viewResolver())

그럼 끝인가? 아쉽지만 아니다. 어떤게 남았나면 이제 우리가 직접 만든 모델을 사용하기 때문에 그 모델에 담긴 데이터를 다시 서블릿의 request에 넣어줘야 한다. JSP는 HttpServletRequest 객체인 requestgetAttribute()를 통해서 데이터를 꺼내오기 때문에 꼭 해줘야 한다. 그게 새롭게 만든 MyViewrender()메서드이다.

public void render(Map<String, Object> model, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    model.forEach(req::setAttribute);

    RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
    dispatcher.forward(req, resp);
}

 

이렇게 하면, FrontController가 하는 일이 많아진 대신 각각의 세부 컨트롤러는 하는일이 더더욱 적어졌다. 그리고 FrontController가 중복적인 부분을 혼자 담당하기 때문에 변경이 필요하면 이 부분만 변경하면 된다. 예를 들면 뷰의 경로가

 

"/WEB-INF/views/" + mv.getViewName() + ".jsp" 여기서 "/WEB-INF/jsp/" + mv.getViewName() + ".jsp" 이렇게 변경되더라도 말이다.

 

그러나, 만든 V3 컨트롤러는 잘 설계된 컨트롤러는 맞지만 (서블릿 종속성을 제거하고, 뷰 경로의 중복을 제거하는 등) 실제 컨트롤러 인터페이스를 구현하는 개발자 입장에서 보면, 항상 ModelView 객체를 생성하고 반환해야 하는 부분이 조금은 번거롭다. 좋은 프레임워크는 아키텍쳐도 중요하지만, 그와 더불어 실제 개발하는 개발자가 단순하고 편리하게 사용할 수 있어야 한다. 소위 실용성이 있어야 한다.

 

이번에는 V3를 조금 변경해서 실제 구현하는 개발자들이 매우 편리하게 개발할 수 있는 V4 버전을 만들어보자!

 

V4

스프링은 ModelView를 반환하는 컨트롤러를 만들수도 있지만, 그냥 단순 스트링을 반환하고 그 스트링이 뷰의 이름이되는 반환을 하기도 한다. 이 V4는 그것을 똑같이 만들어보고 싶은것이다. 

 

지금 상태에서 크게 바꿀것도 없다. 우선 ControllerV4를 만들자.

 

ControllerV4

package org.example.servlet.web.frontcontroller.v4;

import java.util.Map;

public interface ControllerV4 {
    /**
     *
     * @param paramMap
     * @param model
     * @return
     */
    String process(Map<String, String> paramMap, Map<String, Object> model);
}

 

참고로, 저 메서드레벨의 주석 단축키가 굉장히 편한데 그냥 `/**` 입력하고 엔터만 치면 자동으로 저렇게 써준다.

 

이제는 V4는 한가지 파라미터를 더 받는다. model이다. 그래서 ModelView를 컨트롤러가 모두 반환해야 하는 불편함을 없애는 것.

이제 Form, List, Save 컨트롤러를 만들어보자.

 

MemberFormControllerV4

package org.example.servlet.web.frontcontroller.v4.controller;

import org.example.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.Map;

public class MemberFormControllerV4 implements ControllerV4 {
    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        return "new-form";
    }
}

이제 process() 메서드는 단순 스트링만을 반환할 수 있다. 귀찮게 ModelViewnew로 생성해서 반환할 필요가 사라졌다.

그리고 저 반환하는 스트링은 바로 뷰의 이름이 된다. 

MemberListControllerV4

package org.example.servlet.web.frontcontroller.v4.controller;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;
import org.example.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.List;
import java.util.Map;

public class MemberListControllerV4 implements ControllerV4 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    /**
     *
     * @param paramMap
     * @param model
     * @return
     */
    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        List<Member> members = memberRepository.findAll();

        model.put("members", members);

        return "members";
    }
}

모든 멤버들을 보여주는 MemberListControllerV4. 이것 역시 그냥 단순히 스트링을 반환한다. 마찬가지로 반환하는 문자열은 뷰의 이름이 된다.

MemberSaveControllerV4

package org.example.servlet.web.frontcontroller.v4.controller;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.Map;

public class MemberSaveControllerV4 implements ControllerV4 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        String username = paramMap.get("username");
        int age = Integer.parseInt(paramMap.get("age"));

        Member member = new Member(username, age);

        memberRepository.save(member);

        model.put("member", member);

        return "save-result";
    }
}

이번엔 멤버를 저장하는 컨트롤러이다. 마찬가지다. 변경되는 지점은 받는 파라미터에 model이 추가되고, 반환 타입이 String.

이렇게 컨트롤러들을 전부 변경했으니 FrontController도 변경해보자.

 

FrontControllerServletV4

package org.example.servlet.web.frontcontroller.v4;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {

    private final Map<String, ControllerV4> controllerMap = new HashMap<>();

    public FrontControllerServletV4() {
        controllerMap.put("/front-controller/v4/members/new-form", new MemberFormControllerV4());
        controllerMap.put("/front-controller/v4/members/save", new MemberSaveControllerV4());
        controllerMap.put("/front-controller/v4/members", new MemberListControllerV4());
    }

    /**
     *
     * @param req
     * @param resp
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();

        ControllerV4 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        Map<String, String> paramMap = createParamMap(req);
        Map<String, Object> model = new HashMap<>();

        String viewName = controller.process(paramMap, model);

        MyView view = viewResolver(viewName);

        view.render(model, req, resp);
    }

    private static MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }

    private static Map<String, String> createParamMap(HttpServletRequest req) {
        Map<String, String> paramMap = new HashMap<>();
        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName ->
                        paramMap.put(paramName, req.getParameter(paramName))
                );
        return paramMap;
    }
}

여기서 변경되는 부분은 service()에서 적절한 컨트롤러의 process()를 실행할 때 model을 추가적으로 넘겨주는 것과 받는 반환타입이 바로 viewResolver()의 파라미터로 들어간다는 점이다. 그리고 view.render()의 첫번째 파라미터가 model이 되면 된다.

 

이러면 모든게 기존과 동일하게 동작한다. 어떤 부분에서 더 유연해졌냐?

  • 실제 컨트롤러들(FrontController 말고)이 굳이 ModelView를 매번 새로운 인스턴스로 만들어내지 않아도 된다. 특히 모델이 아예 필요없고 뷰의 논리 이름만을 위해서 만드는 MemberFormController의 경우 정말 비효율적인 방식이었는데 이를 깔끔하게 해결해준다.

 

이 방식이 바로 스프링이 컨트롤러를 만들때 ModelView를 반환해도 가능하고 단순 String을 반환해도 상관없는 이유이다. 

"어?! 근데 지금 코드는 단순 String만 반환 가능할 거 같은데요?" 맞다. 왜냐하면, 이 FrontControllerV4는 컨트롤러를 확정짓기 위해 사용되는 controllerMapValueControllerV4로 한정되어 있다. 그러나 스프링은 두 가지 경우 모두 지원한다. 즉, 더 유연하다는 소리고 그 방법을 V5에서 알아보자!

 

V5

지금까지의 구조는 다음과 같다.

위 구조를 흐름대로 설명하면 다음과 같은 흐름이 발생한다.

  • 사용자로부터 요청이 들어온다.
  • 요청을 최초에 FrontControllerV4가 받는다.
  • 요청 URL에 따라 처리 가능한 컨트롤러를 FrontControllerV4는 찾고 그 컨트롤러를 호출한다.
  • 해당 컨트롤러에서 필요한 수행 작업을 모두 마친 후 보여줄 화면에 대한 뷰 이름을 가진 반환값을 FrontControllerV4에게 돌려준다.
  • 받은 뷰 이름을 전체 이름으로 변경해주는 viewResolver()FrontControllerV4가 호출한다.
  • 호출해서 받은 전체 뷰 경로를 가지고 MyView 객체의 render()를 호출해서 사용자에게 최종 화면을 보여준다.

여기서 개선될 부분은 개발자가 "나는 V4 형태 말고 V3 형태로 컨트롤러를 만들고 싶어!"라고 할 때 그러지 못한다는 점이다. 이 점을 가능하게 변경해보자. 

 

우선, MyHandlerAdapter라는 인터페이스 하나가 필요하다.

MyHandlerAdapter

package org.example.servlet.web.frontcontroller.v5;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;

import java.io.IOException;

public interface MyHandlerAdapter {
    boolean supports(Object handler);

    ModelView handle(HttpServletRequest req, HttpServletResponse resp, Object handler)
            throws ServletException, IOException;
}

 

이 인터페이스는 구현체를 봐야 좀 더 이해가 명확하게 되니까 구현체도 바로 만들자.

ControllerV3HandlerAdapter

package org.example.servlet.web.frontcontroller.v5.adapter;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;
import org.example.servlet.web.frontcontroller.v5.MyHandlerAdapter;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class ControllerV3HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV3);
    }

    @Override
    public ModelView handle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws
            ServletException, IOException {
        ControllerV3 controller = (ControllerV3) handler;

        Map<String, String> paramMap = createParamMap(req);

        return controller.process(paramMap);
    }

    private static Map<String, String> createParamMap(HttpServletRequest req) {
        Map<String, String> paramMap = new HashMap<>();
        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName ->
                        paramMap.put(paramName, req.getParameter(paramName))
                );
        return paramMap;
    }
}

보면, ControllerV3HandlerAdapter라는 구현체가 있다. 이 구현체가 하는 역할은 다음과 같다.

  • supports()에서 들어온 파라미터가 ControllerV3 타입인지를 판단한다. 만약 그렇다면, 참을 반환한다.
  • handle()supports()가 참을 반환했을 때 유효한 메서드이다. 들어온 handlerControllerV3 타입이라면 이 handle()ControllerV3가 했던 동작을 그대로 할 뿐이다.

이 둘만 가지고는 이해가 제대로 되지 않는다. FrontControllerV5를 만들어보자.

FrontControllerServletV5

package org.example.servlet.web.frontcontroller.v5;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {

    private final Map<String, Object> handlerMappingMap = new HashMap<>();

    private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();

    public FrontControllerServletV5() {
        initHandlerMappingMap();
        initHandlerAdapters();
    }

    private void initHandlerMappingMap() {
        handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
    }

    private void initHandlerAdapters() {
        handlerAdapters.add(new ControllerV3HandlerAdapter());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Object handler = getHandler(req);
        if (handler == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyHandlerAdapter handlerAdapter = getHandlerAdapter(handler);
        ModelView mv = handlerAdapter.handle(req, resp, handler);

        MyView view = viewResolver(mv.getViewName());

        view.render(mv.getModel(), req, resp);
    }

    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter handlerAdapter : handlerAdapters) {
            if (handlerAdapter.supports(handler)) {
                return handlerAdapter;
            }
        }
        throw new IllegalArgumentException("handler not found for class " + handler.getClass().getName());
    }

    private Object getHandler(HttpServletRequest req) {
        String requestURI = req.getRequestURI();
        return handlerMappingMap.get(requestURI);
    }

    private static MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}
  • handlerMappingMap URL에 따라 처리하는 컨트롤러를 모두 저장해 놓는 Map이다.
  • handlerAdapters는 모든 MyHandlerAdapter 타입의 객체를 담는 List이다.
  • 생성자에서 두 가지 메서드를 호출한다. initHandlerMappingMap(), initHandlerAdapters().
  • initHandlerMappingMap()은 요청 URL에 따라 처리를 할 수 있는 컨트롤러를 모두 Map에 추가해주는 메서드이다. 위 코드를 보면 `/front-controller/v5/v3/members`로 들어온 URL은 MemberListControllerV3로 Key/Value를 가진다.
  • initHandlerAdapters()MyHandlerAdapter 타입의 모든 인스턴스를 추가한다. 위 코드를 보면 ControllerV3HandlerAdapter를 추가했음을 알 수 있다.

이제 여기서부터 실제 흐름이다. 어떤 흐름을 통해 동작하는지 이해해보자.

  • 첫번째로 FrontControllerV5로 모든 사용자의 요청이 들어오게 된다. 
  • 사용자의 요청에 따라 첫번째로 할 일은 요청 URL과 매핑해둔 컨트롤러(핸들러)를 찾는다. -> `getHandler()`
  • 만약 핸들러를 찾지 못했다면, NOT_FOUND 에러를 내보낸다.
  • 핸들러를 찾았다면, 그 핸들러를 통해 핸들러 어답터를 찾는다. 즉, 찾은 핸들러가 만약 MemberListControllerV3였다면, 이 핸들러를 통해 handlerAdapters에서 쭉 루프를 돌면서 핸들러 어답터를 찾는다. 어떻게 찾을까? handlerAdapters에는 모든 MyHandlerAdapter를 구현한 구현체가 들어있게 된다. 각 구현체는 supports()를 구현해야 하는데 이 메서드는 들어온 파라미터가 ControllerV3, ControllerV4 타입인지를 체크한다. 찾았다면 해당 핸들러어답터를 가져오고 찾지 못했다면 에러를 던진다.
  • 가져온 핸들러 어답터의 handle()을 호출한다. 이 handle()은 V3 버전인지 V4 버전인지에 따라 처리하는 로직을 구분지어 각 버전에 맞게 컨트롤러가 처리하는 로직이 담겨있다.

 

이렇게 어떤 버전의 컨트롤러라도 처리할 수 있는 FrontControllerV5가 만들어지게 된다. 스프링도 이렇게 구현해 두었다. 그래서 스프링도 컨트롤러의 메서드 중 반환타입이 단순 문자열인 메서드가 존재하고 ModelAndView 타입의 메서드가 존재해도 상관이 없는 것이다.

 

그럼 V3 관련 컨트롤러를 처리해 놨으니 V4도 어댑터를 만들어보자.

ControllerV4HandlerAdapter

package org.example.servlet.web.frontcontroller.v5.adapter;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v4.ControllerV4;
import org.example.servlet.web.frontcontroller.v5.MyHandlerAdapter;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class ControllerV4HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV4);
    }

    @Override
    public ModelView handle(HttpServletRequest req, HttpServletResponse resp, Object handler)
            throws ServletException, IOException {
        ControllerV4 controller = (ControllerV4) handler;

        Map<String, String> paramMap = createParamMap(req);
        Map<String, Object> model = new HashMap<>();

        String viewName = controller.process(paramMap, model);

        ModelView mv = new ModelView(viewName);
        mv.setModel(model);

        return mv;
    }

    private static Map<String, String> createParamMap(HttpServletRequest req) {
        Map<String, String> paramMap = new HashMap<>();
        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName ->
                        paramMap.put(paramName, req.getParameter(paramName))
                );
        return paramMap;
    }
}

마찬가지로 이 V4 핸들러 어댑터는 MyHandlerAdapter를 구현한다. 그리고 구현 내용이 V3가 아닌 V4일 뿐이다.

 

그리고 FrontControllerV5를 보자.

FrontControllerServletV5

package org.example.servlet.web.frontcontroller.v5;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import org.example.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV4HandlerAdapter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {

    private final Map<String, Object> handlerMappingMap = new HashMap<>();

    private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();

    public FrontControllerServletV5() {
        initHandlerMappingMap();
        initHandlerAdapters();
    }

    private void initHandlerMappingMap() {
        handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
        handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
    }

    private void initHandlerAdapters() {
        handlerAdapters.add(new ControllerV3HandlerAdapter());
        handlerAdapters.add(new ControllerV4HandlerAdapter());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Object handler = getHandler(req);
        if (handler == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyHandlerAdapter handlerAdapter = getHandlerAdapter(handler);
        ModelView mv = handlerAdapter.handle(req, resp, handler);

        MyView view = viewResolver(mv.getViewName());

        view.render(mv.getModel(), req, resp);
    }

    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter handlerAdapter : handlerAdapters) {
            if (handlerAdapter.supports(handler)) {
                return handlerAdapter;
            }
        }
        throw new IllegalArgumentException("handler not found for class " + handler.getClass().getName());
    }

    private Object getHandler(HttpServletRequest req) {
        String requestURI = req.getRequestURI();
        return handlerMappingMap.get(requestURI);
    }

    private static MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}

변경되는 부분은 handlerAdaptersV4HandlerAdapter가 추가되고, handlerMappingMap에 V4관련 컨트롤러를 추가한 것뿐이다. 그 외에는 아무런 변경사항이 없다. 심지어 이 handlerAdaptershandlerMappingMap도 밖에서 주입받게 해 놓으면 아예 이 FrontCotrollerV5는 변경 사항이 아예 없어진다. 이게 변경에는 닫혀있고 확장에는 열려있는 OCP 원칙이다.

 

스프링도 이와 유사한 (거의 똑같다) 구조를 가지고 있고 이렇게 만들어 놓다가 애노테이션 기반의 컨트롤러가 대세가 되면서 애노테이션 기반의 컨트롤러를 처리할 수 있는 어댑터를 하나 만들어서 이 핸들러 어댑터에 추가만 해줄뿐이다. 그러니까 확장이 너무 유연해지고 간결해지는 것이다. 

 

결론

그레서 최종 V5의 모습은 이와 같다.

  • 중간에 핸들러 어댑터라는게 추가됐다. 이 핸들러 어댑터 덕분에 여러 버전의 컨트롤러를 만들어도 아무런 문제없이 해당 버전에 맞는 컨트롤러 처리를 할 수 있게 됐다.
  • 핸들러는 그저 컨트롤러의 다른말 일뿐이다.

이 구조가 바로 Spring MVC 구조이다. 그리고 잠깐 위에서 말했지만 애노테이션 기반의 컨트롤러가 대세가 되면서 이 애노테이션 기반의 컨트롤러를 처리할 수 있는 핸들러 어댑터 하나만 만들어주면 되는 식으로 확장이 용이하다고 했는데 스프링에서 이 핸들러 어댑터 이름이 바로 "RequestMappingHandlerAdapter"이다. 느낌이 바로 오지 않는가? V5 구조에서 만든 핸들러 어댑터 이름은 ControllerV4HandlerAdapter, ControllerV5HandlerAdapter였다. 이제 @RequestMapping("/hello") 이런 애노테이션 기반의 컨트롤러를 처리할 수 있는 핸들러 어댑터가 필요하니까 핸들러 어댑터를 만들었는데 그 이름이 저것인거다. 

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

참고자료:

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런

김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습

www.inflearn.com

 

MVC 패턴이란건 왜 나왔는가? 이전 포스팅까지 그 이유를 알아봤다.

서블릿, JSP를 사용해보니 여러 불편한 점이 많았고 그 중 JSP는 서블릿보단 HTML을 만들어내기가 쉽지만 담당하고 있는게 너무 많아져버린다. 화면과 비즈니스 로직을 전부 담당하고 나니 지저분해지고 보기가 힘들어진다. 이는 곧 유지보수가 어려워진다. 

 

그래서 화면은 딱 화면을 담당하는 쪽에서만, 비즈니스 로직은 비즈니스 로직을 담당하는 쪽에서만 관리하고 처리하게 하고 싶은것이다.

 

그리고 또 하나는 둘 간의 변경 사이클이 다를 확률이 높다. 무슨 말이냐면 화면에 보이는 버튼의 위치를 바꾸고 싶다는 요구사항이 생길 때 비즈니스 로직을 건들 필요가 없다. 반대로 비즈니스 로직을 해결한 기술을 바꾸고 싶을 때 화면을 구성하는 어떤 부분도 변경할 필요가 없다. 근데 두 코드가 같은 파일에 있다는 것은 유지보수하기 좋지 않다. 

 

그래서 MVC 패턴이 등장한다. Model, View, Controller로 영역을 나눈것이다.

  • 컨트롤러: HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다(호출한다). 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.
  • 모델: 뷰에 출력할 데이터를 담아둔다. 뷰가 필요한 데이터를 모두 모델에 담아서 전달해주는 덕분에 뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고, 화면을 렌더링 하는 일에 집중할 수 있다.
  • : 모델에 담겨있는 데이터를 사용해서 화면을 그리는 일에 집중한다. 여기서는 HTML을 생성하는 부분을 말한다.
참고: 컨트롤러가 비즈니스 로직을 실행할 수도 있다. 근데 사이즈가 조금만 크다면 실행하지 말고 호출해라. 비즈니스 로직을 수행하는 부분을 컨트롤러로부터 떼어내는 것이다. 일반적으로 잘 알려진 '서비스'라는 레이어로 말이다.

 

 

 

그래서, 지금부터 할 내용은 작성한 JSP 파일에서 비즈니스 로직과 뷰 부분을 떼어낼 것이다. 서블릿을 컨트롤러로 사용하고 JSP를 뷰로 사용해서 MVC 패턴을 적용해보자.

서블릿을 컨트롤러로, JSP를 뷰로

우선, 서블릿 하나를 만들자. 멤버를 생성하는 폼에 대한 컨트롤러이다.

경로는 /path/your/package/web/servletmvc로 만들었다.

 

MvcMemberFormServlet

package org.example.servlet.web.servletmvc;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "mvcMemberFormServlet", urlPatterns = "/servlet-mvc/members/new-form")
public class MvcMemberFormServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        String viewPath = "/WEB-INF/views/new-form.jsp";
        RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
        dispatcher.forward(req, resp);
    }
}

여기서 보면, 화면으로 뿌려줄 JSP파일의 경로를 작성해서 RequestDispatcher 객체로 만들었다. 이 객체는 서버로 이 요청이 들어오면 forward()를 통해 JSP파일로 요청을 전달해버리는 기능을 가진다. 리다이렉트와 유사한것 같지만 리다이렉트는 클라이언트와 통신을 두번한다.

 

최초의 요청 -> 서버는 리다이렉트 경로를 알려주는 정보를 가지고 응답 -> 응답 데이터에 있는 리다이렉트 확인 ->  다시 해당 정보로 서버에 요청 -> 서버가 응답.

 

이게 리다이렉트라면 이 forward()는 요청이 들어와서 서버 내부에서 호출을 해서 최종 결과를 클라이언트에게 전달해준다. 그래서 클라이언트와 서버 간 통신은 한번뿐이다. 

 

그리고 WEB-INF는 뭐냐면 기존에는 webapp안에 jsp/members/new-form.jsp 이렇게 경로를 지정해서 JSP 파일을 만들었다. 그리고 이 경로 그대로 URL에 입력하면 JSP 파일이 딱 브라우저에 뜬다. 근데 그걸 못하게 하는 것이다. WEB-INF 내부에 있는 자원들은 외부에서 직접적으로 접근하지 못하고 항상 컨트롤러를 통해 호출된다. 

 

src/main/resources/WEB-INF/views/new-form.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
<form action="save" method="post">
    username: <input type="text" name="username" />
    age:      <input type="text" name="age" />
    <button type="submit">전송</button>
</form>
</body>
</html>

여기서는 action의 경로를 유심히 봐야한다. 단순히 "save"가 끝이다. 즉, 상대경로로 POST 요청이 날라가게 설정했다. 이렇게 해두면 현재 URL에 뒤에 "/save"가 붙은 경로로 보낸다.

 

예를 들어 이 new-form.jsp를 보여주기 위한 URL은 "http://localhost:8080/servlet-mvc/members/new-form"이다. 그럼 이제 전송 버튼을 클릭하면 경로가 "http://localhost:8080/servlet-mvc/members/save"인 곳으로 POST 요청이 날라간다.

 

그럼 이제 멤버를 저장할 서블릿과 JSP를 만들어야한다.

MvcMemberSaveServlet

package org.example.servlet.web.servletmvc;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;

import java.io.IOException;

@WebServlet(name = "mvcMemberSaveServlet", urlPatterns = "/servlet-mvc/members/save")
public class MvcMemberSaveServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String username = req.getParameter("username");
        int age = Integer.parseInt(req.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        // Model에 데이터를 보관
        req.setAttribute("member", member);

        String viewPath = "/WEB-INF/views/save-result.jsp";
        RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
        dispatcher.forward(req, resp);
    }
}

 

여기서 중요한 건 모델을 만들어서 뷰에게 전달해줘야한다. 그 부분이 바로 req.setAttribute("member", member);이다.

그 다음은 똑같이 forward()를 통해 JSP 파일을 호출한다.

 

src/main/resources/WEB-INF/views/save-result.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
성공
<ul>
    <li>id=${member.id}</li>
    <li>username=${member.username}</li>
    <li>age=${member.age}</li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>

모델에 담긴 데이터를 꺼내는 방법은 "${}"를 사용하면 된다. 해당 객체가 가지고 있는 프로퍼티를 그대로 꺼내서 사용한다.

한번 잘 나오는지 직접 테스트해보자.

 

이제 회원목록을 보여주는 서블릿과 JSP 파일을 작성하자.

MvcMemberListServlet

package org.example.servlet.web.servletmvc;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;

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

@WebServlet(name = "mvcMemberListServlet", urlPatterns = "/servlet-mvc/members")
public class MvcMemberListServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        // Model에 보관
        req.setAttribute("members", members);

        String viewPath = "/WEB-INF/views/members.jsp";
        RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
        dispatcher.forward(req, resp);
    }
}

 

src/main/resources/WEB-INF/views/members.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
<table>
    <thead>
    <th>id</th>
    <th>username</th>
    <th>age</th>
    </thead>
    <tbody>
    <c:forEach var="item" items="${members}">
        <tr>
            <td>${item.id}</td>
            <td>${item.username}</td>
            <td>${item.age}</td>
        </tr>
    </c:forEach>
    </tbody>
</table>
</body>
</html>

 

JSP에서는 여러 데이터를 하나씩 뽑아서 뿌려주는 방법이 있는데 그 중 하나가 이 녀석을 사용하는 것이다.

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

근데 이거 진짜 그냥 이런게 있구나하고 넘어가도 된다. 필요하면 찾아보면 되는거고 JSP 쓸 일 거의 없다. 이렇게 하고 리스트를 보면 잘 나온다.

 

 

결론

이렇게 서블릿과 JSP를 사용해서 한 곳에서 작성되던 뷰와 비즈니스 로직을 쪼개서 각자가 자기것만 잘 담당할 수 있도록 해봤다. 그러다보니 코드가 이전보다 깔끔해졌다. 그러나 여전히 100% 만족스럽지 않다. 왜냐하면 서블릿에서 지금 중복 코드가 계속 반복되고 있기 때문이다. 다음 코드를 보자.

String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
dispatcher.forward(req, resp);
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
dispatcher.forward(req, resp);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
dispatcher.forward(req, resp);

 

viewPath의 실제 경로 말고 모든게 똑같다. 이런 공통적으로 처리될 부분들이 계속해서 중복되고 있다. 이걸 처리하는 유틸성 메서드를 만들면 조금 더 나아지겠지만 그것을 매번 호출하는 것 역시 중복이다. 그래서 이것을 더 개선하고 싶어진다.

 

공통된 부분들은 앞에서 미리 다 처리하고 들어오는 방식으로 말이다. 수문장 하나가 맨 앞에서 모든 요청을 받고 그 요청마다 공통적으로 처리되는 부분들을 거기서 전부 해결하고난 후 각각의 컨트롤러로 요청을 전달해주는 것이다. 이런 패턴을 Front Controller 패턴이라고 하고 스프링 MVC도 이 패턴을 잘 구현한 방식이다.

 

한번 직접 이 Front Controller를 만들고 MVC 패턴을 구현해보자. 

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

우선, 이 페이지가 세션 기반 인증 방식이 아닌 이유를 먼저 설명해야 할 것 같다.

왜 JWT를 사용했나?

모바일 앱의 등장이 주 원인이라고 할 수 있다. 모바일 앱을 추후에 만든다고 해도 통합된 인증 체계로부터 얻는 이점이 분명히 있다고 생각했다.

 

모바일 앱을 사용할 땐 주로 토큰 기반 인증 방식을 사용한다. 그 이유는 다음과 같다.

  • 장기 세션 유지의 어려움: 모바일 디바이스는 종종 네트워크 연결 상태가 변할 수 있고, 앱이 백그라운드에서 종료되거나 장치가 재부팅될 수 있다. 세션은 이런 환경에서는 연결이 끊기기 쉬워 토큰 방식이 더 안정적인 경우가 많다.
  • 스케일러빌리티: 세션 정보를 서버에서 관리해야 할 경우, 사용자가 많아질수록 서버의 부담이 커질 수 있다. 반면 토큰은 클라이언트 측에서 관리되기 때문에 서버는 인증을 확인만 하면 되므로 부하가 줄어든다.
  • 다양한 플랫폼 지원: 모바일 앱뿐만 아니라 웹사이트, 다른 종류의 클라이언트에서도 동일한 방식으로 인증 시스템을 구현할 수 있다. 이는 통합된 인증 체계를 유지하기에 유리하다.

이러한 이유들이 내가 인증 방식을 JWT로 구현하고 싶어지게 했다. 

우선, 개발 환경은 다음과 같다.

  • Spring Boot 3.2.2
  • Java 21
  • Spring Security 3.2.2
  • io.jsonwebtoken:jjwt 0.12.3
  • MySQL 8

로그인 흐름

유저가 /login 으로 username, password를 같이 보내면 스프링 시큐리티 내부적으로는 UsernamePasswordAuthenticationFilter라는 필터를 통해 해당 유저가 현재 Database에 존재하는 유저인지 찾고 그 유저의 로그인 정보(ID, PW)가 일치하는지 확인해서 맞다면 JWT토큰을 만들어서 유저에게 반환한다. (그림에서는 JWT 토큰 반환은 생략)

 

인증과 인가의 차이가 뭔가요?
  • 인증(Authentication): 인증은 사용자가 누구인지 확인하는 과정. 사용자가 시스템에 로그인할 때 아이디와 비밀번호를 제공하는 것이 대표적인 예. 스프링 시큐리티에서는 AuthenticationManager가 이 역할을 담당하고, 사용자의 신원을 확인한 후 'Authentication' 객체에 이 정보를 저장한다.
  • 인가(Authorization): 인가는 인증된 사용자가 특정 자원에 접근하거나 특정 작업을 수행할 수 있는 권한을 가지고 있는지 확인하는 과정. 예를 들어 어떤 사용자가 특정 페이지에 접근하거나 데이터를 수정할 권한이 있는지 검사하는 것

 

Unauthorization과 Forbidden의 차이는요?
  • Unauthorization(401): 인증이 실패했을 때 반환. 아이디나 비밀번호가 잘못됐거나 아예 제공되지 않았을 때. 즉, 사용자가 누구인지 시스템이 식별하지 못했을 때 발생
  • Forbidden(403): 사용자 인증은 성공적으로 마쳤지만, 요청한 자원에 대한 접근 권한이 없을 때 반환. 예를 들어, 사용자가 로그인은 했지만 관리자 페이지에 접근하려 할 때 해당 페이지에 대한 접근 권한이 없는 경우 발생.

정리하자면, "Unauthorization은 당신이 누구인지 모르겠으니 로그인이 필요합니다"라는 의미이고, Forbidden은 "당신이 누구인지 알겠지만, 이 작업을 수행할 권한이 없습니다"라는 의미이다.

 

 

의존성

build.gradle

//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

//JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

//MySQL
implementation 'mysql:mysql-connector-java:8.0.33'

엔티티 설계 및 구현과 레포지토리

내가 만드는 서비스에 접속할 수 있는 유저는 오로지 어드민뿐이다. 그 중에서도 선택받은 어드민 유저만 접속하게 하기 위해 Administrator라는 엔티티를 만들었다.

 

Administrator (src/main/java/path/your/package/entity/Administrator.java)

import jakarta.persistence.*;
import kr.co.tbell.mm.entity.BaseEntity;
import lombok.*;

@Getter
@Entity
@Builder
@ToString
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Administrator extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;
    private String password;

    @Enumerated(value = EnumType.STRING)
    private Role role;

    @Column(nullable = false)
    private boolean isExpired;
    @Column(nullable = false)
    private boolean isLocked;
    @Column(nullable = false)
    private boolean isCredentialsExpired;

    @Builder.Default
    @Column(nullable = false)
    private boolean isEnabled = true;

    public boolean isAccountNonExpired() {
        return !isExpired;
    }

    public boolean isAccountNonLocked() {
        return !isLocked;
    }

    public boolean isCredentialsNonExpired() {
        return !isCredentialsExpired;
    }

    public boolean isEnabled() {
        return isEnabled;
    }
}

 

Role Enum 클래스를 보자. 이 Enum 클래스에선 Role이 추가될수도 아닐수도 있겠지만 스프링 시큐리티에서 롤 관련 인가 정책을 잘 만들어 두었기 때문에 사용해보기로 했다.

 

Role (src/main/java/path/your/package/entity/Role.java)

import lombok.Getter;

import java.util.Arrays;

@Getter
public enum Role {
    ROLE_ADMIN("ROLE_ADMIN");

    private final String description;

    Role(String description) {
        this.description = description;
    }

    public static Role getRole(String description) {
        return Arrays.stream(Role.values())
                .filter(role -> role.description.equals(description))
                .findFirst()
                .orElse(null);
    }
}

 

AdministratorRepository (src/main/java/path/your/package/repository/AdministratorRepository.java)

레포지토리는 간단하다. 추후에 구현해야 할 UserDetailsService 인터페이스를 위해 findByUsername()을 인터페이스에 추가했다.

import kr.co.tbell.mm.entity.administrator.Administrator;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface AdministratorRepository extends JpaRepository<Administrator, Long> {
    Optional<Administrator> findByUsername(String username);
}

 

회원가입

회원가입을 해야 로그인을 할 수 있으니까 회원가입 컨트롤러 - 서비스 레벨을 구현한다.

회원가입 컨트롤러

AdministratorController (src/main/java/path/your/package/controller/AdministratorController.java)

import jakarta.validation.Valid;
import kr.co.tbell.mm.dto.administrator.CustomAdministratorDetails;
import kr.co.tbell.mm.dto.administrator.ReqCreateAdministrator;
import kr.co.tbell.mm.dto.administrator.ResCreateAdministrator;
import kr.co.tbell.mm.dto.common.Response;
import kr.co.tbell.mm.entity.administrator.Administrator;
import kr.co.tbell.mm.entity.administrator.Role;
import kr.co.tbell.mm.service.administrator.AdministratorService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import javax.management.InstanceAlreadyExistsException;

@Slf4j
@RestController
@RequestMapping("/api/v1/admin")
@RequiredArgsConstructor
public class AdministratorController {

    private final AdministratorService administratorService;

    @PostMapping("/signup")
    public ResponseEntity<Response<ResCreateAdministrator>> signup(
            @RequestBody @Valid ReqCreateAdministrator reqCreateAdministrator) {
        ResCreateAdministrator administrator;

        try {
            administrator = administratorService.createAdministrator(reqCreateAdministrator);
        } catch (InstanceAlreadyExistsException e) {
            return ResponseEntity
                    .status(HttpStatus.BAD_REQUEST)
                    .body(new Response<>(false, e.getMessage(), null));
        }

        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(new Response<>(true, null, administrator));
    }
}

 

위 컨트롤러에서 사용하는 DTO정보는 다음과 같다.

 

ReqCreateAdministrator (src/main/java/path/your/package/dto/ReqCreateAdministrator.java)

import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ReqCreateAdministrator {
    @NotNull(message = "'username' must be required.")
    private String username;
    @NotNull(message = "'password' must be required.")
    private String password;
}

 

ResCreateAdministrator (src/main/java/path/your/package/dto/ResCreateAdministrator.java)

import kr.co.tbell.mm.entity.administrator.Role;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ResCreateAdministrator {
    private Long id;
    private String username;
    private Role role;
}

회원가입 서비스 

AdministratorService (src/main/java/path/your/package/service/AdministratorService.java)

import kr.co.tbell.mm.dto.administrator.ReqCreateAdministrator;
import kr.co.tbell.mm.dto.administrator.ResCreateAdministrator;

import javax.management.InstanceAlreadyExistsException;

public interface AdministratorService {
    ResCreateAdministrator createAdministrator(ReqCreateAdministrator reqCreateAdministrator)
            throws InstanceAlreadyExistsException;
}

 

AdministratorServiceImpl (src/main/java/path/your/package/service/AdministratorServiceImpl.java)

import kr.co.tbell.mm.dto.administrator.*;
import kr.co.tbell.mm.entity.administrator.Administrator;
import kr.co.tbell.mm.entity.administrator.Role;
import kr.co.tbell.mm.repository.administrator.AdministratorRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import javax.management.InstanceAlreadyExistsException;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class AdministratorServiceImpl implements AdministratorService, UserDetailsService {

    private final AdministratorRepository administratorRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public ResCreateAdministrator createAdministrator(ReqCreateAdministrator reqCreateAdministrator)
            throws InstanceAlreadyExistsException {
        Optional<Administrator> adminOptional =
                administratorRepository.findByUsername(reqCreateAdministrator.getUsername());

        if (adminOptional.isPresent()) {
            throw new InstanceAlreadyExistsException("Admin already exists with username: "
                    + reqCreateAdministrator.getUsername());
        }

        Administrator admin = Administrator
                .builder()
                .username(reqCreateAdministrator.getUsername())
                .password(passwordEncoder.encode(reqCreateAdministrator.getPassword()))
                .role(Role.ROLE_ADMIN)
                .build();

        Administrator savedAdmin = administratorRepository.save(admin);

        return new ResCreateAdministrator(savedAdmin.getId(), savedAdmin.getUsername(), savedAdmin.getRole());
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Administrator> byUsername = administratorRepository.findByUsername(username);
        if (byUsername.isEmpty()) {
            throw new UsernameNotFoundException(username);
        }

        return new CustomAdministratorDetails(byUsername.get());
    }
}

AdministratorServiceImpl에서는 두개의 인터페이스를 구현한다. AdministratorSerivce, UserDetailsService.

UserDetailsService는 스프링 시큐리티에서 회원 인증을 위해 구현해야 하는 인터페이스다. 이 인터페이스를 구현해서 데이터베이스로부터 특정 'Username'을 가진 유저가 있는지 찾아, 있다면 UserDetails 라는 인터페이스 타입의 클래스를 반환한다. UserDetails도 마찬가지로 직접 구현해야한다.

 

CustomAdministratorDetails (src/main/java/path/your/package/dto/CustomAdministratorDetails.java)

이 클래스가 UserDetails를 구현하는 클래스이다.

import kr.co.tbell.mm.entity.administrator.Administrator;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@RequiredArgsConstructor
public class CustomAdministratorDetails implements UserDetails {

    private final Administrator administrator;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return administrator.getRole().getDescription();
            }
        });

        return collection;
    }

    @Override
    public String getPassword() {
        return administrator.getPassword();
    }

    @Override
    public String getUsername() {
        return administrator.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return administrator.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return administrator.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return administrator.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return administrator.isEnabled();
    }
}

 

SecurityConfiguration

SecurityConfiguration (src/main/java/path/your/package/config/SecurityConfiguration.java)

import jakarta.servlet.http.HttpServletRequest;
import kr.co.tbell.mm.entity.administrator.Role;
import kr.co.tbell.mm.jwt.JwtFilter;
import kr.co.tbell.mm.jwt.JwtManager;
import kr.co.tbell.mm.jwt.LoginFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import java.util.Collections;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtManager jwtManager;
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // JWT 방식을 사용하기 때문에 CSRF 공격에 대한 위험성 X
        http.csrf(AbstractHttpConfigurer::disable);

        // JWT 방식을 사용하기 때문에 FormLogin, Basic 방식을 Disable
        http.formLogin(AbstractHttpConfigurer::disable);
        http.httpBasic(AbstractHttpConfigurer::disable);

        // 한개의 아이디에 대해 최대 중복 로그인 개수 maximumSessions
        // maxSessionPreventsLogin 다중 로그인 개수를 초과했을 때 처리방법. true: 새로운 로그인 차단, false: 기존 세션 하나 삭제
        // JWT 방식을 사용하기 때문에 SessionCreationPolicy STATELESS
        http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // AntPathRequestMatcher 객체를 사용한 이유는 개인적으로 가시성이 좀 더 좋고 어떤 Http Method인지 바로 알 수 있어서 사용
        // 굳이 안 사용해도 된다.
        http.authorizeHttpRequests(request ->
                request.requestMatchers(
                        new AntPathRequestMatcher("/api/v1/admin/signup", "POST"),
                        new AntPathRequestMatcher("/login", "POST")
                        ).permitAll()
                        .anyRequest().hasRole("ADMIN"));

        // LoginFilter 앞에 JwtFilter 등록
        http.addFilterBefore(new JwtFilter(jwtManager), LoginFilter.class);

        // addFilterAt은 정확히 그 필터(UsernamePasswordAuthenticationFilter)를 내가 만든 LoginFilter로 대체하겠다는 메서드.
        // addFilterBefore, addFilterAfter 이 것들은 말 그대로 그 전 또는 그 후에 붙이겠다는 의미
        http.addFilterAt(
                new LoginFilter(authenticationManager(authenticationConfiguration), jwtManager),
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

 

위 코드에서 URL Path에 따라 인증이 필요한지 또는 특정 Role을 가진 유저인지 확인하는 다음 코드를 보자.

http.authorizeHttpRequests(request ->
    request.requestMatchers(
            new AntPathRequestMatcher("/api/v1/admin/signup", "POST"),
            new AntPathRequestMatcher("/login", "POST")
            ).permitAll()
            .anyRequest().hasRole("ADMIN"));

이 코드는 "/api/v1/admin/signup", "/login"으로의 요청은 인증이 필요없이 모두 허가한다는 의미이고, 그 외(anyRequest())는 모두 ADMIN Role이 있어야 접근 가능하다는 설정이다.

 

 

LoginFilter (src/main/java/path/your/package/jwt/LoginFilter.java)

import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.tbell.mm.dto.administrator.CustomAdministratorDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtManager jwtManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        log.info("[attemptAuthentication]: Username : {}, Password: {}", username, password);

        UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(username, password, null);

        return authenticationManager.authenticate(authToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authentication) {
        log.info("[successfulAuthentication]: Authentication Success");
        CustomAdministratorDetails administratorDetails = (CustomAdministratorDetails) authentication.getPrincipal();

        String username = administratorDetails.getUsername();
        String role = authentication
                        .getAuthorities()
                        .iterator()
                        .next()
                        .getAuthority();

        String token = jwtManager.createJwt(username, role);

        response.addHeader("Authorization", "Bearer " + token);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request,
                                              HttpServletResponse response,
                                              AuthenticationException failed) throws IOException {
        log.info("[unsuccessfulAuthentication]: Authentication Failed");

        String errorMessage = String.format("error: Failed login with this username: %s", obtainUsername(request));

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().write(errorMessage);
    }
}

 

JwtFilter (src/main/java/path/your/package/jwt/JwtFilter.java)

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.tbell.mm.dto.administrator.CustomAdministratorDetails;
import kr.co.tbell.mm.entity.administrator.Administrator;
import kr.co.tbell.mm.entity.administrator.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtManager jwtManager;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (authorization == null || !authorization.startsWith("Bearer ")) {
            log.error("[doFilterInternal]: JWT token does not exist.");
            filterChain.doFilter(request, response);
            return;
        }

        String token = authorization.split(" ")[1];
        if (jwtManager.isExpired(token)) {
            log.error("[doFilterInternal]: JWT token expired.");
            filterChain.doFilter(request, response);
            return;
        }

        String username = jwtManager.getUsername(token);
        String roleStringValue = jwtManager.getRole(token);

        // 현재 로그인 한 사용자 정보를 기반으로 Administrator 객체 생성
        Administrator administrator = Administrator
                .builder()
                .username(username)
                .role(Role.getRole(roleStringValue))
                .build();

        // UserDetails 회원 정보 객체에 현재 로그인 한 사용자 담기
        CustomAdministratorDetails administratorDetails = new CustomAdministratorDetails(administrator);

        // 스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(
                administratorDetails,
                null,
                administratorDetails.getAuthorities());

        // 스프링 시큐리티 세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}

 

JwtManager (src/main/java/path/your/package/jwt/JwtManager.java)

import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JwtManager {

    private final SecretKey secretKey;

    public JwtManager(@Value("${jwt.secret}") String secret) {
        secretKey = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8),
                Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    private static final long EXPIRED_MS = 1800000L; // 30분

    public String getUsername(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get("username", String.class);
    }

    public String getRole(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get("role", String.class);
    }

    public Boolean isExpired(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getExpiration()
                .before(new Date());
    }

    public String createJwt(String username, String role) {
        return Jwts.builder()
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis()  + EXPIRED_MS))
                .signWith(secretKey)
                .compact();
    }
}

 

 

테스트

Authorization을 헤더에 넣지 않고 요청하면 특정 Path가 아니고선 전부 403 Forbidden.

 

 

로그인

/login으로 username, password를 입력 후 요청하면 200 OK가 떨어지고 Response HeadersJWT Token이 발급된다.

 

Token을 이용해서 다시 위에서 인가 거부된 요청을 날려보면 다음과 같이 정상 응답한다.

 

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

 참고자료:

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런

김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습

www.inflearn.com

 

회원 관리 애플리케이션을 서블릿을 사용해서 만들어보자. 간단하게만 일단 만들어보자.

 

Member

package org.example.servlet.domain.member;

import lombok.Data;

@Data
public class Member {
    private Long id;
    private String username;
    private int age;

    public Member(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

 

MemberRepository

package org.example.servlet.domain.member;

import lombok.Getter;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class MemberRepository {
    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    private static final MemberRepository instance = new MemberRepository();

    public static MemberRepository getInstance() {
        return instance;
    }

    private MemberRepository() {}

    public Member save(Member member) {
        member.setId(++sequence);
        store.put(member.getId(), member);
        return member;
    }

    public Member findById(Long id) {
        return store.get(id);
    }

    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clearStore() {
        store.clear();
    }
}

 

데이터베이스는 없지만 메모리 상에서 회원 정보를 저장하게 해보자. 우선 그럴려면 멤버를 저장할 자료구조가 필요한데 그 HashMap을 이용해보자. 동시성 문제에 대해선 고려하지 않은채로 진행하자.

 

그리고, 이 MemberRepository는 딱 한 개의 인스턴스만 존재하는 싱글톤이다. 그래서 getInstance()로만 이 클래스의 인스턴스에 접근이 가능하도록 만들었다. 

 

save(Member member), findById(Long id), findAll(), clearStore() 이렇게 4개의 public 메서드가 있다.

 

 

테스트 코드도 작성하자.

MemberRepositoryTest

package org.example.servlet.domain.member;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

class MemberRepositoryTest {

    MemberRepository memberRepository = MemberRepository.getInstance();

    @AfterEach
    void afterEach() {
        memberRepository.clearStore();
    }

    @Test
    public void save() {
        Member member = new Member("hello", 20);

        Member savedMember = memberRepository.save(member);

        Member findMember = memberRepository.findById(savedMember.getId());

        assertEquals(savedMember, findMember);
    }

    @Test
    void findAll() {
        Member member1 = new Member("member1", 20);
        Member member2 = new Member("member2", 20);

        memberRepository.save(member1);
        memberRepository.save(member2);

        List<Member> members = memberRepository.findAll();

        assertEquals(members.size(), 2);
        assertThat(members).contains(member1, member2);
    }
}

 

Member, MemberRepository를 만들고 이를 이용한 CRUD에 대한 간단한 테스트 코드를 작성했다. 이제 서블릿을 사용해서 클라이언트와 서버간 통신을 해서 간단한 애플리케이션을 만들어보자.

 

package 경로 path/your/package/web/servlet안에 MemberFormServlet을 만들자.

이 클래스는 유저를 생성할 때 필요한 폼을 HTML로 보여주는 클래스가 될 것이다.

 

MemberFormServlet

package org.example.servlet.web.servlet;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.MemberRepository;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class MemberFormServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        resp.setContentType("text/html;charset=utf-8");

        PrintWriter writer = resp.getWriter();
        writer.write("<!DOCTYPE html>\n" +
                "<html>\n" +
                "<head>\n" +
                "    <meta charset=\"UTF-8\">\n" +
                "    <title>Title</title>\n" +
                "</head>\n" +
                "<body>\n" +
                "<form action=\"/servlet/members/save\" method=\"post\">\n" +
                "    username: <input type=\"text\" name=\"username\" />\n" +
                "    age:      <input type=\"text\" name=\"age\" />\n" +
                "    <button type=\"submit\">전송</button>\n" +
                "</form>\n" +
                "</body>\n" +
                "</html>\n");
    }
}

 

서블릿을 사용하면 가장 불편한건 HTML 작성이 너무너무너무 불편하다는 사실이다. 이런 불편함덕에 JSP가 나타나고 Spring MVC가 나타나고 하는거지만 결국 어떤 발전 과정이 있는지 아는게 중요하기 때문에 서블릿으로 MVC패턴을 만드는 것을 해보는 것이다.

 

여튼 저렇게 폼 하나를 만들면 우리의 서버에서 잘 뿌려주는지 확인할 수 있다.

username, age를 입력받는 폼이 잘 나온다. 지금은 전송 버튼을 누르면 에러가 발생한다. 폼을 보면 알겠지만, action="/servlet/members/save" 인데 이 경로에 대한 서블릿을 만들지 않았기 때문이다. 만들어보자.

 

MemberSaveServlet

package org.example.servlet.web.servlet;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(name = "memberSaveServlet", urlPatterns = "/servlet/members/save")
public class MemberSaveServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        String username = req.getParameter("username");
        int age = Integer.parseInt(req.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        resp.setContentType("text/html;charset=utf-8");
        PrintWriter writer = resp.getWriter();

        writer.write("<html>\n" +
                "<head>\n" +
                "    <meta charset=\"UTF-8\">\n" +
                "</head>\n" +
                "<body>\n" +
                "성공\n" +
                "<ul>\n" +
                "    <li>id="+member.getId()+"</li>\n" +
                "    <li>username="+member.getUsername()+"</li>\n" +
                "    <li>age="+member.getAge()+"</li>\n" +
                "</ul>\n" +
                "<a href=\"/index.html\">메인</a>\n" +
                "</body>\n" +
                "</html>");
    }
}

 

POST Method로 들어온 요청에 담긴 username, age를 받아와서 MemberRepository를 통해 멤버를 저장하는 로직이 있다.

그리고 저장된 멤버를 보여주는 HTML을 응답으로 돌려준다.

 

여기서 문제는, 비즈니스 로직과 응답에 대한 뷰를 처리하는게 동시에 있다는 사실이다. 즉, 이 서블릿이 하고 있는 업무가 너무 많다. 여하튼 서블릿을 이용해서 멤버를 만들어내는 폼을 보여주는 화면과 폼 데이터를 전송해서 멤버를 저장하고 저장된 멤버를 보여주는 작업을 완료했다. 아래 화면은 폼 화면에서 username에 "choi", age에 "30"을 넣고 전송버튼을 눌렀을 때 결과 화면이다.

 

 

이제 멤버리스트 화면을 보여주는 서블릿이 필요하다.

MemberListServlet

package org.example.servlet.web.servlet;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;

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

@WebServlet(name = "memberListServlet", urlPatterns = "/servlet/members")
public class MemberListServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        resp.setContentType("text/html;charset=utf-8");
        PrintWriter w = resp.getWriter();

        w.write("<html>");
        w.write("<head>");
        w.write("    <meta charset=\"UTF-8\">");
        w.write("    <title>Title</title>");
        w.write("</head>");
        w.write("<body>");
        w.write("<a href=\"/index.html\">메인</a>");
        w.write("<table>");
        w.write("    <thead>");
        w.write("    <th>id</th>");
        w.write("    <th>username</th>");
        w.write("    <th>age</th>");
        w.write("    </thead>");
        w.write("    <tbody>");

        for (Member member : members) {
            w.write("    <tr>");
            w.write("        <td>"+member.getId()+"</td>");
            w.write("        <td>"+member.getUsername()+"</td>");
            w.write("        <td>"+member.getAge()+"</td>");
            w.write("    </tr>");
        }

        w.write("    </tbody>");
        w.write("</table>");
        w.write("</body>");
        w.write("</html>");
    }
}

 

딱히 설명할 내용은 없는것같다. 멤버 전체를 보여주는 화면이다. 바로 서블릿이 보여주는 화면을 봐보자.

 

이렇게 멤버를 생성하고 저장된 멤버들을 보여주는 화면을 만들어봤다. 사용을 해보니 서블릿으로 동적인 HTML 파일도 만들수 있고 화면을 사용자에게 뿌려주는것도 잘되고 좋은것 같지만 서블릿의 가장 큰 단점은 HTML을 작성해내기가 너무 힘들다는 것이다. 이것을 해결하기 위해 템플릿 엔진이 나왔다. 대표적인 것이 JSP, Thymeleaf이다. JSP를 먼저 해보고 JSP가 서블릿의 문제를 어떻게 해결했고 어떤 불편함이 있길래 Thymeleaf가 나왔는지 또 알아보자.

 

서블릿 대신 JSP를 사용하기

implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'javax.servlet:jstl:1.2'

JSP를 사용하려면 이렇게 의존성을 추가해줘야 한다.

 

이제 JSP 파일을 생성할건데, src/main/webapp/jsp 경로안에 만들어야 한다.

그래서 멤버를 생성하기 위해 입력하는 폼을 보여줄 jsp 파일을 src/main/webapp/jsp/members/new-form.jsp 만든다.

 

new-form.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
<form action="/jsp/members/save.jsp" method="post">
    username: <input type="text" name="username" />
    age:      <input type="text" name="age" />
    <button type="submit">전송</button>
</form>
</body>
</html>

 

이렇게 만들면 브라우저에 webapp 아래부터 경로 그대로를 입력해주면 된다.

 

이제 new-form.jsp에서 form이 보내는 경로인 save.jsp 파일을 만들어야 한다.

save.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.example.servlet.domain.member.Member" %>
<%@ page import="org.example.servlet.domain.member.MemberRepository" %>
<%
    // request, response는 그냥 사용 가능
    MemberRepository memberRepository = MemberRepository.getInstance();

    String username = request.getParameter("username");
    int age = Integer.parseInt(request.getParameter("age"));

    Member member = new Member(username, age);
    memberRepository.save(member);
%>
<html>
<head>
    <title>Title</title>
</head>
<body>
성공
<ul>
    <li>id=<%=member.getId()%></li>
    <li>username=<%=member.getUsername()%></li>
    <li>age=<%=member.getAge()%></li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>

JSP는 로직과 HTML을 딱 구분지어서 이렇게 작성할 수 있다. 

이제 멤버 리스트를 보여주는 화면인 members.jsp 파일도 만들어보자.

 

members.jsp

<%@ page import="org.example.servlet.domain.member.MemberRepository" %>
<%@ page import="org.example.servlet.domain.member.Member" %>
<%@ page import="java.util.List" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
    MemberRepository memberRepository = MemberRepository.getInstance();
    List<Member> members = memberRepository.findAll();
%>
<html>
<head>
    <title>Title</title>
</head>
<body>
<table>
    <thead>
    <th>id</th>
    <th>username</th>
    <th>age</th>
    </thead>
    <tbody>
    <%
        for (Member member : members) {
            out.write("    <tr>");
            out.write("        <td>" + member.getId() + "</td>");
            out.write("        <td>" + member.getUsername() + "</td>");
            out.write("        <td>" + member.getAge() + "</td>");
            out.write("    </tr>");
        }
    %>
    </tbody>
</table>
</body>
</html>

이렇게 JSP를 이용해서, 같은 내용을 서블릿에서 JSP로 변경해봤다. 확실히 HTML을 쉽게 작성할 수 있다. 근데 여전히 맘에 들지 않는다. 두가지 일을 한 곳에서 다 해버리고 있다는 게 불편하다. 비즈니스 로직과 뷰가 동일한 곳에서 작성되다보니 지저분하다. 

 

결론

서블릿과 JSP를 사용해서 아주아주 작은 웹 애플리케이션을 구현해봤다. 

 

서블릿의 단점

  • HTML 코드를 작성하는게 너무 힘들다.

서블릿의 단점을 극복하고자 JSP가 등장했지만? 

JSP의 단점

  • 비즈니스 로직을 담당하는 부분과 화면을 담당하는 부분을 같이 다루고 있기 때문에 복잡하고 지저분하다.
  • 소스가 커지면 커질수록 감당하기 어려워진다.

저 위 코드는 비즈니스 로직이 너무너무 간단하니까 눈에 잘 들어오기라도 하지만 소스가 커지면 커질수록 점점 아찔해질거다. 이런 문제를 해결하고자 MVC 패턴이 등장한것이다. 비즈니스 로직은 비즈니스 로직만 다루고, 화면은 화면에만 집중할 수 있도록 말이다.

 

이제 스프링 MVC를 배울건데 배우기전에 MVC 패턴을 이해하는 과정이 필요하다. 그 과정을 다음에 다뤄보겠다.

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

참고자료:

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런

김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습

www.inflearn.com

 

Spring MVC를 배우기 전에 어디서부터 시작되어 Spring MVC까지 도착했는지를 공부하고 싶어졌다. Spring MVC가 어떤 것을 나 대신해주고 어떤 것 때문에 사용하는지 좀 더 자세히 이해하기 위해 Servlet부터 시작해 보고자 한다. 

 

프로젝트 만들기

우선 프로젝트를 만들자. 만들 때 따로 톰캣을 설치하고 띄운 상태가 아니라면 스프링 부트의 도움을 받아서 바로 WAS 서버를 실행할 수 있게 스프링 프레임워크를 사용할거다. 서블릿은 스프링이고 스프링부트고 아무것도 없어도 되는데 딱 이 이유때문에 스프링 프레임워크를 사용할 거다.

 

IntelliJ > New Project > Spring Boot

 

중요: Packaging을 Jar말고 War로 선택한다. JSP를 사용해야 하기 때문이다. Jar와 War의 차이는 서버가 따로 있고 그 서버에 따로 톰캣을 설치하고 그 톰캣 위에 배포하는 경우 War를 사용하는데 꼭 그렇게 안하더라도 스프링 부트를 사용하면 War에 WAS서버가 내장되어 있다. 근데 JSP를 사용하기 위해선 War를 사용해야 한다.

 

Dependencies는 저 두개만 있으면 된다. Spring Web이 필요한 이유는 Apache Tomcat을 내장시키려면 저게 필요하기 때문.

 

우선 먼저 실행해봐야 한다. 잘 실행된다면 다음과 같이 로그가 찍힌다.

톰캣이 실행됐다는 로그가 찍힌다. 스프링 부트가 내장하고 있는 WAS가 띄워지는 것이다.

다시 말하지만 이거 때문에 서블릿으로 만들건데도 스프링 부트를 사용했다. 딱 이 이유뿐이다. 서블릿은 스프링과 아무런 관련이 없다. 서블릿은 톰캣 같은 웹 애플리케이션 서버를 직접 본인의 서버위에 설치하고 그 위에 서블릿 코드를 클래스 파일로 빌드해서 올린 다음 톰캣 서버를 실행하면 된다. 근데 이 과정이 번거롭기 때문에 톰캣 서버가 내장되어 있는 스프링 부트를 사용한다.

 

스프링에서 서블릿을 사용하려면 @ServletComponentScan 애노테이션을 붙여야 한다. 애노테이션을 붙이면 저 패키지 org.example.servlet 하위에 있는 모든 패키지에서 서블릿을 다 뒤지고 찾아서 자동으로 등록해준다.

 

이제 간단한 Servlet을 하나 만들어보자.

HelloServlet

package org.example.servlet.basic;


import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        super.service(req, resp);
    }
}

패키지는 org.example.servlet.basic이다.

Servlet을 만드려면 HttpServlet을 상속받아야 한다.

 

상속 받으면 service(HttpServletRequest req, HttpServletResponse resp) 메서드를 오버라이딩 할 수 있다. 이 메서드가 /hello로 요청했을 때 실행되는 메서드이다.

 

@WebServlet(name = "helloServlet", urlPatterns = "/hello") 라는 애노테이션을 붙여서 이 서블릿의 이름과 어떤 path로 이동하면 이 서블릿을 가져다가 사용할 건지에 대한 urlPatterns를 명명해준다.

 

다음처럼 클래스와 메서드명을 찍어보자.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    System.out.println("HelloServlet.service");
}

 

이 상태로 다시 서버를 띄우고 "localhost:8080/hello"로 접속해보면 다음과 같이 찍힌다.

 

이제 서버로 Path가 "/hello" 요청이 들어오면 이 서블릿이 요청을 받아 개발자가 작성한 코드를 실행해준다.

 

이제 요청 정보에서 원하는 정보들을 알아오기 위해 다음 코드를 보자.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    System.out.println("HelloServlet.service");

    String username = req.getParameter("username");
    System.out.println("username = " + username);

}

요청 시 파라미터가 있을 때 그 파라미터를 getParameter()로 가져올 수 있다. 

이 상태로 실행하고 다음과 같이 입력해보자.

http://localhost:8080/hello?username=hi

그럼 이렇게 찍히게 된다.

HelloServlet.service
username = hi

 

그래서 요청 데이터에서 원하는 값을 저 req 객체로부터 가져올 수 있다.

그리고 응답도 서블릿이 대신 다 해준다고 했다. 내가 원하는 응답 데이터만 서블릿한테 말해주면 된다.

아래 코드를 보자.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    System.out.println("HelloServlet.service");

    String username = req.getParameter("username");
    System.out.println("username = " + username);

    resp.setContentType("text/plain");
    resp.setCharacterEncoding("UTF-8");
    resp.getWriter().println("Hello " + username);
}

 

응답 데이터에 Content-Type, Encoding, Body값을 넣어준다. getWriter().println()을 사용하면 Response Body에 데이터를 넣어줄 수 있다. 이 상태로 다시 요청을 해보면 다음과 같이 보인다.

 

이렇게 서블릿을 이용해서 HTTP 프로토콜을 통해 요청과 응답을 할 수 있게 됐다.

정리를 하자면

스프링 부트를 이용해서 프로젝트를 만들었다. 스프링 부트를 사용한 이유는 스프링 부트에 톰캣과 같은 WAS가 내장되어 있기 때문이다. 그래서 서블릿을 만들고 서블릿을 스캔하도록 설정하면 스프링 부트가 띄워질 때 서블릿 컨테이너에 만든 서블릿을 다 넣어둔다. 그리고 외부에서 이 서버로부터 요청이 들어올 때 요청을 처리하는 서블릿을 찾아 적절한 응답을 보내줬다.

 

HttpServletRequest

서블릿에서 요청 정보를 가져오려면 파라미터로 들어오는 HttpServletRequest 객체로부터 가능하다. 이 녀석은 말 그대로 HTTP 요청에 대한 모든 정보를 다 가지고 있어서 원하는 정보를 내가 쏙쏙 뽑아올 수 있다.

 

예를 들면 다음 코드를 보자.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    System.out.println("req.getMethod() = " + req.getMethod());
}

 

보면 요청정보에 Method를 가져온다. GET, POST 등 요청이 어떤 Method인지 가져올 수 있단 소리고 이 말은 GET, POST, PUT, DELETE 어떤 Method라도 이 서블릿이 다 처리할 수 있다는 뜻이다. 포스트맨으로 POST로 날려보자.

실행결과:

req.getMethod() = POST

 

이번엔 GET으로 날려보자.

실행결과:

req.getMethod() = GET

 

어떤 Method라도 이 서블릿이 만능으로 다 받아줄 수 있다. 요청정보에 대한 Headers 정보도 가져올 수 있다.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    req
        .getHeaderNames()
        .asIterator()
        .forEachRemaining(header -> System.out.println("header = " + header));
}

실행결과:

header = accept
header = user-agent
header = cache-control
header = postman-token
header = host
header = accept-encoding
header = connection

요청정보에 담긴 Headers 정보들이다. 이런 Key에 대한 Headers가 요청에 들어왔다는 소리이고 이 키로 값도 가져올 수 있다.

이렇게 서블릿은 요청에 대한 원하는 모든 정보를 다 가져올 수가 있다. 

 

HTTP 요청 데이터

HTTP 요청 메시지를 통해 클라이언트에서 서버로 데이터를 전달하는 방법은 크게 3가지다.

  • GET - 쿼리 파라미터
    • 메시지 바디 없이, URL의 쿼리 파라미터에 데이터를 포함해서 전달
  • POST - HTML Form
    • Content-Type: application/x-www-form-urlencoded
    • 메시지 바디에 쿼리 파라미터 형식으로 전달 (예: username=hello&age=20)
  • HTTP message body 
    • HTTP API에서 주로 사용하고 JSON, XML 형식으로 전달 가능

 

GET - 쿼리 파라미터를 통해 데이터를 전달할 때 받아보는 방법을 알아보자.

RequestParamServlet

package org.example.servlet.basic;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "requestParamServlet", urlPatterns = "/request-param")
public class RequestParamServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        System.out.println("[전체 파라미터 조회] - start");

        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName -> System.out.println(paramName + "=" + req.getParameter(paramName)));

        System.out.println("[전체 파라미터 조회] - end");

        System.out.println("[단일 파라미터 조회] - start");

        String username = req.getParameter("username");
        String age = req.getParameter("age");
        System.out.println("username = " + username);
        System.out.println("age = " + age);

        System.out.println("[단일 파라미터 조회] - end");

        System.out.println("[이름이 같은 복수 파라미터 조회] - start");

        String[] usernames = req.getParameterValues("username");
        for (String name : usernames) {
            System.out.println("username = " + name);
        }

        System.out.println("[이름이 같은 복수 파라미터 조회] - end");
    }
}

전체 파라미터를 가져오는 방법은 getParameterNames()를 통해 가져올 수 있다.

 

근데 보통은 저렇게 사용안하고 내가 어떤걸 받을지 이미 알기 때문에 getParameter()로 딱 원하는 쿼리파라미터를 가져온다.

 

그리고 거의 없지만 가끔 이런 경우가 있다. http://localhost:8080/request-param?username=hello&age=20&username=hi 

이렇게 같은 키의 파라미터(username)가 있는 경우에는 getParameterValues()를 호출해서 전체 값을 다 가져올 수 있다.

 

(http://localhost:8080/request-param?username=hello&age=20) 실행결과:

[전체 파라미터 조회] - start
username=hello
age=10
[전체 파라미터 조회] - end
[단일 파라미터 조회] - start
username = hello
age = 10
[단일 파라미터 조회] - end
[이름이 같은 복수 파라미터 조회] - start
username = hello
[이름이 같은 복수 파라미터 조회] - end

 

이게 서블릿을 사용할 때 제공해주는 HttpServletRequest 객체를 통해 요청 정보 중 쿼리 파라미터 데이터를 가져오는 방법이다.

그리고 스프링을 사용해도 결국 이 서블릿의 이 기능을 통해 가져오는건데 우리를 위해 더 편리하게 해줄뿐이다.

 

이제, POST - HTML Form을 통해 데이터를 전송할 때 어떻게 데이터를 받는지 보자.

우선 그러려면 Form이 있는 HTML 파일 하나가 있어야한다.

 

src/main/webapp/basic/hello-form.html

<!DOCTYPE html>
<html>
<head>
 <meta charset="UTF-8">
 <title>Title</title>
</head>
<body>
    <form action="/request-param" method="post">
        username: <input type="text" name="username" />
        age: <input type="text" name="age" />
        <button type="submit">전송</button>
    </form>
</body>
</html>

 

간단한 Form 하나를 만들어서 테스트 해보자. 이 폼을 웹 브라우저에서 열려면 저 webapp 경로 아래의 경로 그대로를 브라우저에 입력하면 된다. 그러니까 http://localhost:8080/basic/hello-form.html 이라고 입력하면 된다.

여기에 입력을 하고 전송버튼을 누르면 서버에 어떻게 들어올까? 

실행결과:

[전체 파라미터 조회] - start
username=choi
age=20
[전체 파라미터 조회] - end
[단일 파라미터 조회] - start
username = choi
age = 20
[단일 파라미터 조회] - end
[이름이 같은 복수 파라미터 조회] - start
username = choi
[이름이 같은 복수 파라미터 조회] - end

 

GET 방식으로 받았던 코드 그대로인 상태로 수행했는데 잘 가져와진다. 그 이유는 Form으로 보내는 데이터 형식은 GET 방식의 쿼리 파라미터랑 형식이 같기 때문이다. 다음은 개발자 도구에서 네트워크 탭에서 요청 시 보여지는 Form Data이다. 쿼리 파라미터랑 형식이 똑같기 때문에 getParameter()로 여전히 가져올 수가 있다.

그리고 한가지 더 HTML Form으로 데이터를 보낼 때 Content-Type을 보면 application/x-www-form-urlencoded로 되어 있다. 이게 Form을 통해 데이터를 전달할 때 Content-Type이다. 

 

그럼 GETContent-Type이 어떻게 될까? Null이다. 왜냐하면 GET은 바디에 데이터를 태우지 않기 때문이다. 그래서 GET - 쿼리 파라미터POST - HTML Form 으로 데이터를 전송할 때 받는 방법은 둘 다 형식이 같아서 Parameter 받아오는 방법을 사용하면 된다는 것을 알았다. 물론, POST의 HTML Form으로 전송하는 데이터는 바디로 들어오는 데이터를 읽어서 가져와도 된다. 상관없다. 근데 좀 귀찮다 그 작업이 InputStream으로 가져와서 변환하고 어쩌구 해야해서 그럴 필요가 없이 getParameter()를 호출하면 된다.

 

 

HTTP Message Body로 데이터를 전송할 때 방식이 나뉜다.

  • 단순 스트링
  • JSON, XML

먼저 단순 스트링으로 보낼 때 데이터를 어떻게 받는지 보자.

RequestBodyStringServlet

package org.example.servlet.basic;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "requestBodyStringServlet", urlPatterns = "/request-body-string")
public class RequestBodyStringServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        ServletInputStream inputStream = req.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        System.out.println(messageBody);
    }
}

 

HttpServletRequest 객체에서 getInputStream()을 호출하면 Body의 데이터를 가져올 수 있다. 그 데이터가 바이트 형식으로 되어 있는데 이것을 스트링으로 변환할 때 여러 방법이 있는데 지금은 스프링 부트를 사용중이니까 (앞으로도 그럴거니까) 스프링이 제공하는 StreamUtils라는 클래스가 있다. 이 녀석을 사용하면 매우 편리하다.

 

한번 실행해보자. Body에 데이터를 태워야 하니까 Postman으로 테스트 해보자.

우선 POST로 설정하고 Body탭에 raw - Text를 선택하면 단순 스트링으로 바디에 데이터를 보낸다.

이렇게 전송을 하면 다음과 같이 보낸 데이터를 받아올 수 있다.

그리고 이렇게 단순 텍스트로 데이터를 보내면 Request Headers에는 Content-Type이 어떻게 되어 있을까? text/plain이다.

그러니까 Request HeadersContent-Typetext/plain으로 되어있는건 "아 요청할 때 단순 텍스트로 바디에 값을 넣었구나!"라고 생각하면 된다.

 

 

하지만, 이렇게 보내는 경우는 거의 없다. 그래서 JSON으로 데이터를 주고 받는 방법에 대해 자세히 알아야 한다. 이제 JSON으로 데이터를 주고 받을 때 어떻게 하는지 보자.

 

들어오는 JSON 데이터를 객체로 변환하려고 한다. 자바는 모든게 객체이기 때문에.

그래서 변환할 객체에 대한 클래스를 하나 만들자.

HelloData

package org.example.servlet.basic.model;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class HelloData {
    private String username;
    private int age;
}

 

RequestBodyJsonServlet

package org.example.servlet.basic.request;

import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@WebServlet(name = "requestBodyJsonServlet", urlPatterns = "/request-body-json")
public class RequestBodyJsonServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        ServletInputStream inputStream = req.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        System.out.println("messageBody = " + messageBody);
    }
}

 

JSON이라고 뭐 엄청 다른게 아니다. 단순 스트링으로 변환할 때처럼 똑같이 할 수 있다. JSON도 텍스트다. 저 상태로 한번 실행해보자.

 

Postman으로 다음과 같이 요청한다. JSON으로 바디에 데이터를 넣으려면 raw - JSON을 선택하면 된다.

이렇게 JSON으로 바디에 데이터를 넣으면 Request Headers에서 Content-Typeapplication/json이 된다.

 

요청해보면 서버에서는 다음과 같이 스트링으로 잘 받아진다.

 

근데 원하는건 이렇게 문자열로 들어오는걸로 끝이 아니라 들어오는 데이터를 객체로 변환해서 객체로 다루고 싶은 것이다. 객체로 변환하려면 라이브러리를 사용해야 한다. 스프링에서는 Jackson 라이브러리가 공식적으로 지원해주고 있다. 이 라이브러리에서 ObjectMapper라는 녀석이 있는데 이 녀석을 사용하면 된다. 가장 좋은 방식은 빈으로 딱 하나만 등록해놓고 가져다가 사용하는 것이다. 그래서 우선 빈으로 등록하자. 

 

ServletApplication

package org.example.servlet;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;

@ServletComponentScan
@SpringBootApplication
public class ServletApplication {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }

    public static void main(String[] args) {
        SpringApplication.run(ServletApplication.class, args);
    }

}

빈으로 등록하는 방법은 여러가지가 있는데, 가장 간단한 건 @SpringBootApplication 애노테이션이 붙은 클래스에서 저렇게 @Bean 애노테이션으로 등록하는 것이다. 등록하면 이제 주입(DI)을 할 수 있다.

 

RequestBodyJsonServlet

package org.example.servlet.basic.request;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.example.servlet.basic.model.HelloData;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@WebServlet(name = "requestBodyJsonServlet", urlPatterns = "/request-body-json")
@RequiredArgsConstructor
public class RequestBodyJsonServlet extends HttpServlet {

    private final ObjectMapper objectMapper;

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        ServletInputStream inputStream = req.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
        System.out.println("helloData.getUsername() = " + helloData.getUsername());
        System.out.println("helloData.getAge() = " + helloData.getAge());
    }
}

주입하는 방법은 여러가지가 있는데 생성자 주입을 통해 하는것을 스프링도 권장하고 있다. 나도 생성자 주입으로 가져왔다. "어? 생성자가 없는데요?" Lombok에서 제공하는 @RequiredArgsConstructor를 공부하고 오면 된다. 

 

그래서 스트링으로 변환된 JSON 데이터를 가지고 ObjectMapperreadValue()를 통해 객체로 변환해낸다. 실행해보자.

실행결과:

helloData.getUsername() = Choi
helloData.getAge() = 20

 

자, 흐름을 한번 짚고 넘어가보면 HttpServletRequest 객체에서 바디의 데이터를 꺼내와서 스트림에 바이트를 스트링으로 변환하고 그 스트링 형태의 데이터를 ObjectMapper를 통해 객체로 변환해내는 과정을 직접 해봤다. 이거 Spring MVC 사용하면 파라미터에 service(HelloData helloData)로 끝낼 수 있다. 그럼 스프링은 저 과정을 다 대신해준다. 근데 이런 과정을 스프링이 해준다는 것을 알고 쓰는거랑 모르고 쓰는거랑은 천지차이라고 누가 그러더라. 맞는 말인거 같고. 그래서 직접 이 과정을 한번은 해보자는 취지에서 공부중이다.

 

이제 HttpServletRequest 객체에 대해서 알아봤으니 HttpServletResponse에 대해서도 알아보자.

HttpServletResponse

요청에 대한 응답을 줄 때도 역시나 여러 정보들이 포함된다. 응답 코드, Response Body, Response Headers 요청과 똑같은 스펙에 필요한 데이터를 넣어서 응답해준다. 그래서 하나씩 살펴보자.

 

ResponseHeaderServlet

package org.example.servlet.basic.response;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "responseHeaderServlet", urlPatterns = "/response-header")
public class ResponseHeaderServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // [response status-line]
        resp.setStatus(HttpServletResponse.SC_OK);

        // [response-headers]
        resp.setHeader("Content-Type", "text/plain");
        resp.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
        resp.setHeader("Pragma", "no-cache");
        resp.setHeader("custom-header", "hello");

        // [response body]
        resp.getWriter().write("ok");
    }
}

 

서블릿 하나를 만들었다. "/response-header"로 들어오는 요청에 대한 응답 정보를 이렇게 보낸다. 한번 테스트 해보자.

요청에 대한 응답 내용을 보면 상태 코드 200, Headers 정보엔 넣은 Key/Value가 고대로 고스란히 들어가 있다. 그리고 Body에도 역시 다음과 같이 내가 작성한 코드 그대로 들어가 있다.

이렇게 응답 정보를 만들어서 요청에 대한 응답으로 돌려줄 수 있다.

 

근데 Content-Type에 Charset 정보를 넣어주지 않았더니 자동으로 ISO-8859-1로 들어가 있는게 보이는가? 이러면 이제 한글같은 경우가 깨질수도 있다. 응답 바디에 한글로 넣으면 어떻게 나오는지 보자.

resp.getWriter().write("안녕");

단순 텍스트로 한글로 된 문자 "안녕"을 응답 바디에 넣어 돌려준다. 결과는 "??"로 나온다. 이건 서버에서 Content-Type에 대한 Charset 설정을 UTF-8로 해주지 않았기에 일어난 일이다.

 

 

그래서 응답에 Content-Type에 대한 Charset 정보를 넣어줄 수 있다. 이렇게 한 줄 수정하고 다시 돌려보자.

resp.setHeader("Content-Type", "text/plain;charset=utf-8");

이제 한글도 잘 나온다.

 

근데 이 Headers에 데이터를 추가할 때 저렇게 두 개의 파라미터를 받아서 Key, Value를 직접 작성할 수도 있고 아예 이런 메서드를 사용할 수도 있다. 이런 편의 메서드도 제공을 한다는 걸 알아두면 좋을 것 같다.

// resp.setHeader("Content-Type", "text/plain;charset=utf-8");
resp.setContentType("text/plain");
resp.setCharacterEncoding("UTF-8");

 

그리고 Content-Length 라는건 말 그대로 바디에 있는 데이터의 길이를 의미한다. Request, Response 둘 다 헤더에 넣을 수 있는데 안 넣으면 자동으로 계산이 된다. 그래서 지금 나의 코드는 헤더에 저 Content-Length를 넣지 않았지만 응답 정보를 보면 이렇게 자동으로 계산해서 보여준다.

 

Cookie도 넣을 수 있다. 한번 넣어보자.

// resp.setHeader("Set-Cookie", "myCookie=good; Max-Age=600");
Cookie cookie = new Cookie("myCookie", "good");
cookie.setMaxAge(600); // 600초
resp.addCookie(cookie);

쿠키도 역시 헤더 정보에 들어가는 데이터이기 때문에 resp.setHeader()로 넣을 수 있지만 귀찮기 때문에 객체를 생성해서 addCookie()를 사용하면 좀 더 가시성도 좋아지고 편리해진다.

 

Redirect도 해보자.

resp.setStatus(HttpServletResponse.SC_FOUND); // 302
resp.setHeader("Location", "/basic/hello-form.html");
//resp.sendRedirect("/basic/hello-form.html");

위에 두 줄이 상태 코드 302로 Redirect임을 알리고 Location"/basic/hello-form.html"로 보내라라는 의미이다.

근데 이것도 역시나 편의메서드를 제공한다. sendRedirect().

 

Network를 보면 response-header로 먼저 요청이 가고 302 Redirect로 hello-form.html로 온 것을 볼 수 있다.

 

이 정도 해두면 응답 시 사용하는 거의 대부분을 확인해본 것이다. 이제 남은건 응답 시 바디 데이터에 데이터를 보내는 것에 초점을 두고 하나씩 알아보자. 여기도 역시나 단순 스트링과 HTML, JSON이 있다.

 

HTTP 응답 메시지는 주로 다음 내용을 담는다.

  • 단순 텍스트
  • HTML
  • JSON

우선 HTML로 응답하는 경우를 보자. 이거 서블릿으로 하려면 상당히 힘들다.

ResponseHtmlServlet

package org.example.servlet.basic.response;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(name = "responseHtmlServlet", urlPatterns = "/response-html")
public class ResponseHtmlServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("text/html");
        resp.setCharacterEncoding("UTF-8");
        
        PrintWriter writer = resp.getWriter();
        writer.println("<html>");
        writer.println("<body>");
        writer.println("<h1>안녕</h1>");
        writer.println("</body>");
        writer.println("</html>");
    }
}

우선, Content-Typetext/html로 해준다. 안 해줘도 요즘은 알아서 잘 해주는데 해주는게 정석이긴 하다.

CharacterEncoding은 UTF-8로 해주면 된다.

 

그리고 이제 PrintWriter 객체를 받아서 HTML을 작성하면 되는데, 이거 상당히 힘들다. 이게 서블릿의 엄청난 한계이다. JSP가 탄생한 이유이기도 하고. 그래서 이렇게 힘들게 HTML을 만들어서 뿌리면 다음과 같이 보여진다.

 

 

이게 이제 HTML을 응답 메시지에 넣어주는 방식이다. 가장 중요한 건 이게 아니라 JSON으로 돌려주는 HTTP API이다. 

HTTP API - JSON

ResponseJsonServlet

package org.example.servlet.basic.response;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.example.servlet.basic.model.HelloData;

import java.io.IOException;

@WebServlet(name = "responseJsonServlet", urlPatterns = "/response-json")
@RequiredArgsConstructor
public class ResponseJsonServlet extends HttpServlet {

    private final ObjectMapper objectMapper;

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("application/json");
        resp.setCharacterEncoding("UTF-8");

        HelloData helloData = new HelloData("choi", 30);

        String json = objectMapper.writeValueAsString(helloData);
        resp.getWriter().write(json);
    }
}

 

우선 응답 메시지를 JSON으로 돌려줄거니까 Content-Typeapplication/json으로 설정한다. 

그리고 객체를 하나 만들어서 그 객체를 JSON으로 만들어서 돌려줄거다. 간단하다. JSON을 객체로 변환했다면 객체를 JSON으로 변환하는것도 가능하다. ObjectMapper를 사용해서 변환한다.

 

실행 결과는 이렇다. 응답 메시지에 JSON 데이터로 잘 들어왔다.

 

이제 이 코드가 스프링에서는 그냥 반환 타입이 HelloData고 객체 만들어서 리턴만 해주면 끝난다. 이거 이렇게 된다는 걸 알고 스프링을 써도 써야지 모르면 안된다고 생각한다. 이렇게까지 하면 이제 응답 메시지를 HTML, 단순 텍스트, JSON으로 보내는거까지 다 알아본것이다. 이제 진짜 MVC패턴을 스프링없이 만들어보자.

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

Appium을 이용해서 App Automation Testing을 할 때 가장 큰 난관 중 하나는 WebView에서 요소들을 가져올 수 없을 때다.

Appium Inspector 도구를 사용해도 WebView에서 필요한 요소들을 가져올 수 없을 때가 종종 있는데 이걸 Visual Testing으로 극복해보자. 

 

OpenCV 설치

우선, 이미지 처리를 위해 거의 모든걸 다 가지고 있는 OpenCV가 필요하다.

 

우선 환경설정을 해줘야한다. (MacOS)

export OPENCV4NODEJS_DISABLE_AUTOBUILD=1

 

나는 MacOS 유저이기 때문에 다음과 같이 Homebrew를 이용해 설치한다.

brew update
brew install opencv@4
brew link --force opencv@4

 

이 두가지 작업을 모두 다 하면 Appium이 OpenCV에 접근할 수 있도록 해주어야 한다.

Global node_modules 경로를 환경설정에 추가해주자. (MacOS)

export NODE_PATH=/usr/local/lib/node_modules

 

그리고 Appium을 설치했다면 저 node_modules 경로에 가면 appium 폴더가 있을텐데 그 폴더안에 들어가서 다음 명령어를 수행

npm i

 

이렇게 하면 OpenCV 설치와 Appium이 OpenCV에 접근 가능하도록 설정을 한 상태이다.

 

Appium Plugin 설치

이제 이미지 처리를 하기 위해 Appium의 플러그인을 설치해줘야 한다.

appium plugin install images

 

설치가 완료되면 이 플러그인을 사용하면서 Appium 서버를 실행해야 한다. 그래서 앞으로 Appium 서버를 실행할 땐 다음 명령어로 실행한다. 

appium --use-plugins images

 

 

이제 테스트 코드를 작성해보자.

테스트 코드 작성하기

우선 테스트 코드를 작성할 파일 경로는 다음과 같다.

src/test/java/AppiumSampleTest.java

 

AppiumSampleTest

@Slf4j
public class AppiumSampleTest {

    public static AndroidDriver driver;
    private static final double VISUAL_THRESHOLD = 0.99;

    @BeforeAll
    public static void setUp() throws MalformedURLException {
        UiAutomator2Options options = new UiAutomator2Options()
                .setUdid("HVA1FG23").setAutoGrantPermissions(true);

        URL appiumServer = URI
                .create("http://0.0.0.0:4723")
                .toURL();
        driver = new AndroidDriver(appiumServer, options);
    }

    @After
    public void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }
}

 

천천히 하나씩 해보자.

우선, 드라이버를 초기화해줘야한다. 그래서 모든 테스트마다 다 테스트 하기 전 드라이버를 초기화해야하니 @BeforeAll 애노테이션을 붙인 setUp()을 작성하자. 

 

그 안에서 하는 작업은 간단하다. 드라이버 초기화 시 옵션(UDID 설정, AutoGrantPermissions 허용)과 Appium Server를 드라이버에 넣어주면 된다. (여기서 옵션에 APK 경로 지정을 해서 시작하면서 해당 앱을 띄우게 해도 되는데 나는 테스트용이라 그냥 앱이 띄워져있다는 가정하에 실행하는거라 따로 옵션을 주지 않았다)

 

@After 애노테이션이 붙은 tearDown() 안에서는 드라이버를 quit()한다.

 

그리고 이 THRESHOLD 이 부분을 보자.

private static final double VISUAL_THRESHOLD = 0.99;

이미지의 유사도를 비교할 것이기 때문에 99퍼센트의 임계점에 대한 상수값이다.

 

 

이제 실제 테스트를 작성한다.

@Test
public void visualSimilarityMatching() throws IOException {

    log.info("System path = {} ", System.getProperty("user.dir"));

    File baseImage = new File("./src/test/resources/assets/base.png");

    if (!baseImage.exists()) {
        File screenshot = driver.getScreenshotAs(OutputType.FILE);
        FileUtils.copyFile(screenshot, new File("./src/test/resources/assets/base.png"));
    }

    SimilarityMatchingOptions opts =
            new SimilarityMatchingOptions().withEnabledVisualization();

    SimilarityMatchingResult res =
            driver.getImagesSimilarity(baseImage, driver.getScreenshotAs(OutputType.FILE), opts);

    if (res.getScore() >= VISUAL_THRESHOLD) {
        log.info("유사도 = [{}], 동일 화면임", res.getScore());
    } else {
        fail("유사도 = " + res.getScore() + ", 동일 화면이 아님");
    }
}

 

우선, 비교할 파일을 먼저 구비를 해둬야한다. 나의 경우 이런 이미지를 미리 src/test/resources/assets/base.png 경로에 넣어두었다.

 

그래서 이 파일이 저 경로에 없는 경우에 현재 드라이버와 연결된 앱의 화면을 스크린샷을 찍고 아닌 경우 넘어간다. 그 부분이 다음 코드.

File baseImage = new File("./src/test/resources/assets/base.png");

if (!baseImage.exists()) {
    File screenshot = driver.getScreenshotAs(OutputType.FILE);
    FileUtils.copyFile(screenshot, new File("./src/test/resources/assets/base.png"));
}

 

이제 Appium에서 제공하는 SimilarityMatchingOptions를 가져다가 사용해야 한다. 다음 코드.

SimilarityMatchingOptions opts =
                new SimilarityMatchingOptions().withEnabledVisualization();

SimilarityMatchingResult res =
        driver.getImagesSimilarity(baseImage, driver.getScreenshotAs(OutputType.FILE), opts);

 

그래서 이미지 두 개를 비교한다.

SimilarityMatchingResult res = driver.getImagesSimilarity(baseImage, driver.getScreenshotAs(OutputType.FILE), opts);
  • baseImage: 비교할 이미지
  • driver.getScreenshotAs(OutputType.FILE): 현재 드라이버를 통해 스크린샷을 찍으면 나오는 이미지

이 두 이미지를 비교한 결과가 res에 담긴다.

이제 이 코드를 테스트 해보자. 현재 내 Real Device는 딱 이 상태로 보여진다. 그러니까 아까 이미지(baseImage)랑 시스템 바 빼고 다 똑같다.

 

 

최종 코드 (AppiumSampleTest)

import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import io.appium.java_client.imagecomparison.OccurrenceMatchingOptions;
import io.appium.java_client.imagecomparison.OccurrenceMatchingResult;
import io.appium.java_client.imagecomparison.SimilarityMatchingOptions;
import io.appium.java_client.imagecomparison.SimilarityMatchingResult;
import lombok.extern.slf4j.Slf4j;
import net.sourceforge.tess4j.Tesseract;
import net.sourceforge.tess4j.TesseractException;
import org.apache.commons.io.FileUtils;
import org.junit.After;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.OutputType;

import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
public class AppiumSampleTest {

    public static AndroidDriver driver;
    private static final double VISUAL_THRESHOLD = 0.99;

    @BeforeAll
    public static void setUp() throws MalformedURLException {
        UiAutomator2Options options = new UiAutomator2Options()
                .setUdid("HVA1FG23").setAutoGrantPermissions(true);

        URL appiumServer = URI
                .create("http://0.0.0.0:4723")
                .toURL();
        driver = new AndroidDriver(appiumServer, options);
    }

    @After
    public void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }

    @Test
    public void visualSimilarityMatching() throws IOException {

        log.info("System path = {} ", System.getProperty("user.dir"));

        File baseImage = new File("./src/test/resources/assets/base.png");

        if (!baseImage.exists()) {
            File screenshot = driver.getScreenshotAs(OutputType.FILE);
            FileUtils.copyFile(screenshot, new File("./src/test/resources/assets/base.png"));
        }

        SimilarityMatchingOptions opts =
                new SimilarityMatchingOptions().withEnabledVisualization();

        SimilarityMatchingResult res =
                driver.getImagesSimilarity(baseImage, driver.getScreenshotAs(OutputType.FILE), opts);

        if (res.getScore() >= VISUAL_THRESHOLD) {
            log.info("유사도 = [{}], 동일 화면임", res.getScore());
        } else {
            fail("유사도 = " + res.getScore() + ", 동일 화면이 아님");
        }
    }
}

실행결과:

15:23:38.305 [Test worker] INFO AppiumSampleTest -- System path = /Users/choichiwon/monimo/monimo-app-ui-automation 
15:23:39.700 [Test worker] INFO AppiumSampleTest -- 유사도 = [0.9995598793029785], 동일 화면임

 

OpenCVAppium을 이용해서 이미지 유사도를 체크할 수 있게 됐다.

하나 더 해보자. 이미지에 어떤 부분이 있는지를 체크해내는 것도 있다.

@Test
public void visualOccurrenceMatching() throws IOException {
    File baseImage = new File("./src/test/resources/assets/base.png");
    File fragment = new File("./src/test/resources/assets/fragment.png");

    OccurrenceMatchingOptions opts = new OccurrenceMatchingOptions().withEnabledVisualization();
    OccurrenceMatchingResult res = driver.findImageOccurrence(baseImage, fragment, opts);

    log.info("조각 부분의 길이 = {}", res.getVisualization().length);
    log.info("조각 부분의 x좌표 = {} | y좌표 = {}", res.getRect().getX(), res.getRect().getY());

    assertTrue(res.getVisualization().length != 0);
    assertNotNull(res.getRect());
}

 

코드를 보면 baseImage 말고 fragment가 보인다. 이 이미지는 다음과 같다.

이 이미지가 저 baseImage에 있는지도 체크할 수 있다. 있다면 x,y 좌표도 알아낼 수 있다.

실행결과:

15:30:19.701 [Test worker] INFO AppiumSampleTest -- 조각 부분의 길이 = 118624
15:30:19.707 [Test worker] INFO AppiumSampleTest -- 조각 부분의 x좌표 = 452 | y좌표 = 996

 

좌표를 알아냈으니 WebView로 된 UI라도 원하는 요소를 선택할 수 있게됐다. 

728x90
반응형
LIST

'테스트 자동화' 카테고리의 다른 글

6. Appium과 Cucumber를 이용해 UI Automation Testing  (0) 2024.04.17
5. 프로젝트 환경 설정  (2) 2024.04.17
4. Appium Inspector 연결  (0) 2024.04.17
3. APK 설치  (0) 2024.04.17
2. Appium  (0) 2024.04.17
728x90
반응형
SMALL

참고자료:

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런

김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습

www.inflearn.com

 

저번 포스팅에선 서블릿에 대해 알아봤다. 서블릿은 HTTP 프로토콜을 통해 클라이언트와 서버간 통신이 가능하게 해주는 것이었다. 개발자 대신 HTTP 요청정보와 응답정보를 만들어주고, 외부 요청이 들어오면 그 요청을 처리하는 서블릿을 서블릿 컨테이너에서 호출하면서 진행된다.

 

이제 서블릿으로 직접 웹 애플리케이션을 만들어 볼건데 그 전에 웹 기술의 역사와 HTML, HTTP API, CSR, SSR과 같은 용어 정리를 한 번 하고 넘어가자. 우선 반드시 알아두어야 할 키워드는 다음과 같다.

 

  • 정적 리소스 - 고정된 HTML 파일, CSS, JS, 이미지, 영상 등을 제공

 

  • HTML 페이지 - 동적으로 필요한 HTML 파일을 생성해서 전달

이때 브라우저는 HTML 페이지를 해석하는 역할을 한다.

 

 

  • HTTP API - HTML이 아니라 데이터를 전달한다. 주로 JSON형식을 사용하고 다양한 시스템(앱, 웹, 서버)에서 호출할 수 있다.

 

그래서 앱, 웹, 서버 어디서나 요청을 할 수 있고 요청에 따라 적절한 응답값을 화면이 아닌 데이터로 돌려주는 것이 HTTP API이다.

 

서버사이드 렌더링(SSR)

서버 사이드 렌더링은 서버에서 HTML 최종 결과물을 만들어서 웹 브라우저에 전달하는 방식이다. 주로 정적인 화면에 사용한다. 대표적인 기술로는 Thymeleaf가 있다.

클라이언트 사이드 렌더링(CSR)

HTML 결과를 자바스크립트를 사용해 웹 브라우저에서 동적으로 생성해서 적용한다. 주로 동적인 화면에 사용한다. 그러니까 서버에서는 이 화면을 만들기 위해 필요한 스크립트와 디자인을 클라이언트에 주고 클라이언트가 그 코드를 서버에 요청하는 방식이다. 대표적인 기술로는 React, Vue가 있다.

 

자바 웹 기술 역사

  • 서블릿 - 1997
    • HTML 생성이 어려움
  • JSP - 1999
    • HTML 생성은 편리하지만 비즈니스 로직까지 너무 많은 역할 담당
  • 서블릿, JSP 조합 MVC 패턴 사용
    • 모델, 뷰, 컨트롤러로 역할을 나누어 개발
  • 다수의 MVC 프레임워크가 출몰(2000 - 2010)
  • 애노테이션 기반의 스프링 MVC 등장
    • @Controller
  • 스프링 부트의 등장
    • 스프링 부트는 서버를 내장하고 있다. 무슨 말이냐면 과거에는 서버에 WAS를 직접 설치하고, 소스는 War 파일을 만들어서 설치한 WAS에 배포했는데 스프링 부트는 빌드 결과(Jar)에 WAS 서버를 포함하고 있다. 그래서 빌드, 배포의 단순화가 됐다.
    • 스프링 부트는 스프링 프레임워크로 개발할 때 필요한 여러 설정을 개발자 대신 해준다. 그리고 최적의 버전 호환성도 맞춰준다.

그래서 스프링 MVC, 스프링 부트가 최종 병기가 된 상태이다.

여기에 스프링이 공식적으로 지원하는 Thymeleaf라는 뷰 템플릿과 같이 사용하면 된다.

 

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

참고자료:

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런

김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습

www.inflearn.com

 

Spring MVC를 제대로 공부하려면, Spring MVC가 없던 시절로 돌아가서 어떤 웹 기술로부터 출발했고 어떻게 발전했는지 이해하는게 중요하다는 것을 깨닫고 맨 밑바닥부터 잡고 올라와 볼 생각이다. 그 시작엔 서블릿이 있다.

 

서블릿

간단한 HTML Form에서 데이터를 서버로 전송한다고 가정해보자.

유저는 Form에 username, age를 입력하고 전송 버튼을 누르면, HTTP 프로토콜을 이용하여 POST 방식으로 서버로 요청이 전달된다.

그럼 서버입장에선 날라온 요청(Request)을 분석하고 적절한 처리를 해야할 것이다.

 

근데 만약 아무런 도움을 받지 않고 0부터 100까지 전부 다 직접 구현해야 한다면 어떨까?

 

우선 TCP/IP 연결 대기부터 시작해서 소켓을 연결하는 코드를 작성하고, HTTP 요청 메시지를 파싱하고, 어떤 Method이고 어떤 URL로의 호출인지 파악하고, 바디를 읽어서 데이터를 가져오고, .... 응답 메시지를 생성하고, 응답을 전달하는 이 모든 과정을 직접 구현해야 한다면 정말 복잡한 일이 될 것이다. 그리고 이 안에서 의미있는 비즈니스 로직은 바디에 있는 데이터를 가져와서 비즈니스 로직에 맞게 데이터를 어떻게 정제하고 데이터베이스에 저장하는 딱 이 부분밖에 없다.

 

이 모든 일련의 과정을 전세계 모든 개발자가 다 일일이 하고 있다면 너무나 비효율적이지 않을까? 여기서 서블릿이 등장한다.

서블릿의 역할

서블릿은 저 부분에서 딱 비즈니스 로직을 제외한 모든 작업을 다 대신해준다.

그래서 지금 사진처럼 딱 초록색 박스에 있는 부분만 개발자는 신경쓰면 된다.

서블릿 코드를 한번 살펴보자.

@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {
    
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response){
    	//애플리케이션 로직
    }
}

 

최신 스타일의 서블릿 코드이지만 충분하다. 저렇게 urlPatterns"/hello"라고 설정하고 웹 브라우저에서 "서버URL/hello" 라고 입력하면 이 서블릿 코드가 실행이 된다. 그래서 실제로 요청과 응답에 필요한 모든 부분은 이 서블릿이 대신 다 해주고 저 코드에서처럼 service()에서 작성한 애플리케이션 로직(비즈니스 로직)에 관련된 코드만 개발자가 작성하면 되는것이다.

 

저 service()가 호출되면 두 개의 파라미터가 들어온다. 

  • HttpServletRequest request: HTTP 요청 정보를 편리하게 사용할 수 있는 객체
  • HttpServletResponse response: HTTP 응답 정보를 편리하게 제공할 수 있는 객체

위 그림처럼 클라이언트로부터 요청이 들어오면 그 요청 정보를 직접 파싱하지 않고 서블릿이 다 해준다고 했는데 예를 들면, Method 정보라던가, Parameter 정보라던가, Content-Type 정보라던가 등 요청 시 필요한 모든 정보를 서블릿이 우리대신 다 만들어서 가져다주고 그게 HttpServletRequest이다.

 

그리고, 위 그림처럼 응답을 해줘야 하는데 이 때 응답 메시지를 직접 만드는게 아니라 서블릿이 제공해주는 HttpServletResponse 객체를 통해 내가 원하는 것을 넣어서 응답만 해주면 된다.

 

서블릿과 HTTP 요청, 응답 흐름

그래서 이 내용을 그림으로 살펴보면 다음과 같다.

 

HTTP 요청이 딱 들어오면 웹 애플리케이션 서버(WAS)와 개발자가 다음과 같은 작업을 한다.

  • WAS는 Request, Response 객체를 새로 만들어서 서블릿 객체 호출
  • 개발자는 Request 객체에서 HTTP 요청 정보를 편리하게 꺼내서 사용
  • 개발자는 Response 객체에 HTTP 응답 정보를 편리하게 입력
  • WAS는 Response 객체에 담겨있는 내용으로 HTTP 응답 정보를 생성

 

저 그림에서 서블릿 컨테이너라는 게 보이는데 쟤는 뭘까?

서블릿 컨테이너

  • 서블릿을 지원하는 WAS를 서블릿 컨테이너라고 함
  • 서블릿 컨테이너는 서블릿 객체를 생성, 초기화, 호출, 종료하는 생명주기를 관리
  • 서블릿 객체는 싱글톤으로 관리
    • 고객의 요청이 올 때 마다 계속 객체를 생성하는 것은 비효율
    • 최초 로딩 시점에 서블릿 객체를 미리 만들어두고 재활용
    • 모든 고객 요청은 동일한 서블릿 객체 인스턴스에 접근
    • 공유 변수 사용 주의
    • 서블릿 컨테이너 종료시 함께 종료
  • 동시 요청을 위한 멀티 스레드 처리 지원

 

동시 요청을 위한 멀티 스레드 처리를 지원한다는 건 어떤 의미인지 살펴보자.

 

동시 요청과 멀티 스레드

지금까지 배운 내용은 웹 애플리케이션 서버가 있고 클라이언트가 이 WAS에 HTTP 요청을 보내면 그에 상응하는 미리 만들어 둔 서블릿 객체를 서블릿 컨테이너로부터 꺼내서 요청과 응답 데이터를 편리하게 꺼내쓰고 작성하여 다시 응답을 돌려준다는 것이다.

 

이런 그림이라고 보면 되는데 여기서 궁금한 점이 있다. 저 호출단계에서 서블릿 객체는 누가 호출할까?

서블릿 객체를 호출하는 것은 스레드이다.

 

스레드

  • 애플리케이션 코드를 하나하나 순차적으로 실행하는 것은 스레드
  • 자바 메인 메서드를 처음 실행하면 main이라는 이름의 스레드가 실행
  • 스레드가 없다면 자바 애플리케이션 실행이 불가능
  • 스레드는 한번에 하나의 코드 라인만 수행
  • 동시 처리가 필요하면 스레드를 추가로 생성

스레드가 서블릿 객체를 호출하고 사용한다는 것을 알았고 이 일련의 과정을 또 그림으로 이해해보자.

 

최초의 상태

 

요청이 들어옴

요청이 들어왔으니 해당 요청에 대해 스레드를 할당한 다음 스레드는 서블릿 객체를 호출한다.

 

요청을 처리하고 응답

모든 작업이 끝남

 

 

단일 요청일 땐 이렇게 간단하게 처리가 된다. 이제 다중 요청을 봐보자.

 

요청이 들어옴

 

요청1이 들어온 상태에서 또 다른 요청2가 들어옴

요청1이 이미 스레드를 사용중이기 때문에 요청2는 대기해야한다.

그런데, 요청1이 처리가 지연되면 결국 요청1, 요청2 모두 작업을 처리하지 못하는 상태가 일어날 수 있다.

요청1과 요청2 모두 처리 불능 상태로 빠짐

 

이런 문제를 해결하기 위해서 가장 간단한 방법은 스레드를 하나 더 생성하면 된다.

요청1이 스레드를 사용하고 있는 중에 요청2가 들어와서 새로운 스레드를 만듦

 

이렇게 요청이 들어올때마다 스레드를 생성하면 어떤 장단점이 있을까?

장점

  • 동시 요청을 처리할 수 있다.
  • 리소스(CPU, 메모리)가 허용할 때까지 처리 가능
  • 하나의 스레드가 지연되어도, 나머지 스레드는 정상 동작한다.

단점

  • 스레드 생성 비용은 매우 비싸다. 고객의 요청이 올 때 마다 스레드를 생성하면 응답 속도가 늦어진다.
  • 스레드는 컨텍스트 스위칭 비용이 발생한다.
  • 스레드 생성에 제한이 없다. 즉, 고객 요청이 너무 많이 오면 CPU, 메모리 임계점을 넘어서서 서버가 죽을 수 있다.

 

그래서 이런 문제를 해결하기 위해 보통의 WAS는 스레드 풀이라는 개념을 사용한다.

풀 안에 적절한 수의 스레드를 미리 만들어서 넣어두고 요청이 들어올 때마다 스레드 풀에서 하나씩 꺼내서 서블릿 객체를 호출해서 요청을 처리한다. 그래서 풀에 있는 모든 스레드가 다 사용중일 때 요청이 들어오면 다음과 같이 그 요청부턴 대기상태로 기다리거나 또는 거절하게 된다.

 

이렇게 풀을 만들어서 사용하면 요청이 들어올 때마다 스레드를 생성하는 단점을 보완할 수 있다.

스레드 풀 특징

  • 필요한 스레드를 스레드 풀에 보관하고 관리한다.
  • 스레드 풀에 생성 가능한 스레드의 최대치를 관리한다. 톰캣은 최대 200개 기본 설정 (변경 가능)

스레드 풀 사용

  • 스레드가 필요하면, 이미 생성되어 있는 스레드를 스레드 풀에서 꺼내서 사용한다.
  • 사용을 종료하면 스레드 풀에 해당 스레드를 반납한다.
  • 최대 스레드가 모두 사용중이어서 스레드 풀에 스레드가 없으면 기다리는 요청을 거절하거나 특정 숫자만큼만 대기하도록 설정할 수 있다.

스레드 풀 장점

  • 스레드가 미리 생성되어 있으므로 스레드를 생성하고 종료하는 비용이 절약되고 응답 시간이 빠르다.
  • 생성 가능한 스레드의 최대치가 있으므로 너무 많은 요청이 들어와도 기존 요청은 안전하게 처리할 수 있다.

 

그럼, 이 최대 스레드 수는 어떻게 설정하면 좋을까?

WAS의 주요 튜닝 포인트는 최대 스레드(max thread)수이다.

  • 이 값을 너무 낮게 설정하면? - 동시 요청이 많으면 서버 리소스는 여유로워도 클라이언트는 금방 응답 지연
  • 이 값을 너무 높게 설정하면? - 동시 요청이 많으면 CPU, 메모리 리소스 임계점 초과로 서버 다운

이 값을 너무 낮게 설정한다면 사용가능한 리소스(CPU, 메모리)는 넘쳐나는데도 고객은 응답을 지연받으니 매우 안좋은 결과가 생기고 이 값을 너무 높게 설정하면 동시 요청이 많아질수록 사용하는 리소스도 많아지다가 임계점 초과로 서버가 다운된다. 그래서 적절한 수의 최대 스레드 수가 필요하다. 

 

그럼 적절한 수는 어떻게 찾아요?

정답은 없고 최대한 실제 서비스와 유사하게 성능 테스트를 해가면서 적절한 숫자를 찾아야 한다. (JMeter, nGrinder와 같은)

 

가장 중요한 핵심은,

멀티 스레드에 대한 부분은 WAS가 다 처리해준다는 것.

개발자는 멀티 스레드 관련 코드를 신경쓰지 않고 아까 저 위에 코드처럼 service() 코드만 작성하면 된다.

그리고 최대 스레드 개수만 적절하게 설정해주면 된다. 

멀티 스레드 환경이므로 싱글톤 객체(서블릿, 스프링 빈)의 공유변수만 주의해서 사용하면 된다.

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

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

예외 처리 1 에서 다룬 프로그램에서 이런 문제가 있었다.

  • 정상 흐름과 예외 흐름이 섞여 있기 때문에 코드를 한눈에 이해하기 어렵다. 쉽게 이야기해서 가장 중요한 정상 흐름이 한눈에 들어오지 않는다.
  • 심지어 예외 흐름이 더 많은 코드 분량을 차지한다.

이 문제를 점진적으로 해결해보자.

 

NetworkClientExceptionV2

package exception.ex2;

public class NetworkClientExceptionV2 extends Exception {
    private String errorCode;

    public NetworkClientExceptionV2(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public String getErrorCode() {
        return errorCode;
    }
}

 

예외도 객체이다. 따라서 필요한 필드와 메서드를 가질 수 있다.

  • 오류 코드(errorCode): 이전에는 오류 코드를 반환값으로 리턴해서 어떤 오류가 발생했는지 구분했다. 여기서는 어떤 종류의 오류가 발생했는지 구분하기 위해 예외 안에 오류 코드를 보관한다.
  • 오류 메시지(message): 오류 메시지에는 어떤 오류가 발생했는지 개발자가 보고 이해할 수 있는 설명을 담아둔다. 오류 메시지는 상위 클래스인 Throwable에서 기본으로 제공하는 기능을 사용한다.  

NetworkClientV2

package exception.ex2;

public class NetworkClientV2 {

    private final String address;
    private boolean connectError;
    private boolean sendError;

    public NetworkClientV2(String address) {
        this.address = address;
    }

    public void connect() throws NetworkClientExceptionV2 {
        if (connectError) {
            throw new NetworkClientExceptionV2("connectError", address + " 서버 연결 실패");
        }

        System.out.println(address + " 서버 연결 성공");
    }

    public void send(String data) throws NetworkClientExceptionV2 {
        if (sendError) {
            throw new NetworkClientExceptionV2("sendError", address + " 서버에 데이터 전송 실패: " + data);
        }

        System.out.println(address + " 서버에 데이터 전송: " + data);
    }

    public void disconnect() {
        System.out.println(address + " 서버 연결 해제");
    }

    public void initError(String data) {
        if (data.contains("error1")) {
            connectError = true;
        }
        if (data.contains("error2")) {
            sendError = true;
        }
    }
}
  • 기존의 코드와 대부분 같지만, 오류가 발생했을 때 오류 코드를 반환하는 게 아니라 예외를 던진다.
  • 예외를 던지기 때문에 따로 반환값이 필요없다. 반환값을 void로 변경했다.
  • 이전에는 반환값을 통해 성공, 실패 여부를 확인했지만 이제 예외 처리 덕분에 메서드가 정상 종료되면 성공이고 예외가 던져지면 예외를 통해 실패를 확인할 수 있다.
  • 오류가 발생하면 예외 객체를 만들고 거기에 오류 코드와 오류 메시지를 담아둔다. 그리고 만든 예외 객체를 throw를 통해 던진다.

NetworkServiceV2_1

package exception.ex2;


public class NetworkServiceV2_1 {

    public void sendMessage(String data) throws NetworkClientExceptionV2 {
        String address = "https://example.com";
        NetworkClientV2 client = new NetworkClientV2(address);
        client.initError(data);

        client.connect();
        client.send(data);
        client.disconnect();
    }
}

예외를 별도로 처리하지 않고 throws를 통해 밖으로 던진다.

 

MainV2

package exception.ex2;

import java.util.Scanner;

public class MainV2 {
    public static void main(String[] args) throws NetworkClientExceptionV2 {
        NetworkServiceV2_1 networkService = new NetworkServiceV2_1();

        Scanner scanner = new Scanner(System.in);

        while (true) {
            System.out.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            networkService.sendMessage(input);
            System.out.println();
        }
        System.out.println("프로그램을 정상 종료합니다.");
    }
}

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
Exception in thread "main" exception.ex2.NetworkClientExceptionV2: https://example.com 서버 연결 실패
	at exception.ex2.NetworkClientV2.connect(NetworkClientV2.java:15)
	at exception.ex2.NetworkServiceV2_1.sendMessage(NetworkServiceV2_1.java:11)
	at exception.ex2.MainV2.main(MainV2.java:18)

 

"error1"을 사용자가 입력하면 외부 서버와 연결에 실패한다. 그리고 그 예외를 모든 곳에서 잡지 않았기 때문에 결과적으로 main() 밖으로 예외가 던져진다. 이렇게 되면 예외를 추적할 수 있는 스택 트레이스를 출력하고 프로그램을 종료한다.

 

남은 문제

  • 예외 처리를 도입했지만 예외가 복구되지 않는다. 그래서 예외가 발생하면 프로그램이 종료된다.
  • 사용 후 반드시 disconnect()를 호출해서 연결을 해제해야 한다.

 

예외 복구하기

이번엔 예외를 잡아서 예외 흐름을 정상 흐름으로 복구해보자.

 

NetworkServiceV2_2

package exception.ex2;


public class NetworkServiceV2_2 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV2 client = new NetworkClientV2(address);
        client.initError(data);

        try {
            client.connect();
        } catch (NetworkClientExceptionV2 e) {
            System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
            return;
        }

        try {
            client.send(data);
        } catch (NetworkClientExceptionV2 e) {
            System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
            return;
        }

        client.disconnect();
    }
}

connect(), send()와 같이 예외가 발생할 수 있는 곳을 try - catch를 사용해서 NetworkClientExceptionV2 예외를 잡았다. 여기서는 예외를 잡으면 오류 코드와 예외 메시지를 출력한다. 예외를 잡아서 처리했기 때문에 이후에는 정상 흐름으로 복귀한다. 여기서는 리턴을 사용해서 sendMessage()를 정상적으로 빠져나간다.

 

MainV2

package exception.ex2;

import java.util.Scanner;

public class MainV2 {
    public static void main(String[] args) throws NetworkClientExceptionV2 {
        NetworkServiceV2_2 networkService = new NetworkServiceV2_2();

        Scanner scanner = new Scanner(System.in);

        while (true) {
            System.out.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            networkService.sendMessage(input);
            System.out.println();
        }
        System.out.println("프로그램을 정상 종료합니다.");
    }
}

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2

전송할 문자: 
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

이제 예외를 잡아서 처리했기 때문에 예외가 복구되고 프로그램도 계속 수행할 수 있다.

 

남은 문제

  • 예외 처리를 했지만 정상 흐름과 예외 흐름이 섞여 있어 코드를 읽기 어렵다.
  • 사용 후 반드시 disconnect()를 호출해서 연결을 해제해야 한다.

 

정상, 예외 흐름 분리하기

NetworkServiceV2_3

package exception.ex2;


public class NetworkServiceV2_3 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV2 client = new NetworkClientV2(address);
        client.initError(data);

        try {
            client.connect();
            client.send(data);
            client.disconnect();
        } catch (NetworkClientExceptionV2 e) {
            System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
        }
    }
}

 

하나의 try 안에 정상 흐름을 모두 담는다. 

그리고 예외 부분은 catch 블럭에서 해결한다.

이렇게 하면 정상 흐름은 try 블럭에 들어가고, 예외 흐름은 catch 블럭으로 명확하게 분리할 수 있다.

 

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패

전송할 문자: 
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

실행 결과를 보면, 에러가 발생하면 다음 라인을 진행하지 않고 바로 catch로 빠지기 때문에 disconnect()를 호출하지 않는다.

 

 

남은 문제

  • 사용 후 반드시 disconnect()를 호출해서 연결을 해제해야 한다.

 

리소스 반환 정상적으로 해보기

현재 구조에서 disconnect()를 항상 호출하려면 다음과 같이 생각해 볼 수 있다.

NetworkServiceV2_4

package exception.ex2;


public class NetworkServiceV2_4 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV2 client = new NetworkClientV2(address);
        client.initError(data);

        try {
            client.connect();
            client.send(data);
        } catch (NetworkClientExceptionV2 e) {
            System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
        }
        
        client.disconnect();
    }
}

 

이 코드를 보면, 예외 처리가 끝난 다음에 정상 흐름의 마지막에 client.disconnect()를 호출했다. 

문제없이 동작할 것 같다.

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패
https://example.com 서버 연결 해제

전송할 문자: 
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2
https://example.com 서버 연결 해제

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

정말 문제없이 동작하고 있다. 에러가 발생해도 연결 해제가 항상 실행된다.

그러나, 이 방법에는 큰 문제가 있다. 바로 catch에서 잡을 수 없는 예외가 발생했을 때이다.

 

잠깐만 connect()를 아래처럼 에러 발생 시 NetworkClientExceptionV2가 아니라 RuntimeException으로 변경해보자.

public void connect() throws NetworkClientExceptionV2 {
    if (connectError) {
        // throw new NetworkClientExceptionV2("connectError", address + " 서버 연결 실패");
        throw new RuntimeException("Ex");
    }

    System.out.println(address + " 서버 연결 성공");
}

 

이렇게 변경하면 기존 코드로는 catch에서 connect() 호출 시 에러가 발생하면 잡을 수 없게 된다.

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
Exception in thread "main" java.lang.RuntimeException: Ex
	at exception.ex2.NetworkClientV2.connect(NetworkClientV2.java:16)
	at exception.ex2.NetworkServiceV2_4.sendMessage(NetworkServiceV2_4.java:12)
	at exception.ex2.MainV2.main(MainV2.java:18)

 

catch에서 잡기로 했던 NetworkClientExceptionV2가 아닌 다른 에러가 발생하면 잡을 수 없어 예외가 밖으로 던져지게 된다. 그러면 그 하단에 있는 모든 라인은 무시된다. 따라서, client.disconnect() 호출은 무시가 된다.

 

finally 등장

자바에서 저런 문제를 해결하기 위해 어떤 경우라도 반드시 호출되는 finally 기능을 제공한다.

 

NetworkServiceV2_5

package exception.ex2;


public class NetworkServiceV2_5 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV2 client = new NetworkClientV2(address);
        client.initError(data);

        try {
            client.connect();
            client.send(data);
        } catch (NetworkClientExceptionV2 e) {
            System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
        } finally {
            client.disconnect();
        }

    }
}

 

finally 키워드는 어떤 상황에서도 마지막에 반드시 실행되는 구문이다. 그래서 catch에서 잡을 수 없는 에러가 발생해서 밖으로 던져질 때에도 이 finally를 한 번 거친 후에 밖으로 던져진다.

 

다음과 같이 catch가 없이 try - finally 만 사용해도 된다.

try {
    client.connect();
    client.send(data);
} finally {
    client.disconnect();
}

그대신 이 경우 예외는 언체크 예외이거나, 체크 예외인 경우엔 throws 예외로 밖으로 던진다고 선언해야 한다. 체크 예외는 반드시 잡거나 던져야 하므로.

 

정리

자바 예외 처리는 try - catch - finally 구조를 사용해서 쉽게 처리할 수 있다. 덕분에 다음과 같은 이점이 있다.

  • 정상 흐름과 예외 흐름을 분리해서, 코드를 읽기 쉽게 만든다.
  • 사용한 자원을 항상 반환할 수 있도록 보장해준다.

 

근데, 이 모든게 체크 예외를 사용하면서 발생하는 번거로움이다. 결국 실무에서는 체크 예외보다 언체크 예외를 더 많이 사용한다. 왜냐하면 실무에선 수십개의 라이브러리를 사용하는데 그 모든 라이브러리에서 쏟아지는 모든 예외를 다 다루고 싶지 않을 것이다. 그리고 결정적으로 그 예외 중 대다수는 복구할 수 있는 예외가 아니라 복구할 수 없는 예외이다. 그런데도 불구하고 체크 예외이기 때문에 throws에 던질 대상을 일일이 명시해야 한다.

 

그래서 결국, 정말 무조건 체크해야 하는 예외나 해결이 가능한 예외만 잡아 처리하고 그 외에 것들은 신경 쓰지 않는 것이 더 나은 선택일 수 있다. 그러한 방법을 사용해보자.

 

언체크 예외 사용 시나리오

이번에는 Service에서 호출하는 클래스들이 언체크 예외를 전달한다고 가정해보자.

NetworkException, DatabaseException은 잡아도 복구할 수 없다. 언체크 예외이므로 이런 경우 무시하면 된다.

 

언체크 예외를 던지는 예시

class Service {
    void sendMessage(String data) {...}
}
  • 언체크 예외이므로 throws를 선언하지 않아도 된다.
  • 사용하는 라이브러리가 늘어나서 언체크 예외가 늘어도 본인이 필요한 예외만 잡으면 되고, throws를 늘리지 않아도 된다.

일부 언체크 예외를 잡아서 처리하는 예시

try {
   ...
} catch (XxxException) {...}

필요하다면 언체크 예외를 직접 잡아서 처리하면 된다.

 

이렇게 언체크 예외로 기본을 무시한다고 큰 틀을 잡고 모든 언체크 예외를 공통으로 처리하는 부분이 있으면 된다.

어차피 해결할 수 없는 예외들이기 때문에 이런 경우 고객에게는 현재 시스템에 문제가 있습니다.라고 오류 메시지를 보여주고, 만약 웹이라면 오류 페이지를 보여주면 된다. 그리고 내부 개발자가 지금의 문제 상황을 빠르게 인지할 수 있도록, 오류에 대한 로그를 남겨두면 된다.

 

이런 시나리오를 토대로 지금까지 다뤘던 코드에 적용해보자.

 

 

 

모든것을 언체크 예외로 변경한다. 우선 NetworkClientException도 두 개로 분류할 수 있다. ConnectException, SendException. connect() 호출 시 발생 가능성이 있는 에러를 ConnectException으로 처리하고 send() 호출 시 발생 가능성이 있는 에러를 SendException으로 처리해서 더 세분화하자.

 

NetworkClientExceptionV4

package exception.ex4.exception;

public class NetworkClientExceptionV4 extends RuntimeException {

    public NetworkClientExceptionV4(String message) {
        super(message);
    }
}

 

ConnectExceptionV4

package exception.ex4.exception;

public class ConnectExceptionV4 extends NetworkClientExceptionV4 {
    private final String address;

    public ConnectExceptionV4(String address, String message) {
        super(message);
        this.address = address;
    }

    public String getAddress() {
        return address;
    }
}

 

SendExceptionV4

package exception.ex4.exception;

public class SendExceptionV4 extends NetworkClientExceptionV4 {
    private final String data;

    public SendExceptionV4(String data, String message) {
        super(message);
        this.data = data;
    }

    public String getData() {
        return data;
    }
}

 

ConnectExceptionV4, SendExceptionV4는 모두 NetworkClientExceptionV4를 상속받는다. 그리고 각 예외 클래스는 본인한테 필요한 필드(address, data)를 가지고 있다. 

 

NetworkClientV4

package exception.ex4;

import exception.ex4.exception.ConnectExceptionV4;
import exception.ex4.exception.SendExceptionV4;

public class NetworkClientV4 {

    private final String address;
    private boolean connectError;
    private boolean sendError;

    public NetworkClientV4(String address) {
        this.address = address;
    }

    public void connect() {
        if (connectError) {
            throw new ConnectExceptionV4(address, "서버 연결 실패");
        }

        System.out.println(address + " 서버 연결 성공");
    }

    public void send(String data) {
        if (sendError) {
            // throw new RuntimeException("ex");
            throw new SendExceptionV4(data, address + " 서버에 데이터 전송 실패: " + data);
        }

        System.out.println(address + " 서버에 데이터 전송: " + data);
    }

    public void disconnect() {
        System.out.println(address + " 서버 연결 해제");
    }

    public void initError(String data) {
        if (data.contains("error1")) {
            connectError = true;
        }
        if (data.contains("error2")) {
            sendError = true;
        }
    }
}

connect(), send() 모두 throws를 사용할 필요가 없다.

 

NetworkServiceV4

package exception.ex4;

public class NetworkServiceV4 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV4 client = new NetworkClientV4(address);
        client.initError(data);

        try {
            client.connect();
            client.send(data);
        } finally {
            client.disconnect();
        }
    }
}

 

  • NetworkServiceV4는 발생하는 예외인 ConnectExceptionV4, SendExceptionV4를 잡아도 복구할 수 없다. 따라서 예외를 밖으로 던진다. 근데 언체크 예외이므로 던지는 걸 throws 선언없이 자동으로 해준다.
  • 개발자 입장에선 네트워크 문제가 발생하는데 그 문제가 발생한다고 뭘 할 수 있겠는가? 따라서 해당 예외들을 생각하지 않는 것이 더 나은 선택일 수 있다. 해결할 수 없는 예외들은 다른 곳에서 공통으로 처리된다.
  • 이런 방식 덕분에 NetworkServiceV4는 해결할 수 없는 예외보단 본인 스스로의 코드에 더 집중할 수 있다. 따라서 코드가 깔끔해진다.

MainV4

package exception.ex4;

import exception.ex4.exception.SendExceptionV4;

import java.util.Scanner;

public class MainV4 {
    public static void main(String[] args) {
        NetworkServiceV4 networkService = new NetworkServiceV4();

        Scanner scanner = new Scanner(System.in);

        while (true) {
            System.out.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            try {
                networkService.sendMessage(input);
            } catch (Exception e) {
                exceptionHandler(e);
            }

            System.out.println();
        }
        System.out.println("프로그램을 정상 종료합니다.");
    }

    // 공통 예외 처리
    private static void exceptionHandler(Exception e) {
        // 공통 처리
        System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");

        System.out.println("==개발자용 디버깅 메시지==");
        e.printStackTrace(System.out);
        // e.printStackTrace();

        //필요하면 예외 별로 별도의 추가 처리 가능
        if (e instanceof SendExceptionV4 sendExceptionV4) {
            System.out.println("[전송 오류] 전송 데이터: " + sendExceptionV4.getData());
        }
    }
}

 

공통 예외 처리

try {
    networkService.sendMessage(input);
} catch (Exception e) {
    exceptionHandler(e);
}

여기에 예외를 공통으로 처리하는 부분이 존재한다.

  • Exception을 잡아서 지금까지 해결하지 못한 모든 예외를 여기서 공통으로 처리한다. Exception을 잡으면 필요한 모든 예외를 잡을 수 있다.
// 공통 예외 처리
private static void exceptionHandler(Exception e) {
    System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");

    System.out.println("==개발자용 디버깅 메시지==");
    e.printStackTrace(System.out);
    // e.printStackTrace();

    //필요하면 예외 별로 별도의 추가 처리 가능
    if (e instanceof SendExceptionV4 sendExceptionV4) {
        System.out.println("[전송 오류] 전송 데이터: " + sendExceptionV4.getData());
    }
}
  • 복구할 수 없는 예외가 발생하면 사용자에게는 시스템 내 알 수 없는 문제가 발생했다고 알린다. (디테일한 내용을 알릴 필요가 없다. 알려서도 안된다)
  • 개발자는 빨리 문제를 찾고 디버깅 할 수 있도록 오류 메시지를 남겨두어야 한다.
  • 예외도 객체이므로 필요하면 instanceof와 같이 예외 객체의 타입을 확인해서 별도의 추가 처리를 할 수 있다.
참고로, 실무에서는 System.out 이런걸 사용하는 게 아니고 Slf4J, logback 같은 별도의 로그 라이브러리를 사용한다.

 

 

정리하자면

1. 대부분의 에러는 어차피 개발자가 복구할 수 없다.

2. 복구할 수 없는 에러를 체크 예외로 던져서 일일이 처리하는 건 예외 지옥에 빠질것이다.

3. 그래서 정말 처리해야 하는 예외만 체크 예외로 하고 나머지는 언체크 예외로 해서 본연의 기능에 충실할 수 있도록 코드를 작성한다. 이는 코드 가독성을 높이고 불필요한 라인을 제거할 수 있다.

4. 실행 중 발생한 언체크 예외를 공통으로 처리하는 부분에서 사용자에겐 문제가 발생했다고 알려주고 개발자는 그 문제를 로그를 통해 빠르게 알아내고 디버깅해서 문제를 빠르게 해결하면 된다.

 

728x90
반응형
LIST

+ Recent posts