스프링을 배울수록 아... 이게 참 어려운 내용이었구나 싶었던 부분이고, 로그 관련해서 앞으로 사용할 계획이기에 이번 기회에 깊게 공부하도록 하자.
아마 가장 많이 사용할 AOP는 트랜잭션이다.
@Transactional은 기계적으로 굉장히 많이 사용했던 것 같다.
트랜잭션 자체가 어떻게 적용되는지는 저번 글에서 알 수 있었고, 이번에는 AOP 자체가 어떻게 적용되는지 알아보자.
메소드 분리
기존에 만들었던 upgradeLevels()를 보면 비즈니스 로직을 트랜잭션 설정들이 감싸고 있는 것을 볼 수 있다.
또한 비즈니스 로직은 트랜잭션에게 어떠한 값도 넘겨주지 않는다.
서로 꼭 필요하지만 굉장히 독립적이다.
그렇기에 분리가 가능해보인다.
일단 바로 분리해보자.
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try
{
this.upgradeLevelsLogic();
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
private void upgradeLevelsLogic(){
List<User> users = userDao.getAll();
for (User user : users) {
if (user.isUpgradeLevel()) upgradeLevel(user);
}
}
이렇게 트랜잭션 코드와 비즈니스 코드를 분리했다.
DI를 이용한 클래스의 분리
이제 UserService에서 트랜잭션 코드를 분리해보자.
이건 참 클래스의 의존들이 어려우니, 이해가 힘들면 계속 다시 보도록 하자.
현재
이렇게 UserService를 인터페이스로 만들고, UserServiceImpl로 구현해 사용하고 있다.
그리고 UserServiceTest에서 이거를 받아 사용하고 있던 중이다.
이 문제를 해결하기 위해서, 처음보는 방법이지만 한 번에 두 개의 UserService 인터페이스를 사용한다고 한다.
UserServiceImpl에 비즈니스 로직을 담고, UserServiceTx는 트랜잭션을 담당하도록 만들고 싶다.
그리고 이거를 다시 UserService가 사용하는..? 어려운 구조이다.
솔직히 이해는 힘들지만 직접 코드를 만들며 이해해보자.
일단 UserService 인터페이스이다.
public interface UserService {
void upgradeLevels();
void add(User user);
}
이렇게 add(), upgradeLevels() 2개의 메서드를 가지고 있었다.
그리고 UserServiceImpl이다.
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{
private final UserDao userDao;
@Getter
private final MailSender mailSender;
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
if (user.isUpgradeLevel()) upgradeLevel(user);
}
}
public void add(User user) {
if(user.getLevel() == null)
user.setLevel(Level.BRONZE);
userDao.add(user);
}
private void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
mailSender.sendUpgradeEmail(user);
}
}
기존에 존재하던 트랜잭션 코드를 모두 제거했다.
트랜잭션 설정은 UserServiceTx에서 하기 때문에, 이제 관련 코드를 제거해도 괜찮다.
이렇게 만드니 막상 과거의 코드로 돌아간 것 같다.
이제 트랜잭션 설정을 해보자.
@RequiredArgsConstructor
public class UserServiceTx implements UserService {
private final UserService userService;
private final PlatformTransactionManager platformTransactionManager;
@Override
public void upgradeLevels() {
TransactionStatus transactionStatus = this.platformTransactionManager.getTransaction(new DefaultTransactionDefinition());
try
{
userService.upgradeLevels();
platformTransactionManager.commit(transactionStatus);
this.platformTransactionManager.commit(transactionStatus);
}
catch(Exception e)
{
this.platformTransactionManager.rollback(transactionStatus);
throw e;
}
}
@Override
public void add(User user) {
this.userService.add(user);
}
}
이렇게 UserService를 가져와서 트랜잭션 설정을 하며 UserService의 메서드를 호출하는 방식이다.
이제부터 클라이언트는 UserServiceTx를 사용해야 하기에, 기존의 UserService를 주입하는 부분에는 UserServiceTx를 주입해주고 UserServiceTx의 UserService에는 내부의 메서드를 사용할 수 있도록 UserServiceImpl을 주입해준다.
일단 테스트를 위해서는 서버가 준비되어 있어야 한다.(지금 SMTP 서버가 없기는 하다...)
그리고 테스트를 할 때마다 메일이 발송되는 것도 웃기지 않은가?
사실 메일 서비스는 테스트를 할 필요가 없다.
javaMail은 스프링에서 검증된 API이며
사실 서버로 보내진 경우에 메일이 보내지지 않는 것은, 여기서 테스트한다고 바뀌지 않는다.
해당 서버의 문제까지 테스트를 할 필요는 없지 않은가.
그렇기에 테스트 단계에서는 실제로는 메일이 전송되지 않는 다른 클래스를 사용해도 괜찮다.
테스트를 위한 서비스 추상화
지금은 메일 전송을 다음과 같이 사용하고 있다.
public class UserServiceImpl implements UserService{
private final UserDao userDao;
private final PlatformTransactionManager transactionManager;
private final MailSender mailSender;
}
늘 그렇듯 이러한 부분은 추상화 인터페이스로 만들고 거기에 의존해야 후에 교체가 가능하다.
그렇기에 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);
}
}
그에 비해 트랜잭션의 추상화는 애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드로 분리했다.
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와 관련된 코드만 작성이 되어 있어서, 어쩌면 이렇게 개발하는 것보다는 그냥 그때그때 바꾸는게 더 빠를 수도 있다.
트랜잭션을 TransactionSynchronizationManager에 등록하는 작업을 수행하기에 JdbcTemplate이 여기서 가져다가 쓰는 것이다.
이렇게 새로 생성하던 JtaTransactionManager도 생성자를 통해 주입받도록 하자.
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{
private final UserDao userDao;
private final PlatformTransactionManager transactionManager;
}
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;
};
}
JDBC는 데이터베이스에 접근하여 데이터를 가져오고 수정할 수 있기에 자바의 가장 많이 사용되는 API 중 하나라고 한다.
//MSSQL
SELECT TOP 5 * FROM USERS;
//MYSQL
SELECT * FROM USERS LIMIT 5;
하지만 이렇게 SQL 중에서도 Database마다 다른 문법이 존재한다. 그럼 우리가 만든 UserDao는 MySQL이라는 데이터베이스에 종속되어 버리며, 다른 데이터베이스로 전환은 거의 불가능해진다. 그리고 여기서 발생하는 SQLException의 에러 정보도 DB마다 다르다.
이런 코드가 있다면 MySQL 전용코드가 되어버리기에 여기에서도 MySQL에 종속되는 것이다. 만약 데이터베이스가 MySQL에서 MSSQL로 바뀐다면 현재 사용하는 query도 사용하지 못하고, 에러코드도 제대로 동작하지 못하는 것이다.
그렇기에 SQLException은 예외가 발생한 경우 DB의 상태 정보를 가져올 수 있도록 한다. getSQLState() 메서드를 통해 상태정보를 가져올 수 있도록 한다. 이때는 DB별로 다른 에러 코드를 통합하기 위해, Open Group의 XOPEN SQL 스펙의 SQL 상태코드를 따르도록 한다고 한다.
DB 에러 코드 매핑을 통한 전환
사용하는 DB를 바꾸더라도, 에러코드를 바꾸지 않으려면 이런 에러코드를 원하는 에러로 가져올 수 있도록 해야한다. 그렇기에 DB별 에러 코드를 참고해서 원하는 예외로 바꾸어야 하는 것이다.
오라클에서 PK 중복으로 발생하는 에러코드는 1이다.
이 에러코드를 duplicateKeyCodes로 변경해야 하는 것이다.
그렇기에 이런 에러 코드를 스프링의 예외 클래스로 매핑할 수 있도록 xml 파일을 작성할 수 있다.
이전에는 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가지이다. 첫째는 해당 예외 상황을 더 자세하게 말해주도록 커스텀 예외를 만들어서 전환하는 경우, 둘째는 체크 예외를 언체크 예외르 바꾸는 경우에 사용한다.
대부분 서버에서는 처리되지 않은 예외를 일괄적으로 다룰 수 있는 기능을 제공한다. 이렇게 처리되지 못한 에러들을 관리자에게 메일로 통보해주고, 사용자에게 에러메시지를 적절하게 보여주는 것이 가장 바람직한 방법이다.
예외처리 전략
사실 지금까지 계속 자바의 예외에 대하여 공부하고 있지만, 사실 이 자바를 스프링에서 사용하기 위함이었다. 스프링은 보통 하나의 요청에 대해 하나의 응답을 주고 있는데, 이 중간에 에러가 발생해도 사용자와 상호작용이 불가능하다. 그렇기에 사실상 대부분의 예외가 처리가 불가능하기에 체크 예외가 굳이 필요하지 않다는 것이다. 차라리 그런 예외 상황을 만들지 않는 것이 더 중요하다고 한다. 어쩌면 언체크 예외로 한번에 처리하는 것이 더 좋을 수도 있다.
여기 수정된 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만 처리하도록 하고, 나머지는 그냥 모두 포기한 것이다.