프레임워크의 기본 동작원리는 제어의 역전(IoC, Inversion Of Control) 이다. 프레임워크는 개발자가 만든 클래스에 대한 제어 권한을 넘겨받아서 주도적으로 애플리케이션의 흐름을 제어한다.
1. JUnit
JUnit프레임워크는 다음 두가지 조건을 요구한다
- 메소드가 Pulbic으로 선언되어야 한다.
- 메소드에 @Test라는 어노테이션을 붙여줘야 한다.
if/else 문장은 JUnit의 assertThat 스태틱 메서드로 바꿀 수 있다.asertThat() 메서드는 첫 번째 파라미터의 값을 뒤에 나오는 매처라고 불리는 조건으로 비교해서 일치하면 다음으로 넘어가고, 아니면 테스트가 실패하도록 만들어준다. is()는 메처의 일종으로 equals() 로 비교해주는 기능을 가진다.
if(!user.getName().equals(user2.getName()))..
을 다음과 같이 바꿀 수 있다.
assertThat(user2.getName(), is(user.getName()));
1.1. deleteAll()과 getCount()의 테스트
2가지 메서드를 추가한다고 가정하자. USER테이블 모든 레코드를 삭제하는 deleteAll()과 USER테이블의 레코드 갯수를 돌려주는 getCount()이다.
2가지 메서드를 추가하였으니, 이것에 대한 테스트를 작성해야 한다. 그런데 두 메서드의 기능들은 add()와 get()처럼 독립적으로 자동 실행되는 테스트를 만들기 좀 애매하다. 테스트를 위해선 USER 테이블에 수동으로 데이터를 넣고 deleteAll() 실행 뒤 테이블에 남은게 있는지 확인해야 하는데, 사람이 테스트 과정에서 참여해야 하니 자동화돼서 반복적으로 실행 가능한 테스트 방법은 아니다. 그래서, 새로운 테스트를 만들기보다는 차라리 기존의 addAndGet()테스트를 확장하는 방법이 낫다.
@Test
public void addAndGet() throws SQLException {
ApplicationContext context = new GenericXmlApplicationContext(
"applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
User user = new User();
user.setId("gyumee");
user.setName("박성철");
user.setPassword("springno1");
dao.add(user);
User user2 = dao.get(user.getId());
assertThat(user2.getName(), is(user.getName()));
assertThat(user2.getPassword(), is(user.getPassword()));
}
addAndGet() 를 봐도 단점이 존재한다. dao에 user를 추가한 다음, 데이터베이스를 초기화하지 않는다.
이전에는 이를 수동으로 USER테이블을 전부 지워줘야 했었다(아마 의도된 예시인 것 같다). 따라서 다음과 같은 개선 방법을 생각해보았다.
deleteAll()테스트 시작될 때 실행해주기- 하지만
deleteAll()자체도 검증되어 있지 않은데, 다른 테스트의 초입에 넣는 것은 옳지 않다
- 하지만
- 1를 보완하기 위해,
getCount()를 활용해보자.deleteAll()이후에getCount()를 호출해서 갯수가 0임을 확인하면 될 것이다.- 그러나
getCount()또한 검증이 되어있는지 장담할 수 없다.
- 그러나
- 따라서
getCount()에도 검증 작업을 하나 추가한다.add()가 정상적으로 DB 테이블에 데이터를 넣는 것을 확인했으니add()를 수행하고 나면 레코드 개수가 0에서 1로 바뀌어야 한다(add()는 검증되어있다고 가정)add()메서드 실행 전후로getCount()를 실행하여,getCount()를 검증한다
- 3에서 getCount()를 검증하였으니, 이를 활용해
deleteAll()도 검증할 수 있다. - 새로 추가된 두 메서드를 통해서,
addAndGet()를 완전 자동화할 수 있다!
deleteAll()을 테스트 맨 앞 혹은 맨 뒤에 넣는 방법이 있지만 맨 앞에 넣는 것을 추천한다. 외부 상황의 변화와 관계없이 테스트의 멱등성을 보장할 수 있기 때문이다.
단위 테스트는 항상 일관성 있는 결과가 보장되어야 한다는 점을 잊어선 안 된다.
2. 포괄적인 테스트
앞서 getCount() 메서드를 테스트에 적용하긴 했지만, 기존의 테스트에서 확인할 수 있었던 것은 deleteAll()을 실행했을 때 테이블이 비어있는 경우(0)와 add()를 한 번 호출한 뒤의 결과(1)뿐이다. 물론 0인 경우와 1인 경우를 해봤으니, 나머지가 당연히 잘될 것이라고 추정할 수도 있겠지만 미처 생각하지 못한 문제가 있을 수 있다. 따라서 더 꼼꼼한 테스트를 해보는 것이 좋은 자세이다. 한 가지 결과만 검증하고 마는 테스트는 상당히 위험한 생각이다.
위 예시에서 일명 '꼼꼼함'을 증가시키려면, 1명만 추가하던 사용자 정보를 3개로 늘리는 것이다. 유저 추가->count->유저 추가->count..로 1명 추가할 때 마다 갯수가 1개씩 증가하는 지 확인하는 것이다.
주의 :
addAndGet()메소드에서 등록한 사용자 정보를count()등 다른 테스트에서 활용하면 안된다. 테스트들은 항상 독립적으로 동일한 결과를 낼 수 있도록 해야 하기 때문이다.
2.1. get() 예외조건에 대한 테스트
위 예시 코드에서 생각해볼만한 문제가 하나 더 있다. get() 메소드에 전달된 id 값에 해당하는 사용자 정보가 없다면 어떻게될까? (get()메소드에 대한 테스트코드를 작성한 적은 없다)
- null과 같은 특별한 값을 리턴하는 것
- id에 해당하는 정보를 찾을 수 없다고 예외를 던지는 것
2번에 대해 알아보자. 이를 위해선 예외 클래스가 필요하지만, 스프링이 미리 정해놓은 예외를 사용(EmptyResultDataAccessException)할 수 있다.
그러나 이상한 점이 있다. 우리는 테스트 도중 이 오류가 발생하는 것이 '테스트 실패' 라고 해석하고 싶지만, 엄밀히 말하자면 이것은 '테스트 오류' 라는 결과를 도출한다. assertThat() 을 통한 검증 실패는 아니고, 테스트 에러라고 볼 수 있다.
이를 해소하기 위해 '특정 예외가 던져지면->테스트 성공', '특정 예외가 던져지지 않으면->테스트 실패'라고 판단해야 한다. 문제는 예외 발생 여부는 메서드 실행 후 결과값 비교 방식(assertThat())으로 확인할 수 없다는 것이다.
이런 경우를 위해 JUnit은 예외조건 테스트를 위한 특별한 방법을 제공해준다.
@Test(expected=EmptyResultDataAccessException.class)
// 테스트 중에 발생할 것으로 기대하는 예외 클래스를 지정해준다.
public void getUserFailure() throws SQLException {
ApplicationContext context = new GenericXmlApplicationContext(
"applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
dao.deleteAll();
assertThat(dao.getCount(), is(0));
dao.get("unknown_id"); // 이 메소드 실행 중에 예외가 발생해야 한다.
// 예외가 발생하지 않으면 테스트가 실패한다.
}
expected 값을 추가해줘서, 의도한 오류가 뜨는지 확인하면 된다. get이 정상적으로 오류를 발생하면 Test는 성공으로, 오류를 발생하지 않는다면 Test는 실패로 간주된다.
아무리 검증된 DAO 메서드라고 해도, 이러한 포괄적인 테스트가 존재하는 것이 훨씬 안전하고 유용하다.
종종 간단하고 단순한 테스트가 치명적인 실수를 피할 수 있게 해주기도 한다.
개발자가 테스트를 직접 만들 때 자주 하는 실수는 성공하는 테스트만 골라서 만드는 것 이다. 개발자의 방어기제라고도 볼 수 있는데, 테스트를 작성할 때도 문제가 될 만한 상황(엄청난 크기의 int 입력값 등)을 교묘히 잘 피해서 테스트 코드를 만드는 습성이 있다.
이러한 문제를 피하기 위해 테스트 코드를 만들 때 엣지케이스들까지도 신경써서 만들어 놓을 필요가 있다. 스프링의 창시자인 로드 존슨은 "항상 네거티브 테스트를 먼저 만들라"는 조언을 했다. 그래서 테스트를 작성할 때 부정적 케이스를 먼저 만드는 습관을 들이는 게 좋다.
3. 테스트가 이끄는 개발
3.1. 기능설계를 위한 테스트
위에서 작성한 getUserFailure()를 살펴보자. 이 테스트에는 만들고 싶은 기능에 대한 조건과 행위, 결과에 대한 내용이 잘 표현되어 있다. 테스트 코드의 내용을 정리해보자.

이렇게 비교해보면 이 테스트 코드는 마치 잘 작성된 하나의 기능정의서처럼 보인다. 그래서 보통 기능설계, 구현, 테스트라는 일반적인 개발 흐름의 기능설계에 해당하는 부분을 이 테스트 코드가 일부분 담당하고 있다고 볼 수도 있다. 이런 식으로 추가하고 싶은 기능을 일반 언어가 아닌 테스트 코드로 표현해서, 마치 코드로 된 설계문서처럼 만들어놓은 것이라고 생각해보자. 그리고 나서 실제 기능을 가진 애플리케이션 코드를 만들고 나면, 바로 이 테스트를 실행해서 설계한 대로 코드가 동작하는지를 빠르게 검증할 수 있다. 만약 테스트 코드가 작동하지 않는다면, 이때는 설계한 대로 코드가 만들어지지 않았음을 바로 알 수 있다. 이를 보고 본 코드를 수정하고..다시 테스트코드를 실행하고.. 를 반복하다 보면 코드 구현과 테스트라는 두가지 작업이 동시에 끝나는 것이다!
3.2. 테스트 주도 개발(Test-Driven-Development, TDD)
테스트 코드를 먼저 만들고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발방법.
TDD는 개발자가 테스트를 만들어가며 개발하는 방법이 주는 장점을 극대화한 방법이라고 볼 수 있다. "실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다"는 것이 TDD의 기본 원칙이다. 이 원칙을 잘 따랐다면 만들어진 모든 코드는 빠짐없이 테스트로 검증된 것이라고 볼 수 있다.
TDD에서는 테스트를 작성하고 이를 성공시키는 코드를 만드는 작업의 주기를 가능한 한 짧게 가져가도록 권장한다. 테스트를 반나절동안 만들고 나머지 반나절동안 테스트를 통과시키는 코드를 만드는 식의 개발은 그다지 좋은 방법이 아니다. 테스트 코드->오류안나게 개발->테스트코드 추가->오류안나게 개발..(반복)하는게 이상적이다.
3.3. JUint 세부 메서드들
3.3.1. @Before과 @After
각 @Test들의 '중복된 사전 작업 코드' 를 넣은 setUp() 메서드를 만들고 @Before 메서드를 붙인 후 테스트 메서드에서 제거한 코드를 넣는다. 이때, setUp()으로 세팅된 객체를 @Test에서 사용한다면 이를 @Test 클래스의 인스턴스 변수로 변경해줘야 한다.

같은 원리로 @After 메서드도 실행된다. JUnit 관점에서 이들의 실행 시점 및 테스트 실행 순서를 알아보자.
- 테스트 클래스에서 @Test가 붙은 public이고 void형이며 파라미터가 없는 테스트 메소드를 모두 찾는다.
- 테스트 클래스의 오브젝트를 하나 만든다.
- @Before가 붙은 메소드가 있으면 실행한다.
- @Test가 붙은 메소드를 하나 호출하고 테스트 결과를 저장해둔다.
- @After가 붙은 메소드가 있으면 실행한다
- 나머지 테스트 메소드에 대해 2~5번을 반복한다.
- 모든 테스트의 결과를 종합해서 돌려준다.
위 순서에서 보듯이 2~5번이 각 테스트 메소드마다 반복된다. 즉:
@Before: 각 @Test 실행 직전마다 호출@After: 각 @Test 실행 직후마다 호출

3.3.2 과도한 @Before/@After 실행으로 인한 테스트 오버헤드
해당 메서드들은 @Test 메서드들 갯수만큼 실행된다. 이들은 모든 싱글톤 빈 오브젝트를 초기화한다. 현재는 단순한 테스트코드여서 괜찮지만, 복잡하거나 오버헤드가 큰 객체를 지속적으로 초기화(만일 이 객체가 개별적으로 쓰레드를 만드는 객체라면 등)하여 오브젝트들을 만든다면 많은 시간과 자원이 소모된다.
이를 보완하기 위해 테스트 클래스 전체에 걸쳐 딱 한번만 실행되는 @BeforeClass @AfterClass스태틱 메서드를 지원한다.
4. 스프링 테스트 컨텍스트 프레임워크(Spring Test Context)
스프링은 JUnit을 이용하는 테스트 컨텍스트 프레임워크를 제공한다. 테스트 컨텍스트의 지원을 받으면 간단한 어노테이션 설정만으로 테스트에서 필요로 하는 애플리케이션 컨텍스트를 만들어서 모든 테스트가 공유하게 할 수 있다.
4.1. 테스트 컨텍스트란?
테스트에 사용되는 애플리케이션 컨텍스트를 생성하고 관리하여 테스트에 적용해주는 테스트 프레임워크이다.
자세한 명칭들에 대한 설명은 아래와 같다.
- 테스트 컨텍스트 프레임워크: 테스트를 위한 애플리케이션 컨텍스트를 생성하고 관리하여 테스트에 적용해주는 테스트 프레임워크
- 테스트 컨텍스트: 테스트에서 사용되는 애플리케이션 컨텍스트를 생성하고 관리하기 위한 컨텍스트
- 애플리케이션 컨텍스트: 스프링 프레임워크의 핵심 컨테이너로, 애플리케이션의 설정 정보를 읽어 객체를 생성하고 관리하는 스프링 IoC 컨테이너
JUnit은 테스트 메소드를 실행할 때마다 매번 새로운 객체의 테스트 클래스를 만든다. 그래서 모든 테스트가 서로에게 영향을 주지 않으며 독립적으로 실행됨을 보장한다. 그런데 문제는 테스트가 독립적이기 위해서라면 매번 별개의 애플리케이션 컨텍스트(DI 컨테이너)를 만들어야 하는 것이다. 물론 스프링은 여러 개의 빈을 생성하고 의존관계를 주입하는 과정을 빠르게 처리하지만 다른 컴포넌트들까지 결합되는 경우라면 더욱 많은 시간이 요구된다. 대표적으로 JPA와 같은 ORM에서 처리하는 초기화 작업은 세션을 지원할 준비를 하고 쓰레드를 생성하는 등에 의해 오래 걸리며, 캐시를 사용하는 경우 캐시 연결을 위한 시간이 또 소요되기 때문이다. 만약 이런 초기화 작업이 테스트마다 반복되면 테스트 비용이 너무 커지게 된다.

따라서, 위 그림과 같이 테스트들에 같은 컨텍스트가 IoC를 진행하여 테스트를 진행하게 된다면 같은 객체(이미 만들어진 객체)를 사용하여, 객체 초기화 시간을 단축하는 등 성능에 지대한 이점이 있다.
프레임워크 라는 개념이 가진 장점을 극대화하는 방식 중 하나라고 생각한다.
4.2. 테스트 메서드의 컨텍스트 공유
@Before
public void setUp(){
System.out.println(this.context);
System.out.println(this);
}
위 코드를 실행하면 아래와 같은 결과가 나온다.
org.springframework.context.support.GenericApplicationContext@d3d6f:
springbook.dao.UserDaoTest@115d06c
org.springframework.context.support.GenericApplicationContext@d3d6f:
springbook.dao,UserDaoTest@116318b
org.springframework.context.support.GenericApplicationContext@d3d6f:
springbook.dao.UserDaoTest@15eQc2b
테스트 오브젝트 객체와는 달리, Context 값들은 전부 같은 걸 볼 수 있다. 왜 그럴까?
스프링의 JUnit은 테스트가 실행되기 전 딱 한번만 애플리케이션 컨텍스트를 만들어두고, 테스트 오브젝트가 만들어질 때마다 애플리케이션 컨텍스트를 테스트 오브젝트의 특정 필드에 주입하는 것이다. 일종의 DI라고 볼 수 있다. 이러한 방식을 채택했기 때문에, 스프링의 테스트는 처음 애플리케이션 컨텍스트를 만들 때 오래걸리고, 이후로는 DI로 주입해주기 때문에 수행시간이 매우 빨라진다.
'Backend > Spring' 카테고리의 다른 글
| 11. 데이터 액세스 기술 (0) | 2025.10.02 |
|---|---|
| AOP(Aspect Oriented Programming) (0) | 2025.09.24 |
| 자바와 스프링의 예외처리 (1) | 2025.09.10 |
| [Security]Spring 소셜로그인 및 JWT 토큰 (0) | 2025.04.27 |
| 스프링 Security (0) | 2025.04.12 |