개발자로 후회없는 삶 살기
spring PART.중간점검 5 본문
서론
스프링 MVC와 데이터 접근 기술을 완강하고 기존 중간점검을 리팩토링합니다. 기획 단계에서 리팩토링 요구사항과 트랜잭션 요구사항이 추가됩니다. 설계는 동일하여 배제하고 진행하겠습니다.
본론
- 프로젝트 기획 단계
=> 요구사항 분석

중간점검 2 상품관리 프로젝트를 리팩토링 합니다.
=> 서비스 기능 설계 정리
1. 설계 : 동일
2. 기능 : 동일
- 개발 시작
1. 도메인별 역할 개발
1) 회원 도메인

기존 패키지 구조를 리팩토링했습니다.
2) 상품 도메인

역시 동일하게 유연성보다 실용성을 따져서 인터페이스를 없애고 빠르게 개발할 수 있는 구조를 선택하여 리팩토링했습니다. JPA를 사용하면 스프링 데이터 JPA와 QueryDSL을 서비스에서 둘 다 사용하도록하여 실용성을 따졌을 것입니다.
2. 도메인별 구현 개발
1) 회원 도메인 비즈니스 로직 구현
// 기존
public Member findById(Long id) {
return store.get(id);
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
// 이후
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
객체를 반환하는 메서드에서 1개의 객체면 Optional, 여러개면 List를 사용하여 기존 코드를 리팩토링합니다.
2) 상품 도메인 비즈니스 로직 구현
public Optional<Item> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public List<Item> findAll() {
return new ArrayList<>(store.values());
}
동일하게 Optional을 사용합니다.
3) 로그인 비즈니스 로직 구현


기존에는 서비스도 OCP를 고려하였는데 서비스는 보통 인터페이스를 만들지 않아 리팩토링했습니다.
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
public Optional<Member> login(String loginId, String password) {
return Optional.of(memberRepository.findByLoginId(loginId)
.filter(member -> member.getPassword().equals(password))
.orElse(null));
}
}
여기서도 Optional을 사용하도록 했습니다.
3. 비즈니스 로직 단위 테스트
비즈니스 로직 작성을 맞쳤다면 단위 테스트를 해야합니다. 안하고 웹 부분을 건드리면 어디서 오류가 발생하는지 절대 못 찾을 수도 있으니 반드시 필요한 단계입니다.
@Test
void loginSuccess() {
MemberSaveDTO memberSaveDTO = new MemberSaveDTO();
memberSaveDTO.setLoginId("a");
memberSaveDTO.setPassword("b");
memberSaveDTO.setUserName("hsb");
Member saveMember = memberRepository.save(memberSaveDTO);
Optional<Member> loginMember = loginService.login("a", "b");
assertThat(loginMember.orElse(null).getLoginId()).isEqualTo("a");
}
@Test
void loginFail() {
MemberSaveDTO memberSaveDTO = new MemberSaveDTO();
memberSaveDTO.setLoginId("a");
memberSaveDTO.setPassword("b");
memberSaveDTO.setUserName("hsb");
Member saveMember = memberRepository.save(memberSaveDTO);
Optional<Member> loginMember = loginService.login("a", "c");
Assertions.assertTrue(loginMember.isEmpty());
}
로그인 서비스에 적용한 옵셔널이 잘 동작하는지 보기 위한 단위 테스트를 진행합니다. 결과적으로는 성공시키지 못했으므로 java8에 대한 학습을 더 하고 사용해야 합니다.
4. 컨트롤러 뷰 번갈아 시스템 흐름에 따라 개발
개발을 할 때 도메인과 web을 분리합니다. 내부 패키지는 item, member, login이 있었는데 없애는 것으로 변경합니다. 컨트롤러 하나 만들고 뷰 만들고 컨토롤러 만들고 뷰 만들기를 반복합니다.
1) 홈 페이지


기존 컨트롤러를 web이라는 패키지를 만들었는데 controller로 변경합니다. 하위 패키지들도 없앱니다.
2) 회원가입



