BACKEND DEVELOPER

준영

✍ Blog 🐙 GitHub ✉ visionkov1001@gmail.com

문제를 분해하고, 구조에 맞는 기술을 찾아갈 때
희열을 느끼는 백엔드 개발자입니다.


Java 17 Spring Boot Spring Security / JWT Spring Data JPA / QueryDSL SSE / WebSocket / STOMP Redis Apache Kafka PostgreSQL AWS EC2 / ECS / ECR Docker GitHub Actions Spring Batch
PROJECT
옷장을 부탁해
옷장을 부탁해
2026.04.29(수) — 2026.06.12(월)

날씨, 취향을 고려해 사용자가 보유한 의상 조합을 추천해주고, OOTD 피드, 팔로우 등의 소셜 기능을 갖춘 서비스입니다.

팀 GitGgal(5인)이 함께 개발했으며,
실시간 알림(SSE), 팔로우, DM(WebSocket/STOMP) 기능을 전담했습니다.

알림 (SSE) 팔로우 DM (WebSocket) Spring Boot Redis Pub/Sub Kafka JWT
01
🔔
실시간 알림 (SSE)

팔로우·좋아요·댓글 등 이벤트를 HTTP 롱폴링 없이 서버에서 클라이언트로 단방향으로 실시간 푸시합니다. Redis Pub/Sub을 사이에 두어 멀티 인스턴스 환경에서도 알림이 정확히 전달됩니다.

// 알림 발송 흐름 서비스 ─→ notificationRepository.save() ─→ Redis Pub/Sub 발행 ─→ NotificationSubscriber ─→ SseEmitter.send() ─→ 브라우저

SseEmitter 30분 타임아웃 설정, 연결·종료·오류 콜백으로 리소스 자동 정리. 최초 connect 이벤트를 즉시 전송해 일부 클라이언트의 연결 미감지 문제를 방지했습니다.

02
👤
팔로우 시스템

팔로우·언팔로우와 팔로워/팔로잉 목록 조회를 구현했습니다.
목록 조회는 커서 기반 페이지네이션을 적용해, 데이터가 누적돼도 응답 속도가 느려지지 않도록 설계했습니다.

// 팔로우 생성 후 알림 발송 followRepository.save(follow)ApplicationEventPublisher .publishEvent(FollowCreatedEvent) ↓ @TransactionalEventListener (phase = AFTER_COMMIT)KafkaNotificationService

@TransactionalEventListener(phase = AFTER_COMMIT)으로 트랜잭션 커밋 이후에만 Kafka 메시지가 발행되어, DB 롤백 시 알림이 발송되는 문제를 방지했습니다.

03
💬
DM (WebSocket / STOMP)

STOMP 프로토콜 기반의 1:1 실시간 채팅을 구현했습니다. CONNECT 프레임 시점에 JWT를 검증하는 인터셉터를 직접 작성해 WebSocket 구간의 인증을 완성했습니다.

// WebSocket DM 흐름 클라이언트 CONNECT → StompAuthChannelInterceptor (JWT 검증 → SecurityContext 등록) → /pub/direct-messages_sendDirectMessageService.send() → 저장 + Kafka 발행 → /sub/direct-messages_{dmKey}

DM 방 키는 두 사용자 UUID를 정렬 후 결합해 생성 — 어느 쪽이 먼저 대화를 시작하든 동일한 채널로 연결되어, 같은 두 사람 사이에 중복된 방이 생기지 않습니다. senderId를 JWT 인증값과 비교해 페이로드 위조를 차단했습니다.


