728x90

여기에 메일 관련된 기능을 추가해보자.

일단 유저 정보에 이메일을 추가로 저장해야 할 것이다.

 

일단 유저 클래스와 유저 Dao 클래스를 수정해보자.

@Builder
@Getter
@Setter
@EqualsAndHashCode
public class User {

    private Level level;
    private Integer login;
    private Integer recommend;
    private String id;
    private String name;
    private String password;
    private String email;

    public void upgradeLevel() {
        this.setLevel(Level.from(this.getLevel().getValue() + 1));
    }

    public boolean isUpgradeLevel() {
        Level level = this.level;
        return switch (level) {
            case BRONZE -> this.login >= SILVER_LOGIN_COUNT;
            case SILVER -> this.recommend >= GOLD_RECOMMEND_COUNT;
            case GOLD -> false;
        };
    }
}

 

public class UserDaoImpl implements UserDao {

    private final JdbcTemplate jdbcTemplate;

    private final RowMapper<User> userRowMapper = (rs, rowNum) -> User.builder()
            .id(rs.getString("id"))
            .name(rs.getString("name"))
            .password(rs.getString("password"))
            .login(rs.getInt("login"))
            .level(Level.from(rs.getInt("level")))
            .recommend(rs.getInt("recommend"))
            .email(rs.getString("email"))
            .build();

    public void update(User user) {
        this.jdbcTemplate.update(
                "update users set name = ?, password = ?, level = ?, login = ?, recommend = ?, email = ? " +
                        "where id = ?",
                user.getName(),
                user.getPassword(),
                user.getLevel().getValue(),
                user.getLogin(),
                user.getRecommend(),
                user.getEmail(),
                user.getId()
        );
    }

    public UserDaoImpl(
            DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void add(User user){
        this.jdbcTemplate.update(
                "insert into users(id, name, password, level, login, recommend, email) values(?, ?, ?, ?, ?, ?, ?)",
                user.getId(),
                user.getName(),
                user.getPassword(),
                user.getLevel().getValue(),
                user.getLogin(),
                user.getRecommend(),
                user.getEmail()
        );
    }

    public void deleteAll(){
        this.jdbcTemplate.update("delete from users");
    }

    public User get(String id){
        return jdbcTemplate.queryForObject(
                "select * from users where id = ?", userRowMapper, id);
    }

    public int getCount() {
        Integer result = jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
        return result == null ? 0 : result;
    }

    public List<User> getAll() {
        return this.jdbcTemplate.query(
                "SELECT * FROM users ORDER BY id", userRowMapper);
    }
}

 

일단 수정하고 테스트를 실행해보니 성공한다.

 

JavaMail을 이용한 메일 발송 기능

이제 메일 관련 코드를 추가해보자.

메일을 발송한다는 임무를 가지는 클래스를 하나 더 만들었다.

public class MailSender {

    public void sendUpgradeEmail(User user){
        Properties properties = new Properties();
        properties.put("mail.smtp.host", "mail.seungkyu.com");
        Session session = Session.getInstance(properties);

        MimeMessage mimeMessage = new MimeMessage(session);

        try
        {
            mimeMessage.setFrom(new InternetAddress("seungkyu@seungkyu.com"));
            mimeMessage.addRecipients(Message.RecipientType.TO, InternetAddress.parse(user.getEmail()));
            mimeMessage.setSubject("승급 안내");
            mimeMessage.setText(user.getLevel() + "로 승급하셨습니다.");

            Transport.send(mimeMessage);
        }
        catch (MessagingException e)
        {
            throw new RuntimeException(e);
        }
    }
}

 

그리고는 upgradeLevel() 메서드에

    private void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
        mailSender.sendUpgradeEmail(user);
    }

메일을 전송하는 코드를 추가해준다.

 

JavaMail이 포함된 코드의 테스트

근데 여기서 테스트를 어떻게 수행할까?

일단 테스트를 위해서는 서버가 준비되어 있어야 한다.(지금 SMTP 서버가 없기는 하다...)

그리고 테스트를 할 때마다 메일이 발송되는 것도 웃기지 않은가?

 

사실 메일 서비스는 테스트를 할 필요가 없다.

 

javaMail은 스프링에서 검증된 API이며

사실 서버로 보내진 경우에 메일이 보내지지 않는 것은, 여기서 테스트한다고 바뀌지 않는다.

해당 서버의 문제까지 테스트를 할 필요는 없지 않은가.

그렇기에 테스트 단계에서는 실제로는 메일이 전송되지 않는 다른 클래스를 사용해도 괜찮다.

 

테스트를 위한 서비스 추상화

지금은 메일 전송을 다음과 같이 사용하고 있다.

public class UserServiceImpl implements UserService{

    private final UserDao userDao;

    private final PlatformTransactionManager transactionManager;

    private final MailSender mailSender;
}

 

늘 그렇듯 이러한 부분은 추상화 인터페이스로 만들고 거기에 의존해야 후에 교체가 가능하다.

 

public interface MailSender {
    void sendUpgradeEmail(User user);
    void sendUpgradeEmail(User[] users);
}

그렇기에 MailSender를 인터페이스로 만들고, MailSenderImpl로 구현해서 빈에 등록했다.

 

테스트는 굳이 메일이 전송되지 않기를 바란다

그렇기에 그냥 로그만 작성하도록 클래스를 구현해서 상속받았다.

public class MailSenderTest implements MailSender {

    public void sendUpgradeEmail(@NonNull User user) {
        System.out.println("sendUpgradeEmail 호출");
        System.out.println(user);
    }

    public void sendUpgradeEmail(@NonNull User[] users) {
        System.out.println("sendUpgradeEmail 호출");
        for (User user : users) System.out.println(user);
    }
}

 

이렇게 하고 테스트를 실행해보니, 이렇게 성공적으로 로그가 남는 것을 볼 수 있었다.

 

현재의 MailSender는 다음과 같이 이용하고 있다.

MailSenderImpl은 JavaMail을 이용하고 있으며, 만약 다른 API를 사용해야 하는 경우 MailSender를 또 구현해서 등록하면 된다.

 

이제 여기서 안타까운 부분이 있다.

만약 1~5번 유저를 업그레이드 하던 중, 3번 유저에게 메일만 전송하고 업그레이드가 되지 않았다면?

이런 부분에서 무결성에 대한 문제가 나올 수 있다.

 

이런 문제에 관해서는 2가지 방법이 있다고 한다.

  • 업그레이드 사용자를 별도의 목록에 저장해두고, 업그레이드가 끝나면 메일을 일괄 전송
  • MailSender 자체를 확장해서 트랜잭션을 적용

일단 이 방법 중 가능한 방법을 사용하면 될 거 같고, 이런 JavaMail조차 추상화하면 큰 장점이 있다.

그렇기에 적극적으로 추상화의 방법을 사용해보도록 하자.

 

테스트 대역

테스트에서는 메일이 전송되지 않고, 단순하게 로그만 남도록 했다.

스프링의 DI를 통해 테스트에서는 다른 빈을 주입받도록 했기에 가능한 일이었다.

이렇게 테스트용으로 사용되는 클래스들이 존재한다.

이렇게 UserService와 같이 테스트의 대상이 의존하는 클래스들이다.

이렇게 빠르게 간편하게 테스트를 도와주도록 주입해주는 클래스들을 테스트 대역이라고 부른다.

 

현재 우리의 MailSenderTest는 모든 메서드가 void지만, 가끔은 테스트 대역들이 값을 리턴해야 하는 경우가 있다.

그럴 때는 대역들에게 테스트에 필요한 값들을 리턴하도록 만들 수 있다.

필요하다면 대역들이 입력받는 값도 검증해 볼 수 있다.

목 오브젝트라고 하며 테스트가 정상적으로 실행되도록 도와주면서, 테스트의 결과를 검증하는데 사용할 수도 있다.

 

사실 mockito를 사용하면 바로 끝나지만, 그래도 MailSenderTest를 수정해서 목 오브젝트 느낌으로 만들어보자.

@Getter
public class MailSenderTest implements MailSender {

    private final List<String> history = new ArrayList<>();

    public void sendUpgradeEmail(@NonNull User user) {
        history.add(user.getEmail());
    }

    public void sendUpgradeEmail(@NonNull User[] users) {
        System.out.println("sendUpgradeEmail 호출");
        for (User user : users) System.out.println(user);
    }
}

이렇게 메일을 전송할 때마다, 일단 내부 리스트에서 저장을 해두었다.

 

그리고 메일을 모두 전송한 후, history와 맞는지 체크해보았다.

@Getter
public class MailSenderTest implements MailSender {

    private final List<String> history = new ArrayList<>();

    public void sendUpgradeEmail(@NonNull User user) {
        history.add(user.getEmail());
    }

    public void sendUpgradeEmail(@NonNull User[] users) {
        System.out.println("sendUpgradeEmail 호출");
        for (User user : users) System.out.println(user);
    }
}

    @Test
    public void upgradeLevels() {
        userDao.deleteAll();

        for(User user : users) userDao.add(user);

        try{
            userService.upgradeLevels();
        }
        catch (Exception ignored){

        }
        if(userService instanceof UserServiceImpl userServiceImpl) {
            if (userServiceImpl.getMailSender() instanceof MailSenderTest mailSenderTest) {
                Assertions.assertEquals(users.get(1).getEmail(), mailSenderTest.getHistory().get(0));
                Assertions.assertEquals(users.get(3).getEmail(), mailSenderTest.getHistory().get(1));
            }
            else assert false;
            Assertions.assertEquals(Level.BRONZE, userDao.get(users.get(0).getId()).getLevel());
            Assertions.assertEquals(Level.SILVER, userDao.get(users.get(1).getId()).getLevel());
            Assertions.assertEquals(Level.SILVER, userDao.get(users.get(2).getId()).getLevel());
            Assertions.assertEquals(Level.GOLD, userDao.get(users.get(3).getId()).getLevel());
            Assertions.assertEquals(Level.GOLD, userDao.get(users.get(4).getId()).getLevel());
        }
        else assert false;
    }