기존에는 controller가 하위 패키지를 도메인과 동일하게 가지고 있었고 form도 컨트롤러에 있었습니다. 컨트롤러도 하위 패키지를 없애도록 리팩토링 하였고
// 기존
@PostMapping("members/add")
public String add(@Validated @ModelAttribute("member") MemberSaveForm form, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
log.info("{}", bindingResult);
return "members/addMemberForm";
}
Member member = new Member(form.getUserName(), form.getLoginId(), form.getPassword());
memberRepository.save(member);
return "redirect:/";
}
// 레포지토리
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
또한 DTO의 위치가 이전에는 컨트롤러에서 form을 최종으로 사용하여 컨트롤러에 있었지만
// 이후
@PostMapping("/members/add")
public String add(@Validated @ModelAttribute MemberSaveDTO memberSaveDTO, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "members/addMemberForm";
}
memberRepository.save(memberSaveDTO);
return "redirect:/";
}
// 레포지토리
public Member save(MemberSaveDTO memberSaveDTO) {
Member member = new Member(memberSaveDTO.getUserName(), memberSaveDTO.getLoginId(), memberSaveDTO.getPassword());
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
레포지토리로 이동시켜 보았습니다. 컨트롤러에는 비즈니스 로직이 있으면 안되는 것이 MVC 원칙이라서 적용해 보았는데 컨트롤러에 둘 때도 있어서 상황에 맞게 적용해야 할 것 같습니다.
> 또한 Member의 save 메서드에는 Member 객체가 들어가는 것이 맥락이 맞습니다. 하지만 객체를 넣으면 컨트롤러에 로직을 작성해야 했었습니다. DTO를 넣는 것이 좋을 지 객체 자체를 넣는 것이 좋을 지 경험을 쌓아야 할 것입니다.
3) 로그인

// 옵셔널 사용 전
@PostMapping("/login")
public String login(@Validated @ModelAttribute("loginForm") LoginForm form, BindingResult bindingResult,
HttpServletRequest request,
@RequestParam(defaultValue = "/") String redirect) {
if(bindingResult.hasErrors()) {
return "login/loginForm";
}
Member member = loginService.login(form.getLoginId(), form.getPassword());
if (member == null) {
bindingResult.reject("loginFail");
return "login/loginForm";
}
HttpSession session = request.getSession();
session.setAttribute(Const.MySessionName, member);
return "redirect:" + redirect;
}
옵셔널을 사용하기 전에는 로그인 메서드에서 얻은 member가 null인지 확인했습니다.
// 옵셔널 사용 후
@PostMapping("/login")
public String login(@Validated @ModelAttribute LoginDTO loginDTO, BindingResult bindingResult,
HttpServletRequest request,
@RequestParam(defaultValue = "/") String redirectURI) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Optional<Member> memberOptional = loginService.login(loginDTO.getLoginId(), loginDTO.getPassword());
if (memberOptional.isEmpty()) {
bindingResult.reject("loginFail", "loginFail");
return "login/loginForm";
}
HttpSession session = request.getSession();
session.setAttribute(SessionConst.SESSION_NAME, memberOptional);
return "redirect:" + redirectURI;
}
옵셔널을 사용하면 isEmpty 등 다양한 메서드를 이용할 수 있습니다.
+ 로그 인터셉터


이전에는 WebConfig를 APP과 같은 계층에 두었고 인터셉터를 로그인과 관련되어있는 인터셉터는 로그인 컨트롤러 패키지 하위에 두었습니다.


이것을 config 하위 패키지를 만들고 컨트롤러에는 하위 패키지가 없으니 컨트롤러 패키지에 공통 인터셉터 패키지를 만들어 넣도록 리팩토링했습니다.
4) 상품 관리
-> 상품 저장


이전에 item 컨트롤러 계층에 form을 두어서

컨트롤러에서 form을 사용하는 로직을 작성한 문제가 있었습니다.

