Skip to content
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ dependencies {
implementation 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.kafka:spring-kafka'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.security:spring-security-messaging'

runtimeOnly 'org.postgresql:postgresql'

Expand Down
91 changes: 71 additions & 20 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,32 +1,45 @@
version: '3.8'

services:
app:
image: discodeit:local
reverse-proxy:
image: nginx:1.28.0
container_name: reverse-proxy
depends_on:
- backend
ports:
- "3000:80"
networks:
- discodeit-network
volumes:
- ./src/main/resources/static:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro

backend:
build:
context: .
dockerfile: Dockerfile
container_name: discodeit
ports:
- "8081:80"
container_name: backend
depends_on:
- db
- redis
- broker
environment:
- SPRING_PROFILES_ACTIVE=prod
- SPRING_PROFILES_ACTIVE=dev
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/discodeit
- SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME}
- SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD}
- STORAGE_TYPE=s3
- STORAGE_TYPE=local
- STORAGE_LOCAL_ROOT_PATH=.discodeit/storage
- AWS_S3_ACCESS_KEY=${AWS_S3_ACCESS_KEY}
- AWS_S3_SECRET_KEY=${AWS_S3_SECRET_KEY}
- AWS_S3_REGION=${AWS_S3_REGION}
- AWS_S3_BUCKET=${AWS_S3_BUCKET}
- AWS_S3_PRESIGNED_URL_EXPIRATION=600
depends_on:
- db
volumes:
- binary-content-storage:/app/.discodeit/storage
- DISCODEIT_ADMIN_USERNAME=${DISCODEIT_ADMIN_USERNAME}
- DISCODEIT_ADMIN_EMAIL=${DISCODEIT_ADMIN_EMAIL}
- DISCODEIT_ADMIN_PASSWORD=${DISCODEIT_ADMIN_PASSWORD}
- KAFKA_BOOTSTRAP_SERVERS=broker:29092
- REDIS_HOST=redis
- REDIS_PORT=6379
expose:
- 8080
networks:
- discodeit-network
volumes:
- binary-content-storage:/app/.discodeit/storage

db:
image: postgres:16-alpine
Expand All @@ -35,17 +48,55 @@ services:
- POSTGRES_DB=discodeit
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- "5432:5432"
expose:
- 5432
networks:
- discodeit-network
volumes:
- postgres-data:/var/lib/postgresql/data
- ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql


broker:
image: apache/kafka:4.0.0
container_name: broker
environment:
KAFKA_BROKER_ID: 1
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_NODE_ID: 1
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:29093
KAFKA_LISTENERS: PLAINTEXT://broker:29092,CONTROLLER://broker:29093,PLAINTEXT_HOST://0.0.0.0:9092
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
expose:
- 29092
networks:
- discodeit-network

redis:
image: redis:7.2-alpine
container_name: redis
expose:
- 6379
networks:
- discodeit-network
volumes:
- redis-data:/data
command: redis-server --appendonly yes


volumes:
postgres-data:
binary-content-storage:
redis-data:

networks:
discodeit-network:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.sprint.mission.discodeit.config;


import com.sprint.mission.discodeit.entity.Role;
import com.sprint.mission.discodeit.security.JwtAuthenticationChannelInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor;
import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager;
import org.springframework.security.messaging.context.SecurityContextChannelInterceptor;
import org.springframework.web.socket.config.annotation.*;

@Slf4j
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final JwtAuthenticationChannelInterceptor jwtAuthenticationChannelInterceptor;

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/sub"); // 구독 prefix
config.setApplicationDestinationPrefixes("/pub"); // 발행 prefix
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}

