2장을 시작하며 필자는 스프링의 가장 중요한 가치를 객체지향과 테스트라고 말하고 있다.
앞서 1장에서 IoC/DI는 오브젝트의 설계, 생성, 관계, 사용에 관한 기술이며
이를 쉽게 사용할 수 있게 해주는 것이 스프링이다.
스프링을 활용함에 있어 복잡한 서버 애플리케이션을 개발하는데 필요한 도구가 두 가지가 있다.
하나는 객체지향이며 하나는 테스트이다.
애플리케이션은 계속 변하고 발전하고 복잡해진다. 개발자는 이에 유연하게 대응할 수 있어야한다.
그 방법은 다음과 같다.
1. 확장과 변화를 고려한 객체지향적 설계, 그에 대해 효과적으로 효과적으로 담아낼 수 있는 IoC/DI
2. 테스트를 통해 코드에 확신을 가져 변화에 유연하게 대처할 수 있도록
따라서 테스트를 통해 다양한 기술을 활용하고, 이해하고, 검증하며 실전에 적용하는 방법을 익힐 수 있다.
2.1 UserDaoTest 다시보기
2.1.1 테스트의 유용성
코드를 지속적으로 유지보수하고 리팩토링하며 온전하게 동작할 수 있도록 보장하는 방법?
개발자가 코드를 지속적으로 들여다보며 시뮬레이션? 확신 가능한가
테스트란 내가 예상하고 의도했던 코드가 정확히 동작하는지를 확인해서 만든 코드를 확신할 수 있게 해주는 작업이다.
2.1.2 UserDaoTest의 특징
public class UserDaoTest {
public static void main(String[] args) throws ClassNotFoundException, SQLException {
// 아직까지는 기존에 DaoFactory가 더 깔끔한 것 같음
// 기능적으로도 차이가 없다.
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
IndependentUserDao userDao = context.getBean("userDao", IndependentUserDao.class);
User user = new User();
user.setId("saddip");
user.setName("김도현");
user.setPassword("123456");
userDao.add(user);
System.out.println(user.getId() + " 등록 성공");
User user2 = userDao.get(user.getId());
System.out.println("user2 = " + user2.getName());
CountingConnectionMaker ccm = context.getBean(
"connectionMaker",
CountingConnectionMaker.class
);
User user3 = userDao.get(user.getId());
System.out.println("user2 = " + user3.getName());
System.out.println("ccm.getCount() = " + ccm.getCount());
}
}
이 코드의 기능을 정리해보자.
- 가장 손쉽게 실행 가능한 main() 메소드 이용
- 테스트 대상 UserDao의 오브젝트를 가져와 메소드 호출
- 테스트에 사용할 입력값을 직접 코드에서 만들어 넣어줌
- 테스트 결과를 콘솔에 출력
- 각 단계 작업이 에러 없이 끝나면 콘솔에 성공 메시지 출력
웹을 통한 DAO 테스트 방법의 문제점
웹을 통해 실제 DAO를 테스트하려면 선행되어야하는 서비스, 컨트롤러, 뷰 등
모든 레이어의 기능을 다 만들어야 테스트가 가능하다.
테스트를 설령 진행하더라도 어떤 부분에서 에러가 나는지 찾아야할 수고도 필요하다.
작은 단위의 테스트
테스트 코드의 단위는 작을수록 좋다. 다른 코드를 신경쓰지 않고, 참여하지도 않으며 테스트가 동작할 수 있으면 좋다.
-> 작은 단위 코드에 대해 테스트를 수행한 것을 단위테스트라고 한다.
테스트 과정에서 DB가 사용된다면 단위테스트가 아니라고 하는 사람들도 있지만,
사용할 DB를 테스트가 관장하고 있다면 단위테스트가 맞다!
(통제할 수 없는 외부의 리소스에 의존하는 테스트는 단위 테스트가 아니라고 보기도 한다)
때로는 보다 큰 과정을 하나로 묶어 테스트하는 것도 필요하지만,
이러한 테스트만 진행한다면 에러 발생시 문제 원인 파악이 매우 어려워진다.
단위별로 테스트를 진행했다면 디버깅이 훨씬 수월할 것이다.
개발자가 설계하고 만든 코드가 의도대로 동작하는지 개발자가 확인하기 위한 테스트라는 뜻으로
개발자, 프로그래머 테스트라고 부르기도 한다.
자동 수행 테스트 코드
앞서 보았던 UserDaoTest는 테스트할 데이터가 코드에 존재하고, 테스트 작업 역시 코드를 통해 자동으로 실행된다.
자동화된 테스트는 매우 빠르기 때문에 자주 수행해도 부담이없다.
하지만 아직 main 메소드를 활용해 애플리케이션과 같이 혼재하고 있어 위치가 애매하다.
실제 서비스에서 테스트코드 한 줄 고치면 어떠한 여파가 미칠지 아무도 모른다..
2.1.3 UserDaoTest의 문제점
아직 테스트의 문제점은 두 가지 존재한다.
- 수동 확인 작업의 번거로움 - 콘솔 출력을 사람이 일일이 확인해야함
- 실행 작업의 번거로움 - main 메소드를 수백개 만들어 수백번 실행하고 정리해야함
2.2 UserDaoTest 개선
2.2.1 테스트 검증의 자동화
테스트의 결과에는 두가지 종류가 있다.
- 테스트 성공
- 테스트 실패
테스트 실패에서도 두가지 종류가 있다.
- 테스트 진행되는 동안 에러 발생 -> 테스트 에러
- 결과값이 기대값과 다름 -> 테스트 실패
테스트 에러는 콘솔로 체크가 가능하지만 테스트 실패는 별도의 확인 작업과 그 결과가 있어야한다.
수정 전 테스트 코드
System.out.println("user2 = " + user2.getName());
System.out.println("user2.password = " + user2.getPassword());
System.out.println("user2.id = " + user2.getId());
수정 후 테스트 코드
if(!user.getName().equals(user2.getName())) {
System.out.println("테스트 실패 (name)");
}
else if (!user.getPassword().equals(user2.getPassword())) {
System.out.println("테스트 (password)");
}
else {
System.out.println("조회 테스트 성공");
}
기존에는 콘솔 출력을 확인해야했는데 수정 후에는 '조회 테스트 성공'이라는 키워드를 기다리면 된다.
실패시에도 어떤 부분에서 실패가 났는지 로그가 확인이 가능해졌다.
나름 형식을 갖춘 테스트가 되었고, 이제 코드의 기능을 모두 점검할 수 있는 포괄적인 테스트를 만들어냈다!
따라서 이후에는 과감한 수정을 하고 나서라도 테스트를 모두 돌리면 안심할 수 있다.
2.2.2 테스트의 효율적인 수행과 결과 관리
테스트를 효율적으로 수행하기에는 main() 메소드로는 분명한 한계가 존재한다.
테스트를 간단히 실행시키고, 결과를 종합하여 볼 수 있으며 테스트가 실패한 곳을 빠르게 찾을 수 있는
기능을 갖춘 테스트 도구와 그에 맞는 테스트 작성 방법이 필요하다.
JUnit 테스트로 전환
JUnit을 활용함으로써 프레임워크의 동작 원리인 제어의 역전을 활용할 수 있다.
1장에서 살펴보았듯, 프레임워크는 개발자가 만든 클래스에 대한 제어 권한을 넘겨받아 주도적으로 애플리케이션 흐름을 제어한다.
개발자가 만든 클래스의 오브젝트를 생성하고 실행하는 일은 프레임워크에 의해 실행된다.
테스트 메소드 전환
기존에 만들었던 main() 메소드는 제어권을 직접 갖고 있으니, 이 테스트 코드를 일반 메소드로 옮겨야한다.
새로 만들 테스트 코드는 JUnit 프레임워크가 요구하는 조건 두 가지를 따라야 한다.
- 메소드가 public으로 선언되어야함
- 메소드에 @Test라는 애노테이션을 붙여줘야함
다만 1번은 JUnit5가 도입되며 따로 접근제어자를 선언하지 않는 default를 사용해도 가능하다.
검증 코드 전환
기존에 if/else 문장을 JUnit이 제공하는 방법을 이용해서 전환해보자.
public class UserDaoTest {
@Test
public void addAndGet() throws SQLException, ClassNotFoundException {
ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
IndependentUserDao userDao = context.getBean("userDao", IndependentUserDao.class);
User user = new User();
user.setId("asdf");
user.setName("김도현");
user.setPassword("asdf");
userDao.add(user);
User user2 = userDao.get(user.getId());
Assertions.assertThat(user.getId()).isEqualTo(user2.getId());
Assertions.assertThat(user.getName()).isEqualTo(user2.getName());
Assertions.assertThat(user.getPassword()).isEqualTo(user2.getPassword());
// 책 예제 코드 JUnit4 환경에서는 아래와 같이 사용한다.
// assertThat(user.getName(), is(user.getName()));
}
}
2.3 개발자를 위한 테스팅 프레임워크 JUnit
테스트 없는 스프링은 의미가 없기 때문에 JUnit 테스트 작성 방법과 실행 방법은 알고있자.
2.3.2 테스트 결과의 일관성
앞서 JUnit을 적용해 깔끔한 테스트 코드를 만들었고 편리하게 실행할 수 있는 IDE 환경을 체감했다.
하지만 아직 테스트 결과가 외부에 의존적이라는 단점이 존재한다. 이로 인해 테스트를 수행하기 이전 상태로 만들어야한다.
점차적으로 도입할 예정인데, 지금은 UserDao에서 deleteAll(), getCount() 두 가지 메소드를 추가해보자.
이 두 메소드는 독립적인 자동 실행 테스트를 만들기가 애매하기 때문에 기존 addAndGet() 테스트를 확장해보자.
동일한 결과를 보장하는 테스트
@Test
public void addAndGet() throws SQLException, ClassNotFoundException {
ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
IndependentUserDao userDao = context.getBean("userDao", IndependentUserDao.class);
userDao.deleteAll();
assertThat(userDao.getCount()).isEqualTo(0);
User user = new User();
user.setId("asdf");
user.setName("김도현");
user.setPassword("asdf");
userDao.add(user);
assertThat(userDao.getCount()).isEqualTo(1);
User user2 = userDao.get(user.getId());
assertThat(user.getId()).isEqualTo(user2.getId());
assertThat(user.getName()).isEqualTo(user2.getName());
assertThat(user.getPassword()).isEqualTo(user2.getPassword());
}
DB에서 id 값을 삭제하지 않고 반복적으로 실행해도 동일한 결과를 보장하는 테스트 코드가 되었다.
단위 테스트는 코드가 바뀌지 않는다면 실행할 때마다 동일한 테스트 결과를 보장할 수 있어야한다.
-> 테스트 실행 순서, 데이터 외부 환경 등에 의존적이지 않아야한다.
2.3.3 포괄적인 테스트
아직은 레코드가 1개만 저장될 때 상황을 테스트 해보았는데, 2개 이상일 때는 어떻게 될까?
당연히 검증을 하고 넘어가야한다. getCount()라는 테스트 메소드를 하나 더 만들어 UserDaoTest 전체 코드를 살펴보고 지나가자.
public class UserDaoTest {
@Test
public void addAndGet() throws SQLException, ClassNotFoundException {
ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
IndependentUserDao userDao = context.getBean("userDao", IndependentUserDao.class);
userDao.deleteAll();
assertThat(userDao.getCount()).isEqualTo(0);
User user = new User();
user.setId("asdf");
user.setName("김도현");
user.setPassword("asdf");
userDao.add(user);
assertThat(userDao.getCount()).isEqualTo(1);
User user2 = userDao.get(user.getId());
assertThat(user.getId()).isEqualTo(user2.getId());
assertThat(user.getName()).isEqualTo(user2.getName());
assertThat(user.getPassword()).isEqualTo(user2.getPassword());
}
@Test
public void count() throws SQLException, ClassNotFoundException {
ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
IndependentUserDao userDao = context.getBean("userDao", IndependentUserDao.class);
User user1 = new User("aa", "김김", "spring1");
User user2 = new User("bb", "이이", "spring2");
User user3 = new User("cc", "박박", "spring3");
userDao.deleteAll();
assertThat(userDao.getCount()).isEqualTo(0);
userDao.add(user1);
assertThat(userDao.getCount()).isEqualTo(1);
userDao.add(user2);
assertThat(userDao.getCount()).isEqualTo(2);
userDao.add(user3);
assertThat(userDao.getCount()).isEqualTo(3);
}
}
여기서 주의할 점은 테스트 두가지가 어떠한 순서로 실행되는지 보장할 수는 없으니, 순서에 영향을 받지 않아야한다.
addAndGet() 테스트 보완
public void addAndGet() throws SQLException, ClassNotFoundException {
ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
IndependentUserDao userDao = context.getBean("userDao", IndependentUserDao.class);
User user1 = new User("aa", "김김", "spring1");
User user2 = new User("bb", "이이", "spring2");
userDao.deleteAll();
assertThat(userDao.getCount()).isEqualTo(0);
userDao.add(user1);
userDao.add(user2);
assertThat(userDao.getCount()).isEqualTo(2);
// id를 활용해 user를 가져오는 테스트 보강
User userGet1 = userDao.get(user1.getId());
assertThat(userGet1.getId()).isEqualTo(user1.getId());
assertThat(userGet1.getName()).isEqualTo(user1.getName());
User userGet2 = userDao.get(user2.getId());
assertThat(userGet2.getId()).isEqualTo(user2.getId());
assertThat(userGet2.getName()).isEqualTo(user2.getName());
// assertThat(userDao.getCount()).isEqualTo(1);
// User user2 = userDao.get(user.getId());
//
// assertThat(user.getId()).isEqualTo(user2.getId());
// assertThat(user.getName()).isEqualTo(user2.getName());
// assertThat(user.getPassword()).isEqualTo(user2.getPassword());
}
주석 부분에서 테스트를 보강했다. 아직 완벽한 테스트라 할 수 없다.
만약 get() 메소드에 전달된 id값에 해당하는 유저가 없다면?
- null과 같은 특별한 값을 리턴
- 해당하는 정보를 찾을 수 없다고 예외를 던지는 것
우리는 2번 방법을 사용해서 테스트를 진행해보자.
주어진 id에 해당하는 정보가 없다는 의미를 가진
스프링의 EmptyResultDataAccessException 예외를 이용해 볼 것이다.
그렇다면 예외처리를 어떻게 테스트 코드로?
JUnit은 예외 조건 테스트를 위한 특별한 방법을 제공하고 있다.
assertThatThrownBy(() -> userDao.get("unknown"))
.isInstanceOf(EmptyResultDataAccessException.class);
JUnit4에서는 @Test(expected = Exception.class)로 캐치가 가능하다.
JUnit5에서는 불가한 것 같아서 위와 같은 코드로 진행했다.
하지만 아직 애플리케이션 단의 코드는 수정을 하지 않아서 실패하고 있다.
테스트를 성공시키기 위한 코드 수정
if(rs.next()) {
user = new User();
user.setId(rs.getString("id"));
user.setName(rs.getString("name"));
user.setPassword(rs.getString("password"));
}
rs.close();
ps.close();
c.close();
if (user == null)
throw new EmptyResultDataAccessException(1);
return user;
User의 get() 메소드에서 id 값으로 찾아내지 못한다면 예외를 던지도록 수정했다.
이제 모든 테스트 메소드가 성공하고 있다. -> 기존 기능에도 영향을 주지 않음이 확실하다.
포괄적인 테스트
JDBC를 자주 접한 개발자라면 코드만 살펴보아도 문제가 되지 않을 것이다.
하지만 DAO 메소드에 대한 포괄적인 테스트를 만들어두는 편이 훨씬 안전하고 유용하다.
막상 특별한 상황이 되면 엉뚱하게 동작하는 코드를 작성했는데도 불구하고
테스트를 안해보았다면 문제 원인 파악을 위해 고생하게 될 것이다.
또한 많은 개발자는 성공하는 테스트를 골라서 작성하는데,
머릿속으로 이 코드가 잘 돌아가는 케이스를 상상하며 코드를 작성하기 때문이다.
하지만 개발자도 조금만 신경쓰면 자신의 코드에서 다양한 상황과 입력 값을 고려하는 포괄적인 테스트를 만들 수 있다.
따라서 테스트를 작성할 때 부정적인 케이스를 먼저 만드는 습관을 들이는 것이 좋다.
2.3.4 테스트가 이끄는 개발
2.3.3에서 테스트를 어떻게 실행할지 결정하고, 테스트 코드를 작성하고 UserDao 코드에 손을 대기 시작했다.
테스트할 대상도 만들지 않고 테스트 코드부터 만드는 것이 이상할 수도 있으나 이런 순서로 개발하는 방법론이 존재한다.
기능 설계를 위한 테스트
우리는 테스트 코드의 개선을 '존재하지 않는 id로 get() 메소드를 실행했을 때 어떻게 될까?'라는 고민에서 시작했다.
이 고민의 시작으로 테스트에서 특정한 예외를 캐치해야함을 생각할 수 있었다.
이는 '어떻게 테스트할까?'라는 생각을 통해 getUserFailure()를 만든 것이 아니라 추가하고 싶은 기능을 코드로 표현했다.

