반응형

데이터 액세스


JDBC

JDBC (Java DataBase Conectivity) 는 DB 제작사에 구애받지 않고 RDBMS 에 접근할 수 있도록 표준 API 셋을 제공한다. DB 에 SQL 문을 실행하는 API 를 제공하는 것이 JDBC 의 주용도지만, JDBC 를 있는 그대로 사용하려면 직접 DB 리소스를 관리하면서 예외도 명시적으로 처리해야 해 개발에 있어 번거롭다. 스프링은 JDBC 를 추상화한 프레임워크인 JDBC Template 을 지원하는데 이를 통해 여러 유형의 JDBC 작업을 템플릿 메소드로 묶어서 제공한다. 전체 작업 흐름은 템플릿 메소드가 각각 알아서 관장하고 개발자는 원하는 작업을 오버라이딩해 사용할 수 있어 개발자에게 편의성을 제공한다.

 

스프링 ORM

JDBC 자체만으로는 필요한 요건을 충족하기 어렵고 개발 편의성을 더 추구할 경우 더 고수준의 추상화를 지원하는 스프링 ORM (Object-Relational Mapping) 솔루션을 사용할 수 있다. 스프링은 하이버네이트, JDO, iBatis, JPA (Java Persistence API) 등의 유명한 ORM 프레임워크를 지원한다. ORM 프레임워크는 매핑 메타데이터에 따라 객체를 저장하는 기술이다. 객체를 저장하는 SQL 문은 런타임에 생성되므로 특정 DB 에 있는 기능을 불러쓰거나 직접 SQL 문을 튜닝하지 않는 이상 DB 에 특정한 SQL 을 작성할 필요가 없다. 결과적으로 DB 에 독립적인 어플리케이션을 개발할 수 있고, 차후 다른 DB 로 마이그레이션하기 쉽다.

Hibernate 는 자바 커뮤니티에서 널리 알려진 고성능 오픈 소스 ORM 프레임워크로, JDBC 호환 DB 는 대부분 지원하며 DB 별 방언 (Dialect) 을 이용해 액세스할 수 있다. ORM 기본 외에 Caching, Cascading, Lazy Loading 과 같은 고급 기능들을 제공하고 HQL (Hibernate Query Language) 을 이용하면 단순하면서 강력한 객체 쿼리를 수행할 수 있다.

 

JDBC 를 직접 사용할 경우 문제점


자동차 정보를 등록하는 애플리케이션을 개발하려 한다. 자동차 레코드를 CRUD 하는 게 주된 기능이고, 레코드는 RDBMS 에 저장해 JDBC 로 접근한다. 다음은 자동차를 나타내는 Vehicle 클래스이다.

public class Vehicle {
	
    private String vehicleNo;
    private String color;
    private int wheel;
    private int seat;
    
    // Constructor, Setter, Getter ...
}

 

DAO 디자인 패턴

서로 다른 종류의 로직 (Presentation Logic, Business Logic, Data Access Logic 등) 을 하나의 거대한 모듈에 뒤섞는 설계상 실수를 저지르는 경우가 왕왕 있다. 이렇게 하면 모듈 간에 단단하게 결합돼 재사용성과 유지 보수성이 현저히 떨어지게 된다. DAO 는 이런 문제를 해결하고자 데이터 액세스 로직을 표현하고, 비즈니스 로직과는 분리해 DAO 라는 독립적인 모듈에 데이터 액세스 로직을 담아 캡슐화한 패턴이다.

자동차 등록 애플리케이션에서도 자동차 레코드를 CRUD 하는 각종 데이터 액세스 작업을 추상화할 수 있다. 다음과 같이 인터페이스로 선언하면 여러 DAO 구현 기술을 적용할 수 있다.

public interface VehicleDao {
    void insert(Vehicle vehicle);
    void insert(Iterable<Vehicle> vehicles);
    void update(Vehicle vehicle);
    void delete(Vehicle vehicle);
    Vehicle findByVehicleNo(String vehicleNo);
    List<Vehicle> findAll();
}

JDBC API 는 대부분 java.sql.SQLException 예외를 던지도록 설계됐지만 이 인터페이스의 유일한 의의는 데이터 액세스 작업의 추상화이므로 구현 기술에 의존해서는 안 된다. JDBC 에 특정한 SQLException 을 던지는 것은 취지에 어긋나므로 보통 DAO 구현체에서는 RuntimeException 의 하위 예외 (직접 구현한 Exception 하위 클래스나 제네릭형) 로 감싼다.

 

JDBC 로 DAO 구현하기

JDBC 를 사용해 DB 데이터에 액세스하려면 DAO 구현 클래스 (JdbcVehicleDao) 가 필요하고 DB 에 SQL 문을 실행하려면 드라이버 클래스명, DB URL, 유저명, 패스워드를 지정해 DB 에 접속해야 한다. 하지만 미리 구성된 javax.sql.DataSource 객체를 이용하면 접속 정보를 자세히 몰라도 DB 에 접속할 수 있다.

public class JdbcVehicleDao implements VehicleDao {
	
    private static final String INSERT_SQL =
        "INSERT INTO VEHICLE (COLOR, WHEEL, SEAT, VEHICLE_NO) VALUES (?, ?, ?, ?)";
    private static final String UPDATE_SQL =
        "UPDATE VEHICLE SET COLOR = ?, WHEEL = ?, SEAT = ? WHERE VEHICLE_NO = ?";
    private static final String SELECT_ALL_SQL =
        "SELECT * FROM VEHICLE";
    private static final String SELECT_ONE_SQL =
        "SELECT * FROM VEHICLE WHERE VEHICLE_NO = ?";
    private static final String DELETE_SQL =
        "DELETE FROM VEHICLE WHERE VEHICLE_NO = ?";
    
    @Autowired
    private final DataSource dataSource;
    
