이번 실습에서는 SSE 알림 시스템의 뼈대를 만든다. Spring Boot 프로젝트를 생성하고, 알림 데이터를 저장할 Entity, Enum, Repository를 설계한다. 도메인이 탄탄해야 이후 편에서 SSE 연결이나 알림 전송 로직을 쌓을 때 흔들리지 않는다.
프로젝트 생성과 의존성
SseEmitter 포함] Project --> JPA[spring-boot-starter-data-jpa
Entity/Repository 관리] Project --> Validation[spring-boot-starter-validation
입력값 검증] Project --> H2[h2
In-memory DB] Project --> Lombok[lombok
코드 간소화]
Spring Initializr에서 프로젝트를 하나 만든다. Group은 com.codeit, Artifact는 sse-notification으로 설정한다. Java 17, Gradle 기반이다.
build.gradle을 열어 의존성을 확인해 보자.
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.11'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.codeit'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
각 의존성이 하는 역할을 짚어 보자.
- spring-boot-starter-web : 웹 서버와 REST API를 만들기 위한 기본 스타터다.
SseEmitter도 이 안에 포함되어 있다. 이후 편에서 SSE 연결 엔드포인트를 만들 때 필요하다. - spring-boot-starter-data-jpa : 알림을 DB에 저장하고 조회하기 위한 JPA 스타터다. Repository 인터페이스 하나로 CRUD를 처리할 수 있게 해준다.
- spring-boot-starter-validation : 입력값 검증 어노테이션을 제공한다. 이후 DTO에서
@NotBlank,@Size같은 검증에 사용한다. - h2 : 인메모리 데이터베이스다. 별도 설치 없이 애플리케이션 실행만으로 DB가 준비된다. 실습 단계에서 빠르게 테스트하기에 적합하다.
- lombok : 보일러플레이트 코드를 줄여주는 라이브러리다.
@Getter,@Builder같은 어노테이션으로 getter, 생성자 코드를 생략할 수 있다.
SseEmitter는 spring-boot-starter-web에 포함되어 있다. SSE를 쓰겠다고 따로 라이브러리를 추가할 필요가 없다는 뜻이다. 이게 WebSocket과의 차이 중 하나이기도 하다. WebSocket은 spring-boot-starter-websocket이 별도로 필요하다.
application.yml 설정
src/main/resources/application.yml에 데이터베이스, JPA, 서버 설정을 넣는다.
spring:
application:
name: sse-notification
datasource:
url: jdbc:h2:mem:notificationdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
h2:
console:
enabled: true
path: /h2-console
server:
port: 8080
logging:
level:
com.codeit.notification: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
notification:
timeout: 3600000
설정 항목이 많으니 블록별로 뜯어보자.
데이터소스 설정
jdbc:h2:mem:notificationdb는 메모리 모드로 H2를 실행한다는 뜻이다. 애플리케이션이 꺼지면 데이터가 사라진다. 실습 단계에서는 오히려 편하다. 매번 깨끗한 상태에서 시작할 수 있으니까.
JPA 설정
ddl-auto: create-drop은 애플리케이션이 시작될 때 테이블을 생성하고, 종료될 때 삭제한다. Entity 클래스를 수정하면 재시작만으로 스키마가 반영되기 때문에 개발 단계에서 유용하다.
show-sql, format_sql, use_sql_comments는 Hibernate가 실행하는 SQL을 콘솔에 보기 좋게 출력해준다. 실습할 때 내가 만든 Repository 메서드가 어떤 SQL로 변환되는지 확인할 수 있다.
H2 콘솔
h2.console.enabled: true로 설정하면 http://localhost:8080/h2-console에서 DB를 웹 브라우저로 직접 조회할 수 있다. 알림 데이터가 제대로 저장됐는지 눈으로 확인할 때 쓴다.
커스텀 설정
notification.timeout: 3600000은 이 프로젝트에서 직접 정의한 커스텀 속성이다. 밀리초 단위로, 3600000ms는 1시간이다. 이후 편에서 SSE 연결의 타임아웃 값으로 사용한다. 이렇게 yml에 빼두면 코드 수정 없이 설정 파일만으로 타임아웃을 조절할 수 있다.
notification.timeout: 3600000] -- "속성 바인딩" --> App[Spring Boot Application] App -- "주입 및 사용" --> SseService[SSE 연결 타임아웃 설정]
NotificationType Enum 작성
알림에는 여러 종류가 있다. 댓글 알림, 좋아요 알림, 시스템 공지 등. 이 종류를 문자열로 관리하면 오타가 나도 컴파일 시점에 잡을 수 없다. "COMENT"라고 잘못 써도 프로그램은 그냥 돌아간다. 버그를 찾기 어려워진다.
Enum으로 관리하면 허용되는 값을 컴파일 타임에 제한할 수 있다. 존재하지 않는 타입을 쓰면 코드가 아예 컴파일되지 않는다.
com.codeit.notification.entity 패키지에 NotificationType을 만든다.
package com.codeit.notification.entity;
public enum NotificationType {
SYSTEM("시스템"),
COMMENT("댓글"),
LIKE("좋아요"),
FOLLOW("팔로우"),
MESSAGE("메시지"),
ANNOUNCEMENT("공지사항");
private final String displayName;
NotificationType(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
각 상수에 한국어 displayName을 매핑해 둔 이유가 있다. 프론트엔드에 알림 종류를 표시할 때 COMMENT를 그대로 보여주면 사용자가 이해하기 어렵다. "댓글"이라는 표시명을 Enum이 직접 들고 있으면, 별도의 변환 로직 없이 getDisplayName()만 호출하면 된다.
단순한 상수 나열에 그치지 않고, Enum에 displayName처럼 부가 정보를 넣거나, 메서드를 추가해서 로직을 품게 하는 건 흔히 쓰이는 패턴이다. 타입별로 다른 알림 메시지 템플릿을 리턴하는 메서드를 넣을 수도 있다.
Notification Entity 작성
알림 시스템의 핵심 도메인이다. 하나의 알림이 어떤 정보를 담고 있어야 하는지 생각해 보자.
- 누구에게 보내는 알림인지 →
userId - 어떤 종류의 알림인지 →
type - 알림 제목과 내용 →
title,message - 관련 페이지 링크 →
link - 읽었는지 여부 →
read,readAt - 언제 만들어졌는지 →
createdAt
com.codeit.notification.entity 패키지에 Notification Entity를 만든다. 한꺼번에 전체를 보여주면 파악이 어려우니, 부분별로 나눠서 살펴보자.
단계 1 클래스 선언과 어노테이션
package com.codeit.notification.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "notifications")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@AllArgsConstructor
@Builder
public class Notification {
}
@Entity는 이 클래스가 JPA 엔티티임을 선언한다. @Table(name = "notifications")로 테이블명을 명시했다. 클래스명 그대로 notification이 되어도 되지만, 복수형으로 테이블명을 짓는 게 관례다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)가 중요하다. JPA는 리플렉션으로 객체를 생성하기 때문에 기본 생성자가 반드시 필요하다. 그런데 public으로 열어두면 아무 데서나 new Notification()으로 불완전한 객체를 만들 수 있다. protected로 제한하면 JPA만 사용할 수 있고, 외부 코드에서는 팩토리 메서드나 빌더를 쓰도록 유도할 수 있다.
@Builder는 Lombok이 빌더 패턴 코드를 자동 생성해준다. 필드가 많은 Entity에서 생성자 파라미터 순서를 외우는 것보다 .userId("user1").type(COMMENT) 같은 빌더 체이닝이 훨씬 읽기 쉽다.
단계 2 필드 선언
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private NotificationType type;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, length = 1000)
private String message;
@Column(length = 500)
private String link;
@Column(nullable = false)
private boolean read = false;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime readAt;
몇 가지 포인트를 짚어 보자.
@Enumerated(EnumType.STRING): Enum을 DB에 저장할 때ORDINAL(순서 숫자)과STRING(이름 문자열) 두 가지 방식이 있다. 반드시STRING을 써야 한다.ORDINAL을 쓰면 Enum에 새 값을 중간에 추가했을 때 기존 데이터의 매핑이 깨진다. 예를 들어COMMENT가 1번이었는데 앞에 새 타입을 추가하면 2번으로 밀린다. DB에 저장된 1은 이제 엉뚱한 타입을 가리키게 된다.updatable = false:createdAt은 한 번 생성되면 바뀌면 안 된다. 이 옵션을 걸면 JPA가 UPDATE 쿼리에서 이 컬럼을 아예 제외한다.read필드의 기본값 :boolean read = false로 초기값을 준다. 알림은 만들어진 직후에는 당연히 안 읽은 상태다.
단계 3 생명주기 콜백과 도메인 메서드
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public void markAsRead() {
this.read = true;
this.readAt = LocalDateTime.now();
}
@PrePersist는 JPA가 이 Entity를 DB에 처음 저장하기 직전에 자동 호출하는 콜백이다. createdAt을 여기서 설정하면 개발자가 일일이 setCreatedAt(LocalDateTime.now())를 호출하는 실수를 방지할 수 있다.
markAsRead()는 알림을 읽음 처리하는 도메인 메서드다. read와 readAt을 동시에 변경해야 하는데, 이 로직을 Entity 안에 두면 두 값의 동기화가 깨질 위험이 없다. 서비스 레이어에서 notification.setRead(true)만 하고 readAt을 빼먹는 실수를 원천 차단하는 셈이다.
단계 4 팩토리 메서드
public static Notification create(String userId, NotificationType type,
String title, String message) {
return Notification.builder()
.userId(userId)
.type(type)
.title(title)
.message(message)
.read(false)
.createdAt(LocalDateTime.now())
.build();
}
public static Notification createWithLink(String userId, NotificationType type,
String title, String message, String link) {
return Notification.builder()
.userId(userId)
.type(type)
.title(title)
.message(message)
.link(link)
.read(false)
.createdAt(LocalDateTime.now())
.build();
}
빌더를 직접 쓰면 read(false)나 createdAt(LocalDateTime.now()) 같은 기본값 설정을 호출자가 알아서 해야 한다. 실수로 빠뜨리면 read가 false가 아닐 수도 있고, createdAt이 null일 수도 있다.
팩토리 메서드는 이런 기본값을 메서드 내부에서 보장한다. 외부에서는 Notification.create("user1", COMMENT, "제목", "내용")처럼 필요한 정보만 넘기면 된다. create와 createWithLink 두 개를 만든 건, 링크가 있는 알림과 없는 알림을 명확히 구분하기 위해서다.
@PrePersist는 JPA를 통해 저장할 때만 동작한다. 팩토리 메서드에서도 createdAt을 설정하는 건, 저장 전에 객체를 로그로 찍거나 다른 곳에서 참조할 때 null이 아닌 값을 가지게 하기 위해서다. 방어적 프로그래밍이라고 보면 된다.
(createdAt 설정) Entity-->>User: 생성된 객체 (Non-null createdAt) User->>JPA: repository.save(entity) JPA->>Entity: @PrePersist onCreate()
(createdAt 최종 확정) JPA->>DB: INSERT Query
NotificationRepository 작성
도메인을 만들었으니 데이터 접근 계층을 만들 차례다. Spring Data JPA는 인터페이스만 선언하면 구현체를 자동으로 만들어준다.
com.codeit.notification.repository 패키지에 NotificationRepository를 만든다.
package com.codeit.notification.repository;
import com.codeit.notification.entity.Notification;
import com.codeit.notification.entity.NotificationType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserIdOrderByCreatedAtDesc(String userId);
List<Notification> findByUserIdAndReadFalseOrderByCreatedAtDesc(String userId);
List<Notification> findByUserIdAndTypeOrderByCreatedAtDesc(String userId,
NotificationType type);
long countByUserIdAndReadFalse(String userId);
@Query("SELECT n FROM Notification n WHERE n.userId = :userId "
+ "AND n.createdAt BETWEEN :startDate AND :endDate "
+ "ORDER BY n.createdAt DESC")
List<Notification> findByUserIdAndDateRange(@Param("userId") String userId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate);
void deleteByReadTrueAndCreatedAtBefore(LocalDateTime date);
}
메서드가 여러 개인데, 하나씩 뜯어보자.
쿼리 메서드 네이밍 규칙
Spring Data JPA는 메서드 이름을 파싱해서 SQL을 자동 생성한다. 이름을 짓는 규칙이 있다.
findBy: SELECT 쿼리의 시작And: WHERE 조건 연결OrderByCreatedAtDesc: ORDER BY created_at DESC
findByUserIdAndReadFalseOrderByCreatedAtDesc를 분해하면:
findBy→ SELECTUserId→ WHERE user_id = ?And→ ANDReadFalse→ read = falseOrderByCreatedAtDesc→ ORDER BY created_at DESC
메서드 이름 자체가 쿼리 명세가 되는 셈이다. SQL을 직접 쓰지 않아도 메서드 이름만으로 의도를 파악할 수 있다.
각 메서드의 용도를 정리하면:
findByUserIdOrderByCreatedAtDesc: 특정 사용자의 전체 알림을 최신순으로 조회한다.findByUserIdAndReadFalseOrderByCreatedAtDesc: 읽지 않은 알림만 조회한다. 알림 목록에서 "안 읽은 알림만 보기" 필터에 사용한다.findByUserIdAndTypeOrderByCreatedAtDesc: 알림 종류별 필터링이다. "댓글 알림만 보기" 같은 기능에 사용한다.countByUserIdAndReadFalse: 읽지 않은 알림 개수를 센다. 알림 아이콘에 뱃지 숫자를 표시할 때 쓴다.deleteByReadTrueAndCreatedAtBefore: 읽은 알림 중 특정 날짜 이전 것을 삭제한다. 오래된 알림 정리 배치에 사용한다.
@Query를 쓰는 경우
findByUserIdAndDateRange는 네이밍 규칙만으로 표현하기 어려운 쿼리다. BETWEEN 같은 범위 조건은 메서드 이름이 너무 길어지거나, 원하는 대로 생성되지 않을 수 있다.
이럴 때 @Query로 JPQL을 직접 작성한다. :userId, :startDate, :endDate는 네임드 파라미터로, @Param 어노테이션으로 매핑한다.
네이밍 규칙으로 충분하면 그대로 쓰고, 복잡해지면 @Query로 전환하면 된다. 둘 다 알아두는 게 좋다.
이번 편 최종 전체 코드
NotificationType.java
package com.codeit.notification.entity;
public enum NotificationType {
SYSTEM("시스템"),
COMMENT("댓글"),
LIKE("좋아요"),
FOLLOW("팔로우"),
MESSAGE("메시지"),
ANNOUNCEMENT("공지사항");
private final String displayName;
NotificationType(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
Notification.java
package com.codeit.notification.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "notifications")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@AllArgsConstructor
@Builder
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private NotificationType type;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, length = 1000)
private String message;
@Column(length = 500)
private String link;
@Column(nullable = false)
private boolean read = false;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime readAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public void markAsRead() {
this.read = true;
this.readAt = LocalDateTime.now();
}
public static Notification create(String userId, NotificationType type,
String title, String message) {
return Notification.builder()
.userId(userId)
.type(type)
.title(title)
.message(message)
.read(false)
.createdAt(LocalDateTime.now())
.build();
}
public static Notification createWithLink(String userId, NotificationType type,
String title, String message, String link) {
return Notification.builder()
.userId(userId)
.type(type)
.title(title)
.message(message)
.link(link)
.read(false)
.createdAt(LocalDateTime.now())
.build();
}
}
NotificationRepository.java
package com.codeit.notification.repository;
import com.codeit.notification.entity.Notification;
import com.codeit.notification.entity.NotificationType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserIdOrderByCreatedAtDesc(String userId);
List<Notification> findByUserIdAndReadFalseOrderByCreatedAtDesc(String userId);
List<Notification> findByUserIdAndTypeOrderByCreatedAtDesc(String userId,
NotificationType type);
long countByUserIdAndReadFalse(String userId);
@Query("SELECT n FROM Notification n WHERE n.userId = :userId "
+ "AND n.createdAt BETWEEN :startDate AND :endDate "
+ "ORDER BY n.createdAt DESC")
List<Notification> findByUserIdAndDateRange(@Param("userId") String userId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate);
void deleteByReadTrueAndCreatedAtBefore(LocalDateTime date);
}
자주 하는 실수
@Enumerated를 안 붙이면 기본값이 ORDINAL이다. DB에 0, 1, 2 같은 숫자가 저장된다. 이 상태에서 Enum 상수의 순서를 바꾸거나 중간에 새 값을 추가하면, 기존 데이터가 전혀 다른 타입으로 읽힌다. 반드시 EnumType.STRING을 명시해야 한다.
[!DANGER] 기본 생성자를 public으로 열어둠
JPA가 기본 생성자를 요구한다는 걸 알고 public 기본 생성자를 만드는 경우가 있다. 이러면 어디서든 new Notification()으로 필수 필드 없는 불완전한 객체를 만들 수 있다. @NoArgsConstructor(access = AccessLevel.PROTECTED)로 접근을 제한하고, 객체 생성은 팩토리 메서드나 빌더를 통하게 유도한다.
[!DANGER] createdAt을 개발자가 직접 세팅하는 방식에만 의존
@PrePersist 없이 팩토리 메서드에서만 createdAt을 설정하면, 누군가 빌더를 직접 쓰면서 createdAt을 빼먹을 수 있다. 반대로 @PrePersist만 쓰면 저장 전까지 createdAt이 null이다. 두 가지를 함께 쓰는 게 가장 안전하다.