getUserFailure() 테스트에는 만들고 싶은 기능에 대한 조건과 행위, 결과에 대한 내용이 잘 표현되어있다.
하나의 기능정의서 같아 보이지 않는가?
그래서 기능설계, 구현, 테스트라는 일반적인 개발 흐름의 기능설계에 해당하는 부분을 테스트 코드가 일부 담당한다고 볼 수도 있다.
테스트 주도 개발
TDD, TFD
만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증도 해줄 수 있도록 테스트 코드를 먼저 만들고,
테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법론
-> 실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다.
TDD는 아예 테스트를 먼저 만들고 테스트가 성공하도록 하는 코드만 만들기 때문에 테스트를 빼먹지 않고 꼼꼼히 만들어낸다.
그렇다면 TDD의 구체적인 장점은 무엇인가?
- 미리 테스트가 작성되어있기 때문에 코드를 만들고 테스트까지 시간이 걸리지 않는다.
- 매번 테스트가 성공함에 따라 코드에 대한 강한 확신을 갖는다.
TDD에서는 테스트를 작성하고 성공시키는 코드를 만드는 작업의 주기를 가능한 짧게 가져가도록 권장하고 있다.
테스트를 반나절 만들고 오후 내내 테스트를 통과시키는 코드를 만드는 식의 개발은 좋지 못하다.
그런데 어떻게 보면 모든 개발자는 TDD를 몰라도 테스트가 개발을 이끌어가는 방식으로 개발을 하고 있다고 생각한다.
만약 새로운 기능을 가진 코드를 만든다고 가정해보자. 그렇다면 개발자의 머릿속에서는 다음과 같은 생각을 할 것이다.
'이런 조건 하에 이런 작업을 하면 이런 결과가 나올 것이다'라는 기능을 먼저 정리할 것이며,
코드를 작성하면서도 코드를 보며 '이런 조건의 값이 들어오면 코드의 흐름과 조건에 따라 이렇게 진행되어 이런 결과값이 나온다'
머릿속에서 이미 TDD를 하고 있다고 볼 수 있다.
하지만 이 방법은 제약이 심하고, 오류가 많고, 반복이 어렵다.
TDD를 하면 개발 지연의 우려를 표하는 사람들이 종종 있는데,
테스트는 애플리케이션 코드보다 작성이 쉽고 독립적이다. 따라서 코드 양에 비해 작성 시간이 짧다.
또한 디버깅 시간이 줄어들기 때문에 개발 속도는 빨라진다.
만약 웹을 통해 테스트하기까지 비효율을 생각한다면 단위 테스트를 미리 만들어 코드를 검증하는 편이 좋다.
책 소스코드
https://github.com/l-lyun/study/tree/book/toby-spring-chapter-2
'spring' 카테고리의 다른 글
| 밴드 플랫폼 서버 개발 일지 - 스프링 시큐리티 세팅 (0) | 2026.06.23 |
|---|---|
| [Spring] 토비의 스프링 Vol.1 2장 - 테스트 (2) (0) | 2026.06.18 |
| [Spring] 토비의 스프링 Vol.1 1장 - 오브젝트와 의존관계 (2) (0) | 2026.06.11 |
| [Spring] 토비의 스프링 Vol.1 1장 - 오브젝트와 의존관계 (1) (0) | 2026.06.09 |
| [Spring] 토비의 스프링 강의 섹션 3 - 스프링 도입 (0) | 2026.05.04 |