이번에 포스팅할 내용은 개인 프로젝트로 진행한 `레거시 코드 리팩토링` 이었습니다. 실무에서는 수년동안 많은 개발자들을 거쳐 작성된 코드들이 있고, 잘 작동되는 코드여도 오래된 기술을 사용하거나 현재는 사용되지 않는 코드도 많습니다. 이런 코드들을 `레거시 코드`라 하고 이 부분을 찾아 리팩토링 하는 과정을 통해 코드 가독성이나 유지보수성을 높이고, 최신 기술을 도입해서 향후 확장성을 높이거나 성능개선 부분에서도 이점을 볼 수 있습니다.
이번 프로젝트를 진행하면서 개선된 부분은 총 14가지 부분이고, 그 내용은 다음과 같습니다.
1. `@Transactional` 이 readOnly 옵션으로 지정되어 있어 쓰기 작업이 제대로 구동안된 점
2. 사용자가 회원가입 시, 닉네임을 지정할 수 있도록 `nickname` 옵션 추가
3. `AOP` 가 제대로된 메서드에 동작하지 않고, 정상적인 시점에 동작하지 않는 부분 수정
4. 테스트코드가 제대로 동작하지 않던 부분을 목적에 맞게 수정
5. JPA 를 활용하여 `Todo` 검색 시, 다양한 조건을 통해 검색할 수 있도록 수정
6. `Cascade` 옵션을 지정해 `Todo` 등록 시, 작성자는 자동으로 담당자로 등록될 수 있도록 수정
7. `N+1` 문제 해결
8. `JPQL` 로 작성되어 있던 조회 메서드를 `QueryDSL` 을 사용해서 조회하는 방식으로 수정
9. 프로젝트에 `Spring Security` 도입
10. `QueryDSL` 을 사용하여 `5번` 항목의 내용과 비슷하게 다양한 조건으로 조회하는 기능 추가
11. 특정 API 를 호출 시, 관련된 로그를 기록하는 `로그 테이블` 추가
12. `AWS` 를 활용하여 `EC2`, `RDS`, `S3` 를 활용하여 프로젝트를 외부에서 접속할 수 있게 배포
13. 테스트코드를 사용하여 100만건의 데이터를 생성하고 그 데이터를 조회할 때 성능개선
14. `Kotlin` 문법을 적용하여 `Entity` 및 `Service` 로직 수정
많은 추가된 부분들, 수정된 부분들, 리팩토링 과 버그픽스의 개념이 같이 있는 프로젝트였다고 생각합니다.
대부분 약간씩 수정하거나 추가하여 간단하게 해결할 수 있었지만, 문제가 발생했던 9번 `Spring Security` 에 대한 부분과 13번 `대량의 데이터 처리` 부분에 대해 해결 과정을 정리하고자 합니다.
1. Spring Security
Spring - Spring Security
프로젝트를 구현하다 보면, 개별적으로 만든 `Filter` 에 `JWT` 토큰을 활용하여 인증과 인가를 진행하는 경우가 많습니다. 하지만 Spring Framework 에서는 강력한 보안 모듈로 `Spring Security` 를 제공하
wanglan.tistory.com
Spring Security 에 대해선 위의 링크에 잘 정리되어 있습니다. 이 부분에서 처음 문제를 느꼈던 것은 기존의 HttpServletRequest 에서 Http에 담겨있던 토큰에서 JWT 유틸리티를 사용해 유저 정보를 추출하여 request 로 받아오던 방식에 갖혀 "이 방식으로 Spring Security 도 동작한다" 라고 생각해서 발생했던 문제였습니다.
Spring Security 를 도입하면 `AuthenticationManager` 가 SecurityContextHolder 에 있는 `SecurityContext` 에 자동으로 저장을 해고, 이것을 UserDetails(Impl) 로 받아 와서 사용하면 된다고 생각했었습니다. 하지만 authenticate 를 하면서 유저 정보를 Manager 가 저장은 하지만 실제로 저장되는 객체는 따로 만들어 주어야 한다는 사실을 간과하고 `UserDetails` 에 `SecurityContext` 에 저장되어 있는 정보를 저장해주지 않은 상태에서 UserDetailsImpl 를 호출하여 사용했었습니다.
이 부분은 Spring Security 의 개념과 동작 방식에 대한 이해를 통해 쉽게 해결할 수 있었지만, 직접 Spring Security 를 정식으로 도입한 경우는 이번이 처음이라 적용하는데 미숙함이 있던 것 같습니다.
2. 대량의 데이터 처리
기존에 작성하던 CRUD 는 데이터를 하나씩 처리하는 방식이였고, 이렇게 대량의 데이터를 다루는 일은 거의 없었습니다. 이번에 주어진 업무는 100만개의 데이터를 생성하는 로직과 그렇게 생성된 데이터를 조회할 때 성능을 개선하여 조회하는 방법을 연구하는 것이였습니다.
처음 직면했던 문제는 말 그대로 100만개를 만드는 것 자체였습니다. 현재 사정상 개발을 6년된 노트북으로 진행하고 있고, 성능이 그렇게 나쁘지도 뛰어나지도 않은 컴퓨터였는데 처리가 가능할까 하는 걱정을 두고 코드를 작성했습니다.
우선 처음 작성했던 코드에 대해 설명하도록 하겠습니다.
1. 첫번째 시도
@SpringBootTest
class AuthControllerTest {
@Autowired
private UserRepository userRepository;
@Test
@Transactional
void signupManyPeople() {
int totalUsers = 1_000_000;
int batchSize = 10_000; // 1만개씩 저장하고 List를 비울 예정
List<User> userBatch = new ArrayList<>(batchSize);
Set<String> emailSet = new HashSet<>();
Set<String> nicknameSet = new HashSet<>();
for (int i = 0; i < totalUsers; i++) {
String email;
do {
email = generateRandomEmail();
} while (!emailSet.add(email)); // 이메일 중복 확인하고 추가
String nickname;
do {
nickname = generateRandomNickname();
} while (!nicknameSet.add(nickname)); // 닉네임 중복 확인 후 추가
String role = ThreadLocalRandom.current().nextBoolean() ? "ADMIN" : "USER";
UserRole userRole = UserRole.of(role); // role 랜덤 배정 후 변환
User user = new User(email, "password123", userRole, nickname);
userBatch.add(user);
if (userBatch.size() == batchSize) {
userRepository.saveAll(userBatch);
userBatch.clear(); // 배치 저장 후 초기화
}
}
if (!userBatch.isEmpty()) {
userRepository.saveAll(userBatch);
}
}
private String generateRandomEmail() {
return UUID.randomUUID().toString().substring(0, 8) + "@example.com";
}
private String generateRandomNickname() {
return "User_" + UUID.randomUUID().toString().substring(0, 8);
}
}
배치사이즈를 설정하여 배치 구간별로 Insert 를 진행하여 데이터를 생성합니다. 이메일과 닉네임은 `UUID` 랜덤 메서드를 사용하여 생성했고, Set 을 사용하여 중복을 방지하는 방식으로 생성했습니다.
위의 방식대로 작성하고 프로그램을 가동해보니 인텔리제이에서 열심히 쿼리를 보내고 있는데, 5분, 10분이 지나도 끝날기미가 보이지 않았습니다. 시간이 지나고 나니 인텔리제이 내부의 힙메모리가 초과되어 프로그램이 중단되었고 첫번째 방식은 실패하였습니다.
2. 두번째 시도
@SpringBootTest
public class UserPerformanceTest {
@Autowired
private UserRepository userRepository;
@Autowired
private EntityManager entityManager;
@Test
@Transactional
void createBatchUsers() {
int totalUsers = 1_000_000; // 생성할 총 유저 수
int batchSize = 500; // 한 번에 처리할 배치 크기
List<User> userBatch = new ArrayList<>(batchSize); // 배치 저장 리스트
Set<String> emailSet = new HashSet<>(); // 이메일 중복 체크용
Set<String> nicknameSet = new HashSet<>(); // 닉네임 중복 체크용
for (int i = 0; i < totalUsers; i++) {
String email;
do {
email = generateRandomEmail();
} while (!emailSet.add(email)); // 이메일 중복 확인
String nickname;
do {
nickname = generateRandomNickname();
} while (!nicknameSet.add(nickname)); // 닉네임 중복 확인
String role = ThreadLocalRandom.current().nextBoolean() ? "ADMIN" : "USER";
UserRole userRole = UserRole.of(role); // 랜덤 역할 할당
User user = new User(email, "password123", userRole, nickname);
userBatch.add(user);
// 배치 크기만큼 쌓이면 저장하고 초기화
if (userBatch.size() == batchSize) {
saveBatch(userBatch);
userBatch.clear(); // 리스트 초기화
}
}
// 마지막에 남은 배치 저장
if (!userBatch.isEmpty()) {
saveBatch(userBatch);
}
}
// 배치 저장을 위한 메서드
private void saveBatch(List<User> userBatch) {
for (int i = 0; i < userBatch.size(); i++) {
entityManager.persist(userBatch.get(i)); // 유저 객체 저장
if (i > 0 && i % 50 == 0) { // 일정 간격마다 flush
entityManager.flush();
entityManager.clear(); // 메모리 최적화를 위해 엔티티 매니저 클리어
}
}
entityManager.flush(); // 남은 객체들을 한번에 flush
entityManager.clear(); // 객체 매니저를 초기화
}
// 랜덤 이메일 생성
private String generateRandomEmail() {
return UUID.randomUUID().toString().substring(0, 8) + "@example.com";
}
// 랜덤 닉네임 생성
private String generateRandomNickname() {
return "User_" + UUID.randomUUID().toString().substring(0, 8);
}
}
두번째는 배치사이즈가 너무 컸다고 생각이 들어 500정도로 수정하고, EntityManager 를 통해서 유저들을 Insert 하는 방식을 사용했고 EntityManager 도 특정한 구간마다 clear() 메서드로 초기화 해주는 과정으로 메모리를 절약해보자고 생각했습니다. 또한 Gradle 로 빌드를 진행하는 것 보다 인텔리제이 IDE 를 사용하는 것이 중간 과정이 간단하여 빠르고 효율적이다고 하여 설정 변경을 해주었습니다.
하지만 이렇게 해도 메모리가 올라가는 속도가 늦어질 뿐, 결국 힙메모리 초과에 도달하여 100만개 생성에는 실패했습니다. 그래서 하이버네이트에서 보내고있는 쿼리를 보니 끊어서 보내더라도 Insert 문이 결국 100만번 전송되어야 하는 구조라는것을 깨닫고 아예 한번의 Insert 에서 여러명의 유저정보를 넣는 방식으로 하자 라는 생각에 도달했습니다.
3. 세번째 시도
그래서 Native Query를 사용해서 String 타입으로 쿼리를 작성하여 하나의 Insert 문에서 여러명의 유저를 넣는 Bulk Insert 방식으로 변경하였습니다.
@SpringBootTest
public class UserPerformanceTest {
@Autowired
private EntityManager entityManager;
@Test
@Transactional
void createBatchUsers() {
int totalUsers = 1_000_000; // 생성할 총 유저 수
int batchSize = 500; // 한 번에 처리할 배치 크기
StringBuilder sql = new StringBuilder("INSERT INTO users (email, password, user_role, nickname) VALUES ");
Set<String> emailSet = new HashSet<>(); // 이메일 중복 체크용
Set<String> nicknameSet = new HashSet<>(); // 닉네임 중복 체크용
for (int i = 0; i < totalUsers; i++) {
String email;
do {
email = generateRandomEmail();
} while (!emailSet.add(email)); // 이메일 중복 확인
String nickname;
do {
nickname = generateRandomNickname();
} while (!nicknameSet.add(nickname)); // 닉네임 중복 확인
String role = ThreadLocalRandom.current().nextBoolean() ? "ADMIN" : "USER";
// VALUES 부분을 생성
sql.append("(")
.append("'").append(email).append("', ")
.append("'password123', ")
.append("'").append(role).append("', ")
.append("'").append(nickname).append("'), ");
// 배치 크기마다 한 번에 INSERT 문을 실행
if ((i + 1) % batchSize == 0 || i == totalUsers - 1) {
// 마지막 값 뒤에 쉼표가 있으면 제거
if (sql.charAt(sql.length() - 2) == ',') {
sql.setLength(sql.length() - 2); // 마지막 쉼표 제거
}
entityManager.createNativeQuery(sql.toString()).executeUpdate();
sql.setLength(0); // SQL 초기화
sql.append("INSERT INTO users (email, password, user_role, nickname) VALUES ");
}
}
}
// 랜덤 이메일 생성
private String generateRandomEmail() {
return UUID.randomUUID().toString().substring(0, 8) + "@example.com";
}
// 랜덤 닉네임 생성
private String generateRandomNickname() {
return "User_" + UUID.randomUUID().toString().substring(0, 8);
}
}
위와같은 방식을 사용하여 하나의 Insert 쿼리문 안에 배치사이즈 500으로 한번에 500명의 데이터를 나눠서 100만명 까지 넣는 쿼리로 수정하였습니다. 결과는 전체 데이터를 생성하는데 40~50초의 시간이 걸렸고, 배치사이즈를 1000으로 늘려서 한번 더 테스트를 진행하니 37초까지 줄어드는 것을 확인할 수 있었습니다.
이 방식으로 진행했을 때 4096mb 의 메모리를 초과하여 프로그램이 중단되던 문제가 500mb 의 힙메모리를 사용하면서 안정적으로 생성하는 것을 확인했습니다.
배치사이즈를 조금씩 조절 해가면서 힙메모리 사용에 부담이 되지않으면서 쿼리를 보내는 주기를 수정해 나가면 시간을 더 단축할 수 있을 것 같습니다.
이렇게 생성을 하는것은 성공적으로 끝냈고, 조회하는 로직을 작성했습니다.
void searchUserByNickname_in_million_data() throws Exception {
if(savedNickname == null) {
throw new Exception("닉네임이 생성되지 않았습니다.");
}
entityManager.clear();
mvc.perform(get("/users/search-by-nickname")
.param("nickname", savedNickname)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nickname").value(savedNickname));
}
조회하는 로직과 생성 로직을 따로 작성한 뒤 통합 테스트로 진행하려고 했습니다. 조회하는 API 의 서비스 로직은 다음과 같습니다.
Service
public UserResponse findByNickname(String nickname) {
User user = userRepository.findByNicknameUsingIndex(nickname)
.orElseThrow(() -> new InvalidRequestException("User not found"));
return new UserResponse(user.getId(), user.getEmail(), user.getNickname());
}
Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 기본 닉네임으로 조회하는 JPA 쿼리
Optional<User> findByNickname(String nickname);
}
위와같은 방식으로 조회하니 1차 캐시를 지우고 조회 했을때 최저 3.5초 까지 조회하는 시간이 걸렸습니다. 100만건 중에서 3.5초면 빠르지 않을까 생각할 수 있지만, 저희가 일반적으로 사용하는 조회하는 API 에서 1초 이상 걸리면 매우 느리다고 느끼는 것 처럼 3.5초면 상당한 시간이 걸리는 것입니다.
이러한 이유는 기본적인 JPA 쿼리를 통해 조회를 하면 전체 테이블 조회를 진행하여 원하는 값을 일찍 찾아도 테이블 크기만큼 전부 스캔을 진행하고 중복된 결과 등을 찾아서 반환하기 때문에 오래걸리게 됩니다.
어떤 방식을 이용하여 개선할 수 있을까 하고 찾던 중 인덱스 방식이 있다는 것을 알게되었습니다.
인덱스 방식을 활용하면 데이터를 조회하는 시간을 `log n` 만큼 줄이게 되어 100만건의 데이터를 조회하는데도 실제로 20번의 처리로 모두 조회를 완료하게 됩니다.
인덱스를 적용하는 방법은 다음과 같습니다.
1. Entity 에 index 설정
@Getter
@Entity
@NoArgsConstructor
@Table(name = "users", indexes ={
@Index(name = "idx_nickname", columnList = "nickname")
})
public class User extends Timestamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String email;
private String password;
//...
}
위와 같이 조회를 원하는 컬럼에 인덱스를 설정합니다.
2. JPQL 을 통해 인덱스를 명시적으로 사용
엔티티에 인덱스를 적용하고, 인덱스가 적용된 컬럼을 통해 조회를 하면 자동으로 Spring boot 에서 인덱스를 적용하여 조회를 하지만 더 확실히 인덱스를 사용하고 싶은 경우에는 JPA Repository 에서 사용해줄 수 있습니다.
public interface UserRepository extends JpaRepository<User, Long> {
// 기본 닉네임으로 조회하는 JPA 쿼리
Optional<User> findByNickname(String nickname);
// 인덱스를 사용해서 닉네임을 조회
@Query(value = "SELECT * FROM users USE INDEX (idx_nickname) WHERE nickname = :nickname", nativeQuery = true)
Optional<User> findByNicknameUsingIndex(@Param("nickname") String nickname);
}
아래 추가된 `findByNicknameUsingIndex` 메서드를 사용하면 전체 데이터를 조회하는 시간이 매우 효율적으로 줄어듭니다. 저의 경우에선 100만건을 생성할떄 조회할 때 1.1 초가 걸리던 환경에서 0.3초까지 줄어든것을 확인할 수 있었습니다.
큰 차이가 아니라고 생각이 들 수 있지만, 데이터 테이블의 전체 크기가 커지면 커질 수록 이 효과는 드라마틱하게 증가합니다.
하지만 그렇다고 해서 인덱스를 사용하는 방식을 반드시 사용해야 하는 것은 아닙니다. 지금과 같이 데이터를 조회하는 환경에서는 매우 효율적이지만, 쓰기 작업이 많은 서비스라면 인덱스부분이 추가적으로 들어가게 되는 것이기 때문에 DB에 부담이 될 수 있습니다. 따라서 주어진 조건에 맞게 골라서 사용하는 것이 좋습니다.
인덱스를 사용하는 방식 말고도 외부 라이브러리를 추가하여 전문검색 엔진을 사용하는 방법이나, 캐시에 자주 사용되는 닉네임등을 저장하여 사용하는 방식들도 있지만 이번 프로젝트에서는 인덱스를 다뤄보았습니다.
이렇게 저의 개인 프로젝트는 마무리 하도록 하겠습니다. 읽어주셔서 감사합니다.
'Project' 카테고리의 다른 글
[코틀린 프로젝트] - 새로운 프로젝트 구상(4/13~4/26) (2) | 2025.04.27 |
---|---|
[프로젝트] 식당 예약 및 웨이팅 플랫폼 개발 - 주제선정 (2) | 2024.12.11 |
[팀 프로젝트] 아웃소싱 - 배달 어플리케이션 구현 (3) | 2024.11.07 |
[기능 개선] - Refactoring, Test, AOP (3) | 2024.10.31 |
[기능 개선] - 일정 관리 앱 (0) | 2024.10.31 |