컨트롤러에 로직을 작성하는 것을 막기 위해 이번에는 memberDTO처럼 레포지토리 계층에 dto를 둡니다.
// 기존 레포지토리
public Item save(Item item) {
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
// 리팩토링
public Item save(ItemSaveDTO itemSaveDTO) throws IOException {
UploadFile uploadFile = fileStore.storeFile(itemSaveDTO.getAttachFile());
List<UploadFile> uploadFiles = fileStore.storeFiles(itemSaveDTO.getImageFiles());
Item item = new Item(itemSaveDTO.getItemName(), itemSaveDTO.getPrice(), itemSaveDTO.getQuantity(), itemSaveDTO.getOpen(),
itemSaveDTO.getRegions(), itemSaveDTO.getItemType(), itemSaveDTO.getDeliveryCode(),
uploadFile, uploadFiles);
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
기존 레포지토리에서는 컨트롤러에서 form을 사용해서 item 객체를 만들어 인자로 넣어줬습니다. 이번에는 레포지토리에 상품 객체를 생성하고 DTO의 데이터를 저장하는 로직을 작성하고
// 기존
@PostMapping("/items/add")
public String add(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, Model model,
RedirectAttributes redirectAttributes) throws IOException {
if (form.getPrice() != null && form.getQuantity() != null) {
int result = form.getPrice() * form.getQuantity();
if (result < 10000) {
bindingResult.reject("maxPriceError");
}
}
if (bindingResult.hasErrors()) {
log.info("{}", bindingResult);
return "items/addForm";
}
UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
List<UploadFile> imageFiles = fileStore.storeFiles(form.getImageFiles());
Item item = new Item(form.getItemName(), form.getPrice(), form.getQuantity(), form.getOpen(), form.getRegions(), form.getItemType(), form.getDeliveryCode(),
attachFile, imageFiles);
Item saveItem = itemRepository.save(item);
model.addAttribute("item", saveItem);
redirectAttributes.addAttribute("id", item.getId());
return "redirect:/items/{id}";
}
// 이후
@GetMapping("/items/add")
public String itemForm(@Validated @ModelAttribute ItemSaveDTO itemSaveDTO,
BindingResult bindingResult,
RedirectAttributes redirectAttributes, Model model) throws IOException {
if (itemSaveDTO.getPrice() != null && itemSaveDTO.getQuantity() != null) {
int result = itemSaveDTO.getPrice() * itemSaveDTO.getQuantity();
if (result < 10000) {
bindingResult.reject("maxPriceError");
}
}
if (bindingResult.hasErrors()) {
return "items";
}
Item saveItem = itemRepository.save(itemSaveDTO);
model.addAttribute("item", saveItem);
redirectAttributes.addAttribute("id", saveItem.getId());
return "/items/{id}";
}
컨트롤러에 DTO를 사용하는 로직을 제거합니다. DTO를 어디까지 사용하느냐에 따라서 다르게 코드를 작성할 수 있습니다. 리팩토링 결과 컨트롤러에서는 request를 받고 검증을 하고 레포지토리에 전달하고 model에 데이터를 넣어 뷰에 뿌리는 것만 합니다. 기존의 item 객체를 생성하고 데이터를 저장하는 것은 레포지토리에서 하게 됩니다.
-> 상품 수정

기존 상품 수정은 상품 레포지토리에 update 코드가 없어서 컨트롤러에서 수정을 처리했습니다.


상품 레포지토리에 update 메서드를 추가하고 컨트롤러에서 레포지토리를 주입받아 호출할 수 있도록 리팩토링합니다.
- 리팩토링 리스트
1. webconfig config 하위 폴더로 이동
2. 구조보다 효율성을 따져서 구조 설계하기
3. api에 리소스는 복수형 ex) members
4. DTO 위치 고려하여 컨트롤러에서 로직 수행 없애기
5. 레포에서 객체 하나 찾으면 Optional, 여러개면 List 적용하기
이렇게 하여 중간점검 2 리팩토링을 마무리하겠습니다.
- 프로젝트 기획 단계
=> 요구사항 분석
중간점검 3 상품관리 프로젝트를 리팩토링 합니다. 이번에는 트랜잭션도 추가됩니다.
=> 서비스 기능 설계 정리
1. 설계 : 동일
2. 기능 : 동일
- 개발 시작
도메인 역할과 프로젝트 구조는 동일하며 리팩토링과 트랜잭션 요구를 중점으로 개발합니다.
2. 도메인별 구현 개발
1) 회원 도메인 비즈니스 로직 구현


