스프링이란 어떤 것이다라고 한마디로 정의하기는 쉽지 않다. 스프링에 대해 가장 잘 알려진 정의는 이렇다.
자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량급 애플리케이션 프레임워크
정의를 봐도 스프링이 무엇인지 감이 바로 오지는 않는다. 하지만 이 정의에는 스프링의 중요한 특징이 잘 담겨 있다.
애플리케이션 프레임워크
일반적으로 라이브러리나 프레임워크는 특정 업무 분야나 한 가지 기술에 특화된 목표를 가지고 만들어진다. 그러나 애플리케이션 프레임워크는 조금 다르다. 애플리케이션 프레임워크는 특정 계층이나, 기술, 업무 분야에 국한되지 않고 애플리케이션의 전 영역을 포괄하는 범용적인 프레임워크를 말한다. 애플리케이션 프레임워크는 애플리케이션 개발의 전 과정을 빠르고 편리하며 효율적으로 진행하는데 일차적인 목표를 두는 프레임워크다. 단지 여러 계층의 다양한 기술을 한데 모아뒀기 때문에 애플리케이션 프레임워크라고 불리는 건 아니다. 애플리케이션의 전 영역을 관통하는 일관된 프로그래밍 모델과 핵심 기술을 바탕으로 해서 각 분야의 특성에 맞는 필요를 채워주고 있기 때문이다.
경량급
스프링이 경량급이라는 건 스프링 자체가 아주 가볍다거나 작은 규모의 코드로 이뤄졌다는 뜻은 아니다. 그럼에도 가볍다고 하는 이유는 불필요하게 무겁지 않다는 의미다. 특히 스프링이 처음 등장하던 시절의 자바 주류 기술이었던 예전의 EJB 같은 과도한 엔지니어링이 적용된 기술과 스프링을 대비시켜 설명하려고 사용했던 표현이다. 스프링은 가장 단순한 서버환경인 톰캣(Tomcat)이나 제티(Jetty)에서도 완벽하게 동작한다. 단순한 개발툴과 기본적인 개발환경으로도 엔터프라이즈 개발에서 필요로 하는 주요한 기능을 갖춘 애플리케이션을 개발하기에 충분하다. 스프링의 장점은 그런 가볍고 단순한 환경에서도 복잡한 EJB와 고가의 WAS를 갖춰야만 가능했던 엔터프라이즈 개발의 고급 기술을 대부분 사용할 수 있다는 점이다. 결과적으로 스프링은 EJB를 대표로 하는 기존의 많은 기술이 불필요하게 무겁고 복잡했음을 증명한 셈이고, 그런 면에서 스프링은 군더더기 없이 깔끔한 기술을 가진 ‘경량급’ 프레임워크라고 불린 것이다. 만들어진 코드가 지원하는 기술수준은 비슷하더라도 그것을 훨씬 빠르고 간편하게 작성하게 해줌으로써 생산성과 품질 면에서 유리하다는 것이 바로 경량급이라는 말로 표현되는 스프링의 특징이다.
자바 엔터프라이즈 개발을 편하게
스프링은 근본적인 부분에서 엔터프라이즈 개발의 복잡함을 제거해내고 진정으로 개발을 편하게 해주는 해결책을 제시한다. 단순히 편리한 몇 가지 도구나 기능을 제공해주는 차원이 아니다. 편리한 애플리케이션 개발이란 개발자가 복잡하고 실수하기 쉬운 로우레벨 기술에 많은 신경을 쓰지 않으면서도 애플리케이션의 핵심인 사용자의 요구사항, 즉 비즈니스 로직을 빠르고 효과적으로 구현하는 것을 말한다.
오픈소스
스프링의 목적
스프링을 사용해서 엔터프라이즈 애플리케이션 개발을 편하게 하려는 이유는 뭘까? 원래 엔터프라이즈 개발이란 편하지 않기 때문이다.
엔터프라이즈 개발의 복잡함
자바 엔터프라이즈(JavaEE) 개발이 실패하는 가장 대표적인 이유는 ‘엔터프라이즈 시스템 개발이 너무 복잡해져서’였다.
복잡합의 근본적인 이유
엔터프라이즈 시스템 개발이 복잡한 원인은 크게 두 가지가 있다. (엔터프라이즈 시스템이란 서버에서 동작하며 기업과 조직의 업무를 처리해주는 시스템을 말한다.)
기술적인 제약조건과 요구사항이 늘어가기 때문이다.
엔터프라이즈 애플리케이션이 구현해야 할 핵심기능인 비즈니스 로직의 복잡함이 증가하기 때문이다.
전통적인 자바 엔터프라이즈 개발 기법은 대부분 비즈니스 로직의 복잡한 구현 코드와 엔터프라이즈 서비스를 이용하는 기술적인 코드가 자꾸 혼재될 수 밖에 없는 방식이었다. 결국 개발자가 동시에 그 두 가지를 모두 신경 써서 개발해야 하는 과도한 부담을 줬고, 그에 따라 전체적인 복잡함은 몇 배로 가중됐다.
복잡함을 해결하려는 도전
제거될 수 없는 근본적인 복잡함
엔터프라이즈 개발의 근본적인 복잡함의 원인은 제거할 대상은 아니다. 현실적으로는 불가능하기 때문이다.
근본적으로 엔터프라이즈 개발에 나타나는 복잡함의 원인은 제거 대상이 아니다. 대신 그 복잡함을 효과적으로 상대할 수 있는 전략과 기법이 필요하다. 문제는 비즈니스 로직의 복잡함을 효과적으로 다루기 위한 방법과 기술적인 복잡함을 효과적으로 처리하는 데 적용되는 방법이 다르다는 점이다. 따라서 두 가지 복잡함이 코드에 한데 어우러져 나타나는 전통적인 개발 방식에서는 효과적으로 복잡함을 다루기가 힘들다. 따라서 가장 먼저 할 일은 성격이 다른 이 두 가지 복잡함을 분리해내는 것이다.
복잡함을 상대하는 스프링의 전략
스프링의 기본적인 전략은 비즈니스 로직을 담은 애플리케이션 코드와 엔터프라이즈 기술을 처리하는 코드를 분리시키는 것이다. 이 분리를 통해 두 가지 복잡함의 문제를 효과적으로 공략하게 해준다.
기술적 복잡함을 상대하는 전략
기술적인 복잡함을 분리해서 생각하면 그것을 효과적으로 상대할 수 있는 적절한 전략을 발견할 수 있다. 스프링은 엔터프라이즈 기술을 적용했을 때 발생하는 복잡함의 문제를 두 가지로 분류하고 각각에 대한 적절한 대응 방법을 제공한다.
첫 번째 문제 : 기술에 대한 접근 방식이 일관성이 없고, 특정 환경에 종속적이다.
일관성 없는 기술과 서버환경의 변화에 대한 스프링의 공략 방법은 바로 서비스 추상화다. 앞에서 보았던 트랜잭션 추상화, OXM 추상화, 데이터 액세스에 관한 일관된 예외변환 기능, 데이터 액세스 기술에 독립적으로 적용 가능한 트랜잭션 동기화 기법 등이 대포적인 예다. 기술적인 복잡합은 일단 추상화를 통해 로우레벨의 기술 구현 부분과 기술을 사용하는 인터페이스를 분리하고, 환경과 세부기술에 독립적인 접근 인터페이스를 제공하는 것이 가장 좋은 해결책이다.
두 번째 문제 : 기술적인 처리를 담당하는 코드가 성격이 다른 코드에 섞여서 등장한다.
책임에 따라 계층을 구분하고 그 사이에 서로의 기술과 특성에 의존적인 인터페이스나 예외처리 등을 최대한 제거한다고 할지라도 근본적으로 엔터프라이즈 서비스를 적용하는 한 이런 문제는 쉽게 해결할 수 없다. 이런 기술과 비즈니스 로직의 혼재로 발생하는 복잡함을 해결하기 위한 스프링의 접근 방법은 바로 AOP다. AOP는 최후까지 애플리케이션 로직을 담당하는 코드에 남아 있는 기술 관련 코드를 깔끔하게 분리해서 별도의 모듈로 관리하게 해주는 강력한 기술이다.
비즈니스와 애플리케이션 로직의 복잡함을 상대하는 전략
비즈니스 로직의 복잡함을 상대하는 전략은 자바라는 객체지향 기술 그 자체다. 스프링은 단지 객체지향 언어의 장점을 제대로 살리지 못하게 방해했던 요소를 제거하도록 도와줄 뿐이다.
핵심 도구 : 객체지향과 DI
객체지향의 설계 기법을 잘 적용할 수 있는 구조를 만들기 위해 DI 같은 유용한 기술을 편하게 적용하도록 도와주는 것이 스프링의 기본 전략이다.
지금까지 보았듯이 기술적인 복잡함을 효과적으로 다루게 해주는 기법은 모두 DI를 바탕으로 하고 있다. 서비스 추상화, 템플릿/콜백, AOP와 같은 스프링의 기술은 DI 없이는 존재할 수 없는 것들이다.
그리고 DI는 객체지향 설계 기술 없이는 그 존재의미가 없다. DI란 특별한 기술이라기보다는 유연하게 확장할 수 있는 오브젝트 설계를 하다 보면 자연스럽게 적용하게 되는 객체지향 프로그래밍 기법일 뿐이다. 스프링은 단지 그것을 더욱 편하고 쉽게 사용하도록 도와줄 뿐이다.
기술적인 복잡함을 해결하는 문제나 기술적인 복잡함이 비즈니스 로직에 침범하지 못하도록 분리하는 경우에도 DI가 바탕이 된 여러 가지 기법이 활용된다. 반면에 비즈니스 로직 자체의 복잡함을 해결하려면 DI보다는 객체지향 설계 기법이 더 중요하다.
POJO 프로그래밍
스프링 핵심 개발자들은 “스프링의 정수(essence)”는 엔터프라이즈 서비스 기능을 POJO에 제공하는 것”이라고 했다. 엔터프라이즈 서비스라고 하는 것은 보안, 트랜잭션과 같은 엔터프라이즈 시스템에서 요구되는 기술을 말한다. 이런 기술을 POJO에 제공한다는 말은, 뒤집어 생각해보면 엔터프라이즈 서비스 기술과 POJO라는 애플리케이션 로직을 담은 코드를 분리했다는 뜻이기도 하다. ‘분리됐지만 반드시 필요한 엔터프라이즈 서비스 기술을 POJO 방식으로 개발된 애플리케이션 핵심 로직을 담은 코드에 제공한다’는 것이 스프링의 가장 강력한 특징과 목표다.
스프링의 핵심 : POJO
스프링 애플리케이션은 POJO를 이용해서 만든 애플리케이션 코드와, POJO가 어떻게 관계를 맺고 동작하는지를 정의해놓은 설계정보로 구분된다. DI의 기본 아이디어는 유연하게 확장 가능한 오브젝트를 만들어두고 그 관계는 외부에서 다이내믹하게 설정해준다는 것이다. 이런 DI의 개념을 애플리케이션 전반에 걸쳐 적용하는 것이 스프링의 프로그래밍 모델이다.
스프링의 주요 기술인 IoC/DI, AOP, 서비스추상화는 애플리케이션을 POJO로 개발할 수 있게 해주는 가능기술이라고 불린다.
POJO란 무엇인가?
POJO는 Plain Old Java Object의 첫 글자를 따서 만든 약자다.
POJO의 조건
단순하게 보자면 그냥 평범한 자바오브젝트라고 할 수 있지만 좀 더 명확하게 하자면 적어도 다음의 조건을 충족해야 POJO라고 불릴 수 있다.
특정 규약에 종속되지 않는다.
POJO는 자바 언어와 꼭 필요한 API 외에는 종속되지 않아야 한다. 따라서 EJB2와 같이 특정 규약을 따라 비즈니스 컴포넌트를 만들어야 하는 경우는 POJO가 아니다. 특정 규약을 따라 만들게 하는 경우는 대부분 규약에서 제시하는 특정 클래스를 상속하도록 요구한다. 그럴 경우 자바의 단일 상속 제한 때문에 더 이상 해당 클래스에 객체지향적인 설계 기법을 적용하기가 어려워지는 문제가 생긴다. 또한 규약이 적용된 환경에 종속적이 되기 때문에 다른 환경으로 이전이 힘들다는 문제점이 있다.
특정 환경에 종속되지 않는다.
어떤 경우는 특정 벤더의 서버나 특정 기업의 프레임워크 안에서만 동작 가능한 코드로 작성되기도 한다. 또 환경에 종속적인 클래스나 API를 직접 쓴 경우도 있다. 순수한 애플리케이션 로직을 담고 있는 오브젝트 코드가 특정 환경에 종속되게 만드는 경우라면 그것 역시 POJO라고 할 수 없다. POJO는 환경에 독립적이어야한다. 특히 비즈니스 로직을 담고 있는 POJO 클래스는 웹이라는 환경정보나 웹 기술을 담고 있는 클래스나 인터페이스를 사용해서는 안된다. 비즈니스 로직을 담은 코드에 HttpServletRequest나 HttpSession, 캐시와 관련된 API가 등장하거나 웹 프레임워크의 클래스를 직접 이용하는 부분이 있다면 그것은 진정한 POJO라고 볼 수 없다. 단지 자바의 문법을 지키고, 순수하게 JavaSE API만을 사용했다고 해서 그 코드를 POJO라고 할 수는 없다. POJO는 객체지향적인 자바 언어의 기본에 충실하게 만들어져야 하기 때문이다.
POJO의 장점
POJO가 될 수 있는 조건이 그대로 POJO의 장점이 된다.
특정한 기술과 환경에 종속되지 않는 오브젝트는 그만큼 깔끔한 코드가 될 수 있다. 로우레벨의 기술과 환경에 종속적인 코드가 비즈니스 로직과 함께 섞여 나오는 것만큼 지저분하고 복잡한 코드도 없다.
POJO로 개발된 코드는 자동화된 테스트에 매우 유리하다. 환경의 제약은 코드의 자동화된 테스트를 어렵게 한다. 컨테이너에서만 동작을 확인할 수 있는 EJB 2는 테스트하려면 서버의 구동 및 빌드와 배치 과정까지 필요하다. 자동화된 테스트가 불가능한 건 아니지만 매우 복잡하고 번거로우므로 대부분 수동 테스트 방식을 선호한다. 그에 반해 어떤 환경에도 종속되지 않은 POJO 코드는 매우 유연한 방식으로 원하는 레벨에서 코드를 빠르고 명확하게 테스트할 수 있다.
객체지향적인 설계를 자유롭게 적용할 수 있다는 것도 큰 장점이다.
POJO 프레임워크
스프링은 POJO를 이용한 엔터프라이즈 애플리케이션 개발을 목적으로 하는 프레임워크이다. POJO 프로그래밍이 가능하도록 기술적인 기반을 제공하는 프레임워크를 POJO 프레임워크라고 한다. 스프링은 엔터프라이즈 애플리케이션 개발의 모든 영역과 계층에서 POJO 방식의 구현이 가능하게 하려는 목적으로 만들어졌다.
스프링을 이용하면 POJO 프로그래밍의 장점을 그대로 살려서 엔터프라이즈 애플리케이션의 핵심 로직을 객체지향적인 POJO를 기반으로 깔끔하게 구현하고, 동시에 엔터프라이즈 환경의 각종 서비스와 기술적인 필요를 POJO 방식으로 만들어진 코드에 적용할 수 있다.
스프링은 비즈니스 로직의 복잡함과 엔터프라이즈 기술의 복잡함을 분리해서 구성할 수 있게 도와준다. 하지만 자신은 기술영역에만 관여하지 비즈니스 로직을 담당하는 POJO에서는 모습을 감춘다. 데이터 액세스 로직이나 웹 UI 로직을 다룰 때만 최소한의 방법으로 관여한다. POJO 프레임워크로서 스프링은 자신을 직접 노출하지 않으면서 애플리케이션을 POJO로 쉽게 개발할 수 있게 지원해준다.
스프링의 기술
제어의 역전(IoC) / 의존관계 주입(DI)
왜 두 개의 오브젝트를 분리해서 만들고, 인터페이스를 두고 느슨하게 연결한 뒤, 실제 사용할 대상은 DI를 통해 외부에서 지정하는 것일까? 직접 자신이 사용할 오브젝트를 new 키워드로 생성해서 사용하는 강한 결합을 쓰는 방법보다 나은 점은 무엇일까?
가장 간단한 답변은 **’유연한 확징이 가능하게 하기 위함’**이다. DI는 개방 폐쇄 원칙(OCP)이라는 객체지향 설계 원칙으로 잘 설명될 수 있다. 유연한 확장이라는 장점은 OCP의 ‘확장에는 열려 있다(개방)’에 해당한다. DI는 역시 OCP의 ‘변경에는 닫혀 있다(폐쇄)’라는 말로도 설명이 가능하다. 폐쇄 관점에서 볼 때 장점은 ‘재사용이 가능하다’라고 볼 수 있다.
DI의 활용 방법
핵심기능의 변경
DI의 가장 대표적인 적용 방법은 바로 의존 대상의 구현을 바꾸는 것이다. 디자인 패턴의 전략 패턴이 대표적인 예다. 실제 의존하는 대상이 가진 핵심기능을 DI 설정을 통해 변경하는 것이 대표적인 DI의 활용 방법이다.
핵심기능의 동적인 변경
두 번째 활용 방법은 첫 번째랑 비슷하게 의존 오브젝트의 핵심 기능 자체를 바꾸는 것이다. DI도 기본적으로는 런타임 시에 동적으로 의존 오브젝트를 연결해주는 것이긴 하지만, 일단 DI 되고 나면 그 후로는 바뀌지 않는다. 즉 동적인 방식으로 연결되지만 한 번 DI되면 바뀌지 않는 정적인 관계를 맺어주는 것이다. 하지만 DI를 잘 활용하면 애플리케이션이 동작하는 중간에 그 의존 대상을 다이내믹하게 변경할 수 있다.
부가기능의 추가
세 번째 활용 방법은 핵심기능은 그대로 둔 채로 부가기능을 추가하는 것이다. 데코레이터 패턴을 생각해보면 된다. 인터페이스를 두고 사용하게 하고, 실제 사용할 오브젝트는 외부에서 주입하는 DI를 적용해두면 데코레이터 패턴을 쉽게 적용할 수 있다. 그래서 핵심기능과 클라이언트 코드에는 전혀 영향을 주지 않으면서 부가적인 기능을 얼마든지 추가할 수 있다.
인터페이스의 변경
사용하려고 하는 오브젝트가 가진 인터페이스가 클라이언트와 호환되지 않는 경우가 있다. 이렇게 클라이언트가 사용하는 인터페이스와 실제 오브젝트 사이에 인터페이스가 일치하지 않는 경우에도 DI가 유용하다. 디자인 패턴에서 말하는 오브젝트 방식의 어댑터 패턴의 응용이라고 볼 수 있다. 이를 좀 더 일반화해서 아예 인터페이스가 다른 다양한 구현을 같은 방식으로 사용하도록, 중간에 인터페이스 어댑터 역할을 해주는 레이어를 하나 추가하는 방법도 있다. 서비스 추상화(PSA)가 그런 방법이다. PSA는 클라이언트가 일관성 있게 사용할 수 있는 인터페이스를 정의해주고 DI를 통해 어댑터 역할을 하는 오브젝트를 이용하게 해준다. 이를 통해 다른 인터페이스를 가진 로우레벨의 기술을 변경하거나 확장해가면서 사용할 수 있는 것이다.
프록시
필요한 시점에서 실제 사용할 오브젝트를 초기화하고 리소스를 준비하게 해주는 지연된 로딩(lazy loading)을 적용하려면 프록시가 필요하다. 원격 오브젝트를 호출할 때 마치 로컬에 존재하는 오브젝트처럼 사용할 수 있게 해주는 원격 프록시를 적용하려고 할 때도 프록시가 필요하다. 두 가지 방법 모두 DI를 필요로 한다.
템플릿과 콜백
템플릿/콜백 패턴은 DI의 특별한 적용 방법이다. 반복적으로 등장하지만 항상 고정적인 작업 흐름과 그 사이에서 자주 바뀌는 부분을 분리해서 템플릿과 콜백으로 만들 고 이를 DI 원리를 응용해 적용하면 지저분하게 매번 만들어야 하는 코드를 간결하게 만들 수 있다.
싱글톤과 오브젝트 스코프
DI가 필요한 중요한 이유 중 한 가지는 DI 할 오브젝트의 생명주기를 제어할 수 있다는 것이다. DI를 프레임워크로 이용한다는 건 DI 대상 오브젝트를 컨테이너가 관리한다는 의미다. 오브젝트의 생성부터 관계 설정, 이용, 소멸에 이르기까지의 모든 과정을 DI 컨테이너가 주관하기 때문에 그 오브젝트의 스코프를 자유롭게 제어할 수 있다. 스프링의 DI는 기본적으로 싱글톤으로 오브젝트를 만들어서 사용하게 한다. 컨테이너가 알아서 싱글톤으로 만들고 관리하기 때문에 클래스 자체는 싱글톤을 고려하지 않고 자유롭게 설계해도 된다는 장점이 있다.
테스트
다른 오브젝트와 협력해서 동작하는 오브젝트를 효과적으로 테스트하는 방법은 가능한 한 고립시키는 것이다. 즉 다른 오브젝트와의 사이에서 일어나는 일을 테스트를 위해 조작할 수 있도록 만든다. 그래야만 테스트 대상인 오브젝트의 기능에 충실하게 테스트가 가능하다. 복잡한 테스트할 대상에 의존하는 오브젝트를, 테스트를 목적으로 만들어진 목 오브젝트로 대체하면 유용하다.
애스펙트 지향 프로그래밍(AOP)
AOP도 스프링의 3개 기술중의 하나다. 사실 애스펙트 지향 프로그래밍은 객체지향 프로그래밍(OOP)처럼 독립적인 프로그래밍 패러다임이 아니다. AOP와 OOP는 서로 배타적이 아니라는 말이다.
객체지향 기술은 매우 성공적은 프로그래밍 방식임에 분명하다. 하지만 한편으로는 복잡해져 가는 애플리케이션의 요구조건과 기술적인 난해함을 모두 해결하는데 한계가 있기도 하다. AOP는 바로 이러한 객체지향 기술의 한계와 단점을 극복하도록 도와주는 보조적인 프로그래밍 기술이다.
IOC/DI를 이용해서 POJO에 선언적인 엔터프라이즈 서비스를 제공할 수 있지만 일부 서비스는 순수한 객체지향 기법만으로는 POJO의 조건을 유지한 채로 적용하기 힘들다. 바로 이런 문제를 해결하기 위해 AOP가 필요하다.
AOP 적용 기법
AOP를 자바 언어에 적용하는 기법은 크게 두 가지로 분류할 수 있다.
스프링과 같이 다이내믹 프록시를 사용하는 방법
이 방법은 기존 코드에 영향을 주지 않고 부가기능을 적용하게 해주는 데코레이터 패턴을 응용한 것이다. 만들기 쉽고 적용하기 간편하지만 부가기능을 부여할 수 있는 곳은 메소드의 호출이 일어나는 지점뿐이라는 제약이 있다. 인터페이스와 DI를 활용하는 데코레이터 패턴이 기반원리이기 때문이다.
자바 언어의 한계를 넘어서는 언어의 확장을 이용하는 방법
AspectJ라는 유명한 오픈소스 AOP 툴이 있다. AspectJ는 프록시 방식의 AOP에서는 불가능한 다양한 조인포인트를 제공한다. 메소드 호출뿐 아니라 인스턴스 생성, 필드 액세스, 특정 호출 경로를 가진 메소드 호출 등에도 부가기능을 제공할 수 있다. 이런 고급 AOP 기능을 적용하려면 자바 언어와 JDK의 지원만으로는 불가능하다. 그 대신 별도의 AOP 컴파일러를 이용한 빌드 과정을 거치거나, 클래스가 메모리로 로딩될 때 그 바이트 코드를 조작하는 위빙과 같은 별도의 방법을 이용해야 한다.
포터블 서비스 추상화(PSA)
세 번째 기능기술은 환경과 세부 기술의 변화에 관계없이 일관된 방식으로 기술에 접근 할 수 있게 해주는 PSA(Portable Service Abstraction)다. POJO로 개발된 코드는 특정 환경이나 구현 방식에 종속적이지 않아야 한다. 스프링은 JavaEE를 기존 플랫폼으로 하는 자바 엔터프라이즈 개발에 주로 사용된다. 따라서 다양한 JavaEE 기술에 의존적일 수밖에 없다 .특정 환경과 기술에 종속적이지 않다는 게 그런 기술을 사용하지 않는다는 뜻은 아니다. 다만 POJO 코드가 그런 기술에 직접 노출되어 만들어지지 않는다는 말이다. 이를 위해 스프링이 제공하는 대표적인 기술이 바로 일관성 있는 서비스 추상화 기술이다.
AOP를 바르게 이용하려면 OOP를 대체하려고 하는 것처럼 보이는 AOP라는 이름 뒤에 감춰진, 그 필연적인 등장배경과 스프링이 그것을 도입한 이유, 그 적용을 통해 얻을 수 있는 장점이 무엇인지에 대한 충분한 이해가 필요하다.
스프링에 적용된 가장 인기 있는 AOP의 적용 대상은 바로 선언적 트랜잭션 기능이다. 서비스 추상화를 통해 많은 근본적인 문제를 해결했던 트랜잭션 경계설정 기능을 AOP를 이용해 더욱 세련되고 깔끔한 방식으로 바꿔보자.
트랜잭션 코드의 분리
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
publicvoidupgradeLevels()throws Exception { TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition()); try { List<User> users = userDao.getAll(); for (User user : users) { if (canUpgradeLevel(user)) { upgradeLevel(user); } } this.transactionManager.commit(status); } catch (Exception e) { this.transactionManager.rollback(status); throw e; } }
얼핏 보면 트랜잭션 경계설정 코드와 비즈니스 로직 코드가 복잡하게 얽혀 있는듯이 보이지만, 자세히 살펴보면 뚜렷하게 두 가지 종류의 코드가 구분되어 있음을 알 수 있다. 비즈니스 로직 코드를 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 위치하고 있다.
또, 이 코드의 특징은 트랜잭션 경계설정의 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보가 없다는 점이다. 다만 이 비즈니스 로직을 담당하는 코드가 트랜잭션의 시작과 종료 작업 사이에서 수행돼야 한다는 사항만 지켜지면 된다.
DI를 이용한 클래스의 분리
DI 적용을 이용한 트랜잭션 분리
지금 UserService는 UserServiceTest가 클라이언트가 되어서 사용하고 있다. 현재 구조는 UserService 클래스와 그 사용 클라이언트 간의 관계가 강한 결합도로 고정되어 있다. 그래서 UserService를 인터페이스로 만들고 기존 코드는 UserService 인터페이스의 구현 클래스를 만들어넣도록 한다. 그러면 클라이언트와 결합이 약해지고, 직접 구현 클래스에 의존하고 있지 않기 때문에 유연한 확장이 가능해진다.
그런데 보통 이렇게 인터페이스를 이용해 구현 클래스를 클라이언트에 노출하지 않고 런타임 시에 DI를 통해 적용하는 방법을 쓰는 이유는, 일반적으로 구현 클래스를 바꿔가면서 사용하기 위해서다.
하지만 꼭 그래야 한다는 제약은 없다. 지금 해결하려고 하는 문제는 UserService에는 순수하게 비즈니스 로직을 담고 있는 코드만 두고 트랜잭션 경계설정을 담당하는 코드를 외부로 빼내려는 것이다.
그래서 다음과 같은 구조를 생각해볼 수 있다. UserService를 구현한 또 다른 구현 클래스를 만든다. 이 클래스는 사용자 관리 로직을 담고 있는 구현 클래스인 UserServiceImpl을 대신하기 위해 만든 게 아니다. 단지 트랜잭션의 경계설정이라는 책임을 맡고 있을 뿐이다. 그리고 스스로는 비즈니스 로직을 담고 있지 않기 때문에 또 다른 비즈니스 로직을 담고 있는 UserService의 구현 클래스에 실제적인 로직 처리 작업은 위임하는 것이다.
UserService 인터페이스 도입
먼저 기존의 UserService 클래스를 UserServiceImpl로 이름을 변경한다. 그리고 클라이언트가 사용할 로직을 담은 핵심 메소드만 UserService 인터페이스로 만든 후 UserServiceImpl이 구현하도록 만든다.
UserService 인터페이스의 구현 클래스인 UserServiceImpl은 기존 UserService 클래스의 내용을 대부분 그대로 유지하면 된다. 단, 트랜잭션 관련된 코드는 독립시키기로 했으니 모두 제거한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
publicclassUserServiceImplimplementsuserService{ UserDao userDao; MailSender mailSender; publicvoidupgradeLevels(){ List<User> users = userDao.getAll(); for (User user : users) { if (canUpgradeLevel(user)) { upgradeLevel(user); } } } ... }
분리된 트랜잭션 기능
비즈니스 트랜잭션 처리를 담은 UserServiceTx를 만들어보자. UserServiceTx는 기본적으로 UserService를 구현하게 만든다. 그리고 같은 인터페이스를 구현한 다른 오브젝트에게 고스란히 작업을 위임하게 만들면 된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
publicclassUserServiceTximplementsUserService{ UserService userService; // UserService를 구현한 다른 오브젝트를 DI 받는다. publicvoidsetUserService(UserService userService){ this.userService = userService; } // DI 받은 UserService 오브젝트에 모든 기능을 위임한다. publicvoidadd(User user){ userService.add(user); } publicvoidupgradeLevels(){ userService.upgradeLevels(); } }
UserServiceTx는 UserService 인터페이스를 구현했으니, 클라이언트에 대해 UserService 타입 오브젝트의 하나로서 행세할 수 있다. UserServiceTx는 사용자 관리라는 비즈니스 로직을 전혀 갖지 않고 고스란히 다른 UserService 구현 오브젝트에 기능을 위임한다. 이렇게 준비된 UserServiceTx에 트랜잭션의 경계설정이라는 부가적인 작업을 부여해보자.
클라이언트가 UserService라는 인터페이스를 통해 사용자 관리 로직을 이용하려고 할 때 먼저 트랜잭션을 담당하는 오브젝트가 사용돼서 트랜잭션에 관련된 작업을 진행해주고, 실제 사용자 관리 로직을 담은 오브젝트가 이후에 호출돼서 비즈니스 로직에 관련된 작업을 수행하도록 만든다.
트랜잭션 경계설정 코드 분리의 장점
비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경 쓰지 않아도 된다. 트랜잭션의 적용이 필요한지도 신경 쓰지 않아도 된다.
비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다.
고립된 단위 테스트
가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다.
하지만 현재 UserService는 UserDao, TransactionManager, MailSender라는 세 가지 의존관계를 갖고 있다. 따라서 그 세 가지 의존관계를 갖는 오브젝트들이 테스트가 진행되는 동안에 같이 실행된다. UserService라는 테스트 대상이 테스트 단위인 것처럼 보이지만 사실은 그 뒤에 의존관계를 따라 등장하는 오브젝트와 서비스, 환경 등이 모두 합쳐져 테스트 대상이 되는 것이다.
테스트 대상 오브젝트 고립시키기
그래서 테스트의 대상이 환경이나, 외부 서버, 다른 클래스의 코드에 종속되고 영향을 받지 않도록 고립시킬 필요가 있다. 테스트를 의존 대상으로부터 분리해서 고립시키는 방법은 테스트를 위한 대역을 사용하는 것이다.
테스트를 위한 UserServiceImpl 고립
의존 오브젝트나 외부 서비스에 의존하지 않는 고립된 테스트 방식으로 만든 UserServiceImpl은 아무리 그 기능이 수행돼도 그 결과가 DB 등을 통해서 남지 않으니, 기존의 방법으로는 작업 결과를 검증하기 힘들다. upgradeLevels()처럼 결과가 리턴되지 않는 경우는 더더욱 그렇다.
그래서 이럴 땐 테스트 대상인 UserServiceImpl과 그 협력 오브젝트인 UserDao에게 어떤 요청을 했는지를 확인하는 작업이 필요하다. 테스트 중에 DB에 결과가 반영되지는 않았지만, UserDao의 update() 메소드를 호출하는 것을 확인할 수 있다면, 결국 DB에 그 결과가 반영될 것이라고 결론을 내릴 수 있기 때문이다. UserDao와 같은 역할을 하면서 UserServiceImpl과의 사이에서 주고받은 정보를 저장해뒀다가, 테스트의 검증에 사용할 수 있게 하는 목 오브젝트를 만들 필요가 있다.
단위 테스트와 통합 테스트
단위 테스트의 단위는 정하기 나름이다. 사용자 관리 기능 전체를 하나의 단위로 볼 수도 있고 하나의 클래스나 하나의 메소드를 단위로 볼 수도 있다. 중요한 것은 하나의 단위에 초점을 맞춘 테스트라는 점이다.
‘테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트 하는 것’을 단위 테스트라고 부른다. 반면에 두 개 이상의, 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하거나, 또는 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트는 통합 테스트라고 부른다. 통합 테스트란 두 개 이상의 단위가 결합해서 동작하면서 테스트가 수행되는 것이라고 보면 된다. 스프링의 테스트 컨텍스트 프레임워크를 이용해서 컨텍스트에서 생성되고 DI된 오브젝트를 테스트하는 것도 통합 테스트다.
아래는 단위 테스트와 통합 테스트 중에서 어떤 방법을 쓸지 어떻게 결장할 것인지에 대한 가이드 라인이다.
항상 단위 테스트를 먼저 고려한다.
하나의 클래스나 성격과 목적이 같은 긴밀한 클래스 몇 개를 모아서 외부와의 의존관계를 모두 차단하고 필요에 따라 스텁이나 목 오브젝트 등의 테스트 대역을 이용하도록 테스트를 만든다. 단위 테스트는 테스트 작성도 간단하고 실행 속도도 빠르며 테스트 대상 외의 코드나 환경으로부터 테스트 결과에 영향을 받지도 않기 때문에 가장 빠른 시간에 효과적인 태스트를 작성하기에 유리하다.
외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
단위 테스트로 만들기가 어려운 코드도 있다. 대표적인 게 DAO다. DAO는 그 자체로 로직을 담고 있기보다는 DB를 통해 로직을 수행하는 인터페이스와 같은 역할을 한다. SQL을 JDBC를 통해 실행하는 코드만으로는 고립된 테스트를 작성하기가 힘들다. 작성한다고 해도 가치가 없는 경우가 대부분이다. 따라서 DAO는 DB까지 연동하는 테스트로 만드는 편이 효과적이다. DB를 사용하는 테스트는 DB에 테스트 데이터를 준비하고, DB를 사용하는 테스트는 DB에 테스트 데이터를 준비하고, DB에 직접 확인을 하는 드의 부가적인 작업이 필요하다.
DAO 테스트는 DB라는 외부 리소스를 사용하기 때문에 통합 테스트로 분류된다. 하지만 코드에서 보자면 하나의 기능 단위를 테스트하는 것이기도 하다. DAO를 테스트를 통해 충분히 검증해두면, DAO를 이용하는 코드는 DAO 역할을 스텁이나 목 오브젝트로 대체해서 테스트할 수 있다. 이후에 실제 DAO와 연동했을 때도 바르게 동작하리라고 확신할 수 있다. 물론 각각의 단위 테스트가 성공했더라도 여러 개의 단위를 연결해서 테스트하면 오류가 발생할 수도 있다. 하지만 충분한 단위 테스트를 거친다면 통합 테스트에서 오류가 발생할 확률도 줄어들고 발생한다고 하더라도 쉽게 처리할 수 있다.
여러 개의 단위가 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 상대적으로 줄어든다.
단위 테스트를 만들기가 너무 복잡하다고 판단되는 코드는 처음부터 통합 테스트를 고려해본다. 이때도 통합 테스트에 참여하는 코드 중에서 가능한 한 많은 부분을 미리 단위 테스트로 검증해두는 게 유리하다.
스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트는 통합 테스트다. 간으하면 스프링의 지원 없이 직접 코드 레벨의 DI를 사용하면서 단위 테스트를 하는게 좋겠지만 스프링의 설정 자체도 테스트 대상이고, 스프링을 이용해 좀 더 추상적인 레벨에서 테스트해야 할 경우도 종종 있다. 이럴 땐 스프링 테스트 컨텍스트 프레임워크를 이용해 통합 테스트를 작성한다.
목 프레임워크
단위 테스트를 만들기 위해서는 스텁이나 목 오브젝트의 사용이 필수적이다. 의존관계가 없는 단순한 클래스나 세부 로직을 검증하기 위해 메소드 단위로 테스트할 때가 아니라면, 대부분 의존 오브젝트를 필요로 하는 코드를 테스트하게 되기 때문이다.
목 오브젝트를 만드는 일은 번거로울 수 있다. 그러나 이런 번거로운 목 오브젝트를 편리하게 작성하도록 도와주는 다양한 목 오브젝트 지원 프레임워크가 있다.
Mockito 프레임워크
Mockito라는 프레임워크는 사용하기도 편리하고, 코드도 직관직이라 최근 많은 인기를 끌고 있다.
Mockito와 같은 목 프레임워크의 특징은 목 클래스를 일일이 준비해둘 필요가 없다는 것이다. 간단한 메소드 호출만으로 다이내믹하게 특정 인터페이스를 구현한 테스트용 목 오브젝트를 만들 수 있다.
USerDao 인터페이스를 구현한 테스트용 목 오브젝트는 다음과 같이 Mockito의 스태틱 메소드를 한 번 호출해주면 만들어진다.
1
UserDao mockUserDao = mock(UserDao.class);
이렇게 만들어진 목 오브젝트는 아직 아무런 기능이 없다. 여기에 먼저 getAll() 메소드가 불려올 때 사용자 목록을 리턴하도록 스텁 기능을 추가해줘야 한다.
User 타입의 오브젝트를 파라미터로 받으며 update() 메소드가 두 번 호출됐는지(times(2)) 확인하라(verify)는 것이다.
Mockito 목 오브젝트는 다음의 네 단계를 거쳐서 사용하면 된다. 두 번째와 네 번째는 각각 필요할 경우에만 사용할 수 있다.
인터페이스를 이용해 목 오브젝트를 만든다.
목 오브젝트가 리턴할 값이 있으면 이를 지정해준다. 메소드가 호출되면 예외를 강제로 던지게 만들 수도 있다.
테스트 대상 오브젝트에 DI 해서 목 오브젝트가 테스트 중에 사용되도록 만든다.
테스트 대상 오브젝트를 사용한 후에 목 오브젝트의 특정 메소드가 호출됐는지, 어떤 값을 가지고 몇 번 호출됐는지를 검증한다.
ArgumentCaptor는 목 오브젝트에 전달된 파라미터를 가져와 내용을 검증하기 위해 사용한다. 파라미터를 직접 비교하기 보다는 파라미터의 내부 정보를 확인해야 하는 경우에 유용하다.
다이내믹 프록시와 팩토리빈
프록시와 프록시 패턴, 데코레이터 패턴
부가기능 외의 나머지 모든 기능은 원래 핵심기능을 가진 클래스로 위임해줘야 한다. 핵심 기능은 부가기능을 가진 클래스의 존재 자체를 모른다. 따라서 부가기능이 핵심기능을 사용하는 구조가 되는 것이다
문제는 이렇게 구성했더라도 클라이언트가 핵심기능을 가진 클래스를 직접 사용해 버리면 부가기능이 적용될 기회가 없다는 점이다. 그래서 부가기능은 마치 자신이 핵심 기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야 한다. 그러기 위해서는 클라이언트는 인터페이스를 통해서만 핵심기능을 사용하게 하고, 부가기능 자신도 같은 인터페이스를 구현한 뒤에 자신이 그 사이에 끼어들어야 한다.
이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요처을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 **프록시(proxy)**라고 부른다. 그리고 프록시를 통해 최종적으로 요처을 위임받아 처리하는 실제 오브젝트를 타깃(target) 또는 실체(real subject)라고 부른다.
프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 것과 프록시가 타깃을 제어할 수 있는 위치에 있다는 것이다.
프록시는 사용 목적에 따라 두 가지로 구분할 수 있다. 첫째는 클라이언트가 타깃에 접근하는 방법을 제어하기 위해서다. 두 번째는 타깃에 부가적인 기능을 부여해주기 위해서다. 두 가지 모두 대리 오브젝트라는 개념의 프록시를 두고 사용한다는 점은 동일하지만, 목적에 따라서 디자인 패턴에서느 다른 패턴으로 구분한다.
데코레이터 패턴
데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시에 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다. 다이내믹하게 기능을 부여한다는 의미는 컴파일 시점, 즉 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다. 데코레이터 패턴에서는 프록시가 꼭 한 개로 제한되지 않는다. 데코레이터 패턴에서는 같은 인터페이스를 구현한 타겟과 여러 개의 프록시를 사용할 수 있다.
프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 알지 못한다. 그래서 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 런타임 시에 주입받을 수 있도록 만들어야 한다.
인터페이스를 통한 데코레이터 정의와 런타임 시의 다이내믹한 구성 방법은 스프링의 DI를 이요하면 아주 편리하다. 데코레이터 빈의 프로퍼티로 같은 인터페이스를 구현한 다른 데코레이터 또는 타깃 빈을 설정하면 된다.
데코레이터 패턴은 타깃의 코드를 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용한 방법이다.
프록시 패턴
일반적으로 사용하는 프록시라는 용어와 디자인 패턴에서 말하는 프록시 패턴은 구분할 필요가 있다. 전자는 클라이언트와 사용 대상 사이에 대리 역할을 맡은 오브젝트를 두는 방법을 말한다면, 후자는 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 가리킨다.
프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다.
프록시 패턴은 타깃의 기능 자체에는 관여하지 않으면서 접근하는 방법을 제어해주는 프록시를 이용하는 것이다. 구조적으로 보자면 프록시와 데코레이터는 유사하다. 다만 프록시는 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많다. 생성을 지연하는 프록시라면 구체적인 생성 방법을 알아야 하기 때문에 타깃 클래스에 대한 직접적인 정보를 알아야 한다. 물론 프록시 팽턴이라고 하더라도 인터페이스를 통해 위임하도록 만들 수도 있다. 인터페이스를 토앻 다음 호출 대상으로 접근하게 되면 그 사이에 다른 프록시나 데코레이터가 계속 추가될 수 있기 때문이다.
앞으로는 타깃과 동일한 인터페이스를 구현하고 클라이언트와 타깃 사이에 존재하면서 기능의 부가 또는 접근 제어를 담당하는 오브젝트를 모두 프록시라고 부르겠다. 하지만 그때마다 사용의 목적이 기능의 부가인지, 접근 제어인지를 구분해보면 각각 어떤 목적으로 프록시가 사용됐는지, 그에 따라 어떤 패턴이 적용됐는지 알 수 있을 것이다.
다이내믹 프록시
목 오브젝트를 만드는 불편함을 목 프레임워크를 사용해 편리하게 바꿨던 것처럼 프록시도 일일이 모든 인터페이스를 구현해서 클래스를 새로 정의하지 않고도 편리하게 만들어서 사용할 방법이 있다.
자바에는 java.lang.reflect 패키지 안에 프록시를 손쉽게 만들 수 있도록 지원해주는 클래스들이 있다. 일일이 프록시 클래스를 정의하지 않고도 몇 가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성하는 것이다.
프록시의 구성과 프록시 작성의 문제점
프록시는 다음의 두 가지 기능으로 구성된다.
타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임한다.
지정된 요청에 대해서는 부가기능을 수행한다.
프록시를 만들기가 번거로운 이유는 두 가지가 있다.
첫째는 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다는 점이다. 부가기능이 필요 없는 메소드도 구현해서 타깃으로 위임하는 코드를 일일이 만들어줘야 한다. 또, 타깃 인터페이스의 메소드가 추가되거나 변경될 때마다 함께 수정해줘야 한다는 부담도 있다.
두 번째 문제점은 부가기능 코드가 중복될 가능성이 많다는 점이다.
첫 번째 문제인 인터페이스 메소드의 구현과 위임 기능 문제는 간단해 보이지 않는다. 바로 이런 문제를 해결하는 데 유용한 것이 바로 JDK의 다이내믹 프록시다.
리플렉션
다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다. 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다.
자바의 모든 클래스는 그 클래스 자체의 구성정보를 담은 Class 타입의 오브젝트를 하나씩 갖고 있다. ‘클래스이름.class’라고 하거나 오브젝트의 getClass() 메소드를 호출하면 클래스 정보를 담은 Class 타입의 오브젝트를 가져올 수 있다. 클래스 오브젝트를 이용하면 크래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있다.
다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다. 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어진다. 클라이언트는 다이내믹 프록시 오브젝트를 타깃 인터페이스를 통해 사용할 수 있다. 이 덕분에 프록시를 만들 때 인터페이스를 모두 구현해가면서 클래스를 정의하는 수고를 덜 수 있다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문이다.
다이내믹 프록시가 인터페이스 구현 클래스의 오브젝트는 만들어주지만, 프록시로서 필요한 부가기능 제공 코드는 직접 생성해야 한다. 부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담는다. InvocationHandler 인터페이스는 다음과 같은 메소드 한 개만 가진 간단한 인터페이스다.
1
public Object invoke(Object proxy, Method method, Object[] args)
invoke() 메소드는 리플렉션의 MEthod 인터페이스를 파라미터로 받는다. 메소드를 호출할 때 전달되는 파라미터도 args로 받는다. 다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것이다. 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다.
InvocationHandler 구현 오브젝트가 타깃 오브젝트 레퍼런스를 갖고 있다면 리플렉션을 이용해 간단히 위임 코드를 만들어 낼 수 있다.
InvocationHandler 인터페이스를 구현한 오브젝트를 제공해주면 다이내믹 프록시가 받는 모든 요청을 InvocationHandler의 invoke() 메소드로 보내준다.
다이내믹 프록시로부터 요청을 전달받으려면 InvocationHandler를 구현해야 한다. 메소드는 invoke() 하나뿐이다. 다이내믹 프록시가 클라이언트로부터 받는 모든 요청은 incoke() 메소드로 전달된다. 다이내믹 프록시를 통해 요청이 전달되면 리플렉션 API를 이용해 타깃 오브젝트의 메소드를 호출한다. 타깃 오브젝트는 생성자를 통해 미리 전달받아 둔다.
다이내믹 프록시의 생성은 Proxy 클래스의 newProxyInstance() 스태틱 팩토리 메소드를 이용하면 된다.
1 2 3 4 5 6 7 8
// 생성된 다이내믹 프록시 오브젝트는 Hello 인터페이스를 구현하고 있으므로 Hello 타입으로 캐스팅해도 안전하다. Hello proxiedHello = (Hello)Proxy.newProxyInstance( // 동적으로 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더 getClass().getClassLoader(), // 구현할 인터페이스 new Class[] { Hello.class }, // 부가기능과 위임 코드를 담은 InvocationHandler new UppercaseHandler(new HelloTarget()));
다이내믹 프록시의 확장
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
publicclassUppercaseHandlerimplementsInvocationHandler{ // 어떤 종류의 인터페이스를 구현한 타깃에도 적용 가능하도록 Object 타입으로 수정 Object target; privateUppercaseHandler(Object target){ this.target = target; } public Object invoke(Object proxy, Method method, Object[] args) thorws Throwable { Object ret = method.invoke(target, args); if (Ret instanceof String) { return ((String)ret).toUpperCase(); } else { return ret; } } }
이제 TransactionHandler와 다이내믹 프록시를 스프링의 DI를 통해 사용할 수 있도록 만들어야 할 차례다.
스프링의 빈은 기본적으로 클래스 이름과 프로퍼티로 정의된다. 스프링은 지정된 클래스 이름을 가지고 리플렉션을 이용해서 해당 클래스의 오브젝트를 만든다. 클래스의 이름을 갖고 있다면 다음과 같은 방법으로 새로운 오브젝트를 생성할 수 있다. Class의 newInstance() 메소드는 해당 클래스의 파라미터가 없는 생성자를 호출하고, 그 결과 생성되는 오브젝트를 돌려주는 리플렉션 API다.
1
Date now = (Date) Class.forName("java.util.Date").newInstance();
스프링은 내부적으로 리플렉션 API를 이용해서 빈 정의에 나오는 클래스 이름을 가지고 빈 오브젝트를 생성한다. 문제는 다이내믹 프록시 오브젝트는 이런 식으로 프록시 오브젝트가 생성되지 않는다는 점이다. 사실 다이내믹 프록시 오브젝트의 클래스가 어떤 것인지 알 수도 없다. 클래스 자체도 내부적으로 다이내믹하게 새로 정의해서 사용하기 때문이다. 따라서 사전에 프록시 오브젝트의 클래스 정보를 미리 알아내서 스프링의 빈에 정의할 방법이 없다. 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 팩토리 메소드를 통해서만 만들 수 있다.
팩토리 빈
스프링은 클래스 정보를 가지고 디폴트 생성자를 통해 오브젝트를 만드는 방법 외에도 빈을 만들 수 있는 여러 가지 방법을 제공한다. 대표적으로 팩토리 빈을 이용한 빈 생성 방법을 들 수 있다. 팩토리 빈이란 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다.
팩토리 빈을 만드는 방법에는 여러 가지가 있는데, 가장 간단한 방법은 스프링의 FactoryBean이라는 인터페이스를 구현하는 것이다.
사실 스프링은 private 생성자를 가진 클래스도 빈으로 등록해주면 리플렉션을 이용해 오브젝트를 만들어준다.
1 2 3 4 5 6 7
package org.springframework.beans.factory;
publicinterfaceFactoryBean<T> { T getObject()throws Exception; // 빈 오브젝트를 생성해서 돌려준다. Class<? extends T> getOBjectType(); // 생성되는 오브젝트의 타입을 알려준다. booleanisSingleton(); // getObject()가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려준다. }
다이내믹 프록시를 만들어주는 팩토리 빈
Proxy의 newPRoxyInstance() 메소드를 통해서만 생성이 가능한 다이내믹 프록시 오브젝트는 일반적인 방법으로는 스프링의 빈을 등록할 수 없다. 대신 팩토리 빈을 사용하면 다이내믹 프록시 오브젝트를 스프링의 빈으로 만들어줄 수가 있다. 팩토리 빈의 getObject() 메소드에 다이내믹 프록시 오브젝트를 만들어주는 코드를 넣으면 되게 때문이다.
스프링 빈에는 팩토리 빈과 UserServiceImpl만 빈으로 등록한다. 팩토리 빈은 다이내믹 프록시가 위임할 타깃 오브젝트인 UserServiceImpl에 대한 레퍼런스를 프로퍼티를 통해 DI 받아둬야 한다. 다이내믹 프록시와 함께 생성할 TransactionHandler에게 타깃 오브젝트를 전달해줘야 하기 때문이다. 그 외에도 다이내믹 프록시나 TransactionHandler를 만들 때 필요한 정보는 팩토리 빈의 프로퍼티로 설정해뒀다가 다이내믹 프록시를 만들면서 전달해줘야 한다.
프록시 팩토리 빈 방식의 장점과 한계
다이내믹 프록시를 생성해주는 팩토리 빈을 사용하는 방법은 여러 가지 장점이 있다. 한번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있기 때문이다.
프록시 팩토리 빈 방식의 장점
다이내믹 프록시를 이용하면 타깃 인터페이스를 구현하는클래스를 일일이 만드는 번거로움을 제거할 수 있다. 하나의 핸들러 메소드를 구현하는 것만으로도 수많은 메소드에 부가기능을 부여해줄 수 있으니 부가기능 코드의 중복 문제도 사라진다. 다이내믹 프록시에 팩토리 빈을 이용한 DI까지 더해주면 번거로운 다이내믹 프록시 생성 코드도 제거할 수 있다. DI 설정만으로 다양한 타깃 오브젝트에 적용도 가능하다.
프록시 팩토리 빈의 한계
프록시를 통해 타깃에 부가기능을 제공하는 것은 메소드 단위로 일어나는 일이다. 하나의 크래스 안에 존재하는 여러 개의 메소드에 부가기능을 한 번에 제공하는 건 어렵지 않게 가능했다. 하지만 한 번에 여러 개의 크래스에 공통적인 부가기능을 제공하는 일은 지금까지 살펴본 방법으로는 불가능하다.
하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때도 문제다. 프록시 팩토리 빈 설정이 부가기능의 개수만큼 따라 붙어야 한다.
스프링의 프록시 팩토리 빈
ProxyFactoryBean
자바에는 JDK에서 제공하는 다이내믹 프록시 외에도 편리하게 프록시를 만들 수 있도록 지원해주는 다양한 기술이 존재한다. 따라서 스프링은 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다. 생성된 프록시는 스프링의 빈으로 등록돼야 한다. 스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공해준다.
스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다. ProxyFactoryBean은 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도의 빈에 둘 수 있다.
ProxyFactoryBean이 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor 인터페이스를 구현해서 만든다. MethodInterceptor는 InvocationHandler와 비슷하지만 한 가지 다른 점이 있다. InvocationHandler의 invoke() 메소드는 타깃 오브젝트에 대한 정보를 제공하지 않는다. 따라서 타깃은 InvocationHandler를 구현한 크래스가 직접 알고 있어야 한다. 반면에 MethodInterceptor의 invoke() 메소드는 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보까지도 함께 제공받는다. 그 차이 덕분에 MethodInterceptor는 타깃 오브젝트에 상관없이 독립적으로 만들어질 수 있다. 따라서 MethodInterceptor 오브젝트는 타깃이 다른 여러 프록시에서 함께 사용할 수 있고, 싱글톤 빈으로 등록 가능하다.
어드바이스: 타깃이 필요 없는 순수한 부가기능
MethodInvocation은 일종의 콜백 오브젝트로, proceed() 메소드를 실행하면 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용해서 적용했기 때문에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다.
MethodInterceptor처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스(advice)라고 부른다.
ProxyFactoryBean은 기본적으로 JDK가 제공하는 다이내믹 프록시를 만들어준다. 경우에 따라서는 CGLib이라고 하는 오픈소스 바이트코드 생성 프레임워크를 이용해 프록시를 만들기도 한다.
어드바이스는 타깃 오브젝트에 종속되지 않는 순수한 부가기능을 담은 오브젝트라는 사실을 잘 기억해두자.
포인트컷: 부가기능 적용 대상 메소드 선정 방법
MethodInterceptor 오브젝트는 여러 프록시가 공유해서 사용할 수 있다. 그러기 위해서 MethodInterceptor 오브젝트는 타깃 정보를 갖고 있지 않도록 만들었다. 그 덕분에 MethodInterceptor를 스프링의 싱글톤 빈으로 등록할 수 있었다. 그런데 여기에다 트랜잭션 적용 대상 메소드 이름 패턴을 넣어주는 것은 곤란하다. 트랜잭션 적용 메소드 패턴은 프록시마다 다를 수 있기 때문에 여러 프록시가 공유하는 MethodInterceptor에 특정 프록시에만 적용되는 패턴을 넣으면 문제가 된다.
InvocationHandler는 타깃과 메소드 선정 알고리즘 코드에 의존하고 있지만, 스프링의 ProxyFactoryBean 방식은 두 가지 확장 기능인 부가기능(Advice)과 메소드 선정 알고리즘(Pointcut)을 활용하는 유연한 구조를 제공한다.
스프링은 부가기능을 제공하는 오브젝트를 어드바이스라고 부르고, 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷이라고 부른다. 어드바이스와 포인트컷은 모두 프록시에 DI로 주입돼서 사용된다. 두 가지 모두 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 스프링의 싱글톤 빈으로 등록이 가능하다.
프록시는 클라이언트로부터 요청을 받으면 먼저 포인트컷에게 부가기능을 부여할 메소드인지를 확인해달라고 요청한다. 포인트컷은 Pointcut 인터페이스를 구현해서 만들면 된다. 프록시는 포인트컷으로부터 부가기능을 적용할 대상 메소드인지 확인받으면, MethodInterceptor 타입의 어드바이스를 호출한다. 어드바이스는 JDK의 다이내믹 프록시의 InvocationHandler와 달리 직접 타깃을 호출하지 않는다.
어드바이스가 일종의 템플릿이 되고 타깃을 호출하는 기능을 갖고 있는 MethodInvocation 오브젝트가 콜백이 되는 것이다. 템플릿은 한 번 만들면 재사용이 가능하고 여러 빈이 공유해서 사용할 수 있듯이, 어드바이스도 독립적인 싱글톤 빈으로 등록하고 DI를 주입해서 여러 프록시가 사용하도록 만들 수 있다.
프록시로부터 어드바이스와 포인트컷을 독립시키고 DI를 사용하게 한 것은 전형적인 전략 패턴 구조다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
@Test publicvoidpointcutAdvisor(){ ProxyFactoryBean pfBean = new ProxyFactoryBean(); pfBean.setTarget(new HelloTarget()); // 메소드 이름을 비교해서 대상을 선정하는 알고리즘을 제공하는 포인트컷 생성 NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); // 이름 비교조건 설정. sayH로 시작하는 모든 메소드를 선택하게 한다. pointcut.setMappedName("syaH*"); // 포인트컷과 어드바이스를 advisor로 묶어서 한 번에 추가 pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice())); Hello proxiedHello = (Hello) pfBean.getObject(); assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY")); assertThat(proxiedHello.sayHi("Toby"), is("HI TOBY")); assertThat(proxiedHello.sayThankYou("Toby"), is("Thank You Toby")); }
ProxyFactoryBean에는 여러 개의 어드바이스와 포인트컷이 추가될 수 있다. 포인트컷과 어드바이스를 따로 등록하면 어떤 어드바이스(부가 기능)에 대해 어떤 포인트컷(메소드 선정)을 적용할지 애매해지기 때문이다. 그래서 이 둘을 Advisor 타입의 오브젝트에 담아서 조합을 만들어 등록하는 것이다. 여러 개의 어드바이스가 등록되더라도 각각 다른 포인트컷과 조합될 수 있기 때문에 각기 다른 메소드 선정 방식을 적용할 수 있다. 이렇게 어드바이스와 포인트 컷을 묶은 오브젝트를 인터페이스 이름을 따서 어드바이저라고 부른다.
어드바이저 = 포인트컷(메소드 선정 알고리즘) + 어드바이스(부가기능)
어드바이스와 포인트컷의 재사용
ProxyFactoryBean은 스프링의 DI와 템플릿/콜백 패턴, 서비스 추상화 등의 기법이 모두 적용된 것이다. 그 덕분에 독립적이며, 여러 프록시가 공유할 수 있는 어드바이스와 포인트컷으로 확장 기능을 분리할 수 있었다.
스프링 AOP
자동 프록시 생성
중복 문제의 접근 방법
JDK의 다이내믹 프록시는 특정 인터페이스를 구현한 오브젝트에 대해서 프록시 역할을 해주는 클래스를 런타임 시 내부적으로 만들어준다. 런타임 시에 만들어져 사용되기 때문에 클래스 소스가 따로 남지 않을 뿐이지 타깃 인터페이스의 모든 메소드를 구현하는 클래스가 분명히 만들어진다.
변하지 않는 타깃으로의 위임과 부가기능 적용 여부 판단이라는 부분은 코드 생성기법을 이용하는 다이내믹 프록시 기술에 맡기고, 변하는 부가기능 코드는 별도로 만들어서 다이내믹 프록시 생성 팩토리에 DI로 제공하는 방법을 사용한 것이다. 좀 독특하긴 하지만 변하는 로직과 변하지 않는 기계적인 코드를 잘 분리해낸 것이다.
반복적인 프록시의 메소드 구현은 코드 자동생성 기법을 이용해 해결했다면 반복적인 ProxyFactoryBean 설정 문제는 설정 자동등록 기법으로 해결할 수 없을까?
하지만 지금까지 살펴본 방법에서는 한 번에 여러 개의 빈에 프록시를 적용할 만한 방법은 없었다.
빈 후처리기를 이용한 자동 프록시 생성기
스프링은 컨테이너로서 제공하는 기능 중에서 변하지 않는 핵심적인 부분외에는 대부분 확장할 수 있도록 확장 포인트를 제공해준다.
그 중에서 관심을 가질 만한 확장 포인트는 BeanPostProcessor 인터페이스를 구현해서 만든 빈 후처리기다. 빈 후처리기는 이름 그대로 스프링 빈 오브젝트로 만들어지고 난 후에, 빈 오브젝트를 다시 가공할 수 있게 해준다.
스프링은 빈 후처리기 중의 하나로 DefaultAdvisorAutoProxyCreator를 제공한다. DefaultAdvisorAutoProxyCreator는 어드바이저를 이용한 자동 프록시 생성기다. 빈 후처리기를 스프링에 적용하는 방법은 간단하다. 빈 후처리기 자체를 빈으로 등록하는 것이다. 스프링은 빈 후처리기가 빈으로 등록되어 있으면 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다. 빈 후처리기는 빈 오브젝트의 프로퍼티를 강제로 수정할 수도 있고 별도의 초기화 작업을 수행할 수도 있다. 심지어는 만들어진 빈오브젝트 자체를 바꿔치기할 수도 있다. 따라서 스프링이 설정을 참고해서 만든 오브젝트가 아닌 다른 오브젝트를 빈으로 등록시키는 것이 가능하다.
이를 잘 활용하면 스프링이 생성하는 빈 오브젝트의 일부를 프록시로 포장하고, 프록시를 빈으로 대신 등록할 수도 있다.
DefaultAdvisorAutoProxyCreator 빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 후처리기에게 빈을 보낸다. DefaultAdvisorAutoProxyCreator는 빈으로 등록된 모든 어드바이저 내의 포인트컷을 이용해 전달받은 빈이 프록시 적용 대상인지 확인한다. 프록시 적용 대상이면 그때는 내장된 프록시 생성기에게 현재 빈에 대한 프록시를 만들게 하고, 만들어진 프록시에 어드바이저를 연결해준다. 빈 후처리기는 프록시가 생성되면 원래 컨테이너가 전달해준 빈 오브젝트 대신 프록시 오브젝트를 컨테이너에게 돌려준다. 컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.
적용할 빈을 선정하는 로직이 추가된 포인트컷이 담긴 어드바이저를 등록하고 빈 후처리기를 사용하면 일일이 ProxyFactoryBean 빈을 등록하지 않아도 타깃 오브젝트에 자동으로 프록시가 적용되게 할 수 있다.
확장된 포인트컷
포인트컷은 클래스 필터와 메소드 매처 두 가지를 돌려주는 메소드를 갖고 있다. 실제 포인트컷의 선별 로직은 이 두가지 타입의 오브젝트에 담겨 있다.
1 2 3 4
publicinterfacePointcut{ ClassFilter getClassFilter(); // 프록시를 적용할 클래스인지 확인해준다. MethodMatcher getMethodMatcher(); // 어드바이스를 적용할 메소드인지 확인해준다. }
만약 Pointcut 선정 기능을 모두 적용한다면 먼저 프록시를 적용할 클래스인지 판단하고 나서, 적용 대상 클래스인 경우에는 어드바이스를 적용할 메소드인지 확인하는 식으로 동작한다. 결국 이 두 가지 조건이 모두 충족되는 타깃의 메소드에 어드바이스가 적용되는 것이다.
모든 빈에 대해 프록시 자동 적용 대상을 선별해야 하는 빈 후처리기인 DefaultAdvisorAutoProxyCreator는 클래스와 메소드 선정 알고리즘을 모두 갖고 있는 포인트컷이 필요하다. 정확히는 그런 포인트컷과 어드바이스가 결합되어 있는 어드바이저가 등록되어 있어야 한다.
DefaultAdvisorAutoProxyCreator의 적용
클래스 필터를 적용할 포인트컷 작성
메소드 이름만 비교하던 포인트컷인 NameMatchMethodPointcut을 상속해서 프로퍼티로 주어진 이름 패턴을 가지고 클래스 이름을 비교하는 ClassFilter를 추가하도록 만든다.
package springbook.learningtest.jdk.proxy; //... publicclassNameMatchClassMethodPointcutextendsNameMatchMethodPointcut{ publicvoidsetMappedClassName(String mappedClassName){ // 모든 클래스를 다 허용하던 디폴트 클래스 필터를 프로퍼티로 받은 클래스 이름을 이용해서 필터를 만들어 덮어씌운다. this.setClassFilter(new SimpleClassFilter(mappedClassName)); } static Class SimpleClassFilter implements ClassFilter { String mappedName; privateSimpleClassFilter(String mappedName){ this.mappedName = mappedName; } publicbooleanmatches(Class<?> clazz){ return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName()); } } }
어드바이저를 이용하는 자동 프록시 생성기 등록
자동 프록시 생성기인 DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 것을 모두 찾는다. 그리고 생성되는 모든 빈에 대해 어드바이저의 포인트컷을 적용해보면서 프록시 적용 대상을 선정한다. 빈 클래스가 프록시 선정 대상이라면 프록시를 만들어서 원래 빈 오브젝트와 바꿔치기한다. 원래 빈 오브젝트는 프록시 뒤에 연결돼서 프록시를 통해서만 접근 가능하게 바뀌며, 타깃 빈에 의존한다고 정의한 다른 빈들은 등록시 프록시 오브젝트를 대신 DI 받게 된다. DefaultAdvisorAutoProxyCreator 등록은 다음 한 줄과 같다.
다른 빈에서 참조되거나 코드에서 빈 이름으로 조회될 필요가 없는 빈이라면 아이디를 등록하지 않아도 무방하다.
포인트컷 표현식을 이용한 포인트컷
스프링은 아주 간단하고 효과적인 방법으로 포인트컷의 클래스와 메소드를 선정하는 알고리즘을 작성할 수 있는 방법을 제공한다. 정규식이나 JSP의 EL과 비슷한 일종의 표현식 언어를 사용해서 포인트컷을 작성할 수 있도록 하는 방법이다. 그래서 이것을 포인트컷 표현식이라고 부른다.
포인트컷 표현식
포인트컷 표현식을 지원하는 포인트컷을 적용하려면 AspectJExpressionPointcut 클래스를 사용하면 된다. Pointcut 인터페이스를 구현해야 하는 스프링의 포인트컷은 클래스 선정을 위한 클래스 필터와 메소드 선정을 위한 메소드 매처 두 가지를 각각 제공해야 한다.
하지만 AspectJExpressionPointcut은 클래스와 메소드의 선정 알고리즘을 포인트컷 표현식을 이용해 한 번에 지정할 수 있게 해준다. 포인트컷 표현식은 자바의 RegEx 클래스가 지원하는 정규식처럼 간단한 문자열로 복잡한 선정조건을 쉽게 만들어낼 수 있는 강력한 표현식을 지원한다. 사실 스프링이 사용하는 포인트컷 표현식은 AspectJ라는 유명한 프로엠워크에서 제공하는 것을 가져와 일부 문법을 확장해서 사용하는 것이다.
포인트컷 표현식을 이용하는 포인트컷 적용
포인트컷 표현식은 메소드의 시그니처를 비교하는 방식인 execution() 외에도 몇 가지 표현식 스타일을 갖고 있다. 대표적으로 스프링에서 사용될 때 빈의 이름으로 비교하는 bean()이 있다.
또 특정 애노테이션이 타입, 메소드, 파라미터에 적용되어 있는 것을 보고 메소드를 선정하게 하는 포인트컷도 만들 수 있다. 애노테이션만 부여해놓고, 포인트컷을 통해 자동으로 선정해서, 부가기능을 제공하게 해주는 방식은 스프링 내에서도 애용되는 편리한 방법이다.
클래스 이름은 ServiceImpl로 끝나고 메소드 일므은 upgrade로 시작하는 모든 클래스에 적용되도록 하는 표현식을 만들고 이를 적용한 빈 설정은 다음과 같다.
부가기능 모듈화 작업은 기존의 객체지향 설계 패러다임과는 구분되는 새로운 특성이 있다고 생각했다. 그래서 이런 부가기능 모듈을 객체지향 기술에서는 주로 사용하는 오브젝트와는 다르게 특별한 이름으로 부르기 시작했다. 그것이 바로 애스펙트(aspect)다. 애스펙트란 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.
애스펙트는 부가될 기능을 정의한 코드인 어드바이스와, 어드바이스를 어디에 적용할지를 결정하는 포인트컷을 함께 갖고 있다.
독립된 측면에 존재하는 애스팩트로 분리한 덕에 핵심기능은 순수하게 그 기능을 담은 코드로만 존재하고 독립적으로 살펴볼 수 있도록 구분된 면에 존재하게 된 것이다.
이렇게 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 애스펙트 지향 프로그래밍(Aspect Oriented Programming) 또는 약자로 AOP라고 부른다.
AOP는 OOP를 돕는 보조적인 기술이지 OOP를 완전히 대체하는 새로운 개념은 아니다. AOP는 애스펙트를 분리함으로써 핵심기능을 설계하고 구현할 때 객체지향적인 가치를 지킬 수 있도록 도와주는 것이라고 보면 된다. AOP를 관점 지향 프로그래밍이라고도 한다.
AOP 적용기술
바이트코드 생성과 조작을 통한 AOP
프록시 방식이 아닌 AOP도 있다. AOP 기술의 원조이자, 가장 강력한 AOP 프레임워크로 꼽히는 AspectJ는 프록시를 사용하지 않는 대표적인 AOP 기술이다. AspectJ는 스프링처럼 다이내믹 프록시 방식을 사용하지 않는다.
AspectJ는 프록시처럼 간접적인 방법이 아니라, 타깃 오브젝트를 뜯어고쳐서 부가기능을 직접 넣어주는 직접적인 방법을 사용한다. 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채서 바이트코드를 조작하는 복잡한 방법을 사용한다.
AspectJ가 프록시 같은 방법이 있지만 컴파일된 클래스 파일 수정이나 바이트코드 조작과 같은 복잡한 방법을 사용하는 것에는 두 가지 이유가 있다.
바이트코드를 조작해서 타깃 오브젝트를 직접 수정해버리면 스프링과 같은 DI 컨테이너의 도움을 받아서 자동 프록시 생성 방식을 사용하지 않아도 AOP를 적용할 수 있기 때문.
프록시 방식보다 훨씬 강력하고 유연한 AOP가 가능하기 때문. 바이트코드를 직접 조작해서 AOP를 적용하면 오브젝트의 생성, 필드 값이 조회와 조작, 스태틱 초기화 등의 다양한 작업에 부가기능을 부여해줄 수 있다.
트랜잭션 속성
트랜잭션이라고 모두 같은 방식으로 동작하는 것이 아니다. DefaultTransactionDefinition이 구현하고 있는 TransactionDefinition 인터페이스는 트랜잭션 동작방식에 영향을 줄 수 있는 4가지 속성을 정의하고 있다.
트랜잭션 전파 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식
격리수준 모든 DB 트랜잭션은 격리수준(isolation level)을 갖고 있다. 격리수준은 기본적으로 DB에 설정되어 있지만 JDBC 드라이버나 DataSource 등에서 재설정할 수 있고, 필요하다면 트랜잭션 단위로 격리수준을 조정할 수 있다.
제한시간 트랜잭션을 수행하는 제한시간(timeout)을 설정할 수 있다. DefaultTransactionDefinition의 기본 설정은 제한시간이 없다.
읽기전용 읽기전용(read only)으로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다. 또한 데이터 액세스 기술에 따라서 성능이 향상될 수도 있다.
포인트컷과 트랜잭션 속성의 적용 전략
프록시 방식 AOP는 같은 타깃 오브젝트 내의 메소드를 호출할 때는 적용되지 않는다
이건 전략이라기보다는 주의사항이다. 프록시 방식의 AOP에서는 프록시를 통한 부가 기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다. 여기서 클라이언트는 인터페이스를 통해 타깃 오브젝트를 사용하는 다른 모든 오브젝트를 말한다. 반대로 타깃 오브젝트가 자기 자신의 메소드를 호출할 떄는 프록시를 통한 부가기능의 적용이 일어나지 않는다. 따라서 같은 오브젝트 안에서의 호출은 새로운 트랜잭션 속성을 부여하지 못한다는 사실을 의식하고 개발할 필요가 있다.
타깃 안에서의 호출에는 프록시가 적용되지 않는 문제를 해결할 수 있는 방법은 두 가지가 있다.
스프링 API를 이용해 프록시 오브젝트에 대한 레퍼런스를 가져온 뒤에 같은 오브젝트의 메소드 호출도 프록시를 이용하도록 강제하는 방법(별로 추천되지 않음)
AspectJ와 같은 타깃의 바이트코드를 직접 조작하는 방식의 AOP 기술을 적용하는 방법
트랜잭션 속성 적용
트랜잭션 경계설정의 일원화
트랜잭션 경계설정의 부가기능을 여러 계층에서 중구난방으로 적용하는 건 좋지 않다. 일바적으로 특정 계층의 경계를 트랜잭션 경계와 일치시키는 것이 바람직하다. 비즈니스 로직을 담고 있는 서비스 계층 오브젝트의 메소드가 트랜잭션 경계를 부여하기에 가장 적절한 대상이다.
트랜잭션 지원 테스트
선언적 트랜잭션과 트랜잭션 전파 속성
AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여해주고 속성을 지정 할 수 있게 하는 방법을 선언적 트랜잭션이라고 한다. 반대로 TransactionTemplate이나 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드 안에서 사용하는 방법은 프로그램에 의한 트랜잭션이라고 한다. 스프링은 이 두 가지 방법을 모두 지원하고 있다. 물론 특별한 경우가 아니라면 선언적 방식의 트랜잭션을 사용하는 것이 바람직하다.
트랜잭션 매니저와 트랜잭션 동기화
트랜잭션 추상화 기술의 핵심은 트랜잭션 매니저와 트랜잭션 동기화다. PlatformTransactionManager 인터페이스를 구현한 트랜잭션 매니저를 통해 구체적인 트랜잭션 기술의 종류에 상관없이 일관된 트랜잭션 제어가 가능했다. 또한 트랜잭션 동기화 기술이 있었기에 시작된 트랜잭션 정보를 저장소에 보관해뒀다가 DAO에서 공유할 수 있었다.
트랜잭션 동기화 기술은 트랜잭션 전파를 위해서도 중요한 역할을 한다. 진행 중인 트랜잭션이 있는지 확인하고, 트랜잭션 전파 속성에 따라서 이에 참여할 수 있도록 만들어주는 것도 트랜잭션 동기화 기술 덕분이다.
JdbcTemplate과 같이 스프링이 제공하는 데이터 액세스 추상화를 적용한 DAO에도 동일한 영향을 미친다. JdbcTemplate은 트랜잭션이 시작된 것이 있으면 그 트랜잭션에 자동으로 참여하고, 없으면 트랜잭션 없이 자동커밋 모드로 JDBC 작업을 수행한다. 개념은 조금 다르지만 JdbcTemplate의 메소드 단위로 마치 트랜잭션 전파 속성이 REQUIRED인 것처럼 동작한다고 볼 수 있다.
정리
트랜잭션 경계설정 코드를 분리해서 별도의 클래스로 만들고 비즈니스 로직 클래스와 당일한 인터페이스를 구현하면 DI의 확장 기능을 이용해 클라이언트의 변경 없이도 깔끔하게 분리된 트랜잭션 부가기능을 만들 수 있다.
트랜잭션처럼 환경과 외부 리소스에 영향을 받는 코드를 분리하면 비즈니스 로직에만 충실한 테스트를 만들 수 있다.
목 오브젝트를 활용하면 의존관계 속에 있는 오브젝트도 손 쉽게 고립된 테스트로 만들 수 있다.
DI를 이용한 트랜잭션의 분리는 데코레이터 패턴과 프록시 패턴으로 이해될 수 있다.
번거로운 프록시 클래스 작성은 JDK의 다이내믹 프록시를 사용하면 간단하게 만들 수 있다.
다이내믹 프록시는 스태틱 팩토리 메소드를 사용하기 때문에 빈으로 등록하기 번거롭다. 따라서 팩토리 빈으로 만들어야 한다. 스프링은 자동 프록시 생성 기술에 대한 추상화 서비스를 제공하는 프록시 팩토리 빈을 제공한다.
프록시 팩토리 빈의 설정이 반복되는 문제를 해결하기 위해 자동 프록시 생성기와 포인트 컷을 활용할 수 있다. 자동 프록시 생성기는 부가기능이 담긴 어드바이스를 제공하는 프록시를 스프링 컨테이너 초기화 시점에 자동으로 만들어준다.
포인트컷은 AspectJ 포인트컷 표현식을 사용해서 작성하면 편리하다.
AOP는 OOP만으로는 모듈화하기 힘든 부가기능을 효과적으로 모듈화하도록 도와주는 기술이다.
스프링은 자주 사용되는 AOP 설정과 트랜잭션 속성을 지정하는데 사용할 수 있는 전용 태그를 제공한다.
AOP를 이용해 트랜잭션 속성을 지정하는 방법에는 포인트컷 표현식과 메소드 이름 패턴을 이용하는 방법과 타깃에 직접 부여하는 @Transactional 애노테이션을 사용하는 방법이 있다.
@Transactgional을 이용한 트랜잭션 속성을 테스트에 적용하면 손쉽게 DB를 사용하는 코드의 테스트를 만들 수 있다.
트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다. 작업을 쪼개서 작은 단위로 만들 수 없다는 것은 트랜잭션의 핵심 속성인 원자성을 의미한다.
트랜잭션 경계설정
DB는 그 자체로 완벽한 트랜잭션을 지원한다. SQL을 이용해 다중 로우의 수정이나 삭제를 위한 요청을 했을 때 일부 로우만 삭제되고 안 된다거나, 일부 필드는 수정했는데 나머지 필드는 수정이 안 되고 실패로 끝나는 경우는 없다. 하나의 SQL 명령을 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.
하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다. 이때 여러 가지 작업이 하나의 트랜잭션이 되려면, 두 번째 이후의 SQL이 성공적으로 DB에서 수행되기 전에 문제가 발생할 경우 앞에서 처리한 SQL 작업도 취소시켜야 한다. 이런 취소 작업을 트랜잭션 롤백(transaction roolback)이라고 한다. 반대로 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확정시켜야 한다. 이것을 트랜잭션 커밋(transaction commit)이라고 한다.
JDBC 트랜잭션의 트랜잭션 경계설정
모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두 가지다. 모든 작업을 무효화하는 롤백과 모든 작업을 다 확장하는 커밋이다.
JDBC의 트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에 일어난다. 트랜잭션의 시작과 종료는 Coonection 오브젝트를 통해 이루어지기 때문이다. JDBC의 기본 설정은 DB 작업을 수행한 직후에 자동으로 커밋이 되도록 되어 있다.
트랜잭션이 한 번 시작되면 commit() 또는 rollback() 메소드가 호출될 때까지의 작업이 하나의 트랜잭션으로 묶인다. commit() 또는 rollback()이 호출되면 그에 따라 작업 결과가 DB에 반영되거나 취소되고 트랜잭션이 종료된다.
setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정(transaction demarcation)이라고 한다. 트랜잭션의 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다는 점도 기억하자. 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션(local transaction)이라고도 한다.
비즈니스 로직 내의 트랜잭션 경계설정
UserService와 UserDao를 그대로 둔 채로 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다. UserDao가 가진 SQL이나 JDBC API를 이용한 데이터 엑세스 코드는 최대한 그대로 남겨둔 채로, UserService에는 트랜잭션 시작과 종료를 담당하는 최소한의 코드만 가져오게 만들면 어느 정도 책임이 다른 코드를 분리해 둔 채로 트랜잭션 문제를 해결할 수 있다.
UserService 트랜잭션 경계설정의 문제점
위의 방법을 사용하면 다음과 같은 문제점이 발생한다.
첫째는 DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다는 점이다. Try/catch/finally 블록은 이제 UserService 내에 존재하고, UserService의 코드는 JDBC 작업 코드의 전형적인 문제점을 그대로 가질 수 밖에 없다.
두 번째 문제점은 DAO의 메소드와 비즈니스 로직을 담고 있는 UserService의 메소드에 Connection 파라미터가 추가돼야 한다는 점이다.
세 번째 문제는 Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao는 더 이상 데이터 액세스 기술에 독립적일 수가 없다는 점이다.
트랜잭션 동기화
스프링은 위의 문제를 해결할 수 있는 멋진 방법을 제공해준다.
Connection 파라미터 제거
Connection 오브젝트를 한번 생성 후 계속 메소드의 파라미터로 전달하다가 DAO를 호출할 때 사용하는 건 피하고 싶다. 이를 위해 스프링이 제안하는 방법은 트랜잭션 동기화(transaction synchronization) 방식이다. 트랜잭션 동기화란 UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서는 저장된 Coonection을 가져다가 사용하게 하는 것이다.
트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌이 날 염려는 없다.
이렇게 트랜잭션 동기화 기법을 사용하면 파라미터를 통해 일일이 Connection 오브젝트를 전달할 필요가 없어진다. 트래잭션의 경계설정이 필요한 Service에서만 Connection을 다루게 하고, 여기에 생성된 Connection과 트랜잭션을 DAO의 JdbcTemplate이 사용할 수 있도록 별도의 저장소에 동기화하는 방법을 적용하기만 하면 된다. 더 이상 로직을 담은 메소드에 Connection 타입의 파라미터가 전달될 필요도 없고, UserDao의 인터페이스에도 일일이 JDBC 인터페이스인 Connection을 사용한다고 노출할 필요가 없다.
트랜잭션 동기화 적용
스프링은 JdbcTemplate과 더불어 이런 트랜잭션 동기화 기능을 지원하는 간단한 유틸리티 메소드를 제공하고 있다.
// Connection을 생성할 때 사용할 DataSource를 DI 받도록 한다. publicvoidsetDataSource(DataSource dataSource){ this.dataSource = dataSource; }
publicvoidupgradeLevels()throws Exception { // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화 한다. TransactionSynchronizationManager.initSynchronization(); // DB 커넥션을 생성하고 트랜잭션을 시작한다. 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행한다. // DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드 Connection c = DataSourceUtils.getConnection(dataSource); c.setAuthCommit(false);
try { List<User> users = UserDao.getAll(); for (User user : users) { if (canUpdatedLevel(user)) { upgradeLevel(user); } } c.commit(); } catch (Exception e) { c.rollback(); throw e; } finally { DataSourceUtils.releaseConnection(c, dataSource); // 동기화 작업 종료 및 정리 TransactionSynchronizationManager.unbindResource(this.dataSource); TransactionSynchronizationManager.clearSynchronization(); } }
스프링이 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager다. 이 클래스를 이용해 먼저 트랜잭션 동기화 작업을 초기화하도록 요청한다. 그리고 DataSourceUtils에서 제공하는 getConnection() 메소드를 통해 DB 커넥션을 생성한다. DataSource에서 Connection을 직접 가져오지 않고, 스프링이 제공하는 유틸리티 메소드를 쓰는 이유는 이 DataSourceUtils의 getConnection() 메소드는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.
트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate의 작업에서 동기화시킨 DB 커넥션을 사용하게 된다.
JdbcTemplate과 트랜잭션 동기화
JdbcTemplate은 영리하게 동작하도록 설계되어 있다. 만약 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate이 직접 DB 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행한다. 반면에 upgradeLevels() 메소드에서처럼 트랜잭션 동기화를 시작해놓았다면 그때부터 실행되는 JdbcTemplate의 메소드에서는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 들어 있는 DB 커넥션을 가져와서 사용한다. 이를 통해 이미 시작된 트랜잭션에 참여하는 것이다.
트랜잭션 서비스 추상화
기술과 환경에 종속되는 트랜잭션 경계설정 코드
한 개 이상의 DB로의 작업을 하나의 트랜잭션으로 만드는 건 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜재션으로는 불가능하다. 왜냐하면 로컬 트랜잭션은 하나의 DB Connection에 종속되기 때문이다. 따라서 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라, 별도의 트랜잭션 고나리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션(global transaction)방식을 사용해야 한다. 글로벌 트랜잭션을 적용해야 트랜잭션 매니저를 통해 여러 개의 DB가 참여하는 작업을 하나의 트랜잭션으로 만들 수 있다.
자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 메니저를 지원하기 위한 API은 JTA(Java Transaction API)를 제공하고 있다.
트랜잭션 매니저는 DB와 메시징 서버를 제어하고 관리하는 각각의 리소스 매니저와 XA 프로토콜을 통해 연결된다. 이를 통해 트랜잭션 매니저가 실제 DB와 메시징 서버의 트랜잭션을 종합적으로 제어할 수 있게 되는 것이다. 이렇게 JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.
트랜잭션 API의 의존관계 문제와 해결책
UserDao가 DAO 패턴을 사용해 구현 데이터 액세스 기술을 유연하게 바꿔서 사용할 수 있게 했지만 UserService에서 트랜잭션의 경계 설정을 해야 할 필요가 생기면서 다시 특정 데이터 액세스 기술에 종속되는 구조가 되고 말았다.
UserService의 코드가 특정 트랜잭션 방법에 의존적이지 않고 독립적일 수 있게 만들려면 어떻게 해야 할까? UserService의 메소드 안에서 트랜잭션 경계설정 코드를 제거할 수는 없다. 하지만 특정 기술에 의존적인 Connection, UserTransaction, Session/Transaction API 등에 종속되지 않게 할 수 있는 방법은 있다.
추상화란 하위 시스템의 공통점을 뽑아내서 분리시키는 것을 말한다. 그렇게 하면 하위 시스템이 어떤 것인지 알지 못해도, 또는 하위 시스템이 바뀌더라도 일관된 방법으로 접할 수가 있다.
스프링의 트랜잭션 서비스 추상화
스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
publicvoidupgradeLevels(){ PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { List<User> users = userDao.getAll(); for (User user : users) { if (canUpgradeLevel(user)) { upgradeLevel(user); } } transactionManager.commit(status); } catch (RuntimeException e) { transactionManager.rollback(status); throw e; } }
스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스는 PlatformTransactionManager다. JDBC의 로컬 트랜잭션을 이용한다면 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하면 된다. 사용할 DB의 DataSource를 생성자 파라미터로 넣으면서 DataSourceTransactionManager의 오브젝트를 만든다.
JDBC를 이용하는 경우에는 먼저 Connection을 생성하고 나서 트랜잭션을 시작했다. 하지만 PlatformTransactionManager에서는 트랜잭션을 가져오는 요청인 getTransation() 메소드를 호출하기만 하면 된다. 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 같이 수행해주기 때문이다. 여기서 트랜잭션을 가져온다는 것은 일단 트랜잭션을 시작한다는 의미라고 생각하자. 파라미터로 넘기는 DefaultTransactionDefinition 오브젝트는 트랜잭션에 대한 속성을 담고 있다.
이렇게 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장된다. TransactionStatus는 트랜잭션에 대한 조작이 필요할 때 PlatformTransactionManager 메소드의 파라미터로 전달해주면 된다.
트랜잭션이 시작됐으니 이제 JdbcTemplate을 사용하는 DAO를 이용하는 작업을 진행한다. 스프링의 트랜잭션 추상화 기술은 앞에서 적용해봤던 트랜잭션 동기화를 사용한다. PlatformTransactionManager로 시작한 트랜잭션 동기화 저장소에 저장된다. PlatformTransactionManager를 구현한 DataSourceTransactionManager 오브젝트는 JdbcTemplate에서 사용될 수 있는 방식으로 트랜잭션을 관리해준다. 따라서 PlatformTransactionManager를 통해 시작한 트랜잭션은 UserDao의 JdbcTemplate 안에서 사용된다.
트랜잭션 기술 설정의 분리
JTATransactionManager는 주요 자바 서버에서 제공하는 JTA 정보를 JNDI를 통해 자동으로 인식하는 기능을 갖고 있다. 따라서 별다른 설정 없이 JTATransactionManager를 사용하기만 해도 서버의 트랜잭션 매니저/서비스와 연동해서 동작한다.
어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService 코드가 알고 있는 것은 DI 원칙에 위배된다. 자신이 사용할 구체적인 클래스를 스스로 결정하고 생성하지 말고 컨테이너를 통해 외부에서 제공받게 하는 스프링 DI의 방식으로 바꾸자.
서비스 추상화와 단일 책임 원칙
수직, 수평 계층구조와 의존관계
기술과 서비스에 대한 추상화 기법을 이용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다.
애플리케이션 로직의 종류에 따른 수평적인 구분이든, 로직과 기술이라는 수직적인 구분이든 모두 결합도가 낮으며, 서로 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만들 수 있는 데는 스프링의 DI가 중요한 역할을 하고 있다. DI의 가치는 이렇게 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.
단일 책임 원칙
이런 적절한 분리가 가져오는 특징은 객체지향 설계의 원칙 중의 하나인 단일 책임 원칙(Single Responsibility Principle)으로 설명할 수 있다. 단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미다. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수도 있다.
단일 책임 원칙의 장점
단일 책임 원칙을 잘 지키고 있다면, 어떤 변경이 필요할 때 수정대상이 명확해진다. 기술이 바뀌면 기술 계층과의 연동을 담당하는 기술 추상화 계층의 설정만 바꿔주면 된다. 데이터를 가져오는 테이블의 이름이 바뀌었다면 데이터 액세스 로직을 담고 있는 UserDao를 변경하면 된다. 비즈니스 로직도 마찬가지다.
적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업은 갈수록 복잡해지는 엔터프라이즈 애플리케이션에는 반드시 필요하다. 이를 위한 핵심적인 도구가 바로 스프링이 제공하는 DI다.
이렇게 스프링의 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심엔진이자 원리이며, 스프링이 지지하고 지원하는, 좋은 설계와 코드를 만드는 모든 과정에서 사용되는 가장 중요한 도구다. 스프링을 DI 프레임워크라고 부르는 이유는 외부 설정정보를 통한 런타임 오브젝트 DI라는 단순한 기능을 제공하기 때문이 아니다. 오히려 스프링이 DI에 담긴 원칙과 이를 응용하는 프로그래밍 모델을 자바 엔터프라이즈 기술의 많은 문제를 해결하는 데 적극적으로 활용하고 있기 때문이다.
메일 서비스 추상화
테스트와 서비스 추상화
일반적으로 서비스 추상화라고 하면 트랜잭션과 같은 기능은 유사하거나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 추상 인터페이스와 일관성 있는 잡근 방법을 제공해주는 것을 말한다.
이를 적용하면 어떤 경우에도 UserService와 같은 애플리케이션 계층의 코드는 아래 계층에서는 어떤 일이 일어나는지 상관없이 메일 발송을 요청한다는 기본 기능에 충실하게 작성하면 된다.
서비스 추상화란 이렇게 원활한 테스트만을 위해서도 충분한 가치가 있다. 기술이나 환경이 바뀔 가능성이 있음에도, JavaMail처럼 확장이 불가능하게 설계해놓은 API를 사용해야 하는 경우라면 추상화 계층의 도입을 적극 고려해볼 필요가 있다.
테스트 대역의 종류와 특징
테스트 환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 이런 오브젝트를 통틀어서 테스트 대역(test double)이라고 부른다.
대표적인 테스트 대역은 테스트 스텁(test stub)이다. 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다.
많은 경우 테스트 스텁이 결과를 돌려줘야 할 때도 있다. MailSender처럼 호출만 하면 그만인 것도 있지만, 리턴 값이 있는 메소드를 이용하는 경우에는 결과가 필요하다. 이럴 땐 스텁에 미리 테스트 중에 필요한 정보를 리턴해주도록 만들 수 있다.
테스트는 보통 어떤 시스템에 입력을 주었을 때 기대하는 출력이 나오는지를 검증한다.
목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.
테스트는 테스트의 대상이 되는 오브젝트에 직접 입력 값을 제공하고, 테스트 오브젝트가 돌려주는 출력 값, 즉 리턴 값을 가지고 결과를 확인한다. 테스트 대상이 받게 될 이볅 값을 제어하면서 그 결과가 어떻게 달라지는지 확인하기도 한다. 문제는 테스트 대상 오브젝트는 테스트로부터만 입력을 받는 것이 아니라는 점이다. 테스트가 수행되는 동안 실행되는 코드는 테스트 대상이 의존하고 있는 다른 의존 오브젝트와도 커뮤니케이션하기도 한다.
때론 테스트 대상 오브젝트가 의존 오브젝트에게 출력한 값에 관심이 있을 겨웅가 있다. 또는 의존 오브젝트를 얼마나 사용했는가 하는 커뮤니케이션 행위 자체에 관심이 있을 수가 있다. 문제는 이 정보는 테스트에서는 직접 알 수가 없다는 것이다. 이때는 테스트 대상과 의존 오브젝트 사이에 주고받는 정보를 보존해두는 기능을 가진 테스트용 의존 오브젝트인 목 오브젝트를 만들어서 사용해야 한다. 테스트 대상 오브젝트의 메소드 호출이 끝나고 나면 테스트는 목 오브젝트에게 테스트 대상과 목 오브젝트 사이에서 일어났던 일에 대해 확인을 요청해서, 그것을 테스트 검증 자료로 삼을 수 있다.
목 오브젝트를 이용한 테스트라는 게, 작성하기는 간단하면서도 기능은 상당히 막강하다는 사실을 알 수 있을 것이다. 보통의 테스트 방법으로는 검증하기가 매우 까다로운 테스트 대상 오브젝트의 내부에서 일어나는 일이나 다른 오브젝트 사이에서 부고받은 정보까지 검증하는 일이 손쉽기 때문이다.
정리
비즈니스 로직을 담은 코드는 데이터 엑세스 로직을 담은 코드와 깔끔하게 분리되는 것이 바람직하다. 비즈니스 로직 코드 또한 내부적으로 책임과 역할에 따라서 깔끔하게 메소드로 정리돼야 한다.
이를 위해서는 DAO의 기술 변화에 서비스 계층의 코드가 영향을 받지 않도록 인터페이스와 DI를 잘 활용해서 결합도를 낮춰줘야 한다.
DAO를 사용하는 비즈니스 로직에는 단위 작업을 보장해주는 트랜잭션이 필요하다.
트랜잭션의 시작과 종료를 지정하는 일을 트랜잭션 경계설정이라고 한다. 트랜잭션 경계설정은 주로 비즈니스 로직 안에서 일어나는 경우가 많다.
시작된 트랜잭션 정보를 담은 오브젝트를 파라미터로 DAO에 전달하는 방법은 매우 비효율적이기 때문에 스프링이 제공하는 트랜잭션 동기화 기법을 활용하는 것이 편리하다.
자바에서 사용되는 트랜잭션 API의 종류와 방법은 다양하다. 환경과 서버에 따라서 트랜잭션 방법이 변경되면 경계설정 코드도 함께 변경돼야 한다.
트랜잭션 방법에 따라 비즈니스 로직을 담은 코드가 함께 변경되면 단일 책임 원칙에 위배되며, DAO가 사용하는 특정 기술에 대해 강한 결합을 만들어낸다.
트랜잭션 경계설정 코드가 비즈니스 로직 코드에 영향을 주지 않게 하려면 스프링이 제공하는 트랜잭션 서비스 추상화를 이용하면 된다.
서비스 추상화는 로우레벨의 트랜잭션 기술과 API의 변화에 상관없이 일괄된 API를 가진 추상화 계층을 도입한다.
서비스 추상화는 테스트하기 어려운 JavaMail 같은 기술에도 적용할 수 있다. 테스트를 편리하게 작성하도록 도와주는 것만으로도 서비스 추상화는 가치가 있다.
테스트 대상이 사용하는 의존 오브젝트를 대체할 수 있도록 만든 오브젝트를 테스트 대역이라고 한다.
테스트 대역은 테스트 대상 오브젝트가 원활하게 동작할 수 있도록 도우면서 테스트를 위해 간접적인 정보를 제공해주기도 한다.
테스트 대역 중에서 테스트 대상으로부터 전달받은 정보를 검증할 수 있도록 설계된 것을 목 오브젝트라고 한다.
java.lang 패키지는 자바프로그래밍에 가장 기본이 되는 클래스들을 포함하고 있다. 그렇기 때문에 java.lang 패키지의 클래스들은 import문 없이도 사용할 수 있게 되어 있다. 그 동안 String 클래스나 System 클래스를 import문 없이 사용할 수 있었던 이유가 바로 java.lang 패키지에 속한 클래스들이기 때문이었던 것이다.
java.lang 패키지
Object 클래스
Object 클래스는 멤버변수는 없고 오직 11개의 메서드만 가지고 있다.
equals(Object obj) 매개변수로 객체의 참조변수를 받아서 비교하여 그 결과를 booelan 값으로 알려주는 역할을 한다.* 아래의 코드는 Object 클래스에 정의되어 있는 equals 메서드의 실제 내용이다.
String 클래스는 Object 클래스의 equals 메서드를 그대로 사용하는 것이 아니라 이처럼 오버라이딩을 통해서 String 인스턴스가 갖는 문자열 값을 비교하도록 되어있다.
String 클래스 뿐만 아니라 Date, File, Wrapper 클래스(Integer, Double 등)의 equals 메서드도 주소값이 아닌 내용을 비교하도록 오버라이딩되어 있다. 그러나 StringBuffer 클래스는 오버라이딩되어 있지 않다.
hashCode() 이 메서드는 해싱(hashing)기법에 사용되는 ‘해시함수(hash function)’를 구현한 것이다. 해싱은 데이터관리기법 중의 하나인데 다량의 데이터를 저장하고 검색하는 데 유용하다.*
해시함수는 찾고자하는 값을 입력하면, 그 값이 저장된 위치를 알려주는 해시코드(hashcode)를 반환한다.
Object 클래스에 정의된 hashCode 메서드는 객체의 주소값을 이용해서 해시코드를 만들어 반환하기 때문에 서로 다른 두 객체는 결코 같은 해시코드를 가질 수 없다. (해싱기법을 사용하는 HashMap이나 HashSet과 같은 클래스에 저장할 객체라면 반드시 hashCode 메서드를 오버라이딩해야 한다.)
String 클래스는 문자열의 내용이 같으면, 동일한 해시코드를 반환하도록 hashCode 메서드가 오버라이딩되어 있기 때문에, 문자열의 내용이 같은 str1과 str2에 대해 hashCode()를 호출하면 항상 동일한 해시코드값을 얻는다.
반면에 System.identifyHashCode(Object x)는 Object 클래스의 hashCode 메서드처럼 객체의 주소값으로 해시코드를 생성하기 때문에 모든 객체에 대해 항상 다른 해시코드값을 반환할 것을 보장한다. 그래서 str1과 str2가 해시코드는 같지만 서로 다른 객체라는 것을 알 수 있다.
toString() 인스턴스에 대한 정보를 문자열(String)로 제공할 목적으로 정의한 것이다. 인스턴스의 정보를 제공한다는 것은 대부분의 경우 인스턴스 변수에 저장된 값들을 문자열로 표현한다는 뜻이다.
Object클래스에 정의된 toString()은 아래와 같다.
1 2 3
public String toString(){ return getClass().getName() + "@" + Integer.toHexString(hashCode()); }
clone() 자신을 복제하여 새로운 인스턴스를 생성하는 일을 한다. Object 클래스에 정의된 clone()은 단순히 인스턴스변수의 값만을 복사하기 때문에 참조 변수 타입의 인스턴스 변수가 정의되어 있는 클래스는 완전한 인스턴스 복제가 이루어지지 않는다.
cloneable 인터페이스를 구현한 클래스에서만 clone()을 호출할 수 있다. 또한 clone()을 오버라이딩하면서 접근 제어자를 protected에서 public으로 변경해야 한다.
clone()은 단순히 객체에 저장된 값을 그대로 복제할 뿐, 객체가 참조하고 있는 객체까지 복제하지는 않는다. 반면에 원본이 참조하고 있는 객체까지 복제하는 것을 ‘깊은 복사’라고 한다. 깊은 복사에서는 원본과 복사본이 서로 다른 객체를 참조하기 때문에 원본의 변경이 복사본에 영향을 미치지 않는다.
공변 반환타입
JDK 1.5부터 ‘공변 반환타입’ 이라는 것이 추가되었다. 이 기능은 오버라이딩할 때 조상 메서드의 반환타입을 자손 클래스의 타입으로 변경하는 것이다. 따라서 clone()의 반환타입을 Object가 아닌 자손의 타입으로 변경가능하다.
‘공변 반환타입’을 사용하면 조상의 타입이 아닌 실제로 반환되는 자손 객체의 타입으로 반환할 수 있어서 번거로운 형변환이 줄어든다는 장점이 있다.
getClass() 자신이 속한 클래스의 Class객체를 반환하는 메서드인데, Class 객체는 이름이 ‘Class’인 클래스의 객체이다. Class 객체는 아래와 같이 정의되어 있다.
1 2 3
publicfinalclassClassimplements ... { // Class 클래스 ... }
Class 객체는 클래스의 모든 정보를 담고 있으며, 클래스당 단 1개만 존재한다. 그리고 클래스 파일이 ‘클래스 로더(ClassLoader)’에 의해서 메모리에 올라갈 때, 자동적으로 생성된다.
클래스 로더는 실행 시에 필요한 클래스를 동적으로 메모리에 로드하는 역할을 한다. 먼저 기존에 생성된 클래스 객체가 메모리에 존재하는지 확인하고, 있으면 객체의 참조를 반환하고 없으면 클래스 패스(classpath)에 지정된 경로를 따라서 클래스 파일을 찾는다. 못 찾으면 ClassNotFoundException이 발생하고, 찾으면 해당 클래스 파일을 읽어서 Class 객체로 변환한다.
파일 형태로 저장되어 있는 클래스를 읽어서 Class 클래스에 정의된 형식으로 변환하는 것이다. 즉, 클래스 파일을 읽어서 사용하기 편한 형태로 저장해 놓은 것이 클래스 객체이다. (클래스 파일을 메모리에 로드하고 변환하는 일은 클래스 로더가 한다.)
Class 객체를 얻는 방법
Class 객체에 대한 참조를 얻는 방법은 여러 가지가 있다.
1 2 3
Class cObj = new Card().getClass(); // 생성된 객체로 부터 얻는 방법 Class cObj = Card.class; // 클래스 리터럴(*.class)로 부터 얻는 방법 Class cObj = Class.forName("Card"); // 클래스 이름으로 부터 얻는 방법
특히 forName()은 특정 클래스 파일, 예를 들어 데이터베이스 드라이버를 메모리에 올릴 때 주로 사용한다. Class 객체를 이용하면 클래스에 정의된 멤버의 이름이나 개수 등, 클래스에 대한 모든 정보를 얻을 수 있기 때문에 Class 객체를 통해서 객체를 생성하고 메서드를 호출하는 등 보다 동적인 코드를 작성할 수 있다.
String 클래스
기존의 다른 언어에서는 문자열을 char형의 배열로 다루었으나 자바에서는 문자열을 위한 클래스를 제공한다.
변경 불가능한(immutable) 클래스 String 클래스는 문자열을 저장하기 위해서 문자형 배열 변수(char[]) value를 인스턴스 변수로 정의해놓고 있다. 인스턴스 생성 시 생성자의 매개변수로 입력받는 문자열은 이 인스턴스변수(value)에 문자형 배열(char[])로 저장되는 것이다.
한번 생성된 String 인스턴스가 갖고 있는 문자열은 읽어 올 수만 있고, 변경할 수는 없다. 예를 들어 ‘+’ 연산자를 이용해서 문자열을 결합하는 경우 인스턴스내의 문자열이 바뀌는 것이 아니라 새로운 문자열이 담긴 String 인스턴스가 생성되는 것이다.
덧셈 연산자 ‘+’를 사용해서 문자열을 결합하는 것은 매 연산 시 마다 새로운 문자열을 가진 String 인스턴스가 생성되어 메모리공간을 차지하게 되므로 가능한 한 결합횟수를 줄이는 것이 좋다.
문자열을 다루는 작업이 많이 필요한 경우에는 String 클래스 대신 StringBuffer 클래스를 사용하는 것이 좋다. StringBuffer 인스턴스에 저장된 문자열은 변경이 가능하므로 하나의 StringBuffer 인스턴스만으로도 문자열을 다루는 것이 가능하다.
문자열의 비교 문자열을 만들 때는 두 가지 방법, 문자열 리터럴을 지정하는 방법과 String 클래스의 생성자를 사용해서 만드는 방법이 있다.
1 2 3 4
Strig str1 = "abc"; // 문자열 리터럴 "abc"의 주소가 str1에 저장됨 Strig str2 = "abc"; // 문자열 리터럴 "abc"의 주소가 str2에 저장됨 Strig str3 = new String("abc"); // 새로운 String 인스턴스를 생성 Strig str4 = new String("abc"); // 새로운 String 인스턴스를 생성
String 클래스의 생성자를 이용한 경우에는 new 연산자에 의해서 메모리할당이 이루어지기 때문에 항상 새로운 String 인스턴스가 생성된다. 그러나 문자열 리터럴은 이미 존재하는 것을 재사용하는 것이다. (문자열 리터럴은 클래스가 메모리에 로드될 때 자동적으로 미리 생성된다.)
equals()를 사용했을 때는 두 문자열의 내용(“abc”)을 비교하기 때문에 두 경우 모두 true를 결과로 얻는다. 하지만, 각 String 인스턴스의 주소를 등가비교연산자 “==”로 비교했을 때는 결과가 다르다.
문자열 리터럴 자바 소스파일에 포함된 모든 문자열 리터럴은 컴파일 시에 클래스 파일에 저장된다. 이대 같은 내용의 문자열 리터럴은 한번만 저장된다.* 문자열 리터럴도 String 인스턴스이고 한번 생성하면 내용을 변경할 수 없기 때문에 하나의 인스턴스를 공유하면 되기 때문이다.
String 리터럴들은 컴파일 시에 클래스파일에 저장된다. 클래스 파일에는 소스파일에 포함된 모든 리터럴의 목록이 있다. 해당 클래스 파일이 클래스 로더에 의해 메모리에 올라갈 때, 이 리터럴의 목록에 있는 리터럴들이 JVM내에 있는 ‘상수 저장소(constant pool)’에 저장된다.
빈 문자열(empty string) 길이가 0인 배열이 존재할 수 있다. char형 배열도 길이가 0인 배열을 생성할 수 있고, 이 배열을 내부적으로 가지고 있는 문자열이 바로 빈 문자열이다.*
‘String s = “”;’ 과 같은 문장이 있을 때, 참조변수 s가 참조하고 있는 String 인스턴스는 내부에 ‘new char[0]’과 같이 길이가 0인 char형 배열을 저장하고 있는 것이다.
그러나 ‘String s = “”;’과 같은 표현이 가능하다고 해서 ‘char c = ‘’;’와 같은 표현도 가능한 것은 아니다. char형 변수에는 반드시 하나의 문자를 지정해야한다.
일반적으로 변수를 선언할 때, 각 타입의 기본값으로 초기화 하지만 String은 참조형 타입의 기본값인 null 보다는 빈 문자열로, char형인 기본값은 ‘₩u0000’ 대신 공백으로 초기화 하는 것이 보통이다. (‘₩u0000’은 유니코드의 첫 번째 문자로써 아무런 문자도 지정되지 않은 빈 문자이다.)
문자 인코딩 변환 getBytes(String charsetName)를 사용하면, 문자열의 문자 인코딩을 다른 인코딩으로 변경할 수 있다. 자바가 UTF-16을 사용하지만, 문자열 리터럴에 포함되는 문자들은 OS의 인코딩을 사용한다.
1 2
byte[] utf8_str = "가".getBytes("UTF-8"); // 문자열을 UTF-8로 분환 String str = new String(utf8_str, "UTF-8"); // byte 배열을 문자열로 변환
서로 다른 문자 인코딩을 사용하는 컴퓨터 간에 데이터를 주고받을 때는 적절한 문자 인코딩이 필요하다.
UTF-8은 한글 한 글자를 3 byte로 표현하고, CP949는 2 byte로 표현한다.
기본형 값을 String으로 변환 기본형을 문자열로 변경하는 방법은 간단하다. 숫자에 빈 문자열””을 더해주기만 하면 된다. 이 외에도 valueOf()를 사용하는 방법도 있다. 성능은 valueOf()가 더 좋지만, 빈 문자열을 더하는 방법이 간단하고 편하기 때문에 성능향상이 필요한 경우에만 valueOf()를 쓰자.
참조변수에 String을 더하면, 참조변수가 가리키고 있는 인스턴스의 toString()을 호출하여 String을 얻은 다음 결합한다.
String을 기본형 값으로 변환 이전에는 parseInt()와 같은 메서드를 많이 섰는데, 메서드의 이름을 통일하기 위해 valueOf()가 나중에 추가되었다. valueOf(String s)는 메서드 내부에서 그저 parseInt(String s)를 호출할 뿐이므로, 두 메서드는 반환 타입만 다르지 같은 메서드이다.
String 클래스는 인스턴스를 생성할 때 지정된 문자열을 변경할 수 없지만 StringBuffer 클래스는 변경이 가능하다. 내부적으로 문자열 편집을 위한 버퍼(buffer)를 가지고 있으며, StringBuffer 인스턴스를 생성할 때 그 크기를 지정할 수 있다.
StringBuffer 클래스는 String 클래스와 같이 문자열을 저장하기 위한 char형 배열의 참조변수 인스턴스로 선언해 놓고 있다. StringBuffer 인스턴스가 생성될 때, char형 배열이 생성되며 이 때 생성된 char형 배열을 인스턴스변수 value가 참조하게 된다.
StringBuffer의 생성자 StringBuffer 클래스의 인스턴스를 생성할 때, 적절한 길이의 char형 배열이 생성되고, 이 배열은 문자열을 저장하고 편집하기 위한 공간(buffer)으로 사용된다.
StringBuffer 인스턴스를 생성할 때는 생성자 StringBuffer(int length)를 사용해서 StringBuffer 인스턴스에 저장될 문자열의 길이를 고려하여 충분히 여유있는 크기로 지정하는 것이 좋다. StringBuffer 인스턴스를 생성할 때, 버퍼의 크기를 지정해 주지 않으면 16개의 문자를 저장할 수 있는 크기의 버퍼를 생성한다.
1 2 3 4 5 6 7 8 9 10 11 12 13
publicStringBuffer(int length){ value = newchar[length]; shared = false; }
StringBuffer 인스턴스로 문자열을 다루는 작업을 할 때, 버퍼의 크기가 작업하려는 문자열의 길이보다 작을 때는 내부적으로 버퍼의 크기를 증가시키는 작업이 수행된다.
배열의 길이는 변경될 수 없으므로 새로운 길이의 배열을 생성한 후에 이전 배열의 값을 복사해야 한다.
StringBuffer의 비교 String 클래스에서는 equals메서드를 오버라이딩해서 문자열의 내용을 비교하도록 구현되어 있지만, StringBuffer 클래스는 equals메서드를 오버라이딩하지 않아서 StringBuffer클래스의 equals메서드를 사용해도 등가비교연산자(==)로 비교한 것과 같은 결과를 얻는다.
1 2 3 4 5
StringBuffer sb = new StringBuffer("abc"); StringBuffer sb2 = new StringBuffer("abc");
반면에 toString()은 오버라이딩되어 있어서 StringBuffer 인스턴스에 toString()을 호출하면, 담고있는 문자열을 String으로 변환한다.
그래서 StringBuffer 인스턴스에 담긴 문자열을 비교하기 위해서는 StringBuffer 인스턴스에 toString()을 호출해서 String 인스턴스를 얻은 다음, 여기에 equals 메서드를 사용해서 비교해야한다.
StringBuilder란? StringBuffer는 멀티쓰레드에 안전(thread safe)하도록 동기화되어 있다. 멀티쓰레드로 작성된 프로그램이 아닌 경우, StringBuffer의 동기화는 불필요하게 성능만 떨어뜨리게 된다.
그래서 StringBuffer에서 쓰레드의 동기화만 뺀 StringBuilder가 새로 추가되었다.
래퍼(wrapper) 클래스
경우에 따라 기본형(primitive type) 변수도 어쩔 수 없이 객체로 다뤄야 하는 경우가 있다. 예를 들면, 매개변수로 객체를 요구할 때, 기본형 값이 아닌 객체로 저장해야할 때, 객체 간의 비교가 필요할 때 등등의 경우에는 기본형 값들을 객체로 변환하여 작업을 수행해야 한다.
이 때 사용되는 것이 래퍼(wrapper)클래스이다. 8개의 기본형을 대표하는 8개의 래퍼클래스가 있는데, 이 클래스들을 이용하면 기본형 값을 객체로 다룰 수 있다.
래퍼 클래스들은 객체생성 시에 생성자의 인자로 주어진 각 자료형에 알맞은 값을 내부적으로 저장하고 있으며, 이에 관련된 여러 메서드가 정의되어 있다.
래퍼 클래스들은 모두 equals()가 오버라이딩되어 있어서 주소값이 아닌 객체가 가지고 있는 값을 비교한다. 오토박싱이 된다고 해도 Integer객체에 비교연산자를 사용할 수 없다. 대신 compareTo()를 제공한다.
그리고 toString()도 오버라이딩되어 있어서 객체가 가지고 있는 값을 문자열로 변환하여 반환한다.
문자열을 숫자로 변환하기
1 2 3
int i = new Integer("100").intValue(); int i2 = Integer.parseInt("100"); Integer i3 = Integer.valueOf("100");
타입.parse타입(String s) 의 반환값이 기본형(primitive type)이고, 타입.valueOf()는 반환값이 래퍼 클래스 타입이라는 차이가 있다.
문자열 -> 기본형 int i = Integer.parseInt(“100”);
문자열 -> 래퍼 클래스 Integer i = Integer.valueIf(“100”);
JDK 1.5부터 도입된 ‘오토박싱(autoboxing)’ 기능 때문에 반환값이 기본형일 때와 래퍼 클래스일 때의 차이가 없어졌다. 그래서 그냥 구별없이 valueOf()를 쓰는 것도 괜찮은 방법이다. 단, 성능은 valueOf()가 조금 더 느리다.
오토박싱 & 언박싱
JDK 1.5 이전에는 기본형과 참조형 간의 연산이 불가능했기 때문에, 래퍼 클래스로 기본형을 객체로 만들어서 연산해야 했다.
그러나 이제는 기본형과 참조형 간의 덧셈이 가능하다. 자바 언어의 규칙이 바뀐 것은 아니고, 컴파일러가 자동으로 변환하는 코드를 넣어주기 때문이다.
기본형 값을 래퍼 클래스의 객체로 자동 변환해주는 것을 ‘오토박싱’이라고 하고, 반대로 변환하는 것을 ‘언박싱’이라고 한다.
1 2 3 4
ArrayList<Integer> list = new ArrayList<Integer>(); list.add(10); // 오토박싱
int value = list.get(0); // 언박싱
ArrayList에 숫자를 저장하거나 꺼낼 때, 기본형 값을 래퍼클래스의 객체로 변환하지 않아도 되므로 편리하다.
유용한 클래스
java.util.Objects 클래스
Object 클래스의 보조 클래스로 Math 클래스처럼 모든 메서드가 ‘static’이다. 객체의 비교나 널 체크(null check)에 유용하다.
IsNull()은 해당 객체가 널인지 확인해서 null이면 true를 반환하고 아니면 false를 반환한다. nonNull()은 isNull()과 정확히 반대의 일을 한다.
Object 클래스에는 두 객체를 비교하는 메서드가 등가비교를 위한 equals()만 있고, 대소비교를 위한 compare()가 없는 것이 좀 아쉬웠다. 그래서인지 Objects에는 compare()가 추가되었다. compare()는 두 비교대상이 같으면 0, 크면 양수, 작으면 음수를 반환한다.
1
staticintcompare(Object a, Object b, Comparator c)
이 메서드는 a와 b 두 객체를 비교하는데, 두 객체를 비교하는데 사용할 비교 기준이 필요하다. 그 역할을 하는 것이 Comparator이다.
Objects 클래스의 equals()는 Object 클래스와는 달리, null 검사를 하지 않아도 된다. equals()의 내부에서 a와 b의 null 검사를 하기 때문에 따로 null 검사를 위한 조건식을 넣지 않아도 된다. 실제 메서드의 코드는 다음과 같다.
1 2 3
publicstaticbooleanequals(Object a, Object b){ return (a == b) || (a != null && a.equals(b)); }
a와 b가 모두 null인 경우에는 참을 반환한다는 점을 빼고는 특별한 것이 없다. deepEquals() 메서드는 객체를 재귀적으로 비교하기 때문에 다차원 배열의 비교도 가능하다.
java.util.Scanner 클래스
Scanner는 화면, 파일, 문자열과 같은 입력소스로부터 문자데이터를 읽어오는데 도움을 줄 목적으로 JDK 1.5부터 추가되었다. Scanner에는 다음과 같은 생성자를 지원하기 때문에 다양한 입력소스로부터 데이터를 읽을 수 있다.
StringTokenizer는 긴 문자열을 지정한 구분자(delimiter)를 기준으로 토큰(token)이라는 여러 개의 문자열로 잘라내는 데 사용된다. StringTokenizer를 이용하는 방법 이외에도 String의 split(String reges)이나 Scanner의 useDelimiter(Spring pattern)를 사용할 수도 있다.
위의 이 두 가지 방법은 정규식 표현(Regular expression)을 사용해야 하므로 정규식 표현에 익숙하지 않은 경우 StringTokenizer를 사용하는 것이 간단하면서도 명확한 결과를 얻을 수 있다.
그러나 StirngTokenizer는 구분자로 단 하나의 문자열 밖에 사용하지 못하기 때문에 복잡한 형태의 구분자로 문자열을 나누어야 하는 경우에는 정규식을 사용하는 메서드를 사용해야 한다.
split()은 빈 문자열도 토큰으로 인식하는 반면 StringTokenizer는 빈 문자열을 토큰으로 인식하지 않기 때문에 인식하는 토큰의 개수가 서로 다른 것을 알 수 있다.
이 외에도 성능의 차이가 있는데, split()은 데이터를 토큰으로 잘라낸 결과를 배열에 담아서 반환하기 때문에 데이터를 토큰으로 바로바로 잘라서 반환하는 StringTokenizer보다 성능이 떨어질 수밖에 없다. 그러나 데이터의 양이 많은 경우가 아니라면 별 문제가 되지 않으므로 크게 신경 쓸 부분은 아니다.
java.math.BigInteger 클래스
정수형으로 표현할 수 있는 값의 한계가 있다. 가장 큰 정수형 타입인 long으로 표현할 수 있는 값은 10진수로 19자리 정도이다. 이 값도 상당히 큰 값이지만, 과학적 계산에서는 더 큰값을 다뤄야할 때가 있다. 그럴 때 사용하면 좋은 것이 BigIntenger이다.
BigInteger는 내부적으로 int배열을 사용해서 값을 다룬다. 그래서 long 타입보다 훨씬 큰 값을 다룰 수 있는 것이다. 대신 성능은 long 타입보다 떨어질 수밖에 없다.
1 2
finalint signum; // 부호. 1(양수), 0, -1(음수) 셋 중의 하나 finalint[] mag; // 값
BigIntenger는 String처럼 불변(immutable)이다. 그리고 모든 정수형이 그렇듯이 BigInteger 역시 값을 ‘2의 보수’의 형태로 표현한다.
위의 코드에서 알 수 있듯이 부호를 따로 저장하고 배열에는 값 자체만 저장한다. 그래서 signum의 값이 -1, 즉 음수인 경우, 2의 보수법에 맞게 mag의 값을 변환해서 처리한다. 그래서 부호만 다른 두 값의 mag의 값을 변환해서 처리한다. 그래서 부호만 다른 두 값의 mag는 같고 signum은 다르다.
BigInteger는 불변이므로, 반환타입이 BigInteger이란 얘기는 새로운 인스턴스가 반환된다는 뜻이다.
비트 연산 메서드 워낙 큰 숫자를 다루기 위한 클래스이므로, 성능을 향상시키기 위해 비트단위로 연산을 수행하는 메서드들을 많이 갖고 있다. 따라서 가능하면 산술연산 대신 비트연산으로 처리하도록 노력해야 한다.
java.math.BigDecimal 클래스
double 타입으로 표현할 수 있는 값은 상당히 범위가 넓지만, 정밀도가 최대 13자리 밖에 되지 않고 실수형의 특성상 오차를 피할 수 없다. BigDecimal은 실수형과 달리 정수를 이용해서 실수를 표현한다. 실수의 오차는 10진 실수를 2진 실수로 정확히 변환할 수 없는 경우가 있기 때문에 발생하는 것이므로, 오차가 없는 2진 정수로 변환하여 다루는 것이다.
매번 공부해도 너무 당연하다는 듯이 넘어가 잊게되거나, 기억해 두면 좋을것 같은 기초적인 문법과 개념들을 간단히 정리해보겠습니다.
변수
덧셈 연산자(+)는 피연산자가 모두 숫자일 때는 두 수를 더하지만, 피연산자 중 어느 한쪽이 String이면 나머지 한 쪽을 먼저 String으로 변환한 다음 두 String을 결합한다.
참조변수의 출력이나 덧셈연산자를 이용한 참조변수와 문자열의 결합에는 toString()이 자동적으로 호출되어 참조변수를 문자열로 대치한 후 처리한다.
연산자
x << 2 + 1 : 쉬프트연산자(<<)는 덧셈연산자보다 우선순위가 낮다. 그래서 해당 식은 ‘x << (2 + 1)’ 과 같다.
data & 0xFF == 0 : 논리연산자(&)는 비교연산자(==)보다 우선순위가 낮으므로 비교연산 후에 논리연산이 수행된다. 그래서 해당 식은 ‘data & (0xFF == 0)’ 과 같다.
x < -1 || x > 3 && x < 5 : 논리연산자 중에서 AND를 의미하는 ‘&&’가 OR을 의미하는 ‘||’보다 우선순위가 높다. 그래서 해당 식은 ‘x < -1 || (x > 3 && x < 5)’ 과 같다.
산술 변환 : 연산 전에 피연산자 타입의 일치를 위해 자동 형변환되는 것. 이 변환은 이항 연산에서 뿐만 아니라 단항 연산에서도 일어난다. ‘산술 변환’의 규칙은 다음과 같다.
두 피연산자의 타입을 같게 일치시킨다. (보다 큰 타입으로 일치)
long + int -> long + long -> long
float + int -> float + float -> float
피연산자의 타입이 int보다 작은 타입이면 int로 변환된다.
byte + short -> int + int -> int
char + short -> int + int -> int
첫 번째 규칙은 자동 형변환처럼 피연산자의 값손실을 최소화하기 위한 것이고, 두 번째 규칙은 정수형의 기본 타입인 int가 가장 효율적으로 처리할 수 있는 타입이기 때문에, 그리고 int보다 작은 타입, 예를 들면 char나 short의 표현범위가 좁아서 연산중에 오버플로우(overflow)가 발생할 가능성이 높기 때문에 만들어진 것이다.
1 2 3 4 5 6 7 8
long a = 1000000 * 1000000; long b = 1000000 * 1000000L; System.out.println("a="+a); System.out.println("b="+b);
// 결과 a = -727379968 b = 1000000000000
위의 예제에서 ‘1000000 * 1000000’의 결과가 1000000000000(2*10의 12승)임에도 불구하고, -727379968라는 결과가 출력되었다. 그 이유는 int 타입과 int 타입의 연산 결과는 int 타입인데, 연산결과가 int 타입의 최대값인 1000000000(2*10의 9승)을 넘으므로 오버플로우(overflow)가 발생했기 때문이다. 이미 오버플로우가 발생한 값을 아무리 long 타입의 변수에 저장해도 소용이 없다.
1 2 3 4 5 6 7 8 9
char c1 = 'a'; // c1에는 문자 'a'의 코드값은 97이 저장된다. char c2 = c1; // c1에 저장되어 있는 값이 c2에 저장된다. char c3 = ' '; // c3를 공백으로 초기화 한다.
int i = c1 +1; // 'a' + 1 -> 97 + 1 -> 98
c3 = (char)(c1 + 1); c2++; c2++;
위의 예제에서 c2++; 대신에 c2=c2+1;을 사용하면 에러가 발생할 것이다. c2+1의 연산결과는 int형이며, 그 결과를 다시 c2에 담으려면 형변환 연산자를 사용하여 char형으로 형변환해야 하기 때문이다.
1 2 3
char c1 = 'a'; char c2 = c1 + 1; // 컴파일 에러 발생 O char c2 = 'a' + 1; // 컴파일 에러 발생 X
‘a’ + 1은 리터럴 간의 연산이기 때문에 에러가 발생하지 않는다. 상수 또는 리터럴 간의 연산은 실행과정동안 변하는 값이 아니기 때문에, 컴파일 시에 컴파일러가 계산해서 그 결과로 대체함으로써 코드를 보다 효율적으로 만든다. 컴파일러가 미리 덧셈연산을 수행하기 때문에 실행 시에는 덧셈 연산이 수행되지 않는다. 수식에 변수가 들어가 있는 경우에는 컴파일러가 미리 계산을 할 수 없기 때문에 형변환을 해줘야한다. (char c2 = (char) (c1 + 1)) 그렇지 않으면 컴파일 에러가 발생한다.
1 2
float f = 0.1f; // f에 0.10000000149011612로 저장된다. double d = 0.1; // d에 0.10000000000000001로 저장된다.
float 타입의 값을 double 타입으로 형변환하면, 부호와 지수는 달라지지 않고 그저 기수의 빈자리를 0으로 채울 뿐이므로 0.1f를 double타입으로 형변환해도 그 값은 전혀 달라지지 않는다. 즉, float 타입의 값을 정밀도가 더 높은 double 타입으로 형변환했다고 해서 오차가 적어지는 것이 아니라는 말이다.
1 2 3 4 5 6 7 8 9 10
String str1 = "abc"; String str2 = new String("abc");
str2와 “abc”의 내용이 같은데도 ‘==’로 비교하면, false를 결과로 얻는다. 내용은 같지만 서로 다른 객체이기 때문이다. 그러나 equals()는 객체가 달라도 내용이 같으면 true를 반환한다. 그래서 문자열을 비교할 때는 항상 equals()를 사용해야 한다.
효율적인 연산
OR 연산 ‘||’의 경우, 두 피연산자 중 어느 한 쪽만 ‘참’이어도 전체 연산결과가 ‘참’이므로 좌측 피연산자가 ‘true(참)’이면, 우측 피연산자의 값은 평가하지 않는다.
AND 연산 ‘&&’의 경우, 어느 한쪽만 ‘거짓(0)’이어도 전체 연산결과가 ‘거짓(0)’이므로 좌측 피연산자가 ‘거짓(0)’이면, 우측 피연산자의 값은 평가하지 않는다.
비트 XOR 연산자 ‘^’는 두 피연산자의 비트가 다를 때만 1이 된다. 그리고 같은 값으로 두고 XOR 연산을 수행하면 원래의 값으로 돌아온다는 특징이 있어서 간단한 암호화에 사용된다.
비트 전환 연산자는 피연산자의 타입이 int보다 작으면 int로 자동 형변환(산술 변환) 후에 연산하기 때문에 연산결과는 32자리의 2진수이다.
쉬프트 연산자의 좌측 피연산자는 산술변환이 적용되어 int보다 작은 타입은 int타입으로 자동 변환되고 연산결과 역시 int타입이 된다. 그러나 쉬프트 연산자는 다른 이항연산자들과 달리 피연산자의 타입을 일치시킬 필요가 없기 때문에 우측 피연산자에는 산술변환이 적용되지 않는다.
변수 앞에 키워드 ‘final’을 붙이면 상수가 된다. 상수는 반드시 선언과 동시에 값을 저장해야하며, 한 번 저장된 값은 바꿀 수 없다.
조건문과 반복문
JDK 1.5부터 배열과 컬렉션에 저장된 요소에 접근할 때 기존보다 편리한 방법으로 처리할 수 있도록 for문의 새로운 문법이 추가되었다.
1 2 3
for (타입 변수명 : 배열 또는 컬렉션) { // 반복할 문장 }
위의 문장에서 타입은 배열 또는 컬렉션의 요소의 타입이어야 한다. 배열 또는 컬렉션에 저장된 값이 매 반복마다 하나씩 순서대로 읽혀서 변수에 저장된다. 그러나 향상된 for문은 일반적인 for문과 달리 배열이나 컬렉션에 저장된 요소들을 읽어오는 용도로만 사용 수 있다는 제약이 있다.
반복문은 그저 같은 문장을 반복해서 수행하는 것이지만, 메서드를 호출하는 것은 반복문 보다 몇 가지 과정, 예를 들면 매개변수 복사와 종료 후 복귀할 주소저장 등, 이 추가로 필요하기 때문에 반복문보다 재귀호출의 수행시간이 더 오래 걸린다.
배열
배열은 같은 타입의 여러 변수를 하나의 묶음으로 다루는 것이다. 배열을 선언하는 것은 단지 생성된 배열을 다루기 위한 참조변수를 위한 공간이 만들어질 뿐이고, 배열을 생성해야만 비로소 값을 저장할 수 있는 공간이 만들어지는 것이다.
Java에서는 배열의 길이가 0일 수도 있다.
배열이름.length - 자바에서는 JVM이 모든 배열의 길이를 별도로 관리하며, ‘배열이름.length’를 통해서 배열의 길이에 대한 정보를 얻을 수 있다. ‘배열이름.length’는 상수이다.
자바에서는 다음과 같이 배열을 간단히 초기화 할 수 있는 방법을 제공한다.
1 2
int[] score1 = newint[]{50, 60, 70, 80, 90}; // 배열의 생성과 초기화를 동시에 int[] score2 = {50, 60, 70, 80, 90} // new int[]를 생략할 수 있음
그러나 배열의 선언과 생성을 따로 하는 경우에는 생략할 수 없다.
1 2 3
int [] score; score = newint[]{50, 60, 70, 80, 90}; // OK score = {50, 60, 70, 80, 90}; // 에러. new int[]를 생략할 수 없음
만약 score의 값을 바로 출력하면 어떻게 될까? 타입@주소의 형식으로 출력된다. ‘[I’는 1차원 int 배열이라는 의미이고, ‘@’뒤에 나오는 16진수는 배열의 주소인데 실제 주소가 아닌 내부 주소이다.
1 2
// 배열을 가리키는 참조변수 score의 값을 출력 System.out.println(score); // [I@14318bb와 같은 형식의 문자열이 출력된다.
예외적으로 char배열은 println메서드로 출력하면 각 요소가 구분자없이 그대로 출력되는데, 이것은 println메서드가 char배열일 때만 이렇게 동작하도록 작성되었기 때문이다.
for문 대신 System클래스의 arraycopy()를 사용하면 보다 간단하고 빠르게 배열을 복사할 수 있다.
자바에서 char배열이 아닌 String클래스를 이용해서 문자열을 처리하는 이유는 String클래스가 char배열에 여러 가지 기능을 추가하여 확장한 것이기 때문이다. char배열과 String클래스의 한 가지 중요한 차이는, String객체(문자열)는 읽을 수만 있을 뿐 내용을 변경할 수 없다. (변경 가능한 문자열을 다루려면, StringBuffer 클래스를 사용하면 된다.)
JVM의 메모리구조
JVM은 시스템으로부터 프로그램을 수행하는데 필요한 메모리를 할당받고 JVM은 이 메모리를 용도에 따라 여러 영역으로 나누어 관리한다.
메서드 영역 (method area) 프로그램 실행 중 어떤 클래스가 사용되면, JVM은 해당 클래스의 클래스파일(*.class)을 읽어서 분석하여 클래스에 대한 정보(클래스 데이터)를 이곳에 저장한다. 그 클래스의 클래스변수도 이 영역에 함께 생성된다.
힙 (heap) 인스턴스가 생성되는 공간. 프로그램 실행 중 생성되는 인스턴스는 모두 이곳에 생성된다. 즉, 인스턴스 변수들이 생성되는 곳이다.
호출스택 (call stack or execution stack) 호출스택은 메서드의 작업에 필요한 메모리 공간을 제공한다. 메서드가 호출되면, 호출스택에 호출된 메서드를 위한 메모리가 할당되고, 이 메모리는 메서드가 작업을 수행하는 동안 지역변수(매개변수 포함)들과 연산의 중간결과 등을 저장하는데 사용된다. 메서드가 작업을 마치면 할당되었던 메모리공간은 반환되어 비워진다.
클래스
인스턴스 메서드는 인스턴스 변수와 관련된 작업을 하는, 즉 메서드의 작업을 수행하는데 인스턴스 변수를 필요로 하는 메서드이다. 반면에 인스턴스와 관계없는(인스턴스 변수나 인스턴스 메서드를 사용하지 않는) 메서드를 클래스 메서드(static 메서드)로 정의한다.
가변인자
기존에는 메서드의 매개변수 개수가 고정적이었으나 JDK1.5부터 동적으로 지정해 줄 수 있게 되었으며, 이 기능을 가변인자라고 한다. 가변인자는 **’타입… 변수명’**과 같은 형식으로 선언한다. PrintStream클래스의 printf()가 대표적인 예이다.
1
public PrintStream printf(String format, Object... args); { ... }
가변인자 외에도 매개변수가 더 있으면, 가변인자를 매개변수 중에서 제일 마지막에 선언해야 한다. 그렇지 않으면, 컴파일 에러가 발생한다.
가변인자는 내부적으로 배열을 이용한다. 그래서 가변인자가 선언된 메서드를 호출할 때마다 배열이 새로 생성된다. 가변인자가 편리하지만, 이런 비효율이 숨어있기 때문에 꼭 필요한 경우에만 사용해야한다.
가변인자를 사용한 메서드를 호출할 때는 인자가 아예 없어도 되고 배열을 사용할 수도 있다. (C언어와 달리 자바에서는 길이가 0인 배열을 생성하는 것이 허용된다.)
생성자
컴파일러가 자동적으로 기본 생성자를 추가해주는 경우는 ‘클래스 내에 생성자가 하나도 없을 때’ 뿐이다.
생성자에서 다른 생성자를 호출할 때에는 생성자의 이름으로 클래스이름 대신 this를 사용한다. ‘this’는 참조변수로 인스턴스 자신을 가리킨다. 또한 한 생성자에서 다른 생성자를 호출할 때는 반드시 첫 줄에서만 호출이 가능하다.
this : 인스턴스 자신을 가리키는 참조변수, 인스턴스의 주소가 저장되어 있다. 모든 인스턴스 메서드에 지역변수에 숨겨진 채로 존재한다.
this(), this(매개변수) : 생성자 같은 클래스의 다른 생성자를 호출할 때 사용한다.
변수의 초기화
멤버변수는 초기화를 하지 않아도 자동적으로 변수의 자료형에 맞는 기본갑으로 초기화가 이루어지므로 초기화하지 않고 사용해도 되지만, 지역변수는 사용하기 전에 반드시 초기화해야 한다. (멤버변수(클래스변수와 인스턴스변수)와 배열의 초기화는 선택적이지만, 지역변수의 초기화는 필수적이다.)
멤버변수의 초기화는 지역변수와 달리 여러 가지 방법이 있다.
명시적 초기화 : 변수를 선언과 동시에 초기화하는 것
생성자
초기화 블럭
인스턴스 초기화 블럭 : 인스턴스변수를 초기화 하는데 사용
클래스 초기화 블럭 : 클래스 변수를 초기화 하는데 사용
1 2 3 4 5 6 7
classtestClass() { static { /* 클래스 초기화 블럭 */ }
{ /* 인스턴스 초기화 블럭 */ }
// ... }
클래스 초기화 블럭은 클래스가 메모리에 처음 로딩될 때 한번만 수행되며, 인스턴스 초기화 블럭은 생성자와 같이 인스턴스를 생성할 때 마다 수행된다. (생성자보다 인스턴스 초기화 블럭이 먼저 수행된다.)
인스턴스 변수의 초기화는 주로 생성자를 사용하고, 인스턴스 초기화 블럭은 모든 생성자에서 공통으로 수행돼야 하는 코드를 넣는데 사용한다.
클래스변수의 초기화 시점 : 클래스가 처음 로딩될 때 단 한번 초기화 된다.
인스턴스변수의 초기화 시점 : 인스턴스가 생성될 때마다 각 인스턴스별로 초기화가 이루어진다.
프로그램 실행도중 클래스에 대한 정보가 요구될 때, 클래스에 로딩된다. 하지만, 해당 클래스가 이미 메모리에 로딩되어 있다면, 또 다시 로딩하지 않는다.
클래스의 로딩 시기는 JVM의 종류에 따라 좀 다를 수 있는데, 클래스가 필요할 때 바로 메모리에 로딩하도록 설계가 되어있는 것도 있고, 실행효율을 높이기 위해서 사용될 클래스들을 프로그램이 시작될 때 미리 로딩하도록 되어있는 것도 있다.
상속
생성자와 초기화 블럭은 상속되지 않는다. 멤버만 상속된다.
오버라이딩의 조건
오버라이딩시 접근 제어자와 예외는 제한된 조건 하에서만 다르게 변경할 수 있다.
접근 제어자는 조상 클래스의 메서드보다 좁은 범위로 변경 할 수 없다. 접근범위는 public, protected, (default), private이다.
조상 클래스의 메서드보다 많은 수의 예외를 선언할 수 없다.
Object 클래스를 제외한 모든 클래스의 생성자는 첫 줄에 반드시 자신의 다른 생성자 또는 조상의 생성자를 호출해야 한다. 그렇지 않으면 컴파일러는 생성자의 첫 줄에 ‘super();’를 자동적으로 추가한다.
어떤 클래스의 인스턴스를 생성하면, 클래스 상속관계의 최고조상인 Object 클래스까지 거슬러 올라가면서 모든 조상클래스의 생성자가 순서대로 호출된다.
package와 import
패키지(package)
패키지란, 클래스의 묶음이다. 클래스의 실제 이름은 패키지명을 포함한 것이다.
클래스가 물리적으로 하나의 클래스파일(.class)인 것과 같이 패키지는 물리적으로 하나의 디렉토리이다. 디렉토리가 하위 디렉토리를 가질 수 있는 것처럼, 패키지도 다른 패키지를 포함할 수 있으며 점’.’으로 구분한다.
패키지 선언문은 반드시 소스파일에서 주석과 공백을 제외한 첫 번째 문장이어야 하며, 하나의 소스파일에 단 한번만 선언될 수 있다.
소스파일에 자신이 속할 패키지를 지정하지 않은 클래스는 자동적으로 ‘이름 없는 패키지’에 속하게 된다. 결국 패키지를 지정하지 않는 모든 클래스들은 같은 패키지에 속한다.
클래스패스(classpath)는 컴파일러(javac.exe)나 JVM 등이 클래스의 위치를 찾는데 사용되는 경로이다.
import문
클래스의 코드를 작성하기 전에 import문으로 사용하고자 하는 클래스의 패키지를 미리 명시해주면 소스코드에 사용되는 클래스이름에서 패키지명은 생략할 수 있다.
import문의 역할은 컴파일러에게 소스파일에 사용된 클래스의 패키지에 대한 정보를 제공하는 것이다.
import문에서 클래스의 이름 대신 ‘*’을 사용하는 것이 하위 패키지의 클래스까지 포함하는 것은 아니라는 것이다.
import문으로 패키지를 지정하지 않으면 위와 같이 모든 클래스이름 앞에 패키지명을 반드시 붙여야 한다. 단, 같은 패키지 내의 클래스들은 import문을 지정하지 않고도 패키지명을 생략할 수 있다.
제어자
생성자가 private인 클래스는 다른 클래스의 조상이 될 수 없다. 그래서 클래스 앞에 final을 더 추가하여 상속할 수 없는 클래스라는 것을 알려야 한다.
제어자를 조합해서 사용할 때 주의해야 할 사항
메서드에 static과 abstract를 함께 사용할 수 없다. (static 메서드는 몸통이 있는 메서드에만 사용할 수 있기 때문이다.)
클래스에 abstract와 final을 동시에 사용할 수 없다.
참조변수
형변환
컴파일 시에는 참조변수간의 타입만 체크하기 때문에 실행 시 생성될 인스턴스의 타입에 대해서는 전혀 알지 못한다. 그래서 컴파일 시에는 문제가 없었지만, 실행 시에는 에러가 발생하여 실행이 비정상적으로 종료될 수 있다.
참조변수가 참좋고 있는 인스턴스의 실제 타입을 알아보기 위해 instance 연산자를 사용한다. 실제 인스턴스와 같은 타입의 instanceof 연산 이외에 조상타입의 instance 연산에도 true를 결과로 얻으며, instanceof의 연산의 결과가 true라는 것은 검사한 타입으로의 형변환을 해도 아무런 문제가 없다는 뜻이다.
참조변수와 인스턴스의 연결
멤버변수가 조상 클래스와 자손 클래스에 중복으로 정의된 경우, 조상 타입의 참조변수를 사용했을 때는 조상 클래스에 선언된 멤버변수가 사용되고, 자손타입의 참조변수를 사용했을 때는 자손 클래스에 선언된 멤버변수가 사용된다. 하지만 중복 정의되지 않은 경우, 조상타입의 참조변수를 사용했을 때와 자손타입의 참조변수를 사용했을 때의 차이는 없다.
인터페이스
인터페이스는 일종의 추상클래스이다. 인터페이스는 추상클래스처럼 추상메서드를 갖지만 추상클래스보다 추상화 정도가 높아서 추상클래스와 달리 몸통을 갖춘 일반 메서드 또는 멤버변수를 구성원으로 가질 수 없다. 오직 추상메서드와 상수만을 멤버로 가질 수 있다.
모든 멤버변수는 public static final 이어야 하며, 이를 생략할 수 있다.
모든 메서드는 public abstract 이어야 하며, 이를 생략할 수 있다. 단, static 메서드와 디폴트 메서드는 예외
원래는 인터페이스의 모든 메서드는 추상메서드이어야 하는데, JDK1.8부터 인터페이스에 static 메서드와 디폴트 메서드의 추가를 허용하는 방향으로 변경되었다.
디폴트 메서드
조상 클래스에 새로운 메서드를 추가하는 것은 별 일이 아니지만, 인터페이스의 경우에는 보통 큰 일이 아니다. 인터페이스에 메서드를 추가한다는 것은, 추상 메서드를 추가한다는 것이고, 이 인터페이스를 구현한 기존의 모든 클래스들이 새로 추가된 메서드를 구현해야 하기 때문이다.
디폴트 메서드는 추상 메서드의 기본적인 구현을 제공하는 메서드로, 추상 메서드가 아니기 때문에 디폴트 메서드가 새로 추가되어도 해당 인터페이스를 구현한 클래스를 변경하지 않아도 된다.
디폴트 메서드는 메서드 앞에 키워드 default를 붙이며, 추상 메서드와 달리 일반 메서드처럼 몸통{}이 있어야 한다. 디폴트 메서드 역시 접근 제어자가 public 이며, 생략가능하다.
내부 클래스
내부 클래스는 클래스 내에 선언된 클래스이다. 클래스에 다른 클래스를 선언하는 이유는 간단하다. 두 클래스가 서로 긴밀한 관계에 있기 때문이다.
내부 클래스의 장점
내부 클래스에서 외부 클래스의 멤버들을 쉽게 접근할 수 있다.
코드의 복잡성을 줄일 수 있다(캡슐화).
내부 클래스의 종류와 특징
내부 클래스의 종류는 변수의 선언위치에 따른 종류와 같다. 내부 클래스는 마치 변수를 선언하는 것과 같은 위치에 선언할 수 있으며, 변수의 선언위치에 따라 인스턴스변수, 클래스변수(static 변수), 지역변수로 구분되는 것과 같이 내부 클래스도 선언위치에 따라 다음과 같이 구분된다.
인스턴스 클래스 (instance class) 외부 클래스의 멤버변수 선언위치에 선언하며, 외부 클래스의 인스턴스 멤버처럼 다루어 진다. 주로 외부 클래스의 인스턴스멤버들과 관련된 작업에 사용될 목적으로 선언된다.
스태틱 클래스 (static class) 외부 클래스의 멤버변수 선언위치에 선언하며, 외부 클래스의 static 멤버처럼 다루어진다. 주로 외부 클래스의 static 멤버, 특히 static 메서드에서 사용될 목적으로 선언된다.
지역 클래스 (local class) 외부 클래스의 메서드나 초기화블럭 안에 선언하며, 선언된 영역 내부에서만 사용될 수 있다.
익명 클래스 (anonymous class) 클래스의 선언과 객체의 생성을 동시에 하는 이름없는 클래스(일회용)
지역 클래스(LocalInner)는 외부 클래스의 인스턴스멤버와 static 멤버를 모두 사용할 수 있으며, 지역 클래스가 포함된 메서드에 정의된 지역변수도 사용할 수 있다. 단, final이 붙은 지역변수만 접근가능한데 그 이유는 메서드가 수행을 마쳐서 지역변수가 소멸된 시점에도, 지역 클래스의 인스턴스가 소멸된 지역변수를 참조하려는 경우가 발생할 수 있기 때문이다.
JDK 1.8부터 지역 클래스에서 접근하는 지역 변수 앞에 final을 생략할 수 있게 바뀌었다. 대신 컴파일러가 자동으로 붙여준다. 즉, 편의상 final을 생략할 수 있게 한 것일 뿐 해당 변수의 값이 바뀌는 문장이 있으면 컴파일 에러가 발생한다.
내부 클래스는 컴파일 했을 때 생성되는 파일명은 외부 클래스명$내부 클래스명.class 형식으로 되어 있다.
익명 클래스 (anonymous class)
익명클래스는 특이하게도 다른 내부 클래스들과는 달리 이름이 없다. 클래스의 선언과 객체의 생성을 동시에 하기 때문에 단 한번만 사용될 수 있고 오직 하나의 객체만을 생성할 수 있는 일회용 클래스이다.
1 2 3 4 5 6 7
new 조상클래스이름() { // 멤버 선언 } 또는 new 구현인터페이스이름() { // 멤버 선언 }
이름이 없기 때문에 생성자도 가질 수 없으며, 조상클래스의 이름이나 구현하고자 하는 인터페이스의 이름을 사용해서 정의하기 때문에 하나의 클래스로 상속받는 동시에 인터페이스를 구현하거나 둘 이상의 인터페이스를 구현할 수 있다. 오로지 단 하나의 클래스를 상속받거나 단 하나의 인터페이스만을 구현할 수 있다.
익명 클래스는 이름이 없기 때문에 외부 클래스명$숫자.class의 형식으로 클래스파일명이 결정된다.
예외처리
프로그램이 실행 중 어떤 원인에 의해서 오작동을 하거나 비정상적으로 종료되는 경우가 있다. 이러한 결과를 초래하는 원인을 프로그램 에러 또는 오류라고 한다.
이를 발생시점에 따라 컴파일 에러와 런타임 에러로 나눌 수 있다.
컴파일 에러 - 컴파일 시에 발생하는 에러
런타임 에러 - 실행 시에 발생하는 에러
논리적 에러 - 실행은 되지만, 의도와 다르게 동작하는 것
컴파일은 잘되었어도 실행 중에 에러에 의해서 잘못된 결과를 얻거나 프로그램이 비정상적으로 종료될 수 있다.
자바에서는 실행 시(runtime) 발생할 수 있는 프로그램 오류를 에러(error)와 예외(exception) 두 가지로 구분하였다.
에러는 메모리 부족(OutOfMemoryError)이나 스택오버플로우(StackOverflowError)와 같이 일단 발생하면 복구할 수 없는 심각한 오류이고, 예외는 발생하더라도 수습될 수 있는 비교적 덜 심각한 것이다.
모든 예외의 최고 조상은 Exception 클래스이다.
예외 처리하기 - try-catch 문
예외처리의
정의 - 프로그램 실행 시 발생할 수 있는 예외의 발생에 대비한 코드를 작성하는 것
목적 - 프로그램의 비정상 종료를 막고, 정상적인 실행상태를 유지하는 것
에러와 예외는 모두 실행 시(runtime) 발생하는 오류이다.
발생한 예외를 처리하지 못하면, 프로그램은 비정상적으로 종료되며, 처리되지 못한 예외(uncaught exception)는 JVM의 ‘예외처리기(UncaughtExceptionHandler)’가 받아서 예외의 원인을 화면에 출력한다.
예외를 처리하기 위해서는 try-catch문을 사용한다. 하나의 try 블럭 다음에는 여러 종류의 예외를 처리할 수 있도록 하나 이상의 catch 블럭이 올 수 있으며, 이 중 발생한 예외의 종류와 일치하는 단 한 개의 catch 블럭만 수행된다.
try 블럭 또는 catch 블럭에 또 다른 try-catch 문이 포함될 수 있다. catch 블럭 내의 코드에서도 예외가 발생할 수 있기 때문이다. catch 블럭 내에 또 하나의 try-catch 문이 포함된 경우, 같은 이름의 참조변수를 사용해서는 안 된다.
정수는 0으로 나누는 것이 금지되어 있지만, 실수를 0으로 나누는 것은 금지되어있지 않으며 예외가 발생하지 않는다.
try 블럭에서 예외가 발생하면, 예외가 발생한 위치 이후에 있는 try 블럭의 문장들은 수행되지 않으므로, try 블럭에 포함시킬 코드의 범위를 잘 선택해야 한다.
예외가 발생한 문장이 try-catch 블럭부터 차례로 내려가면서 catch 블럭의 괄호()내에 선언된 참조변수의 종류와 생성된 예외클래스의 인스턴스에 instanceof 연산자를 이용해서 검사하게 되는데, 검사 결과가 true인 catch 블럭을 만날 때까지 검사는 계속된다.
printStackTrace()와 getMessage()
예외가 발생했을 때 생성되는 예외 클래스의 인스턴스에는 발생한 예외에 대한 정보가 담겨져 있으며, getMessage()와 printStackTrace()를 통해서 이 정보들을 얻을 수 있다.
printStackTrace() - 예외발생 당시의 호출스택(Call Stack)에 있었던 메서드의 정보와 예외 메시지를 화면에 출력한다.
getMessage() - 발생한 예외클래스의 인스턴스에 저장된 메시지를 얻을 수 있다.
printStackTrace(PrintStream s) 또는 printStackTrace(PrintWriter s)를 사용하면 발생한 예외에 대한 정보를 파일에 저장할 수도 있다.
멀티 catch 블럭
JDK 1.7부터 여러 catch 블럭을 ‘|’ 기호를 이용해서, 하나의 catch 블럭으로 합칠 수 있게되었으며, 이를 ‘멀티 catch 블럭’이라 한다. (멀티 catch 블럭에 사용되는 ‘|’는 논리 연산자가 아니라 기호이다.)
만일 멀티 catch 블럭의 ‘|’ 기호로 연결된 예외 클래스가 조상과 자손의 관계에 있다면 컴파일 에러가 발생한다. (그냥 조상 클래스만 써주는 것과 똑같기 때문이다.)
예외 발생시키기
키워드 throw를 사용해서 프로그래머가 고의로 예외를 발생시킬 수 있다.
먼저, 연산자 new를 이용해서 발생시키려는 예외 클래스의 객체를 만든 다음
키워드 throw를 이용해서 예외를 발생시킨다.
Exception 인스턴스를 생성할 때, 생성자에 String을 넣어 주면, 이 String이 Exception 인스턴스에 메시지로 저장된다. 이 메시지는 getMessage()를 이용해서 얻을 수 있다.
RuntimeException을 발생시키는 코드는 이에 대한 예외 처리를 하지 않았음에도 불구하고 성공적으로 컴파일 된다. RuntimeException 클래스들과 그 자손 클래스에 해당하는 예외는 프로그래머가 실수로 발생하는 것들이기 때문에 예외처리를 강제하지 않는 것이다.
컴파일러가 예외처리를 확인하지 않는 RuntimeException 클래스들은 unchecked 예외라고 부르고, 예외처리를 확인하는 Exception 클래스들은 checked 예외라고 부른다.
Error와 그 자손도 unchecked 예외이다. try-catch 블럭으로 처리할 수 없기 때문이다.
메서드에 예외 선언하기
예외를 처리하는 방법에는 try-catch 문을 사용하는 것 외에, 예외를 메서드에 선언하는 방법이 있다. 메서드에 예외를 선언하려면, 메서드의 선언부에 키워드 throws를 사용해서 메서드 내에서 발생할 수 있는 예외를 적어주기만 하면 된다. (예외를 발생시키는 키워드 throw와 예외를 메서드에 선언할 때 쓰이는 throws를 구별해야 한다.)
메서드에 예외를 선언할 때 일반적으로 RuntimeException 클래스들은 적지 않는다.
예외를 메서드의 throws에 명시하는 것은 예외를 처리하는 것이 아니라, 자신(예외가 발생할 가능성이 있는 메서드)을 호출한 메서드에게 예외를 전달하여 예외처리를 떠맡기는 것이다.
예외를 전달받은 메서드가 또다시 자신을 호출한 메서드에게 전달할 수 있으며, 이런 식으로 계속 호출스택에 있는 메서드들을 따라 전달되다가 제일 마지막에 있는 main 메서드에서도 예외가 처리되지 않으면, main 메서드 마저 종료되어 프로그램이 전체가 종료된다.
finally 블럭
finally 블럭은 try-catch 문과 함께 예외의 발생여부에 상관없이 실행되어야할 코드를 포함시킬 목적으로 사용된다.
try 블럭에서 return문이 실행되는 경우에도 finally 블럭의 문장들이 먼저 실행된 후에, 현재 실행 중인 메서드를 종료한다. 마찬가지로 catch 블럭의 문장 수행 중에 return 문을 만나도 finally 블럭의 문장들은 수행된다.
자원 자동 반환 - try-with-resources문
JDK 1.7부터 try-with-resources문이라는 try-catch문의 변형이 새로 추가되었다.
try-with-resources문의 괄호()안에 객체를 생성하는 문장을 넣으면, 이 객체는 따로 close()를 호출하지 않아도 try 블럭을 벗어나는 순간 자동적으로 close()가 호출된다. 그 다음에 catch 블럭 또는 finally 블럭이 수행된다.
try-with-resources문에 의해 자동으로 객체의 close()가 호출될 수 있으려면, 클래스가 AuthCloseable이라는 인터페이스를 구현한 것이어야만 한다.
JdbcTemplate을 대표로 하는 스프링의 데이터 액세스 기능에 담겨 있는 예외처리와 관련된 접근 방법에 대해 알아보자.
사라진 SQLException
JdbcTemplate을 적용한 코드에서는 SQLException이 사라졌다. 이 SQLException은 어디로 간 것일까?
초난감 예외처리
예외가 발생하면 그것을 catch 블록을 써서 잡아내는 것까지는 좋은데 그리고 아무것도 하지 않고 별문제 없는 것처럼 넘어가 버리는 건 정말 위험한 일이다. 원치 않는 예외가 발생하는 것보다도 훨씬 더 나쁜 일이다. 왜냐하면 프로그램 실행 중에 어디선가 오류가 있어서 예외가 발생했는데 그것을 무시하고 계속 진행해버리기 때문이다. 결국 발생한 예외로 인해 어떤 기능이 비정상적으로 동작하거나, 메모리나 리소스가 소진되거나, 예상치 못한 다른 문제를 일으킬 것이다.
예외를 처리할 때 반드시 지켜야할 핵심 원칙은 한 가지다. 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다.
SQLException이 발생하는 이유는 SQL에 문법 에러가 있거나 DB에서 처리할 수 없을 정도로 데이터 액세스 로직에 심각한 버그가 있거나, 서버가 죽거나 네트워크가 끊기는 등의 심각한 상황이 벌어졌기 때문이다.
예외를 처리하는 2가지 나쁜 습관은 어떤 경우에도 용납하지 않아야 한다.
예외 블랙홀
무의미하고 무책임한 throws
예외의 종류와 특징
예외를 어떻게 다뤄야 할까? 가장 큰 이슈는 체크 예외(checked exception)라고 불리는 명시적인 처리가 필요한 예외를 사용하고 다루는 방법이다. 자바에서 throw를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.
Error
첫째는 java.lang.Error 클래스의 서브클래스들이다.에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다. 그래서 주로 자바 VM에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안된다. 시스템 레벨에서 특별한 작업을 하는 게 아니라면 애플리케이션에서는 이런 에러에 대한 처리는 신경 쓰지 않아도 된다.
Exception과 체크 예외
java.lang.Exception 클래스와 그 서브클래스로 정의되는 예외들은 에러와 달리 개발자들이 만든 애플리케이션 코드의 작업 중에 예외상황이 발생했을 경우에 사용된다.
Exception 클래스는 다시 체크 예외(checked exception)와 언체크 예외(Unchecked exception)로 구분된다. 전자는 Exception 크래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것들이고, 후자는 RuntimeException을 상속한 클래스들을 말한다. RuntimeException은 Exception의 서브클래스이므로 Exception의 일종이긴 하지만 자바는 이 RuntimeException과 그 서브클래스는 특별하게 다룬다.
일반적으로 예외라고 하면 Exception 클래스의 서브클래스 중에서 RuntimeException을 상속하지 않은 것만을 말하는 체크 예외라고 생각해도 된다. 체크 예외가 발생할 수 있는 메소드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해야 한다. 사용할 메소드가 체크 예외를 던진다면 이를 catch 문으로 잡든지, 아니면 다시 throws를 정의해서 메소드 밖으로 던져야 한다. 그렇지 않으면 컴파일 에러가 발생한다.
RuntimeException과 언체크/런타임 예외
java.lang.RuntimeException 클래스를 상속한 예외들은 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외라고 불린다. 또는 대표 클래스 이름을 따서 런타임 예외라고도 한다. 에러와 마찬가지로 이 런타임 예외는 catch 문으로 잡거나 throws로 선언하지 않아도 된다. 물론 명시적으로 잡거나 throw로 선언해줘도 상관없다.
대표적으로 오브젝트를 할당하지 않은 레퍼런스 변수를 사용하려고 시도했을 때 발생하는 NullPointerException이나, 허용되지 않는 값을 사용해서 메소드를 호출할 때 발생하는 IllegalArgumentException 등이 있다. 이런 예외는 코드에서 미리 조건을 체크하도록 주의 깊게 만든다면 피할 수 있다. 피할 수 있지만 개발자가 부주의해서 발생할 수 있는 경우에 발생하도록 만든 것이 런타임 예외다. 따라서 런타임 예외는 예상하지 못했던 예외상황에서 발생하는 게 아니기 때문에 굳이 catch나 throws를 사용하지 않아도 되도록 만든 것이다.
예외처리 방법
예외 복구
첫 번째 예외처리 방법은 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다. 예외처리 코드를 강제하는 체크 예외들은 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다.
예외처리 회피
두 번째 방법은 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다. throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 것이다. 예외를 자신이 처리하지 않고 회피하는 방법이다.
JdbcTemplate이 사용하는 콜백 오브젝트는 메소드 선언을 보면 알겠지만 ResultSet이나 PreparedStatement 등을 이용해서 작업하다 발생하는 SQLException을 자신이 처리하지 않고 템플릿으로 던져버린다. 콜백 오브젝트의 메소드는 모두 throws SQLException이 붙어있다. SQLException을 처리하는 일은 콜백 오브젝트의 일이 아니라고 보기 때문이다. 콜백 오브젝트의 메소드는 SQLException에 대한 예외를 회피하고 템플릿 레벨에서 처리하도록 던져준다.
예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다.
예외 전환
마지막으로 예외를 처리하는 방법은 예외 전환을 하는 것이다. 예외 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 때문에 예외를 메소드 밖으로 던지는 것이다. 하지만 예외 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다.
예외 전환은 보통 두 가지 목적으로 사용된다.
첫째는 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서다. API가 발생하는 기술적인 로우레벨을 상황에 적합한 의미를 가진 예외로 변경하는 것이다. 보통 전환하는 예외에 원래 발생한 예외를 담아서 **중첩 예외(nested exception)**로 만드는 것이 좋다. 중첩 예외는 getCause() 메소드를 이용해서 처음 발생한 예외가 무엇인지 확인할 수 있다.
두번째 전환 방법은 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것이다. 중첩 예외를 이용해 새로운 예외를 만들고 원인이 되는 예외를 내부에 담아서 던지는 방식은 같다. 하지만 의미를 명확하게 하려고 다른 예외로 전환하는 것이 아니다. 주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.
일반적으로 체크 예외를 계속 throws를 사용해 넘기는 건 무의미하다. 메소드 선언은 지저분해지고 아무런 장점이 없다. DAO에서 발생한 SQLException이 웹 컨트롤러 메소드까지 명시적으로 전달된다고 해서 무슨 소용이 있을까? 어차피 복구가 불가능한 예외라면 가능한 한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메소드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해줘야 한다.
대부분 서버 환경에서는 애플리케이션 코드에서 처리하지 않고 전달된 예외들을 일괄적으로 다룰 수 있는 기능을 제공한다.
예외처리 전략
런타임 예외의 보편화
일반적으로는 체크 예외가 일반적인 예외를 다루고, 언체크 예외는 시스템 장애나 프로그램사으이 오류에 사용된다고 했다. 문제는 체크 예외는 복구할 가능성이 조금이라도 있는, 말 그대로 예외적인 상황이기 때문에 자바는 이를 처리하는 catch 블록이나 throws 선언을 강제하고 있다는 점이다.
독립형 애플리케이션과 달리 서버의 특정 계층에서 예외가 밸생했을 때 작업을 일시 중지하고 사용자와 바로 커뮤니케이션하면서 예외상황을 복구할 수 있는 방법이 없다. 자바의 환경이 서버로 이동하면서 체크 예외의 활용도와 가치는 점점 떨어지고 있다. 자칫하면 throws Exception으로 점철된 아무런 의미도 없는 메소드들을 낳을 뿐이다. 그래서 대응이 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는게 낫다.
자바 초기부터 있었던 JDK의 API와 달리 최근에 등장하는 표준 스펙 또는 오픈소스 프레임워크에서는 API가 발생시키는 예외를 체크 예외 대신 언체크 예외로 정의하는 것이 일반화되고 있다. 언체크 예외라도 필요하다면 얼마든지 catch 블록으로 잡아서 복구하거나 처리할 수 있다. 하지만 대개는 복구 불가능한 상황이고 보나마나 RuntimeException 등으로 포장해서 던져야 할 테니 아예 API 차원에서 런타임 예외를 던지도록 한 것이다.
애플리케이션 예외
런타임 예외 중심의 전략은 굳이 이름을 붙이자면 낙관적인 예외처리 기법이라고 할 수 있다. 일단 복구할 수 있는 예외는 없다고 가정하고 예외가 생겨도 어차피 런타임 예외이므로 시스템 레벨에서 알아서 처리해줄 것이고, 꼭 필요한 경우는 런타임 예외라도 잡아서 복구하거나 대응해줄 수 있으니 문제 될 것이 없다는 낙관적인 태도를 기반으로 하고 있다.
보통 시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키는 애플리케이션 예외를 가지는 메소드를 설계하는 방법에는 두 가지가 있다.
첫 번째 방법은 정상적인 처리를 했을 경우와 애플리케이션 자체의 로직에 의해 의도적으로 예외를 발생시키고자 하는 경우에 각각 다른 종류의 리턴 값을 돌려주는 것이다. 하지만 이렇게 리턴 값으로 결과를 확인하고, 예외상황을 체크하면 불편한 점도 있다. 우선 예외 상황에 대한 리턴 값을 명확하게 코드화하고 잘 관리하지 않으면 혼란이 생길 수 있다. 또 한 가지 문제는 결과 값을 확인하는 조건문이 자주 등장한다는 점이다.
두 번째 방법은 정상적인 흐름을 따르는 코드는 그대로 두고, 애플리케이션 자체의 로직에 의해 의도적으로 예외를 발생시키고자 하는 경우에는 비즈니스적인 의미를 띤 예외를 던지도록 만드는 것이다. 이때 사용하는 예외는 의도적으로 체크 예외로 만든다. 그래서 개발자가 잊지 않고 자주 발생 가능한 예외상황에 대한 로직을 구현하도록 강제해주는 게 좋다.
SQLException은 어떻게 됐나?
먼저 생각해볼 사항은 SQLException은 과연 복구가 가능한 예외인가이다. 대부분의 SQLException은 복구가 불가능하다. 더군다나 DAO 밖에서 SQLException을 다룰 수 있는 가능성은 거의 없다. 따라서 예외처리 전략을 적용해야 한다. 필요도 없는 기계적인 throws 선언이 등장하도록 방치하지 말고 가능한한 빨리 언체크/런타임 예외로 전환해줘야 한다.
스프링의 JdbcTemplate은 바로 이 예외처리 전략을 따르고 있다. JdbcTemplate 템플릿과 콜백 안에서 발생하는 모든 SQLException을 런타임 예외인 DataAccessException으로 포장해서 던져준다. 따라서 JdbcTemplate을 사용하는 UserDao 메소드에선 꼭 필요한 경우에만 런타임 예외인 DataAccessException을 잡아서 처리하면 되고 그 외의 경우에는 무시해도 된다.
그 밖에도 스프링의 API 메소드에 정의되어 있는 대부분의 예외는 런타임 예외다. 따라서 발생 가능한 예외가 있다고 하더라도 이를 처리하도록 강제하지 않는다.
예외 전환
예외를 다른 것으로 바꿔서 던지는 예외 전환의 목적은 두 가지이다. 하나는 런타임 예외로 포장해서 굳이 필요하지 않은 catch/throws를 줄여주는 것이고, 다른 하나는 로우레벨의 예외를 좀 더 의미 있고 추상화된 예외로 바꿔서 던져주는 것이다.
스프링의 JdbcTemplate이 던지는 DataAccessException은 일단 런타임 예외로 SQLException을 포장해주는 역할을 한다. 그래서 대부분 복구가 불가능한 예외인 SQLException에 대해 애플리케이션 레벨에서는 신경 쓰지 않도록 해주는 것이다. 또한 DataAccessException은 SQLException에 담긴 다루기 힘든 상세한 예외정보를 의미 있고 일관성 있는 예외로 전환해서 추상화해주려는 용돌 쓰이기도 한다.
JDBC의 한계
JDBC는 자바 표준 JDK에서도 가장 많이 사용되는 기능 중의 하나다.
호환성 없는 SQLException의 DB 에러정보
DB마다 SQL 뿐만 아니라 에러의 종류와 원인도 제각각이다. 그래서 JDBC는 데이터 처리 중에 발생하는 다양한 예외를 그냥 SQLException 하나에 모두 담아버린다. SQLException은 예외가 발생했을 때의 DB 상태를 담은 SQL 상태정보를 부가적으로 제공한다. getSQLState() 메소드로 예외상황에 대한 상태정보를 가져올 수 있다. 이 상태정보는 DB별로 달라지는 에러 코드를 대신할 수 있도록, 스펙에 정의된 SQL 상태 코드를 따르도록 되어있다. 그러나 결국 호환성 없는 에러 코드와 표준을 잘 따르지 않는 상태 코드를 가진 SQLException 만으로 DB에 독립적인 유연한 코드를 작성하는 건 불가능에 가깝다.
DB 에러 코드 매핑을 통한 전환
SQLException에 담긴 SQL 상태 코드는 신뢰할 만한게 아니므로 더 이상 고려하지 않는다. 차라리 DB 업체별로 만들어 유지해오고 있는 DB 전용 에러 코드가 더 정확한 정보라고 불 수 있다.
스프링은 DataAccessException이라는 SQLException을 대체할 수 있는 런타임 예외를 정의하고 있을 뿐 아니라 DataAccessException의 서브클래스로 세분화된 예외 클래스들을 정의하고 있다. 디에터 엑세스 작업 중에 발생 할 수 있는 예외 상황을 수십 가지 예외로 분류하고 이를 추상화해 정의한 다양한 예외 클래스를 제공한다.
JdbcTemplate은 SQLException을 단지 런타임 예외인 DataAccessException으로 포장하는 것이 아니라 DB의 에러 코드를 DataAccessException 계층구조의 클래스 중 하나로 매핑해준다. 전환되는 JdbcTemplate에서 던지는 예외는 모두 DataAccessException의 서브클래스 타입이다. DB별로 미리 준비된 매핑정보를 참고해서 적절한 예외 클래스를 선택하기 때문이 DB가 달라져도 같은 종류의 에러라면 동일한 예외를 받을 수 있다. 데이터 엑세스 기술에 독립적인 추상화된 예외를 제공하는 것이다.
JdbcTemplate을 이용한다면 JDBC에서 발생하는 DB 관련 예외는 거의 신경 쓰지 않아도 된다.
JDK 1.6에 포함된 JDBC 4.0부터는 기존에 JDBC의 단일 예외 클래스였던 SQLException을 스프링의 DataAccessException과 비슷한 방식으로 좀 더 세분화해서 정의하고 있다.
DAO 인터페이스와 DataAccessException 계층구조
DataAccessException은 JDBC의 SQLException을 전환하는 용도로만 만들어진 건 아니다. JDBC 외의 자바 데이터 엑세스 기술에서 발생하는 예외에도 적용된다.
DataAccessException은 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록 만들어준다. 데이터 액세스 기술에 독립적인 추상화된 예외를 제공하는 것이다.
DAO 인터페이스와 구현의 분리
DAO를 굳이 따로 만들어서 사용하는 이유는 무엇일까? 가장 중요한 이유는 데이터 액세스 로직을 담은 코드를 성격이 다른 코드에서 분리해놓기 위해서다.
대부분의 데이터 액세스 예외는 애플리케이션에서는 복구 불가능하거나 할 필요가 없는 것이다. 그렇다고 모든 예외를 다 무시해야 하는 건 아니다. 중복 키 에러처럼 비즈니스 로직에서 의미 있게 처리할 수 잇는 예외도 있다. 애플리케이션에서는 사용하지 않더라도 시스템 레벨에서 데이터 액세스 예외를 의미 있게 분류할 필요도 있다. 문제는 데이터 액세스 기술이 달라지면 같은 상황에서도 다른 종류의 예외가 던져진다는 점이다.
따라서 DAO를 사용하는 클라이언트 입장에서는 DAO의 사용 기술에 따라서 예외 처리 방법이 달라져야 한다. 결국 클라이언트가 DAO의 기술에 의존적이 될 수 밖에 없다.
데이터 액세스 예외 추상화
스프링은 자바의 다양한 데이터 액세스 기술을 사용할 때 발생하는 예외들을 추상화해서 DataAccessException 계층구조 안에 정리해놓았다. 스프링의 DataAccessException은 자바의 주요 데이터 액세스 기술에서 발생할 수 있는 대부분의 예외를 추상화하고 있으며 이런 기술에서만 공통적으로 나타나는 예외를 포함해서 데이터 엑세스 기술에서 발상 가능한 대부분의 예외를 계층구조로 분류해놓았다.
JdbcTemplate과 같이 스프링의 데이터 액세스 지원 기술을 이용해 DAO를 만들면 사용 기술에 독립적인 일관성 있는 예외를 던질 수 있다. 결국 인터페이스 사용, 런타임 예외 전환과 함께 DataAccessException 예외 추상화를 적용하면 데이터 액세스 기술과 구현 방법에 독립적인 이상적인 DAO를 만들 수가 있다.
DataAccessException 활용시 주의사항
스프링을 활용하면 DB 종류나 데이터 액세스 기술에 상관없이 키 값이 중복이 되는 상황에서는 동일한 예외가 발생하리라고 기대할 것이다. 하지만 안타깝게도 DuplicateKeyException은 아직까지는 JDBC를 이용하는 경우에만 발생한다. 데이터 액세스 기술을 하이버네이트나 JPA를 사용했을 때도 동일한 예외가 발생할 것으로 기대하지만 실제로 다른 예외가 던져진다. 그 이유는 SQLException에 담긴 DB의 에러 코드를 바로 해석하는 JDBC의 경우와 달리 JPA나 하이버네이트, JDO 등에서는 각 기술이 재정의한 예외를 가져와 스프링이 최종적으로 DataAccessException으로 변환하는데, DB의 에러 코드와 달리 이런 예외들은 세분화되어 있지 않기 때문이다.
DataAccessException이 기술에 상관없이 어느 정도 추상화된 공통 예외로 변환해주긴 하지만 근본적인 한계 때문에 완벽하다고 기대할 수는 없다. 따라서 사용에 주의를 기울여야 한다.
스프링은 SQLException을 DataAccessException으로 전환하는 다양한 방법을 제공한다. 가장 보편적이고 효과적인 방법은 DB 에러 코드를 이용하는 것이다. SQLException을 코드에서 직접 전환하고 싶다면 SQLExceptionTranslator 인터페이스를 구현한 클래스 중에서 SQLErrorCodeSQLExceptionTranslator를 사용하면 된다.