리팩토링 - yi5oyu/writemd GitHub Wiki

N+1 쿼리 문제

데이터 삭제 배치 처리

UserService.deleteUserData(), ChatService.deleteAllSessions()

개수에 따라 여러번의 개별 쿼리 실행

\\ UserService.deleteUserData()
    @Transactional
    public void deleteUserData(Long userId) {
        Users user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));

        // 채팅 및 세션 삭제
        List<Notes> userNotes = noteRepository.findByUsers_Id(userId);
        for (Notes note : userNotes) {
            List<Sessions> sessions = sessionRepository.findByNotes_id(note.getId());
            sessionRepository.deleteAll(sessions);
        }

        // 메모 삭제
        List<Memos> userMemos = memoRepository.findByUsers_Id(userId);
        memoRepository.deleteAll(userMemos);

        // API 키 삭제
        List<APIs> userApis = apiRepository.findByUsersId(userId);
        apiRepository.deleteAll(userApis);

        // 템플릿 삭제
        List<Folders> userFolders = folderRepository.findByUsers(user);
        folderRepository.deleteAll(userFolders);

        // 노트 삭제
        noteRepository.deleteAll(userNotes);
    }

문제 해결

배치 처리: 모든 데이터를 한 번에 처리

// QueryDSL 

// UserRepositoryCustom
    public interface UserRepositoryCustom {

        Optional<Long> findIdByGithubId(String githubId);

        Optional<String> findPrincipalNameByGithubId(String githubId);

        void deleteUserDataBatch(Long userId);
    }

// UserRepositoryCustomImpl
    @Override
    public void deleteUserDataBatch(Long userId) {
        // 순서대로 삭제
        queryFactory.delete(qChats)
            .where(qChats.sessions.notes.users.id.eq(userId))
            .execute();

        queryFactory.delete(qSessions)
            .where(qSessions.notes.users.id.eq(userId))
            .execute();

        queryFactory.delete(qTemplates)
            .where(qTemplates.folders.users.id.eq(userId))
            .execute();

        queryFactory.delete(qFolders)
            .where(qFolders.users.id.eq(userId))
            .execute();

        queryFactory.delete(qTexts)
            .where(qTexts.notes.users.id.eq(userId))
            .execute();

        queryFactory.delete(qNotes)
            .where(qNotes.users.id.eq(userId))
            .execute();

        queryFactory.delete(qMemos)
            .where(qMemos.users.id.eq(userId))
            .execute();

        queryFactory.delete(qAPIs)
            .where(qAPIs.users.id.eq(userId))
            .execute();

        queryFactory.delete(qUsers)
            .where(qUsers.id.eq(userId))
            .execute();
    }

// UserService.deleteUserData()
    @Transactional
    public void deleteUserData(Long userId) {
        // 모든 데이터 삭제
        userRepository.deleteUserDataBatch(userId);
    }

EAGER 로딩 N+1 문제 해결

TemplateService.getTemplates()

사용자의 템플릿 목록을 조회할 때, 폴더 수에 비례하여 데이터베이스 쿼리가 증가하는 N+1 문제

// Folders 엔티티
    @Entity
    public class Folders {
        @OneToMany(mappedBy = "folders", 
                   cascade = CascadeType.ALL, 
                   orphanRemoval = true, 
                   fetch = FetchType.EAGER)
        private List<Templates> templates = new ArrayList<>();
    }

// getTemplates 메소드
    @Transactional(readOnly = true)
    public List<FolderDTO> getTemplates(Long userId) {
        // 사용자 조회 쿼리
        Users user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        // 폴더 조회 쿼리
        List<Folders> userFolders = folderRepository.findByUsers(user);

        List<FolderDTO> folderDTOs = new ArrayList<>();

        for (Folders folder : userFolders) {
            List<TemplateDTO> templateDTOs = new ArrayList<>();
            // template의 수에 따라 추가 쿼리 발생
            for (Templates template : folder.getTemplates()) {
            ...
            }
        }
        return folderDTOs;
    }

문제 해결

JOIN FETCH 사용(1개 쿼리로 모든 데이터 조회)

// QueryDSL
    @Override
    public List<Folders> findByUsersWithTemplates(Users user) {
        return queryFactory
            .selectFrom(qFolders)
            .leftJoin(qFolders.templates, qTemplates).fetchJoin()
            .where(qFolders.users.eq(user))
            .fetch();
    }

// 
    @Transactional(readOnly = true)
    public List<FolderDTO> getTemplates(Long userId) {
        // 사용자 조회 쿼리
        Users user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        // 폴더와 템플릿 1개 쿼리로 모두 조회
        List<Folders> userFolders = folderRepository.findByUsersWithTemplates(user);

        List<FolderDTO> folderDTOs = new ArrayList<>();

        for (Folders folder : userFolders) {
            List<TemplateDTO> templateDTOs = new ArrayList<>();
            // 이미 로드된 데이터로 추가 쿼리 없이 처리
            for (Templates template : folder.getTemplates()) {
            ...
            }
        }
        return folderDTOs;
    }