    @Override
    public void insert(Vehicle vehicle) {
        try (Connection conn = dataSource.getConnection();
            PreparedStatement ps = conn.prepareStatement(INSERT_SQL)){
            prepareStatement(ps, vehicle);
            ps.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
    
    @Override
    public void insert(Collection<Vehicle> vehicle) {
        vehicles.forEach(this::insert);
    }
    
    @Override
    public Vehicle findByVehicleNo(String vehicleNo) {
        try (Connection conn = dataSource.getConnection();
            PreparedStatement ps = conn.prepareStatement(SELECT_ONE_SQL)) {
            ps.setString(1, vehicleNo);
            
            Vehicle vehicle = null;
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    vehicle = toVehicle(rs);
                }
            }
            return vehicle;
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
    
    @Override
    public List<Vehicle> findAll() {
        try (Connection conn = dataSource.getConnection();
            PreparedStatement ps = conn.prepareStatement(SELECT_ALL_SQL);
            ResultSet rs = ps.executeQuery()) {
            List<Vehicle> vehicles = new ArrayList<>();
            while (rs.next()) {
                vehicles.add(toVehicle(rs));
            }
            return vehicles;
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
    
    private Vehicle toVehicle(ResultSet rs) throws SQLException {
        return new Vehicle(rs.getString("VEHICLE_NO"),
            rs.getString("COLOR"),
            rs.getInt("WHEEL"),
            rs.getInt("SEAT"));
    }
    
    private void prepareStatement(PreparedStatement ps, Vehicle vehicle)
        throws SQLException {
        ps.setString(1, vehicle.getColor());
        ps.setInt(2, vehicle.getWheel());
        ps.setInt(3, vehicle.getSeat());
        ps.setString(4, vehicle.getVehicleNo());
    }
    
    @Override
    public void update(Vehicle vehicle) { ... }
    
    @Override
    public void delete(Vehicle vehicle) { ... }
}

자동차 레코드의 등록 작업은 전형적인 JDBC 로 데이터를 수정하는 시나리오이다. insert() 를 호출할 때마다 데이터 소스에서 접속 객체를 얻어와 SQL 문을 실행한다. try-with-resources 메커니즘을 적용한 위 코드는 사용을 마친 리소스를 자동으로 닫는다. try-with-resources 블록을 사용하지 않으면 사용한 리소스를 닫았는지 잘 기억해야 하고 혹여 착오가 있으면 메모리 누수로 이어질 가능성이 있다.

 

스프링 데이터 소스 구성하기

javax.sql.DataSource 는 Connection 인스턴스를 생성하는 표준 인터페이스로, JDBC 명세에 규정되어 있다. 데이터 소스 인터페이스는 여러 구현체가 있는데, 그중 HikariCP 와 Apache Commons 가 가장 잘 알려진 오픈 소스 구현체이다. 종류는 다양하지만 모두 DataSource 라는 공통 인터페이스를 구현하기 때문에 다른 걸로 바꿔쓰기 쉽다. 스프링 역시 간단하면서 기능은 막강한 데이터 소스 구현체를 지원한다. 그중 요청을 할 때마다 접속을 새로 여는 DriverManagerDataSource 는 가장 간단한 구현체이다.

@Configuration
public class VehicleConfiguration {
	
    @Bean
    public VehicleDao vehicleDao() {
        return new JdbcVehicleDao(dataSource());
    }
    
    @Bean
    public DataSource dataSource() {
        DrvierManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setDriverClassName(ClientDriver.class.getName());
        dataSource.setUrl("jdbc_url");
        dataSource.setUserName("ds_user");
        dataSource.setPassword("ds_pwd");
        return dataSource;
    }
}

 

DAO 실행하기

이제 새 자동차 레코드를 DB 에 등록하는 DAO 를 테스트한다. 별 문제 없으면 DB 에 등록한 자동차 정보가 곧바로 출력될 것이다.

public class Main {
	
    public static void main(String[] args) {
        ApplicationContext context =
            new AnnotationConfigApplicationContext(VehicleConfiguration.class);
        
        VehicleDao vehicleDao = context.getBean(VehicleDao.class);
        Vehicle vehicle = new Vehicle("TEM0001", "RED", 4, 4);
        vehicleDao.insert(vehicle);
        
        vehicle = vehicleDao.findByVehicleNo("TEM0001");
        System.out.println(vehicle);
    }
}

 

이렇게 JDBC 를 직접 사용해 DAO 를 구현할 수 있지만, 보다시피 비슷하게 생긴 코드가 DB 작업을 처리할 때마다 반복되는 걸 확인할 수 있다. 이처럼 코드가 계속 중복되면 DAO 메소드는 장황해지고 가독성은 떨어지게 된다.

 

다음에 정리할 내용부터, 위와 같이 JDBC 를 직접 사용했을 때의 문제를 해결하기 위한 방법들을 제공한다. JDBC Template 부터 ORM 프레임워크까지, 이 방법들을 통해 데이터 액세스 로직보다는 비즈니스 로직에 집중하는 개발 방법을 확인할 수 있다.

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

반응형

Spring REST


REST 에 대한 설명은 이전 포스팅 에서 정리했으니, 추가적인 설명은 하지 않고 스프링의 REST 에 대해 살펴보려 한다.

 

1. REST 서비스로 XML 발행하기


스프링에서 REST 서비스를 설계할 때는 두 가지 가능성을 고려해야 한다. 하나는 애플리케이션 데이터 자체를 REST 서비스로 발행하는 것이고, 다른 하나는 애플리케이션에서 쓸 데이터를 서드파티 REST 서비스에서 가져오는 것이다. 여기서는 전자의 경우를 먼저 설명하고 후자의 경우는 뒤에서 다시 설명할 것이다.

스프링 MVC 에서 애플리케이션 데이터를 REST 서비스로 발행하기 위한 주된 방법은 @RequestMapping 과 @PathVariable 두 어노테이션이다. 이들을 적절히 사용함으로써 스프링 MVC 핸들러 메소드가 자신이 조회한 데이터를 REST 서비스로 발행하도록 하는 것이다. 이 외에도 스프링에는 REST 서비스의 페이로드를 생성하는 다양한 매커니즘이 있다.

 

웹 애플리케이션에서 데이터를 REST 서비스로 발행하는 (웹 서비스의 기술 용어로 이를 '엔드포인트를 생성한다' 라고 표현한다) 작업은 3장의 스프링 MVC 와 밀접한 연관을 갖는다. 스프링 MVC 에서는 핸들러 메소드에 @RequestMapping 어노테이션을 붙임으로써 액세스 지점을 정의했는데, REST 서비스의 엔드포인트도 이와 같이 정의하는 것이 좋다.

 

MarshallingView 로 XML 만들기

다음은 스프링 MVC 컨트롤러의 핸들러 메소드에 REST 서비스의 엔드포인트를 정의한 코드이다.

@Controller
public class RestMemberController {
	
    private final MemberService memberService;
    
    // Constructor
    
    @RequestMapping("/members")
    public String getRestMembers(Model model) {
        Members members = memberService.findAll();
        model.addAttribute("members", members);
        return "membertemplate";
    }
}

핸들러 메소드인 getRestMembers() 에 @RequestMapping("/members") 를 붙였기 때문에 REST 서비스 엔드포인트는 "호스트명/[애플리케이션명]/members" URI 로 접근할 수 있고, 마지막 줄에서 membertemplate 논리 뷰로 실행의 흐름을 넘기고 있다. membertemplate 은 구성 클래스에서 다음과 같이 정의할 필요가 있다.

public class CourtRestConfiguration {
	
    @Bean
    public View membertemplate() {
        return new MarshallingView(jaxb2Marshaller());
    }
    
    @Bean
    public Marshaller jaxb2Marshaller() {
        Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
        marshaller.setClassesToBeBound(Members.class, Member.class);
        return marshaller;
    }
    
    @Bean
    public ViewResolver viewResolver() {
        return new BeanNameViewResolver();
    }
}

membertemplate 뷰는 MarshallingView 형으로 정의한다. MarshallingView 는 마샬러를 이용해 응답을 렌더링하는 범용 클래스이다. 마샬링 (Marshalling) 이란, 간단히 말해 메모리에 있는 객체를 특정한 데이터 형식으로 변환하는 과정이다. 위 예제에서 마샬러는 Member, Members 객체를 XML 형식으로 바꾸는 일을 한다.

마샬러 역시 구성이 필요하다. 위에서는 Jaxb2Marshaller 를 사용했다. Jaxb2Marshaller 를 구성할 때는 classesToBeBound, contextPath 둘 중 하나의 프로퍼티를 설정한다. classesToBeBound 는 XML 로 변환할 대상 클래스를 의미한다. 다음과 같이 마샬링할 클래스에 @XmlRootElement 어노테이션을 붙여 설정할 수 있다.

@XmlRootElement
public class Member {
    
    ...
}
@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public class Members {
    
    @XmlElement(name = "member")
    private List<Member> members = new ArrayList<>();
    
    ...
}

위의 Member 클래스에 붙인 @XmlRootElement 는 Jaxb2Marshaller 가 클래스 필드를 자동으로 감지해 XML 데이터로 바꾸도록 지시한다.

더보기

예)

name = John → <name>john</name>

email = john@doe.com → <email>john@doe.com</email>

정리하자면, "http://{호스트명}/{애플리케이션명}/members.xml" 형식의 URL 로 접속하면 담당하는 핸들러가 Members 객체를 생성해 이를 membertemplate 논리 뷰로 넘긴다. 마지막 뷰에 정의한 내용에 따라 마샬러를 이용해 Members 객체를 XML 페이로드로 바꾼 후 REST 서비스를 요청한 클라이언트에게 돌려준다.

 

여기서 예시한 대로 REST 서비스의 엔드포인트를 잘 보면 ".xml" 이라는 확장자로 끝난다. 확장자를 다른 걸로 바꾸거나 아예 빼고 접속할 경우, 이 REST 서비스는 전혀 동작하지 않는다. 이런 로직은 뷰를 해석하는 스프링 MVC 와 직접 연관된 것으로 REST 서비스 자체와는 무관하다.

이 핸들러 메소드에 연결된 뷰는 기본적으로 XML 을 반환하므로 .xml 확장자를 쓸 경우에만 동작하고, 이런 식으로 동일한 핸들러 메소드는 엔드포인트에 따라 여러 뷰를 지원할 수 있다. 요청에 따라 어떤 뷰를 제공할 것인지는 스프링 MVC 의 Content Negotiation 과정에 따라 결정되며, 이 내용은 이전 포스팅 에 정리해 두었다.

 

@ResponseBody 로 XML 만들기

MarshallingView 로 XML 파일을 생성하는 건 결과 데이터를 보여주는 여러 방법 중 하나에 불과하다. 같은 데이터를 다양한 표현형으로 나타내기 위해서는 그때마다 뷰를 하나씩 추가해야 하기 때문에 상당히 불편할 것이다. 이럴 때 스프링 MVC 의 HttpMessageConverter 를 이용하면 유저가 요청한 표현형으로 객체를 손쉽게 변환할 수 있다.

@Controller
public class RestMemberController {
	
    @ReqeustMapping("/members")
    @ResponseBody
    public Members getRestMembers() { ... }
}

getRestMembers() 에 붙인 @ResponseBody 는 메소드 실행 결과를 응답의 본문으로 취급하겠다고 스프링 MVC 에게 밝히는 것이다. XML 형식으로 보려면 스프링 Jaxb2RootElementHttpMessageConverter 클래스가 마샬링을 수행한다. 이처럼 핸들러 메소드에 @ResponseBody 를 붙이면 뷰 이름을 전달하지 않고 Members 객체를 반환하는 것으로 XML 데이터를 만들 수 있다.

더보기

NOTE.

스프링 4부터는 일일이 메소드에 @ResponseBody 를 붙이지 않고 컨트롤러 클래스에 @Controller 대신 @RestController 를 붙임으로써 위와 같은 효과를 얻을 수 있다.

이와 같이 변경하고 나면 구성 클래스에서 기존에 작성했던 MarshallingView, Jaxb2Marshaller 등의 마샬러는 따로 설정을 하지 않아도 된다.

 

@PathVariable 로 결과 거르기

REST 서비스는 응답 페이로드의 양을 제한하거나 필터링할 의도로 요청 매개변수를 넣는 것이 일반적이다. 예를 들어, "http://{호스트명}/{어플리케이션명}/member/353" 으로 요청하면 353번 회원 정보, "http://{호스트명}/{어플리케이션명}/reservations/2010-07-07" 으로 요청하면 2010년 7월 7일 예약 내역만 추릴 수 있다.

스프링에서 REST 서비스를 구성할 때는, 다음과 같이 핸들러 메소드의 입력 매개변수에 @PathVariable 을 붙여 메소드 내부에서 사용할 수 있다.

@RestController
public class RestMemberController {
	
    @RequestMapping("/member/{memberId}")
    public Member getMember(@PathVariable("memberId") long memberId) { ... }
}

 

{} 표기법 대신 와일드카드 문자(*) 로 REST 엔드포인트를 나타내는 방법도 있다. REST 를 설계하는 사람들은 대개 표현적인 URL 을 사용하거나, SEO (Search Engine Optimization) 기법을 응용해 조금이라도 검색 엔진에 친화적인 REST URL 을 선호한다. 다음은 와일드카드 표기법을 사용해 REST 서비스를 선언한 코드이다.

@RequestMapping("/member/*/{memberId}")
public Member getMember(@PathVariable("memberId") long memberId) { ... }

와일드카드를 추가해도 REST 서비스의 본연의 기능에는 어떤 영향도 없지만 /member/John+Smith/353, /member/Mary+Jones/353 같은 형식의 URL 로 요청할 수 있으면 유저 가독성이나 SEO 측면에서 효과를 기대할 수 있다.

 

REST 엔드포인트 핸들러 메소드에서는 다음과 같이 데이터를 바인딩할 수도 있다.

@InitBinder
public void initBinder(WebDataBinder binder) {
    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
    binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, flase));
}

@RequestMapping("/reservations/{date}")
public void getReservation(@PathVariable("date" Date resDate) { ... }

이와 같이 정의하면, "http://{호스트명}/{어플리케이션명}/reservations/2010-07-07" 을 요청할 경우, getReservation() 메소드가 2010-07-07 값을 꺼내 resDate 변수에 할당하고 이 값을 이용해 REST 웹 서비스 페이로드를 필터링할 수 있다.

 

ResponseEntity 로 클라이언트에게 알리기

Member 인스턴스를 하나만 조회하는 엔드포인트의 결과는 올바른 회원 정보를 반환하든지, 아니면 무엇도 반환하지 않든지 둘 중 하나의 결과를 갖는다. 어느 쪽이든 요청한 클라이언트 입장에서는 정상 처리를 의미하는 HTTP 응답 코드 200 을 받게 될 것이다. 하지만 리소스가 없으면 없다는 사실을 있는 그대로 알리는 차원에서, "NOT FOUND" 에 해당하는 응답 코드 404 를 반환하는 것이 맞다.

@RequestMapping("/member/{memberId}")
public ResponseEntity<Member> getMember(@PathVariable("memberId") long memberId) {
    Member member = memberService.find(memberId);
    if (member == null) {
        return new ResponseEntity(HttpStatus.NOT_FOUND);
    } else {
    	return new ResponseEntity<>(member, HttpStatus.OK);
    }
}

스프링 MVC 에서 ResponseEntity 는 응답 결과의 본문을 HTTP 상태 코드와 함께 집어넣은 래퍼 클래스이다. 이제 getMember() 메소드는 요청에 따른 결과가 존재하면 응답 코드 200 과 결과를, 없으면 응답 코드 400 을 반환하게 된다.

 

2. REST 서비스로 JSON 발행하기


AJAX 를 고려한 스프링 애플리케이션의 REST 서비스는 브라우저의 처리 능력이 한정되기 때문에 대부분 JSON 형태의 페이로드를 발행하는 방식으로 설계한다. 브라우저에서 XML 페이로드를 발행하는 REST 서비스로부터 데이터를 받아 처리하는 건 불가능하진 않지만 별로 효율이 좋지 않다. 어느 브라우저든 자바스크립트 언어 해석기는 다 장착되어 있기 때문에 JSON 형식으로 페이로드를 받아 처리하는 방식이 여러모로 효율적이다.

표준 규격인 RSS/Atom Feed 와 달리 JSON 은 딱히 구조가 정해져 있는 형식이 아니기 때문에 JSON 의 페이로드 구조는 AJAX 설계팀과 논의해 결정하는 것이 일반적이다.

 

MappingJackson2JsonView 로 XML 만들기

@RequestMapping("/members")
public String getRestMembers(Model model) {
    ...
    return "jsonmembertemplate";
}

반환하는 뷰의 이름만 빼고 위에서 작성한 핸들러 메소드와 동일하다. 이 메소드가 반환한 jsonmembertemplate 이라는 뷰 이름은 MappingJackson2JsonView 뷰로 매핑되는데, 다음과 같이 구성 클래스에 설정할 수 있다.

public class CourtRestConfiguration {
    
    @Bean
    public View jsonmembertemplate() {
        MappingJackson2JsonView view = new MappingJackson2JsonView();
        view.setPrettyPrint(true);
        return view;
    }
}

MappingJackson2JsonView 뷰는 잭슨2 라이브러리를 이용해 객체를 JSON 으로 바꾸거나 그 반대 작업을 수행한다. 내부적으로는 이 라이브러리의 ObjectMapper 인스턴스가 그 일을 도맡는다.

 

위의 예시와 같은 코드에서, /members 에 대한 요청이나 /member.* 에 대한 요청 모두 JSON 이 생성된다. 이 때 다양한 페이로드를 제공하기 위해서 다음과 같이 메소드를 추가할 수 있다.

@RequestMapping(value = "/members", produces = MediaType.APPLICATION_JSON_VALUE)
public String getRestMembersJson(Model model) {
    ...
    return "jsonmembertemplate";
}

@RequestMapping(value = "/members", produces = MediaType.APPLICATION_JSON_VALUE)
public String getRestMembersXml(Model model) {
    ...
    return "xmlmembertemplate";
}

반환 뷰의 이름을 제외한 나머지 로직은 동일한 getMembersXml(), getMembersJson() 두 메소드가 준비되었다. 어느 메소드를 호출할 지는 @RequestMapping 의 produces 속성값으로 결정된다.

이런 식의 구현도 잘 동작은 하겠지만, 애플리케이션 개발 시 지원 뷰 타입마다 메소드를 일일이 중복해서 구현하는 것은 문제가 있다. Helper 메소드를 만들어 사용하면 중복을 줄일 수는 있지만, @RequestMapping 설정 내용은 메소드마다 다르기 때문에 유사한 코드가 여러 메소드에 도배되는 것을 막기는 힘들 것이다.

 

@ResponseBody 로 JSON 만들기

MappingJackson2JsonView 로 JSON 응답을 생성할 수 있지만 앞에서 언급했듯, 여러 뷰 타입을 지원할 경우 문제가 발생할 수 있다. 이럴 땐 스프링 MVC 의 HttpMessageConverter 를 이용해 유저가 요청한 표현형으로 객체를 변환하는 것이 좋다.

@Controller
public class RestMemberController {
    
    @RequestMapping("/members")
    @ResponseBody
    public Members getRestMembers() { ... }
}

앞서 설명한 것과 같이 @ResponseBody 는 메소드의 실행 결과를 응답의 본문으로 취급하겠다고 스프링 MVC 에 밝힌다. XML 을 발행하는 것과 마찬가지로 이와 같이 적용할 경우 Members 객체만 반환하면 되고, 구성 클래스에서도 마샬러를 따로 작성할 필요가 없다. (@Controller 를 @RestController 로 바꾸면 @ResponseBody 를 사용하지 않아도 되는 것도 같다)

 

이제 /members.xml 을 요청하면 XML 을 받고 /members.json 을 요청하면 JSON 을 받는다. 구성 클래스에서 명시적으로 이를 선언하지 않았음에도 어떻게 이게 가능할까? 스프링 MVC 가 클래스패스에 있는 것을 자동으로 감지해 요청에 따라 적절한 HttpMessageConverter 를 등록해 변환하기 때문이다. 이제 데이터를 어떤 뷰로 제공할 것인지에 대한 고민은 하지 않고, 데이터 객체를 그대로 전달하면 스프링이 알아서 적절한 뷰를 제공한다는 것을 확인했다.

 

3. 스프링으로 REST 서비스 액세스하기


스프링 애플리케이션에서 서드파티 REST 서비스의 페이로드를 사용하려면 어떻게 해야 할까?

서드파티 REST 서비스에 접근하기 위한 방법으로 RestTemplate 클래스를 이용하는 방법이 있다. 이 클래스는 다른 스프링의 *Template 류 클래스와 마찬가지로 장황한 작업을 기본 로직으로 단순화하자는 설계 사상을 따르고 있다. 덕분에 스프링 애플리케이션에서 REST 서비스를 호출하고 반환받은 페이로드를 사용하기 매우 간편하다.

 

스프링 RestTemplate 클래스는 애당초 REST 서비스를 호출할 의도로 설계된 클래스이므로 주요 메소드가 다음 표에서 볼 수 있듯 HTTP 요청 메소드와 같이 REST 의 기본 토대와 밀접하게 연관되어 있다.

RestTemplate Method Description
headForHeaders(String, Object...) HTTP HEAD 작업을 수행한다.
getForObject(String, Class, Object...) HTTP GET 작업을 한 뒤, 주어진 클래스 타입으로 결과를 반환한다.
getForEntity(String, Class, Object...) HTTP GET 작업을 한 뒤, ResponseEntity 를 반환한다.
postForLocation(String, Object, Object...) HTTP POST 작업을 한 뒤, location 헤더값을 반환한다.
postForObject(String, Object, Class, Object...) HTTP POST 작업을 한 뒤, 주어진 클래스 타입으로 결과를 반환한다.
postForEntity(String, Object, Class, Object...) HTTP POST 작업을 한 뒤, ResponseEntity 를 반환한다.
put(String, Object, Object...) HTTP PUT 작업을 수행한다.
delete(String, Object...) HTTP DELETE 작업을 수행한다.
optionsForAllow(String, Object...) HTTP OPTIONS 작업을 수행한다.
execute(String, HttpMethod, RequestCallback,
ResponseExtractor, Object...)
CONNECT 를 제외한 모든 HTTP 작업이 가능한 메소드이다.

위 표에서 볼 수 있듯, RestTemplate 의 메소드명은 HTTP 요청 메소드로 시작한다. execute 는 사용 빈도가 적은 TRACE 를 비롯, 어떤 HTTP 메소드라도 사용 가능한 범용 메소드이다. (단, execute 메소드가 내부에서 사용하는 HttpMethod Enum 에서 제외된 CONNECT 메소드는 지원하지 않는다)

더보기

NOTE.

REST 서비스에서 가장 많이 사용되는 HTTP 메소드는 단연 GET 이다. GET 은 안전하게 정보를 가져오지만 (즉, 데이터 변경이 이루어지지 않지만) PUT, POST, DELETE 등의 메소드는 원본 데이터를 수정하는 수단이므로 REST 서비스의 공급자가 이런 메소드까지 지원할 가능성은 희박하다. 일반적으로 서비스 공급자는 데이터 변경이 필요한 경우 REST 서비스의 대체 수단인 SOAP 프로토콜을 선호한다.

RestTemplate 클래스의 메소드를 대략적으로 살펴봤으니 이번엔 REST 서비스를 스프링 프레임워크에서 자바로 호출해보자. 다음은 서드파티 REST 서비스를 액세스해 그 결과를 표준 출력으로 출력하는 코드이다.

public static void main(String [] args) throws Exception {
    final String uri = "http://localhost:8080/court/members.json";
    RestTemplate restTemplate = new RestTemplate();
    
    String result = restTemplate.getForObject(uri, String.class);
    System.out.println(result);
}

먼저 RestTemplate 객체를 생성한 뒤, getForObject() 메소드를 호출한다. getForObject() 는 브라우저가 REST 서비스 페이로드를 가져오는 것처럼 HTTP GET 요청을 수행한다.

getForObject() 의 호출 결과로 받은 응답을 String 형 변수 result 에 할당한다. 앞서 이 REST 서비스를 브라우저에서 호출한 결과 화면에 표시됐던 내용을 String 형식으로 담는 것이다. 메소드에 전달한 첫 번째 매개변수는 브라우저에서 썼던 것과 동일한 URL 이고, 두 번째 매개변수는 해당 응답의 결과를 어떤 형태로 반환받을 것인지를 의미한다.

위의 코드를 실행하면 브라우저 화면에서 출력됐던 결과와 동일한 문자열이 콘솔 창에 출력될 것이다.

 

매개변수화한 URL 에서 데이터 가져오기

앞에서 URL 을 호출해 데이터를 가져오는 것은 수행했는데, 만약 URL 에 필수 매개변수를 넣어야 하는 경우는 어떻게 해야 할까? 다행히 RestTemplate 의 메소드는 URL 에 placeholder 를 넣고 나중에 이 곳을 실제 값으로 치환할 수 있다. placeholder 는 @RequestMapping 과 같이 {} 로 삽입할 위치를 표시한다.

public static void main(String [] args) throws Exception {
    final String uri = "http://localhost:8080/court/member/{memberId}";
    Map<String, String> params = Map.of("memberId", 1);
    RestTemplate restTemplate = new RestTemplate();
    
    String result = restTemplate.getForObject(uri, String.class, params);
    System.out.println(result);
}

 

데이터를 매핑된 객체로 가져오기

결과를 String 으로 반환받는 대신 Members/Member 클래스로 직접 매핑해서 사용할 수 있다. getForObject() 메소드의 두 번째 매개변수를 String.class 가 아닌, 매핑할 클래스로 지정하면 해당 클래스에 맞게 응답이 매핑된다.

public static void main(String [] args) throws Exception {
    final String uri = "http://localhost:8080/court/member/{memberId}";
    Map<String, String> params = Map.of("memberId", 1);
    RestTemplate restTemplate = new RestTemplate();
    
    Member result = restTemplate.getForObject(uri, Member.class, params);
    System.out.println(result);
}

 

4. RSS/Atom Feed 발행하기


RSS/아톰 피드는 정보 발행 시 널리 사용되는 수단으로, 보통 REST 서비스를 사용해 액세스한다. 즉, 일단 REST 서비스를 구축한 다음 REST/아톰 피드를 발행할 수 있다. 스프링의 기본적인 REST 지원 기능 외에 RSS/아톰 피드 전용 서드파티 라이브러리를 사용하면 편리하게 구현할 수 있다. 여기서는 오픈소스 자바 프레임워크인 ROME 을 사용했다.

 

우선, 어떤 정보를 RSS/아톰 피드로 발행할 지를 결정한다. 정보를 가져오는 방법은 여기서 다룰 내용이 아니므로 넘어가고, 발행할 정보를 선택한 다음 RSS/아톰 피드로 구조를 잡아야 하는데 여기서 바로 ROME 이 사용된다.

 

RSS/아톰 피드는 정보를 발행하는 데 여러 엘리먼트를 활용한 XML 페이로드일 뿐이다. RSS/아톰 피드는 두 형식 모두 공통적인 특징이 있다. 간단히 정리하면 다음과 같다.

  • 피드 내용을 서술하는 메타데이터 영역이 존재한다. (예: 아톰의 <author> 와 <title>, RSS 의 <description> 과 <pubDate>)
  • 순환 엘리먼트로 복수의 정보를 나타낼 수 있다. (예: 아톰의 <entry>, RSS 의 <item>)
    각 순환 엘리먼트는 자체 엘리먼트를 가지고 있어 더 자세한 정보를 나타낼 수 있다.
  • 버전이 다양하다. RSS 는 0.90, 0.01 넷스케이프, 0.91 유저랜드, 0.92, 0.93, 0.94, 1.0 을 거쳐 현재 2.0 이 최신 버전이고 아톰은 0.3 과 1.0 버전이 있다.
    ROME 을 사용하면 버전에 상관없이 자바 코드에서 기용한 정보를 바탕으로 피드의 메타데이터 영역, 순환 엘리먼트를 생성할 수 있다.

RSS/아톰 피드의 구조 및 ROME 의 역할을 간단히 봤으니, 스프링 MVC 컨트롤러에서 유저에게 피드를 표시하는 코드를 살펴보자.

@RestController
public class FeedController {
    
    @RequestMapping("/atomfeed")
    public String getAtomFeed(Model model) {
        List<TournamentContent> tournamentList = new ArrayList<>();
        
        ...
        
        model.addAttribute("feedContent", tournamentList);
        return "atomfeedtemplate";
    }
    
    @RequestMapping("/rssfeed")
    public String getRSSFeed(Model model) {
        List<TournamentContent> tournamentList = new ArrayList<>();
        
        ...
        
        model.addAttribute("feedContent", tournamentList);
        return "rssfeedtemplate";
    }
}

/atomfeed 을 getAtomFeed() 메소드에 /rssfeed 을 getRSSFeed() 메소드에 매핑했다. 이들 메소드엔 TournamentContent 객체 리스트가 tournamentList 변수로 선언되어 있다. 여기서 TournamentContent 객체는 일반 POJO 이다. 두 메소드는 반환 뷰에서 이 리스트에 접근할 수 있도록 Model 객체의 속성에 할당한 뒤 각각 논리 뷰를 반환한다. 논리 뷰는 스프링 구성 클래스에 다음과 같이 정의한다.

public class RestConfiguration {
	
    @Bean
    public AtomFeedView atomfeedtemplate() {
        return new AtomFeedView();
    }
    
    @Bean
    public RSSFeedView rssfeedtemplate() {
    	return new RSSFeedView();
    }
}

 

스프링은 ROME 기반으로 제작된 RSS/아톰 전용 뷰 클래스 AbstractAtomFeedView 와 AbstractRssFeedView 를 지원한다. 두 추상 클래스로 인해 RSS/아톰 형식을 세세하게 알지 못해도 뷰를 구현할 수 있다.

 

다음은 AbstractAtomFeedView 를 상속한 AtomFeedView 클래스로, 논리 뷰 atomfeedtemplate 을 구현한 코드이다.

public class AtomFeedView extends AbstractAtomFeedView {
	
    @Override
    protected void buildFeedMetadata(Map model, Feed feed, HttpServletRequest request) {
        feed.setId("tag:tennis.org");
        feed.setTitle("Grand Slam Tournaments");
        
        List<TournamentContent> tournamentList = 
            (List<TournamentContent>) model.get("feedContent");
        
        feed.setUpdated(tournamentList.stream().map(TournamentContent::getPublicationDate)
            .sorted().findFirst().orElse(null));
    }
    
    @Override
    protected List buildFeedEntries(Map model, HttpServletRequest request,
        HttpServletResponse response) throws Exception {
        List<TournamentContent> tournamentList = 
            (List<TournamentContent>) model.get("feedContent");
        
        return tournamentList.stream().map(this::toEntry).collect(Collectors.toList());
    }
    
    private Entry toEntry(TournamentContent content) { ... }
}

AbstractAtomFeedView 클래스를 상속한 뒤, 이 클래스로부터 물려받은 buildFeedMetadata(), buildFeedEntries() 두 메소드의 세부 로직을 위와 같이 작성한다.

 

다음은 AbstractRssFeedView 를 상속해 구현한 RssFeedView 클래스이다.

public class RSSFeedView extends AbstractRssFeedView {
	
    @Override
    protected void buildFeedMetadata(Map model, Channel feed, HttpServletRequest request) {
        feed.setTitle("World Soccer Tournaments");
        feed.setDescription("FIFA World Soccer Tournament Calendar");
        feed.setLink("tennis:org");
        
        List<TournamentContent> tournamentList = 
            (List<TournamentContent>) model.get("feedContent");
        
        feed.setLastBuildDate(tournamentList.stream()
            .map(TournamentContent::getPublicationDate)
            .sorted().findFirst().orElse(null));
    }
    
    @Override
    protected List<Item> buildFeedItems(Map model, HttpServletRequest request,
        HttpServletResponse response) throws Exception {
        List<TournamentContent> tournamentList = 
            (List<TournamentContent>) model.get("feedContent");
        
        return tournamentList.stream().map(this::toMap).collect(Collectors.toList());
    }
    
    private Item toItem(TournamentContent content) { ... }
}

AbstractRssFeedView 클래스를 상속한 뒤, 위와 마찬가지로 buildFeedMetadata(), buildFeedItems() 메소드의 로직을 작성한다.

 


#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

반응형

스프링 MVC

7. 뷰와 Content Negotiation 활용

클라이언트가 보내는 웹 요청에는 스프링 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 맵을 이용해 기본 미디어 타입과 비교한다.

  1. 요청 경로에 확장자는 있지만 기본 미디어 타입과 매칭되는 확장자가 없는 경우, JAF (JavaBeans Activation Framework) 의 FileTypeMap 을 이용해 확장자의 미디어 타입을 결정한다.
  2. 요청 경로에 확장자가 없으면 HTTP Accept 헤더를 활용한다.

그래서 /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. 컨트롤러에서 폼 처리하기

유저가 폼과 상호작용할 때 컨트롤러는 보통 두 가지 일을 반드시 수행한다.

  1. 폼에 대한 GET 요청이 발생하면 컨트롤러는 폼 뷰를 렌더링해 화면에 표시한다.
  2. 유저가 POST 요청을 통해 폼을 전송하면 폼 데이터를 검증 (Validation) 후 요건에 맞게 처리한다.
  3. 이후 폼 처리에 성공하면 유저에게 성공 뷰를 보여주고, 폼 처리에 실패하면 원래 폼에 에러 메시지를 표시한다.

다음 코드는 간단한 폼 뷰이다. 스프링의 폼 태그 라이브러리를 사용하면 폼 데이터 바인딩과 에러 메시지의 표시를 비롯해 에러 발생 시 유저가 처음 입력했던 값을 다시 보여주는 등의 작업을 간편하게 처리할 수 있다.

<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>

<html>
<head>
    <title>Reservation Form</title>
    <style>
        .error {
            color: #ff0000;
            font-weight: bold;
        }
    </style>
</head>

<body>
    <form:form method="post" modelAttribute="reservation">
    <form:erros path="*" cssClass="error" />
    <table>
        <tr>
            <td>Court Name</td>
            <td><form:input path="courtName" /> </td>
            <td><form:errors path="courtName" cssClass="error" /></td>
        </tr>
        
        ...
        
        <tr>
            <td colspan="3"><input type="submit" /></td>
        </tr>
    </table>
</body>
</html>

스프링 <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 셀렉트 박스에 표시되는 항목) 도 있다. 유저가 코트를 예약할 때 셀렉트 박스에서 종목을 선택할 것이다.

<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>

<html>
<head>
    <title>Reservation Form</title>
</head>

<body>
    <form:form method="post" modelAttribute="reservation">
    <form:erros path="*" cssClass="error" />
    <table>
        ...
        <tr>
            <td>Sport Type</td>
            <td><form:select path="sportType" items="${sportTypes}"
                itemValue="id" itemLabel="name" /> </td>
            <td><form:errors path="sportType" cssClass="error" /></td>
        </tr>
        <tr>
            <td colspan="3"><input type="submit" /></td>
        </tr>
    </table>
</body>
</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() 호출만 추가하면 세션 데이터를 만료시킬 수 있다.

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

반응형

스프링 MVC

4. 유저 로케일 해석

스프링 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 가 제대로 동작을 하지 못하고 있다는 뜻이었는데, 해서 몇 가지 자료를 찾아보다 다음의 링크에서 해결법을 찾았다.

https://stackoverflow.com/questions/51860856/spring-localresolver-based-on-query-parameter-lang-en

 

Spring LocalResolver based on query parameter 'lang=en'?

Can I tell spring-boot to automatically resolve the requested locale by a queryparameter, eg &lang=en? I would like to give the query param precedence over Accept-Language parameter. I found ...

stackoverflow.com

LocaleChangeInterceptor 가 제대로 동작하지 않는 이유는 아직 제대로 확인하지 못했지만, 위의 질문에서 찾은 이 문제에 대한 해결 방법은 LocaleChangeInterceptor 를 사용하지 않고, 로케일 리졸버에서 HTTP 요청 속성값을 읽어 이를 바로 적용하는 것이었다.

@Bean
public AcceptHeaderLocaleResolver localeResolver() {
    AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver() {
        @Override
        public Locale resolveLocale(HttpServletRequest request) {
            String locale = request.getParameter("lang");
            return locale != null
                ? org.springframework.util.StringUtils.parseLocaleString(locale)
                : super.resolveLocale(request);
        }
    };

    localeResolver.setDefaultLocale(new Locale("en"));
    return localeResolver;
}

 

이렇게 구성하면 요청 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 파일 최상단에 선언해야 한다.

<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>

<html>
<head>
    <title><spring:message code="welcome.title" text="Welcome"/></title>
</head>

<body>
    <h2><spring:message code="welcome.message" text="Welcome to Court Reservation System"/></h2>
</body>
</html>

<spring:message> 에서 해당 코드에 맞는 메시지를 해석할 수 없을 경우 표시할 기본 텍스트는 text 속성에 설정한다.

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

반응형

스프링 MVC

2. @RequestMapping 으로 요청 매핑

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 요청 메소드를 명시한다.

@RequestMapping(value = "processUser", method = RequestMethod.POST)
public String submitForm() { ... }

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() 메소드 인자로 지정한다.

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

반응형

서블릿이 무엇인지는 지난 포스팅 에서 살펴봤다. 이번엔 서블릿이 무엇인지 호기심을 갖게 한 스프링의 디스패처 서블릿에 대해서 살펴보려 한다.

 

디스패처 서블릿 (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차적으로 설정된 정적 리소스 경로를 탐색해 리소스를 찾는 방식이다. 이렇게 영역을 분리하면 효율적인 리소스 관리가 가능할 뿐 아니라 추후에 확장이 용이하다는 장점을 가지게 된다.

 

#Reference.

 

[Spring]Dispatcher-Servlet(디스패처 서블릿)이란?

이번에는 servlet의 심화 또는 대표주자인 dispatcher-servlet에 대해서 알아보도록 하겠습니다. 1. Dispatcher-Servlet(디스패처 서블릿)의 개념 dispatcher-servlet에서 dispatch는 보내다라는 뜻을 가지고 있..

mangkyu.tistory.com

 

반응형

스프링 MVC

1. 스프링 MVC 를 이용한 간단한 웹 애플리케이션 개발

프론트 컨트롤러는 스프링 MVC 의 중심 컴포넌트이다. 아주 단순한 스프링 MVC 애플리케이션이라면 자바 웹 배포 서술자(web.xml 파일이나 ServletContainerInitializer) 에 프론트 컨트롤러의 서블릿만 구성하면 된다. 보통 디스패처 서플릿이라 일컫는 스프링 MVC 컨트롤러는 코어 자바 EE 패턴 중 하나인 프론트 컨트롤러 패턴을 구현한 것으로, MVC 프레임워크에서 모든 웹 요청은 반드시 디스패처 서블릿을 거쳐 처리된다.

 

스프링 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 파일에 다음 코드를 추가한다.
<dependency>
	<groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
    <version>${spring.version}</version>
</dependency>​

 

Gradle 프로젝트는 build.gradle 에 다음 코드를 추가한다.
dependencies {
	compile "org.springframework:spring-webmvc:${springVersion}"
}​

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 디렉토리에 추가한다. 서블릿 컨테이너는 이 파일을 로드해 어플리케이션을 시동할 때 사용한다.

com.apress.springrecipes.court.web.CourtServletContainerInitializer

그리고 @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) 을 다음과 같이 작성한다.

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

<html>
<head>
    <title>Welcome</title>
</head>

<body>
<h2>Welcome to Court Reservation System</h2>
Today is <fmt:formatDate value="${today}" pattern="yyyy-MM-dd"/>.
</body>
</html>

JSTL fmt 태그 라이브러리를 이용해 모델 속성 today 를 "yyyy-MM-dd" 형식으로 맞췄다. 태그 라이브러리는 JSP 템플릿의 최상단에 반드시 선언해야 한다.

다음은 ReservationQuery 컨트롤러용 JSP 템플릿 (reservationQuery.jsp) 이다.

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

<html>
<head>
    <title>Reservation Query</title>
</head>

<body>
<form method="post">
    Court Name
    <input type="text" name="courtName" value="${courtName}"/>
    <input type="submit" value="Query"/>
</form>


<table border="1">
    <tr>
        <th>Court Name</th>
        <th>Date</th>
        <th>Hour</th>
        <th>Player</th>
    </tr>
    <c:forEach items="${reservations}" var="reservation">
        <tr>
            <td>${reservation.courtName}</td>
            <td><fmt:formatDate value="${reservation.date}" pattern="yyyy-MM-dd"/></td>
            <td>${reservation.hour}</td>
            <td>${reservation.player.name}</td>
        </tr>
    </c:forEach>
</table>
</body>
</html>

유저가 코트 이름을 입력하는 폼이 하나 있고, <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 로 사용된다. 여러 서블릿이 같은 빈에 접근할 때 편리한 메커니즘이다.

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

위에서 사용한 예제 코드는 https://github.com/nililee/spring-5-recipes 의 코드를 참고한 것입니다. 전체 코드가 필요한 경우는 해당 경로에서 소스 코드를 참고하면 됩니다.

반응형

wkdPOJO 구성 방식

20. AspectJ 애스펙트 로드 타임 위빙

스프링 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 파일에 다음과 같이 구성한다.

<!DOCTYPE aspectj PUBLIC "-//AspectJ//DTD//EN"
  "http://www.eclipse.org/aspectj/dtd/aspectj.dtd">

<aspectj>
  <weaver>
    <include within="com.apress.springrecipes.calculator.*"/>
  </weaver>