계속 타입을 바꾸면서 했지만, mockito를 사용하면 이런 복잡한 과정은 필요없긴 하다.

 

이렇게 테스트만을 위해 존재하는 객체들이 있다.

빠르게 테스트를 위해 대충 사용해보았는데, mockito에 대해서 나중에 더 자세히 알아보도록 하자.

728x90

이제 UserDao와 UserService는 각각 담당하는 분야가 아예 다르다.

그렇기에 서로 영향을 주지 않으며 독자적으로 확장이 가능하다.

그에 비해 트랜잭션의 추상화는 애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드로 분리했다.

 

UserService UserDao 애플리케이션 계층
   
TransactionManager DataSource 서비스 추상화 계층
   
JDBC, JTA, ... 기술 서비스 계층

 

UserService, UserDao는 애플리케이션의 로직을 담고 있는 애플리케이션 계층이다.

UserService가 UserDao를 사용하고 있으며, 인터페이스와 DI를 통해 연결되어 낮은 결합도를 가지고 있다.

또한 UserDao는 DataSource 인터페이스를 통해 추상화하여 DB를 연결하고 있기 때문에 DB 연결을 생성하는 것에 독립적이며, UserService도 PlatformTransactionManager 인터페이스를 통한 추상화 계층으로 트랜잭션 기술에 독립적이다.

이후에 변경이 되더라도 해당 기술을 사용하는 코드들은 변할 필요가 없다는 것이다.

 

이러한 장점이 단일 책임 원칙을 잘 지켰기에 오는 것이다.

단일 책임 원칙은 '하나의 모듈은 한 가지 책임을 가져야 한다'는 의미이다.

 

기존의 UserService에서는 트랜잭션을 직접 관리하고 있었다.

그리고 JDBC transaction, JTA transaction 이렇게 트랜잭션 기술을 변경 할 때마다 UserService를 수정해야 했다.

하지만 트랜잭션의 추상화를 도입하고, 트랜잭션의 이유로 UserService가 변경될 일은 없다.

 

지금은 현재 User와 관련된 코드만 작성이 되어 있어서, 어쩌면 이렇게 개발하는 것보다는 그냥 그때그때 바꾸는게 더 빠를 수도 있다.

하지만 언제까지나 이런 프로젝트만 존재하지는 않는다.

프로젝트가 커질수록 이런 부분을 바꾸는 것은 거의 불가능에 가까울 것이다.

 

나중에 확장을 대비해서 DI와 추상화를 사용하여 객체지향적으로 개발할 수 있도록 하자.

728x90

백엔드 개발자로서 굉장히 신경썼던 부분 중 하나 일 것이다.

바로 transaction이다.

모든 작업은 원자성을 가져야하며, 만약 작업 중 문제가 발생했다면 해당 작업의 내용을 모두 rollback 해야 한다는 것이다.

 

모 아니면 도

해당 코드를 봐보자.

    public void upgradeLevels() {
        for(User user : userDao.getAll())
        {
            if(user.isUpgradeLevel())
                upgradeLevel(user);
        }
    }

 

    @BeforeEach
    public void setUp() {
        this.userService = applicationContext.getBean("userService", UserService.class);
        this.userDao = applicationContext.getBean("userDao", UserDao.class);
        users = Arrays.asList(
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.BRONZE).login(49).recommend(0).build(),
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.BRONZE).login(50).recommend(1).build(),
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(29).build(),
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(30).build(),
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.GOLD).login(100).recommend(100).build()
        );
    }

현재 해당 유저 중 2번, 4번의 유저가 업그레이드 대상이다.

 

하지만 만약 3번째 유저의 작업 수행 중 에러가 발생한다면 어떻게 될까?

이렇게 의도적으로 에러를 발생시키고, 데이터베이스를 확인해보았다.

2번 유저까지는 정상적으로 업그레이드가 되었지만, 업그레이드가 되어야 하는 4번 유저는 업그레이드가 되지 못한 것을 볼 수 있다.

 

모두 업그레이드가 진행되거나, 모두 업그레이드가 되지 않았어야 했지만 일부만 업그레이드가 된 것이다.

아마 바로 이유를 알 수 있을 것이다.

1~5번의 업그레이드가 같은 트랜잭션 내에서 이루어지지 않았기 때문이다.

 

트랜잭션 경계설정

가장 쉽게 생각할 수 있는 건, 이런 작업을 하나의 SQL로 작성하는 것이다.

하나의 SQL 명령을 사용하는 경우 DB가 트랜잭션을 보장해주기 때문이다.

 

하지만 때때로 다른 SQL에서 동작하더라도 트랜잭션으로 묶어야 하는 경우가 있다.

이런 경우에는 만약 도중에 에러가 발생하면, 실행했던 모든 작업들을 rollback하는 작업을 수행해야 한다.

그리고 모든 SQL이 성공한 경우에만, DB에게 알려줘 트랜잭션 커밋을 진행해야 한다.

 

트랜잭션을 적용하는 코드를 대충 만들어보자면, 다음과 같을 것이다.

Connection c = dataSource.getConnection();

c.setAutoCommit(false);

try{
	PreparedStatement st1 = c.preparedStatement("update users ...");
    st1.executeUpdate();
    
    PreparedStatement st2 = c.preparedStatement("update users ...");
    st2.executeUpdate();
    
    c.commit();
}

catch(Exception e){
	c.rollback();
}

c.close();

commit까지 try문 안에서 실행하고, 에러가 발생했다면 롤백하는 것이다.

 

그리고 어디서부터 트랜잭션을 시작하고, 끝낼지도 굉장히 중요한 포인트이다.

setAutoCommit(false)를 하는 이유는 JDBC에서 자동으로 커밋을 하기 때문에, st1만 실행되고 커밋이 될 수도 있다.

그것을 막기 위해 자동 커밋을 해제하고 트랜잭션을 시작하는 것이다.

이렇게 만들어야 여러개의 트랜잭션을 묶을 수 있다.

 

이제 다음 할 일은 당연하다.

upgradeLevels()에 전체적으로 트랜잭션을 설정하는 것이다.

보면 알 수 있겠지만, 트랜잭션은 connection 영역을 벗어나지 못한다.

그렇기 때문에 첫번째 유저부터 마지막 유저까지 데이터베이스에 저장되는 동안 하나의 connection이 유지되어야 한다.

 

그럼 일단 바로 드는 생각은 jdbcTemplate을 userService에서 가져와서 전체적으로 트랜잭션을 설정하는 것이다.

하지만 당연히 좋은 방법이 아니다.

userService는 비즈니스 로직인데, jdbcTemplate을 가져오는 순간 데이터 로직이 섞여 오염되기 때문이다.

 

이거를 그대로 두면서 upgradeLevels()에 트랜잭션을 설정하려면 Connection을 UserService가 시작해야 한다.

그리고 UserDao에 Connection을 계속 넘겨줘야 한다.

그리고 UserDao의 사용이 끝나면 Connection을 닫아주면 된다.

 

update에 오버로딩으로 다음 메서드를 추가한다.

    public void update(Connection c, User user) {

        try
        {
            PreparedStatement ps1 = c.prepareStatement(
                    "update users set name = ?, password = ?, level = ?, login = ?, recommend = ? " +
                            "where id = ?"
            );

            ps1.setString(1, user.getName());
            ps1.setString(2, user.getPassword());
            ps1.setInt(3, user.getLevel().getValue());
            ps1.setInt(4, user.getLogin());
            ps1.setInt(5, user.getRecommend());
            ps1.setString(6, user.getId());

            ps1.executeUpdate();
        }
        catch (SQLException e)
        {
            throw new RuntimeException(e);
        }
    }

 

autoCommit과 commit, rollback은 dao가 아닌 service에서 수행할 예정이다.

 

그리고 service도 다음과 같이 수정한다.

    public void upgradeLevels() {
        Connection c = null;
        try
        {
            c = dataSource.getConnection();
            c.setAutoCommit(false);
            for(User user : userDao.getAll())
            {
                if(user.isUpgradeLevel())
                    upgradeLevel(c, user);
            }
            c.commit();
        }
        catch (SQLException e) {
            if(c != null)
                try{
                    c.rollback();
                }
                catch (SQLException ex) {
                    throw new RuntimeException(ex);
                }
        }
        finally {
            if(c != null)
                try
                {
                    c.close();
                }
                catch (SQLException ignored)
                {
                    
                }
        }
    }

    private void upgradeLevel(Connection c, User user) {
        user.upgradeLevel();
        userDao.update(c, user);
    }

이렇게 각 update보다 더 윗단계인 upgradeLevels에서 트랜잭션을 관리해야, 모든 트랜잭션을 하나로 관리 할 수 있다.

 

하지만 이런 방식은 문제가 있다, 리소스를 깔끔하게 관리해주던 jdbcTemplate을 더 이상 사용할 수 없다.

그렇기에 try/catch/finally를 또 직접 관리해야 한다.

그리고 가장 큰 문제는 비즈니스 로직만 관리해야 하는 UserService에 데이터베이스의 연결 정보인 DataSource가 등장한다는 것이다.

그리고 이런 식으로 Connection이 변수로 사용된다면, 멀티 쓰레드 환경에서 문제가 발생 할 수도 있다.

 

트랜잭션 동기화

이렇게 Connection을 주고 받는 방법이 아닌, 다른 방법으로 해결해보자.

일단 이 트랜잭션을 update()가 아닌, upgradeLevels()가 관리해야 하는 것은 이제 알 수 있다.

 

이럴 때 스프링에서 사용하는 방법은 트랜잭션 동기화라고 한다.

Dao가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 한다는 것이다. 그리고 모든 트랜잭션이 종료되면, 그 때 동기화를 마치면 된다.

 

