transtalk 백엔드 개발 회고록: 성장을 위한 도전과 통찰
transtalk 백엔드 팀에서 인증·보안(JWT, OAuth2, Redis), WebSocket 실시간 통신, MongoDB 기반 메시지 저장 구조를 설계·구현하고, AWS EC2 · RDS · Nginx · MongoDB Atlas로 전체 서버 인프라를 구축했습니다. 실시간 번역 채팅 서비스가 안정적으로 동작할 수 있도록 서버의 핵심 기반 구조 전반을 책임지고 개발·운영했습니다.
이번 프로젝트는 제가 개발자로서 어떤 관점을 가지고 문제를 정의하고 해결해 나가는지, 그리고 팀과 함께 성장하는 데 어떤 태도를 가지고 있는지를 스스로 증명할 수 있었던 중요한 경험이었습니다. 단순히 요구된 기능을 구현하는 것을 넘어서, 실제 서비스 수준의 인증·보안, 실시간 통신, 데이터 저장 구조, 그리고 운영·배포 환경까지 다루며, 웹 서비스가 어떤 방식으로 안정적으로 사용자에게 제공되는지 전체 흐름을 입체적으로 이해할 수 있었습니다.
이 프로젝트에 참여하기 전의 저는 "코드로 기능을 만드는 것"이 개발의 대부분이라고 막연히 생각했습니다. 하지만 이번 경험을 통해 개발은 단순한 코드 작성이 아니라 문제 추적, 사용자 경험 고려, 장애 대응, 운영 환경 설계, 그리고 팀 협업을 통한 책임 있는 의사 결정의 연속이라는 것을 몸으로 느꼈습니다. 신입 개발자로서 얼마나 빠르게 배우고, 낯선 문제 앞에서 어떻게 행동하는지가 중요한데, 이 프로젝트는 그 부분을 깊게 체험하며 제 성장 방향을 명확하게 잡아준 기회였습니다.
1. 인증·보안 전반을 책임지며 ‘신뢰할 수 있는 서비스’의 의미를 이해하다
프로젝트에서 제가 맡은 가장 큰 역할은 로그인부터 권한 부여까지 이어지는 전체 인증 구조를 설계하고 구현하는 일이었습니다.
처음에는 시큐리티 설정을 따라가며 기능을 붙이는 정도였지만, 실제 서비스 수준으로 확장하려면 다음과 같은 다양한 요소들이 필요하다는 것을 알게 됐습니다.
Access Token+Refresh Token구조의 안전성Redis를 이용한 서버 단의 토큰 무효화 처리 (로그아웃 및 토큰 재발급 시)OAuth2로그인 시 사용자 정보의 표준화- 보안 예외 발생 시 일관된 응답 처리
- 인증 흐름과 비즈니스 로직 분리
특히 JWT 블랙리스트를 구현하면서 "로그아웃"이라는 단순한 기능 역시 사용자의 안전과 직결되는 중요한 보안 포인트임을 깨달았습니다.
팀원들이 테스트하는 과정에서 토큰 만료 · 재발급 · 로그아웃 · OAuth 로그인 이 흐름들이 서로 충돌하는 경우가 있었는데, 이를 해결하기 위해 구조를 도식화하고 API 스펙을 정리하면서 복잡한 구조를 ‘다른 사람이 이해할 수 있는 언어’로 정리하는 능력이 크게 성장했습니다.
이 경험은 단순한 기술 구현 경험이 아니라, 사용자가 믿고 사용할 수 있는 서비스란 무엇인가를 고민한 시간이었습니다.