TROUBLE SHOOTING
문제를 만나고
해결한 기록
ADR
SSE 저장소 선택 & 분산 알림 파이프라인 — 단일 서버에서 멀티 서버까지
상황 (1단계 · 단일 서버) SSE는 서버가 연결 객체인 SseEmitter를 저장해두고 알림마다 꺼내 써야 합니다.
어디에 저장할지 고민이 필요했습니다.
검토 Redis는 멀티 인스턴스에서 공통 통로로 활용 가능하지만 외부 인프라 의존성이 생깁니다.
인메모리 Map은 단일 서버 기준으로 외부 의존성 없이 구현이 단순합니다.
결정 기본 기능 단계에서는 인메모리 ConcurrentHashMap으로 직접 저장소를 구현했습니다.

SSE 연결·종료가 여러 스레드에서 동시에 일어나므로 HashMap 대신 ConcurrentHashMap을 선택해 스레드 안전성을 확보했습니다.
상황 (2단계 · 멀티 서버 전환) 단일 서버에서 멀티 서버 환경으로 전환하면서 ApplicationEventPublisher는 같은 JVM 내에서만 이벤트를 전달하므로, 다른 서버 인스턴스의 사용자에게 알림이 도달하지 않는 문제가 생겼습니다.
검토 Redis Pub/Sub 확장은 메시지를 디스크에 저장하지 않아 서버 다운 시 유실됩니다. DB Polling 방식은 실시간성이 현저히 떨어집니다.
운영 환경에 이미 Kafka 브로커 3대(복제 2)가 구성되어 있었습니다.
결정 Kafka를 이벤트 파이프라인 앞단으로 도입했습니다. @RetryableTopic(attempts = "5")으로 지수 백오프(1→2→4→8초) 재시도, 5회 실패 시 DLT로 격리해 정상 흐름에 영향을 주지 않도록 구성했습니다.
결과 단일 서버 → 멀티 서버 전환 과정을 의도적으로 단계를 나눠 직접 경험했습니다.
어느 서버에서 이벤트가 발생해도 Kafka Consumer가 처리하며, groupId 기반 파티션 분배로 인스턴스 수에 관계없이 알림이 정확히 1회 처리됩니다. Redis Pub/Sub은 이후에도 SSE 팬아웃 역할을 그대로 유지합니다.
ADR
팔로우 목록 조회 N+1 문제 — QueryDSL fetchJoin 적용
상황 Follow 엔티티가 follower, followee 두 User를 LAZY로 참조합니다.
팔로잉 20개를 조회하면 Follow SELECT 1번 + User SELECT 40번 = 총 41번 쿼리가 발생합니다.
검토 QueryDSL의 fetchJoin()으로 Follow 조회 시 follower, followee를 JOIN으로 한꺼번에 가져오면 쿼리 1번으로 해결됩니다. COUNT 쿼리는 행 수만 세므로 fetchJoin 없이 일반 join으로 분리했습니다.
결과 SELECT 쿼리 41번 → 1번으로 감소했습니다.
ADR
DM 트랜잭션 커밋 전 WebSocket 푸시 발생 — Spring Event 도입
상황 DM 저장 → WebSocket 푸시 → SSE 알림이 모두 하나의 트랜잭션 안에 있었습니다. DB 롤백이 발생해도 수신자에게는 이미 메시지와 알림이 전달된 불일치 상태가 생길 수 있습니다.
해결 @TransactionalEventListener(phase = AFTER_COMMIT) + Spring Event 조합으로 변경했습니다. DB 커밋이 확인된 이후에만 WebSocket 푸시와 SSE 알림이 실행됩니다.
결과 DB 저장 로직과 발송 로직의 관심사가 분리되어 독립적인 테스트가 가능해졌습니다.
트레이드오프로, 리스너 내 예외 발생 시 트랜잭션이 이미 커밋된 상태라 롤백이 불가능합니다.
BUG
멀티 서버 환경에서 알림이 DB에 저장되지 않는 문제 — Redis 직렬화 오류
상황 8080, 8081 포트로 서버를 각각 띄운 뒤 유저 A → 유저 B로 DM을 전송했을 때 DM은 정상 저장되는데 알림이 DB에 쌓이지 않고 화면에도 출력되지 않는 문제가 발생했습니다.
원인 notificationPublisher.publish()에서 GenericJackson2JsonRedisSerializerNotificationDto.createdAt (Instant 타입) 직렬화를 시도했습니다.
기본 ObjectMapper에는 JavaTimeModule이 없어 SerializationException이 발생했고, 이 예외가 전파되어 트랜잭션이 롤백 → DB INSERT까지 취소됐습니다.
해결 RedisConfig에서 JavaTimeModule을 등록하고 WRITE_DATES_AS_TIMESTAMPS를 비활성화한 ObjectMapper를 직접 주입했습니다. Instant가 숫자 대신 ISO 문자열 형태("2026-05-19T05:43:24Z")로 직렬화되어 문제가 해결됐습니다.
결과 Redis 직렬화 성공 → 트랜잭션 정상 커밋 → 알림 DB 저장 및 SSE 푸시가 모두 정상 동작합니다. Redis용 ObjectMapper를 별도로 관리해야 한다는 것을 직접 경험했습니다.
PROBLEM
WebSocket 연결 후 senderId 위조로 인증 우회 가능성
상황 STOMP SEND 프레임의 페이로드에 있는 senderId를 클라이언트가 임의 값으로 바꾸면, 다른 사람인 척 DM을 보낼 수 있었습니다.
원인 HTTP REST API는 Spring Security 필터체인이 JWT를 검증하지만, WebSocket 구간에는 기본적으로 필터체인이 적용되지 않아 별도 인증 처리가 필요했습니다.
해결 StompAuthChannelInterceptor를 직접 구현해 CONNECT 프레임 시점에 JWT를 검증하고 SecurityContext에 등록했습니다.
서비스 레이어에서 인증 ID와 페이로드의 senderId를 비교해 불일치 시 예외를 던집니다.
결과 CONNECT 시점과 SEND 시점, 두 단계 모두에서 인증이 검증됩니다. senderId를 위조해도 JWT 인증 ID와 불일치하여 요청이 차단됩니다.