1. UserService가 Connection을 생성한다.

2. 이를 트랜잭션 동기화 저장소에 저장하고, auto commit을 해제하며, 트랜잭션을 시작한다.

3. jdbcTemplate에서는 먼저 트랜잭션 동기화 저장소(4.)에서 시작된 트랜잭션을 가진 connection이 존재하는지 확인한다. 만약 connection을 찾으면, 이를 사용한다.

5. connection을 이용해 PreparedStatement를 만들고, SQL을 실행한다. (이를 11까지 반복한다)

12. 트랜잭션이 모두 완료되었다면 commit을 호출해 트랜잭션을 완료시킨다.

13. 저장된 connection을 저장소에서 삭제한다.

만약 이 작업 중 에러가 발생하면 connection의 rollback을 호출해 트랜잭션을 복구하고 종료한다.

 

이 트랜잭션 동기화 저장소는 스레드마다 독립적으로 Connection 오브젝트를 저장한다.

그렇기에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌이 나지는 않는다.

 

이렇게 만들면 connection을 전달할 필요가 없고, 트랜잭션의 시작과 종료를 관리하는 upgradeLevels()에서 connection을 관리하고, dao에서 jdbcTemplate과 동기화만 한다면 끝난다.

 

스프링은 멀티 스레드 환경에서 트랜잭션 동기화 기능을 제공하는 메서드를 가지고 있다.

public void upgradeLevels() {
        TransactionSynchronizationManager.initSynchronization();
        Connection c = DataSourceUtils.getConnection(dataSource);

        try
        {
            c.setAutoCommit(false);
            List<User> users = userDao.getAll();

            for(User user: users)
            {
                if(user.isUpgradeLevel()) upgradeLevel(user);
            }
            c.commit();
        }
        catch (SQLException e)
        {
            try
            {
                c.rollback();
            } catch (SQLException ex) {
                throw new RuntimeException(ex);
            }
        }
        finally
        {
            //Connection을 안정적으로 종료
            DataSourceUtils.releaseConnection(c, dataSource);
            //현재 스레드의 트랜잭션 컨텍스트에서 DataSource 리소스를 제거
            TransactionSynchronizationManager.unbindResource(this.dataSource);
            //스레드의 트랜잭션 동기화 작업 종료 및 정리
            TransactionSynchronizationManager.clearSynchronization();
        }
    }

스프링에서 제공하는 트랜잭션 동기화 manager 클래스는 TransactionSynchronizationManager이다.

이 클래스를 이용해서 먼저 스레드에서 트랜잭션 동기화 작업을 초기화하도록 요청한다.

//사용 X
dataSource.getConnection();
//사용 O
Connection c = DataSourceUtils.getConnection(dataSource);

또한 기존과는 다르게 DataSourceUtils.getConnection(dataSource)를 사용한 것을 볼 수 있는데, 이 메서드는 Connection 오브젝트를 생성해주는 것 뿐만 아니라 트랜잭션 동기화를 사용하도록 저장소에 바인딩해준다.

 

public static Connection doGetConnection(DataSource dataSource) throws SQLException {
        Assert.notNull(dataSource, "No DataSource specified");
        ConnectionHolder conHolder = (ConnectionHolder)TransactionSynchronizationManager.getResource(dataSource);
        if (conHolder == null || !conHolder.hasConnection() && !conHolder.isSynchronizedWithTransaction()) {
            logger.debug("Fetching JDBC Connection from DataSource");
            Connection con = fetchConnection(dataSource);
            if (TransactionSynchronizationManager.isSynchronizationActive()) {
                try {
                    ConnectionHolder holderToUse = conHolder;
                    if (conHolder == null) {
                        holderToUse = new ConnectionHolder(con);
                    } else {
                        conHolder.setConnection(con);
                    }

                    holderToUse.requested();
                    TransactionSynchronizationManager.registerSynchronization(new ConnectionSynchronization(holderToUse, dataSource));
                    holderToUse.setSynchronizedWithTransaction(true);
                    if (holderToUse != conHolder) {
                        TransactionSynchronizationManager.bindResource(dataSource, holderToUse);
                    }
                } catch (RuntimeException ex) {
                    releaseConnection(con, dataSource);
                    throw ex;
                }
            }

            return con;
        } else {
            conHolder.requested();
            if (!conHolder.hasConnection()) {
                logger.debug("Fetching resumed JDBC Connection from DataSource");
                conHolder.setConnection(fetchConnection(dataSource));
            }

            return conHolder.getConnection();
        }
    }

 

내부적으로는 해당 메서드를 사용하는데, TransactionSynchronizationManager를 사용해 초기화 상태를 확인하고, register 하는 것을 볼 수 있다.

 

이렇게 바인딩 해두면, JdbcTemplate이 작업하는 경우 내부적으로

@Nullable
    private <T> T execute(PreparedStatementCreator psc, PreparedStatementCallback<T> action, boolean closeResources) throws DataAccessException {
        Assert.notNull(psc, "PreparedStatementCreator must not be null");
        Assert.notNull(action, "Callback object must not be null");
        if (this.logger.isDebugEnabled()) {
            String sql = getSql(psc);
            String var10001 = sql != null ? " [" + sql + "]" : "";
            this.logger.debug("Executing prepared SQL statement" + var10001);
        }

        Connection con = DataSourceUtils.getConnection(this.obtainDataSource());
        PreparedStatement ps = null;

        Object var17;
        try {
            ps = psc.createPreparedStatement(con);
            this.applyStatementSettings(ps);
            T result = (T)action.doInPreparedStatement(ps);
            this.handleWarnings((Statement)ps);
            var17 = result;
        } catch (SQLException var13) {
            if (psc instanceof ParameterDisposer parameterDisposer) {
                parameterDisposer.cleanupParameters();
            }

            if (ps != null) {
                this.handleWarnings(ps, var13);
            }

            String sql = getSql(psc);
            psc = null;
            JdbcUtils.closeStatement(ps);
            ps = null;
            DataSourceUtils.releaseConnection(con, this.getDataSource());
            con = null;
            throw this.translateException("PreparedStatementCallback", sql, var13);
        } finally {
            if (closeResources) {
                if (psc instanceof ParameterDisposer) {
                    ParameterDisposer parameterDisposer = (ParameterDisposer)psc;
                    parameterDisposer.cleanupParameters();
                }

                JdbcUtils.closeStatement(ps);
                DataSourceUtils.releaseConnection(con, this.getDataSource());
            }

        }

        return (T)var17;
    }

 

이렇게 DataSourceUtils.getConnection()으로 트랜잭션을 가져오며 작업하게 된다.

만약 트랜잭션이 존재하지 않는다면, 새롭게 커넥션을 생성해서 작업한다고 한다.

 

이렇게 코드를 작성한다면, upgradeLevels()의 모든 작업을 한 트랜잭션 내에서 수행 할 수 있다.

 

그렇게 해당 테스트를 수행해보았다.

    @BeforeEach
    public void setUp() {
        this.userService = applicationContext.getBean("userService", UserService.class);
        this.userDao = applicationContext.getBean("userDao", UserDao.class);
        users = Arrays.asList(
                User.builder().id("1").name(uuidHelper()).password("p1").level(Level.BRONZE).login(49).recommend(0).build(),
                User.builder().id("2").name(uuidHelper()).password("p1").level(Level.BRONZE).login(50).recommend(1).build(),
                User.builder().id("3").name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(29).build(),
                User.builder().id("4").name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(30).build(),
                User.builder().id("5").name(uuidHelper()).password("p1").level(Level.GOLD).login(100).recommend(100).build()
        );
    }

    @Test
    public void upgradeLevels() {
        userDao.deleteAll();

        for(User user : users) userDao.add(user);

        try{
            userService.upgradeLevels();
        }
        catch (Exception ignored){

        }
        Assertions.assertEquals(Level.BRONZE, userDao.get(users.get(0).getId()).getLevel());
        Assertions.assertEquals(Level.BRONZE, userDao.get(users.get(1).getId()).getLevel());
        //3번 유저의 작업에서 에러가 발생
        Assertions.assertEquals(Level.SILVER, userDao.get(users.get(2).getId()).getLevel());
        Assertions.assertEquals(Level.SILVER, userDao.get(users.get(3).getId()).getLevel());
        Assertions.assertEquals(Level.GOLD, userDao.get(users.get(4).getId()).getLevel());
    }

이렇게 정상적으로 테스트가 통과되는 것을 볼 수 있었다.

 

이렇게 외부에서 트랜잭션을 관리하더라도 UserDao를 수정할 필요가 없는 이유는 JdbcTemplate이 지원하는 기능 때문이다.

 

트랜잭션 동기화 저장소에 등록된 DB 커넥션이 없는 경우에는 직접 트랜잭션을 시작하고, 트랜잭션 동기화 작업이 시작되었다면 그때부터 실행되는 JdbcTemplate의 메서드는 트랜잭션 동기화 저장소에서 DB 커넥션을 가져와서 사용한다.

이렇게 JdbcTemplate은 try/catch/finally 작업 흐름 지원, SQLException의 예외 변환과 함께 트랜잭션 관리의 3가지 유용한 기능을 제공한다.

 

트랜잭션 서비스 추상화

거의 생길 경우가 없지만, 만약 한 프로젝트에서 여러개의 DB를 사용한다면?

지금은 데이터베이스의 커넥션 내에서 작업을 하기 때문에, 지금 상황으로는 여러개의 DB를 사용하면 같은 트랜잭션으로 묶을 수 없다.

이런 경우에는 별도로 트랜잭션을 관리하는 글로벌 트랜잭션을 사용해야 한다고 한다.

 

이 때 사용하는 트랜잭션이 JTA(Java Transaction API)라고 한다.

트랜잭션을 JDBC에게 맡기는 게 아니라, JTA를 통해 트랜잭션을 관리하도록 위임해야 한다.