2. WebSocket 인증 구조를 완성하며 ‘통신의 본질’을 이해하다
실시간 번역 채팅 서비스라는 특성상 WebSocket은 필수 요소였습니다. 하지만 WebSocket 인증은 HTTP와 완전히 다르고, STOMP 프로토콜과 Spring Security가 충돌하기도 해 해결해야 할 문제가 많았습니다.
저는 WebSocket의 전 단계인 HTTP → WS Upgrade 과정을 처음부터 끝까지 학습하며 JWT가 어떤 시점에 전달되고 검증되어야 하는지 설계했습니다.
초기에는 WebSocket CONNECT 요청에서 토큰이 검증되지 않아 인증 실패가 계속되었고, 연결이 유지되지 않거나 메시지가 누락되기도 했습니다. 하지만 팀원들과 실시간 로그를 함께 확인하며 문제를 추적했고, 다음의 핵심 이슈들을 해결해 나가며 실시간 통신 구조를 완성할 수 있었습니다.
STOMP Frame의 헤더 분석HandshakeInterceptor와ChannelInterceptor의 차이 이해 및 활용SecurityContext와 메시징 기반 접근 제어의 차이점HTTPS와Nginx프록시 환경에서 발생하는WS헤더 누락 문제 해결
이 과정은 특히 협업적이었습니다.
팀원들이 프론트에서 발생하는 콘솔 오류를 빠르게 공유해주었고, 저는 서버와 Nginx 로그에서 패턴을 찾아 원인을 좁혀갔습니다. 문제를 함께 바라보며 관찰이 합쳐질 때 해결 속도가 훨씬 빨라졌습니다.
이 경험은 저에게 “좋은 개발자는 혼자 해결하는 사람이 아니라, 문제를 함께 해결할 수 있도록 맥락을 공유하는 사람”이라는 사실을 가르쳐 주었습니다.

