1. 서론
로아투두는 회원 가입 후 캐릭터 등록을 따로 두고 있습니다.
@ApiOperation(value = "1차 회원가입 이후 캐릭터 추가",
notes="대표캐릭터 검색을 통한 로스트아크 api 검증 \n 대표캐릭터와 연동된 캐릭터 함께 저장")
@PostMapping("/character")
public ResponseEntity<?> saveCharacter(
@AuthenticationPrincipal String username,
@RequestBody SaveCharacterRequest request) {
if (username.equals(TEST_USERNAME)) {
throw new IllegalStateException("테스트 계정은 캐릭터 등록이 불가능 합니다.");
}
if (usernameLocks.putIfAbsent(username, true) != null) {
throw new IllegalStateException("이미 진행중입니다.");
}
try {
// 일일 컨텐츠 통계(카오스던전, 가디언토벌) 호출
List<DayContent> chaos = contentService.findDayContent(Category.카오스던전);
List<DayContent> guardian = contentService.findDayContent(Category.가디언토벌);
// 대표캐릭터와 연동된 캐릭터 호출(api 검증)
List<Character> characterList = lostarkCharacterDao.findCharacterList(request.getCharacterName(), request.getApiKey(), chaos, guardian);
// 재련재료 데이터 리스트로 거래소 데이터 호출
Map<String, Market> contentResource = marketService.findContentResource();
// 일일숙제 예상 수익 계산(휴식 게이지 포함)
List<Character> calculatedCharacterList = new ArrayList<>();
for (Character character : characterList) {
Character result = characterService.calculateDayTodo(character, contentResource);
calculatedCharacterList.add(result);
}
// Member 회원가입
memberService.createCharacter(username, request, calculatedCharacterList);
return new ResponseEntity<>(HttpStatus.OK);
} finally {
usernameLocks.remove(username);
}
}
기본적인 로직은 아래와 같습니다.
- 일일 컨텐츠 통계(카오스던전, 가디언 토벌)을 가져옴.
- 로스트아크 오픈 API를 이용해 캐릭터 데이터를 가져옴.
- 재련 재료 데이터를 가져옴
- 가져온 데이터를 조합해서 일일숙제 예상 수익을 계산 후 저장.
여기서 두가지 Exception이 있습니다.
- 테스트 계정은 캐릭터 등록 불가
- 이미 이메일이 가입 중일 때 가입 불가
- 로스트아크 오픈 API로 데이터를 가져오는데 시간이 걸림
2. 리팩토링 관점
1) 단일 책임 원칙(SRP)가 지켜지지 않음.
2) 테스트코드가 없음.
3) 다른 API 중에서도 테스트 계정이 사용 불가능한 API들이 많은데, 거기마다 if 문을 두어서 코드의 반복이 많아짐.
이러한 관점을 바탕으로 리팩토링을 하였습니다.
3. 리팩토링
1. AuthApi의 Custom Annotation
일단 새로운 AuthApi 클래스 생성
@ApiOperation(value = "1차 회원가입 이후 캐릭터 추가",
notes = "대표캐릭터 검색을 통한 로스트아크 api 검증 \n 대표캐릭터와 연동된 캐릭터 함께 저장")
@PostMapping("/character")
@NotTestMember
public ResponseEntity<?> createCharacter(@AuthenticationPrincipal String username,
@RequestBody @Valid SaveCharacterRequest request) {
memberService.createCharacter(username, request);
return new ResponseEntity<>(HttpStatus.OK);
}
여기서 @NotTestMember 는 새로 만든 커스텀 Annotation 입니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NotTestMember {
String message() default "테스트 계정은 접근이 불가능합니다.";
}
@Aspect
@Component
public class NotTestMemberAspect {
@Before("@annotation(notTestMember)")
public void validateNotTestMember(JoinPoint joinPoint, NotTestMember notTestMember) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
String username = authentication.getName();
if (TEST_USERNAME.equals(username)) {
throw new IllegalStateException(notTestMember.message());
}
}
}
}
동작 과정
- @NotTestMember 어노테이션이 붙은 메서드가 호출되면
- Spring AOP가 이를 감지하여 NotTestMemberAspect의 validateNotTestMember 메서드를 먼저 실행
- SecurityContextHolder에 저장된 username을 가져와서 현재 로그인한 사용자가 테스트 계정인지 확인
- 테스트 계정이면 예외를 발생시켜 메서드 실행을 중단
- 테스트 계정이 아니면 원래 메서드가 정상적으로 실행
2. MemberService와 MemberLock
그 다음 MemberService 클래스의 createCharacter 메소드
// 회원가입 후 캐릭터 추가
@Transactional
public void createCharacter(String username, SaveCharacterRequest request) {
try (var ignored = memberLockManager.acquireLock(username)) {
Member member = get(username);
validateCreateCharacter(member);
List<Character> characterList = createAndCalculateCharacters(request);
member.createCharacter(characterList, request);
}
}
private static void validateCreateCharacter(Member member) {
if (!member.getCharacters().isEmpty()) {
throw new IllegalStateException(CHARACTER_ALREADY_EXISTS);
}
}
private List<Character> createAndCalculateCharacters(SaveCharacterRequest request) {
// 대표캐릭터와 연동된 캐릭터 호출(api 검증)
List<Character> characterList = lostarkCharacterApiClient.createCharacterList(
request.getCharacterName(), request.getApiKey());
// 재련재료 데이터 리스트로 거래소 데이터 호출
Map<String, Market> contentResource = marketRepository.findLevelUpResource();
// 일일숙제 예상 수익 계산(휴식 게이지 포함)
return characterList.stream()
.map(character -> character.calculateDayTodo(character, contentResource))
.collect(Collectors.toList());
}
여기서 처음보는 memberLockManager가 보입니다.
이것은 기존에 있던 ConcurrentHashMap의 관련된 코드를 따로 분리한 것 입니다.
@Component
public class MemberLockManager {
private final ConcurrentHashMap<String, Boolean> locks = new ConcurrentHashMap<>();
public MemberLock acquireLock(String username) {
return new MemberLock(locks, username);
}
@VisibleForTesting // 테스트 코드용
public void setLock(String username) {
locks.put(username, true);
}
@VisibleForTesting
public boolean isLocked(String username) {
return locks.containsKey(username);
}
public static class MemberLock implements AutoCloseable {
private final ConcurrentHashMap<String, Boolean> locks;
private final String username;
public MemberLock(ConcurrentHashMap<String, Boolean> locks, String username) {
this.locks = locks;
this.username = username;
if (locks.putIfAbsent(username, true) != null) {
throw new IllegalStateException(EMAIL_REGISTRATION_IN_PROGRESS);
}
}
@Override
public void close() {
locks.remove(username);
}
}
}
동작 과정
- 사용자A가 요청:
- locks = {} (비어있음)
- acquireLock("userA") 호출
- putIfAbsent 성공 → locks = {"userA": true}
- 로직 수행
- close() 호출 → locks = {}
- 사용자A가 처리 중일 때 다시 요청:
- locks = {"userA": true}
- acquireLock("userA") 호출
- putIfAbsent 실패 → IllegalStateException 발생
try-with-resources 구문은 AutoCloseable 인터페이스를 구현한 클래스에 대해 자동으로 close() 메서드를 호출해줍니다.
3. 테스트 코드 작성
이제 리팩토링한 createCharacter의 테스트 코드를 작성했습니다.
- 회원가입 후 캐릭터 추가 성공
- 이미 등록된 캐릭터가 존재하는데 캐릭터 추가 시도시 예외 발생
- 동시에 같은 계정으로 캐릭터 추가 시도시 예외 발생
- 알 수 없는 에러일 때, lock이 정상적으로 해제되는지 확인
- 멀티스레드 환경에서 동시에 같은 계정으로 시도시 한 번만 성공
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@InjectMocks
MemberService memberService;
@Mock
MemberRepository memberRepository;
@Mock
LostarkCharacterApiClient lostarkCharacterApiClient;
@Mock
MarketRepository marketRepository;
@Spy
MemberLockManager lockManager = new MemberLockManager();
@DisplayName("회원가입 후 캐릭터 추가 성공")
@Test
void createCharacter_Success() {
// given
String username = "testuser@test.com";
SaveCharacterRequest request = saveCharacterRequest();
// Mock 객체들 설정
Member mockMember = MemberTestData.createMockMember(username);
List<Character> mockCharacterList = CharacterTestData.createMockCharacterList();
Map<String, Market> mockMarketMap = MarketTestData.createMockMarketMap();
// Mock 객체 동작 정의
assertThat(mockMember).isNotNull();
when(memberRepository.get(username)).thenReturn(Optional.of(mockMember));
when(lostarkCharacterApiClient.createCharacterList(request.getCharacterName(), API_KEY)).thenReturn(mockCharacterList);
when(marketRepository.findLevelUpResource()).thenReturn(mockMarketMap);
// when
memberService.createCharacter(username, request);
// then
verify(memberRepository).get(username);
assertThat(lockManager.isLocked(username)).isFalse(); // lock이 해제되었는지 확인
assertThat(mockMember.getApiKey()).isEqualTo(API_KEY);
assertThat(mockMember.getMainCharacter()).isEqualTo(request.getCharacterName());
assertThat(mockMember.getCharacters()).hasSize(mockCharacterList.size());
assertThat(mockMember.getCharacters().get(0).getDayTodo().getChaosGold()).isNotEqualTo(0);
assertThat(mockMember.getCharacters().get(0).getDayTodo().getGuardianGold()).isNotEqualTo(0);
}
@DisplayName("이미 등록된 캐릭터가 존재하는데 캐릭터 추가 시도시 예외 발생")
@Test
void createCharacter_WithExistsCharacters_ThrowsException() {
// given
String username = "user@test.com";
SaveCharacterRequest request = saveCharacterRequest();
Member mockMember = MemberTestData.createMockMember(username);
List<Character> mockCharacterList = CharacterTestData.createMockCharacterList();
mockMember.createCharacter(mockCharacterList, request);
when(memberRepository.get(username)).thenReturn(Optional.of(mockMember));
// when & then
IllegalStateException exception = assertThrows(IllegalStateException.class, () ->
memberService.createCharacter(username, request));
assertThat(exception.getMessage())
.isEqualTo(CHARACTER_ALREADY_EXISTS);
verify(memberRepository).get(username); // repository 호출 검증
}
@DisplayName("동시에 같은 계정으로 캐릭터 추가 시도시 예외 발생")
@Test
void createCharacter_WithConcurrentAccess_ThrowsException() {
// given
String username = "user@test.com";
SaveCharacterRequest request = saveCharacterRequest();
// 첫 번째 요청으로 lock 설정
lockManager.setLock(username);
// when & then
IllegalStateException exception = assertThrows(IllegalStateException.class, () ->
memberService.createCharacter(username, request));
assertThat(exception.getMessage())
.isEqualTo(EMAIL_REGISTRATION_IN_PROGRESS);
// 추가 검증
assertThat(lockManager.isLocked(username)).isTrue();
}
@DisplayName("알 수 없는 에러일 때, lock이 정상적으로 해제되는지 확인")
@Test
void createCharacter_LockIsReleased_AfterException() {
// given
String username = "user@test.com";
SaveCharacterRequest request = saveCharacterRequest();
when(memberRepository.get(username))
.thenThrow(new RuntimeException("Unexpected error"));
// when & then
assertThrows(RuntimeException.class, () ->
memberService.createCharacter(username, request));
assertThat(lockManager.isLocked(username)).isFalse();
}
@DisplayName("멀티스레드 환경에서 동시에 같은 계정으로 시도시 한 번만 성공")
@Test
void createCharacter_ConcurrentAccess_OnlyOneSucceeds() throws InterruptedException {
// given
String username = "user@test.com";
SaveCharacterRequest request = saveCharacterRequest();
Member mockMember = MemberTestData.createMockMember(username);
List<Character> mockCharacters = CharacterTestData.createMockCharacterList();
Map<String, Market> mockMarketMap = MarketTestData.createMockMarketMap();
assertThat(mockMember).isNotNull();
when(memberRepository.get(username)).thenReturn(Optional.of(mockMember));
when(marketRepository.findLevelUpResource()).thenReturn(mockMarketMap);
when(lostarkCharacterApiClient.createCharacterList(request.getCharacterName(), API_KEY))
.thenReturn(mockCharacters);
// when
int threadCount = 5;
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
memberService.createCharacter(username, request);
successCount.incrementAndGet();
} catch (IllegalStateException e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
}).start();
}
// then
boolean completed = latch.await(5, TimeUnit.SECONDS);
assertThat(completed).isTrue(); // 타임아웃 없이 모든 스레드가 완료되었는지 확인
assertThat(successCount.get()).isEqualTo(1); // 성공 한번
assertThat(failCount.get()).isEqualTo(threadCount - 1); // 나머지 다 실패
assertThat(lockManager.isLocked(username)).isFalse();
}
private SaveCharacterRequest saveCharacterRequest() {
return SaveCharacterRequest.builder()
.apiKey(API_KEY)
.characterName("마볼링")
.build();
}
}
연관된 데이터들은 따로 클래스를 만들어 생성하였습니다.
그 중 일일 컨텐츠 통계 예시
public class ContentTestData {
// 카테고리별로 그룹화된 맵 반환
public static Map<Category, List<DayContent>> createMockDayContentMap() {
return createMockDayContents().stream()
.collect(Collectors.groupingBy(Content::getCategory));
}
public static List<DayContent> createMockDayContents() {
return Arrays.asList(
// 카오스던전
createDayContent(1L, Category.카오스던전, "타락1", 1415, 72415, 2438, 4.9, 76.4, 226.4, 7),
createDayContent(2L, Category.카오스던전, "타락2", 1445, 73779, 2919, 5.6, 82.2, 241.0, 8),
createDayContent(3L, Category.카오스던전, "타락3", 1475, 75378, 2897, 6.6, 89.1, 268.4, 8),
createDayContent(4L, Category.카오스던전, "공허1", 1490, 76884, 5416, 3.1, 52.5, 149.9, 8),
createDayContent(5L, Category.카오스던전, "공허2", 1520, 77565, 6885, 4.1, 63.4, 243.4, 10),
createDayContent(6L, Category.카오스던전, "절망1", 1540, 81193, 8173, 4.8, 70.2, 207.7, 11),
createDayContent(7L, Category.카오스던전, "절망2", 1560, 81859, 10006, 5.8, 78.2, 241.8, 10),
createDayContent(8L, Category.카오스던전, "천공1", 1580, 80164, 9913, 3.1, 40.7, 110.0, 10),
createDayContent(9L, Category.카오스던전, "천공2", 1600, 84164, 10128, 4.0, 44.1, 128.9, 11),
createDayContent(10L, Category.카오스던전, "계몽1", 1610, 97382, 11198, 5.3, 60.0, 171.9, 11),
createDayContent(91L, Category.카오스던전, "계몽2", 1630, 96918, 14349, 9.2, 95.3, 273.1, 12.3),
createDayContent(110L, Category.카오스던전, "쿠르잔전선 1", 1640, 186048, 22008, 11.0, 129.5, 471.0, 4),
createDayContent(111L, Category.카오스던전, "쿠르잔전선 2", 1660, 194048, 24008, 12.0, 175.0, 600.0, 4),
createDayContent(141L, Category.카오스던전, "쿠르잔전선 3", 1680, 194048, 24008, 13.0, 200.0, 700.0, 5),
// 가디언토벌
createDayContent(11L, Category.가디언토벌, "데스칼루다", 1415, 0, 0, 10.7, 103.4, 310.6, 0),
createDayContent(12L, Category.가디언토벌, "쿤겔라니움", 1460, 0, 0, 15.2, 131.9, 396.9, 0),
createDayContent(13L, Category.가디언토벌, "칼엘리고스", 1490, 0, 0, 10.1, 74.3, 222.6, 0),
createDayContent(14L, Category.가디언토벌, "하누마탄", 1540, 0, 0, 14.0, 101.4, 309.5, 0),
createDayContent(15L, Category.가디언토벌, "소나벨", 1580, 0, 0, 8.0, 66.3, 199.7, 0),
createDayContent(16L, Category.가디언토벌, "가르가디스", 1610, 0, 0, 12.0, 103.7, 311.1, 0),
createDayContent(92L, Category.가디언토벌, "베스칼", 1630, 0, 0, 24.0, 168.3, 506.5, 0),
createDayContent(109L, Category.가디언토벌, "아게오로스", 1640, 0, 0, 12.0, 94.6, 293.0, 0),
createDayContent(142L, Category.가디언토벌, "스콜라키아", 1680, 0, 0, 19.0, 195.0, 434.0, 0)
);
}
private static DayContent createDayContent(
Long id,
Category category,
String name,
double level,
double shilling,
double honorShard,
double leapStone,
double destructionStone,
double guardianStone,
double jewelry) {
return DayContent.builder()
.id(id)
.category(category)
.name(name)
.level(level)
.shilling(shilling)
.honorShard(honorShard)
.leapStone(leapStone)
.destructionStone(destructionStone)
.guardianStone(guardianStone)
.jewelry(jewelry)
.build();
}
}
테스트 결과
4. curl을 이용하여 실제 동시 요청일 때 에러가 잘 나오는지 확인
4. 회고
처음 테스트 코드의 필요성을 들었을 때, 그 중요성은 이해했지만 구체적인 작성 방법을 몰라 막막했습니다.
처음에는 실제 DB에 연결해서 테스트를 해보고, 그 다음에는 테스트용 DB 컨테이너를 따로 올려서 테스트를 진행해보았습니다.
하지만 이런 방식들은 테스트 환경 구성이 복잡하고 실행 시간도 오래 걸렸습니다.
지금은 Mock을 활용한 테스트를 작성하면서, 테스트하고자 하는 메서드에만 집중할 수 있다는 장점을 실감하고 있습니다.
외부 의존성 없이 빠르게 테스트를 실행할 수 있고, 다양한 시나리오를 쉽게 테스트해볼 수 있어서 매우 효율적입니다.
또한, 이번 테스트 코드 작성 경험을 통해 객체 지향의 설계 원칙을 더 깊이 이해하게 되었습니다.
이전보다 시야가 넓어져서 더 나은 설계를 고민하게 되었고, 실제로 코드의 품질도 개선되는 것을 느낄 수 있었습니다.