PROJECT
덕후감
덕후감
2026.02.27(금) — 2026.03.23(월)

ISBN 정보를 기반으로 책 정보를 자동으로 불러와 리뷰와 감상을 나누는 책 커뮤니티 플랫폼입니다.

도서 이미지 OCR과 ISBN 매칭으로 책 등록을 간편하게 만들었습니다.

백엔드 개발자 5인이 함께 개발했으며,
사용자(회원·이메일 인증), 알림, 배포 관리 도메인을 전담했습니다.

사용자 / 이메일 인증 알림 배포 관리 Spring Boot Spring Batch Spring Security Crypto Kafka AWS ECS / ALB
01
👤
사용자 (회원·이메일 인증)

회원가입·로그인과 이메일 인증 기반 비밀번호 찾기를 구현했습니다. 비밀번호는 BCrypt로 단방향 해시 저장하고, 탈퇴는 논리 삭제(soft delete)로 처리합니다.

// 비밀번호 찾기 흐름 비밀번호 찾기 요청 → 6자리 인증 코드 생성 (ThreadLocalRandom)이메일 발송 (Kafka 비동기) → 코드 검증 → 비밀번호 재설정
02
🔔
알림

리뷰 좋아요·댓글 등의 이벤트를 Kafka로 비동기 처리해 알림을 적재합니다. 누적되는 알림 데이터는 Spring Batch로 주기적으로 정리합니다.

// 알림 적재 & 정리 이벤트 발생 → Kafka (notification-topic)NotificationConsumer → 알림 저장 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ 스케줄러 → Batch Job → 오래된 알림 일괄 삭제

Batch JobRepository가 실행 이력을 남겨, 매일 정리 작업이 정상 수행됐는지·어디서 실패했는지 추적할 수 있습니다.

03
🚀
배포 관리

