정규화의 기본적인 목표는 테이블 간에 중복되는 데이터가 발생하지 않도록 하는 것이다. 중복된 데이터를 허용하지 않음으로써 데이터의 무결성을 유지할 수 있고, 이로 인해 데이터베이스 관리에 필요한 저장 공간을 축소시키는 효과가 있다.
데이터의 중복을 피하기 위해 데이터를 구조화하고, 그 과정에서 테이블을 더 작은 테이블로 분해하는데 이를 구조화하는 정규화 단계가 정의되어 있다.
제 1 정규화
제 1 정규화란, 테이블의 컬럼이 원자값 (Atomic Value) 를 갖도록 테이블을 분해하는 것이다. 예를 들어, 아래와 같이 고객의 취미를 저장한 테이블이 존재한다고 하자.
위 테이블에서 '추신수' 와 '박세리' 는 여러 개의 취미를 가지고 있어 제 1 정규형을 만족하지 못하고 있다. 이를 제 1 정규화를 통해 분해할 수 있는데, 제 1 정규화가 적용된 테이블은 다음과 같다.
제 2 정규화
제 2 정규화란, 제 1 정규화가 진행된 테이블에 대해 완전 함수 종속을 만족하도록 테이블을 분해하는 것이다. 여기서 완전 함수 종속이란 것은 기본키의 부분집합이 결정자가 되어선 안된다는 것을 말한다.
말이 조금 어려운데, 간단하게 얘기해서 어떤 테이블의 기본키가 두 개 이상의 컬럼으로 구성된 복합키일 때 기본키를 분해한 부분집합으로 인해 다른 컬럼의 값이 결정되어서는 안된다는 것이다. 예를 들어 다음의 테이블을 보면 조금 더 이해가 빠를 수 있다.
위 테이블에서 기본키는 {학생번호, 강좌이름} 의 복합키로 지정되어 있다. 그리고 {학생번호, 강좌이름} 의 기본키는 성적을 결정하고 있다. 그런데 여기서 강의실이라는 컬럼은 기본키의 부분집합인 {강좌이름} 에 의해 결정될 수 있다. 그렇기 때문에 위 테이블은 다음과 같이 기존의 테이블에서 강의실을 분해해 별도의 테이블을 관리해 제 2 정규형을 만족시킬 수 있다.
제 3 정규화
제 3 정규화란, 제 2 정규화를 진행한 테이블에 대해 이행적 종속이 없도록 테이블을 분해하는 것이다. 여기서 이행적 종속이란, A → B, B → C 가 성립할 때 A → C 가 성립하는 것을 의미한다.
예를 들어, 다음의 테이블을 살펴보자.
위 테이블에서 학생 번호는 강좌 이름을 결정하고 있고, 강좌 이름은 수강료를 결정한다. 결국, 학생 번호에 따라 수강료가 결정되므로 제 3 정규형을 만족하지 않는 테이블이다. 이를 제 3 정규형을 만족하도록 하기 위해서는, (학생 번호, 강좌 이름) 테이블과 (강좌이름, 수강료) 테이블로 분해할 필요가 있다.
이행적 종속을 제거하는 이유는 간단하다. 위의 테이블대로라면 학생번호가 501인 학생은 데이터베이스 강좌를 수강할 것이고 그 수강료로 20000 원을 지불하기로 되어 있다. 이 때, 이행적 종속이 테이블에 존재한다면 501 학생이 수강할 강좌를 스포츠경영학으로 변경할 경우, 스포츠경영학 강좌를 20000 원에 수강하게 된다. 물론 강좌가 변경됨에 따라 수강료 컬럼도 변경할 수 있지만, 한 컬럼의 데이터를 수정함으로 인해 다른 컬럼까지 같이 수정을 하게 되는 번거로움을 해결하기 위해 제 3 정규화를 진행하는 것이다.
즉, 학생 번호를 통해 강좌 이름을 참조하고, 강좌 이름으로 수강료를 참조하도록 테이블을 분해해야 하며 그 결과로 분해한 테이블은 다음과 같다.
BCNF 정규화
BCNF 정규화란, 제 3 정규화를 진행한 테이블에 대해 모든 결정자가 후보키가 되도록 테이블을 분해하는 것이다. 여기서 결정자란, A → B 를 만족하는 A 를 의미한다. 예를 들어, 다음과 같이 특강 수강 정보에 대한 테이블이 있다고 하자.
위의 테이블에서 기본키는 {학생번호, 특강이름} 이다. 그리고 기본키 {학생번호, 특강이름} 은 교수를 결정하고 있다. 또한 여기서 교수는 특강 이름을 결정한다.
여기서 문제는, 교수가 특강이름을 결정하는 결정자이지만 후보키는 아니라는 점이다. 그렇기 때문에 BCNF 정규형을 만족시키기 위해서는 위의 테이블을 분해해야 하고 다음과 같이 특강 신청 테이블과 특강 교수 테이블로 분해하면 BCNF 정규형을 만족시킬 수 있다.
인덱스란, 주기적인 쓰기 작업과 저장 공간을 활용해 데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료구조이다. 책을 예로 들었을 때, 우리가 원하는 내용을 찾기 위해 책의 모든 내용을 뒤져보는 것은 오랜 시간이 걸리는 비효율적인 일이다. 그렇기 때문에 책의 맨 앞이나 맨 뒤에 색인이라는 항목을 추가하는데, 데이터베이스에서 인덱스란 책에서의 색인과 같다.
데이터베이스에서도 원하는 데이터를 찾기 위해 테이블의 모든 데이터를 탐색하면 시간이 오래 걸리기 때문에 데이터와 데이터의 위치를 포함한 자료구조를 생성해 빠르게 조회할 수 있도록 한다.
위의 그림과 같이, 유저가 쿼리를 날렸을 때 인덱스를 통해 찾고자 하는 데이터의 위치를 확인하고 그 데이터를 사용자에게 전달한다. 인덱스를 활용하면 데이터를 조회하는 SELECT 외에도 UPDATE 나 DELETE 의 성능에도 영향을 미친다. 그 이유는 해당 연산을 수행하기 위해 대상을 먼저 조회해야 하기 때문이다.
// Garu 라는 이름을 수정하기 위해서는, Garu 이라는 이름을 가진 데이터를 먼저 조회해야 한다.
UPDATE USER SET NAME = 'DonGaru' WHERE NAME = 'Garu';
만약 인덱스를 사용하지 않은 컬럼을 조회해야 하는 상황에는 전체를 탐색하는 Full Scan 을 수행해야 한다. Full Scan 은 전체를 비교해 탐색하기 때문에 당연히 처리 속도가 떨어질 수밖에 없다.
2. 인덱스의 관리
DBMS 는 인덱스를 항상 최신 정렬된 상태로 유지해야 원하는 값을 빠르게 탐색할 수 있다. 그렇기 때문에 인덱스가 적용된 컬럼에 INSERT, UPDATE, DELETE 가 수행되면 각각 다음의 연산을 추가적으로 수행해야 하며 그에 따른 오버헤드가 발생한다.
INSERT : 새로운 데이터에 대한 인덱스 추가
UPDATE : 기존의 인덱스를 '사용하지 않음' 처리하고 갱신된 데이터에 대한 인덱스 추가
DELETE : 삭제하는 데이터의 인덱스를 '사용하지 않음' 처리
3. 인덱스 사용의 장점과 단점
장점
테이블을 조회하는 속도를 향상시킬 수 있다.
전반적인 시스템의 부하를 줄일 수 있다.
단점
인덱스를 관리하기 위해 추가적인 저장공간이 필요하다.
데이터 관리 외에 인덱스 관리를 위한 추가적인 작업이 필요하다.
인덱스를 잘못 사용하는 경우 오히려 성능이 저하될 수 있다.
만약 CREATE, UPDATE, DELETE 가 빈번한 컬럼에 인덱스를 걸게 되면 인덱스의 크기가 비대해져 성능이 오히려 저하되는 역효과가 발생할 수 있다. 그러한 이유 중 하나는 UPDATE 와 DELETE 연산 때문인데, 앞서 언급한 대로 UPDATE 와 DELETE 는 기존의 인덱스를 삭제하는 것이 아니라 '사용하지 않음' 처리를 한다. 만약 어떤 테이블에 UPDATE 와 DELETE 가 빈번하게 발생한다면 실제 데이터는 10만 건인데 인덱스는 100만 건이 넘어가게 되는 등, SQL 문 처리 시 비대해진 인덱스로 인해 성능이 오히려 떨어지게 된다.
Index 의 효율적인 사용
1. 인덱스의 특징
인덱스는 WHERE 절에서 효과가 있다.
인덱스는 SELECT - FROM - WHERE 절 중 WHERE 절에 사용할 컬럼에 대한 효율화라 볼 수 있다. WHERE 절을 사용하지 않고 인덱스가 걸린 컬럼을 조회하는 것은 인덱스가 성능에 아무런 영향을 주지 못한다.
예를 들어, '학생' 테이블에 학번, 이름, 전화번호 가 있다고 가정해보자. 인덱스는 학번과 전화번호에 걸려 있다. 다음 중 인덱스가 영향을 주는 쿼리는 어떤 것이 있을까?
SELECT '학번' FROM '학생';
SELECT '전화번호' FROM '학생' WHERE '이름' = '김철수';
SELECT * FROM '학생' WHERE '학번' = 1;
정답은 당연히 3번이다. 1번은 WHERE 절을 사용하지 않아 인덱스가 영향을 주지 않고, 2번은 WHERE 절을 사용했지만 해당 절에서 사용한 컬럼이 인덱스가 적용되지 않은 컬럼이기 때문에 영향을 주지 않는다.
무조건 많이 설정하면 성능이 좋아질까?
인덱스는 테이블마다 하나 혹은 여러 개의 컬럼에 대해 설정할 수 있다. 단일 인덱스를 여러 개 생성할 수도 있고, 여러 컬럼을 묶어 복합 인덱스를 설정할 수도 있다.
그러나 무조건 인덱스를 많이 설정하는 것이 검색 속도를 향상시키는 데 도움을 주지 않는다. 인덱스는 데이터베이스의 저장 공간을 사용해 테이블 형태로 저장되므로 개수와 저장 공간은 비례한다.
따라서, 조회 시 자주 사용하고 고유한 값 위주로 인덱스를 설정하는 것이 성능에 도움이 된다.
DML (Data Manipulation Language) 각각에 어떤 영향을 미칠까?
SELECT 쿼리는 성능이 눈에 띄게 향상되지만 INSERT, UPDATE, DELETE 쿼리에서는 때에 따라 다르다.
UPDATE, DELETE 는 WHERE 절에 잘 설정된 인덱스로 조건을 붙여주면 조회할 때 성능에 영향을 준다. (수정, 삭제할 데이터를 조회할 때의 속도가 빨라지는 것이지, 수정 자체의 속도가 빨라지는 것이 아니다.)
INSERT 의 경우는, 새로운 데이터가 추가되면서 기존 인덱스 페이지에 저장되어 있던 탐색 위치가 수정되어야 하므로 효율이 좋지 않다.
즉, 인덱스는 원하는 데이터를 빠르게 찾을 때 빛을 발한다.
어떤 컬럼에 인덱스를 설정하는 게 좋을까?
테이블의 목적 등에 따라 인덱스의 개수는 달라질 수 있지만 일반적으로 한 테이블 당 3~5 개 정도의 인덱스가 적당하다. 인덱스는 컬럼을 정해 설정하는 것이므로 후보 컬럼의 특징을 잘 파악해야 한다. 아래 4가지 기준을 잘 고려하면 효율적으로 인덱스를 설정할 수 있다.
카디널리티 (Cardinality)
선택도 (Selectivity)
활용도
중복도
카디널리티 (Cardinality)
카디널리티가 높을수록 인덱스 설정에 좋은 컬럼이다.
(= 한 칼럼이 갖고 있는 값의 중복 정도가 낮을수록 좋다.)
카디널리티는 컬럼에 사용되는 값의 다양성 정도, 즉 중복 수치를 나타내는 지표이다. 후보 컬럼에 따라 상대적으로 중복 정도가 낮다, 혹은 높다로 표현한다.
예를 들어, 10개의 행을 갖는 '학생' 테이블에 학번과 이름 컬럼이 있다고 하자. 학번은 학생마다 고유하게 부여받는 값이므로 10개의 값 모두가 고유한 값이다. 이름은 동명이인이 있을 수 있으니 1 ~ 10 의 카디널리티 값을 갖는다. 즉, 상대적으로 학번이 이름에 비해 카디널리티 값이 높으므로 인덱스로 설정하기에 알맞다.
선택도 (Selectivity)
선택도가 낮을수록 인덱스 설정에 좋은 컬럼이다.
5 ~ 10% 가 일반적으로 적당하다.
선택도는 데이터에서 특정 값을 얼마나 잘 선택할 수 있는지에 대한 지표이다. 선택도는 다음과 같이 계산할 수 있다.
선택도 (Selectivity) = 컬럼의 특정 값의 row 수 / 테이블의 총 row 수 * 100 = 컬럼의 값들의 평균 row 수 / 테이블의 총 row 수 * 100
예를 들어, 10개 행을 갖는 '학생' 테이블에 학번, 이름, 성별 컬럼이 있다고 하자. 학번은 고유하고, 이름은 동명이인이 2명씩 존재하고, 성별은 남녀 의 비율이 5:5 이다.
학번의 선택도 = 1 / 10 * 100 = 10% (동일한 학번이 존재하지 않으므로 어떤 값의 row 수는 모두 1)
이름의 선택도 = 2 / 10 * 100 = 20% (두 명마다 같은 이름을 가지므로 값들의 평균 row 수는 2)
성별의 선택도 = 5 / 10 * 100 = 50% (10명 중 5명이 남자, 5명이 여자로 특정 값의 row 수가 모두 5)
즉, 선택도는 특정 필드값을 지정했을 때 선택되는 레코드 수를 테이블 전체의 레코드 수로 나눈 비율이다.
활용도
활용도가 높을수록 인덱스 설정에 좋은 컬럼이다.
활용도는 해당 컬럼이 실제 작업에서 얼마나 활용되는지에 대한 값이다. 수동 쿼리 조회, 로직과 서비스에서 쿼리를 날릴 때 WHERE 절에서 자주 활용되는지를 통해 판단할 수 있다.
중복도
중복도가 없을수록 인덱스 설정에 좋은 컬럼이다.
중복도는 중복되는 인덱스 여부가 있는지에 대한 값이다. 인덱스 성능에 대한 고려 없이 마구잡이로 설정하거나, 다른 부서 / 다른 작업자의 분리된 요청으로 같은 컬럼에 대해 인덱스가 중복으로 생성된 경우를 볼 수 있다.
인덱스도 테이블 형태로 생성되므로, 속성을 가지고 그 속성을 컬럼으로 관리한다. 이 속성이 다를 때 같은 컬럼이라 할지라도 중복으로 인덱스 설정이 가능하다. 같은 컬럼에 대해 중복 인덱스 설정이 되어있다 하더라도 SQL 자체 연산이 빠른 쪽으로 데이터를 조회하지만, 인덱스도 결국 메모리의 일부를 사용하기 때문에 필요없는 항목은 삭제하는 것이 좋을 것이다.
인덱스를 설정할 때 고려해야 할 특징들을 위와 같이 정리했지만, 실제로 인덱스를 효율적으로 적용하는 것은 어떤 데이터를 다루느냐, 그 데이터가 어떤 특징을 가지고 있느냐를 추가적으로 고려해야 하기 때문에 위의 수치만을 가지고 절대적으로 판단할 수 있는 것은 아닌 것 같다.
인덱스의 자료구조
인덱스를 구현하기 위해 여러 자료구조를 사용할 수 있다. 아래에서는 가장 대표적인 해시 테이블과 B+Tree 에 대해 설명하겠다.
1. 해시 테이블 (Hash Table)
해시 테이블은 (Key, Value) 형태로 데이터를 저장하는 자료구조 중 하나로 빠른 데이터 검색이 필요할 때 유용하다. 해시 테이블은 Key 값을 이용해 고유한 Index 값을 생성하고 그 Index 에 데이터를 저장하거나 Index 에 저장된 값을 꺼내오는 구조이다.
해시 테이블 기반의 DB Index 는 (데이터=컬럼의 값, 데이터의 위치) 를 (Key, Value) 로 사용해 컬럼의 값으로 생성된 해시를 통해 인덱스를 구현했다. 해시 테이블의 탐색 시간복잡도는 O(1) 이며 매우 빠른 검색을 지원한다.
하지만 DB 인덱스에서 해시 테이블이 사용되는 경우는 매우 제한적인데, 그러한 이유는 해시 연산이 등호 연산에만 특화되었기 때문이다. 해시 함수는 값이 1이라도 달라지면 완전히 다른 해시 값을 생성하는데, 이러한 특성으로 인해 부등호 연산이 자주 사용되는 데이터베이스 검색에 해시 테이블이 적합하지 않다.
예를 들어, "나는" 으로 시작하는 모든 데이터를 검색하기 위한 쿼리문은 인덱스의 혜택을 전혀 받을 수 없게 된다. 이러한 이유로 데이터베이스의 인덱스 저장을 위한 자료구조로는 B+Tree 가 많이 사용되고 있다.
2. B+Tree
B+Tree 는 DB 의 인덱스를 위해 자식 노드가 2개 이상인 B-Tree 를 개선한 자료구조이다. B+Tree 는 모든 노드에 데이터를 저장한 B-Tree 와는 다른 특성을 가지고 있다.
리프 노드 (데이터 노드) 만 인덱스와 함께 데이터 (Value) 를 저장하고, 나머지 노드 (인덱스 노드) 들은 데이터를 위한 인덱스 (Key) 만을 갖는다.
리프 노드들은 Linked List 로 연결되어 있다.
데이터 노드의 크기는 인덱스 노드의 크기와 같지 않아도 된다.
데이터베이스의 인덱스 컬럼은 부등호를 이용한 순차 검색 연산이 자주 발생될 수 있다. 이러한 이유로 B-Tree 의 리프 노드들을 Linked List 로 연결해 순차검색을 용이하게 하는 등 B-Tree 를 인덱스에 맞게 최적화했다. (물론 Best Case 에 대해, 리프 노드까지 가지 않고 데이터를 탐색할 수 있는 B-Tree 에 비해 무조건 리프 노드까지 탐색해야 데이터를 찾을 수 있다는 단점도 존재한다.)
이러한 이유로, 비록 B+Tree 는 O(logn) 의 시간복잡도를 갖지만 해시테이블보다 인덱싱에 적합한 자료구조로 사용되고 있다.
아래 그림은 InnoDB 에서 사용된 B+Tree 의 구조이다.
InnoDB 의 B+Tree 는 일반적인 구조보다 더욱 복잡하게 구현이 되어있다. InnoDB 에서는 같은 레벨의 노드끼리는 Linked List 가 아닌 Double Linked List 로 연결이 되어있고, 부모 노드와 자식 노드들은 Singly Linked List 로 연결되어있다.
클라이언트가 보내는 웹 요청에는 스프링 MVC 가 클라이언트에게 다시 반환할 콘텐트 및 타입을 정확히 파악하는 데 필요한 프로퍼티가 여럿 포함돼 있다. 그 중 중요한 두 가지 단서는 요청 URL 의 일부인 확장자와 HTTP Accept 헤더이다. 예를 들어, /reservationSummary.xml URL 로 요청이 접수되면 컨트롤러는 URL 의 확장자 (.xml) 을 보고 XML 뷰를 표현하는 논리 뷰로 넘길 것이다. 그런데 확장자가 빠진 /reservationSummary 로 요청이 오면 어떻게 판단을 해야 할까? URL 만 봐서는 XML 뷰로 넘길지, HTML 뷰로 넘길지, 아니면 전혀 다른 타입의 뷰로 넘길지 판단하기 어렵다. 이럴 때 HTTP Accept 헤더를 보면 적절한 뷰 타입을 결정할 수 있다.
컨트롤러가 직접 HTTP Accept 헤더를 조사하는 방식은 코드가 복잡해질 수 있으므로 스프링 MVC 가 지원하는 ContentNegotiatingViewResolver 로 헤더를 살펴보고 URL 파일 확장자 또는 HTTP Accept 헤더값에 따라 뷰를 결정해 넘기는 것이 좋다.
스프링 MVC 의 Content Negotation 기능은 ContentNegotiatingViewResolver 클래스에 기반한 뷰 리졸버 형태로 구성한다.
@Configuration
public class ViewResolverConfiguration implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
Map<String, MediaType> mediatypes = new HashMap<>();
mediatypes.put("html", MediaType.TEXT_HTML);
mediatypes.put("json", MediaType.APPLICATION_JSON);
mediatypes.put("xml", MediaType.APPLICATION_XML);
configurer.mediaTypes(mediatypes);
}
@Bean
public ContentNegotiatingViewResolver contentNegotiatingViewResolver(
ContentNegotiationManager contentNegotiationManager) {
ContentNegotiatingViewResolver viewResolver = new ContentNegotiatingViewResolver();
viewResolver.setContentNegotiationManager(contentNegotiationManager);
return viewResolver;
}
}
Content Negotiation 기능이 제대로 동작하려면 ContentNegotiatingViewResolver 의 우선순위가 가장 높게 설정되어야 한다. 이 리졸버는 스스로 뷰를 해석하지 않고 다른 리졸버에게 그 작업을 넘기기 때문이다.
예를 들어, 어떤 컨트롤러가 /reservaitionSummary.xml 요청을 받았고 핸들러 메소드는 처리를 마치고 reservation 이라는 논리 뷰로 제어권을 넘긴다고 하자. 바로 이 시점에 스프링 MVC 리졸버가 개입하는데, ContentNegotiatingViewResolver 가 우선순위가 가장 높아 1번 타자가 된다.
ContentNegotiatingViewResolver 가 미디어 타입을 결정하는 과정은 다음과 같다.
요청 경로에 포함된 확장자를 ContentNegotiationManager 빈 구성 시 지정한 mediaTypes 맵을 이용해 기본 미디어 타입과 비교한다.
그래서 /reservationSummary.xml 요청은 1. 에서 미디어 타입이 application/xml 로 결정되고, /reservationSummary 요청은 Accept 헤더값까지 확인되어야 한다.
이와 같은 프로세스 덕분에 ContentNegotiatingViewResolver 는 이름이 같은 논리 뷰가 여럿 있어도 제각기 지원하는 미디어 타입에 가장 부합하는 뷰를 해석할 수 있다. 어떤 미디어 타입을 생성할 때 필요한 논리 뷰를 일일이 하드코딩하지 않고도 뷰 하나만 있으면 이 리졸버가 가장 적합한 뷰를 알아서 찾아주기 때문에 컨트롤러 설계를 간소화할 수 있다.
8. 뷰에 예외 매핑
서버에서 예기치 않은 예외가 발생하면 대개 페이지에 예외 스택 트레이스가 뿌려진다. HTTP 에러가 발생하거나 클래스 예외가 발생했을 때 JSP 안내 페이지를 보이도록 web.xml 파일을 설정할 수도 있으나, 스프링 MVC 는 클래스 예외 처리용 뷰를 관리하는 기능을 지원한다.
스프링 MVC 어플리케이션에서 컨텍스트 레벨에 ExceptionResolver 빈을 하나 이상 등록하면 디스패처 서블릿이 자동으로 감지해 예외가 발생했을 때 처리할 수 있도록 한다.
코트 예약 서비스에서 예약이 불가한 경우 다음 예외를 발생시키려 한다.
public class ReservationNotAvailableException extends RuntimeException {
private String courtName;
private Date date;
private int hour;
// Constructor, Getter, Setter
}
코드에서 처리되지 않은 예외는 HandlerExceptionResolver 인터페이스를 구현한 커스텀 예외 리졸버로 해석이 가능하다. 보통 예외 카테고리별로 각각의 에러 페이지를 매핑한다. 스프링 MVC 에 탑재된 예외 리졸버 SimpleMappingExceptionResolver 를 이용하면 웹 어플리케이션 컨텍스트에서 발생한 예외를 매핑할 수 있다.
@Configuration
public class ExceptionHandlerConfiguration implements WebMvcConfigurer {
@Override
public void configureHandlerExceptionResolvers(
List<HandlerExceptionResolver> exceptionResolvers) {
exceptionResolvers.add(handlerExceptionResolver());
}
@Bean
public HandlerExceptionResolver handlerExceptionResolver() {
Properties exceptionMapping = new Properties();
exceptionMapping.setProperty(ReservationNotAvailableException.class.getName(),
"reservationNotAvailable");
SimpleMappingExceptionResolver exceptionResolver = new SimpleMappingExceptionResolver();
exceptionResolver.setExceptionMappings(exceptionMapping);
exceptionResolver.setDefaultErrorView("error");
return exceptionResolver;
}
}
위의 코드에서 ReservationNotAvailableException 예외 클래스를 reservationNotAvailable 논리 뷰에 매핑했다. exceptionMapping 프로퍼티에 예외 클래스를 지정해 처리할 예외 클래스를 추가하고, defaultErrorView 프로퍼티로 exceptionMappings 에 매핑되지 않은 예외가 발생하면 표시할 기본 뷰 이름을 설정할 수 있다.
@ExceptionHandler 로 예외 매핑
HandlerExceptionResolver 를 구성하지 않고 메소드에 @ExceptionHandler 를 붙여 예외 핸들러를 매핑하는 방법도 있다. 작동 원리는 @RequestMapping 과 비슷하다.
@Controller
@ReqeustMapping("/reservationForm")
public class ReservationFormController {
...
@ExceptionHandler(ReservationNotAvailableException.class)
public String handle(ReservationNotAvailableException ex) {
return "reservationNotAvailable";
}
@ExceptionHandler
public String handleDefault(Exception e) {
return "error";
}
}
메소드 handle() 은 ReservationNotAvailableException 예외를 처리하는 전용 메소드고 handleDefault() 는 그 외 모든 예외를 처리하는 메소드이다. 이제 더이상 ExceptionHandlerConfiguration 구성 클래스에서 HandlerExceptionResolver 를 정의할 필요가 없다.
@ExceptionHandler 메소드는 여러 타입을 반환할 수 있다. 예제에서는 단순히 렌더링할 뷰 이름을 반환했지만 ModelAndView, View 등 객체도 반환이 가능하다.
이 메소드는 매우 강력하고 유연하지만 자신이 속해 있는 컨트롤러 내부에서만 작동하기 때문에 다른 컨트롤러에서 예외가 발생하면 호출되지 않는 문제가 있다. 따라서 범용적인 예외 처리 메소드는 별도 클래스로 빼내 클래스 레벨에 @ControllerAdvice 를 붙인다.
@ControllerAdvice
public class ExceptionHandlingAdvice {
@ExceptionHandler
public String handleDefault(Exception e) {
return "error";
}
}
9. 컨트롤러에서 폼 처리하기
유저가 폼과 상호작용할 때 컨트롤러는 보통 두 가지 일을 반드시 수행한다.
폼에 대한 GET 요청이 발생하면 컨트롤러는 폼 뷰를 렌더링해 화면에 표시한다.
유저가 POST 요청을 통해 폼을 전송하면 폼 데이터를 검증 (Validation) 후 요건에 맞게 처리한다.
이후 폼 처리에 성공하면 유저에게 성공 뷰를 보여주고, 폼 처리에 실패하면 원래 폼에 에러 메시지를 표시한다.
다음 코드는 간단한 폼 뷰이다. 스프링의 폼 태그 라이브러리를 사용하면 폼 데이터 바인딩과 에러 메시지의 표시를 비롯해 에러 발생 시 유저가 처음 입력했던 값을 다시 보여주는 등의 작업을 간편하게 처리할 수 있다.
스프링 <form:form> 태그에는 두 가지 속성이 있다. method="post" 는 폼 전송 시 POST 요청을 하겠다는 의미이고, modelAttribute="reservation" 은 폼 데이터를 reservation 이라는 모델에 바인딩하겠다는 의미이다.
<form:form> 태그는 유저에게 전송되기 전 표준 HTML 코드로 렌더링된다는 사실을 기억해야 한다. modelAttribute="reservation" 은 브라우저가 쓰는 코드가 아니라, HTML 폼을 실제로 생성하는 데 필요한 편의 기능이다.
<form:errors> 태그는 전송된 폼이 컨트롤러가 정한 규칙을 위반했을 때 에러 메시지를 표시할 폼 위치를 지정한다. path="*" 의 와일드카드는 모든 에러를 표시한다는 뜻이고, cssClass="error" 는 에러 표시에 사용할 CSS 클래스이다.
폼 처리 서비스 작성
유저가 폼에 기재한 예약 데이터는 컨트롤러가 아니라 컨트롤러가 호출하는 서비스가 처리한다. 먼저 서비스 인터페이스 ReservationService 에 make() 메소드를 다음과 같이 정의한다. 예약이 중복되면 ReservationNotAvailableException 예외가 발생하도록 한다.
public class ReservationService {
@Override
public void make(Reservation reservation) throws ReservationNotAvailableException {
long cnt = reservations.stream()
.filter(made -> Objects.equals(made.getCourtName(), reservation.getCourtName()))
.filter(made -> Objects.equals(made.getDate(), reservation.getDate()))
.filter(made -> made.getHour() == reservation.getHour())
.count();
if (cnt > 0) {
throw new ReservationNotAvailableException(reservation.getCourtName(),
reservation.getDate(), reservation.getHour());
} else {
reservations.add(reservation);
}
}
}
폼 컨트롤러 작성
@Controller
@RequestMapping("/reservationForm")
@SessionAttributes("reservation")
public class ReservationFormController {
private final ReservationService reservationService;
// Constructor
@GetMapping
public String setupForm(Model model) {
Reservation reservation = new Reservation();
model.addAttribute("reservation", reservation);
return "reservationForm";
}
@PostMapping
public String submitForm(@ModelAttribute("reservation") Reservation reservation,
BindingResult result, SessionStatus status) {
reservationService.make(reservation);
return "redirect:reservationSuccess";
}
}
브라우저에서 URL 로 접속하면 브라우저는 웹 어플리케이션에 GET 요청을 하고, @RequestMapping 설정 내용에 따라 이를 setupForm() 메소드가 받아 처리한다.
setupForm() 메소드는 Model 객체를 입력 매개변수로 받아 이 객체는 모델 데이터를 담아 뷰에 보낼 떄도 쓰인다. 핸들러 메소드는 비어 있는 Reservation 객체를 하나 만들어 컨트롤러의 Model 객체 속성으로 추가한다. 컨트롤러가 reservationForm 뷰로 실행 흐름을 넘기면 reservationForm.jsp 로 해석될 것이다.
submitForm() 메소드에서 가장 눈여겨 봐야 할 대목은 비어 있는 Reservation 객체를 추가하는 부분이다. reservationForm.jsp 폼에서 <form:form> 태그에서 modelAttribute="reservation" 속성을 선언했다. 따라서 뷰를 렌더링할 때 이 폼은 핸들러 메소드가 Model 객체 속으로 reservation 이라는 이름의 객체를 넣었다고 간주한다.
컨트롤러 클래스에 붙인 @SessionAttributes("reservations") 도 짚고 넘어가야 할 어노테이션이다. 폼에 에러가 날지도 모르는 상황에서 유저가 전에 입력한 유효한 데이터를 또다시 입력하게 만들면 유저 입장에서 매우 불편할 것이다. 그래서 @SessionAttributes 로 reservation 필드를 유저 세션에 보관했다가 폼을 여러 차례 재전송해 동일한 레퍼런스를 참조해 필요한 데이터를 가져오려는 것이다.
폼을 전송하는 부분에서, 유저가 폼 필드를 입력하고 폼을 전송하면 서버에 POST 요청을 하게 되고 @PostMapping 을 붙인 submitForm() 메소드가 호출된다. 이 메소드에는 세 개의 매개변수가 선언되어 있는데, 첫 번쨰 Reservation 은 reservation 객체를 참고한다고 선언한 것이다. BindingResult 객체는 유저가 전송한 데이터가 담기고, SessionStatus 객체는 처리가 다 끝나면 완료 표시 후, HttpSession 에서 Reservation 객체를 삭제하는 용도로 쓰인다.
submitForm() 메소드가 반환하는 redirect:reservationSuccess 뷰에서 redirect: 접두어는 중복된 폼 전송 문제 (폼 처리 다음 페이지를 리다이렉트가 아니라 포워드로 하게 될 경우, 먼저 전송했던 값이 폼에 그대로 남아있어 페이지를 새로고침하면 폼이 한 번 더 전송되는 문제) 를 방지하는 장치이다.
모델 속성 객체의 초기화와 폼 값 미리 채우기
Reservation 도메인 클래스에는 예약을 한 사용자의 정보를 담은 player 필드가 포함되어 있다. reservationForm 입력 시 이를 같이 입력받을 수 있지만, 위의 예제에서 보인 바와 같이 reservation 객체를 새로 생성한 경우 player 속성이 null 이므로 폼 렌더링 시 오류가 발생한다. 다음과 같이 Reservation 객체를 뷰에 건네주기 전, 비어 있는 Player 객체를 미리 초기화해 할당하면 문제를 해결할 수 있다.
@GetMapping
public String setupForm(@RequestParam(required = false, value = "username") String username,
Model model) {
Reservation reservation = new Reservation();
reservation.setPlayer(new Player(username, null));
model.addAttribute("reservation", reservation);
return "reservationForm";
}
폼에 레퍼런스 데이터 제공
폼 컨트롤러가 뷰에 전달하는 데이터 중에는 참조형 데이터 (예: HTML 셀렉트 박스에 표시되는 항목) 도 있다. 유저가 코트를 예약할 때 셀렉트 박스에서 종목을 선택할 것이다.
<form:select> 태그는 컨트롤러에서 전달받은 값 목록을 셀렉트 박스의 형태로 렌더링한다. 그 결과로 유저는 텍스트를 자유롭게 입력할 수 있는 input 대신, select 옵션 값 중 하나를 고르는 sportType 필드가 추가된다.
컨트롤러가 sportType 필드를 모델 속성에 할당하는 것은 앞서 입력한 input 필드와는 과정이 다르다. 일단 ReservationService 클래스에 선택 가능한 종목 모두를 조회하는 getAllSportTypes() 메소드를 다음과 같이 정의한다. 편의를 위해 종목 리스트는 하드코딩해서 리스트로 반환한다.
public class ReservationService {
...
private static final SportType TENNIS = new SportType(1, "Tennis");
private static final SportType SOCCER = new SportType(2, "Soccer");
public List<SportType> getAllSportTypes() {
return Arrays.asList(TENNIS, SOCCER);
}
}
서비스가 반환한 SportType 객체 리스트는 다음과 같이 컨트롤러에서 엮어 폼 뷰에 반환한다.
public class ReservationFormController {
...
@ModelAttribute("sportTypes")
public List<SportType> populateSportTypes() {
return reservationService.getAllSportTypes();
}
}
Reservation 객체를 폼 뷰에 반환하는 setupForm() 메소드는 그대로 두고 SportType 객체 리스트를 반환하는 메소드를 하나 추가했다. populateSportTypes() 메소드에 붙인 @ModelAttribute("sportTypes") 는 모든 핸들러 메소드가 반환하는 뷰에서 공통적으로 쓸 수 있는 전역 모델 속성을 정의하는 어노테이션이다. 핸들러 메소드가 Model 객체를 입력 매개변수로 선언하고 반환 뷰에서 액세스할 속성을 이 객체에 할당하는 것과 같은 방식이다. 메소드가 반환한 List<SportType> 이 sportTypes 모델 속성에 할당되고, 이 모델 속성에 추가된 값을 이용해 뷰는 <form:select> 태그로 셀렉트 박스를 렌더링하는 것이다.
커스텀 타입 프로퍼티 바인딩
폼 전송 시 컨트롤러는 폼 필드값을 같은 이름의 모델 객체의 프로퍼티로 바인딩한다. 하지만 커스텀 타입 프로퍼티는 별도의 프로퍼티 편집기를 설정하지 않는 한 컨트롤러가 알아서 변환할 도리가 없다.
<select> 필드의 작동 원리상 종목 셀렉트 박스에서 실제로 서버에 전송되는 데이터는 유저가 선택한 종목의 ID 뿐이다. 따라서 종목 ID 를 SportType 객체로 변환하려면 프로퍼티 편집기가 필요하다. 종목 ID 에 대응되는 SportType 객체를 조회하는 getSportType() 메소드를 ReservationService 에 정의하자.
public class ReservationService {
public SportType getSportType(int sportTypeId) {
switch (sportTypeId) {
case 1:
return TENNIS;
case 2:
return SOCCER;
default:
return null;
}
}
}
다음은 종목 ID 를 SportType 객체로 변환하는 SportTypeConverter 클래스이다. 이 변환기는 ReservationService 를 호출해 SportType 객체를 찾는다.
public class SportTypeConverter implements Converter<String, SportType> {
@Autowired
private ReservationService reservationService;
@Override
public SportType conver(String source) {
int sportTypeId = Integer.parseInt(source);
return reservationService.getSportType(sportTypeId);
}
}
이제 폼 프로퍼티를 SportType 같은 커스텀 클래스로 바인딩하는 데 필요한 컨버터 클래스와 컨트롤러를 연관지을 차례이다. WebMvcConfigurer 의 addFormatters() 메소드가 이런 상황에 사용된다.
@Configuration
@EnableWebMvc
public class WebConfiguration implements WebMvcConfigurer {
@Autowired
private ReservationService reservationService;
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new SportTypeConverter(reservationService));
}
}
addFormatters() 메소드는 reservationService 필드를 이용해 SportTypeConverter 변환기 객체를 생성한 뒤, 자신의 매개변수로 받은 FormatterRegistry 객체에 추가한다.
폼 데이터 검증
폼이 전송되면 처리하기 전 유저가 입력한 데이터가 올바른지 검사해야 한다. 스프링 MVC 는 Validator 인터페이스를 구현한 검증기 객체를 지원한다. 다음 코드는 필수 입력 필드를 모두 기재했는지, 예약한 시간이 운영 시간 이내인지 체크하는 검증기이다.
@Component
public class ReservationValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Reservation.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "courtName",
"required.courtName", "Court name is required.");
ValidationUtils.rejectIfEmpty(errors, "date",
"required.date", "Date is required.");
ValidationUtils.rejectIfEmpty(errors, "hour",
"required.hour", "Hour is required.");
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "player.name",
"required.playerName", "Player name is required.");
ValidationUtils.rejectIfEmpty(errors, "sportType",
"required.sportType", "Sport type is required.");
Reservation reservation = (Reservation) target;
LocalDate date = reservation.getDate();
int hour = reservation.getHour();
if (date != null) {
if (date.getDayOfWeek() == DayOfWeek.SUNDAY) {
if (hour < 8 || hour > 22) {
errors.reject("invalid.holidayHour", "Invalid holiday hour.");
}
} else {
if (hour < 9 || hour > 21) {
errors.reject("invalid.weekdayHour", "Invalid weekday hour.");
}
}
}
}
}
필수 입력 필드의 기재 여부는 ValidationUtils 클래스의 rejectIfEmptyOrWhitespace(), rejectIfEmpty() 등의 유틸리티 메소드로 조사해 하나라도 값이 비어있으면 필드 에러를 만들어 해당 필드에 바인딩한다. 이들 메소드의 두 번째 인자는 프로퍼티명, 세 번째, 네 번째 인자는 각각 에러 코드 및 기본 에러 메시지이다.
밑의 코드는 유저가 예약 신청한 시간이 운영 시간 이내인지 체크한다. 예약 시간이 올바르지 않으면 reject() 메소드로 에러를 발생시키고 이번엔 필드가 아닌 Reservation 객체에 바인딩한다.
검증기를 위와 같이 정의한 후 컨트롤러를 다음과 같이 수정해 검증기를 적용한다.
public class ReservationFormController {
private ReservationService reservationService;
private ReservationValidator reservationValidator;
...
@PostMapping
public String submitForm(@ModelAttribute("reservation") @Validated Reservation reservation,
BindingResult result, SessionStatus status) {
if (result.hasErrors()) {
return "reservationForm";
} else {
reservationService.make(reservation);
return "redirect:reservationSuccess";
}
}
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.setValidator(reservationValidator);
}
}
먼저 검증기 빈 인스턴스를 액세스하기 위해 reservationValidator 필드를 선언한다.
폼 전송 시 호출하는 POST 메소드도 수정해야 한다. @ModelAttribute 다음에 @Validated 를 나란히 적어 검증 대상 매개변수임을 명시한다. 검증 결과는 모두 BindingResult 객체에 저장되므로 조건문에서 result.hasErrors() 에 따라 분기 처리한다.
폼 처리 도중 에러가 발생하면 메소드 핸들러는 reservationForm 뷰를 반환하고 유저는 동일한 폼에서 오류를 전송한 뒤 다시 전송할 것이다. 에러가 없으면 예약 처리한 후 성공 뷰로 리다이렉트한다.
@InitBinder 를 붙인 메소드는 setValidator() 메소드를 호출해 검증기를 등록한다. 검증기는 바인딩 이후 사용할 수 있게 WebDataBinder 에 설정한다. 검증기 인스턴스를 가변 인수로 여럿 취하는 addValidators() 메소드를 싸용하면 여러 검증기를 한 번에 등록할 수 있다.
NOTE. WebDataBinder 는 형변환 용도로 PropertyEditor, Converter, Formatter 인스턴스를 추가할 때에도 사용한다. 덕분에 전역 PropertyEditor, Converter, Formatter 를 등록하지 않아도 된다.
컨트롤러의 세션 데이터 만료시키기
폼이 여러 번 전송되거나 유저 입력 데이터가 유실되지 않게 하기 위해 컨트롤러에 @SessionAttributes 를 붙여 사용한다. 이렇게 하면 여러 요청을 거치더라도 Reservation 객체 형태의 예약 필드를 참조할 수 있다.
하지만 폼 전송 후 예약을 성공적으로 완료한 이후까지 Reservation 객체를 세션에 보관할 이유는 없다. 유저가 아주 짧은 기간 내에 폼 페이지를 재방문할 경우, 미처 삭제되지 않은 객체의 찌꺼기 데이터가 노출될 수 있기 때문이다.
@SessionAttributes 로 세션에 추가된 값은 SessionStatus 객체로 지운다. 이 객체는 핸들러 메소드의 입력 매개변수로 가져올 수 있다.
@PostMapping
public String submitForm( ... ) {
if (result.hasErrors()) {
...
} else {
...
status.setComplete();
return ...;
}
}
세션에 추가된 값의 삭제는 SessionStatus 객체의 setComplete() 메소드를 호출해서 수행할 수 있다. 위의 submitForm() 메소드에 setComplete() 호출만 추가하면 세션 데이터를 만료시킬 수 있다.
스프링 MVC 어플리케이션에서 유저 로케일은 LocaleResolver 인터페이스를 구현한 로케일 리졸버가 식별한다. 로케일을 해석하는 기준에 따라 여러 LocaleResolver 구현체가 스프링 MVC 에 존재한다. 이러한 구현체를 사용해도 되고, 직접 이 인터페이스를 구현해 커스텀 로케일 리졸버를 만들어도 된다.
로케일 리졸버는 어플리케이션 컨텍스트에 LocaleResolver 형 빈으로 등록한다. 디스패처 서블릿이 이를 자동 감지하려면 로케일 리졸버 빈의 이름을 localeResolver 이라 명명한다. 참고로 로케일 리졸버는 디스패처 서블릿 하나 당 하나만 등록이 가능하다.
HTTP 요청 헤더에 따른 로케일 해석
AcceptHeaderLocaleResolver 는 스프링의 기본 로케일 리졸버로 accept-language 요청 헤더값에 따라 로케일을 해석한다. 유저 웹 브라우저는 자신을 실행한 운영체제의 로케일 설정으로 이 헤더를 설정한다.
유저 운영체제의 로케일 설정을 변경하는 것은 불가능하기 때문에, 로케일 리졸버로 유저 로케일을 변경하는 것 또한 불가능하다.
세션 속성에 따른 로케일 해석
SessionLocaleResolver 는 유저 세션에 사전 정의된 속성에 따라 로케일을 해석한다. 세션 속성이 없을 경우 accept-language 헤더로 기본 로케일을 결정한다.
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver localeResolver = new SessionLocaleResolver();
localeResolver.setDefaultLocale(new Locale("en"));
return localeResolver;
}
로케일 관련 세션 속성이 없는 경우 setDefaultLocale() 메소드로 대체 프로퍼티 defaultLocale 을 설정할 수 있다. 이 LocaleResolver 는 로케일이 저장된 세션 속성을 변경함으로써 유저 로케일을 변경한다.
쿠키에 따른 로케일 해석
CookieLocaleResolver 는 유저 브라우저의 쿠키값에 따라 로케일을 해석한다. 해당 쿠키가 없을 경우 SessionLocaleResolver 와 마찬가지로 accept-language 헤더로 기본 로케일을 결정한다. 쿠키 설정은 cookieName, cookeMaxAge 프로퍼티로 커스터마이징할 수 있다. cookieMaxAge 는 쿠키를 유지할 시간(초) 이며 -1 은 브라우저 종료와 동시에 쿠키를 삭제하라는 뜻이다.
@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver localeResolver = new CookieLocaleResolver();
localeResolver.setCookieName("language");
localeResolver.setCookieMaxAge(3600);
localeResolver.setDefaultLocale(new Locale("en"));
return localeResolver;
}
관련 쿠키가 존재하지 않으면 setDefaultLocale() 메소드로 대체 프로퍼티 defaultLocale 을 설정할 수 있다. 이 LocaleResolver 는 로케일이 저장된 쿠키값을 변경함으로써 유저 로케일을 변경한다.
유저 로케일 변경
LocaleResolver.setLocale() 메소드를 호출해 유저 로케일을 명시적으로 변경할 수도 있지만, LocaleChangeInterceptor 를 핸들러 매핑에 적용할 수도 있다. 이 인터셉터의 특기는, 현재 HTTP 요청에 특정 매개변수가 존재하는 지 감지하는 일이다. 특정 매개변수의 이름은 이 인터셉터의 paramName 프로퍼티값으로 지정하며 그 값으로 유저 로케일을 변경할 수 있다.
@Configuration
public class I18NConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
@Bean
public LocaleChangeInterceptor localgeChangeInterceptor() {
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("langauge");
return localeChangeInterceptor;
}
}
※ 위의 구성 클래스를 적용한 뒤, HTTP 요청 시 Parameter 에 language 속성값을 지정했음에도 제대로 로케일 변경이 적용되지 않았다. 그 이유에 대해 찾아보려 했는데, 몇 가지 설정값을 변경하다 보니 로케일 리졸버는 정상적으로 동작하고 있음을 확인했다. 그렇다면 LocaleChangeInterceptor 가 제대로 동작을 하지 못하고 있다는 뜻이었는데, 해서 몇 가지 자료를 찾아보다 다음의 링크에서 해결법을 찾았다.
LocaleChangeInterceptor 가 제대로 동작하지 않는 이유는 아직 제대로 확인하지 못했지만, 위의 질문에서 찾은 이 문제에 대한 해결 방법은 LocaleChangeInterceptor 를 사용하지 않고, 로케일 리졸버에서 HTTP 요청 속성값을 읽어 이를 바로 적용하는 것이었다.
이렇게 구성하면 요청 URL 의 lang 매개변수를 이용해 유저 로케일을 바꿀 수 있다. 예를 들어, 영어-미국 (en_US), 독일어 (de) 로케일로 바꾸려면 다음 URL 로 접속한다.
http://localhost:8080/welcome?lang=en_US
http://localhost:8080/welcome?lang=de
5. 로케일별 텍스트 메시지 외부화하기
다국어를 지원하는 웹 어플리케이션은 유저가 원하는 로케일로 웹 페이지를 보여줘야 한다. 로케일마다 페이지를 따로 두는 삽질을 하지 않으려면 로케일 관련 텍스트 메시지를 외부화해서 웹 페이지를 로케일에 독립적으로 개발해야 한다. 스프링은 MessageSource 인터페이스를 구현한 메시지 소스로 텍스트 메시지를 해석할 수 있다. JSP 파일에서 스프링 태그 라이브러리 <spring:message> 태그를 사용하면 원하는 코드에 맞게 해석된 메시지가 화면에 출력된다.
웹 어플리케이션 컨텍스트에 메시지 소스를 MessageSource 형 빈으로 등록한다. 이 빈을 messageSource 로 명명하면 디스패처 서블릿이 자동으로 감지하며 디스패처 서블릿 당 하나의 메시지 소스만 등록할 수 있다. ResourceBundleMessageSource 구현체는 로케일마다 따로 배치한 리소스 번들을 이용해 메시지를 해석한다. 다음과 같이 WebConfiguration 에 ResourceBundleMessageSource 구현체를 등록하면 basename 이 messages 인 리소스 번들을 로드한다.
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
return messageSource;
}
각각 기본 로케일과 독일 로케일 메시지를 담아둘 리소스 번들 messages.properties, messages_de.properties 파일을 작성해 클래스패스 루트에 둔다.
// messages.properties
welcome.title=Welcome
welcome.message=Welcome to Court Reservation System
// messages_de.properties
welcome.title=Willkommen
welcome.message=Willkommen zum Spielplatz-Reservierungssytem
이제 welcome.jsp 파일에서 <spring:message> 태그를 사용하면 주어진 코드에 해당하는 메시지를 해석해 보여줄 수 있다. 이 태그는 현재 유저 로케일을 기준으로 메시지를 자동 해석한다. 스프링 태그 라이브러리에 포함된 태그라서 꼭 JSP 파일 최상단에 선언해야 한다.
DispatcherServlet 은 사용자로부터 웹 요청을 받는 즉시 @Controller 가 달린 적합한 컨트롤러 클래스에 요청의 처리를 위임한다. 처리를 위임하는 과정은 컨트롤러 클래스의 핸들러 메소드에 선언된 다양한 @RequestMapping 설정 내용에 따라 좌우된다.
핸들러는 컨텍스트 경로 (웹 어플리케이션 컨텍스트의 배포 경로) 에 대한 상대 경로 및 서블릿 경로 (디스패처 서블릿 매핑 경로) 에 맞게 연결된다. 예를 들어 "http://localhost:8080/court/welcome" URL 로 요청이 들어오면, 컨텍스트 경로는 /court, 서블릿 경로는 없으니 (CourtWebApplicationInitializer 에서 서블릿 경로는 "/" 로 선언했다) /welcome 에 맞는 핸들러를 찾는다.
@RequestMapping 을 가장 쉽게 사용하는 방법은 핸들러 메소드에 직접 어노테이션을 붙여 URL 패턴을 기재하는 것이다. 디스패처 서블릿은 이 어노테이션에 기재한 요청 URL 과 가장 맞는 핸들러에 처리를 위임하게 된다.
@Controller
public class MemberController {
private Memberservice memberService;
// Constructor
@RequestMapping("/member/add")
public String addMember(Model model) {
model.addAttribute("member", new Member());
model.addAttribute("guests", memberService.list());
return "memberList";
}
@RequestMapping(value = {"/member/remove", "/member/delete"},
method = RequestMethod.GET)
public String removeMember(@RequestParam("memberName") String memberName) {
memberService.remove(memberName);
return "redirect:";
}
}
removeMember() 메소드의 @RequestMapping value 속성 값처럼 URL 을 여럿 할당하게 되면, /member/remove, /member/delete 둘 다 이 메소드에 매핑된다. 별다른 설정이 없으면 @RequestMapping 으로 명시한 메소드들은 모두 HTTP GET 방식의 요청에 매핑되는 것으로 간주한다.
클래스에 따른 요청 매핑
@RequestMapping 은 핸들러 메소드 뿐 아니라 컨트롤러 클래스 자체에도 붙일 수 있다. 4장에서 조금 더 자세히 설명하겠지만, @RequestMapping 을 클래스 레벨에 붙이게 되면 그 클래스에 속한 전체 메소드에 어노테이션을 일일이 붙이지 않아도 된다. 또 메소드마다 나름의 @RequestMapping 을 적용함으로써 URL 을 좀 더 세세하게 적용할 수 있다. URL 을 폭넓게 매치하려면 @RequestMapping 에 와일드카드(*) 를 사용한다.
다음 예제 코드에서는 URL 와일드카드를 어떻게 쓰는지, 핸들러 메소드에서 URL 을 세세하게 매치하기 위해 설정하는 방법 등을 볼 수 있다.
@Controller
@RequestMapping("/member/*")
public class MemberController {
private final MemberService memberService;
// Constructor
@ReqeustMapping("add")
public String addMember(Model model) { ... }
@RequestMapping(value = {"remove", "delete"}, method = RequestMethod.GET)
public String removeMember(@RequestParam("memberName") String memberName) { ... }
@RequestMapping("display/{member}")
public String displayMember(@PathVariable("member" String member, Model model) {
model.addAttribute("member", memberService.find(member).orElse(null));
return "member";
}
@RequestMapping
public void memberList() {}
public void memberLogic(String memberName) {}
}
컨트롤러 클래스 레벨의 @RequestMapping 에 와일드카드가 포함된 URL 이 있으므로 /member/ 로 시작하는 URL 은 모두 이 컨트롤러의 핸들러 메소드 중 하나로 매핑된다.
HTTP GET /member/add 를 요청하면 addMember() 메소드가, HTTP GET /member/remove (/member/delete) 를 요청하면 removeMember() 메소드가 각각 호출된다.
displayMember() 메소드의 @RequestMapping 설정에 쓴 "{PathVariable}" 형태의 독특한 표기법은 URL 안에 포함된 값을 핸들러 메소드의 입력 매개변수값으로 전달한다는 의미이다. 그래서 핸들러 메소드의 인자에도 @PathVariable("user") String user 라고 선언되어 있다. 요청 URL 이 /member/display/jdoe 이면 핸들러 메소드의 인자는 "jdoe" 로 설정될 것이다. 핸들러에서 요청 객체를 살펴볼 필요가 없으니 꽤 편리한 기능이고 특히 RESTful Web Service 를 설계할 때 유용한 기법이다.
memberList() 메소드에도 @RequestMapping 이 있지만 특정된 URL 이 없다. 클래스 레벨에 이미 /member/* 라는 와일드카드를 쓴 URL 이 있으므로 다른 메소드에 걸리지 않은 요청은 모두 이 메소드에 매핑된다. 예를 들어, /member/abcdef 또는 /member/random 등 어떤 URL 에 대한 요청도 모두 이 메소드를 거쳐 간다. 반환값은 void 이기 때문에, 앞선 포스팅에서 설명했듯 memberList() 메소드는 자신의 URL 주소와 같은 뷰 (즉, member) 로 제어권을 넘긴다.
memberLogic() 메소드는 @RequestMapping 이 달려있지 않은, 이 클래스 내부에서만 사용되는 유틸리티 메소드이다.
HTTP 요청 메소드에 따른 요청 매핑
기본적으로 @RequestMapping 은 모든 HTTP 요청 메소드를 처리할 수 있지만 GET, POST 요청을 하나의 메소드가 받아 처리할 일은 거의 없을 것이다. HTTP 메소드 별로 요청을 따로 처리하려면 @RequestMapping 에 HTTP 요청 메소드를 명시한다.
HTTP 요청 메소드는 HEAD, GET, POST, PUT, DELETE, PATCH, TRACE, OPTIONS, CONNECT 까지 모두 여덟 가지이다. 그러나 이런 HTTP 요청 메소드는 웹 서버나 요청하는 클라이언트 측에서 모두 지원이 가능해야 하기 때문에 MVC 컨트롤러 영역 밖의 문제이다. HTTP 요청의 대다수가 GET/POST 인 사실을 감안하면 그 밖의 메소드를 지원할 일은 드문 편이다.
스프링 MVC 는 널리 사용되는 HTTP 요청 메소드를 위해 다음과 같이 전용 어노테이션을 지원한다.
요청 메소드
어노테이션
POST
@PostMapping
GET
@GetMapping
DELETE
@DeleteMapping
PUT
@PutMapping
이러한 편의성 어노테이션은 모두 @RequestMapping 을 특정한 것으로, 핸들러 메소드를 조금 더 간결하게 코딩할 수 있도록 한다.
@PostMapping("processUser")
public String submitForm() { ... }
위에서 @RequestMapping 에 지정한 URL 을 살펴보면 .html 이나 .jsp 같은 파일 확장자는 명시되어 있지 않다. 이는 MVC 설계 사상이 충실히 반영된 좋은 모양새라 할 수 있다.
컨트롤러는 HTML, JSP 같은 특정 뷰 구현 기술에 얽매이지 않고 작성이 되어야 한다. 컨트롤러에서 논리 뷰를 반환할 때 URL 에 확장자를 넣지 않은 것도 이 때문이라 할 수 있다.
3. 핸들러 인터셉트로 요청 가로채기
서블릿 명세에 정의된 서블릿 필터를 적용하면 웹 요청을 서블릿이 처리하기 전후에 각각 전처리와 후처리를 할 수 있다.
스프링 MVC 에서 웹 요청은 핸들러 인터셉터로 가로채 전처리/후처리를 적용할 수 있다. 핸들러 인터셉터는 스프링 웹 어플리케이션 컨텍스트에 구성하기 때문에 컨테이너의 기능을 자유롭게 활용할 수 있고, 내부에 선언된 모든 빈을 참조할 수 있다. 또한 인터셉터는 특정 URL 에 대한 요청에만 적용되도록 매핑할 수도 있다.
핸들러 인터셉터는 HandlerInterceptor 인터페이스를 구현해야 하며 preHandle(), postHandle(), afterCompletion() 세 개의 콜백 메소드를 구현한다. preHandle() 과 postHandle() 메소드는 핸들러가 요청을 처리하기 직전과 직후에 각각 호출된다. postHandle() 메소드는 핸들러가 반환한 ModelAndView 객체에 접근할 수 있기 때문에 그 안에 들어있는 모델 속성을 꺼내 조작할 수 있다. afterCompletion() 메소드는 요청 처리가 모두 끝난 (뷰 렌더링까지 완료된) 이후 호출된다.
스프링의 커스텀 핸들러 인터셉터는 다음과 같이 작성할 수 있다.
public class MeasurementInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
long startTime = (Long) request.getAttribute("startTime");
request.removeAttribute("startTime");
long endTime = System.currentTimeMillis();
modelAndView.addObject("handlingTime", endTime - startTime);
}
@Override
public void afterCompletion(HttpServletReqeust request, HttpServletResponse response,
Object handler, Exception e) throws Exception {}
}
디스패처 서블릿은 preHandle() 메소드가 true 를 반환해야만 요청 처리를 계속 진행하며 그 외에는 이 메소드 선에서 요청 처리가 끝났다 판단하고 유저에게 곧장 Response 객체를 반환한다. preHandle() 메소드는 요청 처리를 시작한 시각을 재서 요청 속성에 보관한다. postHandle() 메소드는 요청 속성에 보관된 시작 시각을 읽고 현재 시각과 비교해 요청 처리에 소요된 시간을 모델에 추가한 뒤 뷰에 넘긴다. afterCompletion() 메소드는 할 일이 없으니 비워둔다.
NOTE. 자바에서 인터페이스를 구현할 때는 원치 않는 메소드까지 모조리 구현해야 하는 규칙이 있다. 그래서 인터페이스를 구현하는 대신 인터셉터 어댑터 클래스를 상속받아 사용하는 것이 더 나을 수 있다. 인터셉터 어댑터는 인터페이스에 선언된 메소드를 모두 기본 구현한 클래스이므로 필요한 메소드만 오버라이드해서 사용할 수 있다.
※Java 8 부터는 인터페이스 작성 시 메소드에 default 키워드를 붙여 구현 코드를 직접 작성할 수 있다. 이렇게 작성한 메소드는 구현 클래스에서 오버라이드하지 않아도 된다.
위의 소스 코드에서 인터셉터 인터페이스를 구현하지 않고 인터셉터 어댑터 클래스를 상속하면 아래와 같이 작성할 수 있다.
public class MeasurementInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception { ... }
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception { ... }
}
위와 같이 작성한 커스텀 인터셉터는 다음과 같이 WebMvcConfigurer 인터페이스를 구현한 구성 클래스에서 addInterceptors() 메소드를 오버라이드함으로써 추가할 수 있다.
@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(measuremeantInterceptor());
}
@Bean
public MeasurementInterceptor measurementInterceptor() {
return new MeasurementInterceptor();
}
}
HandlerInterceptor 는 기본적으로 모든 @Controller 에 적용되지만 원하는 컨트롤러만 선택적으로 적용할 수도 있다. 네임스페이스 및 자바 구성 클래스를 이용해 인터셉트를 특정 URL 에 매핑할 수 있다.
@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(measuremeantInterceptor());
registry.addInterceptor(summaryReportInterceptor())
.addPathPatterns("/reservationSummary*");
}
@Bean
public MeasurementInterceptor measurementInterceptor() {
return new MeasurementInterceptor();
}
@Bean
public SummaryReportInterceptor summaryReportInterceptor() {
return new SummaryReportInterceptor();
}
}
위의 소스 코드에서 인터셉터 빈 summaryReportInterceptor 를 추가했다. measurementInterceptor 와 비슷하지만 /reservationSummary URL 에 매핑된 컨트롤러에만 인터셉터 로직이 적용되는 점이 다르다. 인터셉터를 등록할 때 매핑 URL 을 지정하면 되는데, 기본적으로 앤트 스타일의 표현식으로 작성하며 addPathPatterns() 메소드의 인자로 전달한다. 역으로 제외할 URL 이 있으면 같은 표현식을 excludePathPatterns() 메소드 인자로 지정한다.
서블릿이 무엇인지는 지난 포스팅 에서 살펴봤다. 이번엔 서블릿이 무엇인지 호기심을 갖게 한 스프링의 디스패처 서블릿에 대해서 살펴보려 한다.
디스패처 서블릿 (Dispatcher Servlet)
디스패처 서블릿에서 dispatch 는, 보내다라는 뜻을 가지고 있다. 이러한 단어를 포함하고 있는 디스패처 서블릿은 스프링 어플리케이션의 최전방에서 HTTP 프로토콜로 들어오는 모든 요청을 받아 적합한 컨트롤러에 위임하는 프론트 컨트롤러라 볼 수 있다.
보다 자세하게 설명을 하자면, 클라이언트로부터 어떤 요청이 오면 톰캣과 같은 서블릿 컨테이너가 요청을 받게 된다. 그리고 이 모든 요청을 프론트 컨트롤러인 디스패처 서블릿이 받아서 공통적인 작업을 수행한 뒤 해당 요청을 처리할 컨트롤러 빈을 getBean() 메소드로 호출해서 받아와 요청에 적합한 컨트롤러의 메소드를 실행시킨다. 예외가 발생했을 때 일관된 방식으로 처리하는 것 또한 프론트 컨트롤러인 디스패처 서블릿에서 담당하고 있다.
NOTE. 여기서 프론트 컨트롤러 (Front Controller) 라는 표현이 자주 사용되는데, 프론트 컨트롤러는 주로 서블릿 컨테이너의 제일 앞단에서 서버에 들어오는 클라이언트의 모든 요청을 받아 처리하는 컨트롤러로써, MVC 구조에서 함께 사용되는 디자인 패턴이다.
디스패처 서블릿의 장점
Spring MVC 는 디스패처 서블릿이 등장함에 따라 web.xml 의 역할을 상당히 축소시켰다. 기존에는 모든 서블릿에 대해서 URL 매핑을 하기 위해 web.xml 파일에 모두 등록해야 했지만, 디스패처 서블릿이 해당 어플리케이션으로 들어오는 모든 요청을 핸들링하고 공통 작업을 처리하면서 상당히 편리하게 이용이 가능해졌다.
디스패처 서블릿의 기능 처리를 표현하면 아래 그림과 같다.
디스패처 서블릿이 모든 요청을 받아 각각의 컨트롤러로 매핑해주는 방식은 효율적으로 보인다. 하지만 디스패처 서블릿이 모든 요청을 처리하다보니 이미지나 HTML 등과 같은 정적 리소스에 대한 요청까지도 모두 가로채 정적 리소스를 불러오지 못하는 상황도 발생하곤 했다.
이러한 문제를 해결하기 위해 개발자들은 두 가지 방안을 고안했는데, 그 방안은 다음과 같다.
1. 정적 리소스에 대한 요청과 어플리케이션에 대한 요청 분리
첫 번째 방안은, 클라이언트의 요청 자체를 두 개로 분리하는 것이다.
/apps 의 URL 로 접근할 경우 디스패처 서블릿이 처리를 담당
/resources 의 URL 로 접근할 경우 디스패처 서블릿이 컨트롤할 수 없는 요청이므로 담당하지 않음
이러한 방식은, 앞서 언급한 문제는 해결할 수 있지만 소스 코드가 상당히 지저분해지며 모든 요청에 대해 /apps 나 /resources URL 을 붙여야 하므로 직관적인 설계가 될 수 없다.
2. 어플리케이션에 대한 요청을 탐색하고, 없을 경우 정적 리소스에 대한 요청으로 처리
두 번째 방안은 모든 요청에 대해 디스패처 서블릿이 적합한 컨트롤러를 탐색하고, 해당 요청에 대한 컨트롤러를 찾을 수 없는 경우에 2차적으로 설정된 정적 리소스 경로를 탐색해 리소스를 찾는 방식이다. 이렇게 영역을 분리하면 효율적인 리소스 관리가 가능할 뿐 아니라 추후에 확장이 용이하다는 장점을 가지게 된다.
클라이언트의 요청을 처리하고 그 결과를 반환하는, Servlet 클래스의 구현 규칙을 지킨 자바 웹 프로그래밍 기술
서블릿은, 간단하게 말해 자바를 사용해 웹 애플리케이션을 만들기 위해 필요한 기술이다.
클라이언트가 어떤 요청을 했을 때 웹 서버는 그에 대응하는 응답을 다시 전송해야 하는데, 이러한 역할을 하는 자바 프로그램을 서블릿이라 한다.
예를 들어, 어떤 사용자가 로그인을 하려고 할 때, 사용자는 아이디와 패스워드를 입력한 뒤 로그인 버튼을 누른다. 그 때, 서버는 아이디와 패스워드를 확인하고 다음 페이지를 띄워줘야 하는데 이러한 역할을 수행하는 것이 바로 서블릿이다. 그래서 서블릿은 자바로 구현된 CGI 라 표현한다.
NOTE CGI (Common Gateway Interface) 란, 웹 서버에서 동적인 페이지를 보여 주기 위해 임의의 프로그램을 실행할 수 있도록 하는 기술이다. 특별한 라이브러리나 도구를 의미하는 것이 아라 별도로 제작된 웹서버와 프로그램 간의 교환방식이다. CGI 방식은 어떤 프로그래밍 언어로도 구현이 가능하며, 별도로 만들어 놓은 프로그램에 HTML 의 Get / Post 로 클라이언트의 데이터를 환경변수로 전달하고, 프로그램의 표준 출력 결과를 클라이언트에 전송하는 것이다.
Servlet 의 특징
클라이언트의 요청에 대해 동적으로 작동하는 웹 어플리케이션 컴포넌트
HTML 을 사용해 요청에 응답
Java Thread 를 이용해 동작
MVC 패턴에서 Controller 로 이용됨
HTTP 프로토콜 서비스를 지원하는 javax.servlet.http.HttpServlet 클래스를 상속받음
UDP 보다 처리 속도가 느림
HTML 변경 시 Servlet 을 재컴파일해야 함
일반적으로 웹서버는 정적인 페이지만을 제공한다. 그렇기 때문에 동적인 페이지를 제공하기 위해 웹 서버는 다른 곳에 도움을 요청하고 동적인 페이지를 작성해야 한다. 여기서 웹 서버가 동적인 페이지를 제공할 수 있도록 도와주는 어플리케이션이 서블릿이며, 동적인 페이지를 생성하는 어플리케이션을 CGI 라 한다.
Servlet 동작 방식
사용자가 URL 을 주소창에 입력하면 HTTP Request 가 서블릿 컨테이너로 전송된다
요청을 전송받은 서블릿 컨테이너는 HttpServletRequest, HttpServletResponse 객체를 생성한다
web.xml 을 기반으로 사용자가 요청한 URL 이 어떤 서블릿에 대한 요청인지 찾는다
해당 서블릿에서 service 메소드를 호출한 뒤 클라이언트의 HTTP 요청에 따라 doGet() 또는 doPost() 를 호출한다
doGet(), doPost() 메소드는 동적 페이지를 생성한 뒤 HttpServletResponse 객체에 응답을 전송한다.
응답이 끝나면 생성한 HttpServletRequest, HttpServletResponse 객체를 소멸시킨다.
서블릿 컨테이너 (Servlet Container)
서블릿 컨테이너는 서블릿에 대한 이해가 있다면 쉽게 이해할 수 있는데, 간단하게 말해 서블릿을 관리하는 컨테이너이다.
개발자가 서버에 서블릿을 생성했다고 이 서블릿이 알아서 동작하는 것이 아니라 서블릿을 관리해주는 것이 필요한데, 그러한 역할을 하는 것이 서블릿 컨테이너이다. 서블릿 컨테이너는 클라이언트의 요청을 받고 응답할 수 있도록 웹 서버와 소켓으로 통신하며, 대표적인 예로 아파치 톰캣이 있다. 톰캣은 실제로 웹 서버와 통신하며 JSP (Java Server Page) 와 서블릿이 통신하는 환경을 제공해준다.
서블릿 컨테이너의 역할
1. 웹 서버와의 통신 지원
서블릿 컨테이너는 서블릿과 웹 서버가 손쉽게 통신할 수 있도록 한다. 일반적으로 어플리케이션 간 통신은 소켓을 만들고 listen, connect, accept, send/recv 등의 작업이 필요하지만 서블릿 컨테이너는 이러한 기능을 API 로 제공해 복잡한 과정을 생략할 수 있도록 해 준다. 그럼으로써 개발자는 서블릿이 가져야 할 비즈니스 로직에만 집중해서 구현을 진행할 수 있도록 도와준다.
2. 서블릿 생명 주기 (Life Cycle) 관리
서블릿 컨테이너는 서블릿의 생성과 소멸을 관리한다. 서블릿 클래스를 로딩해 인스턴스화하고, 초기화 메소드를 호출하고, 요청이 들어오면 적절한 서블릿 메소드를 호출한다. 또한 서블릿이 소멸된 순간에는 적절하게 GC 를 수행해 개발자에게 편의를 제공한다.
3. 멀티쓰레드 지원 및 관리
서블릿 컨테이너는 사용자의 요청이 들어올 때마다 새로운 자바 쓰레드를 생성하는데, HTTP 서비스 메소드를 실행하고 난 뒤 쓰레드는 자동으로 소멸하게 된다. 원래는 이런 쓰레드의 생성과 소멸을 관리해야 하지만 서버가 다중 쓰레드를 생성 및 운영해주니 쓰레드의 안정성에 대해서는 걱정하지 않아도 된다.
4. 선언적인 보안 관리
서블릿 컨테이너를 사용하면 개발자는 보안에 관한 내용을 서블릿 또는 자바 클래스에 따로 구현하지 않아도 된다. 일반적으로 보안 관리는 XML 배포 서술지에 기록하는데, 보안에 대해 수정할 일이 생겨도 자바 소스 코드를 수정하여 다시 컴파일하지 않아도 보안 관리가 가능하다.
서블릿의 생명 주기
클라이언트의 요청이 들어오면, 서블릿 컨테이너 (이하 컨테이너) 는 해당 서블릿 인스턴스가 메모리에 있는 지 확인하고, 없을 경우 init() 메소드를 호출해 메모리에 적재한다. init() 메소드는 초기에 한 번만 실행되기 때문에 서블릿의 쓰레드에서 공통적으로 사용해야 할 것이 있다면 오버라이딩해 구현하면 된다. 실행 중 서블릿이 변경될 경우, 기존 서블릿을 파괴하고 init() 메소드를 다시 호출해 새로운 내용을 다시 메모리에 적재한다.
init() 이 호출된 후 클라이언트의 요청에 따라 service() 메소드를 통해 요청에 대한 응답이 doGet() 또는 doPost() 로 분기된다. 이 때, 서블릿 컨테이너가 클라이언트의 요청이 들어왔을 때 생성한 HttpServletRequest, HttpServletResponse 객체에 의해 request, response 객체가 제공된다.
컨테이너가 서블릿에 종료 요청을 보내면 destroy() 메소드가 호출되는데, init() 과 마찬가지로 한 번만 실행되며 종료 시에 처리해야 하는 작업들은 destroy() 메소드를 오버라이딩해 구현하면 된다.
JSP (Java Server Page)
서블릿에 대해서 정리를 했는데, 그러면 JSP 는 무엇이고 JSP 와 서블릿의 차이는 무엇일까?
구조적인 차이점을 통해 설명을 하자면, 서블릿은 자바 소스 코드 내에 HTML 코드가 들어가는 형태인데 반해 JSP 는 HTML 소스 코드 내에 자바 코드가 들어가는 구조를 갖는 웹 어플리케이션 프로그래밍 기술이다. HTML 코드 내에서 자바 코드는 <% (SourceCode) %> 또는 <%= (SourceCode) =%> 의 형태로 들어가게 된다.
자바 소스 코드로 작성된 이 부분은 웹 브라우저로 보내는 것이 아니라 웹 서버에서 실행되는 부분이다. 웹 프로그래머가 소스 코드를 수정하고자 할 때에도 디자인 부분을 제외하고 자바 소스 코드 부분만 수정하면 되기에 효율적이다. 또한 컴파일 과정이 필요치 않고 JSP 페이지를 작성해 웹 서버의 디렉토리에 추가만 하면 사용이 가능하다.
서블릿의 규칙은 꽤나 복잡하기 때문에 JSP 가 등장하게 되었는데, JSP 는 WAS (Web Application Server) 에 의해 서블릿 클래스로 변환되어 사용된다.
JSP 동작 구조
웹 서버가 사용자로부터 서블릿에 대한 요청을 받으면 서블릿 컨테이너에 요청을 전달한다. 요청을 받은 컨테이너가 HTTP Request / Response 객체를 만들어, 이들을 통해 서블릿의 doGet() / doPost() 메소드를 호출한다. 만약 서블릿만 사용해 사용자가 요청한 웹 페이지를 보여줄 경우 out 객체의 println 메소드를 사용해 HTML 문서를 작성해야 하는데, 이는 코드의 추가/수정을 어렵게 하고 가독성도 떨어지기 때문에 JSP 를 사용해 비즈니스 로직과 프레젠테이션 로직을 분리한다.
여기서 서블릿은 데이터의 입력, 수정 등에 대한 제어를 JSP 에 넘겨 프레젠테이션 로직을 수행한 후 컨테이너에게 Response 를 전달한다. 이렇게 만들어진 결과물은 사용자가 해당 페이지를 요청하면 컴파일되어 자바 파일을 통해 .class 파일이 만들어지고, 두 로직이 결합되어 클래스화 되는 것을 확인할 수 있다.
즉, out 객체의 println 메소드를 사용해 구현해야 하는 번거로움을 JSP 가 대신 수행해준다고 할 수 있다.
프론트 컨트롤러는 스프링 MVC 의 중심 컴포넌트이다. 아주 단순한 스프링 MVC 애플리케이션이라면 자바 웹 배포 서술자(web.xml 파일이나 ServletContainerInitializer) 에 프론트 컨트롤러의 서블릿만 구성하면 된다. 보통 디스패처 서플릿이라 일컫는 스프링 MVC 컨트롤러는 코어 자바 EE 패턴 중 하나인 프론트 컨트롤러 패턴을 구현한 것으로, MVC 프레임워크에서 모든 웹 요청은 반드시 디스패처 서블릿을 거쳐 처리된다.
스프링 MVC 애플리케이션에 들어온 웹 요청은 제일 먼저 컨트롤러가 접수하고 스프링 웹 애플리케이션 컨텍스트 또는 컨트롤러 자체에 붙인 어노테이션을 이용해 여러 컴포넌트를 조직한다. 위의 그림은 스프링 MVC 에서 요청을 처리하는 흐름이다.
스프링 컨트롤러 클래스에는 @Controller 또는 @RestController 를 붙인다. @Controller 를 붙인 클래스에 요청이 들어오면 스프링은 적합한 Handler Method 를 찾는다. 컨트롤러에는 요청을 처리할 메소드를 하나 이상 매핑하는데, 해당 메소드에 @RequestMapping 을 붙여 핸들러 메소드로 선언한다.
핸들러 메소드의 시그니처는 여느 자바 클래스처럼 정해진 규격이 존재하지 않는다. 메소드명을 임의로 정해도 되고 인자도 다양하게 정의할 수 있으며 어플리케이션 로직에 따라 어떤 값이라도 반환할 수 있다. 이해를 위해 올바른 인자형을 몇 가지 정리해보자면 다음과 같다.
HttpServletRequest 또는 HttpServletResponse
임의형 요청 매개변수 (@RequestParam 을 붙인다)
임의형 모델 속성 (@ModelAttribute 를 붙인다)
요청 내에 포함된 쿠키값 (@CookieValue 를 붙인다)
핸들러 메소드가 모델에 속성을 추가하기 위해 사용하는 Map 또는 ModelMap
핸들러 메소드가 객체 바인딩/유효성을 검증한 결과를 가져올 때 필요한 Errors 또는 BindingResult
핸들러 메소드가 세션 처리를 완료했음을 알릴 때 사용하는 SessionStatus
컨트롤러는 우선 적절한 핸들러 메소드를 선택하고 이 메소드에 요청 객체를 전달해 처리 로직을 실행한다. 대개 컨트롤러는 백엔드 서비스에 요청 처리를 위임하는 게 보통이고, 핸들러 메소드는 다양한 타입의 인자값에 어떤 정보를 더하거나 삭제해 스프링 MVC 의 흐름을 이어가는 형태로 구성한다.
핸들러 메소드는 요청 처리 후, 제어권을 뷰로 넘긴다. 제어권을 넘길 뷰는 핸들러 메소드의 반환값으로 지정하는데, 직접적인 뷰 구현체보다 파일 확장자가 없는 논리 뷰로 나타내는 편이 유연해서 좋다. 핸들러 메소드는 논리 뷰 이름에 해당하는 String 값을 반환하는 경우가 대부분이다. 반환값을 void 로 설정하면 핸들러 메소드나 컨트롤러 이름에 따라 기본적인 논리 뷰가 자동으로 결정된다.
뷰는 핸들러 메소드의 인자값을 얼마든지 가져올 수 있기 때문에 핸들러 메소드가 논리 뷰 이름을 반환할 경우에도 컨트롤러 → 뷰로 정보를 전달하는 데 아무 영향이 없다. 예를 들어 Map 과 SessionStatus 형 객체를 인자로 받은 핸들러 메소드가 이 내용을 수정해도 이 메소드가 반환하는 뷰에서 똑같이 수정된 객체를 바라볼 수 있다.
컨트롤러 클래스는 뷰를 받고 View Resolver (뷰 해석기)를 이용해 논리 뷰 이름을 실제 뷰 구현체로 해석한다. ViewResolver 인터페이스를 구현한 뷰 해석기는 웹 어플리케이션 컨텍스트에 빈으로 구성하며 논리 뷰 이름을 받아 실제 뷰 구현체를 돌려준다.
컨트롤러 클래스가 논리 뷰 이름을 뷰 구현체로 해석하면 각 뷰의 로직에 따라 핸들러 메소드가 전달한 객체를 Rendering 한다. 뷰의 임무는 어디까지나 핸들러 메소드에 추가된 객체를 유저에게 정확히 보여주는 일이다.
스프링 MVC 애플리케이션 설정
자바 EE 명세에 웹 아카이브 (WAR ; Web ARchive 파일) 를 구성하는 자바 웹 애플리케이션의 디렉토리 구조가 명시되어 있다. 가령, 웹 배포 서술자 (web.xml) 는 WEB-INF 루트에 두거나, 하나 이상의 ServletContainerInitializer 구현 클래스로 구성해야 하고 웹 애플리케이션에 필요한 각종 JAR 파일은 각각 WEB-INF/classes 와 WEB-INF/lib 에 넣어둬야 한다.
NOTE. 스프링 MVC 를 이용해 웹 어플리케이션을 개발하려면 표준 스프링 의존체와 더불어 스프링 웹, 스프링 MVC 의존체를 클래스패스에 추가해야 한다. Maven 프로젝트는 pom.xml 파일에 다음 코드를 추가한다.
CSS 파일과 이미지 파일은 WEB-INF 디렉토리 밖에 위치해 유저가 URL 로 직접 접근할 수 있도록 한다. 스프링 MVC 에서 JSP 파일은 일종의 템플릿 역할을 한다. JSP 는 프레임워크가 동적 콘텐츠를 생성하려고 읽는 파일이므로 WEB-INF 디렉토리 안에 두고 유저가 직접 접근하는 것을 차단한다. 하지만 어떤 어플리케이션 서버는 WEB-INF 내부에 파일을 두면 웹 어플리케이션이 내부적으로 읽을 수 없어 WEB-INF 밖에 둬야 하는 경우도 있다.
구성 파일 작성
웹 배포 서술자 (web.xml 또는 ServletContainerInitializer) 는 자바 웹 어플리케이션의 필수 구성 파일이다. 이 파일에 어플리케이션 서블릿을 정의하고 웹 요청 매핑 정보를 기술한다. 스프링 MVC 의 최전방 컨트롤러에 해당하는 디스패처서블릿 인스턴스는 필요 시 여러 개 정의할 수도 있지만 보통 하나를 사용한다.
대규모 어플리케이션에서 디스패처 서블릿 인스턴스를 여러개 두면 인스턴스마다 특정 URL 을 전담하도록 설계할 수 있어 코드를 관리하기 쉬워진다. 또 개발 팀원 간 서로 방해하지 않고 각자 어플리케이션 로직에 집중할 수도 있다.
package com.apress.springrecipes.court.web;
public class CourtServletContainerInitializer implements ServletContainerInitializer {
@Override
public void onStartup(Set<Class<?>> c, ServletContext context)
throws ServletException {
...
DispatcherServlet dispatcherServlet = new DispatcherServlet(applicationContext);
ServletRegistration.Dynamic courtRegistration =
context.addServlet("court", dispatcherServlet);
courtRegistration.setLoadOnStartup(1);
courtRegistration.addMapping("/");
}
}
CourtServletContainerInitializer 클래스에서 정의한 DispatcherServlet 은 스프링 MVC 의 핵심 서블릿 클래스로, 웹 요청을 받아 적절한 핸들러에 송부한다. 이 서블릿 이름은 court 라 짓고 "/" 가 포함된 모든 URL 을 매핑한다. URL 패턴은 좀 더 잘게 나눠 저장할 수도 있다. 대규모 어플리케이션이라면 이런 서블릿을 여럿 만들어 URL 패턴 별로 위임하는 게 더 바람직할 수 있다.
CourtServletContainerInitializer 를 스프링이 감지하려면 javax.servlet.ServletContainerInitializer 라는 파일에 다음과 같이 패키지까지 포함된 전체 명칭을 적고 META-INF/services 디렉토리에 추가한다. 서블릿 컨테이너는 이 파일을 로드해 어플리케이션을 시동할 때 사용한다.
그리고 @Configuration 을 붙인 CourtConfiguration 클래스를 추가하고 @ComponentScan 으로 com.apress.springrecipes.court 패키지를 스캔하여 감지한 빈들을 동작하도록 지시한다.
스프링 MVC 컨트롤러 작성
@Controller 를 붙인 컨트롤러 클래스는 어노테이션만 붙어 있을 뿐 특정 인터페이스를 구현하거나 특정 베이스 클래스를 상속한 클래스가 아니라 평범한 자바 클래스에 불과하다. 컨트롤러에는 하나 이상의 작업을 수행할 하나 이상의 핸들러 메소드를 정의하고 핸들러 메소드에는 앞서 언급한 바와 같이 어떤 정해진 틀 없이 다양한 인자를 선언할 수 있다.
@RequestMapping 은 클래스나 메소드 레벨에 부착 가능한 어노테이션이다. 먼저 컨트롤러 클래스에는 URL 패턴을, 핸들러 메소드에는 HTTP 메소드를 매핑하는 전략을 보자.
@Controller
@RequestMapping("/welcome")
public class WelcomeController {
@RequestMapping(method = RequestMethod.GET)
public String welcome(Model model) {
Date today = new Date();
model.addAttribute("today", today);
return "welcome";
}
}
WelcomeController 클래스는 Date 객체를 생성해 오늘 날짜를 설정하고, 입력받은 Model 객체에 추가해 뷰에서 화면에 표시하도록 한다. @Controller 는 스프링 MVC 컨트롤러 클래스임을 선언하는 어노테이션이다. @RequestMapping 은 프로퍼티를 지정할 수 있고, 클래스/메소드 레벨에 붙일 수 있다. 이 클래스에 붙인 @RequestMapping 의 속성값 "/welcome" 은 이 컨트롤러를 깨어나게 할 URL 이다. URL 이 "/welcome" 인 요청은 모두 이 컨트롤러 클래스로 처리하겠다는 표현이다.
컨트롤러 클래스가 요청을 받게 되면 일단 기본 HTTP GET 핸들러로 선언한 메소드로 넘긴다. 컨트롤러 클래스에서 기본 GET 핸들러 메소드는 @RequestMapping(method = RequestMethod.GET) 을 붙인 welcome() 메소드이다. 기본 HTTP GET 핸들러 메소드가 없으면 ServletException 예외가 발생하므로 스프링 MVC 컨트롤러라면 최소한 URL 경로와 기본 HTTP GET 핸들러 메소드 정도는 갖춰야 한다.
URL 경로와 기본 HTTP GET 핸들러 메소드를 모두 선언한 @RequestMapping 은 다음과 같이 메소드 레벨에도 붙일 수 있다.
@Controller
public class WelcomeController {
@RequestMapping(value = "/welcome", method = RequestMethod.GET)
public String welcome(Model model) { ... }
}
이는 위에서 선언한 바와 동일하게 동작한다. value 속성은 핸들러 메소드가 매핑될 URL 을, method 속성은 이 메소드가 컨트롤러의 기본 GET 핸들러 메소드임을 나타낸다. 이 외에도 @GetMapping / @PostMapping 등 편의성 어노테이션을 활용하면 더 간결하게 작성할 수 있다.
위의 @RequestMapping( ... ) 과 @GetMapping("/welcome") 은 같은 의미를 갖는다.
다음은 주어진 코트의 예약 내역을 조회하는 컨트롤러 코드이다.
@Controller
@RequestMapping("/reservationQuery")
public class ReservationQueryController {
@Autowired
private final ReservationService reservationService;
@GetMapping
public void setupForm() {}
@PostMapping
public String submitForm(@RequestParam("courtName") @NotNull String courtName,
Model model) {
List<Reservation> reservations = reservationService.query(courtName);
model.addAttrivute("reservations", reservation);
return "reservationQuery";
}
}
위 컨트롤러 코드는 앞서 예시한 코드와 달리, setupForm() 메소드가 기본 GET 핸들러 메소드임에도 어떤 매개변수도, 반환값도, 메소드 바디도 존재하지 않는다. 이는 두 가지를 의미하는데, 첫 번째로 입력 매개변수와 메소드 바디가 없는 건 컨트롤러에서 데이터는 하나도 추가되지 않으니 구현체 템플릿에서 하드코딩된 데이터를 뷰에서 보여준다는 것이다. 두 번째로 반환값이 없는 것은 기본 뷰의 이름이 요청 URL 에 의해 결정된다는 것이다. 예를 들어, 요청 URL 이 "/reservationQuery" 면 reservationQuery 라는 이름의 뷰가 반환되는 셈이다.
이미 알다시피 HTTP 요청은 대부분 GET 방식이고, POST 방식은 보통 사용자가 HTML 폼을 전송할 때에 사용된다. 따라서 어플리케이션 뷰 관점에서는 HTML 폼을 초기 로드할 경우 호출하는 메소드와 HTML 폼 전송 시 호출하는 메소드를 각각 두는 편이 더 명확하게 어플리케이션을 정의할 수 있다.
위에서 submitForm() 메소드는 두 입력 매개변수를 받는다. 첫 번째로 @RequestParam("courtName") 은 요청 매개변수 중 courtName 을 추출해 사용하겠다는 선언이다. "/reservationQuery?courtName=<코트명>" URL 로 POST 요청을 하면 코트명 을 courtName 이라는 변수로 받게 된다. 두 번째로 Model 은 나중에 반환할 뷰에 넘길 데이터를 담아둘 객체이다.
이 메소드는 제일 마지막에 "reservationQuery" 를 반환하는데, 이는 위에서 언급한 바와 같이 reservationQuery 뷰를 반환하는 것이다. 이는 또한 setupForm() 메소드가 아무 반환값이 없어 URL 을 반환하는 것과 동일하다.
JSP 뷰 작성
스프링 MVC 에는 JSP, HTML, PDF, 엑셀 워크시트, XML, JSON, 아톰, RSS 피드, JasperReports 및 각종 서드파티 뷰 구현체 등 여러 가지 표현 기술별로 다양한 뷰가 준비되어 있다. 스프링 MVC 애플리케이션의 뷰는 JSTL (Java Standard Tag Library) 이 가미된 JSP 템플릿이 대부분이다. web.xml 파일에 정의된 DispatcherServlet 은 핸들러가 전달한 논리적인 뷰 이름을 실제 렌더링할 뷰 구현체로 해석한다. 이를테면 CourtConfiguration 구성 클래스에서 다음과 같이 InternalResourceViewResolver 빈을 구성하면 웹 어플리케이션 컨텍스트가 논리 뷰 이름을 /WEB-INF/jsp 디렉토리에 있는 실제 JSP 파일로 해석한다.
@Bean
public InternalResourceViewResolver internalResourceViewResolver {
InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/jsp/");
viewResolver.setSuffix(".jsp");
return viewResolver;
}
즉, 컨트롤러가 reservationQuery 라는 논리 뷰 이름을 넘기면 /WEB-INF/jsp/reservationQuery.jsp 라는 뷰 구현체로 처리가 위임된다.
welcome 컨트롤러용 JSP 템플릿 (welcome.jsp) 을 다음과 같이 작성한다.
유저가 코트 이름을 입력하는 폼이 하나 있고, <c:forEach> 태그를 사용해 reservations 객체를 순회하며 HTML <table> 엘리먼트를 생성한다.
WebApplicationInitializer 로 애플리케이션 시동
앞서 웹 애플리케이션을 시동하려면 CourtServletContainerInitliazer 를 작성하면서 META-INF/services/javax.servletServletContainerInitializer 파일도 함께 만들어야 한다고 설명했다. 이 작업을 개발자가 직접 할 수도 있지만 스프링의 SpringServletContainerInitializer 를 쓰면 매우 간편하다. ServletContainerInitializer 인터페이스를 구현한 SpringServletContainerInitalizer 는 클래스패스에서 WebApplicationInitializer 인터페이스 구현체를 찾는다. WebApplicationInitializer 인터페이스 구현체는 이미 스프링에 몇 가지 준비되어 있기 때문에 편하게 골라 사용이 가능하다. AbstractAnnotationConfigDispatcherServletInitilaizer 도 그 중 하나이다.
public class CourtWebApplicationInitializer
extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class<?>[]{ServiceConfiguration.class};
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[]{WebConfiguration.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
}
이와 같이 클래스를 잓어하면 DispatcherServlet 은 이미 생성된 것과 다름없다. 덕분에 개발자는 getServletMappings() 메소드에서 매핑을 설정하고 getServletConfigClasses() 메소드에서 로드할 구성 클래스를 지정하는 일만 신경쓰면 된다. 서블릿 다음의 ContextLoaderListener 컴포넌트 역시 선택적으로 구성할 수 있다. ServletContextListener 인터페이스의 구현체인 ContextLoaderListener 는 ApplicationContext 를 생성하고 ApplicationContext 가 바로 DispatcherServlet 에서 상위 ApplicationContext 로 사용된다. 여러 서블릿이 같은 빈에 접근할 때 편리한 메커니즘이다.
스프링 AOP 프레임워크는 제한된 타입의 AspectJ 포인트컷만 지원하며 IoC 컨테이너에 선언한 빈에 한해 애스펙트를 적용할 수 있다. 따라서 포인트컷 타입을 추가하거나, IoC 컨테이너 외부 객체에 애스펙트를 적용하려면 스프링 애플리케이션에서 AspectJ 프레임워크를 끌어써야 한다.
위빙 (Weaving) 은 애스펙트를 대상 객체에 적용하는 과정이다. 스프링 AOP 는 런타임에 동적 프록시를 활용해 위빙을 하는 반면 AspectJ 프레임워크는 컴파일 타임 위빙, 로드 타임 위빙을 모두 지원한다. 컴파일 타임 위빙은 애스펙트를 자바 소스 파일에 엮고, 위빙된 바이너리 클래스 파일을 결과물로 내놓는다. 또한 이미 컴파일된 클래스 파일이나 JAR 파일에도 애스펙트를 넣을 수 있는데 이를 포스트 컴파일 타임 위빙이라 한다. 자세한 내용은 AspectJ 문서에서 확인할 수 있다.
AspectJ 의 로드타임 위빙 (LTW) 은 JVM 이 클래스 로더를 이용해 대상 클래스를 로드하는 시점에 일어난다. 바이트 코드에 코드를 넣어 클래스를 위빙하려면 특수한 클래스 로더가 필요한데, AspectJ 와 스프링 모두 클래스 로더에 위빙 기능을 부여한 로드 타임 위버를 제공한다. 로드 타임 위버는 간단한 설정으로 바로 사용이 가능하다.
스프링 애플리케이션에서의 로드 타임 위빙 처리를 보기 위해 다음의 간단한 캐싱 구조를 살펴보자.
@Aspect
public class ObjectCashingAspect {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
@Around("call(public Object.new(String)) && args(str)")
public Object cacheAround(ProceedingJointPoint joinPoint, String str)
throws Throwable {
Object someObject = cache.get(str);
if (someObject == null) {
System.out.println("Cache MISS for (" + str + ")");
someObject = (Object) joinPoint.proceed();
cache.put(str, someObject);
} else {
System.out.println("Cache HIT for (" + str + ")");
}
return someObject;
}
}
애스펙트 코드를 보면 객체 생성자 호출 시 사용한 인자를 이용해 객체를 맵에 캐시한다. AspectJ 포인트컷 표현식의 call() 안에 생성자를 호출하는 코드를 넣고, 캐시에 저장된 객체가 존재한다면 그 객체를 반환하고 그렇지 않을 경우 생성자를 통해 객체를 새로 생성하도록 한다. 반환값을 변경하기 위해 Around 어드바이스를 사용했다.
Call 포인트컷은 스프링 AOP 에서 지원하지 않기 때문에 스프링이 이 어노테이션을 스캐닝할 때 지원하지 않는 포인트컷 호출을 의미하는 에러가 발생한다. 이처럼 스프링 AOP 에서 지원하지 않는 포인트컷을 쓴 애스펙트를 적용하려면 AspectJ 프레임워크를 직접 사용해야 한다. AspectJ 프레임워크는 클래스패스 루트의 META-INF 디렉토리에 있는 aop.xml 파일에 다음과 같이 구성한다.
모든 AspectJ 애스펙트에는 Aspects 라는 팩토리 클래스가 있고, 이 클래스의 정적 팩토리 메소드 aspectOf() 를 호출하면 현재 애스펙트 인스턴스를 액세스할 수 있다. IoC 컨테이너에서는 Aspects.aspectOf(SomeObject.class) 를 호출해 빈을 선언한다.
위에서 작성한 바와 같이 캐시 맵을 미리 구성한 뒤, @Bean 메소드를 만들어 애스펙트를 구성하고 앞서 언급한 팩토리 메소드 Aspects.aspectOf() 메소드를 호출해 애스펙트 인스턴스를 가져온다. 이제 이 애스펙트 인스턴스를 구성하는 구성 클래스를 작성한다.
@Configuration
@ComponentScan
public class ObjectConfiguration {
@Bean
public ObjectCachingAspect objectCachingAspect() {
ObjectCachingAspect objectCachingAspect =
Aspects.aspectOf(ObjectCachingAspect.class);
return objectCachingAspect;
}
}
22. AOP 를 이용해 POJO 를 도메인 객체에 주입하기
IoC 컨테이너 외부에서 생성된 객체는 대부분 도메인 객체로, new 연산자 또는 DB 쿼리의 결과로 생긴다. 이렇게 스프링 밖에서 만든 도메인 객체 안에 스프링 빈을 주입하려면 AOP 의 도움이 필요하다. 도메인 객체는 스프링이 만든 객체가 아니어서 스프링 AOP 로는 주입할 수 없다. 따라서 이런 용도에 알맞게 스프링이 제공하는 AspectJ 애스펙트를 AspectJ 프레임워크에서 가져다 쓰면 된다.
복소수 객체의 형식을 전역 포매터로 맞추려 한다. 이 포매터는 형식 패턴을 인수로 받고, 표준 어노테이션 @Component, @Value 로 POJO 를 인스턴스화한다.
@Component
public class ComplexFormatter {
@Value("(a + bi)")
private String pattern;
public void setPattern(String pattern) {
this.pattern = pattern;
}
public String format(Complex complex) {
return pattern.replaceAll("a", Integer.toString(complex.getReal()))
.replaceAll("b", Integer.toString(complex.getImaginary()));
}
}
ComplexFormatter 는 Complex 클래스의 toString() 메소드에서 복소수를 문자열로 바꿀 때 사용한다. ComplexFormatter 를 전달받아야 하므로 세터 메소드를 추가한다.
public class Complex {
private int real;
private int imaginary;
private ComplexFormatter formatter;
public void setFormatter(ComplexFormatter formatter) {
this.formatter = formatter;
}
public String toString() {
return formatter.format(this);
}
}
하지만 여기서 Complex 객체는 IoC 컨테이너가 생성한 인스턴스가 아니므로 평범한 기법으로 의존체 주입을 할 수 없다. 스프링 애스펙트 라이브러리에 포함된 AnnotationBeanConfigurerAspect 를 이용하면 IoC 컨테이너가 생성하지 않은 객체에도 의존체를 주입할 수 있다.
@Configurable
@Component
@Scope("prototype")
public class Complex {
@Autowired
public void setFormatter(ComplexFormatter formatter) {
this.formatter = formatter;
}
}
위와 같이 @Configurable 아래에 표준 어노테이션인 @Component, @Scope, @Autowired 를 나열하면 표준 스프링 빈처럼 사용할 수 있지만 여기서 핵심은 @Configurable 이기 때문에 스프링은 @EnableSpringConfigured 라는 편의성 어노테이션을 지원한다.
@Configurable 을 붙인 클래스를 인스턴스화하면 애스펙트는 이 클래스와 동일한 타입의 프로토타입 스코프 빈을 찾는다. 그 다음, 빈 정의부 내용에 따라 새 인스턴스를 구성한다. 빈 정의부에 프로퍼티가 선언돼 있으면 새 인스턴스도 애스펙트가 설정한 것과 동일한 프로퍼티를 갖게 된다.
24. 스프링 TaskExecutor 로 동시성 적용하기
스프링 TaskExecutor 는 기본 자바의 Executor, CommonJ 의 WorkManager 등 다양한 구현체를 제공하며 필요할 경우 커스텀 구현체를 만들어 쓸 수도 있다. 스프링은 이들 구현체 모두를 자바 Executor 인터페이스로 단일화했다.
동시성은 서버 측 컴포넌트의 중요 요건이지만 Java EE 세계에서는 딱히 표준이라 할 만한 것이 없다. 스레드를 명시적으로 생성하거나 조작하는 행위를 금지하는 내용이 일부 Java EE 명세에 포함되어 있을 뿐이다.
자바의 Executor API 는 매우 간단하다.
package java.util.concurrent;
public interface Executor {
void execute(Runnable command);
}
스레드 관리 기능이 강화된 ExecutorService 이하 인터페이스는 shutdown() 처럼 스레드에 이벤트를 일으키는 메소드를 제공한다. ExecutorService 클래스에는 Future<T> 형 객체를 반환하는 submit() 메소드가 있다. Future<T> 인스턴스는 대개 비동기 실행 스레드의 진행 상황을 추적하는 용도로 쓰인다. Futuer.isDone(), Future.isCancelled() 메소드는 각각 어떤 작업이 완료되었는 지, 취소되었는 지를 확인한다. run() 메소드가 반환형이 없는 Runnable 인스턴스 내부에서 ExecutorService 와 submit() 을 사용할 경우, 반환된 Future 의 get() 메소드를 호출하면 null 또는 전송 시 지정한 값이 반환된다. 다음의 예제에서는 Boolean.TRUE 값이 반환된다.
Runnable task = new Runnable() {
public void run() {
try {
Thread.sleep(1000 * 60);
System.out.println("Done sleeping for a minute, returning! ");
} catch (Exception e) { ... }
}
};
ExecutorService executorService = Executors.newCachedThreadPool();
if (executorService.submit(task, Boolean.TRUE).get().equals(Boolean.TRUE)) {
System.out.println("Job has finished!");
}
다음은 Runnable 을 이용해 시간 경과를 나타낸 클래스이다.
public class SampleRunnable implements Runnable {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
System.out.println("Hello at %s\n", new Date());
}
}
위 인스턴스를 자바 Executors 와 스프링 TaskExecutor 에서 사용해보자.
public static void main(String [] args) throws Throwable {
Runnable task = new SampleRunnable();
ExecutorService cachedThreadPoolExecutorService =
Executors.newCachedThreadPool();
if (cachedThreadPoolExecutorService.submit(task).get() == null) {
System.out.printf(
"The cachedThreadPoolExecutorService has succeded at %s\n",
new Date());
}
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(100);
if (fixedThreadPool.submit(task).get() == null) {
System.out.printf(
"fixedThreadPool has succeded at %s\n",
new Date());
}
ExecutorService singleThreadExecutorService =
Executors.newSingleThreadExecutor();
if (singleThreadExecutorService.submit(task).get() == null) {
System.out.printf(
"singleThreadExecutorService has succeded at %s\n",
new Date());
}
ExecutorService es = Executors.newCachedThreadPool();
if (es.submit(task, Boolean.TRUE).get.equals(Boolean.TRUE)) {
System.out.println("Job has finished!");
}
ScheduledExecutorService scheduledThreadExecutorService =
Executors.newScheduledThreadPool(10);
if (scheduledThreadExecutorService.schedule(task, 30, TimeUnit.SECONDS).get == null) {
System.out.printf(
"scheduledThreadExecutorService has succeded at %s\n",
new Date());
}
scheduledThreadExecutorService.scheduleAtFixedRate(task, 0, 5, TimeUnit.SECONDS);
}
ExecutorService 하위 인터페이스에서 Callable<T> 를 인수로 받는 submit() 메소드를 호출하면 Callable 의 call() 메소드가 반환한 값을 그대로 반환한다.
package java.util.concurrent;
public interface Callable<V> {
V call() throws Exception;
}
Java EE 가 갖는 문제는, Java SE 와는 달리 관리되는 환경에서 컴포넌트에 동시성을 부여하고 스레드를 제어할 수 있는, 간단하면서 이식성이 좋은 표준 방법이 없다는 점이다. 이에 스프링은 자바 5의 Executor 를 상속한 TaskExecutor 인터페이스라는 통합 솔루션을 제공한다.
이를 앞서 정의한 SampleRunnable 을 TaskExecutor 에 넣어 응용한 예제를 살펴보자. 클라이언트에 해당하는 다음 코드는 단순 POJO 로서 여러 TaskExecutor 인스턴스를 자동으로 연결한다. 이들의 임무는 오로지 Runnable 을 전송하는 일이다.
@Component
public class SpringExecutorDemo {
@Autowired
private SimpleAsyncTaskExecutor asyncTaskExecutor;
@Autowired
private SyncTaskExecutor syncTaskExecutor;
@Autowired
private TaskExecutorAdapter taskExecutorAdapter;
@Autowired
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
@Autowired
private SampleRunnable task;
@PostConstruct
public void submitJobs() {
syncTaskExecutor.execute(task);
asyncTaskExecutor.submit(task);
taskExecutorAdapter.submit(task);
for (int i = 0; i < 500; i++) {
threadPoolExecutor.submit(task);
}
}
public static void main(String [] args) {
new AnnotationConfigApplicationContext(ExecutorsConfiguration.class)
.registerShutdownHook();
}
}
다음 어플리케이션 컨텍스트를 보면 다양한 TaskExecutor 구현체를 생성하는 방법을 알 수 있다. 대부분 직접 수동으로 만들어도 될 정도로 단순하며, 딱 한 경우에만 팩토리 빈에 위임해 실행을 자동으로 트리거한다.
@Configuration
@ComponentScan
public class ExecutorsConfiguration {
@Bean
public TaskExecutorAdapter taskExecutorAdapter() {
return new TaskExecutorAdapter(Executors.newCachedThreadPool());
}
@Bean
public SimpleAsyncTaskExecutor simpleAsyncTaskExecutor() {
return new SimpleAsyncTaskExecutor();
}
@Bean
public SyncTaskExecutor syncTaskExecutor() {
return new SyncTaskExecutor();
}
@Bean
public ScheduledExecutorFactoryBean scheduledExecutorFactoryBean(
ScheduledExecutorTask scheduledExecutorTask) {
ScheduledExecutorFactoryBean scheduledExecutorFactoryBean = new ScheduledExecutorFactoryBean();
scheduledExecutorFactoryBean.setScheduledExecutorTasks(scheduledExecutorTask);
return scheduledExecutorFactoryBean;
}
@Bean
public ScheduledExecutorTask scheduledExecutorTask(Runnable runnable) {
ScheduledExecutorTask scheduledExecutorTask = new ScheduledExecutorTask();
scheduledExecutorTask.setPeriod(1000);
scheduledExecutorTask.setRunnable(runnable);
return scheduledExecutorTask;
}
@Bean
public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(50);
taskExecutor.setMaxPoolSize(100);
taskExecutor.setAllowCoreThreadTimeOut(true);
taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
return taskExecutor;
}
}
첫 번째 빈 TaskExecutorAdapter 인스턴스는 Executors 인스턴스를 감싼 단순 래퍼라서 스프링 TaskExecutor 인터페이스와 같은 방식으로 다룰 수 있다. 예제에서는 스프링을 이용해 Executor 인스턴스를 구성하고 TaskExecutorAdapter 의 생성자 인자로 전달한다.
SimpleAsyncTaskExecutor 는 전송한 작업마다 Thread 를 새로 만들어 제공하며 스레드를 풀링하거나 재사용하지 않는다. 전송한 각 작업은 스레드에서 비동기로 실행된다.
SyncTaskExecutor 는 가장 단순한 TaskExecutor 구현체로, 동기적으로 Thread 를 띄워 작업을 실행한 다음, join() 메소드로 바로 연결한다. 사실상 스레딩은 완전히 건너뛰고 호출 스레드에서 run() 메소드를 수동으로 실행한 것이나 다를 것이 없다.
ScheduledExecutorFactoryBean 은 ScheduledExecutorTask 빈으로 정의된 작업을 자동 트리거한다. ScheduledExecutorTask 인스턴스 목록을 지정해 여러 작업을 동시에 실행할 수도 있다. ScheduledExecutorTask 인스턴스에는 작업 실행 간 공백 시간을 인자로 넣을 수 있다.
마지막 ThreadPoolTaskExecutor 는 java.util.concurrent.ThreadPoolExecutor 를 기반으로 모든 기능이 완비된 스레드 풀 구현체이다.
TaskExecutor 지원 기능은 애플리케이션 서버에서 하나로 통합된 인터페이스를 사용해 서비스를 스케줄링하는 강력한 수단이다. 모든 애플리케이션 서버에 배포 가능한 확실한 솔루션이 필요할 경우 스프링 쿼츠의 사용을 고려할 수 있다.
24. POJO 끼리 애플리케이션 이벤트 주고받기
스프링 애플리케이션 컨텍스트는 빈 간 이벤트 기반 통신을 지원한다. 이벤트 기반 통신 모델에서는 실제로 수신기가 여럿 존재할 가능성이 있기 때문에 송신기는 누가 수신할 지 모른 채 이벤트를 발행한다. 수신기 역시 누가 이벤트를 발행했는 지 알 필요가 없고, 여러 송신기가 발행한 이벤트를 리스닝할 수 있다. 이런 방식으로 수신기와 송신기를 느슨하게 엮을 수 있다.
이벤트 기반 통신을 위해서는 제일 먼저 이벤트 자체를 정의할 필요가 있다. 다음 예제에서, Room 을 체크아웃하면 Reception 빈이 체크아웃 시간이 기록된 CheckoutEvent 를 발행한다고 하자.
public class CheckoutEvent extends ApplicationEvent {
private final Room room;
private final Date time;
public CheckoutEvent(Room room, Date time) {
this.room = room;
this.time = time;
}
public Room getRoom() {
return room;
}
public Date getTime() {
return time;
}
}
이벤트를 인스턴스화한 뒤 다음 애플리케이션 이벤트 퍼블리셔에서 publishEvent() 메소드를 호출하면 이벤트가 발행된다. 이벤트 퍼블리셔는 다음과 같이 ApplicationEventPublisherAware 인터페이스 구현 클래스에서 가져오면 된다.
public class Reception implements ApplicationEventPublisherAware {
private ApplicationEventPublisher applicationEventPublisher;
@Override
public void setApplicationEventPublisher(
ApplicationEventPublisher applicationEventPublisher) {
this.applicationEventPublisher = applicationEventPublisher;
}
public void checkout(Room room) throws IOException {
CheckoutEvent event = new CheckoutEvent(room, new Date());
applicationEventPublisher.publishEvent(event);
}
}
ApplicationListener 인터페이스를 구현한 애플리케이션 컨텍스트에 정의된 빈은 타입 매개변수에 매치되는 이벤트를 모두 알림받는다. (이런 방식으로 ApplicationContextEvent 같은 특정 그룹 이벤트들을 리스닝한다.)
@Component
public class CheckoutListener implements ApplicationListener<CheckoutEvent> {
@Override
public void onApplicationEvent(CheckoutEvent event) {
// 체크아웃 시 수행할 로직을 여기에 구현합니다.
System.out.println("Checkout event [" + event.getTime() + "]");
}
}
스프링 4.2 부터는 ApplicatoinListener 인터페이스 없이 @EventListener 를 붙여도 이벤트 리스너로 만들 수 있다.
@Component
public class CheckoutListener {
@EventListener
public void onApplicationEvent(CheckoutEvent event) {
// 체크아웃 시 수행할 로직을 여기에 구현합니다.
System.out.println("Checkout event [" + event.getTime() + "]");
}
}
이제 어플리케이션 컨텍스트에 전체 이벤트를 리스닝할 리스너를 등록하면 된다. 등록 절차는 이 리스너의 빈 인스턴스를 선언하거나 컴포넌트 스캐닝으로 감지하면 된다. 애플리케이션 컨텍스트는 ApplicationListener 인터페이스를 구현한 빈과 @EventListener 를 붙인 메소드가 위치한 빈을 인지해 이들이 관심있는 이벤트를 각각 통지한다.
AspectJ 는 다양한 종류의 조인포인트를 매치할 수 있는 강력한 표현식 언어를 제공한다. 하지만 스프링 AOP 가 지원하는 조인포인트 대상은 IoC 컨테이너 안에 선언된 빈에 국한된다. 스프링 AOP 에서는 AspectJ 포인트컷 언어를 활용해 포인트컷을 정의하며 런타임에 AspectJ 라이브러리를 이용해 포인트컷 표현식을 해석한다.
대상 클래스나 인터페이스가 애스펙트와 같은 패키지에 있는 경우 위의 주석으로 작성한 바와 같이 패키지명은 명시하지 않아도 된다.
AspectJ 에 탑재된 포인트컷 언어는 다양한 조인포인트를 매치할 수 있지만, 간혹 매치하고자 하는 메소드 사이에 이렇다 할 공통 특성이 없는 경우, 다음과 같이 커스텀 어노테이션을 사용할 수 있다.
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LoggingRequired {
}
위와 같이 커스텀 어노테이션을 만든 후, 이를 적용하고자 하는 클래스에 어노테이션을 붙이면 사용이 가능하다. 단, 어노테이션은 상속되지 않으므로 인터페이스가 아니라 구현 클래스에만 적용해야 한다.
@LoggingRequired
public class ArithmeticCalculatorImpl implements ArithmeticCalculator {
public double add(double a, double b) {
...
}
...
}
그 후, @LoggingRequired 를 붙인 클래스/메소드를 스캐닝하도록 @Pointcut 의 annotation 안에 포인트컷 표현식을 넣는다.
@Aspect
public class CalculatorPointcuts {
@Pointcut("annotation(com.apress.springrecipes.calculator.LoggingRequired")
public void loggingOperation() {}
}
위와 같이 적용을 한 뒤, Aspect 클래스에서 위의 pointcut 을 적용했더니 적용이 되지 않는 문제가 있다. 위의 annotation 만으로는 우선 오류가 발생하고 @annotation 으로 변경하면 실행은 되지만, Aspect 클래스에서 Pointcut 을 따로 만들어놓지 않으면 실행이 되지 않는다.
내가 이 부분에 대해 이해를 한 방식은, 위와 같이 적용을 했을 때 Aspect 클래스에서 Pointcut 을 따로 정의하지 않더라도 @LoggingRequired 가 붙은 클래스에 대해서 자동으로 어드바이스가 동작을 하는 것이었는데 그런 게 아닌 것 같다.
어찌어찌 동작이 하도록 코드를 수정하면, @LoggingRequired 가 붙어있지 않아도 애스펙트가 매칭되고 붙어있어도 Aspect 클래스 안의 Pointcut 과 매칭이 안되면 애스펙트가 매칭이 안 되는.. 기이한 현상이 발생한다.
아무래도 내가 책의 내용을 잘못 이해하거나, 책의 내용과 현재 구현된 내용이 변경되었거나, 혹은 그 어떤 모종의 이유로 문제가 발생하는 것 같은데 이 부분에 대해서는 추후 따로 공부를 해서 방법을 찾게 되면 정리를 하도록 하겠다.
특정한 타입 내부의 모든 조인포인트를 매치하는 포인트컷 표현식도 존재한다. 스프링 AOP 에 적용하면 그 타입 안에 구현된 메소드를 실행할 때만 어드바이스가 적용되도록 포인트컷 적용 범위를 좁힐 수 있다. 이를테면 다음 포인트컷은 com.apress.springrecipes.calculator 패키지의 전체 메소드 실행 조인포인트를 매치한다.
어떤 공통 로직을 공유하는 클래스가 여러 개 존재할 경우, OOP 에서는 보통 같은 베이스 클래스를 상속하거나 같은 인터페이스를 구현하는 형태로 애플리케이션을 개발한다. AOP 관점에서는 충분히 모듈화가 가능한 공통 관심사이지만, 자바는 언어 구조상 오직 한 개의 클래스만 상속할 수 있으므로 동시에 여러 구현 클래스로부터 기능을 물려받아 사용하는 것이 불가능하다.
Introduction 은 AOP 어드바이스의 특별한 타입으로 객체가 어떤 인터페이스의 구현 클래스를 공급받아 동적으로 인터페이스를 구현하는 기술이다. 마치 객체가 런타임에 구현 클래스를 상속하는 것처럼 보여지고, 여러 구현 클래스를 지닌 여러 인터페이스를 동시에 인트로듀스해 사실상 다중 상속이 가능해진다.
다음 MaxCalculator 와 MinCalculator 인터페이스에 각각 max(), min() 메소드를 정의하고, 이를 각각 구현한 MaxCalculatorImpl, MinCalculatorImpl 클래스를 정의한다.
public interface MaxCalculator {
public double max(double a, double b);
}
public interface MinCalculator {
public double min(double a, double b);
}
// 구현 클래스
public class MaxCalculatorImpl implements MaxCalculator {
...
}
public class MinCalculatorImpl implements MinCalculator {
...
}
이 때, 만약 ArithmeticCalculatorImpl 클래스에서 max() 와 min() 을 모두 호출하려면 어떻게 해야 할까. 자바는 단일 상속이 가능하므로 MaxCalculatorImpl, MinCalculatorImpl 클래스를 동시에 상속하는 것이 불가능하다. 구현 코드를 복사하든지, 아니면 실제 구현 클래스에 처리를 맡기든 해서 두 클래스 중 한쪽은 상속하고 한 쪽은 인터페이스를 구현하는 방법뿐이다.
이럴 때 인트로덕션을 사용하면 ArithmeticCalculatorImpl 에서 MaxCalculator 와 MinCalculator 인터페이스를 둘 다 동적으로 구현한 것처럼 구현 클래스를 이용할 수 있다. 인트로덕션은 어드바이스와 같이 애스펙트 안에서 필드에 @DeclareParents 를 붙여 선언한다. 애스펙트를 새로 만들거나 용도가 비슷한 기존 애스펙트의 재사용도 가능하다.
@Aspect
@Component
public class CalculatorIntroduction {
@DeclareParents(
value = "com.apress.springrecipes.calculator.ArithmeticCalculatorImpl",
defaultImpl = MaxCalculatorImpl.class)
public MaxCalculator maxCalculator;
@DeclareParents(
value = "com.apress.springrecipes.calculator.ArithmeticCalculatorImpl",
defaultImpl = MinCalculatorImpl.class)
public MinCalculator minCalculator;
}
인트로덕션 대상 클래스는 @DeclareParents 의 value 속성으로 지정하며 이 어노테이션을 붙인 필드형에 따라 들여올 인터페이스가 결정된다. 새 인터페이스에서 사용할 구현 클래스는 defaultImpl 속성에 명시한다. @DeclareParents 의 value 속성값에 AspectJ 의 타입 매치 표현식을 넣으면 여러 클래스로 인터페이스를 들여올 수도 있다. 이와 같이 두 인터페이스를 ArithmeticCalculatorImpl 에 인트로덕션했으면, 해당 인터페이스로 캐스팅 후 max(), min() 메소드를 호출할 수 있다.
기존 객체에 새로운 상태를 추가해 메소드 호출 횟수, 최종 수정 일자 등 사용 내역을 파악하고 싶은 경우가 있다. 모든 객체가 동일한 베이스 클래스를 상속하는 건 해결책이 될 수 없고, 레이어 구조가 다른 여러 클래스에 동일한 상태를 추가하기란 더더욱 어렵다. 이 때, 인트로덕션과 어드바이스를 이용하면 서로 다른 클래스가 동일한 상태를 저장하고 갱신할 수 있도록 할 수 있다.
각 객체의 메소드 호출 횟수를 기록하려고 할 때, 원본 클래스에 호출 횟수를 담을 필드가 없기 때문에 스프링 AOP 인트로덕션을 적용할 수 있다. 우선, 호출 횟수를 기록하기 위한 Counter 인터페이스를 작성한다.
public interface Counter {
public void increase();
public void getCount();
}
그리고 이를 구현한 구현 클래스를 만든다. 호출 횟수는 count 필드에 저장한다.
public class CounterImpl implements Counter {
private int count;
@Override
public void increase() {
count++;
}
@Override
public int getCount() {
return count;
}
}
모든 Calculator 객체에 Counter 인터페이스를 적용하기 위해 다음과 같이 타입 매치 표현식을 이용해 인트로덕션을 적용한다.
@Aspect
@Component
public class CalculatorIntroduction {
@DeclareParents(
value = "com.apress.springrecipes.calculator.*CalculatorImpl",
defaultImpl = CounterImpl.class)
public Counter counter;
}
이로써 CounterImpl 을 모든 Calculator 객체에 적용했지만, 아직 이 상태로는 호출 횟수를 기록할 수 없다. 객체의 메소드가 호출될 때마다 counter 값을 증가시키려면 After 어드바이스를 적용해야 한다. 그리고 Counter 인터페이스를 구현한 객체는 프록시가 유일하므로 반드시 target 이 아니라 this 객체를 가져와 사용해야 한다.
@Aspect
@Component
public class CalculatorIntroduction {
@After("execution(* com.apress.springrecipes.calculator.*Calculator.*(..))"
+ " && this(counter)")
public void increaseCount(Counter counter) {
counter.increase();
}
}
이제 다음과 같이 각 Calculator 객체를 Counter 타입으로 캐스팅해 메소드 호출 횟수를 출력할 수 있다.