📢 공지합니다
이 게시글은 메인 페이지에 항상 고정되어 표시됩니다.
https://balhae.tistory.com/219
🍊구름톤(9oormthon) 11기 대상 후기
📝 지원 동기와 과정대학교 4학년, 졸업을 앞둔 시점에서 문득 돌아보니 지금까지 만든 프로젝트들이 너무 의미없게 느껴졌습니다. 진짜 의미있는 무언가를 만들어보고 싶다는 생각이 들었고,
balhae.tistory.com
이번 연휴 10일 동안은 작년 11월에 참여했던 9oormthon 제주 버스 알림콜 프로젝트를 다시 리메이크하기로 했다.
리메이크를 결심한 이유는, 당시에는 백엔드 개발 역량이 아직 미숙해서 아쉬운 점이 많았기 때문이다.
이번에는 그때의 부족함을 보완하고, 특히 조회 성능 최적화에 집중해 학습해볼 예정이다.
왼쪽은 리팩터링 이전, 오른쪽은 리팩터링 이후의 모습이다.
이전 코드에서는 전반적으로 구조가 복잡하고 일관성이 부족했지만,
리팩터링 후에는 코드의 가독성과 유지보수성이 한층 향상되었다.
![]() |
![]() |
확실히 훨씬 깔끔해졌다.
이전에는 구조가 제대로 잡혀 있지 않아, 코드 흐름을 따라가기가 너무 헷갈렸다.
이번에 DDD 도메인 구조를 적용하면서 역할이 명확해지고, 유지보수도 훨씬 수월해졌다.
![]() |
![]() |
이전에는 엔티티와 컬럼 이름이 제각각이라 일관성이 없었고,
Note 엔티티에는 정규화가 제대로 이루어지지 않아 한 엔티티가 너무 많은 역할을 맡고 있었다.
그래서 이번 리팩터링에서는 Note 엔티티를 BusLog로 변경하고,
알림(BusAlarm)과 즐겨찾기(BusFavorite) 기능은 별도의 엔티티로 분리해 관리하도록 했다.
또한 Member 테이블에는 컬럼이 너무 적어서, 최소한의 정보라도 담기 위해 name 컬럼을 추가했다
이전에는 코드 스타일이 정말 극악이었다…
지금 다시 보니까, 그때는 진짜 내가 얼마나 미숙했는지 느껴진다...
이걸 리팩터링하는 데만 3일이 걸렸으니 말 다 했다.
거의 전부 새로 짰다고 봐도 무방하다.
그래서 여기서는 대표적으로 버스 관련 컨트롤러와 서비스 로직만 가져와서
리팩터링 전후를 비교해보려 한다.
<리펙터링 전>
@RestController
@RequiredArgsConstructor
@Tag(name = "사용자의 버스 기록 정보")
@RequestMapping("/api/bus")
@Slf4j
public class NoteController {
private final NoteService noteService;
private final StationService stationService;
private final NoteRepository noteRepository;
@GetMapping("/list")
@Operation(summary = "즐겨찾기 버스 기록 저장 보여주기")
public SuccessResponse<ListResult<NoteResponse>> readAll(@RequestAttribute("id") String userId) {
ListResult<NoteResponse> note = noteService.findAll(userId);
return SuccessResponse.ok(note);
}
@PostMapping("/save")
@Operation(summary = "버스 데이터 정보들이 쭉 넘어옴 여기서 전화 콜 및 일시 저장해줘야됨 즉 즐격찾기 false")
public SuccessResponse<NoteSaveResponse> create(@Valid
@RequestAttribute("id") String userId,
@RequestBody NoteRequest req
) {
SingleResult<Note> note = noteService.save(req,userId);
String stationId = req.stationId();
int station = req.station();
String busId =req.notionId();
// 5초마다 API 호출 시작
stationService.scheduleBusApiCall(userId,busId, stationId,station);
// Note 객체를 NoteSaveResponse로 변환
NoteSaveResponse response = NoteSaveResponse.of(note.getData());
// 변환된 NoteSaveResponse를 응답으로 반환
return SuccessResponse.ok(response);
// 즉시 note 객체 응답 반환
}
@PostMapping("/favorite")
@Operation(summary = "즐겨 찾기 API")
public ResponseEntity<String> delete(@Valid @RequestBody NoteFavoriteRequest req){
Optional<Note> findNote =noteRepository.findById(req.id());
if (findNote.isPresent()) {
Note note = findNote.get();
if(req.favorite()){
noteRepository.updelete(req,2);
}else{
if(note.getFavorite_pre()!=1){
noteRepository.delete(req);
}
}
} else {
// 값이 없을 때의 처리 로직
throw new NoSuchElementException("해당하는 사용자가 없습니다.");
}
// 즉시 응답 반환
return ResponseEntity.ok("설정 완료");
}
컨트롤러에 서비스 로직이 섞여 있고… 진짜 최악이었다.
그때는 레이어드 아키텍처 개념도 제대로 잡혀 있지 않아서,
비즈니스 로직이 컨트롤러 안에 뒤섞여 있었다.
게다가 커스텀 응답 방식도 제각각이라 일관성이 없었고,
SpringSecurityConfig 역시 구조가 정리되어 있지 않아 여러모로 불완전한 코드였다.
@Service
@EnableScheduling
@Slf4j
@RequiredArgsConstructor
public class StationService {
private final MemberRepository memberRepository;
private final RestTemplate restTemplate = new RestTemplate();
// 사용자별 상태를 저장하기 위한 Map
private final Map<String, String> userBusIdMap = new ConcurrentHashMap<>();
private final Map<String, String> userStationIdMap = new ConcurrentHashMap<>();
private final Map<String, Integer> userStationMap = new ConcurrentHashMap<>();
private final Map<String, Boolean> userStopCallingMap = new ConcurrentHashMap<>();
private final Map<String, AtomicInteger> userCntMap = new ConcurrentHashMap<>();
private final Map<String, Set<Integer>> userSeenBusesMap = new ConcurrentHashMap<>();
private static final long RUNNING_DURATION_HOURS = 3; // 3시간
private final LocalDateTime startTime = LocalDateTime.now();
@Value("${twilio.account-sid}")
private String accountSid;
@Value("${twilio.auth-token}")
private String authToken;
// 사용자별 API 호출 상태 설정 메서드
@Async
public void scheduleBusApiCall(String userId, String busId, String stationId, int station) {
userBusIdMap.put(userId, busId);
userStationIdMap.put(userId, stationId);
userStationMap.put(userId, station);
userStopCallingMap.put(userId, false); // 호출 중단 플래그 초기화
userSeenBusesMap.put(userId, new HashSet<>()); // 중복 체크 목록 초기화
userCntMap.put(userId, new AtomicInteger(0)); // 카운터 초기화
}
// 5초마다 실행되는 메서드 - 사용자별로 독립적으로 동작
@Scheduled(fixedRate = 5000)
public void callBusApi() {
// 현재 시간이 시작 시간으로부터 3시간 경과했는지 확인
if (ChronoUnit.HOURS.between(startTime, LocalDateTime.now()) >= RUNNING_DURATION_HOURS) {
log.info("3시간이 경과하여 스케줄링을 중단합니다.");
return; // 스케줄링 중단
}
userStationIdMap.forEach((userId, stationId) -> {
String busId = userBusIdMap.get(userId);
Integer station = userStationMap.get(userId);
Boolean stopCalling = userStopCallingMap.getOrDefault(userId, true);
AtomicInteger cnt = userCntMap.getOrDefault(userId, new AtomicInteger(0));
Set<Integer> seenBuses = userSeenBusesMap.getOrDefault(userId, new HashSet<>());
if (stationId != null && !stopCalling) {
String url = "https://bus.jeju.go.kr/api/searchArrivalInfoList.do?station_id=" + stationId;
ResponseEntity<BusInfo[]> response = restTemplate.getForEntity(url, BusInfo[].class);
BusInfo[] buses = response.getBody();
log.info("Twilio Account SID: {}", accountSid);
log.info("Twilio Auth Token: {}", authToken);
if (buses != null) {
log.info("API 응답 데이터: {}", Arrays.toString(buses));
log.info("현재 {} 사용자의 cnt 값: {}", userId, cnt);
Arrays.stream(buses)
.filter(bus -> busId.equals(bus.getRouteNum()) && bus.getRemainStation() == station)
.forEach(bus -> {
if (!seenBuses.contains(bus.getVhId())) {
log.info("{} 사용자에게 조건을 만족하는 새로운 버스를 찾았습니다: {}", userId, bus);
seenBuses.add(bus.getVhId());
cnt.incrementAndGet(); // 새로운 버스일 경우 카운터 증가
userCntMap.put(userId, cnt);
log.info("현재 {} 사용자의 cnt 값: {}", userId, cnt);
Optional<Member> byPhone = memberRepository.findByPhone2(userId);
if (byPhone.isPresent()) {
Member member = byPhone.get();
bus_call(member);
// member에 대한 로직 처리
} else {
// 값이 없을 때의 처리 로직
throw new NoSuchElementException("해당하는 사용자가 없습니다.");
}
if (cnt.get() >= 2) {
log.info("{} 사용자의 cnt가 2에 도달하여 호출을 중단합니다.", userId);
userStopCallingMap.put(userId, true);
}
} else {
log.info("{} 사용자는 이미 확인된 버스입니다: {}", userId, bus.getVhId());
}
});
if (cnt.get() < 2) {
log.info("{} 사용자에게 조건을 만족하는 새로운 버스를 찾지 못했습니다.", userId);
}
} else {
log.warn("API 응답에서 버스 데이터가 비어 있습니다.");
}
// 상태 업데이트
userSeenBusesMap.put(userId, seenBuses);
userCntMap.put(userId, cnt);
}
});
}
private void bus_call(Member member) {
Twilio.init(accountSid, authToken);
log.info("버스 콜 실행");
String phone = member.getPhone();
String substring = phone.substring(1);
String from = "+16232992975";
String to = "+82" + substring;
log.info(to);
// 사용자에게 전달할 음성 메시지 작성
Say say = new Say.Builder("안녕하세요, 이것은 당신을 위한 음성 메시지입니다.")
.language(Say.Language.KO_KR)
.voice(Say.Voice.ALICE)
.build();
VoiceResponse response = new VoiceResponse.Builder()
.say(say)
.build();
// VoiceResponse를 XML 문자열로 변환
String twiml = response.toXml();
// TwiML XML 문자열을 Twiml 객체로 감싸기
com.twilio.type.Twiml twimlObject = new com.twilio.type.Twiml(twiml);
// Twiml 객체를 사용하여 전화 걸기
Call call = Call.creator(
new com.twilio.type.PhoneNumber(to),
new com.twilio.type.PhoneNumber(from),
twimlObject
).create();
log.info("Twilio Call SID: {}", call.getSid());
}
}
// BusInfo 클래스는 API 응답에 맞게 필드 정의
class BusInfo {
@JsonProperty("ARRV_STATION_ID")
private int arrvStationId;
@JsonProperty("ROUTE_SUB_NM")
private String routeSubNm;
@JsonProperty("VH_ID")
private int vhId;
@JsonProperty("ROUTE_ID")
private int routeId;
@JsonProperty("ROUTE_NUM")
private String routeNum;
@JsonProperty("PLATE_NO")
private String plateNo;
@JsonProperty("CURR_STATION_ID")
private int currStationId;
@JsonProperty("PREDICT_TRAV_TM")
private int predictTravTm;
@JsonProperty("REMAIN_STATION")
private int remainStation;
@JsonProperty("CURR_STATION_NM")
private String currStationNm;
@JsonProperty("LOW_PLATE_TP")
private String lowPlateTp;
// Getters and Setters
public int getVhId() {
return vhId;
}
public String getRouteNum() {
return routeNum;
}
public int getRemainStation() {
return remainStation;
}
@Override
public String toString() {
return "ROUTE_NUM=" + routeNum + ", REMAIN_STATION=" + remainStation;
}
}
와… 진짜 최악 중의 최악이다.
지금 보면 딱 “비전공자가 ChatGPT 코드 베껴서 붙인 것 같은” 느낌이다.
인터페이스도 없고, 모듈화도 안 되어 있고, 주석도 엉망이다.
그냥 전부 다 뒤섞여 있는 짬뽕 코드 그 자체였다.
아무리 해커톤이라 급하게 만들었다고 해도, 이건 진짜 너무 했다 싶다.
그때는 돌아볼 여유도 없었지만, 지금 보니까 구조부터 완전 잘못된 설계였다.
<리펙터링 후>
@RestController
@RequiredArgsConstructor
@Tag(name = "사용자의 버스 기록 정보", description = "버스 로그, 즐겨찾기 관련 API")
@RequestMapping("/api/bus")
@Slf4j
public class BusLogController {
private final BusLogService busLogService;
/**
* 버스 로그 저장 API
* - 사용자 요청 시 새로운 버스 로그를 저장함
* - 즐겨찾기는 기본적으로 false로 설정
*/
@Operation(summary = "버스 로그 저장", description = "버스 데이터 정보를 저장합니다. (즐겨찾기 기본값 false)")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "버스 로그 저장 성공"),
@ApiResponse(responseCode = "400", description = "요청 데이터가 유효하지 않음"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping
public ResponseEntity<Void> saveBusLog(@Valid @RequestBody BusLogSaveReq req,
@AuthenticationPrincipal String userId) {
busLogService.postBusLogSave(req, userId);
return ResponseEntity.ok().build();
}
/**
* 버스 로그 전체 조회 API
* - 사용자의 모든 버스 로그를 조회
* - 각 로그마다 알림 여부, 즐겨찾기 여부 포함
*/
@Operation(summary = "버스 로그 전체 조회", description = "사용자의 모든 버스 기록을 조회합니다. (알림 여부, 즐겨찾기 여부 포함)")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "버스 로그 조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자 또는 버스 로그가 존재하지 않음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping
public ResponseEntity<List<BusLogAllRes>> getBusLogAll(@RequestParam String memberId,@AuthenticationPrincipal String userId) {
return ResponseEntity.ok(busLogService.getBusLogAll(memberId));
}
/**
* 즐겨찾기 상태 토글 API
* - true ↔ false 상태 전환
*/
@Operation(summary = "버스 즐겨찾기 상태 변경", description = "해당 버스 로그의 즐겨찾기 상태를 토글합니다. (활성/비활성)")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "즐겨찾기 상태 변경 성공"),
@ApiResponse(responseCode = "400", description = "요청 데이터가 유효하지 않음"),
@ApiResponse(responseCode = "404", description = "버스 로그 또는 즐겨찾기 정보 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/favorite")
public ResponseEntity<Void> updateBusFavorite(@Valid @RequestBody BusFavoriteReq req) {
busLogService.updateBusFavorite(req);
return ResponseEntity.ok().build();
}
}
훨씬 깔끔하다!! ✨
이제는 주석도 명확하게 정리되어 있고, Swagger 문서화도 잘 되어 있어서
코드가 한눈에 들어온다.
컨트롤러에는 입출력 로직만 남겨서 역할이 명확해졌고, 서비스 단에서 비즈니스 로직을 깔끔하게 처리하니까
읽기에도, 유지보수하기에도 훨씬 편하다. 이게 바로 내가 원하던 구조다
/**
* 🚌 BusLogServiceImpl
*
* <p>버스 이동 기록(BusLog) 및 즐겨찾기, 알림 관련 비즈니스 로직을 담당하는 구현체입니다.</p>
* <ul>
* <li>사용자의 버스 이용 내역 저장</li>
* <li>버스 로그 전체 조회 (알림, 즐겨찾기 포함)</li>
* <li>즐겨찾기 상태 토글</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class BusLogServiceImpl implements BusLogService {
private final BusLogRepository busLogRepository;
private final BusFavoriteRepository busFavoriteRepository;
private final BusAlarmRepository busAlarmRepository;
private final MemberRepository memberRepository;
/**
* 🪧 버스 로그 저장
*
* <p>새로운 버스 이용 로그를 생성 및 저장합니다.
* 사용자가 존재하지 않으면 예외를 발생시킵니다.</p>
*
* <p>기본적으로 생성 시 즐겨찾기는 false, 알림은 별도의 로직에서 관리됩니다.</p>
*
* @param req 버스 로그 저장 요청 DTO
* @param userId 현재 로그인된 사용자 ID
* @throws GoormBusException 사용자가 존재하지 않을 경우
*/
@Override
public void postBusLogSave(BusLogSaveReq req, String userId) {
Member findMember = memberRepository.findById(userId).orElse(null);
if (findMember == null)
throw new GoormBusException(ErrorCode.USER_NOT_EXIST);
BusLog busLog = BusLog.builder()
.member(findMember)
.departure(req.departure())
.destination(req.destination())
.station(req.station())
.notionId(req.notionId())
.stationId(req.stationId())
.build();
BusAlarm busAlarm = BusAlarm.builder()
.busLog(busLog)
.build();
BusFavorite busFavorite = BusFavorite.builder()
.busLog(busLog)
.build();
busLogRepository.save(busLog);
busFavoriteRepository.save(busFavorite);
busAlarmRepository.save(busAlarm);
}
/**
* 📋 버스 로그 전체 조회
*
* <p>특정 사용자의 모든 버스 로그를 조회합니다.
* 각 로그마다 알림 활성화 여부 및 즐겨찾기 여부를 함께 반환합니다.</p>
*
* <p>사용자 또는 관련 데이터가 없을 경우 예외를 발생시킵니다.</p>
*
* @param memberId 사용자 ID
* @return {@link BusLogAllRes} 리스트
* @throws GoormBusException 사용자, 알림, 즐겨찾기 데이터가 존재하지 않을 경우
*/
@Override
public List<BusLogAllRes> getBusLogAll(String memberId) {
Member findMember = memberRepository.findById(memberId).orElse(null);
if (findMember == null)
throw new GoormBusException(ErrorCode.USER_NOT_EXIST);
List<BusLogAllRes> result = new ArrayList<>();
result = selectV2(findMember, result);
log.info("BusLog 전체 조회 완료: memberId={}, count={}", memberId, result.size());
return result;
}
/**
* ⭐ 즐겨찾기 상태 변경
*
* <p>버스 로그에 연결된 즐겨찾기 상태를 토글(활성/비활성)합니다.
* 이미 true 상태면 비활성화하고, false면 활성화합니다.</p>
*
* @param req 즐겨찾기 상태 변경 요청 DTO
* @throws GoormBusException 버스 로그 또는 즐겨찾기 정보가 존재하지 않을 경우
*/
@Override
public void updateBusFavorite(BusFavoriteReq req) {
BusLog findBusLog = busLogRepository.findById(req.busLogId()).orElse(null);
if (findBusLog == null)
throw new GoormBusException(ErrorCode.BUS_LOG_NOT_EXIST);
BusFavorite findBusFavorite = busFavoriteRepository.findByBusLog(findBusLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_FAVORITE_NOT_EXIST));
if (findBusFavorite.isFavoriteFlag()) {
findBusFavorite.deactivateIsFavoriteFlag();
} else {
findBusFavorite.activateIsFavoriteFlag();
}
}
private List<BusLogAllRes> selectV1(Member findMember, List<BusLogAllRes> result) {
List<BusLog> busLogs = busLogRepository.findByMember(findMember);
for (BusLog busLog : busLogs) {
BusAlarm findBusAlarm = busAlarmRepository.findByBusLog(busLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_ALARM_NOT_EXIST));
BusFavorite findBusFavorite = busFavoriteRepository.findByBusLog(busLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_FAVORITE_NOT_EXIST));
result.add(BusLogAllRes.of(
busLog,
findBusAlarm.isAlarmFlag(),
findBusFavorite.isFavoriteFlag()
));
}
return result;
}
private List<BusLogAllRes> selectV2(Member findMember, List<BusLogAllRes> result) {
List<BusLog> busLogs = busLogRepository.findAllWithAlarmAndFavorite(findMember);
return busLogs.stream()
.map(busLog -> BusLogAllRes.of(
busLog,
busLog.getBusAlarm().isAlarmFlag(),
busLog.getBusFavorite().isFavoriteFlag()
))
.toList();
}
/**
* 🔔 BusAlarmServiceImpl
*
* <p>버스 알림 관련 비즈니스 로직을 구현한 클래스입니다.</p>
* <ul>
* <li>버스 알림 플래그 토글</li>
* <li>Twilio API를 이용한 전화 알림 발송</li>
* </ul>
*/
@Service
@Transactional
@Slf4j
@RequiredArgsConstructor
public class BusAlarmServiceImpl implements BusAlarmService {
private final BusAlarmRepository busAlarmRepository;
private final BusLogRepository busLogRepository;
@Value("${twilio.account-sid}")
private String accountSid;
@Value("${twilio.auth-token}")
private String authToken;
@Value("${twilio.from-number}")
private String fromNumber;
/**
* 🚦 버스 알림 상태 토글
*
* <p>해당 버스 로그의 알림 상태를 활성화 ↔ 비활성화로 전환합니다.</p>
*
* @param req 알림 상태 변경 요청 DTO
* @throws GoormBusException 버스 로그 또는 알림 정보가 존재하지 않을 경우
*/
@Override
public void updateBusAlarm(BusAlarmReq req) {
BusLog findBusLog = busLogRepository.findById(req.busLogId()).orElse(null);
if (findBusLog == null)
throw new GoormBusException(ErrorCode.BUS_LOG_NOT_EXIST);
BusAlarm findBusAlarm = busAlarmRepository.findByBusLog(findBusLog).orElse(null);
if (findBusAlarm == null)
throw new GoormBusException(ErrorCode.BUS_ALARM_NOT_EXIST);
if (findBusAlarm.isAlarmFlag()) {
findBusAlarm.deactivateIsAlarmFlag();
log.info("버스 알림 비활성화: busLogId={}", req.busLogId());
} else {
findBusAlarm.activateIsAlarmFlag();
log.info("버스 알림 활성화: busLogId={}", req.busLogId());
}
}
/**
* 📞 버스 도착 음성 알림 발송
*
* <p>Twilio API를 통해 버스 도착 안내 음성 전화를 사용자에게 발송합니다.</p>
*
* @param member 알림을 받을 사용자
* @param busLog 버스 운행 로그
*/
@Override
public void sendBusArrivalVoiceNotification(Member member, BusLog busLog) {
Twilio.init(accountSid, authToken);
String recipientNumber = formatRecipientPhone(member.getPhone());
String message = buildArrivalMessage(member, busLog);
String twimlXml = buildTwimlXml(message);
makeVoiceCall(recipientNumber, twimlXml);
log.info("버스 도착 알림 발송 완료: member={}, phone={}", member.getName(), recipientNumber);
}
/**
* ☎ 전화번호를 국제 표준 형태(+82...)로 변환
*
* @param phone 사용자 전화번호
* @return 국제 표준 형태의 전화번호
* @throws IllegalArgumentException 전화번호 형식이 잘못된 경우
*/
private String formatRecipientPhone(String phone) {
if (phone == null || phone.length() < 2) {
throw new IllegalArgumentException("잘못된 전화번호 형식입니다: " + phone);
}
return "+82" + phone.substring(1); // 예: 010 → +8210
}
/**
* 🗣 안내 음성 메시지 문자열 생성
*
* @param member 사용자 정보
* @param busLog 버스 운행 로그
* @return 음성 메시지 내용
*/
private String buildArrivalMessage(Member member, BusLog busLog) {
return String.format(
"안녕하세요, %s님. %s에서 %s로 가는 %s번 버스가 현재 %s 정류장 남았습니다. 서둘러 주세요.",
member.getName(),
busLog.getDeparture(),
busLog.getDestination(),
busLog.getNotionId(),
busLog.getStation()
);
}
/**
* 📄 Twilio용 TwiML XML 생성
*
* @param message 음성 안내 문장
* @return TwiML XML 문자열
*/
private String buildTwimlXml(String message) {
Say say = new Say.Builder(message)
.language(Say.Language.KO_KR)
.voice(Say.Voice.ALICE)
.build();
VoiceResponse response = new VoiceResponse.Builder()
.say(say)
.build();
return response.toXml();
}
/**
* 📡 Twilio API를 통해 실제 전화를 발신
*
* @param recipientNumber 수신자 전화번호
* @param twimlXml Twilio용 XML 메시지
*/
private void makeVoiceCall(String recipientNumber, String twimlXml) {
Twiml twiml = new Twiml(twimlXml);
Call.creator(
new PhoneNumber(recipientNumber),
new PhoneNumber(fromNumber),
twiml
).create();
}
진짜 이전보다 훨씬 깔끔해졌다!!!
모듈화도 잘 되어 있고, 인터페이스도 적절히 활용했으며, 주석 처리도 깔끔해서
이제는 코드를 한눈에 파악하기가 훨씬 쉽다.
물론 아직 더 세분화할 수 있는 부분도 있지만…!
시간상 이번에는 여기까지만 하고, 나중에 다시 다듬어볼 예정이다.
무엇보다 인상 깊었던 건 스케줄러 로직의 개선이다.
이전에는 버스 로그 생성 API가 완료되면 비동기로 스케줄러를 호출하도록 짜놨는데,
지금 보니 왜 그렇게 비효율적인 구조를 만들었는지 모르겠다.
당시엔 스케줄러 코드가 서비스 로직이랑 완전히 뒤섞여 있어서
유지보수가 어렵고, 확장성도 전혀 없었다.
지금은 그 구조를 완전히 개선해서, 스케줄러를 서비스 로직과 분리하고 아래처럼 훨씬 깔끔하고 직관적인 코드로 바꿨다.
/**
* 🕒 StationService
*
* <p>버스 도착 정보와 알림 발송을 담당하는 스케줄링 서비스입니다.</p>
* <ul>
* <li>30초마다 버스 도착 여부를 확인하고 음성 알림 발송</li>
* <li>24시간이 지난 알림의 잔여 횟수를 자동으로 초기화</li>
* </ul>
*/
@Service
@EnableScheduling
@Slf4j
@RequiredArgsConstructor
public class StationService {
private final BusLogRepository busLogRepository;
private final JejuBusClient jejuBusClient;
private final BusAlarmRepository busAlarmRepository;
private final BusAlarmService busAlarmService;
/**
* 🔁 30초마다 실행되는 버스 도착 알림 스케줄러
*
* <p>모든 BusLog를 순회하면서:
* <ul>
* <li>알림이 비활성화된 경우 제외</li>
* <li>잔여 알림 횟수가 0인 경우 제외</li>
* <li>JejuBus API를 통해 도착 정보를 조회 후,
* 해당 정류장에 도착한 경우 음성 알림 발송 및 잔여 횟수 차감</li>
* </ul>
*/
@Scheduled(fixedRate = 30000)
public void callBusScheduler() {
List<BusLog> findBusLogAll = busLogRepository.findAll();
for (BusLog busLog : findBusLogAll) {
BusAlarm findBusAlarm = busAlarmRepository.findByBusLog(busLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_ALARM_NOT_EXIST));
// 비활성화된 알림은 건너뛰기
if (!findBusAlarm.isAlarmFlag()) continue;
// 잔여 횟수가 0이면 알림 스킵
if (findBusAlarm.getAlarmRemaining() == 0L) continue;
checkBusArrivalAndNotify(busLog, findBusAlarm);
}
}
/**
* ⏰ 1분마다 실행되는 알림 초기화 스케줄러
*
* <p>버스 로그 생성 이후 24시간이 지난 경우,
* 알림 잔여 횟수를 2로 초기화합니다.</p>
*/
@Scheduled(fixedRate = 60000)
public void reactivateBusNotification() {
List<BusLog> findBusLogAll = busLogRepository.findAll();
for (BusLog busLog : findBusLogAll) {
BusAlarm findBusAlarm = busAlarmRepository.findByBusLog(busLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_ALARM_NOT_EXIST));
LocalDateTime createdAt = findBusAlarm.getCreatedAt();
LocalDateTime now = LocalDateTime.now();
// 24시간 이상 경과 확인
if (Duration.between(createdAt, now).toHours() >= 24) {
if (findBusAlarm.getAlarmRemaining() < 2) {
findBusAlarm.initAlarmRemaining();
log.info("버스 알림 잔여 횟수 초기화 완료: busLogId={}", busLog.getId());
}
}
}
}
/**
* 🚌 버스 도착 여부 확인 후 알림 발송
*
* <p>JejuBus API로 버스 도착 정보를 조회하고,
* 현재 사용자의 목표 정류장과 일치하면 음성 알림을 발송합니다.</p>
*
* @param busLog 버스 운행 로그
* @param busAlarm 해당 로그의 알림 엔티티
* @throws GoormBusException 제주 버스 API 응답이 유효하지 않을 경우
*/
private void checkBusArrivalAndNotify(BusLog busLog, BusAlarm busAlarm) {
ArrivalResponse response = jejuBusClient.getArrivalInfo(busLog.getStationId());
if (response == null || response.getResultList() == null)
throw new GoormBusException(ErrorCode.JEJU_RESPONSE_NOT_EXIST);
response.getResultList().forEach(arrival -> {
int remainStation = Integer.parseInt(arrival.getRemainStation());
int busLogStation = Integer.parseInt(String.valueOf(busLog.getStation()));
// 현재 정류장 도착 시 알림 발송
if (remainStation == busLogStation) {
busAlarm.minusAlarmRemaining(); // 잔여 횟수 -1 감소
busAlarmService.sendBusArrivalVoiceNotification(busLog.getMember(), busLog);
log.info("버스 도착 알림 발송 완료: busLogId={}, member={}", busLog.getId(), busLog.getMember().getName());
}
});
}
}
전체적으로 보면 이번 리팩터링은 단순한 코드 수정이 아니라 프로젝트를 완전히 새로 세운 작업이었다.
엔티티 설계부터 프로젝트 구조, 코드 구성, 주석 스타일, 응답 포맷까지 전면적으로 손봤다.
특히 ResponseEntity로 통일하고, yml 설정과 PR/이슈 템플릿을 추가하면서 협업 환경도 훨씬 체계적으로 바뀌었다.
그렇게 해서, 제주 버스 알림콜 프로젝트는 새로운 시작을 맞이했다.
약 1년 전에는 기술적으로 조금이라도 점수를 얻고 싶어서 버스 정보 조회 시 발생할 수 있는 N+1 문제를 가정하고 해결한 코드를 넣어뒀다.
그런데 지금 다시 보니, 그때 만든 건 블로그에 올리지도 않았고 코드 구조도 정리가 안 되어 있었다.
그래서 이번엔 처음부터 다시 설계하면서, N+1 문제를 가정한 상황을 직접 재현하고,
그걸 해결하는 과정을 깔끔하게 정리해보려 한다.
jpa:
hibernate:
ddl-auto: none
properties:
hibernate:
show_sql: false # 기본적으로 SQL 로그는 찍지 않음
format_sql: true # 보기 좋게 포매팅 (켜질 때용)
use_sql_comments: false # JPQL 주석 비활성화
generate_statistics: true # 통계 비활성화 (Hibernate 내부 로그 제거)
dialect: org.hibernate.dialect.MySQLDialect
해당 코드는 쿼리 로그를 보기 위해 설정을 해두었다.
package goorm.global.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
@Slf4j
@Aspect
@Component
public class PerformanceAspect {
@Around("execution(* goorm.domain.buslog.application.service..*(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Object result = joinPoint.proceed(); // 실제 메서드 실행
stopWatch.stop();
String methodName = joinPoint.getSignature().toShortString();
log.info("⏱️ [성능측정] {} 실행 시간: {} ms", methodName, stopWatch.getTotalTimeMillis());
return result;
}
}
해당 코드는 Aop 기반의 메서드별 성능 측정 코드이다.
package goorm.global.infra.monitoring;
import jakarta.persistence.EntityManagerFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.stereotype.Component;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class HibernateStatisticsLogger {
private final EntityManagerFactory entityManagerFactory;
/**
* 버스 관련 API 요청 후 Hibernate 쿼리 통계 출력
*/
@AfterReturning("execution(* goorm.domain.buslog.presentation..*(..)) || execution(* goorm.domain.busalarm.presentation..*(..))")
public void logHibernateStatistics() {
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
long selectCount = stats.getQueryExecutionCount(); // 실행된 SELECT 문 수
long insertCount = stats.getEntityInsertCount(); // INSERT 문 수
long updateCount = stats.getEntityUpdateCount(); // UPDATE 문 수
long deleteCount = stats.getEntityDeleteCount(); // DELETE 문 수
long totalCount = selectCount + insertCount + updateCount + deleteCount;
log.info("🧾 [Hibernate 쿼리 통계 요약]");
log.info("───────────────────────────────");
log.info("SELECT 실행 횟수 : {}", selectCount);
log.info("INSERT 실행 횟수 : {}", insertCount);
log.info("UPDATE 실행 횟수 : {}", updateCount);
log.info("DELETE 실행 횟수 : {}", deleteCount);
log.info("총 쿼리 실행 횟수 : {}", totalCount);
log.info("───────────────────────────────");
// 요청별 측정을 위해 통계 초기화
stats.clear();
}
}
각각의 요청마다 최종 실행된 쿼리 수를 확인하기 위해,
직접 수동으로 빈(Bean)을 추가해서 구현했다.
참고로, 이 로직은 Hibernate에서 기본적으로 제공하는 기능을 활용한 것이다.
즉, 별도의 라이브러리를 추가하지 않아도
Hibernate의 내부 로깅 기능을 통해 쿼리 실행 횟수를 추적할 수 있다.
/**
* 버스 로그 전체 조회 API
* - 사용자의 모든 버스 로그를 조회
* - 각 로그마다 알림 여부, 즐겨찾기 여부 포함
*/
@Operation(summary = "버스 로그 전체 조회", description = "사용자의 모든 버스 기록을 조회합니다. (알림 여부, 즐겨찾기 여부 포함)")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "버스 로그 조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자 또는 버스 로그가 존재하지 않음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping
public ResponseEntity<List<BusLogAllRes>> getBusLogAll(@RequestParam String memberId,@AuthenticationPrincipal String userId) {
return ResponseEntity.ok(busLogService.getBusLogAll(memberId));
}
이번에 테스트할 API다.
위 주석에서도 볼 수 있듯이, 버스 로그를 불러오면서
각 버스에는 알림(BusAlarm)과 즐겨찾기(BusFavorite) 두 개의 연관 엔티티가 매핑되어 있다.
참고로 @RequestParam String memberId는 k6 부하 테스트를 위해 임시로 추가한 코드다.
실제 서비스 코드에는 포함되지 않는 부분이다.
/**
* 📋 버스 로그 전체 조회
*
* <p>특정 사용자의 모든 버스 로그를 조회합니다.
* 각 로그마다 알림 활성화 여부 및 즐겨찾기 여부를 함께 반환합니다.</p>
*
* <p>사용자 또는 관련 데이터가 없을 경우 예외를 발생시킵니다.</p>
*
* @param memberId 사용자 ID
* @return {@link BusLogAllRes} 리스트
* @throws GoormBusException 사용자, 알림, 즐겨찾기 데이터가 존재하지 않을 경우
*/
@Override
public List<BusLogAllRes> getBusLogAll(String memberId) {
Member findMember = memberRepository.findById(memberId).orElse(null);
if (findMember == null)
throw new GoormBusException(ErrorCode.USER_NOT_EXIST);
List<BusLogAllRes> result = new ArrayList<>();
List<BusLog> busLogs = busLogRepository.findByMember(findMember);
for (BusLog busLog : busLogs) {
BusAlarm findBusAlarm = busAlarmRepository.findByBusLog(busLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_ALARM_NOT_EXIST));
BusFavorite findBusFavorite = busFavoriteRepository.findByBusLog(busLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_FAVORITE_NOT_EXIST));
result.add(BusLogAllRes.of(
busLog,
findBusAlarm.isAlarmFlag(),
findBusFavorite.isFavoriteFlag()
));
}
log.info("BusLog 전체 조회 완료: memberId={}, count={}", memberId, result.size());
return result;
}
테스트를 진행할 서비스 코드다.
겉으로 보기에도 이미 성능적으로 문제가 많아 보이지만, 직접 실행해보면서 어느 정도의 쿼리 부하가 발생하는지 확인해보겠다.
일단 실제 트래픽은 아니지만, 문제 상황을 가정하자.
사용자 한 명이 버스 로그 1,000건을 조회한다고 했을 때,
2025-10-07T09:09:00.412+09:00 DEBUG 37124 --- [GoormBus-prod] [nio-8080-exec-2] org.hibernate.SQL :
select
bl1_0.id,
bl1_0.created_at,
bl1_0.departure,
bl1_0.destination,
bl1_0.member_id,
bl1_0.notion_id,
bl1_0.station,
bl1_0.station_id
from
bus_log bl1_0
where
bl1_0.member_id=?
버스 로그 데이터 1,000개를 가져오는 1개의 쿼리
2025-10-07T09:09:00.504+09:00 DEBUG 37124 --- [GoormBus-prod] [nio-8080-exec-2] org.hibernate.SQL :
select
ba1_0.id,
ba1_0.alarm_remaining,
ba1_0.bus_log_id,
ba1_0.created_at,
ba1_0.is_alarm_flag
from
bus_alarm ba1_0
where
ba1_0.bus_log_id=?
2025-10-07T09:09:00.504+09:00 TRACE 37124 --- [GoormBus-prod] [nio-8080-exec-2] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]
2025-10-07T09:09:00.517+09:00 DEBUG 37124 --- [GoormBus-prod] [nio-8080-exec-2] org.hibernate.SQL :
select
bf1_0.id,
bf1_0.bus_log_id,
bf1_0.created_at,
bf1_0.is_favorite_flag
from
bus_favorite bf1_0
where
bf1_0.bus_log_id=?
2025-10-07T09:09:00.517+09:00 TRACE 37124 --- [GoormBus-prod] [nio-8080-exec-2] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]
binding parameter를 참고하면 위 로그가 1000개씩 2세트로 있다.
최종적으로 현재 구조에서는 위 로그처럼 연관된 알림(BusAlarm)과 즐겨찾기(BusFavorite)를 개별 조회하게 된다.
즉,
1 + 2N 문제가 발생하게 된다.