GitHub Actions로 빌드한 Docker 이미지를 ECR에 푸시하고 AWS ECS(EC2)에 배포합니다. ALB가 트래픽을 분산하고 헬스 체크로 컨테이너 상태를 관리합니다.

// 배포 파이프라인 git push → GitHub Actions (build + test) → Docker 이미지 → ECRECS(EC2) 배포 → ALB Health Check → 트래픽 연결

배포 과정에서 겪은 문제와 해결 과정은 아래 트러블슈팅에서 자세히 다룹니다.


TROUBLE SHOOTING & ADR
덕후감에서
해결한 기록
BUG
JPA 전역 필터와 Fetch Join 충돌로 인한 500 에러
상황 탈퇴(논리 삭제)한 유저가 작성한 리뷰 목록을 조회하면 500 Internal Server Error가 100% 발생했습니다. (Entity User ... does not exist)
원인 User 엔티티에 @SQLRestriction("deleted_at IS NULL")이 걸려 있어, ReviewRepositoryfetchJoin()으로 작성자를 채우려 할 때 탈퇴 유저가 '없는 데이터'로 취급되어 무결성 예외가 발생했습니다.
해결 User의 전역 필터를 제거해 연관 조회를 허용하고, 대신 로그인·중복 체크 등 실제 유효 유저만 다뤄야 하는 곳에 deletedAt IS NULL 조건을 수동으로 추가했습니다.
조회 시에는 Optional.filter로 탈퇴 유저를 걸러 USER_NOT_FOUND를 던지도록 설계했습니다.
결과 전역 필터의 편리함과 연관 조회의 안정성이 충돌할 수 있음을 직접 경험했고, '어디서 필터링할지'를 계층별로 명확히 나누게 됐습니다.
ADR
이메일 인증 코드 생성 — Random vs ThreadLocalRandom
상황 비밀번호 찾기용 6자리 숫자 인증 코드가 필요했고, 여러 사용자가 동시에 요청해도 성능 저하가 적은 난수 생성 방식을 골라야 했습니다.
검토 Random은 하나의 seed를 공유해, 멀티스레드에서 동시 호출 시 CAS 경합으로 재시도가 늘며 성능이 떨어집니다.
ThreadLocalRandom은 스레드마다 독립적인 난수 생성기를 가져 공유 자원 경합이 없습니다.
결정 ThreadLocalRandom.current().nextInt(100_000, 1_000_000)으로 항상 6자리(100,000~999,999) 정수를 생성하고, 앞자리가 0으로 시작하는 경우까지 대비해 String.format("%06d", ...)으로 자릿수를 보정했습니다. 표준 API만 써 의존성이 낮고 의도가 명확합니다.
ADR
Spring Security Crypto 모듈 단독 채택
상황 비밀번호 암호화만 필요해 spring-boot-starter-security를 넣었더니, 모든 요청에 보안 필터가 강제로 걸려 H2 콘솔·정적 리소스 접근까지 막혔습니다.
검토 필터를 일일이 permitAll()·csrf().disable()로 끄는 방식은 설정 누락 위험과 복잡도가 커지고, Jasypt 같은 외부 라이브러리는 표준 PasswordEncoder와의 정합성이 떨어졌습니다.
결정 전체 프레임워크 대신 spring-security-crypto만 단독으로 사용했습니다. @EnableWebSecurity 없이 BCryptPasswordEncoder만 빈으로 등록해, 필터 부담 없이 BCrypt(단방향 해시·내장 솔팅·적응형 연산)의 보안성만 챙겼습니다.
ADR
알림 데이터 정리 — Spring Batch(Tasklet) 도입
상황 운영 기간이 길어지면 누적 알림이 DB 성능에 부담을 줄 수 있고, 기존 스케줄러는 중간에 서버가 죽으면 '어디까지 처리했는지' 기록이 남지 않는 점이 고민이었습니다.
검토 Chunk 방식은 대량 데이터를 끊어 처리해 메모리 효율이 좋지만 ItemReader/Processor/Writer 구현으로 구조가 복잡해집니다. Tasklet 방식은 단일 트랜잭션으로 단순하게 처리할 수 있습니다.
결정 부트캠프 기간 내 프로젝트라 운영 기간과 사용자 규모가 제한적이었고, 이 조건에서는 삭제량이 수백만 건 수준까지 쌓이지 않을 것으로 판단해 Tasklet 방식을 채택했습니다. cleanupNotificationJobcleanupNotificationStep에서 기존 서비스 로직(deleteOldNotifications())을 재사용하고, JobRepository로 실행 이력을 남겼습니다.
결과 BATCH_JOB_EXECUTION 테이블로 배치 성공 여부를 즉시 확인할 수 있게 됐습니다. 데이터가 급증하면 Step만 Chunk 방식으로 바꾸면 되도록 확장 여지를 남겼습니다.
PROBLEM
로드밸런서 상태 검사 임계값 때문에 배포가 실패하던 문제
상황 애플리케이션은 정상 기동되고 DNS로 접속도 되는데, ALB 상태 검사 때문에 컨테이너가 강제 종료되어 배포가 실패했습니다.
원인 '전체 확인 시간'이 ECS '유예 기간'을 초과했기 때문입니다.
기동 82초 + (검사 간격 30초 × 정상 임계값 5회 = 150초) = 232초가 필요한데, 유예 기간은 120초뿐이라 정상 판정 전에 종료됐습니다.
해결 정상 임계값을 5회 → 2회로 낮추고, 유예 기간을 120초 → 200초로 늘렸습니다.
기동 82초 + (30초 × 2회 = 60초) = 142초로 줄어 유예 기간 200초 안에 'Healthy' 판정을 받아 배포에 성공했습니다.

