AOP(Aspect Oriented Programming)
이전 유닛의 학습을 통해 우리는 AOP(Aspect Oriented Programming) 또는 관심 지향 프로그래밍이라 부르는 프로그래밍 기법에 대한 개념적인 내용을 배울 수 있었습니다.
핵심적인 내용을 다시 요약하면, AOP란 애플리케이션 개발의 과정에서 여러 객체에 공통적으로 적용할 수 있는 공통의 관심 사항(Cross-cutting Concern)과 핵심 로직과 관련한 핵심 관심 사항(Cross-cutting concern)을 분리시키는 프로그래밍 기법을 의미한다고 했습니다.
좀 더 구체적인 예시로, 애플리케이션의 보안, 로깅, 트랜젝션 등 공통적인 관심 사항을 따로 분리시켜 관리하는 것과 관련이 있었습니다.
결론적으로, 이렇게 AOP를 적용하여 공통의 관심 사항과 핵심 관심 사항의 기능을 구분하여 코드를 설계하면, 코드의 간결성과 재사용성을 높이고, 객체지향 설계 원칙에 좀 더 부합하는 코드를 구현할 수 있습니다
이러한 기본적인 이해를 바탕으로 이번 챕터에서는 AOP와 관련한 좀 더 구체적인 내용과 실제적인 사용 방법에 대해 알아보도록 하겠습니다. 실제로 코드를 작성해 보면서, AOP를 적용한 코드 설계가 객체지향적 관점에서 어떻게 앞에서 언급한 장점들을 가질 수 있는지 직접 경험해 보시길 바랍니다.
결론적으로, 이렇게 AOP를 적용하여 공통의 관심 사항과 핵심 관심 사항의 기능을 구분하여 코드를 설계하면, 코드의 간결성과 재사용성을 높이고, 객체지향 설계 원칙에 좀 더 부합하는 코드를 구현할 수 있습니다
이러한 기본적인 이해를 바탕으로 이번 챕터에서는 AOP와 관련한 좀 더 구체적인 내용과 실제적인 사용 방법에 대해 알아보도록 하겠습니다. 실제로 코드를 작성해 보면서, AOP를 적용한 코드 설계가 객체지향적 관점에서 어떻게 앞에서 언급한 장점들을 가질 수 있는지 직접 경험해 보시길 바랍니다.
학습 목표
- AOP가 무엇이며, 왜 필요한 지에 대해 이해하고 설명할 수 있다.
- Spring AOP에서 내부적으로 프록시 객체를 생성하여 AOP를 구현한다는 사실을 이해할 수 있다.
- AOP의 핵심 개념과 내용에 대해 이해하고, 적절하게 사용할 수 있다.
- 여러 개의 Advice를 적용한 AOP를 구현할 수 있다.
- 실습 과제를 통해 버거퀸 프로그램에 AOP를 적용해 본다.
AOP와 프록시 객체
스프링 프레임워크가 제공하는 AOP방식은 런타임 시에 프록시(Proxy) 객체를 생성해서 공통 관심 기능을 적용하는 방식을 사용합니다.
아직 무슨 의미인지 잘 와닿지 않으시리라 생각합니다. 지금부터 하나씩 코드를 통해서 알아보도록 하겠습니다.
시작하기
먼저 구구단 코드를 조금 변경하여 아래와 같이 인터페이스와 구현 객체로 작성해 보겠습니다.
- Gugudan 인터페이스
public interface Gugudan {
// 추상 메서드 정의
void calculate(int level, int count);
}
먼저 객체의 역할을 정의한 Gugudan 인터페이스를 작성합니다.
calculate(int level, int count) 메서드는 구하고자 하는 단수와 시작점을 파라미터로 전달하며, 단순하게 값을 출력할 예정이기 때문에 반환값은 void로 정의합니다.
- GugudanByForLoop 클래스
public class GugudanByForLoop implements Gugudan {
// for문을 사용한 구구단 메서드
@Override
public void calculate(int level, int number) {
for(int count = number; count < 10; count++) {
System.out.printf("%d x %d = %d\\\\n", level, count, level * count);
}
}
}
- GugudanByRecursion 클래스
public class GugudanByRecursion implements Gugudan {
// 재귀호출로 구현한 구구단
@Override
public void calculate(int level, int count) {
if(count > 9) {
return;
}
System.out.printf("%d x %d = %d\\\\n", level, count, level*count);
calculate(level, ++count);
}
}
다음으로 Gugudan 인터페이스를 각각 for문과 재귀 함수를 사용하여 구현한 구현 객체 클래스입니다. 앞에서 각각의 내용에 대해 배웠기 때문에 여기서 코드 흐름 자체에 대한 설명은 생략하겠습니다.
여기서 만약에 for문과 재귀 함수의 실행 시간을 측정하여, 어떤 방법이 더 빠르게 우리가 의도한 숫자를 출력해 낼 수 있는지 파악하고 싶다면 어떻게 해야 할까요?
몇 가지 방법이 있을 수 있겠지만, 가장 대표적인 방법은 메서드의 시작과 끝에서 각각의 시간을 구하고, 끝 시간에서 시작 시간을 뺄셈하여 그 차이를 출력하는 방법일 것입니다.
먼저 GugudanByForLoop 클래스에 다음과 같이 적용해 볼 수 있을 것입니다.
- GugudanByForLoop 클래스
public class GugudanByForLoop implements Gugudan {
// for문을 사용한 구구단 메서드
@Override
public void calculate(int level, int number) {
// 시작 시간
long start = System.nanoTime();
for(int count = number; count < 10; count++) {
System.out.printf("%d x %d = %d\\\\n", level, count, level * count);
}
// 종료 시간
long end = System.nanoTime();
System.out.println("-------------------------------");
System.out.printf("for문 구구단 (%d)단 실행 시간 = %d ns", level, (end - start));
}
}
- GugudanTest 클래스
public class GugudanTest {
public static void main(String[] args) {
// for문 구구단
GugudanByForLoop gugudanByForLoop = new GugudanByForLoop();
gugudanByForLoop.calculate(2, 1);
}
}
위의 코드를 작성하고 실행하면 다음과 같은 출력 화면을 확인할 수 있습니다.
- 출력 화면
하지만, 같은 방식으로 GugudanByRecursion 클래스를 작성하면 문제가 발생합니다. 직접 실행해서 어떤 문제가 발생하는지 확인해 보겠습니다.
- GugudanByRecursion 클래스
public class GugudanByRecursion implements Gugudan {
// 재귀호출로 구현한 구구단
@Override
public void calculate(int level, int count) {
// 시작 시간
long start = System.nanoTime();
if(count > 9) {
return;
}
System.out.printf("%d x %d = %d\\\\n", level, count, level*count);
calculate(level, ++count);
// 종료 시간
long end = System.nanoTime();
System.out.println("-------------------------------");
System.out.printf("재귀 구구단 (%d)단 실행 시간 = %d\\\\n", level, (end - start));
}
}
- GugudanTest 클래스
public class GugudanTest {
public static void main(String[] args) {
// 재귀 구구단
GugudanByRecursion gugudanByRecursion = new GugudanByRecursion();
gugudanByRecursion.calculate(2,1);
}
}
- 출력 화면
재귀 함수의 경우, calculate() 메서드를 여러 번 반복 호출하기 때문에 위의 출력 화면처럼 출력값이 의도한 것과 다르게 여러 번 출력되는 문제가 발생하는 것을 확인할 수 있습니다.
이런 문제를 해소하기 위해서, 기존에 구현 클래스들에 작성한 실행 시간 측정과 관련한 코드를 주석 처리하고, 다음과 같은 코드를 작성해 봅니다.
- GugudanTest 클래스
public class GugudanTest {
public static void main(String[] args) {
// for문 구구단
GugudanByForLoop gugudanByForLoop = new GugudanByForLoop();
System.out.println("🎯 for문 구구단 2단");
long start = System.nanoTime();
gugudanByForLoop.calculate(2,1);
long end = System.nanoTime();
System.out.println("---------------------------------------");
System.out.printf("실행 시간 = %d ns\\\\n", (end - start));
// 재귀 구구단
GugudanByRecursion gugudanByRecursion = new GugudanByRecursion();
System.out.println("\\\\n🎯 재귀 구구단 2단");
long start2 = System.nanoTime();
gugudanByRecursion.calculate(2,1);
long end2 = System.nanoTime();
System.out.println("---------------------------------------");
System.out.printf("실행 시간 = %d ns\\\\n", (end2 - start2));
}
}
- 출력 화면
이제 우리가 의도한 대로 값이 출력되고 있습니다. 하지만 현재 코드는 여전히 어떤 한계를 가지고 있습니다.
만약에 어떤 이유에서, 위에서 실행 시간의 측정 단위를 나노세컨드(ns)에서 밀리세컨드(ms)로 바꿔야 한다면 어떤 일이 발생할까요?
- GugudanTest 클래스
public class GugudanTest {
public static void main(String[] args) {
// for문 구구단
GugudanByForLoop gugudanByForLoop = new GugudanByForLoop();
System.out.println("🎯 for문 구구단 2단");
long start = System.nanoTime(); // 🛑
gugudanByForLoop.calculate(2,1);
long end = System.nanoTime(); // 🛑
System.out.println("---------------------------------------");
System.out.printf("실행 시간 = %d ms\\\\n", (end - start)); // 🛑
// 재귀 구구단
GugudanByRecursion gugudanByRecursion = new GugudanByRecursion();
System.out.println("\\\\n🎯 재귀 구구단 2단");
long start2 = System.nanoTime(); // 🛑
gugudanByRecursion.calculate(2,1);
long end2 = System.nanoTime(); // 🛑
System.out.println("---------------------------------------");
System.out.printf("실행 시간 = %d ms\\\\n", (end2 - start2)); // 🛑
}
}
만약 어떤 변경 사항이 발생한다면, 위에서 빨간 아이콘(🛑)으로 표시된 부분을 모두 일일이 변경해주어야 할 것입니다. 만약 구현 클래스가 늘어나면 그만큼 코드 중복이 일어나고, 중복이 일어난만큼의 수정이 필요합니다. 결론적으로, 위의 코드는 객체지향적이지 못합니다.
그렇다면 어떻게 해야 할까요?
이때 사용할 수 있는 방법이 바로 프록시 객체(Proxy Object)입니다. 영어로 프록시는 “대리”라는 뜻을 가지고 있습니다. 즉, 어떤 대상의 역할을 대리해서 처리하는 객체라고 이해할 수 있습니다.
한번 직접 코드를 통해 확인해 보겠습니다.
GugudanProxy라는 이름의 클래스를 만들고 아래와 같이 코드를 작성해 보겠습니다.
- GugudanProxy 클래스
public class GugudanProxy implements Gugudan {
private Gugudan delegator;
public GugudanProxy(Gugudan delegator) {
this.delegator = delegator;
}
@Override
public void calculate(int level, int count) {
long start = System.nanoTime();
delegator.calculate(2,1);
long end = System.nanoTime();
System.out.printf("클래스명: = %s\\\\n", delegator.getClass().getSimpleName());
System.out.printf("실행 시간 = %d ms\\\\n", (end - start));
System.out.println("-------------------------------");
}
}
위의 GugudanProxy 클래스를 보면, 해당 클래스는 자체적으로 핵심 기능 로직을 가지고 있는 것이 아니라, 생성자로 전달받은 객체에게 핵심 기능의 실행을 다른 객체에게 위임하고 있습니다. 동시에, 실행 시간을 측정하는 것과 같은 부가적인 기능에 대한 로직을 정의하고 있습니다.
이제 GugudanTest 클래스를 실행해 볼까요?
- GugudanTest 클래스
public class GugudanTest {
public static void main(String[] args) {
GugudanByForLoop gugudanByForLoop = new GugudanByForLoop();
System.out.println("🎯 for문 구구단 2단");
GugudanProxy proxy = new GugudanProxy(gugudanByForLoop);
proxy.calculate(2,1);
GugudanByRecursion gugudanByRecursion = new GugudanByRecursion();
System.out.println("🎯 재귀 구구단 2단");
GugudanProxy proxy2 = new GugudanProxy(gugudanByRecursion);
proxy2.calculate(2, 1);
}
}
코드를 실행해 보면 이전과 똑같은 출력 화면을 확인할 수 있습니다.
- 출력 화면
이처럼 GugudanProxy 클래스와 같이 핵심 기능을 다른 객체에게 위임하고, 동시에 실행 시간 측정과 같은 부가적인 기능을 제공하는 객체를 프록시라고 부릅니다. 이제 프록시의 존재로 인해 코드의 중복을 없애고, 기존 코드의 변경 없이도 실행 시간을 출력할 수 있게 되었습니다. 즉, GugudanByForLoop 클래스와 GugudanByRecursion 클래스 내부의 코드 변경 없이도 객체지향적으로 우리가 원하는 결과를 얻을 수 있게 되었습니다.
결론적으로, GugudanProxy 클래스는 실행 시간 측정이라는 공통 사항 로직에 집중하고, GugudanByForLoop 클래스와 GugudanByRecursion 클래스에서 구구단 계산에 대한 핵심 기능을 담당하여 역할을 분리시켰습니다.
그 결과, 핵심 기능을 구현한 코드의 수정 없이도 공통 기능을 적용할 수 있게 되었고, 코드의 중복을 최소화시킬 수 있게 되었는데 이것이 바로 AOP 프로그래밍의 핵심이라 할 수 있습니다. 즉, AOP는 핵심 기능의 코드의 변경 없이 공통 기능의 구현을 가능하게 합니다.
AOP의 핵심 개념
앞에서 봤었던 AOP(Aspect Oriented Programming)의 핵심을 다음의 두 가지로 정리할 수 있습니다.
- AOP란 공통 관심 사항과 핵심 관심 사항을 분리시켜 코드의 중복을 제거하고, 코드의 재사용성을 높이는 프로그래밍 방법론을 의미한다.
- AOP은 핵심 기능에 공통기능을 삽입하는 것으로, 이를 통해 핵심 관심 사항 코드의 변경 없이 공통 기능의 구현을 추가 또는 변경하는 것이 가능하다.
스프링 프레임워크는 앞에서 봤던 프록시 객체를 자동으로 생성하여 AOP를 구현하는 방식을 지원하고 있습니다.
참고로, 프록시를 사용하는 방법 외에도 컴파일 시점에 코드에 공통 기능을 삽입하거나 클래스 로딩 시점에 공통 기능을 삽입하는 방법이 있습니다. 혹시 좀 더 자세한 내용이 궁금하다면, 관련된 내용을 좀 더 찾아보세요. (클래스 로딩 시점, 런타임 시점)
스프링 AOP는 위의 그림과 같이 타깃 객체(Target Object)를 외부에서 프록시 객체가 한번 감싸는 구조를 가지고 있습니다. 마치, 이전의 챕터에서 GugudanProxy 클래스를 통해서 타깃 객체가 실행되는 것과 같은 구조입니다. 따라서, 설정에 따라 타깃 객체의 핵심 로직이 실행되기 전과 후에 공통 기능을 호출할 수 있습니다.
한 가지 차이는, 앞의 예제에서는 우리가 프록시 객체를 직접 생성해야 했지만, 스프링의 경우 해당 프록시 객체를 스프링이 자동적으로 생성해 주기 때문에 GugudanProxy 클래스와 같은 객체를 따로 만들 필요가 없다는 점입니다.
이제 스프링 AOP와 관련한 핵심 개념들을 코드 예제와 함께 알아보겠습니다.
먼저, AOP에서는 공통 관심 사항에 대한 기능을 애스펙트(Aspect)라고 부릅니다.
AOP에서 첫 번째 A에 해당하는 부분이기도 한데, 공통 기능과 적용 시점을 정의한 어드바이스(Advice)와 어드바이스가 적용될 지점을 정의하는 포인트컷(Pointcut)의 조합으로 구성됩니다. 트랜잭션이나 보안 등이 애스펙트의 대표적인 예시입니다.
그럼 이제 Aspect와 함께 알아두어야 할 핵심적인 개념들에 대해 살펴보겠습니다.
참고로, 스프링에서 구현 가능한 어드바이스의 종류는 몇 가지가 있는데, 아래와 같이 애너테이션을 사용하여 다양한 시점에 원하는 기능을 삽입할 수 있습니다.
- @Before
- 타깃 객체의 메서드 호출 전에 공통 기능을 실행
- @After
- 예외 발생 여부에 관계없이 타깃 객체의 메서드 실행 후 공통 기능을 실행
- @AfterReturning
- 타깃 객체의 메서드가 예외 없이 실행되어 값을 반환한 경우 공통 기능을 실행
- @AfterThrowing
- 타깃 객체의 메서드 실행 중 예외가 발생한 경우 공통 기능을 실행
- @Around
- 타깃 객체의 메서드 실행 전과 후 또는 예외 발생 시 공통 기능을 실행
- 가장 빈번하게 사용됨
개념적으로 복잡해 보이는 용어들이 한 번에 소개되어, 아직 구체적으로 각 용어들이 어떤 의미를 지니며 어떻게 사용될 수 있는 것인지 이해하기 어려울 것입니다.
이제 실제로 스프링 AOP를 구현해 보면서 위에서 소개되었던 개념들이 어떻게 사용되는지 순차적으로 이해해 보도록 합시다.
AOP 구현
이제 위에서 학습한 개념들이 스프링 AOP에서 실제적으로 어떻게 사용되는지 코드 예제를 통해서 확인해 보겠습니다.
예제는 앞에서 우리가 보았던 구구단 프로그램을 다시 활용해 보겠습니다.
가장 먼저 해야 할 일은 공통 기능을 제공하는 Aspect 구현 클래스를 만드는 일입니다.
이를 위해, 아래와 같이 gugudan 패키지에 애스펙트를 정의하기 위한 GugudanAspect 클래스를 생성합니다.
- GugudanAspect 클래스 생성
다음으로, GugudanAspect 클래스 안에 아래의 코드를 순차적으로 작성해 봅시다.
- GugudanAspect 클래스
package com.springboot.gugudan;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
/*
공통 관심 사항을 정의하는 Aspect 구현 클래스
*/
// (1) @Aspect
@Aspect
public class GugudanAspect {
// (2) @PointCut
// @Pointcut("execution(public void cal*(..))")
@Pointcut("execution(public void com..calculate(..))")
private void targetMethod() {}
// (3) @Around
@Around("targetMethod()")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
Object result = joinPoint.proceed();
return result;
} finally {
long end = System.nanoTime();
Signature signature = joinPoint.getSignature();
System.out.printf("%s.%s 메서드 호출!\\n", joinPoint.getTarget().getClass().getSimpleName(), signature.getName());
System.out.printf("실행 시간: %d ns", (end-start));
}
}
}
이제 위의 코드를 하나씩 풀어서 설명해보겠습니다. 처음 보기에 복잡해 보여도, 알고 보면 크게 어렵지 않습니다.
(1) @Aspect
애스펙트는 위와 같이 @Aspect 애너테이션을 사용하여 구현할 수 있습니다.
위에서 설명한 것처럼, 애스펙트는 공통 기능과 그 적용 시점을 정의한 어드바이스, 그리고 이것을 적용할 지점을 의미하는 포인트컷을 포함하는데, 해당하는 부분이 각각 (2) @Pointcut과 (3) @Around 애너테이션으로 구현됩니다.
(2) @Pointcut
@Pointcut은 포인트컷을 구현하기 위한 애너테이션입니다.
애스펙트를 적용할 위치를 지정할 때 사용하는 포인트컷의 설정을 보면 execution()으로 시작되는 명시자를 사용하여 어드바이스의 대상이 되는 메서드를 지정하고 있는데, 그 문법 구조는 다음과 같습니다.
위에서 대괄호([])로 표시된 부분은 생략 가능하고, 각 패턴은 * 기호를 사용하여 모든 값을 표현하는 것이 가능합니다. 마지막으로 점 두 개( .. )를 사용하여 0개 이상의 수를 표현할 수 있습니다.
위의 코드 예제를 통해서 좀 더 알아보겠습니다.
@Pointcut("execution(public void com..calculate(..))")
execution 명시자 안의 내용을 보면 순서대로 설명해 보겠습니다.
위의 명시자에 따라, 우리 코드 예제의 어드바이스의 대상이 되는 메서드는 public 접근제어자를 가지고, 리턴타입은 void , com 패키지 아래 매개 변수가 0개 이상인 calculate라는 이름을 가진 메서드들 모두입니다.
같은 결과를 아래와 같이 다른 표현식을 사용하여 도출하는 것도 가능합니다.
@Pointcut*("execution(* cal*(..))")
예를 들면, 접근제어자를 생략하고 타입 패턴을 로 하여 모든 타입을 받도록 한 후에, 다시 패키지(클래스)명 패턴을 생략하고 *기호를 사용하여 0개 이상의 매개변수를 사용하는 cal로 메서드를 대상으로 지정할 수 있습니다.
위의 예제들을 눈으로 확인하는데 그치지 말고, 아래에서 AOP를 구현한 코드를 실행할 때 반드시 직접 여러 시도해 보면서 충분히 이해해 보시기를 권장드립니다.
좀 더 많은 예제가 궁금하시다면, 여기서 확인해보세요.
(3) @Around
앞서 배웠던 것처럼, @Around 애너테이션은 타깃 객체의 메서드 실행 전과 후 또는 예외가 발생했을 때 사용합니다.
- @Around 애너테이션
// (3) @Around
@Around("targetMethod()")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
--- 생략 ---
}
@Around 애너테이션의 값으로 targetMethod()가 정의된 것을 확인할 수 있는 데, 이것은 targetMethod()에 정의한 포인트컷에 공통 기능을 적용한다는 것을 의미합니다.
즉, 앞에서 우리가 정의한 대상 메서드들의 실행 전과 후에 공통 기능을 실행한다는 의미입니다. 여기서 공통 기능을 실행하는 메서드는 위에서 정의한 measureTime()이며, 메서드 바디에 실행 시간 측정을 위한 로직이 작성됩니다.
measureTime(ProceedingJoinPoint joinPoint)의 ProceedingJoinPoint 타입의 매개변수는 타깃 객체의 실제 메서드를 호출할 때 사용되는데, 아래와 같이 proceed() 메서드를 사용합니다.
Object result = joinPoint.proceed();
이렇게 proceed() 메서드를 실행하면 실제 타깃이 되는 객체의 메서드가 호출됩니다.
우리의 구구단 프로그램의 경우, GugudanByForLoop 클래스 또는 GugudanByRecursion 클래스의 calculate() 메서드가 실행되는 식입니다. 따라서 실행 시간을 측정하기 위해 proceed() 메서드의 전과 후에 필요한 기능 구현을 위한 코드를 아래와 같이 위치시켜야 합니다.
- 공통 관심 기능 정의 & 핵심 기능 호출
@Around("targetMethod()")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
// 핵심 기능 로직 실행 전 호출
long start = System.nanoTime();
try {
// 핵심 기능 호출
Object result = joinPoint.proceed();
return result;
} finally {
// 핵심 기능 로직 실행 후 호출
long end = System.nanoTime();
Signature signature = joinPoint.getSignature();
System.out.printf("%s.%s 메서드 호출!\\n", joinPoint.getTarget().getClass().getSimpleName(), signature.getName());
System.out.printf("실행 시간: %d ns", (end-start));
}
}
마지막으로 ProceedingJoinPoint 인터페이스는 위의 getSignature() , getTarget() 등 호출한 메서드의 시그니처와 대상 객체를 구할 수 있는 메서드를 제공합니다.
이 외에도 매개 변수의 목록을 불러올 수 있는 getArgs() 메서드가 있습니다. 특별히 getSignature() 메서드는 Signature 타입의 값을 반환하는 데 해당 인터페이스를 사용하여 getName() , toShortString() , toLongString()과 같은 메서드의 정보를 제공받을 수 있습니다.
이제 공통 기능을 정의하기 위해 필요한 애스팩트 클래스를 만들었으므로, 이를 사용하기 위해 GugudanConfig 클래스를 통해 해당 클래스를 스프링 빈으로 등록하도록 합니다.
- GugudanConfig 클래스
package com.codestates.gugudan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@Configuration
@EnableAspectJAutoProxy // @Aspect 애노테이션 붙인 클래스를 공통 기능으로 적용
public class GugudanConfig {
@Bean
public GugudanAspect gugudanAspect() {
return new GugudanAspect();
}
// For문을 사용한 구구단 객체 빈 등록
@Bean
public Gugudan gugudan() {
return new GugudanByForLoop();
}
}
위의 코드에서, @EnableAspectJAutoProxy 애너테이션은 스프링으로 @Aspect 애너테이션이 붙은 빈 객체를 찾아 해당 객체의 포인트컷과 어드바이스 설정을 사용하도록 합니다.
이제 모든 준비가 끝났습니다.
마지막으로 실행 클래스 GugudanTest 클래스를 통해 앞에서 우리가 정의한 기능들이 제대로 동작하는지 확인해 봅시다.
- GugudanTest 클래스
public class GugudanTest {
public static void main(String[] args) {
// 레거시 코드
// GugudanByForLoop gugudanByForLoop = new GugudanByForLoop();
// System.out.println("🎯 for문 구구단 2단");
// GugudanProxy proxy = new GugudanProxy(gugudanByForLoop);
// proxy.calculate(2,1);
//
// GugudanByRecursion gugudanByRecursion = new GugudanByRecursion();
// System.out.println("🎯 재귀 구구단 2단");
// GugudanProxy proxy2 = new GugudanProxy(gugudanByRecursion);
// proxy2.calculate(2, 1);
AnnotationConfigApplicationContext annotationConfigApplicationContext = new AnnotationConfigApplicationContext(GugudanConfig.class);
Gugudan gugudan = annotationConfigApplicationContext.getBean("gugudan", Gugudan.class);
gugudan.calculate(2,1);
}
}
정상적으로 실행되었다면 다음과 같은 출력 결과를 확인할 수 있습니다.
- 출력 결과
이제 다시 GugudanConfig 클래스에서 타깃 객체를 GugudanByForLoop에서 GugudanByRecursion으로 아래와 같이 변경하고 실행해 봅시다.
- GugudanConfig 클래스
@Configuration
@EnableAspectJAutoProxy
public class GugudanConfig {
@Bean
public GugudanAspect gugudanAspect() {
return new GugudanAspect();
}
@Bean
public Gugudan gugudan() {
return new GugudanByRecursion();
}
}
정상적으로 작동한다면, 아래와 같은 실행 결과를 확인할 수 있습니다.
- 출력 결과
어떤가요?
이처럼 스프링 AOP는 프록시 객체를 통해 공통 관심 사항과 핵심 관심 사항에 대한 코드를 분리시켜 코드의 중복은 줄이고, 코드의 재사용성을 높이도록 편리하게 고안되었습니다. 또한, 코드의 수정과 변경이 필요한 경우에도 최소한의 변경으로 의도한 목적을 달성할 수 있습니다.
이번 예제는 가장 빈번하게 사용되는 Around Advice를 중심으로 구현해 봤지만, 만약 호기심이 더 생긴다면, @Before, @AfterReturning, @After 등 다른 어드바이스 종류들도 자기주도적으로 학습해 보면서 구현해 보시길 바랍니다.
여러 개의 Advice 사용
경우에 따라, 하나의 포인트컷에 여러 개의 어드바이스를 적용할 수도 있습니다. 앞에서 우리가 작성한 코드에 캐시 기능을 구현한 공통 기능을 부여하는 과정을 통해 좀 더 알아보도록 하겠습니다.
- GugudanCacheAspect 클래스
@Aspect
public class GugudanCacheAspect {
// 캐시 저장소
private List<Object> cache = new ArrayList<>();
// 포인트컷 적용
@Pointcut("execution(* cal*(..))")
public void cacheTarget() {
}
// 어드바이스 정의
@Around("cacheTarget()")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
// 데이터 초기화
Object[] argumentObject = joinPoint.getArgs();
String argumentToString = Arrays.toString(argumentObject);
// 만약 데이터가 있다면, 캐시에서 꺼내서 전달
if(cashe.size() != 0) {
for (Object element : cashe) {
String elementToString = Arrays.toString((Object[]) element);
if (elementToString.equals(argumentToString)) {
System.out.printf("캐시에서 데이터 불러오기[%s]\\\\n", elementToString);
return elementToString;
}
}
}
// 데이터가 없다면, 타깃 객체의 메서드를 호출하여 캐시에 데이터 추가
Object result = joinPoint.proceed();
cashe.add(argumentObject);
System.out.printf("캐시에 데이터 추가[%s]\\\\n", Arrays.toString(argumentObject));
return result;
}
}
위의 코드는 간단하게 캐시 기능을 구현한 새로운 애스펙트를 보여줍니다. 포인트컷은 이전의 GugudanAspect의 그것과 동일하게 적용하였고, @Around의 값으로 cacheTarget() 메서드를 설정했습니다.
캐시 기능과 관련해서는, 먼저 ArrayList로 캐시 저장소를 만든 다음, 만약 받아온 데이터와 일치하는 값이 있는 경우에는 캐시에서 데이터를 불러와 리턴하고 그렇지 않은 경우에는 타깃 객체의 메서드를 호출하여 캐시에 새로운 데이터를 추가하도록 했습니다. 전체적인 흐름을 보는 것이 목적이기 때문에 편의상 실제 데이터는 불러오지 않고, “캐시에서 데이터 불러오기”라는 문구만 출력해 보겠습니다.
이제, 이렇게 구현한 새로운 애스팩트를 구성 클래스에 추가해서 사용해 보도록 합시다.
- GugudanConfig 구성 클래스
@Configuration
@EnableAspectJAutoProxy
public class GugudanConfig {
// 새로운 애스펙트 추가
@Bean
public GugudanCacheAspect casheAspect() {
return new GugudanCacheAspect();
}
@Bean
public GugudanAspect gugudanAspect() {
return new GugudanAspect();
}
@Bean
public Gugudan gugudan() {
return new GugudanByRecursion();
}
}
- GugudanTest 클래스
public class GugudanTest {
public static void main(String[] args) {
AnnotationConfigApplicationContext annotationConfigApplicationContext = new AnnotationConfigApplicationContext(GugudanConfig.class);
Gugudan gugudan = annotationConfigApplicationContext.getBean("gugudan", Gugudan.class);
// 동일한 방법으로 메서드 3번 호출
gugudan.calculate(2,1);
gugudan.calculate(2,1);
gugudan.calculate(2,1);
}
}
- 출력 결과
출력 결과를 확인해 보면, 앞에서 우리가 정의한 캐시 애스펙트가 잘 적용된 것을 확인할 수 있습니다.
실행시간에 대한 공통 관심 사항은 GugudanAspect가, 캐시에 대한 공통 기능은 GugudanCacheAspect가 출력하고 있습니다. 또한, 캐시 기능이 잘 적용되어 동일한 방식으로 메서드를 3번 호출했을 때 첫 번째 호출에서만 GugudanAspect와 GugudanCacheAspect가 모두 동작하고, 나머지 두 번은 캐시에 대한 공통 기능만 사용된 것을 확인할 수 있습니다.
이러한 출력 흐름이 발생된 것은 두 개의 어드바이스가 다음의 순서로 적용되었기 때문입니다.
앞에서 살펴봤듯이, 위의 GugudanTest 클래스에서 getBean() 메서드를 사용하여 조회할 수 있는 빈 객체의 타입은 GugudanCacheAspect 프록시 객체입니다.
annotationConfigApplicationContext.getBean("gugudan", Gugudan.class);
그리고 GugudanCacheAspect 프록시 객체의 타깃 객체는 다시 GugudanAspect이며, 최종적으로 GugudanAspect 프록시 객체가 그 타깃 객체인 GugudanByRecursion 구현 객체의 메서드를 호출하는 식입니다.
좀 더 구체적으로 코드를 통해 확인해 보겠습니다.
가장 먼저, GugudanTest의 gugudan.calculate(2,1) 메서드가 실행되면 위의 그림처럼 GugudanCacheAspect 프록시 객체가 동작합니다.
- GugudanCacheAspect 클래스
@Aspect
public class GugudanCacheAspect {
// 캐시 저장소
private List<Object> cashe = new ArrayList<>();
--- 생략 ---
// 어드바이스 정의
@Around("cacheTarget()")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
--- 생략 ---
**// (1) 데이터가 없기 때문에 joinPoint.proceed() 호출**
Object result = joinPoint.proceed();
**// (5) 캐시에 데이터 추가 및 메시지 출력**
cashe.add(argumentObject);
System.out.printf("캐시에 데이터 추가[%s]\\n", Arrays.toString(argumentObject));
return result;
}
}
최초 호출 시 데이터가 없기 때문에 (1) joinPoint.proceed() 메서드가 호출이 되는데, 그 대상 객체가 GugudanAspect 프록시 객체이기 때문에 해당 객체의 measureTime() 메서드가 실행됩니다.
- GugudanAspect 클래스
@Aspect
public class GugudanAspect {
--- 생략 ---
@Around("targetMethod()")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
**// (2) 실제 타깃 객체 메서드 호출 -> GugudanByRecursion 클래스 메서드 호출**
Object result = joinPoint.proceed();
return result;
} finally {
**// (4) 실행 시간을 측정하여 출력**
long end = System.nanoTime();
Signature signature = joinPoint.getSignature();
System.out.printf("%s.%s 메서드 호출!\\n", joinPoint.getTarget().getClass().getSimpleName(), signature.getName());
System.out.printf("실행 시간: %d ns\\n", (end-start));
System.out.println("----------------------------");
}
}
}
- GugudanByRecursion 클래스
public class GugudanByRecursion implements Gugudan {
**// (3) 실제 타깃 객체 메서드 호출**
@Override
public void calculate(int level, int count) {
if(count > 9) {
return;
}
System.out.printf("%d x %d = %d\\\\n", level, count, level*count);
calculate(level, ++count);;
}
}
measureTime 메서드는 위와 같이 다시 (2) - (3) 실제 타깃 객체 메서드인 GugudanByRecursion의 calculate(*int* level, *int* count) 메서드를 실행하고, 이어서 (4) 실행 시간을 측정하여 출력합니다. 마지막으로, 다시 GugudanCacheAspect 클래스에서 (5) 반환된 값을 캐시에 새롭게 추가하고, 이를 확인하는 메시지를 출력합니다.
그러나 두 번째 gugudan.calculate(2,1) 호출부터는 아래와 같이 캐시 기능을 통해 기존에 저장된 데이터가 반환되기 때문에 GugudanAspect 클래스는 실행되지 않습니다.
- GugudanCacheAspect 클래스
@Aspect
public class GugudanCacheAspect {
--- 생략 ---
// 어드바이스 정의
@Around("cacheTarget()")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
// 데이터 초기화
Object[] argumentObject = joinPoint.getArgs();
String argumentToString = Arrays.toString(argumentObject);
// 만약 데이터가 있다면, 캐시에서 꺼내서 전달
if(cashe.size() != 0) {
for (Object element : cashe) {
String elementToString = Arrays.toString((Object[]) element);
if (elementToString.equals(argumentToString)) {
System.out.printf("캐시에서 데이터 불러오기[%s]\\\\n", elementToString);
return elementToString;
}
}
}
// 실행 X
Object result = joinPoint.proceed();
cashe.add(argumentObject);
System.out.printf("캐시에 데이터 추가[%s]\\\\n", Arrays.toString(argumentObject));
return result;
}
}
결론적으로, 어드바이스가 적용되는 순서는 GugudanCacheAspect 프록시 ⇒ GugudanAspect 프록시 ⇒ GugudanByRecursion 객체 순이며, 이에 따라 각각 해당하는 메서드가 순차적으로 호출되는 것을 눈으로 확인할 수 있습니다.
만약에 이 순서를 바꾸고 싶다면 어떻게 할 수 있을까요?
경우에 따라 애스펙트의 적용 순서가 중요한 경우, 순서를 개발자의 의도대로 맞추기 위해 @Order 애너테이션을 아래와 같이 사용할 수 있습니다. 우리의 예제에서 GugudanAspect 클래스와 GugudanCacheAspect의 순서를 @Order 애너테이션을 사용하여 변경해 보겠습니다.
- GugudanAspect 클래스
@Aspect
@Order(value = 1) // 🛑 추가
public class GugudanAspect {
--- 생략 ---
}
- GugudanCasheAspect 클래스
@Aspect
@Order(value = 2) // 🛑 추가
public class GugudanCacheAspect {
--- 생략 ---
}
위와 같이 @Order 애너테이션의 속성 값으로 각각 1, 2를 추가해 준 이후에 다시 프로그램을 실행시켜 보겠습니다.
- 출력 결과
프로그램을 실행해보면, 위와 같이 이전과 다른 출력 결과가 보여지는 것을 확인할 수 있습니다. 이처럼 애스펙트의 실행 순서가 중요한 경우 @Order 애너테이션을 사용하여 순서를 정할 수 있습니다.
[Spring MVC] API 계층
이번 챕터부터 여러분들은 Spring 기반의 웹 애플리케이션 제작을 위한 설계, 구현, 테스트 등에 대한 학습을 하게 될 것입니다.
단순히 Spring 기반의 웹 애플리케이션 구현 기술에 대한 이론적인 지식만을 확인하는 것이 아니라 기술의 이해를 바탕으로 애플리케이션 제작에 대한 설계, 코드 작성, 구현한 코드의 실행 등을 여러분들이 직접 실습해보면서 웹 애플리케이션 제작에 대한 막막한 접근이 아니라 ‘아, 웹 애플리케이션을 이런 식으로 만들면 되는구나’라는 느낌을 손가락으로, 눈으로, 그리고 여러분의 머리로 익숙하게 받아들일 수 있는 시간을 가져보겠습니다.
이번 챕터는 그 첫 번째 시간으로 웹브라우저나 모바일 기기 등의 클라이언트로부터 들어오는 요청을 직접적으로 전달받게 되는 API 계층에 대한 학습을 진행하도록 하겠습니다.
“백문이 불여일견”이라는 말 아시죠?
프로그래밍의 세계에서는 “백문이 불여일타(백번 듣는 것보다 소스 코드를 한번 타이핑해보는 게 좋다는 의미)”라는 비슷한 표현을 사용합니다.
이번 시간부터 여러분들이 만나게 되는 예제 코드는 반드시 직접 타이핑을 해보면서 해당 지식을 자신의 것으로 만들길 바랍니다.
단, 무턱대고 아무 생각 없이 텍스트로 된 코드를 단순히 옮겨 적는 적는 것이 아니라 코드에 담겨 있는 의미(사용 방법 등)를 되새겨 보면서 타이핑을 해보길 바랍니다.
그럼 지금부터 Java 백엔드 개발자가 되기 위한 즐거운 시간 속으로 들어가 보도록 하겠습니다.
[Spring MVC] API 계층 유닛에 대한 권장 학습 가이드
- [기본] 챕터
- 여러분들이 Spring MVC 및 Spring Security 유닛이 끝날 때까지 샘플 애플리케이션에 단계적으로 기능을 추가해 보고 해당 기술들을 익히기 위한 필수 챕터입니다.
- 따라서 [기본] 챕터에 있는 예제 코드들은 가이드에 따라서 반드시 코드 타이핑을 해보고, 여러분들의 샘플 프로젝트에 적용해 보아야 합니다.
- [심화] 챕터
- 여러분들이 만들어보는 샘플 애플리케이션에 기본적으로 포함이 되지 않는 추가 학습 콘텐츠입니다.
- 따라서 [심화] 챕터에서 배우게 되는 내용들은 프로젝트 기간에 여러분들의 프로젝트에 적용해 볼만한 내용들이라고 생각하면 됩니다.
주의 사항
- [심화] 챕터에 나오는 예제 코드들은 [기본] 챕터에서 만든 프로젝트 외에 별도의 Spring Boot 프로젝트 하나를 더 만들어서 학습하기를 권장합니다.
- [심화] 챕터를 위한 별도의 Spring Boot 프로젝트를 만들어서 학습하기를 권장하는 이유
- [기본] 챕터에서 학습하는 샘플 프로젝트에 [심화] 챕터에서 학습한 내용을 적용해 볼 수는 있습니다.
- 다만, [심화] 챕터에서 학습한 내용을 적용하면서 오류가 발생할 경우 문제를 빨리 찾으면 다행이지만 그렇지 않을 경우 여러분들의 머리가 복잡해질 수 있기 때문에 가급적이면 별도의 Spring Boot 프로젝트를 만들어서 학습하길 권장합니다.
- 물론 이렇게 ⭐ 발생하는 오류들을 해결하는 것이 여러분들이 성장하는 밑거름이 되는 것은 분명하지만 다음 챕터를 학습하는 과정이 지체될 정도로 오류를 붙잡고 있는 것은 여러분들의 코스 전반에 악영향을 미칠 가능성도 있으므로 밸런스 조절을 잘하길 바랍니다.
Spring Boot 프로젝트 생성 관련해서는 이어지는 [샘플 프로젝트 소개 및 프로젝트 환경 구성] 챕터를 참고하세요!
학습 목표
- Spring MVC
- Spring MVC란 무엇인지 이해할 수 있다.
- Spring MVC의 동작방식과 구성요소를 이해할 수 있다.
- Controller
- API 엔드 포인트인 Controller의 구성 요소를 이해할 수 있다.
- 실제 동작하는 Controller의 기본 기능을 구현할 수 있다.
- DTO(Data Transfer Object)
- DTO가 무엇인지 이해할 수 있다
- DTO Validation이 무엇인지 이해할 수 있다.
- Controller에 DTO 클래스를 적용할 수 있다.
샘플 프로젝트 소개 및 프로젝트 환경 구성
학습 방향
“만들어 보면서 익히자!”
Spring 기반의 웹 애플리케이션 구현 기술을 익히는 가장 좋은 방법 중 한 가지는 바로 간단한 애플리케이션이라도 스스로 직접 만들어 보는 것입니다.
개발자로서 입문하고자 하는 분들이 개발 서적 몇 권을 모두 정독한 후에 애플리케이션을 직접 구현하는데 어려움을 겪는 이유 중에 하나는 책에 나오는 예제 코드들을 반복해서 타이핑해보며 해당 기술을 직접 몸으로 체험하지 않았거나 책 자체에 실습을 위한 예제 코드보다는 이론적인 내용들이 더 많아서일 거라고 생각합니다.
직접 만들어보면서 이 기능이 왜 필요한지, 이 기능을 사용하면 어떤 식으로 동작하는지 등을 눈으로 확인하고, 그렇게 확인한 결과들을 자신만의 지식 창고에 살아있는 지식으로 보관해서 자기 것으로 만들어야지 필요할 때마다 꺼내서 올바르게 사용할 수 있을 거라 확신합니다.
따라서 우리는 이번 챕터부터 간단하지만 실제로 잘 돌아가는 샘플 애플리케이션을 직접 만들어보는 시간을 가질 것입니다. 그리고 그 안에서 샘플 애플리케이션 제작에 사용되는 기술에 대한 지식을 쌓아가 보도록 하겠습니다.
직접 만들어 보고 여러 가지 문제를 겪어보면서 그 기술의 기본을 이해한 후에 중/고급 지식을 접하는 것과 단순히 텍스트를 읽어서 해당 기술을 이해하려고 하는 것은 성장 속도면에서 굉장한 차이가 있습니다.
코드를 타이핑하는 것은 개발자의 숙명이라는 것을 꼭 기억하길 바랍니다.
‘내가 과연 혼자서 애플리케이션을 만들 수 있을까?’라는 고민은 하지 않아도 됩니다.
여러분들이 샘플 애플리케이션을 스스로 만들어볼 수 있는 충분한 예제 코드와 설명을 제공하고 있고, 예제 코드에 나오는 기술에 대한 이해를 바탕으로 실습 챕터에서 추가적인 기능까지 직접 구현해 보는 시간을 지속적으로 가지도록 하겠습니다.
애플리케이션의 큰 틀이 만들어지는 것을 시작으로 세부 기능이 채워지고 마지막에는 보안적인 기능까지 추가된 후, 안전하게 잘 돌아가는 애플리케이션이 만들어지는 과정을 처음부터 끝까지 여러분의 눈으로 직접 확인해 보세요.
스스로를 뿌듯하게 만들 그런 값진 시간을 만들어갈 수 있길 바라봅니다.
샘플 프로젝트 소개
애플리케이션 이름
커피 주문 웹 애플리케이션
애플리케이션 설명
[그림 3-1] 커피 주문 앱 화면 예
[그림 3-1]은 어느 프랜차이즈 매장의 메뉴를 주문할 수 있는 스마트 폰 앱 화면 중 커피 및 음료를 선택할 수 있는 화면입니다.
백엔드(Backend) 서버에서 실행되는 웹 애플리케이션은 [그림 3-1]처럼 고객이 볼 수 있는 화면이 실행되는 애플리케이션이 아닙니다.
[그림 3-1]에 있는 정보만으로 설명하자면, 백엔드 측 애플리케이션은 스마트 폰 앱의 화면에서 볼 수 있는 커피의 이름, 커피 이미지 등의 정보 자체를 스마트폰 앱 쪽에 제공해 주는 역할을 합니다.
스마트폰 앱에서는 백엔드 서버로부터 전달받은 커피 정보를 토대로 [그림 3-1]과 같은 화면을 예쁘게 재구성하게 됩니다.
스마트폰의 앱처럼 사용자에게 직접적으로 보이고 사용자가 직접 사용할 수 있는 애플리케이션을 프론트엔드(Frontend) 또는 클라이언트 측 애플리케이션이라고 합니다.
즉, 서버 측 자원(Resource)을 사용하는 쪽이 클라이언트가 되며, 이러한 클라이언트로는 웹브라우저, 모바일 브라우저, 스마트폰 앱, 데스크 탑 애플리케이션 등이 있습니다.
요즘은 매장에서 키오스크 화면을 통해 고객이 직접 셀프 주문도 할 수 있는데, 키오스크 역시 클라이언트의 하나라고 보면 되겠습니다.
백엔드 측의 애플리케이션은 기본적으로 클라이언트의 유형과 무관하게 공통의 정보를 제공하도록 디자인이 되어야 하지만 특정 클라이언트 유형에 맞는 정보를 추가적으로 제공하기 위한 확장을 고려해야 하는 경우도 빈번하다는 사실을 기억하길 바랍니다.
우리가 제작해야 할 샘플 애플리케이션은 바로 클라이언트 측에서 커피 주문을 위해 필요한 정보를 제공하기 위한 서버용 웹 애플리케이션이 되겠습니다.
사실 프랜차이즈 매장에서 고객이 주문할 수 있는 메뉴로 커피 이외에 햄버거, 콜라, 감자튀김, 피자, 주스 등등 굉장히 많은 것들이 있을 텐데, 우리는 학습을 위해 다양한 메뉴 중에서 커피만 주문할 수 있는 것으로 기능을 제한하도록 하겠습니다.
이처럼 요구사항에 맞춰서 어떤 애플리케이션의 기능을 특정 범위로 제한하는 것을 애플리케이션 경계를 설정한다라고 합니다.
서버용 웹 애플리케이션에서 제공할 기능
고객이 스마트폰 앱을 통해 커피를 주문하기 위해서는 어떤 정보들이 필요할까요?
여러분들 스스로 곰곰이 생각해 보고 아래 내용을 확인해 보기 바랍니다.
- 커피 주문에 필요한 기능 예
- 제일 먼저 커피 자체에 대한 정보(Coffee)가 필요할 것입니다.
- 다음으로 이 커피를 주문하는 고객의 정보(Member)가 필요할 것입니다.
- 그리고 고객이 주문하려는 커피의 주문 정보(Order)가 필요할 것입니다.
기본적으로는 이 세 가지 정보만 있으면 고객은 커피를 주문하고, 주문된 커피를 마실 수 있습니다.
하지만 현실적으로 사용자 입장에서 불편하지 않은 애플리케이션으로 완성되려면 아래 예와 같이 고려해야 될 사항이 꽤 많습니다.
- 커피 주문을 위해 더 고려해야 할 사항 예
- 커피 전문점의 주인이 커피 정보를 등록하는 기능
- 고객이 주문한 커피에 대한 결제 기능
- 고객에게 배달을 통해 커피를 전달할지 매장에 직접 방문해서 가져가게 할 지의 선택 기능
- 고객이 결제한 커피에 대한 포인트 또는 스탬프에 대한 처리 기능
- 고객이 결제한 커피에 대한 배달 및 픽업 완료 기능
우리는 학습용 샘플 애플리케이션을 제작해야 하기 때문에 결체 처리 같은 외부 연동 기능을 사용할 수는 없지만 결제 기능 이외에 앞에서 언급한 대부분의 기능은 샘플 애플리케이션을 제작하면서 포함을 하도록 하겠습니다.
샘플 프로젝트 환경 구성
커피 주문 애플리케이션 제작을 위한 프로젝트 환경은 기존의 설정 파일을 활용하거나 아래의 가이드 대로 새로운 프로젝트 하나를 생성하면 됩니다.
단, 변경되는 부분은 아래를 참고해서 변경하길 바랍니다.
- https://start.spring.io 사용시 아래 부분 확인
Spring Initializr 항목 입력 부분에서 [Project Metadata] 부분의 [Artifact], [Name], [Description] 항목을 ‘spring-week1’으로 변경하고, Package name은 ⭐com.springboot로 변경하면 됩니다.
Chapter - Spring MVC 아키텍처
이번 챕터에서는 본격적으로 샘플 애플리케이션을 만들어보기 전에 우리가 만들어야 할 애플리케이션 구현에 사용되는 Spring MVC에 대해서 간단히 살펴보도록 하겠습니다.
이번 챕터에서는 Spring MVC가 무엇인지 그리고 Spring MVC의 동작 방식에 대해서 대략적으로 살펴보기만 하고 Spring MVC의 구체적인 기능들은 샘플 애플리케이션을 만들어가면서 본격적으로 익혀보도록 하겠습니다.
학습 목표
- Spring MVC가 무엇인지 알 수 있다.
- Spring MVC의 동작 방식과 구성요소를 이해할 수 있다.
Spring MVC란?
‘아키텍처로 보는 Spring Framework 모듈(Module) 구성’ 챕터에서 본 것처럼 Spring에서 지원하는 모든 기능들을 포함해서 Spring Framework이라고 부릅니다.
Spring의 모듈 중에는 웹 계층을 담당하는 몇 가지 모듈이 있습니다. 특히 서블릿(Servlet) API를 기반으로 클라이언트의 요청을 처리하는 모듈이 있는데, 이 모듈 이름이 바로 spring-webmvc입니다.
개발자들 사이에서는 Spring Web MVC를 줄여서 Spring MVC라고 부르고 있고, Spring MVC가 웹 프레임워크의 한 종류이기 때문에 Spring MVC 프레임워크라고도 부릅니다.
우리는 가장 많이 사용하는 용어인 Spring MVC라고 부르도록 하겠습니다.
아직 Spring MVC가 무엇인지 구체적으로 머릿속에 그려지지 않겠지만 지금은 두 가지만 기억하면 됩니다.
- Spring MVC는 클라이언트의 요청을 편리하게 처리해 주는 프레임워크이다.
- 우리가 만들게 될 샘플 애플리케이션은 Spring MVC가 제공해 주는 기능을 이용해서 만든다.
서블릿(Servlet)이란?
서블릿에 대해서는 이전 학습에서 잠깐 언급을 한 적이 있습니다.
서블릿은 클라이언트의 요청을 처리하도록 특정 규약에 맞추어서 Java 코드로 작성하는 클래스 파일입니다.
그리고 아파치 톰캣(Apache Tomcat)은 이러한 서블릿들이 웹 애플리케이션으로 실행이 되도록 해주는 서블릿 컨테이너(Servlet Container) 중 하나입니다.
우리가 학습을 진행하면서 직접적으로 서블릿 기술을 사용할 일은 없지만 Spring MVC 내부에서는 서블릿을 기반으로 웹 애플리케이션이 동작한다는 사실은 기억을 하고 있으면 좋겠습니다.
외울 필요는 없습니다. 어차피 학습을 진행하다 보면 서블릿이나 아파치 톰캣 같은 용어들은 여러분들한테 자연스럽게 다가갈 테니까요.
그렇다면 Spring MVC에서 MVC는 무엇을 의미할까요? 지금부터 MVC의 의미를 살펴보도록 하겠습니다.
Model
Model은 Spring MVC에서 M에 해당됩니다.
여러분들은 우리말로 모델이라고 하면 제일 먼저 무엇이 떠오를까요?
아마도 TV에서 볼 수 있는 패션쇼 무대에서 디자이너가 새롭게 만든 옷을 입고 무대를 멋지게 걷는 패션모델들이 제일 많이 떠오를 것 같습니다.
즉, 일상 세계에서 의미하는 패션모델은 디자이너가 만든 작업의 결과물을 관객들에게 보여주기 위한 역할을 합니다.
Spring MVC에서의 Model도 마찬가지 역할을 합니다.
Spring MVC 기반의 웹 애플리케이션이 클라이언트의 요청을 전달받으면 요청 사항을 처리하기 위한 작업을 합니다.
이렇게 처리한 작업의 결과 데이터를 클라이언트에게 응답으로 돌려줘야 하는데, 이때 클라이언트에게 응답으로 돌려주는 작업의 처리 결과 데이터를 Model이라고 합니다.
클라이언트의 요청 사항을 구체적으로 처리하는 영역을 서비스 계층(Service Layer)이라고 하며, 실제로 요청 사항을 처리하기 위해 Java 코드로 구현한 것을 비즈니스 로직(Business Logic)이라고 합니다.
View
View는 Spring MVC에서 V에 해당됩니다.
View는 앞에서 설명한 Model 데이터를 이용해서 웹브라우저 같은 클라이언트 애플리케이션의 화면에 보이는 리소스(Resource)를 제공하는 역할을 합니다.
Spring MVC에는 다양한 View 기술이 포함되어 있는데 View의 형태는 아래와 같이 나눌 수 있습니다.
- HTML 페이지의 출력
- 클라이언트 애플리케이션에 보이는 HTML 페이지를 직접 렌더링 해서 클라이언트 측에 전송하는 방식입니다.
- 즉, 기본적인 HTML 태그로 구성된 페이지에 Model 데이터를 채워 넣은 후, 최종적인 HTML 페이지를 만들어서 클라이언트 측에 전송해 줍니다.
- Spring MVC에서 지원하는 HTML 페이지 출력 기술에는 Thymeleaf, FreeMarker, JSP + JSTL, Tiles 등이 있습니다.
- PDF, Excel 등의 문서 형태로 출력
- Model 데이터를 가공해서 PDF 문서나 Excel 문서를 만들어서 클라이언트 측에 전송하는 방식입니다.
- 문서 내에서 데이터가 동적으로 변경되어야 하는 경우 사용할 수 있는 방식입니다.
- XML, JSON 등 특정 형식의 포맷으로의 변환
- Model 데이터를 특정 프로토콜 형태로 변환해서 변환된 데이터를 클라이언트 측에 전송하는 방식입니다.
- 이 방식의 경우 특정 형식의 데이터만 전송하고, 프론트엔드 측에서 이 데이터를 기반으로 HTML 페이지를 만드는 방식입니다.
- 장점
- 프론트엔드 영역과 백엔드 영역이 명확하게 구분되므로 개발 및 유지보수가 상대적으로 용이합니다.
- 프론트엔드 측에서 비동기 클라이언트 애플리케이션을 만드는 것이 가능해집니다.
- 참고로 여러분들에게 현실감 있게 와닿지 않는 내용들은 이런 게 있구나라고 생각만 하고 그냥 넘겨도 됩니다.
JSON(JavaScript Object Notation)이란?
JSON은 우리가 앞으로 학습하게 될 Spring MVC에서 클라이언트 애플리케이션과 서버 애플리케이션이 주고받는 데이터 형식입니다.
과거에는 XML 형식의 데이터가 많이 사용되었으나 현재는 XML보다 상대적으로 가볍고, 복잡하지 않은 JSON 형식을 대부분 사용하고 있는 추세입니다.
- JSON의 기본 포맷
- {”속성”:”값”} 형태입니다.
예를 들어 Coffee라는 클래스가 있다고 가정하겠습니다.
public class Coffee {
private String korName;
private String engName;
private int price;
public Coffee(String korName, String engName, int price) {
this.korName = korName;
this.engName = engName;
this.price = price;
}
}
코드 3-1은 Coffee를 클래스로 표현한 코드입니다. 고객이 아메리카노 한 잔을 주문하기 위해서 아메리카노의 정보를 요청한다면 서버 애플리케이션 쪽에서 아메리카노 정보를 JSON 형식으로 변환해서 전송해주어야 합니다.
이 경우 서버 애플리케이션 쪽에서 클라이언트 쪽으로 전송하는 아메리카노 정보는 JSON으로 어떻게 표현되는지 확인해 보겠습니다.
public class JsonExample {
public static void main(String[] args) {
Coffee coffee = new Coffee("아메리카노", "Americano", 3000);
Gson gson = new Gson();
String jsonString = gson.toJson(coffee);
System.out.println(jsonString);
}
}
코드 3-2는 Gson이라는 라이브러리를 사용해서 Coffee 클래스의 객체를 JSON 포맷 형태로 출력하는 예제입니다.
여기서 Gson의 사용법은 지금 당장 중요하지 않습니다.
중요한 것은 Java의 객체를 JSON 포맷으로 변환할 수 있다는 것과 JSON 포맷의 데이터를 눈으로 직접 확인하는 것입니다.
출력 결과를 확인해 볼까요?
코드 실행 결과
=============================================================
{"korName":"아메리카노","engName":"Americano","price":3000}
이 처럼 JSON 포맷은 기본적으로{”속성”:”값”}형태로 구성이 됩니다.
JSON 포맷은 중요하기 때문에 [필수 학습] 코너에서 별도로 학습을 하기 바랍니다.
Controller
Controller는 Spring MVC에서 C에 해당됩니다.
Controller는 클라이언트 측의 요청을 직접적으로 전달받는 엔드포인트(Endpoint)로써 Model과 View의 중간에서 상호 작용을 해주는 역할을 합니다.
즉, 클라이언트 측의 요청을 전달받아서 비즈니스 로직을 거친 후에 Model 데이터가 만들어지면, 이 Model 데이터를 View로 전달하는 역할을 합니다.
@RestController
@RequestMapping(path = "/v1/coffee")
public class CoffeeController {
private final CoffeeService coffeeService;
CoffeeController(CoffeeService coffeeService) {
this.coffeeService = coffeeService;
}
@GetMapping("/{coffee-id}") // (1)
public Coffee getCoffee(@PathVariable("coffee-id") long coffeeId) {
return coffeeService.findCoffee(coffeeId); // (2)
}
}
코드 3-3은 Spring MVC에서 Controller에 해당되는 영역을 코드로 작성한 예입니다. (Spring MVC의 사용법은 뒤에서 배울 예정이므로 지금은 몰라도 됩니다. 동작 흐름만 파악하세요)
- (1)의 @GetMapping 애노테이션을 통해 클라이언트 측의 요청을 수신합니다.
- (2)에서 CoffeeService 클래스의 findCoffee() 메서드를 호출해서 비즈니스 로직을 처리합니다.
(2)에서 비즈니스 로직을 처리한 다음 리턴 받는 Coffee가 여기서는 Model 데이터가 됩니다.
그리고 getCoffee()에서 이 Model 데이터를 리턴하는데, 리턴되는 이 Model 데이터는 우리가 코드 상에서는 확인할 수 없지만 내부적으로 Spring의 View가 전달받아서 JSON 포맷으로 변경한 후에 클라이언트 측에 전달합니다.
[Spring MVC의 동작 방식] 챕터에서 다시 살펴보겠지만 Model, View, Controller 간의 처리 흐름은 최종적으로 아래와 같습니다.
Client가 요청 데이터 전송
→ Controller가 요청 데이터 수신 → 비즈니스 로직 처리 → Model 데이터 생성
→ Controller에게 Model 데이터 전달 → Controller가 View에게 Model 데이터 전달
→ View가 응답 데이터 생성
핵심 포인트
- Spring의 모듈 중에서 서블릿(Servlet) API를 기반으로 클라이언트의 요청을 처리하는 모듈이 바로 spring-webmvc이다.
- spring-webmvc 모듈이 Spring MVC이다.
- Spring MVC는 웹 프레임워크의 한 종류이기 때문에 Spring MVC 프레임워크라고도 부른다.
- Spring MVC에서 M은 Model을 의미한다.
- 클라이언트에게 응답으로 돌려주는 작업의 처리 결과 데이터를 Model이라고 한다.
- Spring MVC에서 V는 View를 의미한다.
- View는 Model 데이터를 이용해서 웹브라우저 같은 클라이언트 애플리케이션의 화면에 보이는 리소스(Resource)를 제공한다.
- 우리가 실질적으로 학습하게 되는 View는 JSON 포맷의 데이터를 생성한다.
- Spring MVC에서 C는 Controller를 의미한다.
- Controller는 클라이언트 측의 요청을 전달받아 Model과 View의 중간에서 상호 작용을 해주는 역할을 담당한다.
- Spring MVC에서 MVC의 전체적인 동작 흐름은 다음과 같다.
- Client가 요청 데이터 전송→ Controller에게 Model 데이터 전달 → Controller가 View에게 Model 데이터 전달
- → View가 응답 데이터 생성
- → Controller가 요청 데이터 수신 → 비즈니스 로직 처리 → Model 데이터 생성
심화 학습
- JSON의 표기법과 사용법이 익숙하지 않다면 아래 링크를 통해 학습하세요
- 아래 링크는 JSON 포맷의 문자열을 Java 클래스로 변경해 주는 온라인 툴입니다. 이 온라인 툴을 사용해서 JSON 포맷의 문자열이 Java에서 어떻게 표현되는지 연습해 보세요.
[그림 3-2] json to java object online 툴 사용법
- (1)의 입력 폼에 JSON 포맷의 문자열을 입력하세요.
- (2)의 [JSON to Java] 버튼을 눌러주세요.
- (3)의 출력 폼에 Java 클래스가 정상적으로 출력되는지 확인하세요.(클래스 이름은 중요하지 않습니다. 에러 없이 클래스가 표시되면 됩니다)
- 만약 JSON 포맷이 올바르지 않을 경우에는 클래스가 정상적으로 생성되지 않습니다.
- JSON 포맷 문자열에서 무엇이 문제인지 고민해보고, 해결해 보세요.
Spring MVC의 동작 방식과 구성 요소
이번 시간에는 Spring MVC에서 클라이언트의 요청이 어떤 과정들을 거쳐서 Controller까지 전달되는지 Spring MVC 내부의 동작 방식을 살펴보도록 하겠습니다.
Spring MVC의 요청과 응답 흐름에 대한 내부적인 동작 방식을 외울 필요는 없지만 Spring MVC 구성 요소 간의 관계를 이해하고 있다면 실무에서 어떤 문제가 발생했을 때, 문제를 어디서부터 해결해야 될지에 대한 방법을 찾는 것이 조금 더 수월해집니다.
Spring MVC의 동작 방식과 구성 요소
[그림 3-3] Spring MVC의 동작 방식 및 구성요소
[그림 3-3]은 클라이언트가 요청을 전송했을 때, Spring MVC가 내부적으로 이 요청을 어떻게 처리하는지를 보여주고 있습니다.
번호가 매겨진 대로 그 흐름을 천천히 따라가보겠습니다.
(1) 먼저 클라이언트가 요청을 전송하면 DispatcherServlet이라는 클래스에 요청이 전달됩니다.
(2) DispatcherServlet은 클라이언트의 요청을 처리할 Controller에 대한 검색을 HandlerMapping 인터페이스에게 요청합니다.
(3) HandlerMapping은 클라이언트 요청과 매핑되는 핸들러 객체를 다시 DispatcherServlet에게 리턴해줍니다.
핸들러 객체는 해당 핸들러의 Handler 메서드 정보를 포함하고 있습니다. Handler 메서드는 Controller 클래스 안에 구현된 요청 처리 메서드를 의미합니다.
(4) 요청을 처리할 Controller 클래스를 찾았으니 이제는 실제로 클라이언트 요청을 처리할 Handler 메서드를 찾아서 호출해야 합니다. DispatcherServlet은 Handler 메서드를 직접 호출하지 않고, HandlerAdpater에게 Handler 메서드 호출을 위임합니다.
(5) HandlerAdapter는 DispatcherServlet으로부터 전달받은 Controller 정보를 기반으로 해당 Controller의 Handler 메서드를 호출합니다.
이제 전체 처리 흐름의 반환점을 돌았습니다. 이제부터는 반대로 되돌아갑니다.
(6) Controller의 Handler 메서드는 비즈니스 로직 처리 후 리턴 받은 Model 데이터를 HandlerAdapter에게 전달합니다.
(7) HandlerAdapter는 전달받은 Model 데이터와 View 정보를 다시 DispatcherServlet에게 전달합니다.
(8) DispatcherServlet은 전달받은 View 정보를 다시 ViewResolver에게 전달해서 View 검색을 요청합니다.
(9) ViewResolver는 View 정보에 해당하는 View를 찾아서 View를 다시 리턴해줍니다.
(10) DispatcherServlet은 ViewResolver로부터 전달받은 View 객체를 통해 Model 데이터를 넘겨주면서 클라이언트에게 전달할 응답 데이터 생성을 요청합니다.
(11) View는 응답 데이터를 생성해서 다시 DispatcherServlet에게 전달합니다.
(12) DispatcherServlet은 View로부터 전달받은 응답 데이터를 최종적으로 클라이언트에게 전달합니다.
DispatcherServlet의 역할
Spring MVC의 요청 처리 흐름을 가만히 살펴보면 DispatcherServlet이 굉장히 많은 일을 하는 것처럼 보입니다.
클라이언트로부터 요청을 전달받으면 HandlerMapping, HandlerAdapter, ViewResolver, View 등 대부분의 Spring MVC 구성 요소들과 상호 작용을 하고 있는 것을 볼 수 있습니다.
그런데 DispatcherServlet이 굉장히 바빠 보이지만 실제로 요청에 대한 처리는 다른 구성 요소들에게 위임(Delegate)하고 있습니다.
마치 “HandlerMapping(핸들러매핑)아 Handler Controller 좀 찾아줄래? → ViewResolver(뷰리졸버)야 View 좀 찾아줄래? → View야 Model 데이터를 합쳐서 콘텐츠 좀 만들어 줄래?”라고 하는 것과 같습니다.
이처럼 DispatcherServlet이 애플리케이션의 가장 앞단에 배치되어 다른 구성요소들과 상호작용하면서 클라이언트의 요청을 처리하는 패턴을 Front Controller Pattern이라고 합니다.
Spring MVC의 동작 방식이 여러분들 머릿속에 오래 기억되도록 요청 처리 흐름을 애니메이션으로 구성했으니, 기억이 나지 않으면 틈틈이 확인하면서 기억하길 바라봅니다.
[그림 3-4] Spring MVC의 동작 방식 및 구성요소 애니메이션
핵심 포인트
- Spring MVC의 요청 처리 흐름
- 클라이언트의 요청을 제일 먼저 전달받는 구성요소는 DispatcherServlet이다.
- DispatcherServlet은 HandlerMapping 인터페이스에게 Controller의 검색을 위임한다.
- DispatcherServlet은 검색된 Controller 정보를 토대로 HandlerAdapter 인터페이스에게 Controller 클래스 내에 있는 Handler 메서드의 호출을 위임한다.
- HandlerAdapter 인터페이스는 Controller 클래스의 Handler 메서드를 호출한다.
- DispatcherServlet은 ViewResolver에게 View의 검색을 위임한다.
- DispatcherServlet은 View에게 Model 데이터를 포함한 응답 데이터 생성을 위임한다.
- DispatcherServlet은 최종 응답 데이터를 클라이언트에게 전달한다.
- DispatcherServlet이 애플리케이션의 가장 앞단에 배치되어 다른 구성요소들과 상호작용하면서 클라이언트의 요청을 처리하는 패턴을 Front Controller Pattern이라고 한다.
심화 학습
- 아래 링크를 통해 Spring MVC에서 사용하는 HandlerMapping, HandlerAdapter, ViewResolver 등에서 사용하는 용어의 의미와 역할을 조금 더 구체적으로 이해해 보세요.
- Spring MVC 동작 방식 추가 설명: https://itvillage.tistory.com/entry/Spring-MVC의-동작-방식-추가-설명
Chapter - Controller
이번 챕터부터는 Spring MVC 기반 웹 애플리케이션의 API 계층에서 사용되는 기능들을 알아보고 여러분들이 직접 코드로 구현해 보는 시간을 가져보도록 하겠습니다.
Controller 챕터에서는 클라이언트의 HTTP 요청을 직접적으로 전달받는 Controller에 대해서 살펴보고, 여러분들이 직접 Controller를 구현해 보는 실습을 진행합니다.
이번 챕터부터는 여러분들이 코드를 타이핑하는 시간이 늘어납니다.
챕터의 기본 예제 코드들은 여러분들이 모두 직접 타이핑해보면서 해당 기능들을 직접 손으로 익혀야 합니다.
눈으로만 익히는 것이 아니라 손으로 타이핑한 것을 눈으로 보고, 머리로 익히는 것이 여러분들이 이번 시간부터 해야 할 일이라는 것 명심하기 바랍니다.
코드의 타이핑과 반복적인 코드 타이핑 연습이 많으면 많을수록 여러분들이 Spring MVC의 기술에 그만큼 빨리 익숙해질 수 있는 길이라는 것! 명심하세요!
그럼 지금부터 Controller에 대한 학습을 시작해 보도록 하겠습니다.
API 계층에서는 순수하게 API 계층에 대한 학습만 진행하기 때문에 별도의 데이터베이스 연동은 없습니다. 데이터베이스 연동은 데이터 액세스 계층에서 진행하므로 데이터베이스 연동이 궁금하더라도 조금만 기다려주세요!
학습 목표
- Controller
- API 엔드 포인트인 Controller의 구성 요소를 이해할 수 있다.
- 실제 동작하는 Controller의 기본 기능을 구현할 수 있다.
[기본] Controller 클래스 설계 및 구조 생성
[그림 3-5] API 계층(계층형 아키텍처)
[그림 3-5]는 계층형 아키텍처에서 API 계층의 모습입니다.
API 계층은 여러분이 앞서 학습한 것처럼 클라이언트의 요청을 직접적으로 전달받는 계층입니다.
이번 챕터부터 이 API 계층을 Spring MVC 기반의 코드로 구현해 보도록 하겠습니다.
여러분들이 해야 할 일은 학습 내용을 천천히 읽어가면서 코드를 직접 따라서 타이핑하는 것입니다.
그리고 API 계층에서 사용하는 기능들을 여러분들의 것으로 만드는 것입니다.
그럼 지금부터 시작해 볼까요?
[Spring MVC의 동작 방식과 구성 요소] 챕터에서 살펴보았던 클라이언트 요청 흐름의 끝에는 Controller가 있습니다.
바로 이 Controller 클래스가 Spring MVC에서 클라이언트 요청의 최종 목적지인 셈입니다.
이제 여러분들과 함께 Controller를 만들어가면 됩니다.
어디서부터 시작하면 될까요?
애플리케이션을 제작하기 위해서 실질적으로 제일 먼저 해야 되는 일은 애플리케이션의 경계를 설정하는 것과 애플리케이션 기능 구현을 위한 요구 사항을 수집하는 일입니다.
하지만 우리는 이미 \[샘플 프로젝트 소개 및 프로젝트 환경 구성] 챕터에서 이미 커피 주문 애플리케이션이라는 애플리케이션 경계를 설정했고, 커피 주문 애플리케이션에서 제공해야 할 기능이 무엇인지 대략적으로 살펴보았습니다.
여러분들이 Spring MVC를 처음 배우는 현재 시점에서 요구 사항 수집은 이 정도면 충분합니다.
패키지 구조 생성
그다음으로 해야 할 일은 [샘플 프로젝트 소개 및 프로젝트 환경 구성] 챕터에서 생성한 프로젝트에 Java 패키지 구조를 잡는 것입니다.
Spring Boot 기반의 애플리케이션에서 주로 사용되는 Java 패키지 구조는 기능 기반 패키지 구조(package-by-feature)와 계층 기반 패키지 구조(package-by-layer)가 있습니다.
기능 기반 패키지 구조(package-by-feature)
기능 기반 패키지 구조란 말 그대로 애플리케이션의 패키지를 애플리케이션에서 구현해야 하는 기능을 기준으로 패키지를 구성하는 것입니다.
이렇게 나누어진 패키지 안에는 하나의 기능을 완성하기 위한 계층별(API 계층, 서비스 계층, 데이터 액세스 계층) 클래스들이 모여있습니다.
[그림 3-6] 기능 기반 패키지 구조 예
[그림 3-6]은 기능 기반 패키지 구조의 예입니다.
[그림 3-6]에서는 회원을 관리하기 위한 회원 기능과 커피를 관리하기 위한 커피 기능을 각각 coffee와 member라는 패키지로 나누었으며, 각각의 패키지 안에 레이어 별 클래스들이 존재합니다.
[그림 3-6]에서 보이는 클래스 들은 어떤 역할을 하는지 이번 챕터부터 천천히 알아갈 예정이므로 지금은 몰라도 됩니다.
패키지 구조라는 측면에서 내용을 이해하길 바랍니다.
계층 기반 패키지 구조(package-by-layer)
계층 기반 패키지 구조란 패키지를 하나의 계층(Layer)으로 보고 클래스들을 계층별로 묶어서 관리하는 구조를 말합니다.
[그림 3-7] 계층 기반 구조 패키지 구조 예
[그림 3-7]은 계층(Layer)을 기반으로 패키지를 구성한 예입니다. ‘controller, dto’ 패키지는 API 계층에 해당되고, ‘model, service’ 패키지는 비즈니스 계층에 해당되며, repository는 데이터 액세스 계층에 해당됩니다.
우리는 어떤 유형의 패키지 구조를 사용하면 좋을까요?
위 두 가지 패키지 구조는 애플리케이션의 요구 사항이나 특성에 따라서 상황에 맞게 적절하게 사용하면 됩니다.
다만, Spring Boot 팀에서는 테스트와 리팩토링이 용이하고, 향후에 마이크로 서비스 시스템으로의 분리가 상대적으로 용이한 기능 기반 패키지 구조 사용을 권장하고 있습니다.
따라서 우리 역시 앞으로 진행되는 샘플 애플리케이션 제작에 기능 기반 패키지 구조를 따르도록 하겠습니다.
커피 주문 애플리케이션의 Controller 설계
커피 주문 애플리케이션의 Controller 클래스 코드를 작성하기 전에 우리가 생각해 보아야 할 부분은 무엇이 있을까요?
제일 먼저 ‘클라이언트로부터 발생할 요청에는 어떤 것들이 있을까’를 고민해 보는 것입니다.
이 말을 서버 애플리케이션 입장에서 생각하면 ‘클라이언트 요청을 처리할 서버 애플리케이션의 기능으로 뭐가 필요할까’와 같습니다.
커피 주문 애플리케이션의 기능 요구 사항
[샘플 프로젝트 소개 및 프로젝트 환경 구성] 챕터의 [서버용 웹 애플리케이션에서 제공할 기능]을 토대로 다시 생각을 해보면 기본적으로 고객이 커피를 주문하고 주문한 커피를 마시기 위해서는 아래와 같은 기능이 필요합니다.
- 주인이 커피 정보를 관리하는 기능
- 커피 정보 등록 기능
- 등록한 커피 정보 수정 기능
- 등록한 커피 정보 삭제 기능
- 등록한 커피 정보 조회 기능
- 고객이 커피 정보를 조회하는 기능
- 커피 정보 조회 기능
- 고객이 커피를 주문하는 기능
- 커피 주문 등록 기능
- 커피 주문 취소 기능
- 커피 주문 조회 기능
- 고객이 주문한 커피를 주인이 조회하는 기능
- 커피 주문 조회 기능
- 고객에게 전달 완료한 커피에 대한 주문 완료 처리 기능
위 기능들에 대한 Controller 클래스를 작성하려면 총 몇 개의 Controller 클래스가 필요할까요?
애플리케이션 구현에 있어서 정답이란 존재하지 않지만 더 나은 해결책을 찾기 위한 접근법이나 패턴은 존재합니다.
커피 주문 애플리케이션에 필요한 리소스
REST API 기반의 애플리케이션에서는 일반적으로 애플리케이션이 제공해야 될 기능을 리소스(Resource, 자원)로 분류합니다.
따라서 우리가 만들어야 할 커피 주문 애플리케이션에는 [그림 3-8]과 같은 리소스가 필요합니다.
[그림 3-8] 커피 주문 애플리케이션에 기본적으로 필요한 리소스(자원, Resource)
따라서 우리는 [그림 3-8]의 리소스에 해당하는 Controller 클래스를 작성하면 됩니다.
일반 고객과 주인의 기능을 따로 분리해야 되는 것 아니냐는 의문이 들 수도 있습니다.
만약 고객과 주인을 완전히 분리된 별개의 리소스로 정의한다면 Customer와 Host라는 별개의 리소스가 만들어질 수 있습니다.
하지만 이 경우에는 고객과 주인의 인증 프로세스가 복잡해질 가능성이 높다는 점 기억하길 바랍니다.
고객과 주인을 회원(Member)이라는 리소스에 포함을 시키고, 둘 사이의 ROLE을 통해 기능을 구분할 수 있습니다. 이 부분은 보안 기능을 적용할 때 다시 배우게 됩니다.
엔트리포인트(Entrypoint) 클래스 작성
Spring Boot 기반의 애플리케이션이 정상적으로 실행되기 위해서 가장 먼저 해야 될 일은 main() 메서드가 포함된 애플리케이션의 엔트리포인트(Entrypoint, 애플리케이션 시작점)를 작성하는 것입니다.
하지만 여러분들이 ‘Spring Initializr’를 통해 생성한 프로젝트에는 엔트리포인트 클래스가 이미 작성되어 있습니다.
package com.springboot;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication // (1)
public class Section3Week1Application {
public static void main(String[] args) {
// (2)
SpringApplication.run(Section3Week1Application.class, args);
}
}
[코드 3-4] 애플리케이션 엔트리 포인트 클래스(src/main/java/com/springboot/Week1Application.java)
✔️ 코드 설명
[코드 3-4]는 커피 주문 애플리케이션의 엔트리포인트 클래스입니다.
[코드 3-4]에서 우리가 알아야 할 사항은 아래와 같이 두 가지 입니다.
(1) @SpringBootApplication
@SpringBootApplication은 코드 상에서는 보이지 않지만 내부적으로 세 가지 일을 해줍니다.
- 자동 구성을 활성화합니다.
- 애플리케이션 패키지 내에서 @Component가 붙은 클래스를 검색한 후(scan), Spring Bean으로 등록하는 기능을 활성화합니다.
- @Configuration 이 붙은 클래스를 자동으로 찾아주고, 추가적으로 Spring Bean을 등록하는 기능을 활성화합니다.
(2) SpringApplication.run(Section3Week1Application.class, args);
Spring 애플리케이션을 부트스트랩하고, 실행하는 역할을 합니다.
부트스트랩(Bootstrap)이란?
애플리케이션이 실행되기 전에 여러 가지 설정 작업을 수행하여 실행 가능한 애플리케이션으로 만드는 단계를 의미합니다.
커피 주문 애플리케이션의 Controller 구조 작성
이제 커피 주문 애플리케이션에 기본적으로 필요한 세 개의 Controller 구조를 만들어 보도록 하겠습니다.
MemberController 구조 작성
MemberController 클래스는 ‘src/main/java/com/springboot/member’ 패키지를 생성해서 그 안에 작성합니다.
package com.springboot.member;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // (1)
@RequestMapping("/v1/members") // (2)
public class MemberController {
}
[코드 3-5] 회원 관리를 위한 MemberController 클래스(src/main/java/com/springboot/member/MemberController .java)
✔️ 코드 설명
MemberController의 구조를 작성하는 것은 놀랍도록 심플합니다. 코드 3-5에서 우리가 알아야 할 부분은 아래의 두 가지입니다.
(1) @RestController
- Spring MVC에서는 특정 클래스에 @RestController를 추가하면 해당 클래스가 REST API의 리소스(자원, Resource)를 처리하기 위한 API 엔드포인트로 동작함을 정의합니다.
- 또한 @RestController가 추가된 클래스는 애플리케이션 로딩 시, Spring Bean으로 등록해 줍니다.
(2) @RequestMapping
- @RequestMapping 은 클라이언트의 요청과 클라이언트 요청을 처리하는 핸들러 메서드(Handler Method)를 매핑해 주는 역할을 합니다.
@RequestMapping 은 코드 3-5와 같이 Controller 클래스 레벨에 추가하여 클래스 전체에 사용되는 공통 URL(Base URL) 설정을 합니다.
핸들러 메서드 레벨에 추가하는 @RequestMapping 애너테이션은 [핸들러 메서드(Handler Method)] 챕터에서 설명할 예정입니다.
CoffeeController 구조 작성
CoffeeController 클래스는 ‘src/main/java/com/springboot/coffee’ 패키지를 생성해서 그 안에 작성합니다.
package com.springboot.coffee;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/v1/coffees")
public class CoffeeController {
}
[코드 3-6] 커피 정보 관리를 위한 CoffeeController
✔️ 코드 설명
앞에서 설명한 내용과 중복되므로 별도 설명은 생략합니다.
OrderController 구조 작성
필수 학습 란에서 여러분들이 직접 작성해 봅니다.
이번 챕터에서는 Controller 클래스의 구조만 작성했기 때문에 아직은 미완성 Controller입니다.
다음 챕터에서 핸들러(Handler) 메서드를 채워 넣으면 실제 동작하는 Controller를 확인할 수 있으니 조금만 기다려주세요.
핵심 포인트
- Spring Boot 애플리케이션으로서 동작하기 위한 엔트리포인트에는 @SpringBootApplication을 추가한다.
- main() 메서드 내에서 SpringApplication.run()을 호출하면 Spring Boot 기반의 애플리케이션으로 동작한다.
- @RestController를 클래스에 추가함으로써 해당 클래스를 REST API의 리소스(자원, Resource)를 처리하기 위한 API 엔드포인트로 동작하게 해 준다.
- @RequestMapping을 Controller 클래스 레벨에 추가하여 클래스 전체에 사용되는 공통 URL(Base URL)을 설정할 수 있다.
심화 학습
- 아래 링크에서 REST API Resource 접근 URI 작성 규칙 등을 학습하세요.
- RESTful API의 URI 작성 규칙: 개념학습 / REST API
- 다음 예제 코드를 직접 따라서 작성해보세요.
- MemberController 클래스
- 작성 경로: ‘src/main/java/com/springboot/member’ 패키지 생성 후, 작성
- CoffeeController 클래스
- 작성 경로: ‘src/main/java/com/springboot/coffee’ 패키지 생성 후, 작성
- MemberController 클래스
- MemberController나 CoffeeController를 참고해서 커피 주문에 필요한 OrderController의 구조를 직접 작성해 보세요!
- 작성 경로: ‘src/main/java/com/codestates/order’ 패키지 생성 후 작성하세요.
- 아래 링크에서 @SpringBootApplication의 구체적인 기능을 학습해 보세요.
- @SpringBootApplication의 역할: https://itvillage.tistory.com/36
- 아래 링크에서 Spring 애플리케이션의 부트스트랩(Bootstrap) 과정을 학습해 보세요.
- Spring Boot 애플리케이션의 부트스트랩(Bootstrap) 과정: https://itvillage.tistory.com/37
심화 학습에 포함된 내용 중에 이해되지 않는 부분이 있더라도 좌절하지 마세요! 지금은 몰라도 되는 내용들일 수 있으니 부담 없이 읽어 주세요!
[기본] 핸들러 메서드(Handler Method)
이 전 챕터에서 여러분은 Controller의 구조를 작성했습니다.
그런데 Controller의 구조만 작성한 상태에서 MemberController에 요청을 전송하면 \[그림 3-9]와 같은 응답 메시지를 보게 됩니다.
[그림 3-9] Member Controlle에 POST 요청 전송 예
응답 메시지의 “status”는 404입니다.
즉, 요청 페이지 또는 요청 리소스를 찾을 수 없다는 의미입니다.
이처럼 에러 응답을 받는 이유는 MemberController에 클라이언트의 요청을 처리할 핸들러 메서드(Handler Method)가 아직 없기 때문입니다.
우리가 만드는 애플리케이션이 REST API 기반 애플리케이션이기 때문에 응답 메시지는 JSON 형식으로 클라이언트에게 전달됩니다.
[그림 3-9]는 클라이언트 요청을 위해 ‘Postman’이라는 툴을 사용한 모습입니다. Postman의 사용법은 아래 [심화 학습]을 참고하세요.
지금부터 우리가 작성한 Controller 클래스에 클라이언트의 요청을 처리할 핸들러 메서드를 추가해 봅시다.
핸들러 메서드(Handler Method) 적용
핸들러 메서드를 작성하기 전에 먼저 각 핸들러 메서드에서 필요한 기능별 정보들을 정의합니다. 예를 들면 회원 정보, 커피 정보, 주문 정보 등입니다.
각각의 핸들러 항목에서 확인하세요.
MemberController의 핸들러 메서드(Handler Method) 작성
✔️ 요청에 필요한 회원(Member) 정보
- 회원 이메일 주소: email
- 회원 이름: name
- 회원 전화번호: phoneNumber
회원 정보인 email, name, phoneNumber 값은 클라이언트의 요청 또는 응답에 필요한 정보입니다.
코드 실행 시, 이 회원 정보를 사용하는 방법을 살펴보겠습니다.
이제 MemberController의 핸들러 메서드를 작성해 봅시다.
package com.springboot.member;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "/v1/members", produces = {MediaType.APPLICATION_JSON_VALUE})
public class MemberController {
@PostMapping
public String postMember(@RequestParam("email") String email,
@RequestParam("name") String name,
@RequestParam("phone") String phone) {
System.out.println("# email: " + email);
System.out.println("# name: " + name);
System.out.println("# phone: " + phone);
String response =
"{\\"" +
"email\\":\\"" + email + "\\"," +
"\\"name\\":\\"" + name + "\\"," +
"\\"phone\\":\\"" + phone +
"\\"}";
return response;
}
@GetMapping("/{member-id}")
public String getMember(@PathVariable("member-id")long memberId) {
System.out.println("# memberId: " + memberId);
// not implementation
return null;
}
@GetMapping
public String getMembers() {
System.out.println("# get Members");
// not implementation
return null;
}
}
[코드 3-7] 핸들러 메서드가 추가된 MemberController
✔️ 코드 설명
- MemberController에 추가된 코드 설명
- 클래스 레벨의 @RequestMapping에 추가된 항목
- produces
- 클래스 레벨의 @RequestMapping에 추가된 항목
- postMember() 메서드 설명
[그림 3-10] Postman에서 postMember() 핸들러 메서드의 매핑 URI로 요청 전송
postMember() 메서드는 회원 정보를 등록해 주는 핸들러 메서드입니다.
- @PostMapping
- @PostMapping 은 클라이언트의 요청 데이터(request body)를 서버에 생성할 때 사용하는 애너테이션이며, [그림 3-10]과 같이 클라이언트 쪽에서 요청 전송 시, HTTP Method 타입을 동일하게 맞춰주어야 합니다.
- @RequestParam
@RequestParam은 핸들러 메서드의 파라미터 종류 중 하나입니다.
주로 클라이언트 쪽에서 전송하는 요청 데이터를 쿼리 파라미터(Query Parmeter 또는 Query String), 폼 데이터(form-data), x-www-form-urlencoded 형식으로 전송하면 이를 서버 쪽에서 전달받을 때 사용하는 애너테이션입니다.
쿼리 파라미터(Query Parameter 또는 QueryString)는 요청 URL에서 ‘?’를 기준으로 붙는 key/value 쌍의 데이터를 말합니다. ex) http://localhost:8080/coffees/1?page=1&size=10
- HTTP POST Method의 요청 데이터의 형식은 아래 [심화 학습]을 참고하세요.
- HTTP Method에 대해서는 아래 [심화 학습]을 참고하세요.
- 리턴 값
postMember() 핸들러 메서드의 리턴 타입은 현재는 String입니다.
단, 클라이언트 쪽에서 JSON 형식의 데이터를 전송받아야 하기 때문에 응답 문자열을 JSON 형식에 맞게 작성해 주었습니다.
일반적으로 POST Method를 처리하는 핸들러 메서드는 데이터를 생성한 후에 클라이언트 쪽에 생성한 데이터를 리턴해주는 것이 관례입니다.
그런데 개발자가 일일이 직접 JSON 형식에 맞추어 문자열을 작성하는 일은 번거롭고, 오타로 인해 에러가 발생할 가능성이 높습니다. 이러한 문제점은 이어지는 챕터에서 개선이 될 예정입니다.
✔️ postMember() 요청/응답
[그림 3-11] postMember() 핸들러 메서드의 매핑 URI로 요청 전송 및 응답 수신
그림 [3-11]은 Postman으로 postMember() 핸들러 메서드 매핑 URI로의 요청/응답 모습입니다.
(1) HTTP POST Method와 요청 URI를 입력합니다.
(2) [Body] 탭에서 ‘x-www-form-urlencoded’ 형식의 요청 데이터를 KEY/VALUE 형태로 입력합니다.
(3) JSON 형식의 응답 데이터를 전달받은 모습입니다. 빨간색 박스 우측 상단을 보면 ‘200’, ‘OK’라는 값을 통해서 클라이언트가 정상적으로 응답을 전달받았음을 알 수 있습니다.
- getMember() 메서드 설명
getMember() 메서드는 특정 회원의 정보를 클라이언트 쪽에 제공하는 핸들러 메서드입니다.
- @GetMapping
@GetMapping은 클라이언트가 서버에 리소스를 조회할 때 사용하는 애너테이션입니다.
@GetMapping 애너테이션의 괄호 안에는 몇 가지 애트리뷰트(Attribute)를 사용할 수 있지만 여기서는 전체 HTTP URI의 일부를 지정했습니다.
클라이언트 쪽에서 getMember() 핸들러 메서드에 요청을 보낼 경우, 최종 URI는 형태는 아래와 같습니다. ”/v1/members/{member-id}”
즉, 이 URI는 클래스 레벨의 @RequestMapping에 설정된 URI와 @GetMapping에 설정한 URI가 합쳐진 형태입니다.
{member-id}는 회원 식별자를 의미하며 클라이언트가 요청을 보낼 때 URI로 어떤 값을 지정하느냐에 따라서 동적으로 바뀌는 값입니다.
- @PathVariable
@PathVariable 역시 핸들러 메서드의 파라미터 종류 중 하나입니다.
@PathVariable의 괄호 안에 입력한 문자열 값은 @GetMapping("/{member-id}")처럼 중괄호({ }) 안의 문자열과 동일해야 합니다. 여기서는 두 문자열 모두 “member-id” 로 동일하게 지정해 주었습니다. 만약 두 문자열이 다르다면 MissingPathVariableException이 발생합니다.
✔️ getMember() 요청
[그림 3-12] Postman에서 getMember() 핸들러 메서드의 매핑 URI로 요청 전송
[그림 3-12]는 Postman으로 getMember() 핸들러 메서드의 매핑 URI로 요청을 전송하는 예입니다. 우리가 아직 getMember() 메서드에 구체적인 로직을 작성하지 않았기 때문에 클라이언트 쪽에서 전달받는 응답 데이터는 없습니다.
- getMembers() 메서드 설명
getMembers() 메서드는 회원 목록을 클라이언트에게 제공하는 핸들러 메서드입니다.
- @GetMapping 에는 별도의 URI를 지정해주지 않았기 때문에 클래스 레벨의 URI(“/v1/members”)에 매핑됩니다.
✔️ getMembers() 요청
[그림 3-13] Postman에서 getMembers() 핸들러 메서드 매핑 URI로 요청 전송
[그림 3-13]은 Postman으로 getMembers() 핸들러 메서드의 매핑 URI로 요청을 전송하는 예입니다. getMember()와 마찬가지로 아직 전달받는 별도의 응답 데이터는 없습니다.
CoffeeController의 핸들러 메서드(Handler Method) 작성
심화 학습란에서 여러분들이 직접 작성해 봅니다.
OrderController의 핸들러 메서드(Handler Method) 작성
✔️ 요청에 필요한 주문(Order) 정보
- 회원 식별자: memberId
- 커피 식별자: coffeeId
OrderController의 핸들러 메서드 코드는 아래의 [코드 3-8]과 같습니다.
package com.springboot.order;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "/v1/orders", produces = MediaType.APPLICATION_JSON_VALUE)
public class OrderController {
@PostMapping
public String postOrder(@RequestParam("memberId") long memberId,
@RequestParam("coffeeId") long coffeeId) {
System.out.println("# memberId: " + memberId);
System.out.println("# coffeeId: " + coffeeId);
String response =
"{\\"" +
"memberId\\":\\""+memberId+"\\"," +
"\\"coffeeId\\":\\""+coffeeId+"\\"" +
"}";
return response;
}
@GetMapping("/{order-id}")
public String getOrder(@PathVariable("order-id") long orderId) {
System.out.println("# orderId: " + orderId);
// not implementation
return null;
}
@GetMapping
public String getOrders() {
System.out.println("# get Orders");
// not implementation
return null;
}
}
✔️ 코드 설명
- OrderController에 추가된 코드 설명
MemberController에서 설명한 내용과 차이점이 없으므로 생략하겠습니다.
- postOrder() 메서드 설명
postOrder() 메서드는 회원 고객이 주문한 커피 주문 정보를 등록해 주는 핸들러 메서드입니다.
고객이 주문한 커피에 필요한 주문 정보는 어떤 고객이 어떤 커피를 주문했느냐 하는 것입니다.
‘어떤 고객’에 해당하는 정보가 회원 식별자(memberId)이고, ‘어떤 커피’에 해당하는 정보가 바로 커피 식별자(coffeeId)입니다.
그 외에는 MemberController postMember()에서 모두 설명했으므로 나머지 설명은 생략하도록 하겠습니다.
✅ 참고 현재의 postOrder() 메서드는 API 계층에 대한 여러분의 이해를 돕기 위해 회원 한 명이 한 잔의 커피만 주문한다는 가정 하에 코드를 작성하고 있습니다.
이 부분은 데이터 액세스 계층을 학습하면서 현실 상황에 맞도록 수정해 나갈 예정이니 참고하기 바랍니다.
식별자(Identifier)란?
여러분들이 데이터베이스 학습 시간에 배웠던 기본키(Primary key)는 대표적인 식별자 중 하나입니다. 식별자란 어떤 데이터를 식별할 수 있는 고유값을 의미합니다.
여러분들이 현재 API 계층 영역만 학습하고 있기 때문에 당장에 데이터베이스를 사용할 일은 없습니다.
다만, API 계층에서 사용하는 memberId, coffeeId 등은 데이터 액세스 계층을 학습하고 난 뒤에 데이터베이스의 테이블에 저장되는 로우(row)의 식별자인 기본키(Primary key)가 된다고 생각하면 되겠습니다.
- getOrder() 메서드 설명
getOrder() 메서드는 특정 주문 정보를 클라이언트 쪽에 제공하는 핸들러 메서드입니다.
MemberController의 getMember()에서 모두 설명했으므로 나머지 설명은 생략합니다.
- getOrders() 메서드 설명
getOrders() 메서드는 주문 목록을 클라이언트에게 제공하는 핸들러 메서드입니다.
MemberController의 getOrders()와 기능상의 설명은 동일하므로 생략합니다.
현재 코드에서 더 개선할 수 있는 부분
여러분들이 보기에는 어떨지 모르지만 현재까지 작성한 코드는 잘 동작하지만 여전히 개선이 필요한 곳이 몇 군데 있습니다. 어떤 부분의 코드를 더 개선할 수 있는지 간단히 살펴보겠습니다.
- 개발자가 수작업으로 JSON 문자열을 만들어 주는 부분
가장 개선이 시급한 부분은 바로 JSON 문자열을 개발자가 직접 수작업으로 작성하고 있는 것입니다.
- @RequestParam 애너테이션을 사용한 요청 파라미터 수신
만약 클라이언트 쪽에서 전달받아야 되는 요청 파라미터들이 다섯 개라면 여러분은 핸들러 메서드의 파라미터로 총 다섯 개의 @RequestParameter를 사용해서 파라미터로 입력해야 됩니다.
위 문제들은 챕터를 진행하면서 점차적으로 개선이 되니, 지금은 ‘이런 부분에서 개선이 필요하구나’ 정도만 알고 넘어가길 바랍니다.
핵심 포인트
- 클라이언트의 요청을 전달받아서 처리하기 위해서는 요청 핸들러 메서드(Request Handler Method)가 필요하다.
- Spring MVC에서는 HTTP Method 유형과 매치되는 @GetMapping, @PostMapping 등의 애너테이션을 지원한다.
- @PathVariable 애너테이션을 사용하면 클라이언트 요청 URI에 패턴 형식으로 지정된 변수의 값을 파라미터로 전달받을 수 있다.
- @RequestParam 애너테이션을 사용하면 쿼리 파라미터(Query Parmeter 또는 Query string), 폼 데이터(form-data), x-www-form-urlencoded 형식의 데이터를 파라미터로 전달받을 수 있다.
- @GetMapping, @PostMapping 등에서 URI를 생략하면 클래스 레벨의 URI 경로만으로 요청 URI를 구성한다.
심화 학습
- MemberController를 참고해서 CoffeeController의 핸들러 메서드를 직접 작성해 보세요!
- 커피명(영문): engName
- 커피명(한글): korName
- 가격: price
- postCoffee()
- 커피 정보를 등록하는 핸들러 메서드
- getCoffee()
- 특정 커피 정보를 제공하는 핸들러 메서드
- getCoffees()
- 커피 목록을 제공하는 핸들러 메서드
- ✔️ 요청에 필요한 커피(Coffee) 정보
- Postman을 사용해서 작성한 CoffeeController의 각 핸들러 메서드에 요청을 전송해 보세요.
- Postman의 기본 사용법은 아래 링크에서 학습하세요.
- Postman 기본 사용법: https://itvillage.tistory.com/38
- Postman의 기본 사용법은 아래 링크에서 학습하세요.
- 아래 링크에서 @RequestMapping에 대한 추가 내용을 학습하세요.
- @RequestMapping 추가 내용: https://itvillage.tistory.com/40
- 아래 링크에서 Controller의 핸들러 메서드 파라미터에 대한 추가 내용을 학습하세요.
- Controller의 핸들러 메서드 파라미터 추가 내용: https://itvillage.tistory.com/41
- 아래 링크에서 HTTP 미디어 타입(Media Type)에 대해서 학습하세요.
- 미디어 타입(Media Type)이란? : https://ko.wikipedia.org/wiki/미디어_타입
- 미디어 타입(Media Type), MIME Type, Content Type
- 아래 링크에서 HTTP Method에 대해서 학습하세요.
- 아래 링크에서 HTTP POST Method의 요청 데이터 형식에 대해서 학습하세요.
- HTTP POST Method의 요청 데이터 형식 관련 내용: https://developer.mozilla.org/ko/docs/Web/HTTP/Methods/POST
- 아래 링크에서 HTTP 응답 상태(Response Status)에 대해서 학습하세요.
- HTTP 응답 상태(Response Status): https://developer.mozilla.org/ko/docs/Web/HTTP/Status
[기본] 응답 데이터에 ResponseEntity 적용
레거시 코드 리뷰
지난 시간에 여러분들은 각각의 Controller 클래스에서 클라이언트의 요청을 처리해 주는 핸들러 메서드(Handler Method)를 추가해 보면서 핸들러 메서드의 기본 구현 방법을 학습했습니다.
그런데 현재까지 작성한 핸들러 메서드는 기본적으로 잘 동작하지만 개선이 필요한 부분이 있다고 지난 챕터에서 언급했습니다.
지난 시간까지 작성한 MemberController 클래스의 코드를 리뷰해 보면서 이번 시간에 어떤 부분을 개선할 수 있을지 알아보도록 하겠습니다.
package com.springboot.member;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "/v1/members", produces = {MediaType.APPLICATION_JSON_VALUE})
public class MemberController {
@PostMapping
public String postMember(@RequestParam("email") String email,
@RequestParam("name") String name,
@RequestParam("phone") String phone) {
System.out.println("# email: " + email);
System.out.println("# name: " + name);
System.out.println("# phone: " + phone);
// 코드 개선이 필요한 부분
String response =
"{\\\\"" +
"email\\\\":\\\\""+email+"\\\\"," +
"\\\\"name\\\\":\\\\""+name+"\\\\",\\\\"" +
"phone\\\\":\\\\"" + phone+
"\\\\"}";
return response;
}
@GetMapping("/{member-id}")
public String getMember(@PathVariable("member-id") long memberId) {
System.out.println("# memberId: " + memberId);
// not implementation
return null;
}
@GetMapping
public String getMembers() {
System.out.println("# get Members");
// not implementation
return null;
}
}
[코드 3-9]는 지난 시간에 작성해 본 MemberController입니다.
지난 시간에 언급했다시피 [코드 3-9]에서 가장 개선이 필요한 곳은 바로 postMember() 핸들러 메서드에서 응답으로 전송하는 JSON 형식의 문자열을 개발자가 직접 수작업으로 작성하고 있는 부분입니다.
여러분들이 개발자로서 항상 습관화하면 좋은 부분 중에 하나는 바로 여러분이 작성한 코드 중에서 여러분들이 매번 수작업으로 작성해야 되는 부분이 있는지를 확인하고, 있다면 어떻게 개선할지를 고민하고 수정해 보려는 노력을 하는 것입니다.
그럼 이제 JSON 형식의 응답 문자열을 수작업으로 작성하는 부분을 개선해 봅시다.
레거시 코드의 개선
우선 개선된 코드를 보면서 앞에서 언급한 문제점이 어떻게 개선되었는지 보겠습니다.
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/v1/members") // (1) produces 설정 제거됨
public class MemberController {
@PostMapping
public ResponseEntity postMember(@RequestParam("email") String email,
@RequestParam("name") String name,
@RequestParam("phone") String phone) {
// (2) JSON 문자열 수작업을 Map 객체로 대체
Map<String, String> map = new HashMap<>();
map.put("email", email);
map.put("name", name);
map.put("phone", phone);
// (3) 리턴 값을 ResponseEntity 객체로 변경
return new ResponseEntity<>(map, HttpStatus.CREATED);
}
@GetMapping("/{member-id}")
public ResponseEntity getMember(@PathVariable("member-id") long memberId) {
System.out.println("# memberId: " + memberId);
// not implementation
// (4) 리턴 값을 ResponseEntity 객체로 변경
return new ResponseEntity<>(HttpStatus.OK);
}
@GetMapping
public ResponseEntity getMembers() {
System.out.println("# get Members");
// not implementation
// (5) 리턴 값을 ResponseEntity 객체로 변경
return new ResponseEntity<>(HttpStatus.OK);
}
}
코드 3-10은 JSON 형식의 문자열을 직접 수동으로 작성해야 하는 문제점을 개선한 코드입니다. 레거시 코드(코드 3-9)와 비교했을 때 어떤 점이 달라졌는지 볼까요?
(1) 클래스 레벨의 @RequestMapping의 ‘produces’ 애트리뷰트가 사라졌습니다. 왜 사라졌는지는 (2)에서 설명하겠습니다.
(2) JSON 문자열을 개발자가 직접 수작업으로 작성하던 부분이 Map 객체로 대체되었습니다.
이를 통해 클래스 레벨의 @RequestMapping의 ‘produces’ 애트리뷰트를 생략할 수 있게 되었습니다.
Map 객체를 리턴하게 되면 내부적으로 ‘이 데이터는 JSON 형식의 응답 데이터로 변환해야 되는구나’라고 이해하고 JSON 형식으로 자동 변환해 주기 때문입니다.
사실 ‘처음부터 레거시 코드에서 Map 객체를 사용했으면 되는 거 아냐?’라고 생각하시는 분이 있었으면 좋겠네요. ^^
맞습니다. 그랬다면 아마도 JSON 문자열을 수작업으로 작성하는 일은 없었을 겁니다.
그런데 왜 처음부터 Map 객체를 사용하지 않았을까요?
그 이유는 여러분들이 좋지 않은 개발 방식을 직접 눈으로 확인하고 좋은 코드로 개선하는 과정을 거친다면 해당 기술을 더 잘 이해하게 되고, 더 좋은 개발자가 되기 위한 밑거름이 된다고 생각하기 때문입니다.
그러니 이렇게 코드를 개선해 나가는 과정을 함께 즐기면서 조금씩 성장하길 바랍니다.
(3) 리턴 값으로 JSON 문자열을 리턴하던 부분이 ResponseEntity 객체를 리턴하는 것으로 바뀌었습니다.
코드를 보면, new ResponseEntity<>(map, HttpStatus.CREATED);처럼 ResponseEntity 객체를 생성하면서 생성자 파라미터로 응답 데이터(map)와 HTTP 응답 상태를 함께 전달하고 있습니다.
리턴 값으로 단순히 Map 객체를 리턴해도 클라이언트 쪽에서는 정상적으로 JSON 형식의 응답 데이터를 받을 수 있습니다.
그런데 ResponseEntity 객체로 응답 데이터를 래핑함으로써 조금 더 세련된 방식으로 응답 데이터를 생성할 수 있습니다.
이처럼 HTTP 응답 상태를 명시적으로 함께 전달하면 클라이언트의 요청을 서버가 어떻게 처리했는지를 쉽게 알 수 있습니다.
클라이언트 쪽에서는 이 HTTP 응답 상태를 기반으로 정상적으로 다음 처리를 할지 에러 처리를 할지 결정하면 되는 것입니다.
HttpStatus.CREATED는 클라이언트의 POST 요청을 처리해서 요청 데이터(리소스)가 정상적으로 생성되었음을 의미하는 HTTP 응답 상태입니다.
기타 다른 HTTP 응답 상태에 대해서는 아래 링크를 참고하세요. HTTP 응답 상태 종류 확인하기: https://developer.mozilla.org/ko/docs/Web/HTTP/Status
(4), (5) getMember(), getMembers() 핸들러 메서드 역시 ResponseEntity 객체를 리턴하는 걸로 수정하였으며, HttpStatus.OK 응답 상태를 전달하도록 수정했습니다.
그럼 이제 개선된 코드의 동작을 확인하기 위해 애플리케이션을 실행하고, Postman으로 postMember() 핸들러 메서드에 요청을 보내보도록 하겠습니다.
[그림 3-14] ResponseEntity를 적용한 postMember()에 대한 응답 예
[그림 3-14]는 ResponseEntity를 적용한 postMember() 핸들러 메서드에게 요청을 전송한 응답 결과입니다.
결과를 보면 정상적으로 JSON 응답 데이터를 전달받은 것을 확인할 수 있습니다. ResponseEntity를 적용하지 않았을 때의 응답 결과와 달라진 점은 응답 상태가 ‘200 OK’에서 ‘201 Created’로 바뀌었다는 것입니다.
물론 ResponseEntity에 HttpStatus.OK를 지정할 수 도 있지만 HttpStatus.CREATED를 지정함으로써 서버에서의 처리 결과를 조금 더 명확하게 알려주고 있습니다.
핵심 포인트
- 핸들러 메서드의 리턴 값으로 Map 객체를 리턴하면 Spring MVC 내부적으로 JSON 형식의 데이터를 생성해 준다. 즉, 클래스 레벨의 @RequestMapping에 ‘produces’ 애트리뷰트를 지정할 필요가 없다.
- ResponseEntity 클래스로 응답 데이터를 래핑함으로써 조금 더 세련된 방식으로 응답 데이터를 리턴할 수 있다.
- POST Method 형식의 클라이언트 요청에 대한 응답 상태는 HttpStatus.OK보다는 HttpStatus.CREATED가 조금 더 자연스럽다.
심화 학습
- 개선된 MemberController를 참고해서 CoffeeController와 OrderController 핸들러 메서드의 응답 데이터를 ResponseEntity로 변경해 보세요.
- 팁
- Map<String, String>의 경우 key와 value가 모두 String 이어야 됩니다. key가 String이고, 다른 타입의 데이터를 map에 추가하기 위해서는 value 타입을 Object로 지정해야 합니다.
- 팁
- 아래 링크에서 ResponseEntity에 대해 조금 더 알아보세요.
- ResponseEntity 더 알아보기: https://itvillage.tistory.com/44
[심화] HTTP 헤더(Header)
HTTP 헤더(Header)란?
HTTP 헤더(Header)는 무엇일까요?
HTTP 헤더는 여러분들이 \[네트워크 기초] 유닛에서 학습한 것처럼 HTTP 메시지(Messages)의 구성 요소 중 하나로써 클라이언트의 요청이나 서버의 응답에 포함되어 부가적인 정보를 HTTP 메시지에 포함할 수 있도록 해줍니다.
HTTP 헤더에 대한 더 구체적인 내용은 아래 \[심화 학습]을 참고하세요.
HTTP 헤더(Header)의 사용 목적
그렇다면 우리는 HTTP 헤더에 포함되어 있는 부가적인 정보를 어디에 사용할 수 있을까요?
클라이언트와 서버 관점에서의 대표적인 HTTP 헤더 예시
클라이언트와 서버의 관점에서 내부적으로 가장 많이 사용되는 헤더 정보로 ‘Content-Type’이 있습니다.
‘Content-Type’ 헤더 정보는 클라이언트와 서버가 주고받는 HTTP 메시지 바디(body, 본문)의 데이터 형식이 무엇인지를 알려주는 역할을 합니다.
클라이언트와 서버는 이 ‘Content-Type’이 명시된 데이터 형식에 맞는 데이터들을 주고받는 것입니다.
우리가 구현하는 샘플 애플리케이션의 ‘Content-Type’은 ‘application/json’입니다.
개발자들이 직접 실무에서 사용하는 대표적인 HTTP 헤더 예시
클라이언트와 서버 관점에서의 HTTP 헤더들은 개발자가 건드릴 일은 사실 많지 않습니다.
하지만 개발자가 직접 코드 레벨에서 HTTP 헤더를 컨트롤해야 될 경우가 있는데 대표적인 예를 두 가지만 들어보겠습니다.
- Authorization
‘Authorization’ 헤더 정보는 클라이언트가 적절한 자격 증명을 가지고 있는지를 확인하기 위한 정보입니다.
일반적으로 REST API 기반 애플리케이션의 경우 클라이언트와 서버 간의 로그인(사용자 ID/비밀번호) 인증에 통과한 클라이언트들은 ‘Authorization’ 헤더 정보를 기준으로 인증에 통과한 클라이언트가 맞는지 확인하는 절차를 거칩니다.
인증(Authenticatioin)과 인가(Authorization)에 대해서는 이후 학습에서 학습할 예정이니, 어렵게 생각하지 말고 가볍게 넘어가면 되겠습니다.
- User-Agent
실무에서 애플리케이션을 구현하다 보면 여러 가지 유형의 클라이언트가 하나의 서버 애플리케이션에 요청을 전송하는 경우가 굉장히 많습니다.
어떤 사용자는 데스크톱 또는 노트북 컴퓨터에 있는 웹 브라우저를 사용해서 서버에 요청을 보내고, 또 어떤 사용자는 스마트 폰이나 태블릿 등 모바일에서 요청을 보내는데 이 경우, 데스크 탑에서 들어오는 요청과 모바일에서 들어오는 요청을 구분해서 응답 데이터를 다르게 보내줘야 되는 경우가 있을 수 있습니다.
예를 들면, 모바일 화면과 데스크톱 또는 노트북의 화면 크기의 차이가 많이 나기 때문에 더 큰 화면에서 더 많은 정보를 보여주기 위해 각각 데이터의 종류와 크기가 다를 수 있습니다.
이 경우, ‘User-Agent’ 정보를 이용해서 모바일 에이전트에서 들어오는 요청인지 모바일 이외에 다른 에이전트에서 들어오는 요청인지를 구분해서 처리할 수 있습니다.
HTTP Request 헤더(Header) 정보 얻기
여러분들이 HTTP 헤더를 자주 사용할 일은 많지 않지만 Spring MVC에서 헤더 정보를 이용하는 기본적인 방법은 알고 있어야 합니다.
Spring MVC는 아래와 같이 HTTP 헤더 정보를 읽어오는 몇 가지 방법을 제공하고 있습니다.
- @RequestHeader로 개별 헤더 정보 받기
import com.springboot.week1.api.mvc_examples.json.Coffee;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(path = "/v1/coffees")
public class CoffeeController {
@PostMapping
public ResponseEntity postCoffee(@RequestHeader("user-agent") String userAgent,//(1)
@RequestParam("korName") String korName,
@RequestParam("engName") String engName,
@RequestParam("price") int price) {
System.out.println("user-agent: " + userAgent);
return new ResponseEntity<>(new Coffee(korName, engName, price),
HttpStatus.CREATED);
}
}
[코드 3-11]은 @RequestHeader를 사용해서 특정 헤더 정보만 읽는 예제입니다. @RequestParam과 사용법이 거의 동일하기 때문에 사용하는데 어려움은 없을 거라 생각합니다.
- @RequestHeader로 전체 헤더 정보 받기
import com.codestates.section3.week1.api.mvc_examples.common.Member;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping(path = "/v1/members")
public class MemberController {
@PostMapping
public ResponseEntity postMember(@RequestHeader Map<String, String> headers,//(1)
@RequestParam("email") String email,
@RequestParam("name") String name,
@RequestParam("phone") String phone) {
for (Map.Entry<String, String> entry : headers.entrySet()) {
System.out.println("key: " + entry.getKey() +
", value: " + entry.getValue());
}
return new ResponseEntity<>(new Member(email, name, phone),
HttpStatus.CREATED);
}
}
[코드 3-12]에서는 (1)과 같이 @RequestHeader를 사용해서 Request의 모든 헤더 정보를 Map으로 전달받는 예제입니다.
실무에서 모든 헤더 정보를 사용할 일은 거의 없겠지만 필요한 헤더 정보가 3-4개 이상이 된다면 코드 3-11과 같이 개별 헤더 정보를 읽기 위해서 @RequestHeader를 일일이 추가해야 해서 번거롭고, 코드 가독성도 떨어집니다.
이 경우, [코드 3-12]와 같이 헤더 정보를 Map으로 한 번에 받아서 원하는 헤더 정보를 얻을 수 있습니다.
postMember() 요청 결과
key: user-agent, value: PostmanRuntime/7.29.0
key: accept, value: */*
key: cache-control, value: no-cache
key: postman-token, value: 6082ccc2-3195-4726-84ed-6a2009cbae95
key: host, value: localhost:8080
key: accept-encoding, value: gzip, deflate, br
key: connection, value: keep-alive
key: content-type, value: application/x-www-form-urlencoded
key: content-length, value: 54
- HttpServletRequest 객체로 헤더 정보 얻기
@RestController
@RequestMapping(path = "/v1/orders")
public class OrderController {
@PostMapping
public ResponseEntity postOrder(HttpServletRequest httpServletRequest,//(1)
@RequestParam("memberId") long memberId,
@RequestParam("coffeeId") long coffeeId) {
System.out.println("user-agent: " + httpServletRequest.getHeader("user-agent"));
return new ResponseEntity<>(new Order(memberId, coffeeId),
HttpStatus.CREATED);
}
}
코드 3-13에서는 HttpServletRequest 객체를 통해서 Request 헤더 정보를 얻을 수 있습니다.
HttpServletRequest 객체를 이용하면 Request 헤더 정보에 다양한 방법으로 접근이 가능합니다.
그런데 HttpServletRequest는 다양한 API를 지원하지만 여러분들이 단순히 특정 헤더 정보에 접근하고자 한다면 HttpServletRequest 대신에 앞에서 설명한 @RequestHeader를 이용하는 편이 낫다는 사실을 기억하세요.
HttpServletRequest API에 대해서 더 알아보고 싶다면 아래 [심화 학습]을 확인하세요.
- HttpEntity 객체로 헤더 정보 얻기
Spring MVC에서는 HttpEntity 객체를 통해서도 헤더 정보를 읽을 수 있습니다. HttpEntity는 Request 헤더와 바디 정보를 래핑하고 있으며, 조금 더 쉽게 헤더와 바디에 접근할 수 있는 다양한 API를 지원합니다.
@RestController
@RequestMapping(path = "/v1/coffees")
public class CoffeeController{
@PostMapping
public ResponseEntity postCoffee(@RequestHeader("user-agent") String userAgent,//(1)
@RequestParam("korName") String korName,
@RequestParam("engName") String engName,
@RequestParam("price") int price) {
System.out.println("user-agent: " + userAgent);
return new ResponseEntity<>(new Coffee(korName, engName, price),
HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity getCoffees(HttpEntity httpEntity) {
for(Map.Entry<String, List<String>> entry : httpEntity.getHeaders().entrySet()){
System.out.println("key: " + entry.getKey()
+ ", " + "value: " + entry.getValue());
}
System.out.println("host: " + httpEntity.getHeaders().getHost());
return null;
}
}
코드 3-14는 HttpEntity 객체를 통해서 Request 헤더 정보를 읽어오는 예제입니다.
HttpServletRequest 객체를 사용할 때와 마찬가지로 Entry를 통해서 각각의 헤더 정보에 접근할 수 있는데, 특이한 것은 자주 사용될 만한 헤더 정보들을 getXXXX()로 얻을 수 있습니다.
코드 3-14에서는 getHost() 메서드를 통해서 host 정보를 확인하고 있습니다.
getXXXX() 메서드는 자주 사용되는 헤더 정보만 얻어올 수 있으므로 getXXXX() 메서드로 원하는 헤더 정보를 읽어올 수 없다면 get() 메서드를 사용해서 get(”host”)와 같이 해당 헤더 정보를 얻을 수 있습니다.
출력 결과
key: user-agent, value: [PostmanRuntime/7.29.0]
key: accept, value: [*/*]
key: cache-control, value: [no-cache]
key: postman-token, value: [368ad61b-b196-4f75-9222-b9a5af750414]
key: host, value: [localhost:8080]
key: accept-encoding, value: [gzip, deflate, br]
key: connection, value: [keep-alive]
host: localhost:8080
HTTP Response 헤더(Header) 정보 추가
앞에서 Request HTTP 헤더 정보를 읽어오는 다양한 방법을 살펴보았는데, 이번에는 클라이언트에게 전달하는 Response에 헤더 정보를 추가하는 방법을 알아보도록 하겠습니다.
- ResponseEntity와 HttpHeaders를 이용해 헤더 정보 추가하기
Spring MVC에서는 ResponseEntity와 HttpHeaders를 이용해서 손쉽게 헤더 정보를 추가할 수 있습니다.
@RestController
@RequestMapping(path = "/v1/members")
public class MemberController{
@PostMapping
public ResponseEntity postMember(@RequestParam("email") String email,
@RequestParam("name") String name,
@RequestParam("phone") String phone) {
// (1) 위치 정보를 헤더에 추가
HttpHeaders headers = new HttpHeaders();
headers.set("Client-Geo-Location", "Korea,Seoul");
return new ResponseEntity<>(new Member(email, name, phone), headers,
HttpStatus.CREATED);
}
}
코드 3-15에서는 ResponseEntity와 HttpHeaders를 이용해서 위치 정보를 커스텀 헤더로 추가하고 있습니다.
여러분들이 잘 알고 있는 Map이나 Set 같은 Java의 컬렉션(Collections) API처럼 HttpHeaders의 set() 메서드를 이용해서 헤더 정보를 추가하는 것 역시 어렵지 않을 거라 생각합니다.
커스텀 헤더(Custom Header) 사용 HTTP Request에 기본적으로 포함되어 있는 헤더 정보는 개발자가 컨트롤해야 될 경우가 생각보다 많지 않습니다. 그런데 코드 3-15처럼 커스텀 헤더를 종종 추가해서 부가적인 정보를 전달하는 경우가 있습니다.
커스텀 헤더를 활용하는 사례는 아래 [심화 학습]을 확인하세요.
커스텀 헤더 네이밍(Naming) 2012년 이 전에는 커스텀 헤더에 ‘X-’라는 Prefix를 추가하는 것이 관례였으나, 이 관례는 문제점이 발생할 가능성이 높아서 더 이상 사용하지 않습니다(Deprecated).
다만, 커스텀 헤더의 이름을 지을 때, 이 헤더를 사용하는 측에서 헤더의 목적을 쉽게 이용할 수 있도록 대시(-)를 기준으로 의미가 명확한 용어를 사용하기 바랍니다.
대시(-)를 기준으로 각 단어의 첫 글자를 대문자로 작성하는 것이 관례이지만 Spring에서 Request 헤더 정보를 확인할 때, 대/소문자를 구분하지는 않습니다.
실행 결과
[그림 3-15] 추가된 커스텀 헤더를 Postman으로 확인한 결과 예시
클라이언트 API 툴인 Postman으로 코드 3-15의 postMember() 핸들러 메서드에 요청을 보낼 경우, [그림 3-15]와 같이 [Headers] 탭에서 추가된 커스텀 헤더를 확인할 수 있습니다.
- HttpServletResponse 객체로 헤더 정보 추가하기
Spring MVC에서는 HttpServletResponse 클래스를 통해서 헤더 정보를 추가할 수 있습니다.
@RestController
@RequestMapping(path = "/v1/members")
public class MemberController{
@GetMapping
public ResponseEntity getMembers(HttpServletResponse response) {
response.addHeader("Client-Geo-Location", "Korea,Seoul");
return null;
}
}
코드 3-16에서는 HttpServletResponse를 이용해서 위치 정보를 커스텀 헤더로 추가하고 있습니다.
HttpServletResponse의 addHeader() 메서드 역시 HttpHeaders의 set() 메서드와 메서드 이름만 다를 뿐 헤더 정보를 추가하는 방법은 같습니다.
한 가지 차이점은 HttpHeaders 객체는 ResponseEntity에 포함을 시키는 처리가 필요하지만 HttpServletResponse 객체는 헤더 정보만 추가할 뿐 별도의 처리가 필요 없습니다.
HttpServletRequest와 HttpServletResponse는 저수준(Low Level)의 서블릿 API를 사용할 수 있기 때문에 복잡한 HTTP Request/Response를 처리하는 데 사용할 수 있습니다.
반면에 ResponseEntity나 HttpHeaders는 Spring에서 지원하는 고수준(High Level) API로써 간단한 HTTP Request/Response 처리를 빠르게 진행할 수 있습니다.
복잡한 처리가 아니라면 코드의 간결성이나 생산성 면에서 가급적 Spring에서 지원하는 고수준 API를 사용하길 권장합니다.
핵심 포인트
- HTTP 헤더(Header)는 HTTP 메시지(Messages)의 구성 요소 중 하나로써 클라이언트의 요청이나 서버의 응답에 포함되어 부가적인 정보를 HTTP 메시지에 포함할 수 있다.
- HTTP Request 헤더(Header) 정보 얻기
- @RequestHeader 애너테이션을 이용해서 개별 헤더 정보 및 전체 헤더 정보를 얻을 수 있다.
- HttpServletRequest 또는 HttpEntity 객체로 헤더 정보를 얻을 수 있다.
- HTTP Response 헤더(Header) 정보 추가
- ResponseEntity와 HttpHeaders를 이용해 헤더 정보를 추가할 수 있다.
- HttpServletResponse 객체를 이용해 헤더 정보를 추가할 수 있다.
심화 학습
- 아래 링크를 통해 HTTP 헤더(Header)에 대해 더 알아보세요.
- HTTP 헤더(Header) 더 알아보기: https://developer.mozilla.org/ko/docs/Web/HTTP/Headers
- 아래 링크를 통해 HttpServletRequest에 대해 더 알아보세요.
- HttpServletRequest API Docs
- HttpServletResponse API Docs
- 아래 링크를 통해 User Agent의 유형에 대해 더 알아보세요(가벼운 마음으로 ^^)
- 커스텀 헤더(Custom Header)를 어떤 식으로 사용할 수 있는지 더 알아보세요.
커스텀 헤더를 지금 당장 사용할 일은 없습니다. 이런 식으로 사용하는구나 라는 정도만 알고 넘어가도 무방합니다.
- Google Cloud Load Balancing 사례: https://cloud.google.com/load-balancing/docs/user-defined-request-headers?hl=ko
- AWS Elastic Load Balancing 사례: https://docs.aws.amazon.com/ko_kr/elasticloadbalancing/latest/application/x-forwarded-headers.html
[심화] Rest Client
클라이언트(Client)와 서버(Server)의 관계
여러분들이 늘 사용하고 있는 크롬이나 사파리, 익스플로러 같은 웹 브라우저는 웹 브라우저에서 보이는 HTML 콘텐츠를 웹 서버에 요청하고, 웹 서버는 요청에 해당하는 적절한 콘텐츠를 여러분의 웹 브라우저에 응답으로 전달하게 됩니다.
즉, 웹 브라우저는 웹 서버로부터 HTML 콘텐츠를 제공받는 클라이언트가 됩니다.
위에서 설명한 것을 그림으로 표현하면 [그림 3-16]과 같습니다.
[그림 3-16]에서 웹 브라우저와 웹 서버
[그림 3-16]은 웹 브라우저와 웹 서버와의 관계를 보여주고 있습니다.
웹 브라우저는 웹 서버가 응답으로 전달해 주는 HTML 콘텐츠를 전달받아서 브라우저 내에 보여줍니다.
여기서 서버 쪽의 콘텐츠 즉, 서버 쪽의 리소스(Resource, 자원)를 이용하는 측이 클라이언트가 됩니다.
그런데 서버는 항상 클라이언트에게 리소스를 제공하는 역할만 하는 것이 아니라 서버도 다른 서버로부터 리소스를 제공받아야 하는 경우가 굉장히 많습니다.
대표적인 예가 바로 Frontend 서버와 Backend 서버의 관계입니다.
[그림 3-17] Frontend와 Backend의 관계
[그림 3-17]은 웹 브라우저 그리고 Frontend와 Backend와의 관계를 보여주고 있습니다.
웹 브라우저 입장에서는 Frontend의 리소스를 제공받기 때문에 명백하게 클라이언트입니다.
그런데 Frontend의 경우, 웹 브라우저에게는 리소스를 제공하는 입장이니까 서버가 맞지만 Frontend가 Backend에 동적인 데이터를 요청하게 된다면 이때만큼은 Frontend가 Backend의 리소스를 이용하는 클라이언트가 되는 것입니다.
이처럼 클라이언트와 서버의 관계는 상대적이라는 것을 알 수 있습니다.
클라이언트 앱을 만들기 위한 React, Angular 같은 자바스크립트 진영에서는 Backend 서버와 통신하기 위해 Axios 같은 라이브러리를 사용합니다.
그런데 Backend 쪽에서도 모든 작업을 하나의 서버에서 전부 처리하는 것이 아니라 Backend 서버 내부적으로 다른 서버에게 HTTP 요청을 전송해서 작업을 나누어 처리하는 경우가 굉장히 많습니다.
[그림 3-18] Backend와 Backend의 관계
[그림 3-18]에서는 Backend 서버가 하나 더 추가되었습니다.
이때 Backend A가 Frontend에게 리소스를 제공해 주기 때문에 서버 역할을 하지만 Backend A에서 Backend B의 리소스를 다시 이용하기 때문에 Backend B의 리소스를 이용하는 그때만큼은 Backend A도 클라이언트의 역할을 하게 됩니다.
서버가 하나둘씩 늘어나기 때문에 여러분들의 머릿속이 혼란스러워질 수 있을 거라 생각합니다.
'어떤 서버가 HTTP 통신을 통해서 다른 서버의 리소스를 이용한다면 그때만큼은 클라이언트의 역할을 한다.'
이 문장 하나만큼은 꼭 기억을 하길 바랍니다!
Rest Client란?
Rest Client란 무엇을 의미하는 걸까요?
Rest Client란 말 그대로 Rest API 서버에 HTTP 요청을 보낼 수 있는 클라이언트 툴 또는 라이브러리를 의미합니다.
여러분들이 샘플 애플리케이션을 만들면서 사용하고 있는 Postman은 UI가 갖춰진 Rest Client라고 보면 되겠습니다.
그런데 [그림 3-18]과 같이 UI가 없는 Backend A의 애플리케이션 내부에서 Backend B의 애플리케이션에 HTTP 요청을 보내려면 어떻게 할까요?
그럴 땐 UI가 없는 Rest Client 라이브러리를 사용하면 됩니다.
RestTemplate
Java에서 사용할 수 있는 HTTP Client 라이브러리로는 java.net.HttpURLConnection, Apache HttpComponents, OkHttp 3, Netty 등이 있습니다.
Spring에서는 이 HTTP Client 라이브러리 중 하나를 이용해서 원격지에 있는 다른 Backend 서버에 HTTP 요청을 보낼 수 있는 RestTemplate이라는 꽤 괜찮은 Rest Client API를 제공합니다.
RestTemplate을 이용하면 Rest 엔드 포인트 지정, 헤더 설정, 파라미터 및 body 설정을 한 줄의 코드로 손쉽게 전송할 수 있는데 지금부터 그 과정을 단계적으로 살펴보도록 하겠습니다.
RestTemplate에서 Template의 의미
여러분들이 알고 있는 템플릿(Template) 중에서 가장 자주 사용되는 템플릿 중 하나가 바로 파워포인트 템플릿입니다.
인터넷상에서 제공되는 파워포인트 템플릿을 사용하면 PPT 디자인에 대한 고민 없이 여러분들이 작성하고자 하는 내용만 채워 넣으면 됩니다.
RestTemplate에서 Template 역시 마찬가지의 의미입니다.
PPT 템플릿처럼 RestTemplate이라는 템플릿 클래스를 이용해 앞에서 언급한 java.net.HttpURLConnection, Apache HttpComponents, OkHttp 3, Netty 같은 HTTP Client 라이브러리 중 하나를 유연하게 사용할 수 있다는 사실을 기억하길 바랍니다!
RestTemplate 객체 생성
public class RestClientExample01 {
public static void main(String[] args) {
// (1) 객체 생성
RestTemplate restTemplate =
new RestTemplate(new HttpComponentsClientHttpRequestFactory());
}
}
기본적으로 RestTemplate의 객체를 생성하기 위해서는 RestTemplate의 생성자 파라미터로 HTTP Client 라이브러리의 구현 객체를 전달해야 합니다.
저희는 HttpComponentsClientHttpRequestFactory 클래스를 통해 Apache HttpComponents를 전달합니다.
Apache HttpComponents를 사용하기 위해서는 builde.gradle의 dependencies 항목에 아래와 같이 의존 라이브러리를 추가해야 합니다.
dependencies {
...
...
implementation 'org.apache.httpcomponents:httpclient'
}
URI 생성
RestTemplate 객체를 생성했다면 HTTP Request를 전송할 Rest 엔드포인트의 URI를 지정해 주어야 합니다.
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
public class RestClientExample01 {
public static void main(String[] args) {
// (1) 객체 생성
RestTemplate restTemplate =
new RestTemplate(new HttpComponentsClientHttpRequestFactory());
// (2) URI 생성
UriComponents uriComponents =
UriComponentsBuilder
.newInstance()
.scheme("http")
.host("worldtimeapi.org")
// .port(80)
.path("/api/timezone/{continents}/{city}")
.encode()
.build();
URI uri = uriComponents.expand("Asia", "Seoul").toUri();
}
}
[코드 3-18]에서는 UriComponentsBuilder 클래스를 이용해서 UriComponents 객체를 생성한 후에 이 UriComponents 객체를 이용해서 HTTP Request를 요청할 엔드포인트의 URI를 생성하고 있습니다.
먼저 UriComponentsBuilder 클래스에서 제공하는 API 메서드의 기능을 간단하게 살펴봅시다.
- newInstance()
- UriComponentsBuilder 객체를 생성합니다.
- scheme()
- URI의 scheme을 설정합니다.
- host()
- 호스트 정보를 입력합니다.
- port()
- 디폴트 값은 80이므로 80 포트를 사용하는 호스트라면 생략 가능합니다.
- path()
- URI의 경로(path)를 입력합니다.
- [코드 3-18]에서는 URI의 path에서 {continents}, {city}의 두 개의 템플릿 변수를 사용하고 있습니다.
- 두 개의 템플릿 변수는 uriComponents.expand("Asia", "Seoul").toUri();에서 expand() 메서드 파라미터의 문자열로 채워집니다.
- 즉, 빌드 타임에 {continents}는 ‘Asia’, {city}는 ‘Seoul’로 변환됩니다.
- encode()
- URI에 사용된 템플릿 변수들을 인코딩해줍니다.
- 여기서 인코딩의 의미는 non-ASCII 문자와 URI에 적절하지 않은 문자를 Percent Encoding 한다는 의미입니다.
- Percent-Encoding에 대해서는 아래 [심화 학습]을 참고하세요.
- build()
- UriComponents 객체를 생성합니다.
코드 3-18에서는 테스트를 위해서 HTTP Request 엔드포인트로 World Time API의 URI를 사용하고 있습니다.
World Time API에 대해서는 아래 링크를 확인하세요.
World Time API: http://worldtimeapi.org
다음으로, UriComponents에 사용된 API 메서드의 기능을 보겠습니다.
- expand()
- 파라미터로 입력한 값을 URI 템플릿 변수의 값으로 대체합니다.
- toUri()
- URI 객체를 생성합니다.
요청 전송
✔️ getForObject()를 이용한 문자열 응답 데이터 전달받기
URI 설정까지 끝났다면 이제 엔드포인트로 Request를 전송해 보도록 하겠습니다.
public class RestClientExample01 {
public static void main(String[] args) {
// (1) 객체 생성
RestTemplate restTemplate =
new RestTemplate(new HttpComponentsClientHttpRequestFactory());
// (2) URI 생성
UriComponents uriComponents =
UriComponentsBuilder
.newInstance()
.scheme("http")
.host("worldtimeapi.org")
// .port(80)
.path("/api/timezone/{continents}/{city}")
.encode()
.build();
URI uri = uriComponents.expand("Asia", "Seoul").toUri();
// (3) Request 전송
String result = restTemplate.getForObject(uri, String.class);
System.out.println(result);
}
}
코드 3-19에는 Rest 엔드포인트로 Request를 전송하는 부분이 추가되었습니다.
- getForObject(URI uri, Class<T> responseType)
- 기능 설명
- getForObject() 메서드는 HTTP Get 요청을 통해 서버의 리소스를 조회합니다.
- 파라미터 설명
- URI uri
- Request를 전송할 엔드포인트의 URI 객체를 지정해 줍니다.
- Class<T> responseType
- 응답으로 전달받을 클래스의 타입을 지정해 줍니다.
- 코드 3-19에서는 응답 데이터를 문자열로 받을 수 있도록 String.class로 지정했습니다.
- URI uri
- 기능 설명
이제 HTTP Request를 정상적으로 전송할 수 있게 되었습니다. 코드를 실행해서 실행 결과를 확인해 봅시다!
[코드 3-19] 코드 실행 결과
abbreviation: KST
client_ip: 125.129.191.130
datetime: 2022-04-28T09:49:44.492621+09:00
day_of_week: 4
day_of_year: 118
dst: false
dst_from:
dst_offset: 0
dst_until:
raw_offset: 32400
timezone: Asia/Seoul
unixtime: 1651106984
utc_datetime: 2022-04-28T00:49:44.492621+00:00
utc_offset: +09:00
week_number: 17
실행 결과를 통해 Request로 전송한 엔드포인트에서 꽤 많은 응답 데이터를 전달한 것을 볼 수 있습니다.
✔️ getForObject()를 이용한 커스텀 클래스 타입으로 원하는 정보만 응답으로 전달받기
그런데 이렇게 문자열 형태로 전달받은 응답 데이터 중에서 여러분들이 원하는 데이터만 전달받고 싶다면 문자열을 조작해서 원하는 정보를 얻어야 하는데 그러기 위해서는 로직이 너무 복잡해집니다.
이 경우에는 클래스를 별도로 하나 생성해서 원하는 데이터만 손쉽게 전달받을 수 있습니다.
public class RestClientExample02 {
public static void main(String[] args) {
// (1) 객체 생성
RestTemplate restTemplate =
new RestTemplate(new HttpComponentsClientHttpRequestFactory());
// (2) URI 생성
UriComponents uriComponents =
UriComponentsBuilder
.newInstance()
.scheme("http")
.host("worldtimeapi.org")
// .port(80)
.path("/api/timezone/{continents}/{city}")
.encode()
.build();
URI uri = uriComponents.expand("Asia", "Seoul").toUri();
// (3) Request 전송. WorldTime 클래스로 응답 데이터를 전달받는다.
WorldTime worldTime = restTemplate.getForObject(uri, WorldTime.class);
System.out.println("# datatime: " + worldTime.getDatetime());
System.out.println("# timezone: " + worldTime.getTimezone());
System.out.println("# day_of_week: " + worldTime.getDay_of_week());
}
}
[코드 3-20]에서는 응답 데이터를 WorldTime이라는 클래스로 전달받고 있습니다.
public class WorldTime {
private String datetime;
private String timezone;
private int day_of_week;
public String getDatetime() {
return datetime;
}
public String getTimezone() {
return timezone;
}
public int getDay_of_week() {
return day_of_week;
}
}
[코드 3-21]은 응답 데이터를 받기 위한 WorldTime 클래스입니다.
WorldTime 클래스를 사용해서 전체 응답 데이터를 모두 전달받는 것이 아니라 datetime과 timezone 정보만 전달을 받고 있습니다.
주의해야 할 부분은 전달받고자하는 응답 데이터의 JSON 프로퍼티 이름과 클래스의 멤버변수 이름이 동일해야 하고 해당 멤버 변수에 접근하기 위한 getter 메서드 역시 동일한 이름이어야 합니다.
예를 들어, JSON 프로퍼티 이름이 ‘day_of_week’라면 클래스 멤버 변수의 이름도 ‘day_of_week’여야 하고, 클래스 멤버 변수의 getter 메서드 명은 ‘getDay_of_week’가 되어야 합니다.
[코드 3-20] 실행 결과
# datatime: 2021-10-10T11:39:15.099207+09:00
# timezone: Asia/Seoul
# day_of_week: 4
✔️ getForEntity()를 사용한 Response Body(바디, 콘텐츠) + Header(헤더) 정보 전달받기
public class RestClientExample02 {
public static void main(String[] args) {
// (1) 객체 생성
RestTemplate restTemplate =
new RestTemplate(new HttpComponentsClientHttpRequestFactory());
// (2) URI 생성
UriComponents uriComponents =
UriComponentsBuilder
.newInstance()
.scheme("http")
.host("worldtimeapi.org")
// .port(80)
.path("/api/timezone/{continents}/{city}")
.encode()
.build();
URI uri = uriComponents.expand("Asia", "Seoul").toUri();
// (3) Request 전송. ResponseEntity로 헤더와 바디 정보를 모두 전달받을 수 있다.
ResponseEntity<WorldTime> response =
restTemplate.getForEntity(uri, WorldTime.class);
System.out.println("# datatime: " + response.getBody().getDatetime());
System.out.println("# timezone: " + response.getBody().getTimezone()());
System.out.println("# day_of_week: " + response.getBody().getDay_of_week());
System.out.println("# HTTP Status Code: " + response.getStatusCode());
System.out.println("# HTTP Status Value: " + response.getStatusCodeValue());
System.out.println("# Content Type: " + response.getHeaders().getContentType());
System.out.println(response.getHeaders().entrySet());
}
}
[코드 3-22]에서는 getForEntity() 메서드를 사용해서 헤더 정보와 바디 정보를 모두 전달받고 있습니다.
응답 데이터는 ResponseEntity 클래스로 래핑 되어서 전달되며 예제 코드와 같이 getBody(), getHeaders() 메서드 등을 이용해서 바디와 헤더 정보를 얻을 수 있습니다.
응답으로 전달되는 모든 헤더 정보를 보고 싶다면 예제 코드에서처럼 getHeaders().entrySet() 메서드를 이용해서 확인할 수 있습니다.
✔️ exchange()를 사용한 응답 데이터 받기
exchange() 메서드를 사용한 방식은 앞에서 보았던 방식들보다 조금 더 일반적인 HTTP Request 방식입니다.
즉, HTTP Method나 HTTP Request, HTTP Response 방식을 개발자가 직접 지정해서 유연하게 사용할 수 있습니다.
public class RestClientExample03 {
public static void main(String[] args) {
// (1) 객체 생성
RestTemplate restTemplate =
new RestTemplate(new HttpComponentsClientHttpRequestFactory());
// (2) URI 생성
UriComponents uriComponents =
UriComponentsBuilder
.newInstance()
.scheme("http")
.host("worldtimeapi.org")
// .port(80)
.path("/api/timezone/{continents}/{city}")
.encode()
.build();
URI uri = uriComponents.expand("Asia", "Seoul").toUri();
// (3) Request 전송. exchange()를 사용한 일반화 된 방식
ResponseEntity<WorldTime> response =
restTemplate.exchange(uri,
HttpMethod.GET,
null,
WorldTime.class);
System.out.println("# datatime: " + response.getBody().getDatetime());
System.out.println("# timezone: " + response.getBody().getTimezone());
System.out.println("# day_of_week: " + response.getBody().getDay_of_week());
System.out.println("# HTTP Status Code: " + response.getStatusCode());
System.out.println("# HTTP Status Value: " + response.getStatusCodeValue());
}
}
코드 3-23은 exchange() 메서드를 사용한 예제 코드입니다.
- exchange(URI uri, HttpMethod method, HttpEntity<?> requestEntity, Class<T> responseType)
- 기능 설명
- getForObject(), getForEntity() 등과 달리 exchange() 메서드는 HTTP Method, RequestEntity, ResponseEntity를 직접 지정해서 HTTP Request를 전송할 수 있는 가장 일반적인 방식입니다.
- 파라미터 설명
- URI url
- Request를 전송할 엔드포인트의 URI 객체를 지정해 줍니다.
- HttpMethod method
- HTTP Method 타입을 지정해 줍니다.
- HttpEntity<?> requestEntity
- HttpEntity 객체를 지정해 줍니다.
- HttpEntity 객체를 통해 헤더 및 바디, 파라미터 등을 설정해 줄 수 있습니다.
- Class<T> responseType
- 응답으로 전달받을 클래스의 타입을 지정해 줍니다.
- URI url
- 기능 설명
샘플 애플리케이션에서 RestTemplate 적용 포인트
여러분들이 현재까지 만들어 본 커피 주문을 위한 샘플 애플리케이션은 여전히 미완성의 애플리케이션입니다.
하지만 여러분들이 RestTemplate을 학습했으니 외부에 있는 API 서버와 통신을 해야 될 가능성이 있는 기능에는 어떤 것들이 있을까라는 고민은 한번쯤 해볼 수 있습니다.
샘플 애플리케이션에서 RestTemplate을 이용할 수 있는 기능에는 뭐가 있을까요?
- 결제 서비스
우리가 이번 코스가 끝날 때까지 커피 주문에 대한 실제 결제 서비스를 연동하지는 않습니다.
하지만 결제 서비스를 지원해 주는 PG(Payment Gateway)사와의 API 통신에 RestTemplate을 사용할 수 있을 것입니다.
- 메시징 기능
주문한 커피가 나올 경우 등 카카오톡 같은 메시지로 고객에게 메시지 알림을 전송할 필요가 있을 때, 외부의 메시징 서비스와의 HTTP 통신을 위해서 RestTemplate을 사용할 수 있을 것입니다.
앞에서 언급한 기능들을 샘플 애플리케이션에 적용하지는 않지만 여러분들이 프로젝트를 수행할 경우, 외부 서비스로 제공되는 API 기능을 포함시켜서 RestTemplate을 사용해 본다면 개발자로서 한층 성장할 수 있는 좋은 경험이 될 거라고 생각합니다.
💡 maintenance mode가 된 RestTemplate
Spring 공식 API 문서에는 RestTemplate이 5.0 버전부터 maintenance mode 상태를 유지한다라고 명시가 되어 있습니다.
maintenance mode란 API의 사소한 변경이나 버그에 대해서는 대응을 하겠지만 신규 기능의 추가는 없을 것이라는 의미입니다. 미래에는 Deprecated될 가능성이 있다고도 볼 수 있습니다.
그런데 굳이 RestTemplate의 사용법을 배울 필요가 있을까요? 네, 있습니다. ^^
여러분들은 아직 외부 API와 연동하는 경험이 없을 가능성이 높기 때문에 외부 서비스의 API를 사용하는 개념이나 방법 등에 대해서 학습을 해야 하는 상태이고 여러분이 학습하기 위해 가장 적절한 Rest Client는 여전히 RestTemplate입니다.
Spring 공식 API 문서에는 RestTemplate 대신에 WebClient라는 현대적인 API를 사용하라고 권장을 하고 있긴 합니다.
그런데 WebClient는 원래 Non-Blocking 통신을 주목적으로 탄생한 Rest Client입니다.
물론 Blocking 통신을 지원하기 때문에 RestTemplate 대신에 WebClient의 사용을 고려해 보라고 권장하는 건 맞지만 WebClient를 제대로 잘 사용하기 위해서는 Non-Blocking의 개념과 Spring WebFlux의 개념을 이해하고 난 다음에 사용하는 것이 낫다고 판단됩니다.
여러분이 지금은 RestTemplate을 사용해도 전혀 문제 될 게 없으며, 실제로 여러분이 취업을 해도 여러분의 기업에서 RestTemplate을 여전히 사용하고 있을 가능성이 높습니다.
정 WebClient를 사용해보고 싶다면 최소한 우리 코스에서 학습하게 될 Spring WebFlux 유닛을 학습하고 난 이후에 사용해 보길 바라봅니다.
핵심 포인트
- 웹 브라우저는 웹 서버로부터 HTML 콘텐츠를 제공받는 클라이언트 중 하나이다**.**
- 어떤 서버가 HTTP 통신을 통해서 다른 서버의 리소스를 이용한다면 그때만큼은 클라이언트의 역할을 한다.
- Rest Client란 Rest API 서버에 HTTP 요청을 보낼 수 있는 클라이언트 툴 또는 라이브러리를 의미합니다.
- RestTemplate은 원격지에 있는 다른 Backend 서버에 HTTP 요청을 전송할 수 있는 Rest Client API이다.
- RestTemplate 사용 단계
- RestTemplate 객체를 생성한다.
- HTTP 요청을 전송할 엔드포인트의 URI 객체를 생성한다.
- getForObject(), getForEntity(), exchange() 등을 이용해서 HTTP 요청을 전송한다.
- RestTemplate을 사용할 수 있는 기능 예
- 결제 서비스
- 카카오톡 등의 메시징 서비스
- Google Map 등의 지도 서비스
- 공공 데이터 포털, 카카오, 네이버 등에서 제공하는 Open API
- 기타 원격지 API 서버와의 통신
심화 학습
- 아래 링크를 통해 URI의 scheme에 대해서 더 알아보세요.
- URI scheme 목록: https://en.wikipedia.org/wiki/List_of_URI_schemes
- 아래 링크를 통해 Percent-Encoding에 대해서 더 알아보세요.
- Percent Encoding: https://ko.wikipedia.org/wiki/퍼센트_인코딩
- 아래 링크를 통해 RestTemplate API 기능을 더 알아보세요.
- RestTemplate API: https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#rest-client-access
- RestTemplate API Docs: https://docs.spring.io/spring-framework/docs/current/javadoc-api/
- 아래 링크를 통해 RestTemplate API를 활용할만한 Open API 서비스 제공 사이트를 둘러보고 어떤 기능들을 제공하는지 확인해 본 후, 향후 여러분들이 프로젝트를 수행할 경우 어떤 API 서비스를 적용할 수 있을지 생각해 보세요.(가벼운 마음으로 보시길 바랍니다.^^)
- 공공 데이터 포털: https://www.data.go.kr/dataset/3043385/openapi.do
- 카카오 REST API: https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
- 네이버 API: https://developers.naver.com/products/intro/plan/plan.md
- 구글 API 서비스: https://console.cloud.google.com
- 공공 인공지능 API 서비스: https://aiopen.etri.re.kr/
[실습] Controller 구현 실습
Controller 실습 개요
- 이번 실습은 실습용 샘플 프로젝트에 포함되어 있는 Controller(MemberController, CoffeeController, OrderController) 클래스에 추가 기능을 위한 핸들러 메서드를 구현하는 실습입니다.
- 지난 챕터까지 학습했던 핸들러 메서드가 포함되어 있으며, 이를 기반으로 실습 요구사항에 맞는 핸들러 메서드를 추가하고 요구 사항에 맞게 구현하면 됩니다.
- 실습에 필요한 Controller 클래스는 ‘com.spring’ 내의 기능별 패키지(member, coffee, order)에 포함되어 있습니다.
실습 사전 준비
- 실습용 샘플 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 fork합니다.
- fork한 repository를 여러분의 PC에서 git clone 명령으로 local repository에 복제합니다.
- IntelliJ IDE로 clone 받은 forked local repository 디렉토리의 프로젝트를 Open합니다.
- 아래 실습 요구 사항에 따라 실습을 진행합니다.
- 작성한 코드는 main branch에 작성해 주세요.
- main branch가 아닌 별도의 branch를 생성해서 작업을 했다면 작업이 끝난 후, 반드시 main branch로 merge 해야 합니다.
실습 요구 사항
MemberController
공통
- 클라이언트에서 요청으로 전달할 수 있는 요청 파라미터 명은 다음으로 제한합니다.
- memberId(회원 식별자): long
- email(이메일): String
- name(이름): String
- phone(휴대폰 번호): String
- 예) 010-1234-5678
- 애플리케이션 로딩 시, MemberController에는 코드 h-1과 같이 init() 메서드를 통해 memberId가 1인 회원의 회원 정보가 Map에 저장됩니다.
@RestController
@RequestMapping("/v1/members")
public class MemberController {
private final Map<Long, Map<String, Object>> members = new HashMap<>();
@PostConstruct
public void init() {
Map<String, Object> member1 = new HashMap<>();
long memberId = 1L;
member1.put("memberId", memberId);
member1.put("email", "hgd@gmail.com");
member1.put("name", "홍길동");
member1.put("phone", "010-1234-5678");
members.put(memberId, member1);
}
...
...
}
MemberController 핸들러 메서드 구현 1
- 구현 내용
- memberId가 1인 회원의 회원 정보 중에서 아래 정보를 수정하는 핸들러 메서드를 구현하세요.
- phone(휴대폰 번호) 정보를 ‘010-1111-2222’로 수정하세요.
- ⭐ 여러분들이 아직 데이터를 실제로 데이터베이스에 저장하는 학습을 진행하지 않았기 때문에 memberId가 1인 회원이 데이터베이스에 저장되어 있지 않고, 코드 h-1과 같이 members Map에 저장되어 있습니다. 따라서 members Map에서 memberId가 1인 회원 정보(member1)를 얻어서 요청으로 전달받은 phone 정보를 업데이트 한 뒤에 응답으로 전송하면 됩니다.
- members Map은 key를 memberId로 가지고, 회원 정보를 포함한 map 객체를 value로 가집니다.
- memberId가 1인 회원의 회원 정보 중에서 아래 정보를 수정하는 핸들러 메서드를 구현하세요.
- 구현 조건
- memberId는 URI 경로에 포함되어야 합니다.
- 수정을 위한 휴대폰 번호는 클라이언트의 요청 데이터에 포함되어야 합니다.
- 응답 바디(Body)로 Map 객체를 사용하고 아래 데이터를 포함해야 합니다.
- 회원 식별자
- 이메일
- 이름
- 수정된 휴대폰 번호
- 핸들러 메서드의 리턴값은 ResponseEntity 객체여야 하며, 응답 데이터를 포함해야 합니다.
- HTTP 응답 상태 코드(Response Status Code)는 200이어야 합니다.
- 서비스 계층을 배우지 않았기 때문에 회원 정보를 실제로 수정하는 비즈니스 로직은 필요 없습니다.
- 실행 결과
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-1]과 같이 응답 결과를 받아야 합니다.
- HTTP Response Status가 “200 OK”로 표시되어야 합니다.
- 수정된 휴대폰 번호인 “010-1111-2222”가 응답 바디(Body)에 포함되어 있어야 합니다.
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-1]과 같이 응답 결과를 받아야 합니다.
[그림 h-1] MemberController 핸들러 메서드 구현 1의 HTTP 요청 결과
MemberController 핸들러 메서드 구현 2
- 구현 내용
- memberId가 1인 회원 정보를 삭제하는 핸들러 메서드를 구현하세요.
- 이 의미는 회원 탈퇴로 인한 회원 정보 삭제를 의미합니다.
- ⭐ 역시 아직 데이터를 실제로 데이터베이스에 저장하는 학습을 진행하지 않았기 때문에 members Map에 포함된 memberId가 1인 회원 정보를 제거(remove)하면 됩니다.
- memberId가 1인 회원 정보를 삭제하는 핸들러 메서드를 구현하세요.
- 구현 조건
- memberId는 URI 경로에 포함되어야 합니다.
- 핸들러 메서드의 리턴 값은 ResponseEntity 객체여야 합니다.
- 응답 바디(Response Body) 데이터는 null이어야 합니다.
- HTTP 응답 상태 코드(Response Status Code)는 204여야 합니다.
- 서비스 계층을 배우지 않았기 때문에 회원 정보를 실제로 삭제하는 비즈니스 로직은 필요 없습니다.
- 실행 결과
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, \[그림 h-2]와 같이 응답 결과를 받아야 합니다.
- HTTP Response Status가 “204 NO Content”로 표시되어야 합니다.
- 전달받은 응답 바디(Body)가 없어야 합니다.
- Postman에서는 응답 바디(Response Body)가 null이거나, 서버 쪽에서 에러 발생으로 응답 바디가 정상적으로 전송되지 않았을 경우 [Pretty] 탭에서 1을 표시합니다.
- [Raw] 또는 [Preview] 탭에서는 아무것도 표시되지 않습니다.
- Postman에서는 응답 바디(Response Body)가 null이거나, 서버 쪽에서 에러 발생으로 응답 바디가 정상적으로 전송되지 않았을 경우 [Pretty] 탭에서 1을 표시합니다.
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, \[그림 h-2]와 같이 응답 결과를 받아야 합니다.
[그림 h-2] MemberController 핸들러 메서드 구현 2의 HTTP 요청 결과
❗ 회원 정보 삭제 핸들러 메서드가 정상 동작하여 memberId가 1인 회원 정보가 members Map에서 제거된다면 더 이상 memberId가 1인 회원 정보가 members Map에 존재하지 않으므로, 회원 정보 수정 핸들러 메서드를 테스트하기 위해서는 애플리케이션을 다시 실행해야 함을 기억하세요.
CoffeeController
공통
- 클라이언트에서 요청으로 전달할 수 있는 요청 파라미터 명은 다음으로 제한합니다.
- coffeeId(커피 식별자): long
- korName(한글 커피명): String
- engName(영문 커피명): String
- price(가격): int
- 예) 3500
- 애플리케이션 로딩 시, CoffeeController에는 코드 h-2와 같이 init() 메서드를 통해 coffeeId가 1인 커피 정보가 Map에 저장됩니다.
@RestController
@RequestMapping("/v1/coffees")
public class CoffeeController {
private final Map<Long, Map<String, Object>> coffees = new HashMap<>();
@PostConstruct
public void init() {
Map<String, Object> coffee1 = new HashMap<>();
long coffeeId = 1L;
coffee1.put("coffeeId", coffeeId);
coffee1.put("korName", "바닐라 라떼");
coffee1.put("engName", "Vanilla Latte");
coffee1.put("price", 4500);
coffees.put(coffeeId, coffee1);
}
...
...
}
CoffeeController 핸들러 메서드 구현 1
- 구현 내용
- coffeeId가 1인 커피 정보 중에서 아래 정보를 수정하는 핸들러 메서드를 구현하세요.
- 커피의 한글 커피명을 ‘바닐라 빈 라떼’로 수정하세요.
- 커피 가격을 5000원으로 수정하세요.
- ⭐ 회원 정보와 마찬가지로 커피 정보 역시 coffeeId가 1인 커피 정보가 데이터베이스에 저장되어 있지 않고, 코드 h-2와 같이 coffees Map에 저장되어 있습니다. 따라서 coffees Map에서 coffeeId가 1인 커피 정보(coffee1)를 얻어서 요청으로 전달받은 한글 커피명과 커피 가격을 업데이트 한 뒤에 응답으로 전송하면 됩니다.
- coffees Map은 key를 coffeeId로 가지고, 커피 정보를 포함한 map 객체를 value로 가집니다.
- coffeeId가 1인 커피 정보 중에서 아래 정보를 수정하는 핸들러 메서드를 구현하세요.
- 구현 조건
- coffeeId는 URI 경로에 포함되어야 합니다.
- 수정을 위한 커피 정보는 클라이언트의 요청에 포함되어야 합니다.
- 응답 바디(Body)로 Map 객체를 사용하고 아래 데이터를 포함해야 합니다.
- 커피 식별자
- 수정된 한글 커피명
- 영문 커피명
- 수정된 커피 가격
- 핸들러 메서드의 리턴값은 ResponseEntity 객체여야 하며, 응답 바디(Body)를 포함해야 합니다.
- HTTP 응답 상태 코드(Response Status Code)는 200이어야 합니다.
- 서비스 계층을 배우지 않았기 때문에 커피 정보를 실제로 수정하는 비즈니스 로직은 필요 없습니다.
- 실행 결과
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-3]과 같이 응답 결과를 받아야 합니다.
- HTTP Response Status가 “200 OK”로 표시되어야 합니다.
- 수정된 한글 커피명인 “바닐라 빈 라떼”가 응답 바디(Body)에 포함되어 있어야 합니다.
- 수정된 커피 가격인 5000이 응답 바디(Body)에 포함되어 있어야 합니다.
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-3]과 같이 응답 결과를 받아야 합니다.
[그림 h-3] CoffeeController 핸들러 메서드 구현 1의 HTTP 요청 결과
CoffeeController 핸들러 메서드 구현 2
- 구현 내용
- coffeeId가 1인 커피 정보를 삭제하는 핸들러 메서드를 구현하세요.
- 이 의미는 커피 정보의 삭제를 의미합니다.
- ⭐ 역시 아직 데이터를 실제로 데이터베이스에 저장하는 학습을 진행하지 않았기 때문에 coffees Map에 포함된 coffeeId가 1인 커피 정보를 제거(remove)하면 됩니다.
- coffeeId가 1인 커피 정보를 삭제하는 핸들러 메서드를 구현하세요.
- 구현 조건
- coffeeId는 URI 경로에 포함되어야 합니다.
- 핸들러 메서드의 리턴 값은 ResponseEntity 객체여야 합니다.
- 응답 바디(Response Body) 데이터는 null이어야 합니다.
- HTTP 응답 상태 코드(Response Status Code)는 204여야 합니다.
- 서비스 계층을 배우지 않았기 때문에 커피 정보를 실제로 삭제하는 비즈니스 로직은 필요 없습니다.
- 실행 결과
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-4]와 같이 응답 결과를 받아야 합니다.
- HTTP Response Status가 “204 NO Content”로 표시되어야 합니다.
- 전달받은 응답 바디(Body)가 없어야 합니다.
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-4]와 같이 응답 결과를 받아야 합니다.
[그림 h-4] CoffeeController 핸들러 메서드 구현 2의 HTTP 요청 결과
❗ 커피 정보 삭제 핸들러 메서드가 정상 동작하여 coffeeId가 1인 커피 정보가 coffees Map에서 제거된다면 더 이상 coffeeId가 1인 커피 정보가 coffees Map에 존재하지 않으므로, 커피 정보 수정 핸들러 메서드를 테스트하기 위해서는 애플리케이션을 다시 실행해야 함을 기억하세요.