이렇게 JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.

 

JTA를 사용하는 구조는 다음과 같다.

InitialContext initialContext = new InitialContext();
UserTransaction userTransaction = (UserTransaction)ctx.lookup("java:comp/UserTransaction");

userTransaction.begin();
Connection c = dataSource.getConnection();

try
{
	//코드
    userTransaction.commit();
}
catch(Exception e)
{
	userTransaction.rollback();
    throw e;
}
finally
{
	c.close();
}

어쨋든 트랜잭션이 데이터 접근 밖에 위치해야 하는거는 같다.

그냥 사용하는 것을 Connection이 아니라, UserTransaction을 사용하는 것 뿐이다.

 

그럼 이제 모든 UserService의 코드를 변경해야 할까?

현재 UserService는 UserDaoImpl이 아닌, UserDao에 의존하고 있기에 OCP를 지키며 구현이 바뀌어도 UserService는 영향을 받지 않았다.

당연히 UserService도 그렇게 만들어야 한다.

사실 이러한 부분도 공통적으로 등장하는 코드일 것이다.

유사한 구조가 반복되는 구조이기에 추상화를 고려해 볼 수 있다.

 

스프링에서는 이미 트랜잭션 기술들의 추상화가 존재한다.

PlatformTransactionManager 인터페이스이다.

 

public interface PlatformTransactionManager extends TransactionManager {
    TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;

    void commit(TransactionStatus status) throws TransactionException;

    void rollback(TransactionStatus status) throws TransactionException;
}

해당 인터페이스는 이렇게 존재한다.

 

이 중에서 우리는 지금 JTA를 사용하고 있기에, JtaTransactionManager()를 사용했다.

    public void upgradeLevels() {

        PlatformTransactionManager transactionManager = new JtaTransactionManager();

        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try
        {
            List<User> users = userDao.getAll();

            for (User user : users) {
                if (user.isUpgradeLevel()) upgradeLevel(user);
            }
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            throw e;
        }
    }

 

여기서 getTransaction()을 호출하면, 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 수행해준다.

이렇게 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장된다.

트랜잭션의 모든 작업이 끝나면 돌려받은 TransactionStatus 오브젝트를 파라미터로 PlatformTransactionManager의 commit을 호출한다.

getTransaction()의 내부적으로

    protected void prepareSynchronization(DefaultTransactionStatus status, TransactionDefinition definition) {
        if (status.isNewSynchronization()) {
            TransactionSynchronizationManager.setActualTransactionActive(status.hasTransaction());
            TransactionSynchronizationManager.setCurrentTransactionIsolationLevel(definition.getIsolationLevel() != -1 ? definition.getIsolationLevel() : null);
            TransactionSynchronizationManager.setCurrentTransactionReadOnly(definition.isReadOnly());
            TransactionSynchronizationManager.setCurrentTransactionName(definition.getName());
            TransactionSynchronizationManager.initSynchronization();
        }

    }

트랜잭션을 TransactionSynchronizationManager에 등록하는 작업을 수행하기에 JdbcTemplate이 여기서 가져다가 쓰는 것이다.

 

이렇게 새로 생성하던 JtaTransactionManager도 생성자를 통해 주입받도록 하자.

@RequiredArgsConstructor
public class UserServiceImpl implements UserService{

    private final UserDao userDao;

    private final PlatformTransactionManager transactionManager;

}
    <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource"/>
    </bean>

    <bean id="userService" class="seungkyu.UserServiceImpl">
        <constructor-arg ref="userDao" />
        <constructor-arg ref="transactionManager"/>
    </bean>

 

이렇게해서 어떤 식으로 transaction이 적용되는지 알 수 있었다.

굉장히 중요한 부분이라고 생각되어, 내부적인 동작까지 살펴보았다.

앞으로는 내부까지 살펴볼 일은 없겠지만, 아마 @Transactional도 비슷한 동작을 프록시로 처리한 것이라고 생각한다.

 

728x90

기존에 작성한 UserDao는 단순히 JDBC를 사용하여 데이터를 읽고 쓰는 클래스였다.

이것을 이용해 사용자를 관리하는 서비스를 간단하게 만들어보자.

 

만들 기능들은 다음과 같다.

  • 사용자의 레벨은 BRONZE, SILVER, GOLD 3가지이다.
  • 처음 가입하면 BRONZE 레벨이다.
  • 가입 후 로그인을 50회 이상 하면 BRONZE -> SILVER로 승급한다.
  • SILVER 레벨에서 30번 이상 좋아요를 받으면 GOLD로 승급한다.
  • 승급은 즉시 일어나는게 아니라, 배치로 일괄적으로 진행된다.

 

필드 추가

일단 레벨을 나타낼 enum을 만들자.

이런 값들을 나타내는 가장 편한 방법은 역시 enum일 것이다.

package seungkyu;

import lombok.Getter;

@Getter
public enum Level {

    BRONZE(1),
    SILVER(2),
    GOLD(3);

    private final int value;

    Level(int value) {
        this.value = value;
    }

    public static Level valueOf(int value) {
        return switch (value) {
            case 1 -> BRONZE;
            case 2 -> SILVER;
            case 3 -> GOLD;
            default -> throw new IllegalArgumentException("Invalid level value: " + value);
        };
    }
}

이렇게 int를 value로 가지는 enum을 만들어준다.

 

그리고 이런 필드를 가질 수 있도록 User 클래스도 수정해준다.

@Builder
@Getter
@Setter
@EqualsAndHashCode
public class User {

    private Level level;
    private Integer login;
    private Integer recommend;
    private String id;
    private String name;
    private String password;
}

데이터베이스에도 column들은 추가해둔다.

 

    @Test
    public void addAndGet(){
        Assertions.assertEquals(0, userDao.getCount());

        User createdUser = User.builder()
                .id("seungkyu")
                .name("승규")
                .password("password")
                .level(Level.BRONZE)
                .recommend(1)
                .login(10)
                .build();
        
        userDao.add(createdUser);
        Assertions.assertEquals(1, userDao.getCount());

        User readUser = userDao.get("seungkyu");

        Assertions.assertEquals(createdUser, readUser);
    }

그리고는 이렇게 테스트를 수행해보면 통과하지 못한다.

 

왜냐하면 데이터메이스에는 level, login, recommend 값을 저장하고 조회하지 않기 때문이다.

일단 나머지 테스트 코드도 해당 필드를 가지도록 모두 수정해준다.

 

그렇기에 UserDao도 같이 수정해준다.

그냥 단순히 Mapper를 수정해주고

    private final RowMapper<User> userRowMapper = (rs, rowNum) -> User.builder()
            .id(rs.getString("id"))
            .name(rs.getString("name"))
            .password(rs.getString("password"))
            .login(rs.getInt("login"))
            .level(Level.valueOf(rs.getInt("level")))
            .recommend(rs.getInt("recommend"))
            .build();

 

insert문도 추가해서 저장해준다.

    public void add(User user){
        this.jdbcTemplate.update(
                "insert into users(id, name, password, level, login, recommend) values(?, ?, ?, ?, ?, ?)",
                user.getId(),
                user.getName(),
                user.getPassword(),
                user.getLevel().getValue(),
                user.getLogin(),
                user.getRecommend()
        );
    }

mapper도 저렇게 분리하지 않았었다면, 아마 한 번에 수정하지 못해 힘들었을 것이다.

또한, 데이터베이스에는 enum을 저장하지 못하기에 level에 해당하는 값은 int로 바꾸어 저장해준다.

 

그러고 테스트를 돌려보면 통과하는 것을 볼 수 있다.

 

사용자 수정 기능 추가

사용자의 정보는 ID를 제외하고 언제든지 수정이 가능하다.

TDD의 느낌으로 일단 수정 관련 테스트 먼저 만들고 시작한다.

@Test
    public void update(){
        userDao.deleteAll();

        var originalName = UUID.randomUUID().toString().substring(0, 6);
        var newName = UUID.randomUUID().toString().substring(0, 6);
        var originalLevel = Level.BRONZE;
        var newLevel = Level.GOLD;

        User modifyUser = User.builder()
                .id("seungkyu")
                .name(originalName)
                .password("password")
                .level(originalLevel)
                .recommend(1)
                .login(1)
                .build();

        User unmodifyUser = User.builder()
                .id("seungkyu1")
                .name(originalName)
                .password("password")
                .level(originalLevel)
                .recommend(1)
                .login(1)
                .build();

        userDao.add(modifyUser);
        userDao.add(unmodifyUser);

        modifyUser.setName(newName);
        modifyUser.setLevel(newLevel);

        userDao.update(modifyUser);

        Assertions.assertEquals(modifyUser, userDao.get("seungkyu"));
        Assertions.assertEquals(unmodifyUser, userDao.get("seungkyu1"));
    }

 

다른 사용자들은 변경되면 안되기 때문에 이렇게 테스트를 작성했다.

이렇게 일단 만들고 update 메서드를 만들러 가보자.

 

public void update(User user) {
        this.jdbcTemplate.update(
                "update users set name = ?, password = ?, level = ?, login = ?, recommend = ? " +
                        "where id = ?",
                user.getName(),
                user.getPassword(),
                user.getLevel().getValue(),
                user.getLogin(),
                user.getRecommend(),
                user.getId()
        );
    }

MySQL의 간단한 쿼리이기에 어렵지는 않을 것이다.

 

UserService.upgradeLevels()

데이터베이스에 접근하는 부분은 Dao로 작성하고 있다.

만약 레벨을 올리는 로직이 필요하다면, UserDao에 레벨만 올리는 코드를 작성해야 할까?

컬럼 하나만을 바꾸기 위해 쿼리를 또 작성해야 할 것이다.

 

그렇게 개발하기보다 사용자의 관리 로직을 따로 빼는 것이 더 적당해보인다.