  <aspects>
    <aspect
      name="com.apress.springrecipes.calculator.ComplexCachingAspect"/>
  </aspects>
</aspectj>

 

 

21. 스프링에서 AspectJ 애스펙트 구성하기

모든 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 인터페이스라는 통합 솔루션을 제공한다.

package org.springframework.core.task;

public interface TaskExecutor extends Executor {
    void execute(Runnable task);
}

이를 앞서 정의한 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 를 붙인 메소드가 위치한 빈을 인지해 이들이 관심있는 이벤트를 각각 통지한다.

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

반응형

POJO 구성 방식

17. AspectJ 포인트컷 표현식 작성

AspectJ 는 다양한 종류의 조인포인트를 매치할 수 있는 강력한 표현식 언어를 제공한다. 하지만 스프링 AOP 가 지원하는 조인포인트 대상은 IoC 컨테이너 안에 선언된 빈에 국한된다. 스프링 AOP 에서는 AspectJ 포인트컷 언어를 활용해 포인트컷을 정의하며 런타임에 AspectJ 라이브러리를 이용해 포인트컷 표현식을 해석한다.

더보기

AspectJ 포인트컷 언어에 대한 자세한 내용은 공식 웹 사이트 (https://www.eclipse.org/aspectj/) 에서 확인할 수 있다.

 

포인트컷 표현식의 가장 일반적인 형태는 시그니처를 기준으로 여러 메소드를 매치하는 것이다. 예를 들어 다음 포인트컷 표현식은 ArithmeticCalculator 인터페이스에 선언한 메소드를 모두 매치한다. 

execution(* com.apress.springrecipes.calculator.ArithmeticCalculator.*(..))

// execution(* ArithmeticCalculator.*(..))

대상 클래스나 인터페이스가 애스펙트와 같은 패키지에 있는 경우 위의 주석으로 작성한 바와 같이 패키지명은 명시하지 않아도 된다.

 

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 패키지의 전체 메소드 실행 조인포인트를 매치한다.

within(com.apress.springrecipes.calculator.*)
더보기

위의 예시도 within 과 @within 이 존재하는데, 어떤 차이가 있는 지는 따로 공부를 해야할 것 같다.

다음과 같이 어떤 인터페이스를 구현한 모든 클래스의 메소드 실행 조인포인트를 매치하려면 맨 뒤에 + 기호를 붙인다.

within(Interface+)

AspectJ 포인트컷 표현식은 &&, || ! 등의 연산자로 조합이 가능하다. 예를 들어 다음 포인트컷은 ArithmeticCalculator 또는 UnitCalculator 인터페이스를 구현한 클래스의 조인포인트를 매치한다.

within(ArithmeticCalculator+ || within(UnitCalculator+))

 

18. Introduction 을 이용해 POJO 에 기능 추가

어떤 공통 로직을 공유하는 클래스가 여러 개 존재할 경우, 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() 메소드를 호출할 수 있다.

public static void main(String [] args) {
	
    ArithmeticCalculator arithmeticCalculator;
    
    ...
    
    MaxCalculator maxCalculator = (MaxCalculator) arithmeticCalculator;
    maxCalculator.max(2, 1);
    
    MinCalculator minCalculator = (MinCalculator) arithmeticCalculator;
    minCalculator.min(1, 2);
}

 

19. AOP 를 이용해 POJO 에 상태 추가

기존 객체에 새로운 상태를 추가해 메소드 호출 횟수, 최종 수정 일자 등 사용 내역을 파악하고 싶은 경우가 있다. 모든 객체가 동일한 베이스 클래스를 상속하는 건 해결책이 될 수 없고, 레이어 구조가 다른 여러 클래스에 동일한 상태를 추가하기란 더더욱 어렵다. 이 때, 인트로덕션과 어드바이스를 이용하면 서로 다른 클래스가 동일한 상태를 저장하고 갱신할 수 있도록 할 수 있다.

 

각 객체의 메소드 호출 횟수를 기록하려고 할 때, 원본 클래스에 호출 횟수를 담을 필드가 없기 때문에 스프링 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 타입으로 캐스팅해 메소드 호출 횟수를 출력할 수 있다.

public static void main(String [] args) {

    ArithmeticCalculator arithmeticCalculator;
    UnitCalculator unitCalculator;
    
    ...
    
    Counter arithmeticCounter = (Counter) arithmeticCalculator;
    System.out.println(arithmeticCounter.getCount());
    
    Counter unitCounter = (Counter) unitCalculator;
    System.out.println(unitCounter.getCount());
}

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

반응형

POJO 구성 방식

14. JoinPoint 정보 가져오기

AOP 에서 어드바이스는 여러 조인포인트, 즉 프로그램 실행지점 곳곳에 적용된다. 어드바이스가 정확하게 작동하려면 조인포인트에 대한 세부 정보가 필요한 경우가 존재한다.

 

어드바이스 메소드의 Signature 에 org.aspectj.lang.JoinPoint 형 인수를 선언하면 조인포인트 정보를 얻을 수 있다. 다음과 같이 logJoinPoint 어드바이스에서 조인포인트 정보를 액세스한다고 하자. 필요한 정보는 조인포인트 유형, 메소드 시그니처, 인수값, 대상 객체와 프록시 객체이다.

@Aspect
@Component
public class CalculatorLoggingAspect {
	
    private Logger log = LoggerFactory.getLogger(this.getClass());
    
    @Before("execution(* *.*(..))") 
    public void logJoinPoint(JoinPoint joinPoint) {
        log.info("Join point kind : {}", joinPoint.getKind());
        log.info("Signature declaring type : {}", joinPoint.getSignature().getDeclaringTypeName());
        log.info("Signature name : {}", joinPoint.getSignature().getName());
        log.info("Arguments : {}", Arrays.toString(joinPoint.getArgs()));
        log.info("Target class : {}", joinPoint.getTarget().getClass().getName());
        log.info("This class : {}", joinPoint.getThis().getClass().getName());
    }
}

 

프록시로 감싼 원본 빈은 Target Object 라고 하며 프록시 객체는 this 로 참조한다. 대상 객체와 프록시 객체는 각각 조인포인트에서 getTarget(), getThis() 메소드로 가져올 수 있다. 실행 결과는 다음과 같다.

더보기

Join point kind : method-execution
Signature declaring type : com.apress.springrecipes.calculator.ArithmeticCalculator
Signature name : add
Arguments : [1.0, 2.0]
Target class : com.apress.springrecipes.calculator.ArithmeticCalculatorImpl
This class : com.sun.proxy.$Proxy18

 

15. @Order 로 애스펙트 우선순위 설정

같은 조인포인트에 애스펙트를 여러 개 적용할 경우, 우선순위를 정해야 한다. 애스펙트 간 우선순위는 Ordered 인터페이스를 구현하거나 @Order 어노테이션을 붙여 지정할 수 있다.

 

@Aspect
@Component
public class CalculatorVaildationAspect {
	
    @Before("execution(* *.*(double, double))")
    public void validateBefore(JoinPoint joinPoint) {
    	for (Object arg : joinPoint.getArgs()) {
        	validate((Double) arg);
        }
    }
    
    private void validate(double a) {
    	if (a < 0) {
        	throw new IllegalArgumentException("Positive Numbers only");
        }
    }
}

@Aspect
@Component
public class CalculatorLoggingAspect {
	
    @Before("execution(* *.*(..))")
    public void logBefore(JoinPoint joinPoint) {
    	// ...
    }
}

 

위와 같이 두 애스펙트가 존재할 경우 어느 쪽을 먼저 적용해야 할 지 알 수가 없다. 이런 경우 어느 한 애스팩트가 다른 것보다 먼저 실행되게 하기 위해 우선순위를 설정해야 한다. 두 애스펙트 모두 Ordered 인터페이스를 구현하거나 @Order 어노테이션을 활용하면 된다.

 

먼저 Ordered 인터페이스를 구현할 경우, getOrder() 메소드가 반환하는 값이 작을수록 우선순위가 높다. 다음과 같이 작성할 경우 검증 애스펙트가 로깅 애스펙트보다 우선순위가 더 높아진다.

@Aspect
@Component
public class CalculatorVaildationAspect implements Ordered {
	
    ...
    public int getOrder() {
    	return 0;
    }
}

@Aspect
@Component
public class CalculatorLoggingAspect implements Ordered {
	
    ...
    public int getOrder() {
    	return 1;
    }
}

 

아래와 같이 @Order 에 우선순위값을 넣으면 더 깔끔하게 구현이 가능하다.

@Aspect
@Component
@Order(0)
public class CalculatorVaildationAspect { ... }

@Aspect
@Component
@Order(1)
public class CalculatorLoggingAspect { ... }

 

16. 애스펙트 포인트컷 재사용

포인트컷 표현식을 여러 번 되풀이해 사용할 경우에, 어드바이스 어노테이션에 직접 써넣는 것보다 재사용할 방법을 필요로 하게 된다. @Pointcut 어노테이션을 활용하면 포인트컷만 따로 정의해 여러 어드바이스에서 재사용이 가능하다.

 

애스펙트에서 포인트컷은 @Pointcut 을 붙인 단순 메소드로 선언할 수 있다. 포인트컷과 어플리케이션 로직이 뒤섞이는 것은 바람직하지 않으니 메소드 바디는 보통 비워두고 포인트컷의 가시성은 메소드의 수정자로 조정한다. 이렇게 선언한 포인트컷은 다른 어드바이스가 메소드명으로 참조할 수 있다.

 

@Aspect
@Component
public class CalculatorLogginAspect {
	
    ...
    @Pointcut("execution(* *.*(..))")
    private void loggingOperation() {}
    
    @Before("loggingOperation()")
    public void logBefore() { ... }
    
    @After("loggingOperation()")
    public void logAfter() { ... }
}

 

여러 애스펙트가 포인트컷을 공유하는 경우라면 공통 클래스 한 곳에 포인트컷을 모아두는 편이 좋다. 이 때 포인트컷 메소드는 public 으로 선언한다.

@Aspect
public class CalculatorPointcuts {
	
    @Pointcut("execution(* *.*(..))")
    public void loggingOperation() {}
}

 

외부 클래스에 있는 포인트컷을 참조할 때는 클래스명도 함께 적는다. 만약 다른 패키지에 있는 경우는 패키지명까지 기재한다.

@Aspect
@Component
public class CalculatorLoggingAspect {
	
    @Before("CalculatorPointcuts.loggingOperation()")
    public void logBefore() { ... }
    
    @After("CalculatorPointcuts.loggingOperation()")
    public void logAfter() { ... }
}

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

+ Recent posts