터미널 로그를 확인해보니, 쿼리 개수가 무려 2,001개나 찍혔다.
실행 시간은 10,835ms, 즉 거의 10초다.
단순히 데이터를 불러오는 데 이 정도 시간이 걸린다는 건, N+1 문제가 얼마나 치명적인지 그대로 보여주는 결과였다.
https://balhae.tistory.com/307
N+1문제
https://balhae.tistory.com/156 섹션1 영속성 관리 - 내부 동작 방식최코딩의 개발 섹션1 영속성 관리 - 내부 동작 방식 본문 JPA/JPA 기본 핵심 원리 섹션1 영속성 관리 - 내부 동작 방식 seung_ho_choi.s 2024. 1. 2
balhae.tistory.com
필자의 CS 블로그에서도 다뤘듯이, N+1 문제를 해결하는 방법은 여러 가지가 있다.
그중에서도 가장 대표적인 방식이 바로 Fetch Join이다.
이번에는 이 Fetch Join을 적용해서
앞서 발생했던 과도한 쿼리 실행 문제를 어떻게 개선할 수 있는지 직접 확인해보겠다.
@Query("""
SELECT DISTINCT b
FROM BusLog b
LEFT JOIN FETCH b.busAlarm
LEFT JOIN FETCH b.busFavorite
WHERE b.member = :member
""")
List<BusLog> findAllWithAlarmAndFavorite(@Param("member") Member member);
}
Fetch Join을 활용해 조인과 동시에 연관된 엔티티를 함께 조회하는 방식을 사용했다.
LEFT JOIN FETCH 구문을 통해 BusLog와 연관된 BusAlarm, BusFavorite 엔티티를 한 번에 가져오도록 구성했다.
private List<BusLogAllRes> selectV1(Member findMember, List<BusLogAllRes> result) {
List<BusLog> busLogs = busLogRepository.findByMember(findMember);
for (BusLog busLog : busLogs) {
BusAlarm findBusAlarm = busAlarmRepository.findByBusLog(busLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_ALARM_NOT_EXIST));
BusFavorite findBusFavorite = busFavoriteRepository.findByBusLog(busLog)
.orElseThrow(() -> new GoormBusException(ErrorCode.BUS_FAVORITE_NOT_EXIST));
result.add(BusLogAllRes.of(
busLog,
findBusAlarm.isAlarmFlag(),
findBusFavorite.isFavoriteFlag()
));
}
return result;
}
private List<BusLogAllRes> selectV2(Member findMember, List<BusLogAllRes> result) {
List<BusLog> busLogs = busLogRepository.findAllWithAlarmAndFavorite(findMember);
return busLogs.stream()
.map(busLog -> BusLogAllRes.of(
busLog,
busLog.getBusAlarm().isAlarmFlag(),
busLog.getBusFavorite().isFavoriteFlag()
))
.toList();
}
selectV1이 기존 코드이고, selectV2가 Fetch Join을 적용한 서비스 코드다.
이제 두 버전의 성능 차이가 어떻게 나타날지 결과가 정말 기대된다.
2025-10-07T09:21:27.074+09:00 DEBUG 22516 --- [GoormBus-prod] [nio-8080-exec-3] org.hibernate.SQL :
select
distinct bl1_0.id,
ba1_0.id,
ba1_0.alarm_remaining,
ba1_0.created_at,
ba1_0.is_alarm_flag,
bf1_0.id,
bf1_0.created_at,
bf1_0.is_favorite_flag,
bl1_0.created_at,
bl1_0.departure,
bl1_0.destination,
bl1_0.member_id,
bl1_0.notion_id,
bl1_0.station,
bl1_0.station_id
from
bus_log bl1_0
left join
bus_alarm ba1_0
on bl1_0.id=ba1_0.bus_log_id
left join
bus_favorite bf1_0
on bl1_0.id=bf1_0.bus_log_id
where
bl1_0.member_id=?
2025-10-07T09:21:27.075+09:00 TRACE 22516 --- [GoormBus-prod] [nio-8080-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [ea45ebca-ce2a-4abe-8b8c-470699637be5]

쿼리 로그와 터미널 결과를 보면, 이번에는 쿼리가 단 1회만 실행된 것을 확인할 수 있다.
무엇보다 실행 시간도 이전 10,835ms에서 50ms로, 비교할 수 없을 만큼 압도적인 성능 향상을 보여줬다.

10,000개를 기준으로 조회하면 475ms가 소요된다.
Fetch Join으로 쿼리 수를 최적화하긴 했지만, 여전히 조회 속도 측면에서는 개선의 여지가 있었다.
특히 버스 로그가 수백만 건인 경우, member_id를 기준으로 검색할 때 탐색 범위가 너무 넓어지는 문제가 있었다.
@Table(
name = "bus_log",
indexes = {
@Index(name = "idx_member_id", columnList = "member_id")
}
)
public class BusLog {
...
}
그래서 이렇게 인덱스를 걸면, DB가 member_id 컬럼을 기준으로 B-Tree 구조로 데이터를 정렬하여 관리하게 된다.
참고로 MySQL은 B+Tree 으로 구현되어있다.
자세히 알아보자.
버스 로그 테이블에 데이터가 쭉 쌓여 있다고 하자. 이때 특정 memberId에 해당하는 로그를 찾아야 하는데, 인덱스가 없다면 데이터 전체를 훑는 풀 스캔(Full Scan) 이 발생해 성능이 저하된다.
이를 해결하기 위해 인덱스(index) 를 도입하면, memberId 기준으로 정렬된 별도의 탐색 구조가 만들어지고, DB는 이진 탐색(Binary Search) 기법을 사용해 중간에서 데이터를 빠르게 찾아간다.
하지만 이진트리는 한 노드에 하나의 데이터만 저장하므로 깊이가 깊어질수록 탐색 비용이 증가한다.
이를 개선한 구조가 B-Tree다.
B-Tree는 한 노드에 여러 개의 키를 저장하여, 탐색 시 데이터를 반씩이 아니라 3분의 2씩 줄여나가는 식으로 더 빠르게 찾을 수 있다.
여기에 한 단계 더 나아가, 실제 데이터는 가장 하단(리프 노드) 에만 보관하고, 상위 노드에는 탐색을 위한 가이드 역할만 남긴 구조가 바로 B+Tree다.
이 구조는 범위 검색(range scan) 에 매우 효율적이다.
즉, 인덱스를 생성하면 테이블과는 별도로 memberId만 모아둔 인덱스 공간이 생기고,
DB는 먼저 인덱스에서 해당 키를 찾은 뒤, 거기에 연결된 실제 테이블의 행(row)을 가져오는 방식으로 데이터를 탐색한다.
정리하자면,
pk가 primary로 선언된 기본 키는 자동으로 클러스터드 인덱스(Clustered Index) 가 된다.
이를 보조하기 위해 만든 인덱스가 비클러스터 인덱스(Non-Clustered Index, 보조 인덱스) 다.
이번 실험에서는 바로 이 보조 인덱스의 성능을 테스트하는 것이다.

그 덕분에 특정 회원의 로그를 조회할 때, 전체 테이블을 스캔하지 않고도 필요한 데이터만 빠르게 탐색할 수 있다.
하지만 인덱스는 필요할 때만 사용하는 것이 중요하다.
무분별하게 남발하면 오히려 성능 저하를 초래할 수 있다.
인덱스는 조회 속도를 빠르게 해주는 대신, 데이터 삽입·수정·삭제 시마다 인덱스도 함께 갱신되어야 하기 때문에
쓰기 작업이 잦은 테이블에서는 오버헤드가 커질 수 있다.
즉, 자주 조회되는 컬럼이나 검색 조건으로 자주 쓰이는 컬럼에만 선별적으로 인덱스를 적용하는 것이 가장 효율적이다.

인덱스를 적용한 후 실행 결과는 429ms로, 이전의 475ms보다 약간 개선되긴 했지만
체감할 만큼 큰 차이는 아니었다.
그래서 이번에는 K6을 활용해 동시 사용자 부하 테스트를 진행해, 인덱스 도입 전과 후의 실제 트래픽 환경에서의 성능 차이를 비교해보려 한다.
import http from 'k6/http';
import { check, sleep } from 'k6';
// 실제 DB에 저장된 member UUID 리스트 (새로운 100개)
const memberIds = [
100개 있음 (생략)
];
export const options = {
vus: 100, // 동시 접속 사용자 수
duration: '30s', // 테스트 시간
thresholds: {
http_req_duration: ['p(95)<500'], // 95% 요청이 0.5초 이내에 끝나야 함
},
};
export default function () {
const memberId = memberIds[Math.floor(Math.random() * memberIds.length)];
const url = `http://localhost:8080/api/bus?memberId=${memberId}`;
const res = http.get(url);
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1);
}
choco로 K6을 설치한 뒤, 프로젝트 내에 per.js 파일을 작성하고 k6 run per.js 명령어로 실행했다.
시나리오
Spring Boot + JPA 기반 프로젝트의 /api/bus API에
DB 인덱스를 적용하기 전후로 K6를 사용해 부하 테스트를 진행했습니다.
테스트 환경은 다음과 같습니다:
결과
![]() |
![]() |
위 사진은 K6 부하 테스트 결과를 보여준다.
왼쪽은 인덱스 도입 전, 오른쪽은 인덱스 도입 후의 모습이다.
두 결과를 비교하면, 인덱스 적용이 실제 트래픽 환경에서 요청 처리 속도와 안정성에 어떤 영향을 주었는지 한눈에 확인할 수 있다.
| 구분 | 평균 응답시간(avg) | 95% 응답시간(p95) | 최대 응답시간(max) | 요청 수 | 성공률 | 초당 처리량(RPS) |
| 인덱스 적용 전 | 32.21ms | 25.27ms | 685.49ms | 2,955 | 100% | 95.3/s |
| 인덱스 적용 후 | 30.48ms | 23.05ms | 662.02ms | 2,982 | 100% | 96.17/s |
결과를 정리해보면 위 표와 같다.
| 항목 | 변화 | 의미 |
| 평균 응답속도 | ⬇️ 5.4% 개선 | 전체 쿼리 처리속도가 더 빨라짐 |
| 95% 응답속도 | ⬇️ 8.8% 개선 | 대부분의 요청이 더 짧은 시간 내 완료 |
| 최대 응답시간 | ⬇️ 3.4% 개선 | 부하 상황에서도 응답 안정성 유지 |
| 초당 요청 처리량 | ⬆️ 0.9% 증가 | 서버의 동시 처리 여유가 생김 |
| 성공률 | ✅ 동일 (100%) | 안정적 운영 유지 |
분석은 위와 같다.
응답속도(ms)
|
| 인덱스 전 avg 32.21 ────────
| 인덱스 후 avg 30.48 ──────
|
|----|----|----|----|----|----|----|---->
0 10 20 30 40 50 (ms)
시각화를 해본 그래프이다.
5~10% 정도의 개선은 겉보기에는 큰 변화가 아닌 것처럼 느껴질 수 있다. 하지만 시스템 레벨에서 보면 이는 매우 의미 있는 차이다.
단일 요청 기준으로 2ms가 단축되었다고 가정하더라도, 초당 100건의 요청, 하루 800만 건의 트래픽이 처리되는 시스템에서는
결국 하루 기준 약 4시간 이상이 절약되는 효과가 발생한다.
또한 DB 인덱스의 효율은 선형적으로 증가하지 않는다. 데이터가 쌓일수록 인덱스를 사용한 탐색 속도 이득은 지수적으로 커진다.
지금은 데이터량이 적어 체감이 크지 않지만, 100만 건 이상의 데이터가 누적되는 시점부터는 10배 이상의 성능 차이가 발생할 수 있다.
인덱스는 단순히 조회 속도만 개선하는 것이 아니다. CPU 효율 향상과 Lock 경합 완화에도 직접적인 영향을 준다.
전체 테이블 스캔(Table Scan)을 피하면서 DB Connection 점유 시간이 감소하고, 그만큼 더 많은 요청을 병렬로 처리할 수 있게 된다.
결론적으로, 인덱스 적용을 통해 평균 5~9%의 응답 속도 향상과 처리 안정성 확보가 이루어졌다. 지금은 미세한 수치 변화처럼 보이지만, 데이터가 커질수록 쿼리 탐색 성능 향상 폭은 훨씬 커질 것으로 기대된다.
이번 테스트를 통해 얻은 인사이트들은 붐빔(Boombim) 프로젝트에도 꼭 적용해봐야겠다.
현재 붐빔에서는 서울 실시간 인구 데이터 OpenAPI를 통해 약 120개의 장소 정보를 조회하고 있는데,
이 데이터를 지도를 열 때마다 매번 호출하다 보니 트래픽이 증가할수록 성능 저하가 발생할 가능성이 크다.
다음에는 이 부분에 캐싱 로직을 도입해서 API 호출 빈도를 줄이고, 실제 사용자 환경에서의 반응 속도 개선에도 도전해볼 생각이다.
| [맞춤형 헤어 컨설팅 예약] 서킷 브레이커 패턴 연구하기 (2) | 2025.10.10 |
|---|---|
| [맞춤형 헤어 컨설팅 예약] 동시성 충돌 연구해보기 (0) | 2025.10.10 |
| [붐빔] MSA와 RabbitMQ로 알림 API 응답 성능 개선하기 (1) | 2025.08.31 |
| [맞춤형 헤어 컨설팅 예약] Fegin Client를 활용한 카카오페이 API 기반 결제 시스템 개발 (0) | 2025.02.24 |
| 웹 서비스 실시간 채팅 구현하기 (관리자 : 사용자, AWS 환경) (8) | 2024.10.10 |