비즈니스 로직 서비스를 제공한다는 의미로 UserService로 파일을 만든다.

UserService는 UserDao 인터페이스 타입으로 UserDao 빈을 DI 받아 사용하게 만든다.

또한 개발자답게 UserService에 대한 테스트도 작성해야 한다.

그렇기에 의존관계를 정리해보면 다음과 같을 것이다.

늘 그렇듯 일단 테스트코드부터 만들어준다.

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"/test-applicationContext.xml"})
public class UserServiceTest {

    private UserService userService;
    List<User> users;

    @Autowired
    private ApplicationContext applicationContext;
    @Autowired
    private UserDao userDao;

    private String uuidHelper(){
        return UUID.randomUUID().toString().substring(0, 6);
    }

    @BeforeEach
    public void setUp() {
        this.userService = applicationContext.getBean("userService", UserService.class);
        this.userDao = applicationContext.getBean("userDao", UserDao.class);
        users = Arrays.asList(
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.BRONZE).login(49).recommend(0).build(),
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.BRONZE).login(50).recommend(1).build(),
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(29).build(),
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(30).build(),
                User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.GOLD).login(100).recommend(100).build()
        );
    }

    @Test
    public void upgradeLevels() {
        userDao.deleteAll();

        for(User user : users) userDao.add(user);

        userService.upgradeLevels();

        Assertions.assertEquals(Level.BRONZE, userDao.get(users.get(0).getId()).getLevel());
        Assertions.assertEquals(Level.SILVER, userDao.get(users.get(1).getId()).getLevel());
        Assertions.assertEquals(Level.SILVER, userDao.get(users.get(2).getId()).getLevel());
        Assertions.assertEquals(Level.GOLD, userDao.get(users.get(3).getId()).getLevel());
        Assertions.assertEquals(Level.GOLD, userDao.get(users.get(4).getId()).getLevel());
    }

}

UserDao와 UserService를 주입해주고, 각 유저들을 만든 뒤 upgradeLevels를 호출해 업데이트가 되는지 확인한다.

 

@RequiredArgsConstructor
public class UserServiceImpl implements UserService{

    private final UserDao userDao;

    public void upgradeLevels() {
        List<User> users = userDao.getAll();

        for(User user : users)
        {
            Boolean changed = null;
            if(user.getLevel() == Level.BRONZE && user.getLogin() >= 50)
            {
                user.setLevel(Level.SILVER);
                changed = true;
            }
            else if(user.getLevel() == Level.SILVER && user.getRecommend() >= 30)
            {
                user.setLevel(Level.GOLD);
                changed = true;
            }
            else if(user.getLevel() == Level.GOLD)
            {
                changed = false;
            }
            else
            {
                changed = false;
            }
            if(changed)
                userDao.update(user);
        }
    }
}

그리고 이렇게 레벨을 변경하는 service를 만든다.

이렇게 레벨만을 올리기 위해 쿼리를 작성하기는 힘들고, 해당 쿼리로 조건을 체크해서 레벨을 올려야 한다.

이런 방법 보다는 service layer에서 dao를 통해 조회한 데이터를 확인해 다시 dao로 저장하는 이런 비즈니스 로직을 분리하는 것이 더 좋다는 판단이 들었다.

 

UserService.add()

사용자를 처음 생성한다면 BRONZE 레벨로 만들어주어야 한다.

처음 생각으로는 UserDao에 add 부분을 수정해서 기본적으로 BRONZE 레벨을 줄 수 있지만, UserDao는 데이터베이스에만 관심을 가지고 비즈니스 로직은 위와같이 분리하는 것이 맞다고 생각이 들었다.

그렇기에 비즈니스 로직을 담당하는 UserService에서 해결해보자.

 

늘 그렇듯 테스트먼저 작성한다.

    @Test
    public void add(){
        userDao.deleteAll();

        User userWithLevel = users.get(4);
        User userWithoutLevel = users.get(0);
        userWithoutLevel.setLevel(null);

        userService.add(userWithLevel);
        userService.add(userWithoutLevel);

        User userWithLevelRead = userDao.get(userWithLevel.getId());
        User userWithoutLevelRead = userDao.get(userWithoutLevel.getId());

        Assertions.assertEquals(userWithLevel.getLevel(), userWithLevelRead.getLevel());
        Assertions.assertEquals(Level.BRONZE, userWithoutLevelRead.getLevel());
    }

만약 처음 등록되는 유저로 level이 Null이라면 Bronze가 들어가있어야 한다.

 

그렇기에 userService 단계에서 level을 확인하고 null이면 초기값을 설정해준다.

    public void add(User user) {
        if(user.getLevel() == null)
            user.setLevel(Level.BRONZE);
        userDao.add(user);
    }

간단한 로직이다.

 

테스트도 간단히 통과하는 것을 볼 수 있다.

 

코드 개선

코드를 작성하고 나서는 항상 스스로에게 더 나은 코드를 만들 수 있는지 질문을 해보면 좋다고 한다.

    public void upgradeLevels() {
        List<User> users = userDao.getAll();

        for(User user : users)
        {
            Boolean changed = null;
            if(user.getLevel() == Level.BRONZE && user.getLogin() >= 50)
            {
                user.setLevel(Level.SILVER);
                changed = true;
            }
            else if(user.getLevel() == Level.SILVER && user.getRecommend() >= 30)
            {
                user.setLevel(Level.GOLD);
                changed = true;
            }
            else if(user.getLevel() == Level.GOLD)
            {
                changed = false;
            }
            else
            {
                changed = false;
            }
            if(changed)
                userDao.update(user);
        }
    }

현재 UserService의 upgradeLevels()이다.

 

보면 알겠지만, 굉장히 읽기 불편하다.

개선이 필요하다.

 

우선 코드 자체가 업그레이드가 가능한지 묻는 조건문과 사용자를 업데이트하는 로직이 섞여있다.

이 로직을 업그레이드가 가능한지 확인하는 로직과 업그레이드를 하는 로직으로 분리해보자.

 

private boolean isUpgradeLevel(User user) {
        Level level = user.getLevel();
        return switch (level) {
            case BRONZE -> user.getLogin() >= 50;
            case SILVER -> user.getRecommend() >= 30;
            case GOLD -> false;
        };
    }

이렇게 업그레이드가 가능한지 확인하는 isUpgradeLevel()

 

    private void upgradeLevel(User user) {
        user.setLevel(Level.from(user.getLevel().getValue() + 1));
        userDao.update(user);
    }

 

직접 레벨을 올려주는 upgradeLevel()이다.

 

그에따라 upgradeLevels()도 이렇게 수정이 가능하다.

    public void upgradeLevels() {
        for(User user : userDao.getAll())
        {
            if(isUpgradeLevel(user))
                upgradeLevel(user);
        }
    }

코드가 간결해지기도 했지만, 두개의 관심사(확인, 업그레이드)가 분리되어 각각의 메서드에서 처리하고 있는 것이 더 장점이다.

 

그런 관점에서 보면 다시 upgradeLevel()을 확인해보자.

    private void upgradeLevel(User user) {
        user.setLevel(Level.from(user.getLevel().getValue() + 1));
        userDao.update(user);
    }

사용자의 레벨을 올리는 메서드는 여기서 할 작업은 아닌 거 같다.

 

그렇기에 nextLevel로 설정하는 메서드는 User로 넘기도록 한다.

@Builder
@Getter
@Setter
@EqualsAndHashCode
public class User {
    private Level level;
    private Integer login;
    private Integer recommend;
    private String id;
    private String name;
    private String password;

    public void nextLevel(){
        this.setLevel(Level.from(this.getLevel().getValue() + 1));
    }
}
    private void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
    }

 

그에따라 또 분리할 수 있게 되었다.

만약 각 단계마다 추가적으로 해야할 작업이 생긴다면 그 메서드에 추가하면 되기에 관심사에 맞게 작업 할 수 있다.

이렇게 객체지향적인 코드는 다른 오브젝트의 데이트를 직접 수정하는 것이 아니라, 해당 오브젝트에게 작업을 요청한다고 한다.

그러면 이제 진짜 마지막으로 또 수정할 부분이 보인다.

    private boolean isUpgradeLevel(User user) {
        Level level = user.getLevel();
        return switch (level) {
            case BRONZE -> user.getLogin() >= 50;
            case SILVER -> user.getRecommend() >= 30;
            case GOLD -> false;
        };
    }

이 부분도 사실 UserService가 아닌, User에 있어야 하는 코드인 것이다.

 

    public void upgradeLevels() {
        for(User user : userDao.getAll())
        {
            if(user.isUpgradeLevel())
                upgradeLevel(user);
        }
    }

 

해당 메서드를 User로 옮기고 이렇게 수정해줬다.

 

User의 테스트도 만들어보자.

테스트는 어차피 꾸준하게 사용하니, 간단하더라도 추가해두는 것이 좋다.

class UserTest {

    private User user;

    @BeforeEach
    void setUp() {
        user = User.builder()
                .id(UUID.randomUUID().toString().substring(0, 8))
                .name("seungkyu")
                .password("123456")
                .login(1)
                .recommend(2)
                .level(Level.BRONZE)
                .build();
    }

    @Test
    public void upgradeLevelTest(){
        user.upgradeLevel();
        
        Assertions.assertEquals(Level.SILVER, user.getLevel());
    }
}

User는 스프링 빈이 아니기 때문에 SpringExtension을 추가해주지 않아도 괜찮다.

 

또한 User로 다시 돌아가보자.

    public boolean isUpgradeLevel() {
        Level level = this.level;
        return switch (level) {
            case BRONZE -> this.login >= 50;
            case SILVER -> this.recommend >= 30;
            case GOLD -> false;
        };
    }

지금은 조건을 이렇게 50, 30과 같은 수로 나타내고 있다.

하지만 나중에 변경될 수도 있으며, 이 조건을 외부에서 사용할 경우도 있을 것이다.

