문제를 분해하고, 구조에 맞는 기술을 찾아갈 때
희열을 느끼는 백엔드 개발자입니다.
날씨, 취향을 고려해 사용자가 보유한 의상 조합을 추천해주고, OOTD 피드, 팔로우 등의 소셜 기능을 갖춘 서비스입니다.
팀 GitGgal(5인)이 함께 개발했으며,
실시간 알림(SSE), 팔로우, DM(WebSocket/STOMP) 기능을 전담했습니다.
팔로우·좋아요·댓글 등 이벤트를 HTTP 롱폴링 없이 서버에서 클라이언트로 단방향으로 실시간 푸시합니다. Redis Pub/Sub을 사이에 두어 멀티 인스턴스 환경에서도 알림이 정확히 전달됩니다.
SseEmitter 30분 타임아웃 설정, 연결·종료·오류 콜백으로 리소스 자동 정리. 최초 connect 이벤트를 즉시 전송해 일부 클라이언트의 연결 미감지 문제를 방지했습니다.
팔로우·언팔로우와 팔로워/팔로잉 목록 조회를 구현했습니다.
목록 조회는 커서 기반 페이지네이션을 적용해, 데이터가 누적돼도 응답 속도가 느려지지 않도록 설계했습니다.
@TransactionalEventListener(phase = AFTER_COMMIT)으로 트랜잭션 커밋 이후에만 Kafka 메시지가 발행되어, DB 롤백 시 알림이 발송되는 문제를 방지했습니다.
STOMP 프로토콜 기반의 1:1 실시간 채팅을 구현했습니다. CONNECT 프레임 시점에 JWT를 검증하는 인터셉터를 직접 작성해 WebSocket 구간의 인증을 완성했습니다.
DM 방 키는 두 사용자 UUID를 정렬 후 결합해 생성 — 어느 쪽이 먼저 대화를 시작하든 동일한 채널로 연결되어, 같은 두 사람 사이에 중복된 방이 생기지 않습니다. senderId를 JWT 인증값과 비교해 페이로드 위조를 차단했습니다.
SseEmitter를 저장해두고 알림마다 꺼내 써야 합니다.ConcurrentHashMap으로 직접 저장소를 구현했습니다.HashMap 대신 ConcurrentHashMap을 선택해 스레드 안전성을 확보했습니다.
ApplicationEventPublisher는 같은 JVM 내에서만 이벤트를 전달하므로, 다른 서버 인스턴스의 사용자에게 알림이 도달하지 않는 문제가 생겼습니다.
@RetryableTopic(attempts = "5")으로 지수 백오프(1→2→4→8초) 재시도, 5회 실패 시 DLT로 격리해 정상 흐름에 영향을 주지 않도록 구성했습니다.
groupId 기반 파티션 분배로 인스턴스 수에 관계없이 알림이 정확히 1회 처리됩니다. Redis Pub/Sub은 이후에도 SSE 팬아웃 역할을 그대로 유지합니다.
Follow 엔티티가 follower, followee 두 User를 LAZY로 참조합니다.fetchJoin()으로 Follow 조회 시 follower, followee를 JOIN으로 한꺼번에 가져오면 쿼리 1번으로 해결됩니다. COUNT 쿼리는 행 수만 세므로 fetchJoin 없이 일반 join으로 분리했습니다.
@TransactionalEventListener(phase = AFTER_COMMIT) + Spring Event 조합으로 변경했습니다. DB 커밋이 확인된 이후에만 WebSocket 푸시와 SSE 알림이 실행됩니다.
notificationPublisher.publish()에서 GenericJackson2JsonRedisSerializer가 NotificationDto.createdAt (Instant 타입) 직렬화를 시도했습니다.JavaTimeModule이 없어 SerializationException이 발생했고, 이 예외가 전파되어 트랜잭션이 롤백 → DB INSERT까지 취소됐습니다.
RedisConfig에서 JavaTimeModule을 등록하고 WRITE_DATES_AS_TIMESTAMPS를 비활성화한 ObjectMapper를 직접 주입했습니다. Instant가 숫자 대신 ISO 문자열 형태("2026-05-19T05:43:24Z")로 직렬화되어 문제가 해결됐습니다.
SEND 프레임의 페이로드에 있는 senderId를 클라이언트가 임의 값으로 바꾸면, 다른 사람인 척 DM을 보낼 수 있었습니다.
StompAuthChannelInterceptor를 직접 구현해 CONNECT 프레임 시점에 JWT를 검증하고 SecurityContext에 등록했습니다.
ISBN 정보를 기반으로 책 정보를 자동으로 불러와 리뷰와 감상을 나누는 책 커뮤니티 플랫폼입니다.
도서 이미지 OCR과 ISBN 매칭으로 책 등록을 간편하게 만들었습니다.
백엔드 개발자 5인이 함께 개발했으며,
사용자(회원·이메일 인증), 알림, 배포 관리 도메인을 전담했습니다.
회원가입·로그인과 이메일 인증 기반 비밀번호 찾기를 구현했습니다. 비밀번호는 BCrypt로 단방향 해시 저장하고, 탈퇴는 논리 삭제(soft delete)로 처리합니다.
리뷰 좋아요·댓글 등의 이벤트를 Kafka로 비동기 처리해 알림을 적재합니다. 누적되는 알림 데이터는 Spring Batch로 주기적으로 정리합니다.
Batch JobRepository가 실행 이력을 남겨, 매일 정리 작업이 정상 수행됐는지·어디서 실패했는지 추적할 수 있습니다.
GitHub Actions로 빌드한 Docker 이미지를 ECR에 푸시하고 AWS ECS(EC2)에 배포합니다. ALB가 트래픽을 분산하고 헬스 체크로 컨테이너 상태를 관리합니다.
배포 과정에서 겪은 문제와 해결 과정은 아래 트러블슈팅에서 자세히 다룹니다.
500 Internal Server Error가 100% 발생했습니다. (Entity User ... does not exist)
User 엔티티에 @SQLRestriction("deleted_at IS NULL")이 걸려 있어, ReviewRepository가 fetchJoin()으로 작성자를 채우려 할 때 탈퇴 유저가 '없는 데이터'로 취급되어 무결성 예외가 발생했습니다.
User의 전역 필터를 제거해 연관 조회를 허용하고, 대신 로그인·중복 체크 등 실제 유효 유저만 다뤄야 하는 곳에 deletedAt IS NULL 조건을 수동으로 추가했습니다.Optional.filter로 탈퇴 유저를 걸러 USER_NOT_FOUND를 던지도록 설계했습니다.
Random은 하나의 seed를 공유해, 멀티스레드에서 동시 호출 시 CAS 경합으로 재시도가 늘며 성능이 떨어집니다.ThreadLocalRandom은 스레드마다 독립적인 난수 생성기를 가져 공유 자원 경합이 없습니다.
ThreadLocalRandom.current().nextInt(100_000, 1_000_000)으로 항상 6자리(100,000~999,999) 정수를 생성하고, 앞자리가 0으로 시작하는 경우까지 대비해 String.format("%06d", ...)으로 자릿수를 보정했습니다. 표준 API만 써 의존성이 낮고 의도가 명확합니다.
spring-boot-starter-security를 넣었더니, 모든 요청에 보안 필터가 강제로 걸려 H2 콘솔·정적 리소스 접근까지 막혔습니다.
permitAll()·csrf().disable()로 끄는 방식은 설정 누락 위험과 복잡도가 커지고, Jasypt 같은 외부 라이브러리는 표준 PasswordEncoder와의 정합성이 떨어졌습니다.
spring-security-crypto만 단독으로 사용했습니다. @EnableWebSecurity 없이 BCryptPasswordEncoder만 빈으로 등록해, 필터 부담 없이 BCrypt(단방향 해시·내장 솔팅·적응형 연산)의 보안성만 챙겼습니다.
ItemReader/Processor/Writer 구현으로 구조가 복잡해집니다. Tasklet 방식은 단일 트랜잭션으로 단순하게 처리할 수 있습니다.
cleanupNotificationJob → cleanupNotificationStep에서 기존 서비스 로직(deleteOldNotifications())을 재사용하고, JobRepository로 실행 이력을 남겼습니다.
BATCH_JOB_EXECUTION 테이블로 배치 성공 여부를 즉시 확인할 수 있게 됐습니다. 데이터가 급증하면 Step만 Chunk 방식으로 바꾸면 되도록 확장 여지를 남겼습니다.
기업의 인적 자원을 안전하게 관리하는 인사 관리 시스템입니다.
팀 WOONG-CHICKEN(5인)이 함께 개발했으며,
팀장·PM, 직원 정보 수정 이력 관리를 전담했습니다.
직원 정보의 생성·수정·삭제를 하나의 변경 이벤트로 보고, 수정 전·후 데이터를 비교해 실제로 변경된 속성만 이력으로 저장합니다.
변경 유형별로 이전 값·이후 값의 유무가 달라, CREATED·UPDATED·DELETED 세 가지를 하나의 이력 구조에서 일관되게 관리하도록 설계했습니다.
누적되는 변경 이력을 커서 기반 페이지네이션과 다중 조건 검색으로 조회하고, 목록·상세 조회를 분리해 자주 호출되는 목록 API의 성능을 지켰습니다.
정렬 기준 값과 이전 페이지의 마지막 요소 ID를 함께 커서로 사용해, 데이터가 누적돼도 중복·누락 없이 일정한 조회 성능을 유지합니다.
등록·수정·삭제 로직마다 반복되던 이력 기록 코드를 함수형 인터페이스 기반 헬퍼로 통합했습니다.
자세한 리팩토링 과정은 아래 트러블슈팅에서 다룹니다.
cursor 파라미터가 빈 문자열("")로 들어올 때 DateTimeParseException과 함께 500 에러가 발생했습니다.
cursor가 null인지만 검증했습니다.null이 아닌 빈 문자열로 들어오는데, LocalDateTime.parse("")는 이를 파싱할 수 없어 예외가 발생했습니다.
cursor != null && !cursor.isBlank()로 검증을 추가했습니다. && 연산자를 사용해 cursor가 null일 때는 뒤의 isBlank()가 실행되지 않도록 해 NullPointerException도 함께 차단했습니다.
LocalDateTime은 타임존 정보가 없어, 프론트가 보낸 UTC(Z) 식별자를 서버가 숫자만 취득하고 무시했습니다.Instant로 변환하며 9시간을 중복으로 차감해, 조회 범위가 의도보다 9시간 앞당겨졌습니다.
LocalDateTime에서 OffsetDateTime으로 바꿔 Z 오프셋 정보를 그대로 보존하고, 생성자 내부에서 한국 시간(KST) 기준 00:00:00~23:59:59로 하루 경계값을 명시적으로 정규화했습니다.
OffsetDateTime/Instant)으로 다뤄야 한다는 것을 직접 경험했습니다.
addDiff 호출이 그대로 반복돼, 총 21회의 중복 코드가 있었습니다.
Function<Employee, R> 기반으로 '어떤 필드를 어떻게 꺼낼지'만 넘기는 방식을 검토했습니다.
compareAndRecord() 헬퍼가 변경 유형(CREATED/UPDATED/DELETED)에 따른 분기 처리를 하나로 통합하고, transform() 헬퍼로 값 추출과 문자열 변환까지 공통화했습니다.
addDiff 호출을 saveLog() 하나와 7회의 addDiff 호출로 줄였습니다.