중간점검 2 리팩토링에서 기존 패키지 구조를 개선했습니다. 동일하게 개선하고 레포지토리를 기존에는 멤버 레포 인터페이스를 만들고 그것을 구현한 Jdbc 레포를 만들었는데 실용성을 위해 Jdbc 레포를 Member 레포로 변경합니다.
> 이렇게 하면 추후에 OCP를 할 때 MemberRepository가 jdbc 레포인 것을 헷갈릴 수 있지만 빠르게 개발하고 이후 변경이 필요할 시점이나 프로젝트 규모가 커진 후에 역할과 구현으로 리팩토링하는 것이 더 좋습니다.
// 기존
private final DataSource dataSource;
private final SQLErrorCodeSQLExceptionTranslator translator;
@Autowired
public JdbcMemberRepository(DataSource dataSource) {
this.dataSource = dataSource;
translator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
}
// 이후
@Slf4j
@Repository
public class MemberRepository {
private final SQLExceptionTranslator translator;
public MemberRepository(DataSource dataSource) {
this.translator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
}
jdbc 레포지토리 데이터 소스 주입받는 것을 최적의 코드로 수정합니다. DI는 private final로 선언하는 것도 아니고 생성자를 만드는 것도 아닌 인자로 타입과 변수를 (DataSource dataSource) 넣는 것입니다. 따라서 데이터 소스를 선언하지 않아도 주입받을 수 있습니다. translator처럼 직접 new로 생성하는 것이 관례인 코드도 있습니다.
-> app과 테스트 환경 분리
① app 환경 테스트


구현을 개발한 후에 app과 테스트 환경을 분리합니다. app은 DB를 사용할 것이고 테스트는 내장 DB를 사용할 것이며 원래 테스트 환경과 운영 환경의 테스트는 다른 DB를 사용해야 합니다. 또한 app에서는 초기화 데이터를 넣고 테스트에서는 초기화 데이터 없이 계속 롤백하여 반복적인 테스트를 할 수 있도록 해야합니다.


config 패키지를 만들고 초기화 데이터를 등록하는 클래스를 만듭니다. 이 클래스는 스프링 컨테이너가 준비 완료된 순간에 실행된 메서드를 가지도록 Event 리스너를 붙입니다.
@SpringBootApplication
public class ExApplication {
public static void main(String[] args) {
SpringApplication.run(ExApplication.class, args);
}
@Bean
@Profile("local")
public TestDataInit testDataInit(MemberRepository memberRepository) {
return new TestDataInit(memberRepository);
}
}
이렇게 프로필에 따라 다르게 등록될 빈은 app 클래스에 등록합니다. 빈에 등록되는 순간 리스너가 동작하는 것은 아니며 빈에 등록은 일반 빈들과 동일한 시점에 스프링 컨테이너가 뜨면서 등록되고 뜨는 것이 완료되면 등록된 testDataInit 빈의 리스너가 붙은 메서드가 자동 실행됩니다.


돌려보면 아직 컨트롤러를 만들지 않아 오류 페이지가 뜨는데도 스프링 컨테이너는 실행됐으므로 데이터가 들어갑니다.
② test 환경 테스트


스프링은 내장 DB를 사용하여 테스트를 할 수 있습니다. 스키마를 만들고 프로퍼티스에 아무 설정도 하지 않으면 내장 DB를 사용하여 테스트합니다. JPA를 사용하면 스키마도 없어도 되고 매번 엔터티 정보를 확인하여 create table을 해줍니다.
@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
void insert() {
Member member = new Member("a", "b", "c", 10000);
memberRepository.save(member);
Member findMember = memberRepository.findByLoginId(member.getLoginId());
assertThat(findMember).isEqualTo(member);
assertThat(memberRepository.findAll().size()).isEqualTo(3);
}
}
DB를 사용하는 테스트는 무조건 트랜잭션을 붙여야합니다. 트랜잭션을 사용하여 테스트 메서드들이 서로 영향을 받지 않도록 분리합니다.