그렇기에 이렇게 숫자로 나타내는 것이 아닌 상수로 만들어주는 것이 좋다.

 

해당 조건의 값

public class UserServiceImpl implements UserService{

    public static final int SILVER_LOGIN_COUNT = 50;
    public static final int GOLD_RECOMMEND_COUNT = 30;
    
}

은 비즈니스 로직에 있는것이 맞다고 생각하여 UserService에 상수로 선언하였다.

 

그리고 User 클래스에서 값들에 맞추어 코드를 변경해준다.

    public boolean isUpgradeLevel() {
        Level level = this.level;
        return switch (level) {
            case BRONZE -> this.login >= SILVER_LOGIN_COUNT;
            case SILVER -> this.recommend >= GOLD_RECOMMEND_COUNT;
            case GOLD -> false;
        };
    }

 

이번 글에서는 비즈니스 로직에 맞추어 service layer를 추가했다.

그리고 관심사에 맞추어 최대한 로직을 분리함으로 객체지향에 맞추어 수정했다.

 

유지보수 단계로 넘어가게 된다면, 객체지향적으로 설계하는 것이 굉장히 큰 장점이 되었다.

앞으로도 관심사에 맞추어 최대한 객체지향적으로 개발을 해보도록 하자.

 

728x90

4.1에서 예외의 전환 이유는 보통 2가지라고 말했었다.

하나는 예외를 더 구체적이고 의미 있도록 바꿔주는 것이고, 다른 하나는 런타임 예외로 바꾸어 catch를 줄여주는 것이었다.

 

JdbcTemplate은 SQLException을 DataAccessException등으로 바꿔줬었다.

여기에서도 예외를 더 구체적으로 알 수 있게 했으며, 런타임 예외로 바꾸던 것이다.

 

JDBC의 한계

JDBC는 데이터베이스에 접근하여 데이터를 가져오고 수정할 수 있기에 자바의 가장 많이 사용되는 API 중 하나라고 한다.

 

//MSSQL
SELECT TOP 5 * FROM USERS;	

//MYSQL
SELECT * FROM USERS LIMIT 5;

 

하지만 이렇게 SQL 중에서도 Database마다 다른 문법이 존재한다. 그럼 우리가 만든 UserDao는 MySQL이라는 데이터베이스에 종속되어 버리며, 다른 데이터베이스로 전환은 거의 불가능해진다. 그리고 여기서 발생하는 SQLException의 에러 정보도 DB마다 다르다.

if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)

이런 코드가 있다면 MySQL 전용코드가 되어버리기에 여기에서도 MySQL에 종속되는 것이다. 만약 데이터베이스가 MySQL에서 MSSQL로 바뀐다면 현재 사용하는 query도 사용하지 못하고, 에러코드도 제대로 동작하지 못하는 것이다.

 

그렇기에 SQLException은 예외가 발생한 경우 DB의 상태 정보를 가져올 수 있도록 한다. getSQLState() 메서드를 통해 상태정보를 가져올 수 있도록 한다. 이때는 DB별로 다른 에러 코드를 통합하기 위해, Open Group의 XOPEN SQL 스펙의 SQL 상태코드를 따르도록 한다고 한다.

 

DB 에러 코드 매핑을 통한 전환

사용하는 DB를 바꾸더라도, 에러코드를 바꾸지 않으려면 이런 에러코드를 원하는 에러로 가져올 수 있도록 해야한다. 그렇기에 DB별 에러 코드를 참고해서 원하는 예외로 바꾸어야 하는 것이다.

 

오라클에서 PK 중복으로 발생하는 에러코드는 1이다.

이 에러코드를 duplicateKeyCodes로 변경해야 하는 것이다.

 

그렇기에 이런 에러 코드를 스프링의 예외 클래스로 매핑할 수 있도록 xml 파일을 작성할 수 있다.

<bean id="Oracle" class="org.springframework.jdbc.support.SQLErrorCodes">
	<property name="duplicateKeyCodes">
    	<value>1</value>
    </property>
</bean>

 

이렇게 작성해서 매핑한다.

 

JDBCTemplate 자체가 다 런타임 예외라서 예외를 많이 신경쓰지 않지만, 그럼에도 처리하고 싶은 예외가 있다면 이렇게 예외를 가져와서 처리할 수 있는 것이다.

 

DAO 인터페이스와 DataAccessException 계층구조

이렇게 Dao를 만들어서 사용하는 이유가 뭘까?

데이터베이스에 접근하는 로직을 분리하기 위해서다. 데이터 액세스 기술을 신경쓰지 않고, 그냥 단순히 Dao를 가져가서 사용하도록 만드는 것이다.

그렇기에 UserDao 인터페이스를 만들고, DI를 통해 주입받아서 사용하며 클라이언트에게 감추며 만드는 것이다.

public interface UserDao{
	public void add(User user);
}

 

이렇게 인터페이스를 만들 수 있을까?

지금은 JDBC를 사용하기에 별다른 예외가 발생하지 않아 가능하지만, 위 인터페이스는 구현 메서드에서 에러가 발생하기라도 하면 사용할 수 없는 인터페이스이다.

데이터 엑세스 기술에 따라 각각 다른 예외를 던지기 때문이다. 그렇다고 해서 모두를 포괄하는 Exception을 throws 할 수도 없을 것이다.

 

그렇기에 위의 인터페이스를 사용하려면 구현 메서드에서 체크 예외가 아닌 모두 런타임 예외를 사용해야 한다. 만약 체크 예외가 발생한다면 런타임 예외로 전환해주어야 할 것이다.

 

그럼 이제 add(User user) 메서드를 사용하면 체크 예외를 처리할 필요가 없을텐데, 그러면 모든 예외를 무시해도 되는가?

당연히 아닐 것이다.

JdbcTemplate은 에러가 발생하면 그에 맞는 DataAccessException의 서브 클래스로 바꿔준다. 그렇기에 그 계층 구조를 파악하고 있다가 처리 가능한 예외는 시도해 볼 수 있는 것이다.

 

DataAccessException의 서브 클래스들을 어느정도는 파악하고 있다가, 처리 가능한 예외는 처리해보도록 하자.

 

기술에 독립적인 UserDao 만들기

지금까지 개발했던 UserDao를 인터페이스랑 분리래보자.(사실 원래 이렇게 했어야 하는데...)

public interface UserDao {

    void add(User user);
    User get(String id);
    List<User> getAll();
    void deleteAll();
    int getCount();
}

 

public class UserDaoImpl implements UserDao {
}

 

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="
           http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id="dataSource"
          class = "org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.cj.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost:3306/test"/>
        <property name="username" value="root"/>
        <property name="password" value="1204"/>
    </bean>

    <bean id="jdbcContext"
          class = "seungkyu.JdbcContext">
        <constructor-arg ref="dataSource"/>
    </bean>

    <bean id="userDao" class="seungkyu.UserDaoImpl">
        <constructor-arg ref="dataSource" />
    </bean>

</beans>
    @BeforeEach
    public void setUp() {
        this.userDao = applicationContext.getBean("userDao", UserDao.class);
        userDao.deleteAll();
    }

 

마지막 코드는 테스트 클래스의 일부인데, 여기서도 UserDaoImpl이 아닌 UserDao로 가져오는 것을 볼 수 있다.

빈의 이름은 인터페이스로 만들고, 그 구현체를 등록하는 방법을 사용해야 의존성이 낮아지기 때문이다.

이렇게 의존하고 있어야 나중에 편하게 구현체를 변경 가능하다.

 

그대로 테스트를 수행해보니

성공적으로 통과하는 것을 볼 수 있다.

 

이렇게 JDBC에서 발생하는 에러도 꼼꼼하게 테스트하면서 개발 할 수 있도록 하자.

728x90

이전에는 jdbcTemplate으로 쿼리를 교체 했었는데, 의도하지 않았지만 같이 달라진 부분이 있다.

바로 throws에 던지는 예외들이 사라졌다.

 

기존에는

public void deleteAll() throws SQLException{
	jdbcContext.executeSql("delete from users");
}

이렇게 작성했지만

 

지금은

public void deleteAll(){
	jdbcTemplate.update("delete from users");
}

이렇게 throws가 사라진 것을 볼 수 있다.

 

예외의 종류와 특징

자바에서 예외는 크게 3가지라고 한다.

  • Error

주로 JVM에서 발생하는 에러이기에 애플리케이션 코드로 잡을 수는 없다.

이 예외는 처리할 필요도 할 수도 없다고 한다.

  • Exception과 체크 예외

java.lang.Exception을 상속받아 만든 클래스들이다. 개발자가 작성한 애플리케이션 코드에서 예외가 발생했음을 알리는 것이며 Exception 클래스는 다시 체크 예외와 언체크 예외로 구분된다. 체크 예외는 RuntimeException을 상속받지 않은 클래스, 언체크 예외는 RuntimeException을 상속받은 클래스이다. 체크 예외가 발생할 수 있는 메서드를 사용하면 catch로 감싸던지, throws로 밖에 던지든지 해야한다.

  • RuntimeException과 언체크 예외

java.lang.RuntimeException 클래스를 상속한 예외들은 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외라고 불린다. 주로 프로그램의 오류가 있을 때 발생하도록 의도되었으며, NullPointerException이 해당된다.

 

예외처리 방법

이렇게 예외의 종류를 알아보았으니, 예외를 처리하는 방법들에 대하여 알아보자.

크게 3가지가 존재한다.

  • 예외 복구

예외의 상황을 파악하고, 문제를 해결해서 돌려놓는 것이다.

만약 데이터베이스에 접근해야 하는데, 네트워크 관련으로 문제가 발생한다. 이런 경우에 예외 처리 부분에서 직접 연결의 재시도를 수행한다면, 이것은 예외 복구에 해당하는 것이다. 물론 일정한 횟수까지만 재시도를 시도해야 한다.

  • 예외처리 회피

