4장까지는 데이터 접근 방식에 대해 추상화를 진행했다. 즉, DAO를 추상화했다. 5장에서는 DAO에 트랜잭션을 적용해보면서 스프링이 어떻게 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있도록 지원하는지 살펴볼 것이다.
5.1 사용자 레벨 관리 기능 추가
지금까지 만들었던 기능(CRUD)에 간단한 비즈니스 로직을 추가해보자.
- 사용자의 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나다.
- 사용자가 처음 가입하면 BASIC 레벨이 되며, 이후 활동에 따라서 한 단계식 업그레이드 될 수 있다.
- 가입 후 50회 이상 로그인 시 BASIC->SILVER
- SILVER 레벨이면서 30번 이상 추천을 받을 시 SILVER->GOLD
- 사용자 레벨의 변경 작업은 일정 주기를 가지고 일괄적으로 진행된다.
5.1.1 필드 추가
User 클래스에 Enum 타입을 사용하여 안전하고 편리하게 관리할 수 있는 사용자의 레벨을 저장할 필드를 추가하자.
- DB에 varchar 타입으로 선언하고 문자로 저장
- 공간을 불필요하게 차지하고 정렬 등의 기능을 사용하기 어렵다.
- User에 추가할 프로퍼티 타입을 int로 저장
- 의미 없는 숫자를 프로퍼티에 사용하면 타입이 안전하지 않아서 위험
- User에 추가할 프로퍼티 타입을 상수 값으로 저장
- level 타입이 int이기 때문에 다른 종류의 정보(0이나 4 등)를 넣는 실수를 해도 컴파일러가 체크하지 못한다.
public enum Level {
BASIC(1), SILVER(2), GOLD(3);
private final int value;
Level(int value) {
this.value = value;
}
public int intValue() {
return value;
}
public static Level valueOf(int value) {
return switch (value) {
case 1 -> BASIC;
case 2 -> SILVER;
case 3 -> GOLD;
default -> throw new AssertionError("Unknown value: " + value);
};
}
}
public class User {
...
Level level;
int loginCount; //추천 수와 로그인 횟수는 숫자 범위가 매우 크고 의미가 없기 때문에 int로 저장
int recommendCount;
public Level getLevel() {
return level;
}
...
UserDaoTest 테스트도 수정하도록 하자. 테스트에서 유용하게 쓰려면 레벨에 각각 다른 값을 넣는 것이 좋다. 또한, 새로 추가된 필드의 DB 쓰기와 읽기가 올바르게 되는지 검증하기 위해 검증용 필드를 추가한다.
public class UserDaoTest {
...
@BeforeEach
public void setUp() {
userDao.deleteAll();
this.user1 = new User("user1", "김똘일", "1234", Level.BASIC, 1 ,0);
this.user2 = new User("user2", "김똘이", "1234", Level.SILVER, 55, 10);
this.user3 = new User("user3", "김똘삼", "1234", Level.GOLD, 55, 10);
this.user4 = new User("user4", "김똘사", "1234", Level.BASIC, 1, 0);
}
@Test public void addAndGet() {
...
User userget1 = dao.get(user1.getId());
checkSameUser(userget1, user1);
User userget2 = dao.get(user2.getId());
checkSameUser(userget2. user2);
}
private void checkSameUser(User user1, User user2) {
assertEquals(user1.getId(), user2.getId());
assertEquals(user1.getName(), user2.getName());
assertEquals(user1.getPassword(), user2.getPassword());
assertEquals(user1.getLevel(), user2.getLevel()); //추가
assertEquals(user1.getLoginCount(), user2.getLoginCount()); //추가
assertEquals(user1.getRecommendCount(), user2.getRecommendCount()); //추가
}
}
이제 미리 준비된 UserDaoTest가 성공하도록 UserDaoJdbc 클래스를 수정하자. Level enum은 오브젝트이므로 DB에 저장될 수 있는 SQL 타입이 아니다. 따라서 DB에 저장 가능한 정수형 값으로 변환해줘야 한다. 반대로, valueOf를 이용해 int 타입의 값을 Level 타입의 enum 오브젝트로 만들어서 setLevel 메서드에 넣어줘야한다.
public void add(User user) throws DuplicateUserIdException {
try {
this.jdbcTemplate.update("insert into users(id, name, password, level, login_count, recommend_count) values (?, ?, ?, ?, ?, ?)"
, user.getId()
, user.getName()
, user.getPassword()
, user.getLevel().intValue() //추가
, user.getLoginCount() //추가
, user.getRecommendCount() //추가
);
} catch (DuplicateKeyException e) {
throw new DuplicateUserIdException(e);
}
}
5.1.2 사용자 수정 기능 추가
수정할 정보가 담긴 User 오브젝트를 전달하면 id를 참고해서 사용자를 찾아 필드 정보를 UPDATE 문을 사용해 모두 변경해주는 메소드를 만들자. (성능을 극대화하기 위해, 필드의 종류에 따라 각각의 수정용 DAO 메소드를 만들어야 할 때도 있지만, 여기서는 단순하게 구현한다.)
update 메소드를 테스트할 시, 수정하지 않아야 할 로우의 내용이 그대로 남아있는지도 확인해주어야 한다. 다음 두 가지 방법을 이용할 수 있다.
- UserDao의 메소드 리턴 타입을 int로 바꾸고 이 정보가 1인지 확인하는 코드를 추가한다.
- 사용자를 두 명 등록해놓고, 그 중 하나만 수정한 뒤 사용자의 정보를 모두 확인한다.
@Test
@DisplayName("사용자 수정 기능 테스트")
public void update() {
userDao.add(user1);
userDao.add(user2);
user1.setName("오민규");
user1.setPassword("2345");
user1.setLevel(Level.GOLD);
user1.setLoginCount(1000);
user1.setRecommendCount(999);
int updateCount = userDao.update(user1);
assertEquals(updateCount, 1);
User user1update = userDao.get(user1.getId());
checkSameUser(user1, user1update);
User user2same = userDao.get(user2.getId());
checkSameUser(user2, user2same);
}
5.1.3 UserService.upgradleLevels()
이제 본격적인 사용자 관리 비즈니스 로직을 구현해보자. UserService는 UserDao 인터페이스 타입으로 userDao 빈을 DI로 받아 사용하게 만든다.
public class UserService {
UserDao userDao;
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
}
이제 UserService가 정상적으로 userDao를 주입받는지만 확인해보자.
- Spring 5 이후로 @RunWith(SpringJUnit4ClassRunner.class)는 @ExtendWith(SpringExtension.class) 로, xml 은 java config를 사용한다. 이 두 조합을 이용하거나, @SpringBootTest를 선언하면 스프링 컨텍스트를 테스트에 이용할 수 있다.
@ExtendWith(SpringExtension.class) // (JUnit5)
@ContextConfiguration(locations="/spring/applicationContext.xml")
class UserServiceTest {
@Autowired UserService userService;
@Test
@DisplayName("userDao를 정상적으로 주입받았는지 확인")
public void isUserDaoNotEmpty() {
Assertions.assertNotNull(userService.userDao);
}
}
이제 사용자 레벨 관리 기능을 만들어보자.
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
Boolean changed = null;
if (user.getLevel() == Level.BASIC && user.getLoginCount() >= 50) {
user.setLevel(Level.SILVER);
changed = true;
} else if (user.getLevel() == Level.SILVER && user.getRecommendCount() >= 30) {
user.setLevel(Level.GOLD);
changed = true;
} else if (user.getLevel() == Level.GOLD) {
changed = false;
} else {
changed = false;
}
if(changed) {
userDao.update(user);
}
}
}
본격적인 비즈니스 로직이 들어가니 코드가 조금 복잡해보인다. 정말 뛰어난 개발자라면 아무리 간단해보여도 실수할 수 있음을 알고 있기 때문에 테스트를 만들어서 직접 동작하는 모습을 확인하려고 할 것이다.
upgradeLevels() 테스트 코드를 작성해보자.
- 적어도 가능한 모든 조건을 하나씩은 확인해봐야한다. GOLD를 제외한 나머지 두 가지는 업그레이드가 되는 경우와 아닌 경우가 있을 수 있으므로, 최소 다섯 가지 경우를 살펴봐야한다. 테스트 픽스터 개수가 많아졌으니 리스트를 사용해보자.
- 테스트에 사용할 데이터를 경계가 되는 값 전후로 선택하는 것이 좋다.
@BeforeEach
public void setUp() {
users = Arrays.asList(
new User("bumjin", "박범진", "p1", Level.BASIC, 49, 0)
, new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0)
, new User("erwins", "신승한", "p3", Level.SILVER, 60, 29)
, new User("madnite1", "이상호", "p4", Level.SILVER, 60, 30)
, new User("green", "오민규", "p5", Level.GOLD, 100, 100)
);
}
@Test
@DisplayName("사용자 레벨 업그레이드 테스트")
public void upgradeLevels() {
for (User user : users) {
userDao.add(user);
}
userService.upgradeLevels();
checkLevel(users.get(0), Level.BASIC);
checkLevel(users.get(1), Level.SILVER);
checkLevel(users.get(2), Level.SILVER);
checkLevel(users.get(3), Level.GOLD);
checkLevel(users.get(4), Level.GOLD);
}
private void checkLevel(User user, Level expectedLevel) {
User userUpdate = userDao.get(user.getId());
Assertions.assertEquals(userUpdate.getLevel(), expectedLevel);
}
5.1.4 UserService.add()
처음 가입하는 사용자는 기본적으로 BASIC 이어야한다는 기능을 추가하자. 처음 가입할 때를 제외하면 무의미한 정보인데다가, 단지 이 로직을 담기 위해 클래스에서 직접 초기화하는 것은 좀 문제가 있어 보인다.
UserService에 add()를 만들어 사용자가 등록될 때 적용할 만한 비즈니스 로직을 담당하게 하도록 하자.
@Test
@DisplayName("기본 레벨이 Level.BASIC으로 설정되는지 테스트")
public void defaultLevelIsBasic() {
User userWithLevel = users.get(3); //SILVER
User userWithoutLevel = users.get(4);
userWithoutLevel.setLevel(null);
userService.add(userWithLevel);
userService.add(userWithoutLevel);
//db에 저장된 결과를 가져와 반환한다.
User dbUserWithLevel = userDao.get(userWithLevel.getId());
User dbUserWithoutLevel = userDao.get(userWithoutLevel.getId());
Assertions.assertEquals(dbUserWithLevel.getLevel(), userWithLevel.getLevel());
Assertions.assertEquals(dbUserWithoutLevel.getLevel(), Level.BASIC);
}
public void add(User user) {
// 간단히 level이 null이라면, Level.BASIC 삽입
if(user.getLevel() == null) {
user.setLevel(Level.BASIC);
}
userDao.add(user);
}
5.1.5 코드 개선
작성된 코드를 살펴볼 때는 다음과 같은 질문을 해볼 필요가 있다.
- 코드에 중복된 부분은 없는가?
- 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?
- 코드가 자신이 있어야 할 자리에 있는가?
- 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?
이런 질문을 하며 upgradeLevels() 메서드를 살펴보면 몇 가지 문제점이 보인다.
- if/elseif/else 블록이 읽기 불편하다. 레벨의 변화 단계와, 업그레이드 조건, 조건이 충족됐을 때 해야 할 작업이 한데 섞여 있어서 로직을 이해하기 쉽지 않다.
- 게다가, if 조건 블록이 레벨 개수만큼 반복된다.
- 첫 단계에서는 레벨을 확인하고, 각 레벨별로 다시 조건을 판단하는 조건식을 넣어야한다.
- 다음 단계가 무엇인가 하는 로직과, 그때 사용자 오브젝트의 level 필드를 변경해주는 로직이 서비스에 있으면 안된다.
- 서비스가 아닌 Level 클래스가 담당하도록 해야한다.
- 업그레이드 조건인 로그인 횟수와 추천 횟수는 테스트와 서비스 클래스에 중복적으로 나타난다.
- 정수형 상수로 선언해야한다.
- 레벨을 업그레이드하는 정책을 유연하게 변경할 수 있게 해야한다.
- 사용자 업그레이드 정책을 UserService에서 분리하고, 업그레이드 정책을 담은 인터페이스를 만들어 UserService에서 DI로 제공받은 정책 구현 클래스를 이 인터페이스를 통해 사용하도록 해야한다.
public class UserService {
UserDao userDao;
public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_COUNT_FOR_GOLD = 30;
...
//정책 분리 필요
private boolean canUpgradeLevel(User user) {
Level currentLevel = user.getLevel();
return switch(currentLevel) {
case BASIC -> user.getLoginCount() >= MIN_LOGIN_COUNT_FOR_SILVER;
case SILVER -> user.getRecommendCount() >= MIN_RECOMMEND_COUNT_FOR_GOLD;
case GOLD -> false;
default -> throw new IllegalArgumentException("Unknown Level: " + currentLevel);
};
}
}
public class User {
...
public void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
//비즈니스 로직이 추가됐으므로 User에 대한 테스트도 필요
private void upgradeLevel() {
Level nextLevel = this.level.nextLevel();
if (nextLevel == null) {
throw new IllegalStateException(this.level + "은 업그레이드가 불가능합니다.");
} else {
this.level = nextLevel;
}
}
}
public enum Level {
// 초기화 순서를 3, 2, 1 순서로 하지 않으면 `SILVER`의 다음 레벨에 `GOLD`를 넣는데 에러가 발생한다.
GOLD(3, null), SILVER(2, GOLD), BASIC(1, SILVER);
private final int value;
private final Level next;
Level(int value, Level next) {
this.value = value;
this.next = next;
}
public Level nextLevel() {
return next;
}
public int intValue() {
return value;
}
public static Level valueOf(int value) {
return switch (value) {
case 1 -> BASIC;
case 2 -> SILVER;
case 3 -> GOLD;
default -> throw new AssertionError("Unknown value: " + value);
};
}
}
import static toby_spring.user.service.UserService.*;
class UserServiceTest {
@Autowired UserService userService;
UserDao userDao;
List<User> users;
@BeforeEach
public void setUp() {
this.userDao = this.userService.userDao;
userDao.deleteAll();
users = Arrays.asList(
new User("bumjin", "박범진", "p1", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER - 1, 0)
, new User("joytouch", "강명성", "p2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0)
, new User("erwins", "신승한", "p3", Level.SILVER, MIN_LOGIN_COUNT_FOR_SILVER, MIN_RECOMMEND_COUNT_FOR_GOLD - 1)
, new User("madnite1", "이상호", "p4", Level.SILVER, MIN_LOGIN_COUNT_FOR_SILVER, MIN_RECOMMEND_COUNT_FOR_GOLD)
, new User("green", "오민규", "p5", Level.GOLD, 100, 100)
);
}
...
public interface UserLevelUpgradePolicy {
boolean canUpgradeLevel(User user);
void upgradeLevel(User user);
}
5.2 트랜잭션 서비스 추상화
모든 사용자에 대한 레벨 업그레이드 작업은 전체가 다 성공하든지 아니면 전체가 다 실패하든지 해야한다. 하지만, upgradeLevels() 메소드가 하나의 트랜잭션 안에서 동작하지 않았기 때문에 문제가 발생한다.
5.2.1 트랜잭션 경계 설정
하나의 SQL 명령을 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다. 하지만 여러 개의 SQL 작업을 하나의 트랜잭션으로 취급해야하는 경우에는 따로 경계 설정을 해주어야 한다.
JDBC(SQL 전용) 트랜잭션의 트랜잭션 경계 설정은 자동 커밋 옵션을 false로 만들어준 뒤(트랜잭션 시작 선언), 중간에 오류가 발생하면 rollback()으로, 성공하면 commit()으로 트랜잭션을 종료해주면 된다. 이렇게 하나의 커넥션이 만들어지고 닫히는 범위 안에 존재하는 트랜잭션의 경계는 로컬 트랜잭션이라고 한다.
Connection c = dataSource.getConnection();
c.setAutoCommit(false); // 트랜잭션 경계 시작
try {
PreparedStatement st1 =
c.prepareStatement("update users ...");
st1.executeUpdate();
PreparedStatement st2 =
c.prepareStatement("delete users ...");
st2.executeUpdate();
c.commit(); // 트랜잭션 경계 끝지점 (커밋)
} catch(Exception e) {
c.rollback(); // 트랜잭션 경계 끝지점 (롤백)
}
c.close();
5.2.2 UserService와 UserDao의 트랜잭션 문제
- dao 별로 커넥션을 따로 생성하여 트랜잭션이 개별적으로 이루어진다. => Connection을 인자로 넘겨주는 방법으로 해결
- 커넥션 파라미터로 지저분해지고, 데이터 액세스 기술에 독립적일 수 없다. => 트랜잭션 동기화로 해결
- TransactionSynchronizationManager로 트랜잭션 동기화 작업을 초기화 및 종료하고, DataSourceUtils를 통해 커넥션을 생성한다. 직접 DataSource에서 커넥션을 가져오지 않는 이유는 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문.
- 또한, JdbcTemplate은 영리하게 설계되어있어서 동기화된 커넥션을 가져오거나, 트랜잭션이 굳이 필요없다면 바로 호출해서 사용된다.

- 두 개 이상의 DBMS 혹은 메세징 서버 같은 API를 사용한다면 로컬 트랜잭션으로는 해결되지 않는다. => 자바가 제공하는 JTA 이용

- 글로벌 트랜잭션으로 바꾸려면 UserService의 코드를 수정해야한다는 문제와, 하이버네이트와 같이 데이터 액세스 기술이 변경되면 코드가 변경된다. => UserDao를 추상화한 것처럼, 트랜잭션 추상 계층을 이용하여 특정 기술에 종속되지 않도록 한다.

=> UserService는 사용자 레벨을 어떻게 관리할 것인가와 어떻게 트랜잭션을 관리할 것인가 두 가지 책임을, 책임을 분리함으로써 단일 책임 원칙을 지키는 코드가 되었다. 즉, 기술이 바뀌면 기술 계층과 연동을 담당하는 기술 추상화 계층의 설정만 바꿔주면 된다!
5.3 메일 서비스 추상화
레벨이 업그레이드 되는 사용자에게 안내 메일을 발송해달라는 새로운 요구사항이 추가되었다고 하자.
자바에서는 메일을 발송할 때 표준 기술인 JavaMail을 사용하면 된다.

SMTP 프로토콜을 지원하는 메일 전송 서버가 준비되어 있다면, 이 코드는 정상적으로 동작할 것이고 안내 메일이 발송될 것이다.
5.3.1 JavaMail이 포함된 코드의 테스트
사용자 레벨 업그레이드의 기능을 테스트하려고 만든 테스트를 실행하면 이 메일 전송 메소드가 호출된다. 이는 실제 운영 중인 메일 서버를 통해 테스트를 실행할 때마다 메일 서버에 상당한 부담을 줄 수 있다.
메일 발송 기능은 사용자 레벨 업그레이드 작업의 보조적인 기능에 불과하다. 게다가 메일 발송 테스트란 엄밀히 말해서 불가능하다. JavaMail을 통해 메일 서버 까지만 메일이 잘 전달됐으면, 기능이 성공했다고 봐도 된다. 따라서 테스트 메일 서버를 이용해 테스트 가능한 메일 서버까지만 잘 전송되는지 확인하면 된다.

하지만, JavaMail 또한 자바의 표준 기술이고 검증된 안정적인 모듈이다. 따라서 JavaMail API를 통해 요청이 들어간다는 보장만 있다면 굳이 테스트할 때마다 JavaMail을 직접 구동시킬 필요가 없다. JavaMail이 동작하면 외부의 메일 서버와 네트워크로 연동하고 전송하는 부하가 큰 작업이 일어나기 때문에 이를 생갹할 수 있다면 더할 나위 없이 좋을 것이다.

즉, JavaMail을 사용할 때와 동일한 인터페이스를 갖는 코드가 동작하도록 만들면 된다.
5.3.2 테스트를 위한 서비스 추상화
그런데 한 가지 심각한 문제가 있다. JavaMail의 API는 이 방법을 적용할 수 없다는 점이다. JavaMail의 핵심 API에는 DataSource처럼 인터페이스로 만들어져서 구현을 바꿀 수 있는 게 없다.
이때, 트랜잭션을 적용하면서 살펴봤던 서비스 추상화를 적용하면 해결할 수 있다! 스프링은 JavaMail을 사용해 만든 코드를 손쉽게 테스트할 수 있도록 JavaMail에 대한 추상화 기능을 제공하고 있다.

이 인터페이스는 메일 메시지를 전송하는 메소드로만 구성되어 있다. 기본적으로는 JavaMail을 사용해 메일 발송 기능을 제공하는 JavaMailSenderImpl 클래스를 이용하면 된다.

위 코드에서 이제 스프링 DI를 적용하여 MailSender 인터페이스만 남기고, 구체적인 메일 전송 구현을 담은 클래스의 정보는 모두 제거한다.


이제 테스트 코드에서는 아무것도 하지 않는 MailSender 구현 빈 클래스를 만들어 주입하면 된다.

일반적으로 서비스 추상화라고 하면 트랜잭션과 같은 다양한 기술에 대하여 일관성 있는 접근 방법을 제공해주는 것을 말한다. 하지만 JavaMail의 경우처럼 테스트가 어렵도록 설계된 API를 사용할 때도 유용하게 쓰일 수 있다.

5.3.3 테스트 대역
위에서 본 것처럼, 테스트 환경을 만들어주기 위해 테스트를 실행할 수 있도록 사용하는 오브젝트를 통틀어서 테스트 대역이라고 부른다. 대표적인 테스트 대역은 테스트 스텁이다. 이는 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는다.
하지만 많은 경우 테스트 스텁이 결과를 돌려줘야 할 때도 있다. 이처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해주는 오브젝트를 목 오브젝트라고 한다.

아래의 목 오브젝트를 활용하면, 목 오브젝트에 저장된 메일 수신자 목록을 통해 업그레이드 대상과 일치하는지 확인할 수 있다.

이렇게, 목 오브젝트를 통해 테스트 대상 오브젝트 내부에서 일어나는 일이나 다른 오브젝트 사이에서 주고받는 정보까지 검증하는 일을 손쉽게 처리할 수 있다.
'Coding > 개발 서적' 카테고리의 다른 글
| [토비의 스프링 3.1 Vol 1] 06장. AOP (1) | 2025.04.16 |
|---|---|
| [Real MySQL 8.0 V1] 04장. 아키텍처 (0) | 2025.03.20 |
| [토비의 스프링 3.1 Vol 1] 04장. 예외 (2) | 2025.02.27 |
| [토비의 스프링 3.1 Vol 1] 03장. 템플릿 (0) | 2025.02.22 |
| [토비의 스프링 3.1 Vol 1] 01장. 오브젝트와 의존 관계 (2) | 2025.01.23 |