어느 평범한 오후, 사용자 문의 시스템에 새로운 메시지가 도착했습니다.
"안녕하세요. 디스코드에서 알람을 삭제하려고 하는데 계속 실패한다고 나와요. 확인해 주세요."
처음에는 단순한 사용자 조작 실수라고 생각했습니다. 하지만 직접 테스트해보니 정말로 알람 삭제가 작동하지 않았습니다.
아래 코드, 엔티티명, API 경로, 데이터 값은 모두 임의의 예시이며 실제 서비스와 무관합니다.
데이터베이스를 확인하자 곧바로 원인을 의심할 만한 상황이 눈에 들어왔습니다.
동일한 사용자가 동일한 맵에 대해 두 번 알람을 등록한 것이었습니다.
동일한 사용자의 동일한 맵에 대한 알람이 두 번 등록되어 있었던 것입니다.
-- 실제 DB 상황
user_id | map_name | created_at
--------|-------------|------------
1234 | sample_map | 2024-01-15 14:30:21
1234 | sample_map | 2024-01-15 14:30:22 -- 1초 차이로 중복!
'어? 왜 같은 알람이 두 번 들어가 있지?'
이때 깨달았습니다. 사용자 문의 기능을 만들어놓은 게 정말 다행이었다고. 만약 이 기능이 없었다면 이런 숨어있는 버그를 언제 발견할 수 있었을까요?
처음에는 당연히 제가 개선한 알람 등록 로직에 문제가 있을 것이라고 생각했습니다.
// 기존 알람 등록 로직 (간소화)
public void registerAlarm(AlarmRequest request) {
// 중복 체크
if (alarmRepository.existsByUserAndMap(user, map)) {
throw new AlreadyRegisteredException("이미 등록된 알람입니다.");
}
// 알람 등록
Alarm alarm = new Alarm(user, map);
alarmRepository.save(alarm);
}
코드만 보면 문제가 없어 보였습니다. 중복 체크도, 예외 처리도 정상적으로 되어 있었기 때문입니다.
그렇다면 왜 같은 알람이 두 번 들어간 걸까요?
디버깅 로그를 찍어가며 확인한 결과, 알람 로직 자체는 문제가 없었습니다.
그때 한 가지 사실을 깨달았습니다. 디스코드 부분은 제가 개발한 게 아니었습니다.
'혹시... 디스코드에서 요청이 두 번 오는 건 아닐까?'
디스코드 설정 코드를 확인하니 원인이 바로 드러났습니다.
@Configuration
public class DiscordConfig {
@Bean
public JDA jda() {
return JDABuilder.createDefault(token)
.addEventListeners(new CommandListener())
.build();
}
}
@Component
public class DiscordBotStarter {
@PostConstruct
public void startBot() {
JDABuilder.createDefault(token)
.addEventListeners(new CommandListener())
.build();
}
}
문제: 동일한 봇이 두 번 생성되어 명령어가 중복 처리되고 있었습니다!
중복된 설정을 제거하고, 하나의 Bean으로만 관리하도록 변경했습니다.
@Component
public class DiscordBotStarter {
@Bean
public JDA jda() {
return JDABuilder.createDefault(token)
.addEventListeners(new CommandListener())
.build();
}
}
중복 등록 문제는 해결했지만, 근본적인 동시성 문제와 함께 발견된 키워드 검색 문제도 함께 해결했습니다.
@Transactional // 동시성 문제 해결
public void registerAlarm(AlarmRequest request) {
try {
Alarm alarm = new Alarm(user, map);
alarmRepository.save(alarm);
} catch (DataIntegrityViolationException e) {
// DB 제약조건에 의한 중복 방지
throw new AlreadyRegisteredException("이미 등록된 알람입니다.");
}
}
@Transactional
public void deleteAlarm(AlarmRequest request) {
int deletedCount = alarmRepository.deleteByUserIdAndMapName(
user.getUserId(), request.mapName()
);
if (deletedCount == 0) {
throw new NotFoundAlarmException("등록된 알람을 찾을 수 없습니다.");
}
}
코드 리팩토링 중, 빈 문자열 검색이 전체 조회로 이어지는 부분을 발견했습니다.
이는 원래 의도된 로직이지만, SQL 상에서는 LIKE '%%'
라는 부작용으로 구현되어 있었습니다.
개선 후에는 이를 명시적으로 전체 조회 처리하도록 보완했습니다.
// 기존 문제 코드
@Query("SELECT m.mapName FROM MapleMap m WHERE m.mapName LIKE CONCAT('%', :keyword, '%')")
List<String> findByKeyword(@Param("keyword") String keyword);
// 빈 문자열 입력 시 LIKE '%%' 가 되어 모든 데이터 반환
해결책: null 키워드 처리 로직
// 개선된 Repository
@Query("""
SELECT m.mapName FROM MapleMap m
WHERE (:keyword IS NULL OR REPLACE(m.mapName, ' ', '') LIKE CONCAT('%', REPLACE(:keyword, ' ', ''), '%'))
ORDER BY m.mapName ASC
""")
List<String> findMapNamesByKeyword(@Param("keyword") String keyword);
// 개선된 Service
public List<String> searchMapNames(String keyword, int limit) {
// 빈 문자열이나 null일 때는 null로 전달하여 필터링 없이 전체 조회
String processedKeyword = (keyword == null || keyword.trim().isEmpty()) ? null : keyword.trim();
List<String> names = mapRepository.findMapNamesByKeyword(processedKeyword);
return names.stream().limit(Math.max(1, limit)).toList();
}
기존: 빈 문자열 → 쿼리의 부작용(LIKE '%%')으로 전체 조회
개선: 빈 문자열 → Service에서 null 처리 → Repository에서 조건 분기 (명시적 설계)
테스트 결과, 모든 기능이 정상적으로 동작했고 부가적인 문제까지 함께 해결할 수 있었습니다.
사용자 피드백의 가치
만약 사용자 문의 기능이 없었다면 이런 버그를 언제 발견했을까요? 사용자는 최고의 테스터입니다.
다른 사람 코드도 의심하자
처음에는 제 코드만 의심했습니다. 하지만 모든 코드는 의심의 대상이 될 수 있습니다. 특히 팀 프로젝트에서는 더욱 그렇습니다.
로그의 중요성
디버깅 로그를 하나하나 찍지 않았다면 문제를 찾기 훨씬 어려웠을 것입니다. 로그는 개발자의 눈입니다.
방어적 프로그래밍
트랜잭션과 DB 제약조건으로 이중 보안을 구축했습니다. "설마"는 금물입니다.
한 줄의 사용자 문의가 시스템의 숨겨진 문제를 드러내고, 더 견고한 서비스를 만들 수 있게 해주었습니다.
혹시 여러분도 "내 코드는 완벽해"라고 생각하고 계신가요?
한 번 더 의심해 보세요. 그 안에 뜻밖의 발견이 숨어 있을지도 모릅니다.