예외처리를 자신이 담당하지 않고, 자신을 호출한 쪽으로 던져버리는 것이다. 물론 무책임하게 계속 상위 메서드로 보내라는 것이 아니다. JdbcTemplate도 SQLException을 거기서 처리하지 않고 던져주고 있다. 템플릿에서 처리할 문제가 아니라고 생각하기 때문이다. 무조건 발생한 곳에서 처리하는 것이 아닌, 상위 메서드로 보내서 적절한 곳에서 처리하는 것도 좋은 방법이다.

  • 예외 전환

예외를 회피하기보다 다른 예외로 전환하는 방식이다.

사용하는 이유는 크게 2가지이다. 첫째는 해당 예외 상황을 더 자세하게 말해주도록 커스텀 예외를 만들어서 전환하는 경우, 둘째는 체크 예외를 언체크 예외르 바꾸는 경우에 사용한다.

 

대부분 서버에서는 처리되지 않은 예외를 일괄적으로 다룰 수 있는 기능을 제공한다. 이렇게 처리되지 못한 에러들을 관리자에게 메일로 통보해주고, 사용자에게 에러메시지를 적절하게 보여주는 것이 가장 바람직한 방법이다.

 

예외처리 전략

사실 지금까지 계속 자바의 예외에 대하여 공부하고 있지만, 사실 이 자바를 스프링에서 사용하기 위함이었다. 스프링은 보통 하나의 요청에 대해 하나의 응답을 주고 있는데, 이 중간에 에러가 발생해도 사용자와 상호작용이 불가능하다. 그렇기에 사실상 대부분의 예외가 처리가 불가능하기에 체크 예외가 굳이 필요하지 않다는 것이다. 차라리 그런 예외 상황을 만들지 않는 것이 더 중요하다고 한다. 어쩌면 언체크 예외로 한번에 처리하는 것이 더 좋을 수도 있다.

 

public void add(User user) throws DuplicateUserIdException, SQLException{
	try{
    	//
    }
    catch (SQLException e){
    	if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
        	throw DuplicateUserIdException();
        else
        	throw e;
    }
}

여기 수정된 add() 메서드를 보면 DuplicatedUserIdException, SQLException 두 가지의 예외를 던지고 있다.

중복이 발생한 경우에는 DuplicatedUserIdException이 더 명확한 의미를 지니기에 해당 예외로 전환했다.

그리고 DuplicatedUserIdException은 복구가 가능하지만, SQLException은 보통 복구가 불가능하기에 런타임 예외로 그냥 던져버리는게 나을 수도 있다.

 

그렇기에 예외처리 전략을 수정해서 다음과 같이 바꿀 수 있다.

public void add(User user) throws DuplicateUserIdException{
	try{
    	//
    }
    catch (SQLException e){
    	if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
        	throw new DuplicateUserIdException(e);
        else
        	throw new RuntimeException(e);
    }
}

의미를 가지는 DuplicateUserIdException을 제외하고는 언체크 예외가 되었다.

이제 add()를 사용하는 곳에서는 더 이상 불필요하게 SQLException을 처리할 필요가 없으며 DuplicateUserIdException은 핸들링 할 수 있다.

 

이렇게 복구할 수 없다고 생각하고 그냥 낙관적으로 생각해버리는 것이 런타임 예외 중심 전략이다. 반면에 시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에서 발생시키고, catch 하도록 바라는 것이 있다. 이런 예외들은 애플리케이션 예외라고 한다. 이런 예외들은 if문보다 더 명확하게 해당 로직을 처리하도록 개발자에게 강요한다.

 

SQLException은 어떻게 되었나?

이제는 알 수 있을 것이다.

왜 SQLException을 처리할 필요가 없어졌는지

우선 SQLException은 Exception으로 체크 예외이다.

 

jdbcTemplate에서는 이러한 SQLException을 

이렇게 잡아서 DataAccessException으로 처리하고 있다.

 

DataAccessException은

RuntimeException이기 때문에, 언체크 예외로 전환된 것을 알 수 있다.

 

대부분의 경우 SQLException은 복구가 불가능하다.

네트워크와 관련된 부분을 로직에서 어떻게 처리하겠는가?

그렇기에 로직에서 수정 가능한 DataAccessException만 처리하도록 하고, 나머지는 그냥 모두 포기한 것이다.

 

DuplicateKeyException도 최종적으로는 DataAccessException을 상속받고,

 

CannotGetJdbcConnectionException도 DataAccessException을 상속받는다.

 

하지만 CannotGetJdbcConnectionException은 개발자가 어떻게 할 수 있는 예외가 아니다. 그렇기에 DuplicateKeyException만 처리하면 된다. 그래도 개발자에게 처리를 강요하지는 않는 것이다.

 

이 밖에도 스프링의 대부분의 예외는 런타임 예외로 개발자에게 처리를 강요하지는 않는다고 한다.

728x90

지금까지 데이터베이스를 조작하기 위해 계속 무언가를 만들었는데, 사실 스프링에서는 이러한 것들을 편리하게 템플릿으로 이미 제공해준다.

 

일단 JdbcTemplate은 생성자로 dataSource를 받기 때문에 멤버로 JdbcTemplate을 만들어주고, 생성자에서 dataSource를 넣어서 만들어준다.

    public UserDao(
            DataSource dataSource) {
        this.dataSource = dataSource;
        this.jdbcContext = new JdbcContext(dataSource);
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

 

그리고는 다음과 같이 3가지 방법 중 하나로 쿼리문을 작성하면 되는데

public void deleteAll(){
        this.jdbcTemplate.update("delete from users");
        this.jdbcTemplate.update(con -> con.prepareStatement("delete from users"));
        this.jdbcTemplate.update(new PreparedStatementCreator() {
            @Override
            public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
                return con.prepareStatement("delete from users");
            }
        });
    }

 

아무리봐도 가장 위가 단순해보인다.

 

add()에 관한 메서드도 기존

this.jdbcContext.workWithStatementStrategy(c -> {
            PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());

            return ps;
        });

방법에서

 

    this.jdbcTemplate.update(
                "insert into users(id, name, password) values(?, ?, ?)",
                user.getId(), user.getName(), user.getPassword()
        );

이렇게 더 간단하게 사용이 가능하다.

 

이렇게 Create, Update, Delete와 같이 데이터베이스의 정보가 변경되는 쿼리는 update를 통해 작성해주면 된다.

 

이제는 정보를 가져오는 쿼리를 작성해보자.

getCount()는 유저의 수를 가져오는 메서드였다.

수를 가져오는 쿼리는 간단하게 queryForObject()를 사용한다.

쿼리를 작성하고, 원하는 타입을 지정해주면 된다.

    public int getCount() {
        Integer result = jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
        return result == null ? 0 : result;
    }

 

이 방법으로 user의 정보 또한 읽어올 수 있다.

    public User get(String id){
        return jdbcTemplate.queryForObject(
                "select * from users where id = ?", (rs, rowNum) -> new User(
                        rs.getString("id"),
                        rs.getString("name"),
                        rs.getString("password")
                ), id);
    }

엄청 간단해진 get() 메서드이다.

RowMapper를 상속받아서 mapRow() 메서드를 구현해야 하지만, 이 부분도 lambda로 간단하게 작성했다.

 

대신 queryForObject는 하나의 row만 가져오기 때문에, 해당하는 row가 없는 경우에도 에러가 발생한다.

그 때 발생하는 에러가 EmptyResultDataAccessException이기 때문에, 해당 에러를 핸들링하며 개발하면 된다.

 

다음 row를 가져오고 싶다면 rs.next()를 호출해가며 다음 값을 읽어오면 된다.

 

그러면 리스트로 user들을 조회하는 메서드를 만들어보자.

일단 TDD 느낌으로 테스트를 먼저 만들어보면

    @Test
    public void getAll() throws SQLException {
        User user1 = new User("user1", "user1", "pw1");
        User user2 = new User("user2", "user2", "pw2");

        userDao.deleteAll();

        userDao.add(user1);
        List<User> users1 = userDao.getAll();
        Assertions.assertEquals(1, users1.size());
        Assertions.assertEquals(user1, users1.get(0));

        userDao.add(user2);
        List<User> users2 = userDao.getAll();
        Assertions.assertEquals(2, users2.size());
        Assertions.assertEquals(user1, users2.get(0));
        Assertions.assertEquals(user2, users2.get(1));
    }

 

그리고는 getAll() 메서드를 다음과 같이 작성해준다.

    public List<User> getAll() {
        return this.jdbcTemplate.query(
                "SELECT * FROM users ORDER BY id",
                (rs, rowNum) ->
                        new User(rs.getString("id"), rs.getString("name"), rs.getString("password"))
        );
    }

query() 메서드를 사용해 값을 가져온다.

query()는 여러 개의 로우가 결과로 나오는 일반적인 경우에 사용한다.

그렇기 때문에 리턴 타입은 List<T>이다.

query()를 사용하면 next를 사용하지 않아도, 로우의 개수만큼 RowMapper 콜백으로 데이터를 mapping 해준다.

 

이렇게 작성하고 테스트를 실행하면 깔끔하게 성공하는 것을 볼 수 있다.

 

이렇게하면 끝나지만 이 책에서는 항상 예외적인 상황까지 생각해보라고 말한다.

 

데이터베이스가 빈 경우에는 과연 null이 리턴될까? 빈 list가 리턴될까?의 질문이다.

사실 당연히 빈 list가 나오겠지만, 그래도 테스트 코드를 작성해보자.

    @Test
    public void getAllWithEmpty() {
        userDao.deleteAll();

        List<User> users = userDao.getAll();
        Assertions.assertEquals(0, users.size());
    }

 

이렇게 작성하고 테스트를 해보니

테스트를 통과한다.

 

이런 당연한 경우도, 혹시 모를 변경에 대비하여 미리 테스트 코드를 작성해두라고 한다.

 

