MOVE
2년 전 졸업작품으로 진행했던 것을 정리하려고 한다. 당시 기록을 남기지 않아서 지금 와서 코드를 다시 보니 눈물이 날 정도로 아쉬운 부분이 많다. 그래도 그때와 비교했을 때 발전했으니, 이것에 위안을 삼는다.
과거에는 이렇게 했는데 지금은 어떻게 할 것 같다는 회고 글이다. 해당 프로젝트는 스프링 백엔드와 안드로이드 앱으로 진행되었으며, 서버와 화면 모두 배포되지 않았다.
서비스 소개
프로젝트명은 MOVE다. 당시 포켓몬GO가 막 떠오르던 시절이고, 속초에서만 되던 게임이 화제였다.
틈새 시장을 노려서 포켓몬GO와 비슷하게 만보기 기능을 넣고, 위치를 트래킹하고, 주변 지역을 방문해 포인트를 쌓고, 아바타를 구매할 수 있는 서비스를 기획했다.
차별점이라면 친구나 동행자가 경로를 같이 볼 수 있다는 정도였다. 2명 팀으로 시작했지만 결국 혼자서 백엔드와 안드로이드를 모두 담당하게 됐다.
Spring Boot로 백엔드 구축하기
설정 관리의 아쉬움
당시에는 서버 환경이나 배포 개념이 없어서 단순한 하나의 application.yaml
로 모든 걸 처리했다.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
hibernate:
ddl-auto: create
이런 방식은 환경에 따라 변수 값이 달라질 수 있기 때문에 지금이라면 환경별로 설정 파일을 분리할 것 같다. DB 로그나 DDL 설정도 개발/운영에 맞춰 다르게 가져갈 것이다.
아키텍처 선택의 시행착오
처음에는 컨트롤러, 서비스, 레포지터리로 구성된 3 Layer 아키텍처를 사용했다. 그러다가 당시 읽었던 헥사고날 아키텍처에 매료되어 중간에 리팩터링을 시도한 흔적이 보인다.
이건 정말 나쁜 선택이었다.
스프링도 익숙하지 않은 상황에서 무리한 구조 변경은 독만 된다. 프로젝트 자체가 크지 않았고, 혼자 개발하는 상황이라 더더욱 그랬다. 덕분에 코드를 되돌아보는 것도 오랜 시간이 걸렸다.
단일 책임 원칙을 지키겠다고 컨트롤러 메서드와 서비스 메서드를 파일별로 분리하기까지 했다. 유지보수를 고려한 건 좋은데, 개발 시간이나 프로젝트 크기를 생각하면 과도했다.
지금 생각해보니 당시 기술 블로그에서 읽은 걸 바로 적용해보려는 욕심이 컸다. 경험 차원에서는 나쁘지 않았지만, 프로젝트 완성도 측면에서는 아쉬웠다.
테스트 데이터 처리
@Component
@RequiredArgsConstructor
public class DBInitializer {
private final UserRepository userRepository;
private final RecordRepository recordRepository;
@PostConstruct
public void postConstruct() {
// 테스트 데이터 저장
}
}
컴포넌트로 등록하고 @PostConstruct
를 사용해서 테스트 데이터를 넣었다.
지금이라면 프로파일별로 분리하고, 운영 환경에서는 이런 초기화 로직이 실행되지 않도록 할 것이다. @EventListener(ApplicationReadyEvent.class)
나 CommandLineRunner를 사용해서 애플리케이션이 완전히 준비된 후 실행하거나, 아예 별도의 마이그레이션 스크립트로 관리하는 게 나을 것 같다.
Spring Security 구현
당시에는 UserDetailsService, UserDetails 구현체를 만들지 않고 GenericFilterBean
을 상속해서 로그인, 회원가입을 필터 단에서 처리했다.
@Component
public class LoginFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 로그인 로직 처리
}
}
이 부분에 대해서는 지금까지도 고민이 있는데, 나는 필터 단에서 끝내기보다는 컨트롤러와 서비스까지 가져가는 걸 선호한다. 로그인이나 회원가입에 비즈니스 로직이 붙을 수 있고, 감사 로그나 알림 같은 부가 기능을 처리하기에는 필터보다 서비스 레이어가 적합하다고 생각한다.
핵심 서비스 로직의 문제점들
이 글을 쓴 가장 큰 이유다. 특정 지역 방문 시 방문 기록을 저장하고 포인트를 지급하는 서비스 로직이 있었는데, 지금 보니 문제투성이다.
@Transactional
public void saveOrUpdateVisitedMoveStop(String email, VisitedMoveStopRequest request) {
Member foundMember = memberRepository.findMemberByEmail(email)
.orElseThrow(MemberNotFoundException::new);
List<MoveStopEntity> foundMoveStopEntities = moveStopRepository
.findMoveStopByLatitudeBetweenAndLongitudeBetween(
request.getLatitude1(), request.getLatitude2(),
request.getLongitude1(), request.getLongitude2());
for (MoveStopEntity foundMoveStopEntity : foundMoveStopEntities) {
double distance = getDistanceInMeter(request.getMemberLatitude(),
request.getMemberLongitude(), foundMoveStopEntity.getLatitude(),
foundMoveStopEntity.getLongitude());
if (distance <= 35) {
Optional<VisitedMoveStopEntity> foundVisitedMoveStopEntityOptional =
visitedMoveStopRepository.findVisitedMoveStopByMemberIdAndMoveStopEntityId(
foundMember.getId(), foundMoveStopEntity.getId());
if (foundVisitedMoveStopEntityOptional.isPresent()) {
// 재방문 처리 로직
} else {
// 첫 방문 처리 로직
// 2번 저장되서 다시 조회
if (!visitedMoveStopRepository.existsVisitedMoveStopByMemberIdAndMoveStopEntityId(
foundMember.getId(), foundMoveStopEntity.getId())) {
visitedMoveStopRepository.save(new VisitedMoveStopEntity(foundMember, foundMoveStopEntity, LocalDateTime.now()));
foundMember.addMileage(foundMoveStopEntity.getEarnMileage());
}
}
}
}
}
반복문 안에서 visitedMoveStopRepository.findVisitedMoveStopByMemberIdAndMoveStopEntityId
를 호출하고 있다. MoveStop이 100개라면 DB 조회가 100번 발생한다.
지금이라면 해당 사용자의 모든 방문 기록을 한 번에 가져와서 Map으로 변환한 후 조회하는 방식으로 개선할 것이다.
1. 동시성 이슈
코드에 "2번 저장되서 다시 조회"라는 주석이 있는 걸 보면, 동시에 같은 요청이 들어올 때 중복 저장이 발생했던 것 같다.
existsVisitedMoveStopByMemberIdAndMoveStopEntityId
로 한 번 더 체크하고 있지만, 이건 근본적인 해결책이 아니다. 동시에 두 요청이 들어오면 둘 다 존재하지 않는다고 판단해서 둘 다 저장할 수 있기 때문이다.
지금이라면 데이터베이스 레벨에서 유니크 제약 조건을 걸고, DataIntegrityViolationException
을 잡아서 처리하거나 분산 락을 사용할 것 같다.
2. 메서드 책임 과다
한 메서드에서 위치 검증, 거리 계산, 중복 체크, 방문 기록 저장, 포인트 지급까지 모든 걸 처리하고 있다. 각 단계를 별도 메서드로 분리하면 테스트와 유지보수가 훨씬 수월해진다.
3. 트랜잭션 범위
전체 메서드에 @Transactional
이 걸려 있어서, 하나의 MoveStop 처리에서 예외가 발생하면 전체가 롤백된다. 각 MoveStop 처리를 독립적인 트랜잭션으로 분리하는 게 더 안전할 수 있다.
지금이라면 이렇게 개선할 것 같다.
public void processLocationVisits(String email, LocationRequest request) {
Member member = findMemberByEmail(email);
List<MoveStop> nearbyStops = findNearbyStops(request);
Map<Long, VisitRecord> existingVisits = getExistingVisitsAsMap(member.getId(), nearbyStops);
nearbyStops.stream()
.filter(stop -> isWithinRange(request.getLocation(), stop.getLocation()))
.forEach(stop -> processStopVisit(member, stop, existingVisits.get(stop.getId())));
}
@Transactional
public void processStopVisit(Member member, MoveStop stop, VisitRecord existingVisit) {
if (canEarnPoints(existingVisit, stop.getCooldownMinutes())) {
try {
visitRecordRepository.save(VisitRecord.of(member, stop, LocalDateTime.now()));
member.addPoints(stop.getPointReward());
} catch (DataIntegrityViolationException e) {
log.info("중복 방문 시도 감지: {}", stop.getName());
}
}
}
이렇게 하면 각 메서드의 책임이 명확해지고, 데이터베이스 제약 조건으로 동시성 문제도 해결할 수 있다.
안드로이드 개발 경험
안드로이드는 이때가 처음이자 마지막이었다. 4대 컴포넌트부터 시작해서 MVVM 아키텍처까지 적용해봤다.
안드로이드 공식 문서에서 MVP보다 MVVM을 권장한다는 글을 읽고 채택했는데, 당시에는 왜 이렇게 하는지 잘 몰랐다. LiveData와 ViewModel 사용법을 문서 보면서 따라 하기 급급했다.
지금 생각해보니 UI와 비즈니스 로직의 분리, 생명주기에 안전한 데이터 관리라는 장점 때문에 쉽게 할 수 있었던 것 같다. 이 경험 덕분에 나중에 Vue.js도 금방 적응할 수 있었다.
만보기 기능 구현의 성공과 한계
안드로이드에서 가장 까다로웠던 부분은 백그라운드에서 지속적으로 걸음 수를 측정하는 기능이었다.
처음에는 단순하게 생각했는데, 실제로 구현해보니 Fragment와 Service 간의 통신, 그리고 센서 데이터의 생명주기 관리가 복잡했다.
위 다이어그램처럼 최종적으로는 동작하게 만들었지만, 그 과정에서 많은 시행착오가 있었다. 특히 TrackingService
에서 StepRepository
를 초기화할 때 Context 문제로 며칠을 헤맸던 기억이 난다. ServiceConnection 타이밍과 센서 초기화 순서를 맞추는 것도 쉽지 않았다.
결국 LiveData - step
으로 실시간 걸음 수 업데이트까지 구현했지만, 지금 보니 아키텍처가 복잡하다.
MVVM 아키텍처 개선 과정
당시 안드로이드 공식 문서를 보고 MVVM 패턴을 적용했는데, 처음에는 HomeFragment
에서 모든 것을 처리하고 있었다.
하지만 Fragment가 너무 많은 책임을 가지는 문제를 인식하고, 위 다이어그램처럼 관심사를 분리했다. BroadcastReceiver
를 별도 클래스로 분리하고, ServiceConnection
도 독립적인 컴포넌트로 만들었다.
HomeFragment
는 이제 인터페이스를 통해 각 컴포넌트와 통신하도록 개선했다. 이렇게 하니 각 클래스의 역할이 명확해지고, 테스트하기도 훨씬 수월해졌다.
지금 생각해보니 UI와 비즈니스 로직의 분리, 생명주기에 안전한 데이터 관리라는 MVVM의 핵심 개념을 이때 제대로 이해하게 된 것 같다. 이 경험 덕분에 나중에 Vue.js도 금방 적응할 수 있었다.
기술 선택 과정
의존성 주입은 Hilt, HTTP 요청은 Retrofit2, 지도는 Google Maps SDK를 사용했다.
Dagger2도 고려했지만 Hilt가 설정이 간단하고 러닝 커브가 낮아서 선택했다. HTTP 클라이언트는 OkHttp와 Retrofit 사이에서 고민했는데, Retrofit이 고수준 API를 제공해서 개발이 편했다. 지도 API는 Google OAuth2를 쓰기로 했던 터라 일관성을 위해 Google Maps를 선택했다.
주요 이슈들
JPA 지연 로딩 때문에 LazyInitializationException
을 겪었고, N+1 문제도 있었다. LazyInitializationException
은 서비스 계층에서 DTO를 만들어서 해결했고, N+1은 fetch join으로 처리했다.
그리고 0.3초마다 위치 데이터를 서버에 보내다가 서버가 죽는 사고도 있었다. 당시에는 동시성 개념이 부족해서 통신 주기를 1초로 늘리고 이중 검증하는 방식으로 대응했다.
지금이라면 클라이언트에서 배칭해서 보내거나, 서버에서 비동기 처리로 분리할 것 같다. Redis 같은 걸 써서 위치 데이터를 캐싱하고, 별도 테이블에 이력만 쌓는 방식도 고려할 만하다.
아쉬운 점들과 개선사항
3 Layer 아키텍처에서 헥사고날로 전환하다가 중단된 터라 일관되지 않은 코드가 많다. 공통 응답 포맷도 없고, Swagger 문서화도 안 되어 있어서 API 확인하기 어렵다.
컨트롤러에서 SecurityContextHolder
에 직접 접근하는 것도 아쉽다.
private String getMemberEmail() {
return (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
지금이라면 @AuthenticationPrincipal
을 써서 깔끔하게 처리할 것이다.
그래도 예외 처리용 공통 에러 코드와 응답 객체는 있어서 그나마 다행이었다.
되돌아보며
코드를 다시 보니 정말 많이 아쉽다. 당시에는 ChatGPT도 없어서 "Spring Boot JPA LazyInitializationException", "Android MVVM tutorial" 같은 키워드로 밤새 블로그를 뒤져가며 어떻게든 동작시키려고 했던 기억이 난다. 스택오버플로우는 기본이고, 영어 블로그까지 번역기 돌려가며 봤다. 정보가 맞는지 확인하려고 여러 블로그를 비교해보고, 실제로 따라 해보면서 검증했다.
팀원 2명으로 시작했지만 중간에 다른 팀원이 개인 사정으로 빠지게 되면서 혼자 백엔드와 안드로이드를 모두 담당하게 됐다. 처음에는 역할 분담을 했었는데, 갑자기 혼자 해야 하니까 정말 당황스러웠다. 그래도 포기할 수는 없어서 밤새가며 공부하고 구현했다. 제일 어려웠던 건 안드로이드와 백엔드 사이의 데이터 형식을 맞추는 일이었다. API 스펙을 혼자 정하고 혼자 맞춰야 하니까 일관성 유지가 쉽지 않았다.
지금 봤을 때 가장 후회되는 선택은 헥사고날 아키텍처로 무리하게 바꾸려고 했던 것이다. 당시에는 새로운 걸 배우고 적용해보고 싶은 욕심이 컸는데, 프로젝트 완성도보다 기술 실험에 더 집중했던 것 같다. 덕분에 코드가 중구난방이 되고, 나중에 기능 추가할 때도 어디에 뭘 넣어야 할지 헷갈렸다.
이 프로젝트를 통해 가장 크게 깨달은 건 기술 선택의 기준이다. 멋있어 보이는 기술이나 아키텍처가 항상 좋은 건 아니고, 팀 역량과 프로젝트 상황에 맞는 선택이 중요하다는 걸 배웠다. 그리고 무엇보다 완성도 있는 결과물을 만드는 게 우선이라는 것도.
만약 이 서비스를 실제 운영해야 한다면 가장 먼저 모니터링부터 붙일 것 같다. 당시에는 에러가 나도 로그로만 확인했는데, 실제 사용자가 쓸 거라면 응답 시간이나 에러율 같은 지표들을 실시간으로 볼 수 있어야 한다. 그 다음이 데이터베이스 최적화고, 캐싱 전략도 필요할 것이다.
코드에는 정답이 없다고 하지만, 당시 뿌듯해했던 코드를 지금 보기 어려울 정도라면 그만큼 성장했다는 뜻이기도 하다. 분명 몇 년 후에는 지금 작성한 글들도 보며 고개를 절레절레 흔들 것이다.
이 글은 부끄러움이자 경험이자 애증의 기록이다. 그래도 이런 발자국이 있었기에 지금의 내가 있다고 생각한다.