[WebSocket 인증 흐름]
+--------+ +-------+ +---------+ +---------------+
| Client | | Nginx | | Spring | | Spring Security |
| (Browser)| | (Proxy)| | Server | | (Interceptors) |
+--------+ +-------+ +---------+ +---------------+
| | | |
| 1. HTTP Upgrade Request (with JWT in Header) | |
|---------------------->|---------------------->|------------------------->|
| | (WS Upgrade Header passthrough) | | (JWT Validation) |
| | |<---------- 2. Validate JWT ----|
| | | |
|<------ 3. WS Handshake Success (or Failure) ---| |
| | | |
|<---------------- WebSocket Connection Established ------------------------->|
| | | |
| 4. STOMP CONNECT/MESSAGE (over WS) | |
|---------------------------------------------->|------------------------->|
| | | (JwtChannelInterceptor for STOMP msgs) |
3. MongoDB 설정 문제를 해결하며 깊은 문제 해결력을 기르다
MongoDB는 처음 사용해보는 데이터베이스였고, Atlas 클라우드 연결 과정에서 예상치 못한 오류가 발생했습니다. 여러 설정을 수정해도 계속해서 localhost로만 접속하려는 문제였습니다.
처음에는 Spring Boot 애플리케이션이 127.0.0.1:27017 (localhost)로만 연결을 시도하며 "Connection refused" 오류를 뱉어내는 것을 로그에서 확인했습니다.
2025-11-11T16:38:00.331+09:00 INFO 11184 --- [transtalk] [ main] org.mongodb.driver.client : MongoClient with metadata {"driver": ..., "clusterSettings": {hosts=[127.0.0.1:27017] ...}
...
2025-11-11T16:38:00.339+09:00 INFO 11184 --- [transtalk] [127.0.0.1:27017] org.mongodb.driver.cluster : Exception in monitor thread while connecting to server 127.0.0.1:27017
com.mongodb.MongoSocketOpenException: Exception opening socket
at com.mongodb.internal.connection.SocketStream.lambda$open$0(SocketStream.java:85) ~[mongodb-driver-core-5.5.2.jar:na]
at java.base/java.util.Optional.orElseThrow(Optional.java:403) ~[na:na]
at com.mongodb.internal.connection.SocketStream.open(SocketStream.java:85) ~[mongodb-driver-core-5.5.2.jar:na]
...
Caused by: java.net.ConnectException: Connection refused: getsockopt
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
at java.base/sun.nio.ch.Net.pollConnectNow(Net.java:682) ~[na:na]
이후 MongoDB Compass (GUI 도구)를 통해 연결을 시도했을 때도 TLSV1_ALERT_INTERNAL_ERROR와 같은 SSL/TLS 관련 에러가 발생했습니다. 이는 단순히 애플리케이션 설정 문제가 아니라, MongoDB Atlas의 네트워크 접근 설정 문제일 가능성도 있음을 시사했습니다. 따라서 Atlas에서 서버의 IP 주소를 Network Access 리스트에 추가하는 작업도 함께 진행했습니다.
atlas accessList create 172.30.1.49 --type ipAddress --projectId 6911ddd570608c2503b1d902 --comment "IP address for develop" --output json
이 문제를 해결하기 위해 저는 다음과 같은 방식으로 접근했습니다.
- 가장 먼저 로그의 정확한 위치를 찾아 패턴을 수집했습니다.
Atlas클러스터 설정,VPC,Network Access규칙을 재검토했습니다.- 스프링 설정 코드 로직을 분석했습니다.
ConnectionString이 실제로 적용되는 시점을 역추적했습니다.AbstractMongoClientConfiguration의 동작 방식을 다시 확인했습니다.
결국 문제는 AbstractMongoClientConfiguration을 상속받은 커스텀 설정 클래스에서 configureClientSettings() 메서드를 재정의하지 않아 커스텀 ConnectionString이 제대로 적용되지 않고, 기본적으로 localhost로 연결을 시도하고 있었던 것이었습니다. Connection String 자체를 인식하지 못하고 있었던 것이죠.
아래와 같이 configureClientSettings()를 재정의하여 application.yml에 설정된 URI와 Database 정보를 사용하도록 수정했습니다.
@Slf4j
@Configuration
@RequiredArgsConstructor
public class MongoConfig extends AbstractMongoClientConfiguration {
@Value("${spring.data.mongodb.uri}")
private String uri;
@Value("${spring.data.mongodb.database}")
private String database;
@Bean
@NonNull
@Override
public MongoClient mongoClient() {
return super.mongoClient();
}
@Bean
public MongoTemplate mongoTemplate() {
return new MongoTemplate(new SimpleMongoClientDatabaseFactory(uri));
}
@EventListener(ApplicationReadyEvent.class)
public void setupMongoIndexes() {
IndexOperations indexOps = mongoTemplate().indexOps(MongoChat.class);
// TTL: 365일
Duration ttlDuration = Duration.ofDays(365);
// createdAt 기준
IndexDefinition ttlIndex = new Index().on("createdAt", Sort.Direction.ASC)
.expire(ttlDuration);
try {
indexOps.createIndex(ttlIndex);
log.info("[MongoConfig] MongoDB TTL index for ChatMessageDocument.createdAt created successfully.");
} catch (Exception e) {
log.error("[MongoConfig] Error creating MongoDB TTL index: " + e.getMessage());
}
}
@Override
protected void configureClientSettings(MongoClientSettings.Builder builder) {
builder.applyConnectionString(new ConnectionString(uri))
.writeConcern(WriteConcern.MAJORITY)
.applyToSocketSettings(socketSetting -> socketSetting
.connectTimeout(5, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.MINUTES)
)
.applyToConnectionPoolSettings(pool -> pool
.maxSize(100)
.maxSize(5)
.maxConnectionIdleTime(10, TimeUnit.SECONDS)
)
.applyToSslSettings(ssl -> ssl.enabled(true))
.retryWrites(true)
.timeout(1, TimeUnit.MINUTES)
.build();
}
@Override
protected String getDatabaseName() {
return database;
}
}
해결 후에는 왜 이 문제가 발생했는지 원리까지 이해했고, 그 과정에서 단순히 오류를 고친 것이 아니라 문제를 구조적으로 분석하는 사고방식을 배우게 되었습니다.
4. AWS 기반 배포는 가장 큰 도전이었고, 가장 크게 성장한 경험이었다
프로젝트 후반부에는 제가 백엔드 전체 배포를 책임졌습니다. 이 경험은 개발자로서의 가치관을 가장 강하게 바꾼 순간이었습니다. 코드는 로컬에서는 당연히 잘 동작하지만, 운영 서버에서는 아주 작은 설정 하나로도 장애가 발생할 수 있다는 사실을 처음 체감했습니다.
다음과 같은 주요 배포 스텝들을 직접 수행하며 서비스 운영에 대한 이해를 높였습니다.
EC2인스턴스 설정 및 보안 그룹 구성RDS데이터베이스 보안 그룹 구성 및 연결Redis분리 서버 구축 및Spring Boot연동Nginx Reverse Proxy설정 (로드밸런싱 및SSL/TLS종료)HTTPS(Let’s Encrypt)적용 및 자동 갱신WebSocket업그레이드 및Nginx프록시 설정nohup기반 무중단 실행 및Supervisor를 이용한 프로세스 관리- 실시간 로그 분석 및 모니터링 환경 구축
각 단계를 밟아가는 동안, 개발자가 단순히 "기능을 만드는 사람"이 아니라 “서비스를 책임지는 사람”이라는 사실을 이해하게 되었습니다.
HTTPS 설정 트러블슈팅: 302 리디렉션 무한루프
특히 HTTPS를 적용해야 쿠키 도난에 대한 보안 문제가 발생하지 않을 것으로 판단했고, 이를 위해 Nginx를 설치했습니다. HTTPS 적용 후 Nginx와 Spring Boot 사이에 302 리디렉션 무한루프가 발생하는 문제에 직면했습니다.

이 문제는 Spring Security, WebConfig, Nginx 등 여러 곳에서 리디렉션 관련 설정을 중첩하여 발생한 것이었습니다. 근본적인 원인은 Nginx에서 HTTPS 요청을 받아 Spring Boot 애플리케이션으로 HTTP 요청을 전달할 때, 원래 요청이 HTTPS였다는 정보를 Spring Boot에 제대로 알려주지 않아 발생했습니다. Spring Boot는 HTTP 요청으로 인식하고 다시 HTTPS로 리디렉션하고, Nginx는 다시 HTTP로 전달하는 무한 반복에 빠진 것이죠.
해결의 핵심은 X-Forwarded-Proto 헤더에 관한 설정이었습니다. Nginx 설정에 X-Forwarded-Proto $scheme;를 추가하여 Spring Boot 애플리케이션이 클라이언트로부터 받은 요청이 원래 HTTPS였음을 정확히 인지하도록 했습니다.
[Nginx 설정 파일 /etc/nginx/sites-available/transtalk.conf]
// nginx 설정파일 /etc/nginx/sites-available/transtalk.conf
server {
listen 80;
server_name transtalk.duckdns.org;
return 301 https://transtalk.duckdns.org$request_uri;
}
server {
listen 443 ssl;
server_name transtalk.duckdns.org;
ssl_certificate /etc/letsencrypt/live/transtalk.duckdns.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/transtalk.duckdns.org/privkey.pem;
location /ws-connect {
proxy_pass http://localhost:8080/ws-connect;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api {
proxy_pass http://localhost:8080/api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
이와 함께 Spring Security 설정도 확인하여, X-Forwarded 헤더가 올바르게 처리될 수 있도록 하였습니다. 아래는 프로젝트에 적용된 Spring Security 설정입니다. 이 설정에서는 sessionManagement를 STATELESS로 두어 Spring Security 자체의 리디렉션을 최소화했습니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class)
.formLogin(AbstractHttpConfigurer::disable)
.anonymous(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.rememberMe(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(request -> request.getRequestURI().startsWith("/api/v1/auth")).permitAll()
.requestMatchers("/ws-connect/**", "/ws-connect").permitAll() // websocket 연결 경로
.requestMatchers("/app/**", "/topic/**", "/queue/**").permitAll() // STOMP 엔드포인트
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated())
.exceptionHandling(ex -> ex
.accessDeniedHandler(appAccessDeniedHandler)
.authenticationEntryPoint(appAuthenticationEntryPoint)
)
.cors(Customizer.withDefaults())
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:5173",
"https://transtalk.vercel.app"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); // 쿠키 사용을 위해 허용
config.setMaxAge(3600L); // 3600초 (1시간) 동안 프리플라이트 요청 캐싱
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
curl 명령어를 통해 응답을 확인한 결과, 무한 리디렉션 없이 정상적으로 HTTPS 연결이 이루어졌음을 확인할 수 있었습니다.
ubuntu@ip-172-31-12-111:~$ curl -v https://transtalk.duckdns.org/api/v1/auth
* Host transtalk.duckdns.org:443 was resolved.
* IPv6: (none)
* IPv4: 52.78.176.155
* Trying 52.78.176.155:443...
* Connected to transtalk.duckdns.org (52.78.176.155) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / id-ecPublicKey
* ALPN: server accepted http/1.1
* Server certificate:
* subject: CN=transtalk.duckdns.org
* start date: Nov 16 15:19:23 2025 GMT
* expire date: Feb 14 15:19:22 2026 GMT
* subjectAltName: host "transtalk.duckdns.org" matched cert's "transtalk.duckdns.org"
* issuer: C=US; O=Let's Encrypt; CN=E7
* SSL certificate verify ok.
* Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA384
* Certificate level 1: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using sha256Wi
이러한 문제 해결 과정은 저에게 큰 자신감을 주었습니다. “어떤 문제라도, 정확한 관찰과 분석이 있다면 반드시 해결할 수 있다.” 이 믿음을 얻게 되었습니다.
5. 영어 기반 협업은 또 다른 도전이었고, 소통의 본질을 배울 수 있었다
이번 프로젝트는 매일 영어 회의와 영어 문서 기반으로 진행되었습니다. 처음에는 기술적인 내용을 영어로 설명하기가 어려웠습니다. 하지만 문제를 설명할 때 “문제–원인–가설–해결 과정” 구조로 정리해 전달하며 자연스럽게 커뮤니케이션 능력이 발전했습니다.
API 문서를 영어로 작성해 프론트와 공유했고, 발생 가능한 장애 상황을 영어로 논의하며 서로의 이해를 맞춰가는 과정에서 ‘좋은 협업’이 무엇인지 배우게 되었습니다.
6. 이번 프로젝트를 통해 증명한 나의 역량
이번 경험은 제가 신입 개발자로서 어떤 사람인지 명확하게 보여줍니다.
- 빠르게 학습하고 스스로 문제를 정의하는 능력: 단순히 구글링해서 붙이는 것이 아니라, 왜 문제가 발생했는지 원리를 이해하려고 노력했습니다.
- 문제를 끝까지 파고드는 끈기:
DNS설정부터Nginx프록시,SSL까지— 처음 보는 영역도 포기하지 않고 끝까지 해결했습니다. - 복잡한 기술 구조를 도식화하고 다른 사람에게 설명하는 능력: 팀원들이 이해할 수 있도록 요약 · 문서화하며 협업 효율을 높였습니다.
- 주도적으로 팀 내 기술 기반을 설계하고 개선한 경험: 보안, 인증, 실시간 통신, 배포 등 핵심 구조를 맡아 팀 전체 개발 속도를 높였습니다.
7. 앞으로의 목표 — ‘문제를 해결하는 개발자’에서 ‘문제를 예방하는 개발자’로
이번 프로젝트는 저에게 "문제를 해결하는 능력"을 길러주었습니다. 하지만 앞으로는 더 나아가 문제가 생기기 전에 미리 감지하고 예방할 수 있는 개발자가 되고 싶습니다.
우아한테크코스에서 제공하는 자기 주도 학습 문화, 페어 프로그래밍, 코드 리뷰, 협업 중심 개발 환경은 제가 그 목표에 도달하는 데 가장 적합한 곳이라고 확신합니다.
저는 이 프로젝트를 통해 개발의 본질은 문제 해결, 소통, 그리고 사용자 가치 실현이라는 것을 배웠습니다. 앞으로도 배우고 성장하는 데 주저하지 않는 개발자가 되고 싶습니다.
'[우아한테크코스] 8기 프리코스' 카테고리의 다른 글
| [우아한테크코스] 8기 프리코스 - 3차 미션 회고록 (0) | 2025.11.25 |
|---|---|
| [우아한테크코스] 8기 프리코스 - 2차 미션 회고록 (0) | 2025.11.25 |
| [우아한테크코스] 8기 프리코스 - 1차 미션 회고록 (0) | 2025.11.25 |