PROJECT
HR Bank
HR Bank
2026.01.20(화) — 2026.01.29(목)

기업의 인적 자원을 안전하게 관리하는 인사 관리 시스템입니다.

팀 WOONG-CHICKEN(5인)이 함께 개발했으며,
팀장·PM, 직원 정보 수정 이력 관리를 전담했습니다.

팀장 / PM 수정 이력 관리 Spring Boot Spring Data JPA PostgreSQL / H2 springdoc-openapi MapStruct Railway.io
01
📝
Diff 기반 변경 이력(ChangeLog) 시스템

직원 정보의 생성·수정·삭제를 하나의 변경 이벤트로 보고, 수정 전·후 데이터를 비교해 실제로 변경된 속성만 이력으로 저장합니다.

// 변경 이력 기록 흐름 직원 정보 수정 요청 → 수정 전/후 Employee 객체 비교compareAndRecord() 필드별 Diff 추출 → ChangeLog + ChangeLogDiff 저장 (CREATED / UPDATED / DELETED)

변경 유형별로 이전 값·이후 값의 유무가 달라, CREATED·UPDATED·DELETED 세 가지를 하나의 이력 구조에서 일관되게 관리하도록 설계했습니다.

02
🔍
변경 이력 조회 최적화

누적되는 변경 이력을 커서 기반 페이지네이션과 다중 조건 검색으로 조회하고, 목록·상세 조회를 분리해 자주 호출되는 목록 API의 성능을 지켰습니다.

// 목록 / 상세 조회 분리 목록 조회 (cursor, 다중 조건) → ChangeLog 메타 정보만 조회 (Diff 제외) ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ 상세 진입 시 → ChangeLogDiff 추가 로딩

정렬 기준 값과 이전 페이지의 마지막 요소 ID를 함께 커서로 사용해, 데이터가 누적돼도 중복·누락 없이 일정한 조회 성능을 유지합니다.

03
🧹
중복 코드 제거

등록·수정·삭제 로직마다 반복되던 이력 기록 코드를 함수형 인터페이스 기반 헬퍼로 통합했습니다.

