- 서비스 설명 : 사용자들이 누구나 게시판을 만들 수 있고, 게시판을 통해 정보, 일상 공유를 할 수 있도록 하기 위한 Restful API 형태의 커뮤니티 서비스
- 백엔드 기능에 집중하고자, 프론트엔드 기능 구현은 하지 않았습니다.
- API 루트 경로 :
https://api.board-system.site - API 문서 : https://api.board-system.site/docs
- Github 링크 : https://github.com/ttasjwi/board-system
- Wiki: https://github.com/ttasjwi/board-system/wiki
- 주요기능 : 로그인/소셜로그인, 게시글 기능, 게시글 좋아요/싫어요 기능, 게시글 댓글 기능, 게시글 조회수 기능, …
- Kotlin, Spring Boot, MySQL, Redis, JPA, AWS(EC2, RDS, ElastiCache), Docker, Github Actions, K6, ...
-
도입배경
- 서비스 기능은 테스트 코드로 어느 정도 검증했지만, 실제로 많은 동시 사용자 요청이 들어왔을 때 성능이 유지될 수 있는지는 확인할 수 없었습니다.
- 특히, 대규모 데이터가 삽입된 운영 환경과 유사한 조건에서의 성능을 검증하는 경험이 필요하다고 판단했습니다.
-
문제 해결 과정: 직접적인 트래픽 경험은 어렵지만, 이를 대체할 수 있는 환경을 구성해보기로 했습니다.
- 회원 1,200만 건, 게시글 2,400만 건, 댓글 2,400만 건 등 대규모 데이터를 삽입하여 운영 환경에 가까운 데이터 스케일을 구성했습니다.
- 단순히 로컬 컴퓨터 테스트에서 그치지 않고, AWS EC2 인스턴스에 실제로 서비스를 배포함으로서 실무와 유사한 환경을 구성했습니다.
- 부하 테스트 도구로는 오픈소스인
k6를 사용했습니다.k6는 가볍고, 설치 및 설정이 간편하며, 부하 생성 능력도 충분해 선택하게 되었습니다. - 동시 요청 수를 1명부터 50명(많게는 1,000명)까지 점진적으로 증가시키며, 요청 처리 가능 수와 응답 시간의 변화 등을 측정했습니다.
-
- 로컬 테스트에서는 원활하게 동작하던 기능이었지만, 실제 부하 테스트 결과 응답 속도가 최대 30초까지 치솟고, TPS가 불안정해지는 현상을 발견할 수 있었습니다.
- CloudWatch를 통해 RDS의 CPU 사용률은 급증한 반면 EC2는 낮은 수준을 유지하고 있다는 점에서 DB 성능 병목이 원인임을 파악할 수 있었습니다.
- 이를 계기로 DB 인덱싱 최적화, 조회 쿼리 개선, 커버링 인덱스 활용 등 성능 개선을 진행했고, 그 결과는 아래 항목에서 상세히 서술하였습니다.
-
도입배경
- 부하 테스트 결과, 게시글 수가 약 2,400만 건에 이르는 상황에서 게시글 목록 조회 속도가 급격히 느려지는 현상을 확인했습니다.
-
- MySQL의 실행 계획 도구(
EXPLAIN,EXPLAIN ANALYZE)를 활용해 쿼리의 비효율적인 실행 방식을 분석했습니다. - 분석 결과, 기존 쿼리는 모든 테이블을 풀스캔한 뒤 조건에 맞는 게시글을 필터링하고, 정렬 및 Offset을 적용해
LIMIT수만큼 조회하는 방식이었습니다. - 이는 정렬 기준이 인덱스에 포함되어 있지 않아, 매번 정렬 및 필터링 연산이 추가 발생하고 있었고, 이로 인해 성능이 크게 저하되었습니다.
- MySQL의 실행 계획 도구(
-
- MySQL이 필터링과 정렬 없이도 효율적으로 게시글을 조회할 수 있도록 멀티 컬럼 인덱스를 추가했습니다.
- 인덱스 구성:
board_id ASC, article_id DESC
- 인덱스 구성:
- 이 인덱스는 “특정 게시판 내에서 최신 게시글 목록을 페이지 단위로 조회” 하는 패턴과 완전히 일치합니다.
- 인덱스가 적용된 이후, MySQL은 더 이상 테이블 풀스캔 없이 인덱스 레인지 스캔만으로 필요한 게시글을 빠르게 조회할 수 있게 되었습니다.
- MySQL이 필터링과 정렬 없이도 효율적으로 게시글을 조회할 수 있도록 멀티 컬럼 인덱스를 추가했습니다.
-
- 실행 계획 확인 결과, 쿼리가 새로 생성한 인덱스를 정확히 활용하는 것을 확인했습니다.
- 인덱스 상에서 게시글들이 게시판별로 최신순으로 정렬되어 있어 추가적인 정렬 작업 없이 효율적으로 조회가 가능해졌습니다.
- 부하 테스트 성과
- 개선 전: 낮은 동시 사용자 수(VUs)에서도 TPS가 불안정
- 개선 후: TPS가 VUs 23 부근에서 최대 20.8까지 상승, 부하를 안정적으로 처리
- API 응답 속도 비교
- 개선 전: 500번 게시판의 20페이지 조회 시 약 1,550ms
- 개선 후: 동일 조건에서 약 43ms
→ 약 36배 성능 향상
- 이와 비슷한 방식으로 댓글 목록 조회 성능 역시 대폭 성능을 향상시켰습니다.
- 개선 전: 1시간이 넘도록 댓글 목록 조회 응답이 오지 않음. (성능 확인 불가능 수준)
- 개선 후: 104ms 만에 댓글 목록 조회 응답이 오도록 API 성능 개선
-
도입배경
- 인덱스를 적용해 36배의 성능 향상을 이루었지만, 인기 게시판에서 페이지 번호가 1,000 이상으로 커질 경우 여전히 성능 저하가 발생하는 것을 확인했습니다.
-
- 페이지 번호가 커질수록
OFFSET이 커지고, 그로 인해 조회 시간이 급격히 늘어납니다. - 게시글 목록을 조회할 때 전체 게시글 수를 구하기 위한
COUNT(*)쿼리도 성능 저하의 원인이 됩니다. - 또한, 조회 대상 게시글에 포함된 컬럼들이 인덱스에 존재하지 않으면 테이블 접근이 발생하고, 이는 Disk I/O 증가로 이어집니다.
- 따라서 인덱스만으로 정렬·필터링하고, 실제 테이블 접근은 필요한 경우에만 최소화하는 방향이 필요했습니다.
- 페이지 번호가 커질수록
-
개선 방향
-
페이지 번호 제한: 과도한
OFFSET으로 인한 비효율을 방지하기 위해 페이지 번호를 1~100 사이로 제한했습니다. -
게시글 수 비정규화: 게시판 별 게시글 수를 별도 테이블에서 관리함으로써
COUNT(*)없이도 총 페이지 수를 빠르게 계산할 수 있도록 했습니다. -
-
OFFSET접근 시 성능 저하를 줄이기 위해 커버링 인덱스가 적용되는 형태로 쿼리를 재구성했습니다.- 하나의 쿼리 안에서 CTE(Common Table Expression) 를 활용하여, 먼저 인덱스만으로 원하는 게시글
ID목록을 추출한 뒤, 해당 ID들을 기준으로 실제 게시글 정보를 조회하는 방식입니다. - 예시 쿼리:
WITH cte(article_id) AS ( SELECT article_id FROM articles WHERE board_id = 1 ORDER BY article_id DESC LIMIT 50 OFFSET 199950 ) SELECT ... FROM articles a JOIN cte ON a.article_id = cte.article_id ...
CTE내부의SELECT는 인덱스 컬럼(board_id, article_id)만을 조회하여 디스크 접근 없이 인덱스 레벨에서 빠르게 처리됩니다.- 이후 본문 쿼리에서는
JOIN을 통해 필요한 정보만 테이블에서 조회함으로써 Disk I/O를 최소화하고, 정렬/필터링 비용도 줄이는 구조를 구현했습니다.
- 하나의 쿼리 안에서 CTE(Common Table Expression) 를 활용하여, 먼저 인덱스만으로 원하는 게시글
-
-
성과
-
- 게시글에 "좋아요"를 누를 수 있는 기능을 구현했으며, 좋아요 수는 조회 성능을 높이기 위해 비정규화된 형태로 별도로 저장하고 있었습니다.
- 그러나 수천 명의 사용자가 동시에 같은 게시글에 좋아요 요청을 보낼 경우, 기대값보다 적은 수의 좋아요가 반영되는 현상이 발생했습니다.
- 예를 들어, 3000명이 동시에 요청했음에도 좋아요 수는 3000이 아닌 302 정도만 증가하는 문제가 있었습니다.
-
- 해당 현상은 좋아요 수를 조회 → 증가 → 저장하는 기존의 구현 방식이 트랜잭션 간 충돌에 취약한 구조였기 때문에 발생했습니다.
- 다수의 트랜잭션이 동일한 좋아요 수 레코드를 거의 동시에 조회하고, 각자의 상태에서 1을 증가시킨 후 저장하는 방식이었기 때문에 동시성 문제가 발생했고, 이로 인해 최종 결과가 덮어써지는 문제가 있었습니다.
- 특히 좋아요 수 레코드가 존재하지 않는 경우 초기화 과정에서도 중복 INSERT 충돌이 발생할 수 있어, 단순히 락을 걸어도 완전한 해결이 어려운 구조였습니다.
-
문제해결 방안 고민
- 낙관적 락 (Optimistic Lock)
version필드를 통해 충돌을 감지하고 재시도합니다.- 구현은 간단하지만, 동시 요청이 많을 경우 충돌률이 높아 성능 저하 우려가 있습니다.
- 최초 좋아요수 초기화 시 행 자체가 존재하지 않으므로 중복 insert 충돌 문제를 해결할 수 없습니다.
- 비관적 락 (Pessimistic Lock)
SELECT ... FOR UPDATE를 사용해 행에 배타적 락을 걸고 순차적으로 처리합니다- 정확도는 보장되지만, 병목 가능성과 데이터베이스 부하 증가 우려가 있습니다.
- 최초 좋아요수 초기화 시 행 자체가 존재하지 않으므로 중복 insert 충돌 문제를 해결할 수 없습니다.
- Redis 기반 분산 락
- Redis 의 SetIfAbsent 기능을 활용한 분산락을 통해 게시글 좋아요 기능에 동시 접근 제한을 둡니다.
- 최초 좋아요수 초기화 를 하는 상황에도 Redis 를 통해 락을 획득하므로, 중복 insert 충돌 문제를 해결할 수 있긴 합니다.
- 신뢰성이 높지만 인프라 복잡도 및 비용이 증가합니다.
- 초기화 시점 변경 + 낙관적락 / 비관적락 사용
- 게시글 생성 시 좋아요 수를 함께 초기화 생성하여 최초 좋아요 시점에도 ‘좋아요 수’가 이미 존재하게 하는 방법입니다.
- 위의 낙관적 락 / 비관적락 방식을 사용 가능하게 합니다.
- 하지만 게시글 맥락에서 ‘게시글 좋아요’ 맥락을 의존하게 되므로, 맥락간 개념 순환참조 문제가 발생하게 됩니다.
- 낙관적 락 (Optimistic Lock)
-
- 기존 구조의 복잡도와 성능 문제를 해결하면서도 락 없이 정확성을 보장할 수 있는 방법으로, MySQL의
INSERT ... ON DUPLICATE KEY UPDATE쿼리 사용으로 문제를 해결했습니다.- 좋아요 수를 조회하고 객체를 조작하는 대신, ****매 요청 시점마다 DB에 직접 INSERT 시도
- 이미 해당 게시글에 대한 좋아요 수 레코드가 존재할 경우, 중복키 충돌을 유도해
UPDATE로 전환 - 결과적으로 DB 상태를 기준으로 즉시 1 증가시키는 원자 연산이 수행됨
- 이 방식은 다음과 같은 장점이 있습니다.
- 락을 사용하지 않아도 동시성 문제를 해결할 수 있습니다.
- 좋아요 수 객체의 존재 유무와 관계없이 동일한 쿼리로 처리할 수 있습니다.
- 추가적인 인프라 비용 투자 없이도 정확성과 성능을 모두 확보 가능
- 기존 구조의 복잡도와 성능 문제를 해결하면서도 락 없이 정확성을 보장할 수 있는 방법으로, MySQL의
-
- 기존에는 수천 명이 동시에 요청해도 수십 건만 반영되던 문제가, 3000명이 동시에 좋아요 요청 시 정확히 3000건이 반영되도록 개선됐습니다.
- Redis 등 외부 캐시나 락 저장소를 도입하지 않고도, 데이터베이스(MySQL)의 기능만으로 정합성과 성능을 모두 달성했습니다.
- 데이터 조작 로직이 도메인 객체 중심이 아닌 DB 연산 중심이 되는 점에서 다소 아쉬움이 있지만, 정합성, 성능 문제를 모두 만족시키는 실용적인 선택이였다고 생각합니다.
-
- 우리 서비스에서는 사용자 조회수 증가 API 를 별도로 관리하고 있습니다. 사용자가 요청을 하면 게시글의 조회수가 1 증가합니다.
- 그러나 동시에 특정 사용자가 악용 목적으로 게시글 조회수 증가를 동시에 매우 많이 요청할 경우, 게시글 조회수가 순식간에 폭증하는 문제가 발생합니다. (악용, 어뷰징)
-
- 기존 로직에, '게시글 조회수 락' 획득 로직을 추가했습니다
- 사용자는 요청할 때마다 게시글 조회수 락 획득을 시도합니다. 락 획득에 성공하면 게시글 조회수 증가 로직을 수행하고, 락 획득에 실패하면 조회수 증가를 하지 않습니다.
- Redis 에서는 setIfAbsent 라는 기능을 제공합니다.
- key 가 존재하지 않으면 value 를 저장할 수 있습니다. 저장에 성공하면 true 가 반환됩니다.(ttl 지정 가능)
- key 가 존재하면 value를 저장할 수 없습니다. 이 경우 false가 반환됩니다.
- 최초 락 획득 시도시, 10분간 유효하도록 setIfAbsent 를 호출하여, 10분간 value 를 저장할 수 없게 하는 것입니다.
- 최초 요청 시 set에 성공하면 true 가 반환되는데(10분간 저장됨) 이를 락 획득 성공으로 간주합니다.
- 이후 10분간 요청 시 false 를 반환받는데, 이를 락 회득 실패로 간주하게 하면 됩니다.
- 이 기능을 활용하여 분산락을 구현할 수 있습니다.
-
- 동시에 한 사용자가 1만번 조회수 증가 요청을 하게 시뮬레이션 한 결과(100개 스레드), 조회수는 1 정도만 증가하는 것을 확인했습니다.
- 10분 이내로 같은 사용자가 조회수 증가 어뷰징을 하더라도 조회수가 폭증하는 어뷰징 현상을 막을 수 있게 됐습니다.
-
도입 배경
- 코드 변경 시, 기존 기능과의 통합이 제대로 이루어졌는지를 매번 수동으로 확인해야 했습니다.
- 애플리케이션 배포 과정도 수작업으로 진행했기 때문에, 무중단 배포를 실수 없이 수행하기 어려운 구조였습니다.
- 테스트 및 배포 작업에 소요되는 시간도 길어 개발 생산성을 떨어뜨리는 원인이 되었습니다.
-
GitHub Actions와 Docker를 활용하여 CI/CD 파이프라인을 자동화하고, EC2 + Nginx 기반의 무중단 배포 구조를 구축했습니다.
-
CI 단계:
- PR 생성 시 자동으로 빌드 및 테스트 실행
- 테스트 실패 시 병합 차단 → 코드 품질 보장
-
CD단계:
master브랜치에 병합되면 자동으로 배포 파이프라인 실행- JAR → Docker 이미지 생성
- DockerHub로 Push
- EC2 서버에서 이미지 Pull 및 새 컨테이너 실행
- 헬스체크 완료 시, Nginx의 리버스 프록시 라우팅 변경
- 이전 버전 컨테이너 종료 및 불필요한 이미지 정리
-
환경 간 일관성 확보:
- Docker를 통해 배포 환경을 컨테이너화함으로써, 환경 차이에 의한 배포 오류 방지
- EC2 인스턴스 어디서든 동일한 환경 조건으로 애플리케이션 실행 가능
-
-
성과
- 자동화된 테스트 및 배포로 실수 가능성 감소 및 개발 생산성 향상
- 무중단 배포 구현: 기존 트래픽 처리 중인 컨테이너에 영향을 주지 않고 새 버전으로 안전하게 전환
- Docker 기반 컨테이너 격리를 통해, 컨테이너 내부 서비스 장애가 발생해도 호스트 환경에는 영향이 없습니다.
-
- 기존에는 모든 기능이 하나의 모듈에 결합되고 패키지 의존 방향이 명확하지 않은 구조였습니다.
- 기능이 늘어나면서 의존성 방향이 뒤섞이고 통제 불가능해지는 문제가 발생했습니다.
- 예를 들어, 단순히 Redis 기능만 테스트하고 싶어도, 전혀 상관없는 RDBMS 설정 오류로 테스트가 실패하는 일이 자주 발생했습니다.
-
개선방향
- 헥사고날 아키텍처 적용 + 의존성 역전 원칙(DIP) 적극 도입
- 계층 간 의존은 내부 인터페이스를 통해 연결
- 실제 구현체 대신 mock, fixture 등을 활용한 독립적 테스트 가능
- 코드 변경 시 추상화 수준에서의 수정으로 변경 영향 최소화
- 기능 단위 모듈화 및 의존 방향 명시적 통제
- 표현 계층은 도메인 계층을 모르도록
- 애플리케이션 계층은 영속성 계층을 모르도록
- 게시글은 좋아요 기능을 모르도록
- Redis 모듈은 RDBMS 모듈과 완전히 분리
- 헥사고날 아키텍처 적용 + 의존성 역전 원칙(DIP) 적극 도입
-
- 계층별 역할 분리와 모듈화로 인해, 테스트가 가볍고 명확해졌습니다.
- 예: Redis 기능 테스트 시 RDBMS 설정 불필요
- 모든 계층은 자신에게 필요한 인터페이스만 의존하게 되어 유지보수가 쉬워졌습니다.
- 도메인 개념을 ‘맥락’ 단위로 분리하고, 의존성 방향을 명확히 통제해 향후 마이크로서비스 구조로의 전환도 수월한 기반이 마련되었습니다.
- 기능 추가 또는 기술 교체 시, 전체 구조를 건드릴 필요 없이 부분 수정 가능한 유연한 구조가 되었습니다.
- 계층별 역할 분리와 모듈화로 인해, 테스트가 가볍고 명확해졌습니다.
-
- 테스트 코드 작성 시, 테스트에 필요한 도메인 모델 객체 생성에는 많은 양의 코드가 필요할 때가 많습니다. 이런 코드들은 보통 fixture 형태로 구성되어 재사용하기 위해 사용합니다.
- 그러나 같은 여러 모듈에서 동일한 테스트 픽스쳐를 사용해야할 때가 많은데 이럴 경우 test 소스셋 내부에서 동일한 fixture 코드를 작성해야하는 문제가 발생합니다.
- 여러 모듈에서 공유할 수 있도록 별도의 테스트 픽스쳐 모듈에서 구현하는 방법도 있지만, 원본 모듈 내부에서는 캡슐화 시킬 수 있는 도메인 지식이 외부로 유출되는 문제가 발생할 수 있고, 순환참조 문제가 발생할 수 있습니다.
-
개선 방향
- gradle의 java-fixtures 플러그인을 활용하여 자주 사용되는 테스트 픽스쳐를 textFixtures 소스셋으로 구성합니다.
- 원본 도메인 모델과 같은 모듈 내에서, testFixtures 소스셋에 테스트 픽스쳐를 구현할 수 있습니다. 원본 모듈 내부에서 테스트 픽스쳐 생성에 관한 핵심 도메인 지식을 캡슐화시킬 수 있습니다.
-
- 테스트 픽스쳐 생성에 관한 로직을 같은 모듈 내에서 할 수 있게 됐습니다. 외부 모듈에서는 복잡한 픽스쳐 생성 관련 지식에 접근할 수 없습니다.
- 여러 모듈들에서 다른 외부 모듈의 테스트픽스쳐를 의존할 수 있게 되어, 테스트픽스쳐 재사용성이 향상 됐습니다.
- 도입배경
- REST API 기반의 서비스에서는 클라이언트의 요청마다 사용자를 인증할 수단이 필요합니다.
- 또한 API마다 접근 권한이 다르기 때문에, 인증된 사용자만 접근 가능하거나, 관리자만 사용할 수 있는 엔드포인트에 대한 세분화된 인가 로직이 필요합니다.
- 세션 기반 인증 방식을 쓰는 것을 고려해봤으나, 분산 환경에서 확장성과 유지보수에 제약이 있었습니다.
- 각 API 별로 인증/인가 기능을 수동으로 구현하기엔 각 API마다 반복적인 보안 로직이 필요했습니다.
- 기술 선택 및 구현
-
JWT 기반 인증 (Access Token + Refresh Token)
- Stateless한 구조를 위해 세션이 아닌 JWT 기반 인증을 도입했습니다.
- Access Token에는 사용자 식별을 위한 최소 정보만 담고, 서명 키를 이용해 위변조 여부를 검증합니다.
- 보안 강화를 위해 유효기간이 짧은 Access Token과, 갱신 용도의 Refresh Token을 분리하여 사용했습니다.
- Refresh Token은 Redis에 저장하며, 사용자당 최대 5개까지만 유지하여 동시 로그인 수 제한 및 토큰 탈취 대응이 가능하게 했습니다.
-
Spring Security를 활용한 인증 흐름 구성
- Spring Security의 서블릿 필터 체인 구조를 활용하여, 인증 로직을 MVC 앞단에서 공통 처리하도록 구성했습니다.
- 사용자는 매 요청 시 토큰을 헤더에 담아 전송하며, 필터에서는 토큰 파싱 및 사용자 인증 객체(SecurityContext) 주입을 담당합니다.
- 이 구조를 통해 API 메서드마다 인증 코드를 반복하지 않고 전역 인증 처리가 가능해졌습니다.
-
Spring AOP 기반 인가(Authorization) 처리
- API별 접근 제어는 커스텀 어노테이션(@RequireAuthnticated, @RequireAdminRole 등) 을 컨트롤러에 선언하여 명시합니다.
- 공통 권한 검증 로직은 Aspect로 분리하여, 어노테이션 기반으로 자동 적용되도록 구성했습니다.
- AOP 프록시를 통해 실제 컨트롤러가 실행되기 전 권한 검사를 수행하며, 인가 실패 시 예외를 발생시켜 ****컨트롤러 진입을 방지합니다.
-
- 성과
- 인증·인가 로직을 재사용 가능하고 확장 가능한 구조로 모듈화하여, API마다 중복 코드를 작성하지 않아도 됩니다.
- 향후 서비스가 마이크로서비스 아키텍처로 확장되더라도, JWT 기반 인증 방식을 통해 각 서비스 간 세션 공유 없이 인증 연동이 가능합니다.
- Refresh Token을 별도로 저장해 관리해야 하는 부담은 있지만, 회원/인증 서버 단일 지점(서비스 분리 시)에서만 관리되므로 구조적으로 단순하며, 토큰 탈취와 같은 보안 이슈에도 더 견고한 대응이 가능해졌습니다.
- 신규 API 추가 시 어노테이션 한 줄만으로 권한 제어가 적용되어, 보안 정책 적용이 일관되고 유지보수도 간편해졌습니다.
-
도입배경
- 일반적인 이메일/패스워드 방식 외에도, 사용자 접근성이 높은 소셜 로그인 방식이 널리 사용되고 있습니다.
- 특히 Google, Kakao, Naver 등 대중적인 서비스는 OAuth2 기반 인증/인가 흐름을 제공합니다.
- 사용자는 각 서비스에 별도의 계정을 생성할 필요 없이, 기존 소셜 계정을 활용하여 빠르게 로그인할 수 있습니다.
-
문제상황
-
확장성 문제
- OAuth2 기반이라도 각 소셜 서비스마다 API 명세나 응답 형식이 상이합니다.
- 초기에는 하나의 소셜 서비스를 기준으로 구현하더라도, 다른 소셜 서비스가 추가될 때마다 코드가 중복될 우려가 있습니다.
- 각 소셜 API를 개별적으로 구현할 경우 유지보수 부담이 커지고, 공통 흐름을 재사용하지 못하는 문제가 있습니다.
-
보안 문제
- 웹에서 직접 소셜 서비스로 리다이렉트하는 방식은 보안 취약점이 존재합니다.
- 예를들면 외부 악성 사이트가 OAuth2 인가 코드를 이용해 우리 서비스에 로그인을 시도할 수 있습니다.
- 이를 방지하기 위해 인가 요청을 반드시 우리 서버를 통해 중계하도록 설계해야 했습니다.
-
-
기술 선택 : Spring Security OAuth2 Client vs 직접 구현
- 초기에는 Spring Security OAuth2 Client를 활용하여 소셜 로그인 기능을 구현했습니다. 이 방식은 많은 설정과 로직이 자동화되어 빠르게 기능을 완성할 수 있다는 장점이 있습니다.
- 하지만 실제로 적용해본 결과, 전반적인 유지보수성과 테스트 코드 작성 관점에서 한계를 느꼈습니다.
- 주요 OAuth2 인증/인가 흐름이 Spring Security 필터 체인 및 내부 컴포넌트에 숨겨져 있어, 로직을 명확히 추적하고 제어하기 어려웠습니다.
- 일반적인 비즈니스 로직은
컨트롤러 → 애플리케이션 서비스의 명확한 흐름을 따르지만, Spring Security OAuth2 Client 방식은 로직이 필터 체인 깊숙이 위치하고 추상화의 깊이가 깊어져 디버깅과 테스트가 복잡해졌습니다.
- 이러한 이유로, Spring Security의 OAuth2 흐름을 그대로 모방하되, 커스텀 방식으로 직접 구현하기로 결정했습니다.
- 내부 로직을 기존의 이메일 로그인 등 다른 인증 API들과 동일하게,
컨트롤러 → 애플리케이션 서비스의 흐름 안에 소셜 로그인 로직을 통합할 수 있습니다 - 이를 통해 도메인 로직의 책임 분리 원칙을 유지하면서도, 기능 흐름을 일관되게 구성할 수 있습니다.
- 내부 로직을 기존의 이메일 로그인 등 다른 인증 API들과 동일하게,
-
문제 해결 과정
-
인가 요청 분리 및 리다이렉트 흐름 구성
- 사용자가 소셜 로그인 버튼을 누르면, 우선 우리 API 서버로 요청을 보내는 것을 전제로 서비스를 구성했습니다.(다만 웹사이트는 구현하지 않았습니다.)
- 서버는 인가 요청에 필요한 정보들을 생성 및 저장한 뒤, 사용자를 소셜 서비스로 리다이렉트합니다.
- 이 방식은 로그인 프로세스를 서버가 전적으로 통제할 수 있게 하며, 보안 파라미터도 안전하게 관리할 수 있습니다.
-
보안 파라미터 적용
-
구조 설계 및 추상화 : Spring Security OAuth2 Client 흐름을 참조하여 공통 흐름을 추상화했습니다.
- 다음의 흐름을 기준으로 서비스별 차이를 설정 기반으로 공통로직과, 개별 구현사항을 분리했습니다.
- 소셜서비스별 설정: 설정 파일 기반으로
ClientRegistration자동 생성 - 사전요청 생성:
OAuth2AuthorizationRequest객체 생성 및 저장 - 사용자 리다이렉트:
OAuth2AuthorizationRequest를 통해 인가 URL 생성 후 클라이언트를 소셜 서비스로 이동 - 콜백 처리: state 값으로 요청 정보 조회 후 로그인 처리
- 소셜서비스별 설정: 설정 파일 기반으로
- 전체 흐름은 단일한 컨트롤러 및 애플리케이션 서비스 계층으로 추상화되어 있으며, 이후 소셜 서비스가 추가되더라도 설정 및 응답 매핑 정도만 확장하면 됩니다.
- 다음의 흐름을 기준으로 서비스별 차이를 설정 기반으로 공통로직과, 개별 구현사항을 분리했습니다.
-
-
성과
- 이메일/비밀번호 로그인 외에도 Google, Naver, Kakao 로그인이 가능해졌습니다.
- 인가 요청의 흐름을 전면 통제함으로써 보안 요소(nonce, PKCE, state 등)를 효과적으로 적용할 수 있었습니다.
- 핵심 흐름은 공통화 및 추상화되어 있어, 향후 Facebook, GitHub 등의 소셜 로그인 추가도 최소한의 코드 변경만으로 가능합니다.
-
도입배경
- API 명세는 클라이언트-서버 간 협업의 핵심입니다.
- 하지만 초기 개발 단계에서는 API 사양이 자주 변경되고, 그에 따른 문서 업데이트가 지연되며 API 문서와 실제 동작 간 불일치가 발생하기 쉽습니다.
-
- 예를 들어보면, 기존 회원가입 API 엔드포인트는
/api/v1/members인데 이후/api/v1/users로 변경되었다고 해보겠습니다. - 하지만 GitHub Wiki에 남겨둔 명세는 전히
/api/v1/members로 되어 있어 혼란을 초래할 수 있습니다. - API 변경 시 수동으로 명세를 갱신해야 하는 점이 개발자-클라이언트 간 커뮤니케이션 비용을 증가시킬 가능성이 있습니다.
- 예를 들어보면, 기존 회원가입 API 엔드포인트는
-
대안 검토
- GitHub Wiki, Notion 등 수기 작성 (기존 방식)
- 자유로운 서술이 가능하지만, 코드 변경 시 자동 반영이 불가능합니다.
- Swagger
- 자동화는 되지만, 실제 동작과 어긋날 수 있고, 애플리케이션 코드에 문서화를 위한 코드가 침투하는 문제가 발생합니다.
- Spring Rest Docs (최종 선택)
- 테스트 코드 기반으로 명세를 생성해, API 문서와 실제 동작의 완전한 일치를 보장합니다.
- 문서화를 위한 별도 애너테이션이 불필요합니다.
- Swagger 대비 UI는 다소 단순하지만, 정확성 면에서 탁월합니다.
- GitHub Wiki, Notion 등 수기 작성 (기존 방식)
-
- API 문서가 테스트 코드와 연동되어 자동 생성됩니다.
- API 사양 변경 시, 테스트 실패를 통해 즉시 감지가 가능해졌습니다.
- 향후 API 유지보수, 수정 과정에서 최신화 누락 문제를 예방할 수 있게 됐습니다.
- 클라이언트와의 API 계약 신뢰성이 향상됐습니다.
-
도입배경
- API 처리 중 예외가 발생했을 때, 사용자에게 맥락에 맞는 에러 메시지를 명확하고 일관되게 전달하는 것은 매우 중요합니다.
- 하지만 모든 컨트롤러에서 메시지를 직접 구성하면 중복 로직이 많아지고 유지보수가 어렵습니다.
- 또한 예외 클래스 자체에서 메시지를 포함할 경우, 다국어(다국적 환경) 대응이 불가능하거나 제한됩니다.
- API 로직을 실행하는 과정에서 예외가 발생하면 그에 맞게 사용자들에게 적절한 예외 메시지를 전달할 수 있어야합니다. 그러나 모든 컨트롤러에서 이 기능을 구현하는 것은 불편해집니다.
- 또, 예외 클래스가 예외 메시지를 가지게 하고 이를 사용자에게 내보내기만 하면 다국어 환경에 맞게 각각 다른 메시지를 제공하기 어렵습니다.
-
개선방향
-
예외 클래스 추상화: 모든 도메인 예외는
CustomException을 상속받아 구현되며, 다음과 같은 구조를 가집니다.abstract class CustomException( val status: ErrorStatus, // 예외가 발생한 이유를 설명하는 상태 정보. val code: String, // 예외 메시지 구성을 위한 코드 ("Error.xxx" 형식). val args: List<Any?>, // 메시지 템플릿에서 사용할 인자들 (빈 리스트일 경우 인자 없고, 순서대로 사용됨). val source: String, // 예외를 발생시킨 필드 또는 맥락을 설명하는 값 (예: "nickname") val debugMessage: String, // 디버깅을 위한 메시지 (사용자에게 제공되지 않음). cause: Throwable? = null // 근본 원인이 되는 예외 (선택적). ) : RuntimeException(debugMessage, cause)
- 각 예외는 메시지를 직접 들고 있지 않고, 예외 코드와 파라미터만 보유합니다.
- 이를 통해 메시지 출력은 로케일 및 메시지 파일을 통해 동적으로 처리됩니다.
-
다국어 메시지 구성 (YAML)
NullArgument: message: "필수값 누락" description: "''{0}''은(는) 필수입니다." InvalidEmailFormat: message: "유효하지 않은 이메일 포맷" description: "이메일 포맷이 올바르지 않습니다. (email = {0})" # 생략
NullArgument: message: "Missing required value" description: "The field ''{0}'' is required." InvalidEmailFormat: message: "Invalid email format" description: "The email format is not valid. (email = {0})" # 생략
- 각 메시지는 예외 코드 (
code)를 키로 하여 국가별로 다르게 번역됩니다. {0},{1}등의 위치 인자를 통해 파라미터도 유연하게 삽입됩니다.
- 각 메시지는 예외 코드 (
-
- 예외는
@RestControllerAdvice를 통해 전역적으로 처리됩니다.- 이는 내부적으로 Spring MVC가 등록한
HandlerExceptionResolver구현체가@RestControllerAdvice클래스를 감지하고 예외 발생 시 해당 핸들러 메서드로 위임하여 응답을 생성하는 방식으로 동작합니다. Accept-Language헤더를 기반으로 사용자의 로케일을 감지하고,- 예외 인스턴스의
code,args를 기반으로 적절한 메시지를 반환합니다.
- 이는 내부적으로 Spring MVC가 등록한
- 스프링 MVC 앞단(예: 서블릿 필터)에서 발생한 예외도 커버할 수 있도록,
- 전역 예외 처리용 서블릿 필터를 추가하였습니다.
- 이 필터는 내부적으로
HandlerExceptionResolver를 호출하여 모든 예외를 공통 처리 흐름에 통합합니다.
- 예외는
-
-
- 예외 발생 시, 국가별로 자연스러운 언어의 메시지를 제공할 수 있게 되었습니다.
- 예외 처리 로직을 완전히 공통화함으로써, 모든 컨트롤러 및 필터 계층에서도 일관된 방식으로 처리할 수 있게 되었습니다.
- 예외 메시지 구조가 정형화되어 있어, 로깅 및 테스트, 분석에도 용이합니다.
- 애플리케이션은 Amazon AWS 인프라 위에 배포 및 운영되고 있습니다. 안정적인 서비스 제공과 보안 강화를 위해 다양한 AWS 서비스를 활용했습니다.
- 주요 인프라 구성
- VPC (Virtual Private Cloud)
- AWS에서 제공하는 가상 사설망(Virtual Network).
- 서비스 전반을 하나의 VPC 단위로 격리하여 관리합니다.
- 서브넷 분리 (Subnet)
- 보안 수준에 따라 퍼블릭/프라이빗 서브넷으로 분리해 구성했습니다.
- 퍼블릭 서브넷: 외부 사용자 접근이 필요한 리소스 (예: EC2).
- 프라이빗 서브넷: 외부 접근이 차단된 내부 리소스 (예: RDS, ElastiCache).
- 보안 그룹 (Security Group)
- ELB, EC2, RDS, ElastiCache 등 각 리소스마다 접근 가능한 IP, 포트 범위를 제어합니다.
- 최소한의 접근만 허용하여 네트워크 레벨 보안을 강화했습니다.
- ELB (Elastic Load Balancer)
- HTTPS 기반의 애플리케이션 로드 밸런서를 사용하여 외부 요청을 처리합니다.
- SLA(서비스 수준 계약) 기준 가용성:
- 다중 AZ: 99.99%
- 단일 AZ: 99.9%
- 요청은 ELB를 통해 EC2 인스턴스로 분산됩니다.
- 컨테이너 수평 확장 시에도 유연한 부하 분산이 가능합니다.
- Route 53 (DNS 서비스)
- AWS의 고가용성 도메인 네임 시스템(DNS) 서비스입니다.
- "api.board-system.site"와 같은 도메인 이름을 ELB에 매핑하여, 클라이언트 요청이 올바른 엔드포인트로 도달할 수 있도록 합니다.
- EC2
- 실제 애플리케이션이 배포되는 가상 머신.
- 현재는 단일 EC2 인스턴스에서 운영되고 있으며, Nginx 및 Spring Boot 컨테이너가 Docker 환경에서 실행 중입니다.
- RDS(Relational Database Service)
- MySQL 기반의 관계형 데이터베이스.
- 프라이빗 서브넷에 위치시켜 외부 접근을 차단하고, EC2 컨테이너만 접근할 수 있도록 설정했습니다.
- ElastiCache
- 인메모리 캐시 서버로 Redis를 사용.
- 프라이빗 서브넷에 위치하며, 외부 노출 없이 EC2 애플리케이션에서만 접근할 수 있습니다.
- VPC (Virtual Private Cloud)
- 요청 흐름
- 사용자가
https://api.board-system.site로 요청을 보냅니다. 이 도메인은 Amazon Route 53에 등록되어 있으며, DNS 질의 시 AWS ELB(애플리케이션 로드 밸런서) 의 엔드포인트를 응답합니다. - 사용자의 요청은 HTTPS 프로토콜을 통해 ELB의 443 포트로 전달됩니다. ELB는 등록된 EC2 인스턴스로 트래픽을 부하 분산하며, 현재는 단일 EC2 인스턴스가 동작 중입니다.
- ELB는 EC2 인스턴스의 80번 포트로 요청을 전달하고, 이 포트는 내부적으로 Docker 컨테이너의 80번 포트와 포워딩되어 있습니다. 해당 컨테이너에는 Nginx 리버스 프록시 서버가 실행되고 있습니다.
- Nginx는 현재 설정에 따라 Blue 또는 Green 컨테이너로 트래픽을 전달합니다. 두 컨테이너 모두 Spring Boot 기반 애플리케이션이 실행 중이며, 웹 애플리케이션 서버 역할을 수행합니다.
- Blue/Green 배포 전략이 적용되어 있어, 새로운 버전이 배포될 때는 현재 동작 중이지 않은 컨테이너(Green 또는 Blue)에 새 버전을 배포하고, 정상적으로 동작함을 확인한 후 Nginx가 새 컨테이너로 트래픽을 전환합니다. 이를 통해 무중단 배포(Zero Downtime Deployment) 가 가능합니다.
- 웹 애플리케이션은 내부적으로 AWS의 RDS(MySQL) 와 ElastiCache(Redis) 를 사용해 데이터를 저장하거나 조회합니다. 두 서비스 모두 프라이빗 서브넷 내에 위치하여 외부 접근이 차단된 안전한 구조입니다.
- 사용자가
- 우리 시스템의 데이터베이스 ERD 입니다.
- 대부분은 정규화 된 형태로 설계했으나, 일부분 테이블은 성능테스트 시 발견한 조회 성능 문제로 인해 비정규화를 했고, 정합성은 애플리케이션 수준에서 맞추도록 했습니다.
- 구성 테이블
users: 사용자social_connections: 소셜연동boards: 게시판article_categories: 게시글 카테고리articles: 게시글board_article_counts: 게시판별 게시글 수 (비정규화)article_likes: 게시글 좋아요article_like_counts: 게시글 좋아요 수 (비정규화)article_dislikes: 게시글 싫어요article_dislike_counts: 게시글 싫어요 수 (비정규화)article_comments: 게시글 댓글article_comment_counts: 게시글 댓글 수 (비정규화)
- 애플리케이션의 안정성, 신뢰성 보장을 위해 각 모듈단위 테스트를 작성했습니다.
- 기본계층인 api 계층, 애플리케이션 계층, 도메인 계층에 대한 테스트 커버리지를 최대한 유지하도록 하고 있습니다. (인텔리제이 기준 테스트 커버리지 100% 지향)
- 외부 기술을 사용하는 부분은 주요 지점에서 검증 코드를 작성하고 문제가 발생했을 때 추가적인 대응 및 테스트 보강하도록 하고 있습니다.
- 활용
- CI/CD 스크립트의 빌드테스트 과정에서 함께 계속 실행되어, 테스트가 깨지는 코드가 배포되지 않도록 하고 있습니다.
- Spring Rest Docs 문서화 과정에서 테스트코드 통과를 선행 조건으로 둠으로서, API 문서 최신화에도 적극적으로 활용하고 있습니다.
- 2025.06.25 기준 1102개 테스트가 작성되어 있습니다.
- 공통 모듈: 113개
core: 90개data-serializer: 1개id-generator: 2개token: 20개
- 사용자 모듈: 261개
user-application-output-port: 38개user-application-service: 86개user-domain: 116개user-email-format-validate-adapter: 5개user-email-oauth2-client-adapter: 3개user-password-adapter: 3개user-web-adapter: 10개
- 게시판 모듈: 128개
board-application-output-port: 16개board-application-service: 27개board-domain: 81개board-web-adapter: 4개
- 게시글 모듈: 105개
article-application-output-port: 14개article-application-service: 44개article-domain: 40개article-web-adapter: 7개
- 게시글 댓글 모듈: 101개
article-comment-application-output-port: 17개article-comment-application-service: 41개article-comment-domain: 37개article-comment-web-adapter: 6개
- 게시글 좋아요/싫어요 모듈: 103개
article-like-application-output-port: 28개article-like-application-service: 32개article-like-domain: 33개article-like-web-adapter: 10개
- 게시글 조회 모듈: 47개
article-read-application-output-port: 15개article-read-application-service: 22개article-read-domain: 7개article-read-web-adapter: 3개
- 게시글 조회수 모듈: 23개
article-view-application-output-port: 7개article-view-application-service: 8개article-view-domain: 5개article-view-web-adapter: 3개
- 이메일 발송 모듈: 2개
email-sender: 2개
- 외부 기술 모듈: 219개
database-adapter: 109개event-publisher: 1개jwt: 18개redis-adapter: 24개web-support: 67개
- 공통 모듈: 113개











