캐싱

JSON 파일 읽기

새 사용자 등록 시마다 템플릿 JSON 파일을 디스크에서 읽어오는 병목

//  매번 JSON 파일 읽기
@Transactional
public Users saveUser(String githubId, ...) {
    if (existingUser.isEmpty()) {
        try {
            Resource myResource = new ClassPathResource("data/template.json");
            myTemplates = objectMapper.readValue(myResource.getInputStream(), ...);
        } catch (IOException e) {
            myTemplates = Collections.emptyList();
        }
        
        try {
            Resource resource = new ClassPathResource("data/git_template.json");
            gitTemplates = objectMapper.readValue(resource.getInputStream(), ...);
        } catch (IOException e) {
            gitTemplates = Collections.emptyList();
        }
    }
}

문제 해결

애플리케이션 시작시 JSON 데이터 캐싱처리

// 캐시 초기화
@Component
@RequiredArgsConstructor
public class CacheInitializer {
    @EventListener(ApplicationReadyEvent.class)
    public void initializeCache() {
        loadAndCacheTemplateData("template-data", "my-templates", "data/template.json");
        loadAndCacheTemplateData("template-data", "git-templates", "data/git_template.json");
    }
}

// 캐싱 서비스
@Service
public class CachingDataService {
    @Cacheable(value = "template-data", key = "'my-templates'")
    public List<Map<String, String>> getMyTemplates() {
        return Collections.emptyList();
    }
}

// 캐시에서 조회
public Users saveUser(String githubId, ...) {
    if (existingUser.isEmpty()) {
        List<Map<String, String>> myTemplates = cachingDataService.getMyTemplates();
        List<Map<String, String>> gitTemplates = cachingDataService.getGitTemplates();
    }
}

사용자 조회 중복

API 호출 시마다 사용자 정보를 DB에서 조회하는 반복 쿼리

// 여러 메소드에서 DB에서 사용자 조회
public NoteDTO createNote(String githubId, String noteName) {
    Users user = userRepository.findByGithubId(githubId)
        .orElseThrow(() -> new RuntimeException("유저를 찾을 수 없습니다."));
}

public UserDTO userInfo(String githubId) {
    Users user = userRepository.findByGithubId(githubId)
        .orElseThrow(() -> new RuntimeException("유저 찾을 수 없음"));
}

public Memos saveMemo(String githubId, String text, Long memoId) {
    Users user = userRepository.findByGithubId(githubId)
        .orElseThrow(() -> new RuntimeException("유저 찾을 수 없음"));
}

...

문제 해결

사용자 정보 캐싱

@Service
public class CachingDataService {
    @Cacheable(value = "user", key = "#githubId")
    public UserDTO findUserByGithubId(String githubId) {
        Users user = userRepository.findByGithubId(githubId)
            .orElseThrow(() -> {
                return new RuntimeException("유저 찾을 수 없음: " + githubId);
            });

        return UserDTO.builder()
            .userId(user.getId())
            .githubId(user.getGithubId())
            .name(user.getName())
            .avatarUrl(user.getAvatarUrl())
            .htmlUrl(user.getHtmlUrl())
            .build();
    }
}

// 캐시에서 사용자 조회(DB 호출 감소)
public NoteDTO createNote(String githubId, String noteName) {
    Users user = cachingDataService.findUserByGithubId(githubId);
}

...

API 키 반복 조회

AI 채팅 요청 시마다 사용자의 API 키를 DB에서 조회

    // 매번 DB에서 API 키 조회
public String sendChatMessage(Long userId, Long apiId, String message) 
    APIs apiKey = apiRepository.findById(apiId)
        .orElseThrow(() -> new RuntimeException("API 키를 찾을 수 없습니다."));
    
    String apiKeyValue = apiKey.getApiKey();
    // AI 서비스 호출...
}

문재해결

API 키 캐싱

@Service
@RequiredArgsConstructor
public class CachingDataService {
    
    private final ApiRepository apiRepository;
    
    @Cacheable(value = "api-key", key = "#userId + ':' + #apiId")
    public APIDTO findApiKey(Long userId, Long apiId) {
        Optional<APIs> apiEntity = apiRepository.findById(apiId);
        
        if (apiEntity.isPresent()) {
            APIs api = apiEntity.get();
            return APIDTO.builder()
                .apiId(api.getId())
                .aiModel(api.getAiModel())
                .apiKey(api.getApiKey())
                .build();
        }
        return null;
    }
}

// 캐시에서 API 키 조회
@Service
@RequiredArgsConstructor
public class ChatService {
    
    private final CachingDataService cachingDataService;
    
    public String sendChatMessage(Long userId, Long apiId, String message) {
        APIDTO apiKey = cachingDataService.findApiKey(userId, apiId);
        
        if (apiKey != null) {
            String apiKeyValue = apiKey.getApiKey();
            // AI 서비스 호출...
        }
        return "응답";
    }
}
⚠️ **GitHub.com Fallback** ⚠️