// 리팩토링 전후 Before: 등록/수정/삭제 각각 addDiff × 7 (총 21회 중복) After: saveLog() → compareAndRecord() × 7 (Function<Employee,R> 기반 공통화)

자세한 리팩토링 과정은 아래 트러블슈팅에서 다룹니다.


TROUBLE SHOOTING & ADR
HR Bank에서
해결한 기록
BUG
빈 문자열 파라미터로 인한 500 에러
상황 cursor 파라미터가 빈 문자열("")로 들어올 때 DateTimeParseException과 함께 500 에러가 발생했습니다.
원인 기존 코드는 cursornull인지만 검증했습니다.
HTTP 요청 특성상 파라미터 키는 존재하고 값만 비어 있으면 Java의 String은 null이 아닌 빈 문자열로 들어오는데, LocalDateTime.parse("")는 이를 파싱할 수 없어 예외가 발생했습니다.
해결 cursor != null && !cursor.isBlank()로 검증을 추가했습니다. && 연산자를 사용해 cursornull일 때는 뒤의 isBlank()가 실행되지 않도록 해 NullPointerException도 함께 차단했습니다.
결과 빈 문자열 파라미터로 인한 500 에러가 해결됐습니다. HTTP 파라미터는 '값이 없음(null)'과 '값이 빈 문자열("")'이 다르다는 것을 직접 경험했습니다.
BUG
분산 환경 타임존 불일치로 인한 데이터 누락
상황 프론트엔드에서 특정 날짜(예: 1월 27일)로 조회했을 때, 해당 날짜 오후에 발생한 수정 이력이 목록에서 누락되는 현상이 발생했습니다.
원인 LocalDateTime은 타임존 정보가 없어, 프론트가 보낸 UTC(Z) 식별자를 서버가 숫자만 취득하고 무시했습니다.
이 값을 다시 한국 시간으로 간주해 Instant로 변환하며 9시간을 중복으로 차감해, 조회 범위가 의도보다 9시간 앞당겨졌습니다.
해결 요청 파라미터 타입을 LocalDateTime에서 OffsetDateTime으로 바꿔 Z 오프셋 정보를 그대로 보존하고, 생성자 내부에서 한국 시간(KST) 기준 00:00:00~23:59:59로 하루 경계값을 명시적으로 정규화했습니다.
결과 날짜 경계에서 발생하던 데이터 누락이 해결됐습니다. 시간 데이터는 타임존 정보를 유지하는 타입(OffsetDateTime/Instant)으로 다뤄야 한다는 것을 직접 경험했습니다.
ADR
중복 코드 제거 — 함수형 인터페이스 리팩토링
상황 직원 등록·수정·삭제 메서드마다 7~8개의 addDiff 호출이 그대로 반복돼, 총 21회의 중복 코드가 있었습니다.
검토 필드마다 조건이 달랐습니다 — 등록은 이전 값이 없고, 수정은 값이 실제로 다를 때만, 삭제는 현재 값이 없습니다.
Function<Employee, R> 기반으로 '어떤 필드를 어떻게 꺼낼지'만 넘기는 방식을 검토했습니다.
결정 compareAndRecord() 헬퍼가 변경 유형(CREATED/UPDATED/DELETED)에 따른 분기 처리를 하나로 통합하고, transform() 헬퍼로 값 추출과 문자열 변환까지 공통화했습니다.
결과 21회였던 addDiff 호출을 saveLog() 하나와 7회의 addDiff 호출로 줄였습니다.

SKILLS
기술 역량
BACKEND
Java 17
Spring Boot
Spring Security / JWT
Spring Data JPA / QueryDSL
SSE / WebSocket / STOMP
MESSAGING / DB
Redis (Pub/Sub, Cache)
Apache Kafka
PostgreSQL
INFRA / DEVOPS
AWS EC2 / ECS / ECR
Docker
GitHub Actions
ETC
JUnit 5 / Mockito
Swagger / SpringDoc
Git / GitHub
Spring Batch