@Bean
public AuthorizationChannelInterceptor authorizationChannelInterceptor() {
var authzManager = MessageMatcherDelegatingAuthorizationManager
.builder()
// 예: 모든 메시지는 최소 USER 권한 요구
.anyMessage().hasRole(Role.USER.name())
.build();
return new AuthorizationChannelInterceptor(authzManager);
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(
jwtAuthenticationChannelInterceptor, // 1) CONNECT 시 JWT 인증 → accessor.setUser
new SecurityContextChannelInterceptor(), // 2) 이후 흐름에서 SecurityContext 연동
authorizationChannelInterceptor() // 3) 권한 검사 (hasRole(USER))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.sprint.mission.discodeit.controller;

import com.sprint.mission.discodeit.dto.data.MessageDto;
import com.sprint.mission.discodeit.dto.request.MessageCreateRequest;
import com.sprint.mission.discodeit.service.MessageService;
import java.util.ArrayList;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller;

@Slf4j
@Controller
@RequiredArgsConstructor
public class MessageWebSocketController {

private final MessageService messageService;

// 클라이언트는 "/pub/messages" 로 발행
@MessageMapping("messages")
public MessageDto sendMessage(@Payload MessageCreateRequest messageCreateRequest) {
log.info("텍스트 메시지 생성 요청: request={}", messageCreateRequest);
// 첨부파일 없는 케이스만 WS로 받음. 첨부가 있으면 기존 REST POST /api/messages 사용.
MessageDto createdMessage = messageService.create(messageCreateRequest, new ArrayList<>());
log.debug("텍스트 메시지 생성 응답: {}", createdMessage);
return createdMessage;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.sprint.mission.discodeit.controller;

import com.sprint.mission.discodeit.sse.SseService;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sse")
public class SseController {

private final SseService sseService;

// 표준: Last-Event-ID 헤더 사용 (없으면 null 허용)
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect(
@RequestParam(required = false) UUID receiverId,
@RequestHeader(name = "Last-Event-ID", required = false) String lastEventIdHeader
) {
if (receiverId == null) {
// TODO: 보안 컨텍스트에서 현재 사용자 ID를 추출해 사용하고 싶으면 여기에 적용
// ex) receiverId = currentUserIdProvider.get();
throw new IllegalArgumentException("receiverId is required (또는 인증 사용자 ID 사용으로 교체)");
}
UUID lastEventId = null;
if (lastEventIdHeader != null && !lastEventIdHeader.isBlank()) {
try { lastEventId = UUID.fromString(lastEventIdHeader); } catch (IllegalArgumentException ignore) {}
}
return sseService.connect(receiverId, lastEventId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.sprint.mission.discodeit.event.listener;

import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
import com.sprint.mission.discodeit.sse.SseService;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class BinaryContentSseEventListener {

private final SseService sseService;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUpdated(/* BinaryContentUpdatedEvent */ Object event) {
UUID ownerId = /* ((BinaryContentUpdatedEvent) event).getOwnerId() */ null;
BinaryContentDto dto = /* ((BinaryContentUpdatedEvent) event).getData() */ null;

if (ownerId != null && dto != null) {
sseService.send(Set.of(ownerId), "binaryContents.updated", dto);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.sprint.mission.discodeit.event.listener;

import com.sprint.mission.discodeit.dto.data.ChannelDto;
import com.sprint.mission.discodeit.sse.SseService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Component
@RequiredArgsConstructor
public class ChannelSseEventListener {

private final SseService sseService;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCreated(/* ChannelCreatedEvent */ Object event) {
ChannelDto dto = /* ((ChannelCreatedEvent) event).getData() */ null;
if (dto != null) sseService.broadcast("channels.created", dto);
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUpdated(/* ChannelUpdatedEvent */ Object event) {
ChannelDto dto = /* ((ChannelUpdatedEvent) event).getData() */ null;
if (dto != null) sseService.broadcast("channels.updated", dto);
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onDeleted(/* ChannelDeletedEvent */ Object event) {
ChannelDto dto = /* ((ChannelDeletedEvent) event).getData() */ null;
if (dto != null) sseService.broadcast("channels.deleted", dto);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.sprint.mission.discodeit.event.listener;

import com.sprint.mission.discodeit.dto.data.NotificationDto;
import com.sprint.mission.discodeit.sse.SseService;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class NotificationSseEventListener {

private final SseService sseService;


@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCreated(/* NotificationCreatedEvent */ Object event) {

UUID receiverId = /* ((NotificationCreatedEvent) event).getReceiverId() */ null;
NotificationDto dto = /* ((NotificationCreatedEvent) event).getData() */ null;

if (receiverId != null && dto != null) {
sseService.send(Set.of(receiverId), "notifications.created", dto);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.sprint.mission.discodeit.event.listener;

import com.sprint.mission.discodeit.dto.data.UserDto;
import com.sprint.mission.discodeit.sse.SseService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Component
@RequiredArgsConstructor
public class UserSseEventListener {

private final SseService sseService;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCreated(/* UserCreatedEvent */ Object event) {
UserDto dto = /* ((UserCreatedEvent) event).getData() */ null;
if (dto != null) sseService.broadcast("users.created", dto);
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUpdated(/* UserUpdatedEvent */ Object event) {
UserDto dto = /* ((UserUpdatedEvent) event).getData() */ null;
if (dto != null) sseService.broadcast("users.updated", dto);
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onDeleted(/* UserDeletedEvent */ Object event) {
UserDto dto = /* ((UserDeletedEvent) event).getData() */ null;
if (dto != null) sseService.broadcast("users.deleted", dto);
}
}
Loading