package seungkyu;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.lang.NonNullApi;

import javax.sql.DataSource;
import java.sql.*;
import java.util.List;

public class UserDao {

    private final JdbcTemplate jdbcTemplate;

    public UserDao(
            DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void add(User user) throws SQLException {
        this.jdbcTemplate.update(
                "insert into users(id, name, password) values(?, ?, ?)",
                user.getId(), user.getName(), user.getPassword()
        );
    }

    public void deleteAll(){
        this.jdbcTemplate.update("delete from users");
    }

    public User get(String id){
        return jdbcTemplate.queryForObject(
                "select * from users where id = ?", (rs, rowNum) -> new User(
                        rs.getString("id"),
                        rs.getString("name"),
                        rs.getString("password")
                ), id);
    }

    public int getCount() {
        Integer result = jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
        return result == null ? 0 : result;
    }

    public List<User> getAll() {
        return this.jdbcTemplate.query(
                "SELECT * FROM users ORDER BY id",
                (rs, rowNum) ->
                        new User(rs.getString("id"), rs.getString("name"), rs.getString("password"))
        );
    }
}

코드가 기존에 dataSource를 가져오던 때에 비해 많이 깔끔해졌다.

 

하지만 그럼에도 중복되는 부분이 보인다.

(rs, rowNum) -> new User(...) 부분이다.

 

이 정도면 깨끗하긴 하지만, 더 깨끗한 코드를 위해서가 아니라 확장을 위해서다.

앞으로 Dao에 이렇게 Mapping 하는 코드를 더 작성할텐데, 그 때마다 이 코드를 작성해 줄 수가 없기 때문이다.

 

private final RowMapper<User> userRowMapper = (rs, rowNum) -> new User(
            rs.getString("id"),
            rs.getString("name"),
            rs.getString("password"));

 

이렇게 Mapper 인스턴스를 만들어서 사용하자.

 

뭔가 함수를 변수로 가지고 있는 것 같지만

private final RowMapper<User> userRowMapperRaw = new RowMapper<>() {
        @Override
        public User mapRow(ResultSet rs, int rowNum) throws SQLException {
            return new User(
                    rs.getString("id"),
                    rs.getString("name"),
                    rs.getString("password"));
        }
    };

그냥 이렇게 RowMapper 인터페이스를 구현해서 가지고 있는 것이다.

@FunctionalInterface
public interface RowMapper<T> {
    @Nullable
    T mapRow(ResultSet rs, int rowNum) throws SQLException;
}

이렇게 @FunctionalInterface를 가지고 있기 때문에 가능한 일이다.

 

일단 이렇게해서 템플릿에 대해 공부했다.

뭔가 JDBC가 많이 나왔지만, 그래도 최대한 템플릿과 callback으로 이해해보려고 한다.

사실 JDBC는 원래도 사용할 줄 알았고, 보통은 JPA를 더 많이 사용하기에

728x90

저번 workWithStatementStrategy로 StatementStrategy 구현체를 넘기는 과정에서 ide가 자동으로 콜백을 추천했었다.

callback은 실행되는 것을 목적으로 다른 오브젝트의 메서드에 넘겨주는 오브젝트를 말한다.

안에 무언가 정보는 신경쓰지 않고, 그 오브젝트의 메서드를 실행하기 위해서이다.

 

인터페이스가 여러 개의 메서드를 가지는 전략 패턴과 달리, 콜백은 단일 메서드만을 가지는 인터페이스에서 시작한다.

어차피 그 메서드 하나만 실행되기 때문이다.

 

저번에 만들었던 workWithStatementStrategy()에 콜백으로 넘겨보자.

workWithStatementStrategy()에 StatementStrategy를 넘겨주어야 하기 때문에

public interface StatementStrategy {
    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

 

해당 인터페이스의 구현체를 만들어 넘겨줬었고, 그럼 또 makePreparedStatement에 Connection을 넘겨줘야 했었다.

 

하지만 callback을 이용하면 해당 구현체를 만들지 않고 사용 할 수 있다.

    public void add(User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(c -> {
            PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());

            return ps;
        });
    }

 

이렇게 람다식으로 connection을 이용해 preparedStatement를 만드는 과정만 넘겨주면 된다.

 

콜백을 작업 흐름은 다음과 같다고 한다.

나는 그냥 파라미터로 함수를 넘긴다고 생각하려고 한다.

그리고 호출된 메서드에서 해당 함수로 작업을 수행한다고 이해했다.

 

이런 callback 패턴은 try-catch-finally에도 굉장히 많이 사용한다고 한다.

예제를 위해 테스트 코드를 작성해보자.

package seungkyu;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.Objects;

public class CalcSumTest {

    static class Calculator{

        public Integer calcSum(String filePath) throws IOException {
            BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath));
            int sum = 0;
            String line;
            while ((line = bufferedReader.readLine()) != null) {
                sum += Integer.parseInt(line);
            }

            bufferedReader.close();
            return sum;
        }
    }

    @Test
    public void sumOfNumbers() throws IOException {
        ClassLoader classLoader = getClass().getClassLoader();
        Calculator calculator = new Calculator();
        int sum = calculator.calcSum(Objects.requireNonNull(classLoader.getResource("numbers.txt")).getFile());
        Assertions.assertTrue(sum > 0);
    }
}

 

그냥 단순하게 파일 읽어서 값 테스트하는 코드이다.

이제 throws한 IOException을 잡아보자.

 

public class CalcSumTest {

    static class Calculator{

        public Integer calcSum(String filePath) {
            try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
                int sum = 0;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    sum += Integer.parseInt(line);
                }

                bufferedReader.close();
                return sum;
            } catch (IOException e) {
                System.out.println(e.getMessage());
            }
            return 1;
        }
    }

    @Test
    public void sumOfNumbers() {
        ClassLoader classLoader = getClass().getClassLoader();
        Calculator calculator = new Calculator();
        int sum = calculator.calcSum(Objects.requireNonNull(classLoader.getResource("numbers.txt")).getFile());
        Assertions.assertTrue(sum > 0);
    }
}

 

일단 try-catch-finally로 핸들링 하도록 했다.

 

하지만 메서드들이 추가될 때마다 예외처리 코드를 복사할 수는 없을 것이다.

이제 이 중복이 될 코드들을 템플릿/콜백으로 제거해보도록 하자.

 

가장 간단하게 생각이 드는 것은 템플릿이 파일을 열고 각 라인을 읽어올 수 있는 BufferedReader를 만들어서 콜백에게 전달해주고, 콜백이 각 라인을 읽어서 알아서 처리한 후 결과를 돌려주는 것이다.

 

일단 파일을 읽어서 결과를 반환해주는 메서드를 만들어준다.

    public int fileReadTemplate(String filePath, BufferedReaderCallback bufferedReaderCallback){
            try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
                return bufferedReaderCallback.doSomethingWithBufferedReader(bufferedReader);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

parameter로는 파일의 위치와 어떤 작업을 수행 할건지에 대한 callback을 작성해준다.

 

넘겨주는 callback의 interface도 만들어준다.

public interface BufferedReaderCallback {

    int doSomethingWithBufferedReader(BufferedReader bufferedReader) throws IOException;
}

 

이제 더하기에 대한 연산을 만들어보자.

    public Integer calcSum(String filePath) {
            BufferedReaderCallback sumCallback = bufferedReader -> {
                int sum = 0;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    sum += Integer.parseInt(line);
                }
                return sum;
            };

            return fileReadTemplate(filePath, sumCallback);
        }

BufferedReaderCallback의 구현체를 lambda로 만들어서 바로 fileReadTemplate의 결과를 반환해준다.

 

이런 식으로 작성하면 일단 try-catch-finally에 대한 중복은 제거할 수 있다.

 

더 간결하게 만들 수 있을까?

만약 여기 곱하기가 추가된다고 해보자.

그러면 while문으로 버퍼를 읽는 부분도 중복이 될거고, 그 부분도 추출하고 싶어진다.

 

그러기 위해서는 또 callback을 위한 인터페이스를 만들고

public interface LineCallback {
    int doSomethingWithLine(String line, int value);
}

 

해당 인터페이스를 이용하는 메서드도 만든다. (fileReadTemplate메서드 처럼)

 

    public int lineReadTemplate(String filePath, LineCallback callback, int initVal){
            BufferedReader bufferedReader;
            try{
                bufferedReader = new BufferedReader(new FileReader(filePath));
                int res = initVal;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    res = callback.doSomethingWithLine(line, res);
                }
                return res;
            }
            catch (IOException e){
                System.out.println(e.getMessage());
            }
            return initVal;
        }

 

메서드를 읽어보자면, 파일을 읽어오기 위한 경로와 작업을 수행할 메서드 그리고 초기값을 받아온다.

 

res 값을 callback 메서드로 계속 변경하기 때문에 더하기, 곱하기에 대한 메서드만 넣어주면 된다.

    public Integer calcSum(String filePath) {
            return lineReadTemplate(filePath, (line, value) -> {
                return Integer.parseInt(line) + value;
            }, 0);
        }

덕분에 calcSum 메서드도 더하기에 대한 연산만 넣어주면 끝나게 된다.

 

그리고 만약 곱하기에 대한 메서드도 필요하다면 

    public Integer calcMulti(String filePath) {
            return lineReadTemplate(filePath, (line, value) -> {
                return Integer.parseInt(line) * value;
            }, 1);
        }

이렇게 간단하게 곱하기에 대한 얀산만 넣어준다.

 

calcSum, calcMulti를 보면 중복되는 부분도 없이 리펙토링 된 것을 볼 수 있다.

 

굉장히 좋은 방법이지만, 솔직히 실전에서는 callback을 쓸 생각이 잘 안난다.

프론트 개발자들은 자주 사용하던데, 나도 좀 적극적으로 사용하려고 노력해야겠다.

+ Recent posts