✅ 수직 수평 계층 구조와 의존 관계
UserService와 UserDao는 애플리케이션의 로직을 담고 있는 애플리케이션 계층입니다. UserDao는 데이터를 어떻게 가져오고 등록할 것인가에 대한 데이터 액세스 로직을 담고 있습니다. UserSerice는 순수하게 사용자 관리의 업무 비즈니스 로직을 담고 있습니다. UserDao와 UserService는 인터페이스와 DI를 통해 연결됨으로써 결합도가 낮아졌습니다.
UserDao는 DB를 연결을 생성하는 방법에 대해 DataSource 인터페이스를 활용하여 결합도가 낮아졌습니다. UserService의 트랜잭션 기술도 스프링이 제공하는 PlatformTransactionManager 인터페이스를 통한 추상화를 활용하여 결합도가 낮아졌습니다.
👉 이렇게 스프링의 DI는 관심사, 챔임, 성격에 따른 코드를 깔금하게 분리하는 중요한 역할을 맡고 있습니다.
🔹 단일 책임 원칙
단일 책임 원칙(Single Responsibility Principle)은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미입니다. 하나의 모듈이 바뀌는 이유가 한 가지 이유에서야만 한다고 설명할 수도 있습니다.
UserSerivce 코드에 스프링에서 제공하는 트랜잭션 매니저를 쓰지 않았을 경우
- 사용자의 레벨을 어떻게 관리할 것인가?
- 트랜잭션을 어떻게 관리할 것인가?
두 가지 책임을 가지고 있었습니다. 두 가지 책임을 가지고 있다는 것은 코드가 수정될 두 가지 이유를 가지고 있다는 것과 같습니다. 하지만 트랜잭션 서비스의 추상화를 도입하고 난 후에는 UserService는 오직 사용자 레벨을 어떻게 관리할 것인가에 대해서만 집중하면 되었습니다.
- 단일 책임 원칙의 장점
응집도 향상 단일 책임 원칙은 모듈을 단일 기능에 집중시키므로 모듈 내부의 응집도가 향상됩니다. 응집도란 모듈 내부의 요소들이 서로 관련되어 있는 정도를 나타내는데, 단일 책임 원칙을 따르면 한 가지 기능에 집중되기 때문에 모듈 내부의 요소들이 서로 강하게 연결되고 응집력 있는 구조를 유지할 수 있습니다. 이는 코드의 가독성과 유지보수성을 향상시키는데 도움을 줍니다. 재사용성 증가 단일 책임 원칙을 따르면 모듈이나 클래스가 특정한 기능에 집중되므로 해당 기능을 다른 프로젝트나 모듈에서 재사용하기 쉬워집니다. 기능이 분리되어 있기 때문에 필요한 부분만 가져와서 다른 곳에서 사용할 수 있습니다. 이는 코드의 재사용성을 높이고 개발 생산성을 향상시킵니다. 유지보수 용이성 단일 책임 원칙을 따르면 각 모듈이나 클래스가 독립적으로 작동하므로 변경이 필요한 경우 해당 모듈 또는 클래스만 수정하면 됩니다. 다른 기능과 관련이 없기 때문에 다른 부분에 영향을 주지 않고 수정이 가능합니다. 이는 버그 수정이나 새로운 기능 추가 시에 유지보수 작업을 단순화시키고 오류 발생 가능성을 낮추는 데 도움을 줍니다. 테스트 용이성 단일 책임 원칙을 따르면 각 모듈이나 클래스가 작고 명확한 목적을 가지므로 단위 테스트를 작성하기 쉽습니다. 특정 기능에 대한 테스트를 구현하기 위해 해당 기능과 관련된 모듈만 고려하면 되기 때문에 테스트 케이스를 작성하고 실행하기가 간단해집니다.
✅ 메일 서비스
- pom.xml
<!-- Java Mail --> <dependency> <groupId>com.sun.mail</groupId> <artifactId>javax.mail</artifactId> <version>1.6.2</version> </dependency>
pom.xml에 java mail과 관련된 의존성을 추가합니다.
- 테이블에 email 컬럼 추가
ALTER TABLE users ADD COLUMN email VARCHAR(20); COMMIT;
테이블에 eamil 컬럼을 추가합니다.
- User
public class User { String id; String name; String password; String eamil; // == 테스트를 쉽게 하기 위해 파라미터가 있는 생성자 ==// public User(String id, String name, String password, Level level, int login, int recommend, String email) { this.id = id; this.name = name; this.password = password; this.level = level; this.login = login; this.recommend = recommend; this.eamil = email; } public String getEmail() { return eamil; } public void setEamil(String email) { this.eamil = email; }
email에 대한 정보를 담기 위해 필드, 생성자의 매개변수, getter & setter을 추가합니다.
- UserDaoJdbc
public class UserDaoJdbc implements UserDao { private JdbcTemplate jdbcTemplate; public void setDataSource(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } //==RowMapper 객체를 생성하기 위한 메소드==// private RowMapper<User> userRowMapper() { return ((rs, rowNum) -> { User user = new User(); user.setId(rs.getString("id")); user.setName(rs.getString("name")); user.setPassword(rs.getString("password")); user.setLevel(Level.valueOf(rs.getInt("level"))); user.setLogin(rs.getInt("login")); user.setRecommend(rs.getInt("recommend")); user.setEamil(rs.getString("email")); return user; }); } //== DB에 사용자 추가 ==// public void add(final User user) { String sql = "INSERT INTO users(id, name, password, level, login, recommend, email) " + "VALUES(?,?,?,?,?,?,?)"; this.jdbcTemplate.update(sql, user.getId(), user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend(), user.getEmail()); } //== 사용자 정보 수정 ==// public void update(final User user) { String sql = "UPDATE users SET name=?, " + "password=?, " + "level=?, " + "login=?, " + "recommend=?, " + "email=? " + "WHERE id=?"; this.jdbcTemplate.update(sql, user.getName() , user.getPassword() , user.getLevel().intValue() , user.getLogin() , user.getRecommend() , user.getEmail() , user.getId()); }
Email 컬럼이 추가되었기 때문에 그에 맞게 UserDaoJdbc의 코드도 수정합니다.
- UserService or UserlevelUpgradeImple
@Override public void upgradeLevel(User user, UserDao userDao) { user.upgradeLevel(); userDao.update(user); sendUpgradeEMail(user); } private void sendUpgradeEMail(User user) { Properties props = new Properties(); props.put("mail.smtp.host", "mail.ksug.org"); Session s = Session.getInstance(props, null); MimeMessage message = new MimeMessage(s); try { message.setFrom(new InternetAddress("useradmin@ksug.org")); message.addRecipient(Message.RecipientType.TO, new InternetAddress(user.getEmail())); message.setSubject("Upgrade 안내"); message.setText("사용자님의 등급이 "+ user.getLevel().name() + "로 업그레이드 되었습니다."); Transport.send(message); } catch (Exception e) { e.printStackTrace(); } }
서버가 구성되어 있지 않았기 때문에 테스트를 해도 실제로 메일이 발송되지는 않습니다. 그러나 테스트는 잘 통과하는 것을 보아 로직상의 문제는 없습니다.
🔹 메일 발송 기능 추상화
위의 메일을 발송하는 코드는 UserService(실제로는 UserLevelUpgradeImpl 클래스를 사용하고 있습니다. 이는 앞에서 업그레이드 정책에 따라 쉽게 수정할 수 있도록 추상화를 진행한 것입니다.)에서 어떤 클래스를 사용하여서 메일을 발송해야 하는지 모두 알고 있어야 합니다. 이를 결합도를 낮추기 위해 추상화를 진행해보겠습니다.
- DummyMailSender
package com.jhcode.spring.ch5.user.service; import org.springframework.mail.MailException; import org.springframework.mail.MailSender; import org.springframework.mail.SimpleMailMessage; public class DummyMailSender implements MailSender { public void send(SimpleMailMessage mailMessage) throws MailException {} public void send(SimpleMailMessage[] mailMessage) throws MailException {} }
- UserLevelUpgradeImpl
private MailSender mailSender; public void setMailSender(MailSender dummyMailSender) { this.mailSender = dummyMailSender; } private void sendUpgradeEMail(User user) { SimpleMailMessage mailMessage = new SimpleMailMessage(); mailMessage.setTo(user.getEmail()); mailMessage.setFrom("useradmin@ksug.org"); mailMessage.setSubject("Upgrade 안내"); mailMessage.setText("사용자님의 등급이 "+ user.getLevel().name()); this.mailSender.send(mailMessage); }
- TestUserServiceFactory
@Bean public UserLevelUpgradePolicy userLevelUpgradePolicy() { UserLevelUpgradeImpl userLevelUpgradePolicy = new UserLevelUpgradeImpl(); userLevelUpgradePolicy.setMailSender(new DummyMailSender()); return userLevelUpgradePolicy; } @Bean public UserService userService() { UserService userService = new UserService(); userService.setUserDao(userDao()); userService.setUserLevelUpgradePolicy(userLevelUpgradePolicy()); userService.setTranscationManager(transactionManager()); return userService; }
- UserSeviceTest
//예외 발생 시 작업 취소 여부 테스트 @Test public void upgradeAllorNothing() throws Exception { UserService testUserService = new TestUserService(users.get(3).getId()); UserLevelUpgradeImpl policy = new UserLevelUpgradeImpl(); policy.setMailSender(new DummyMailSender()); testUserService.setUserDao(userDao); testUserService.setTranscationManager(transactionManager); testUserService.setUserLevelUpgradePolicy(policy); userDao.deleteAll(); for (User user : users) { testUserService.add(user); } try { testUserService.upgradeLevels(); //테스트가 제대로 동작하게 하기 위한 안전장치, 로직을 잘못짜서 upgradeLevels() 메소드가 통과되도 무조건 실패함. //fail("TestUserServiceException expected"); } catch (TestUserServiceException e) { System.out.println("TestUserServiceException 예외 발생함"); } finally { checkLevel(users.get(1), false); }
위 테스트는 TestUserService 객체를 사용합니다. TestUserService 객체는 Bean으로 등록되지 않았기 때문에 위와 같이 수동으로 DI를 모두 해주어야 합니다.
DummyMailSender은 MailSender 인터페이스를 구현하였지만, 실제로는 어떠한 일도 하고 있지 않습니다. 이는 테스트의 간편성을 위해 설계된 구조입니다. 테스트 대상이 되는 오브젝트가 또 다른 오브젝트에 의존하는 일은 매우 흔합니다. UserService는 이미 DI를 통해 주입받는 오브젝트만 세가지입니다. 즉, 의존 오브젝트가 3가지이며 이는 종속되거나 기능을 사용한다는 의미입니다.
이처럼 너무 많은 DI를 받는 의존 오브젝트를 테스트하기 위해서는 메일 서비스와 같이 아무런 일을 하지 않는 빈 오브젝트로 대체해주는 것이 해결책이 됩니다.
- MailService 완성
서버가 연결되어 있어야 가능한 줄 알았는데, Session과 Authentication, MimeMessage, Transport 객체를 사용해서 이메일을 전송할 수 있었습니다. 그 전에 메일을 전송할 Gmail의 앱 비밀번호 설정을 해주어야 합니다.
Google 계정 관리를 통해서 2단계 인증을 설정합니다.
2단계 인증을 설정하고 나면 앱 비밀번호를 설정할 수 있습니다.
메일과 windows 컴퓨터를 선택하고 생성을 누릅니다.
이렇게 16자리 비밀번호가 생성되는데 이것을 String password 부분에 붙이시면 됩니다. 위의 비밀번호는 삭제했으니 안심하세요ㅎ
- UserServiceTest
@Test public void sendEmailToGmail() throws UnsupportedEncodingException { String host = "smtp.gmail.com"; int port = 587; String username = "메일을 전송할 아이디"; String password = "앱 어플리케이션 비밀번호"; // 수진자 이메일 주소 String toAddress = "메일을 받을 아이디"; // 메일 속성 설정 Properties props = new Properties(); props.put("mail.smtp.auth", "true"); props.put("mail.smtp.starttls.enable", "true"); props.put("mail.smtp.host", host); props.put("mail.smtp.prot", port); // 인증 객체 생성 Authenticator authenticator = new Authenticator() { protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(username, password); } }; // 세션 생성 Session session = Session.getInstance(props, authenticator); try { MimeMessage message = new MimeMessage(session); message.setFrom(new InternetAddress(username)); message.setRecipient(Message.RecipientType.TO, new InternetAddress(toAddress)); message.setSubject(MimeUtility.encodeText("반가워요", "UTF-8", "B")); message.setText("테스트 메일입니다.", "UTF-8"); // 메일 전송 Transport.send(message); System.out.println("Email sent successfully!"); } catch (Exception e) { System.out.println("Failed to send email. Error message: " + e.getMessage()); fail("This sendEmailToFmail test is failed!!"); } }
메일을 보낼 아이디와 앱 비밀번호, 메일을 받을 아이디를 입력하고 테스트를 돌리면 정상적으로 수행됩니다.
- UserServiceTest
✅ 테스트 대역의 종류와 특징
- 테스트 대역(test double)
UserDao의 DataSource, UserService의 DummyMailSender과 같이 테스트 환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하고, 가볍게 만든 오브젝트를 통틀어 테스트 대역(test double)라고 부릅니다.
- 테스트 스텁(Test stub)
대표적인 테스트 대역은 테스트 스텁(test stub)입니다. 테스트 스텁은 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 해줍니다. 위에서 만든 DummyMailSender이 테스트 스텁의 예라고 할 수 있습니다.
- 목 오브젝트(Mock Object)
DummyMailSender처럼 메소드가 호출될 수도 있지만, 호출되지 않을 수도 있습니다. 일반적인 테스트는 입력을 주었을 때 결과를 도출해는 것을 보고 검증합니다. 이렇게 데스트 대상의 간접적인 출력 결과를 검증하고, 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어난 일을 검증할 수 있도록 설계된 오브젝트를 목 오브젝트(mock object)라고 합니다.
위 그림에서 (5)번을 제외하면 테스트 스텁이라고도 볼 수 있습니다.
✅ 목 오브젝트 테스트 적용
upgradeAllOrNothing() 테스트의 경우 트랜잭션 기능을 테스트하려고 만들었기 때문에 테스트가 수행되는 동안에 메일이 전송되었는지 여부는 관심 대상이 아닙니다. 따라서 DummyMailSender 테스트 스텁 오브젝트를 사용하는 것이 더 어울립니다.
반면에, upgradeLevels() 테스트는 메일 전송 자체에 대해서도 검증할 필요가 있습니다. 조건을 만족하는 사용자의 레벨을 수정했다면, 메일도 발송했어야 하기 떄문입니다.
- UserServiceTest
//== upgradeLevels() 테스트에 사용될 Mock 객체 ==// static class MockMailSender implements MailSender { private List<String> requests = new ArrayList<String>(); public List<String> getRequests() { return requests; } public void send(SimpleMailMessage mailMessage) throws MailException { requests.add(mailMessage.getTo()[0]); } public void send(SimpleMailMessage[] mailMessage) throws MailException { } } @Test @DirtiesContext public void upgradeLevels() throws Exception { userDao.deleteAll(); for(User user : users) { userDao.add(user); } MockMailSender mockMailSender = new MockMailSender(); UserLevelUpgradeImpl policy = new UserLevelUpgradeImpl(); policy.setMailSender(mockMailSender); userService.setUserLevelUpgradePolicy(policy); //DB의 모든 User을 가지고와서 Level 등급을 조정함 userService.upgradeLevels(); checkLevel(users.get(0), false); checkLevel(users.get(1), true); checkLevel(users.get(2), false); checkLevel(users.get(3), true); checkLevel(users.get(4), false); List<String> request = mockMailSender.getRequests(); assertEquals(request.size(), 2); assertEquals(request.get(0), users.get(1).getEmail()); assertEquals(request.get(1), users.get(3).getEmail()); }
- @DirtiesContext : 테스트 메서드나 테스트 클래스가 ApplicationContext의 상태를 변경한다는 것을 나타내는 데 사용됩니다. 즉, 해당 애너테이션을 사용하면 테스트 컨텍스트를 "더러운(dirty)" 상태로 표시하여 해당 테스트 메소드가 종료되면 ApplicationContext가 다시 로드되도록 지정할 수 있습니다.
테스트에 사용할 Mock 객체이므로, 따로 클래스로 만들지 않고 테스트 클래스 내에서 static 클래스로 만들었습니다.
📖 토비 스프링 3.1 -p375~399
🚩jhcode33의 toby-spring-study.git으로 이동하기
Uploaded by N2T