실행해보면 find 회원과 일반 회원은 동일한 값을 가지고 있고 size는 초기화 데이터를 포함하여 3를 기대했는데 실제로는 1밖에 없습니다. 즉 app에서 초기화한 데이터가 없는 새로운 DB로 테스트한 것입니다.
=> 비즈니스 로직에서 서비스와 데이터 계층 간 예외처리
-> 트랜잭션이 쓰인 요구사항 분석
① (트랜잭션 1개) A의 잔고 5000원 감소, B의 잔고 5000원 증가
private void bizlogic(String fromId, String toId, int money) {
Member fromMember = memberRepository.findByLoginId(fromId);
Member toMember = memberRepository.findByLoginId(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
이 경우 두 개의 DB row에 변화가 발생하여 2개의 트랜잭션이지 않나 싶을 수 있는데 하나의 트랜잭션입니다. 트랜잭션은 시작부터 커밋이나 롤백할 때까지가 하나입니다.
② (트랜잭션 1개) 이체시 시스템 예외 발생

롤백과 커밋을 로그로 보려면 jpa 로그가 필요합니다. 따라서 회원 객체를 @Entity로 만들어야 하는 줄 알았는데 안해도 로그에 나옵니다. 후에 JPA를 학습해 봐야겠습니다.
public void accountTransfer(String fromId, String toId, int money) {
bizlogic(fromId, toId, money);
}
private void bizlogic(String fromId, String toId, int money) {
Member fromMember = memberRepository.findByLoginId(fromId);
Member toMember = memberRepository.findByLoginId(toId);
log.info("트랜잭션 작동확인 = {}", TransactionSynchronizationManager.isActualTransactionActive());
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
throw new RuntimeException("시스템 예외");
}
서비스에서 시스템 예외를 발생시키고
@Test
void accountTransfer() {
Member a = new Member("a", "a", "a", 10000);
Member b = new Member("b","b","b", 10000);
memberRepository.save(a);
memberRepository.save(b);
memberService.accountTransfer(a.getLoginId(), b.getLoginId(), 2000);
}
테스트를 돌려보면

트랜잭션에서 런타임 예외가 발생하여 롤백됩니다. 트랜잭션은 시스템 예외는 복구 불가능한 예외라고 기본 정책으로 합니다.
③ (트랜잭션 1개) 이체시 잔고 부족 비즈니스 로직 발생

exception 하위 패키지를 만들고 비즈니스 로직 사용자 지정 예외를 만듭니다.
public class NotEnoughMoneyException extends Exception{
public NotEnoughMoneyException(String message) {
super(message);
}
}
현재 비즈니스 로직 예외는 잔고 부족으로 스프링은 체크 예외를 비즈니스 로직 예외로 보아 커밋하는 것을 기본 정책으로 합니다.
private void bizlogic(String fromId, String toId, int money) throws NotEnoughMoneyException {
Member fromMember = memberRepository.findByLoginId(fromId);
Member toMember = memberRepository.findByLoginId(toId);
log.info("트랜잭션 작동확인 = {}", TransactionSynchronizationManager.isActualTransactionActive());
int fromMoney = fromMember.getMoney() - money;
if (fromMoney <= 0) {
throw new NotEnoughMoneyException("잔고 부족");
}
memberRepository.update(fromId, fromMoney);
memberRepository.update(toId, toMember.getMoney() + money);
throw new RuntimeException("시스템 예외");
}
서비스에서 비즈니스 예외를 발생시키고
@Test
void accountTransfer() throws NotEnoughMoneyException {
Member a = new Member("a", "a", "a", 10000);
Member b = new Member("b","b","b", 10000);
memberRepository.save(a);
memberRepository.save(b);
try {
memberService.accountTransfer(a.getLoginId(), b.getLoginId(), 12000);
} catch (NotEnoughMoneyException e) {
log.info("잔고 부족입니다. 다른 계좌로 이어서 부탁드립니다.");
}
}
테스트를 돌려보면

트랜잭션에서 체크 예외가 발생하여 커밋됩니다. 트랜잭션은 체크 예외는 비즈니스 로직 예외라 커밋한다는 기본 정책으로 합니다.
④ (트랜잭션 2개) 트랜잭션 1, 2 각각 실행


DB 로그 테이블 만들고 트랜잭션 2개 상황을 만들어봅니다. (참고 1) 서비스에서 다른 2개의 레포지토리를 사용하는 상황입니다. 트랜잭션의 범위를 고민할 때는 (참고 2) 그림처럼 연상하면 됩니다.
// 서비스 메서드
public void joinV1(String username) {
Member a = new Member("", "a", username, 10000);
Log logMessage = new Log(username);
log.info("== memberRepository 호출 시작 ==");
memberRepository.save(a);
log.info("== memberRepository 호출 종료 ==");
log.info("== logRepository 호출 시작 ==");
logRepository.save(logMessage);
log.info("== logRepository 호출 종료 ==");
}
서비스가 2개의 레포지토리를 참조하는데 둘 다 트랜잭션이 붙어있으면 2개의 트랜잭션입니다. 이때 서비스에는 트랜잭션을 주석 처리해서 전파는 무시했습니다.

2개를 각각 실행했고 정상 로직을 했으므로 둘 다 커밋됩니다.
@Test
public void outerTxOff_fail() {
String username = "로그예외outerTxOff_success";
memberService.joinV1(username);
}
로그에서 롤백을 하도록하면

역시 두 트랜잭션이 각각 동작합니다.
④ 2개를 단일 트랜잭션으로 묶는 경우
@Test
public void outerTxOff_single() {
String username = "outerTxOff_single";
memberService.joinV1(username);
}
2개의 레포지토리 결과가 따로따로, 하나는 커밋하고 하나는 롤백하면 안 되는 경우 서비스에만 트랜잭션을 붙이고 두 레포지토리에는 트랜잭션을 안 붙이면 됩니다.

결과적으로 멤버 저장과 로그 저장이 같이 일어납니다. 예외를 일으키면 같이 롤백됩니다.
⑤ 트랜잭션 외부 커밋, 내부 커밋

서비스와 레포 둘 다 트랜잭션을 하고 싶은 경우 3개 클래스에 모두 트랜잭션을 붙입니다. 외부 트랜잭션이 서비스, 내부가 레포 2개입니다. 외부와 내부 둘 다 성공하도록 로직을 짜면 내부 트랜잭션이 모두 무시되고 종료된 후에 외부 커밋이 됩니다.
⑥ 트랜잭션 외부 롤백, 내부(로그 X, 멤버 O) 커밋
public void save(Log logMessage) {
log.info("log 저장");
em.persist(logMessage);
if (logMessage.getMessage().contains("로그예외")) {
log.info("log 저장시 예외 발생");
throw new RuntimeException("예외 발생");
}
}
로그 예외를 터트려서 내부에서 롤백이 일어납니다. 내부에서 롤백이 터지니 롤백 only를 마킹하여 전체가 다 롤백됩니다.
public void joinV1(String username) {
Member a = new Member("", "a", username, 10000);
Log logMessage = new Log(username);
log.info("== memberRepository 호출 시작 ==");
memberRepository.save(a);
log.info("== memberRepository 호출 종료 ==");
log.info("== logRepository 호출 시작 ==");
logRepository.save(logMessage);
log.info("== logRepository 호출 종료 ==");
}
예외가 호출된 곳까지 날라왔을 때 런타임이라 throws를 명시하지 않아도 자동으로 던져지는데 던지면 예외가 터진 것입니다. 따라서 서비스에서도 롤백됩니다.
⑦ 트랜잭션 외부 커밋, 내부 롤백
public void joinV2(String username) {
Member a = new Member("", "a", username, 10000);
Log logMessage = new Log(username);
log.info("== memberRepository 호출 시작 ==");
memberRepository.save(a);
log.info("== memberRepository 호출 종료 ==");
log.info("== logRepository 호출 시작 ==");
try {
logRepository.save(logMessage);
} catch (RuntimeException e) {
log.info("log 저장에 실패 = {}", logMessage.getMessage());
log.info("정상 흐름 반환");
}
log.info("== logRepository 호출 종료 ==");
}
서비스의 v2의 경우 로그에서 발생한 예외를 잡아서 서비스에서 예외가 발생하지 않아 롤백, 커밋하지 않게 작성합니다. 하지만 내부에서 마킹이 되어있어서 롤백됩니다.
⑧ 트랜잭션 외부 커밋, 내부 롤백 NEW
@Slf4j
@Repository
@Transactional(propagation = Propagation.REQUIRES_NEW)
@RequiredArgsConstructor
public class LogRepository {
로그 정도는 회원가입에 영향을 주면 안 좋으니 회원가입과 로그를 따로따로 롤백, 커밋하게 해도 될 것 같습니다. REQUIRED_NEW를 하면 로그 트랜잭션을 독립적으로 물리 트랜잭션으로 만들어 로그만 롤백되고 서비스와 회원 레포지토리는 커밋되게 할 수 있습니다.

멤버 레포는 내부라서 영향을 주지 않아 전부 무시되고 로그는 물리 트랜잭션이라서 혼자만 롤백되며 모두 끝난 후 서비스는 로그에서 발생한 예외를 잡아 정상처리 되어 내부와 외부 관계인 멤버 서비스와 멤버 레포지토리는 커밋됩니다.
-> 우리 플젝에서 발생할 수 있는 예외
예외 처리 요구사항이 있습니다. 회원 가입과 이체시 비즈니스 로직 예외가 발생했을 때 언체크면 롤백되고 체크면 커밋되며 이를 호출한 곳에서 잡으면 예외가 발생하지 않고 던지면 예외가 발생하는 것을 보겠습니다. 이때 발생하는 예외를 자바 예외로 해도 되지만 비즈니스 로직 예외라고 생각하고 사용자 지정으로 만들어 해보겠습니다.
① 트랜잭션 안하는 그냥 우리 플젝에서 발생할 수 있는 비즈니스 예외 예시
public Member join(Member member) {
boolean active = TransactionSynchronizationManager.isSynchronizationActive();
log.info("트랜잭션 엑티브 = {}", active);
try {
memberRepository.save(member);
return member;
} catch (DuplicateKeyException e) {
String retryId = generateNewId(member.getLoginId());
member.setLoginId(retryId);
memberRepository.save(member);
return member;
}
}
회원 가입 시에는 결제 상태 같이 상태를 저장하거나 바꾸거나 할 필요가 없고 그냥 catch로 잡아도 됩니다. 그때 굳이 controlladvice까지 가지 않고 서비스에서 잡아도 되며 트랜잭션이 필요없습니다. 트랜잭션이 필요한 이유가 롤백과 커밋 때문인데 회원가입은 애초에 예외가 터지면 저장이 안 되어서 저장하거나 복구할 게 없으니 트랜잭션이 필요없습니다.
@Test
public void join() {
Member a = new Member("a", "a", "a", 10000);
Member a2 = new Member("a", "a", "a", 10000);
memberService.join(a);
memberService.join(a2);
}
테스트를 해보면

트랜잭션도 적용되고 DuplicateKeyException이 런타임 예외지만 catch 해서 정상처리 되어서 commit도 되지만 굳이 저장하고 롤백하고 할 게 없습니다. 키 중복이 일어나면 예외가 발생하여 애초에 DB에 저장을 하지 않고 아무일도 일어나지 않으며 예외가 발생했다고만 알려주기 때문입니다.
public Member join(Member member) {
memberRepository.save(member);
return member;
}
만약 서비스에서 잡지 않아서 catch를 하지않는다면

중복 키 예외가 런타임 예외라서 롤백이 일어납니다.
② 비즈니스 로직 예외 발생하여 커밋되는데 저장하기 싫어서 롤백하기
public class NoSuchIdPlzReTryException extends Exception{
public NoSuchIdPlzReTryException(String message) {
super(message);
}
}
비즈니스 로직에 맞는 사용자 지정 예외를 만들고
public Member findByLoginId(String loginId) {
String sql = "select * from member where loginid = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, loginId);
rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setMoney(rs.getInt("money"));
member.setLoginId(rs.getString("loginid"));
member.setPassword(rs.getString("password"));
member.setUsername(rs.getString("username"));
return member;
} else {
throw new NoSuchIdPlzReTryException("그런 아이디 없습니다.");
}
} catch (SQLException e) {
throw translator.translate("select", sql, e);
} catch (NoSuchIdPlzReTryException e) {
throw new RuntimeException(e);
} finally {
close(con, pstmt, rs);
}
}
레포지토리에서 NoSuchElementException가 일어날 것을 사용자 예외로 바꿔봅니다. 비즈니스 로직이란 것을 강조하기 위함이지 체크 예외인 것은 동일합니다.
@Test
public void findByLoginId() {
Member a = new Member("a", "a", "a", 10000);
memberService.join(a);
memberRepository.findByLoginId("b");
}


테스트 해보면 역시 예외가 발생하고 사용자 지정 예외라서 커밋합니다.
@Transactional(rollbackFor = NoSuchIdPlzReTryException.class)
public Member findByLoginId(String loginId) throws NoSuchIdPlzReTryException {
이것을 특정 예외일 경우 체크 예외여도 롤백하도록 지정합니다.

실행해보면 체크 예외인데도 롤백할 수 있습니다.
- 리팩토링 리스트
1. DI 최적화
2. 프로퍼티스 테스트 환경 분리
3. 로직상 중요한 예외는 사용자 정의 예외로 처리
결론
이렇게 하여 트랜잭션과 비즈니스 로직 예외처리를 경험해보았습니다. 이렇게 한 번 어플리케이션에 적용해보면 실제로 프로젝트를 할 때 참고할 수 있을 것입니다.
참고
'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글
[문법] 의존관계 자동 주입 (0) | 2024.07.31 |
---|---|
[문법] Spring 프레임워크 사용 목적 및 Web 통신 원리 (0) | 2024.07.29 |
spring PART.트랜잭션 전파 활용 (0) | 2023.05.23 |
spring PART.트랜잭션 전파 기본 (0) | 2023.05.22 |
spring PART.스프링 트랜잭션 이해 (0) | 2023.05.20 |