개요
커뮤니티 단위의 쓰레기 분류 관리 플랫폼은 주거 지역 내 재활용 및 폐기물 처리 효율성을 극대화하는 것을 목표로 한다. 본 시스템은 백엔드에 스프링부트, 프론트엔드에 Vue.js를 적용하여 RESTful 아키텍처 기반의 웹 애플리케이션을 구현한다.
백엔드 아키텍처
스프링부트 핵심 구성
스프링부트는 자동 설정(Auto-configuration) 메커니즘을 통해 개발 환경 구성의 복잡성을 제거한다. 내장 웹 서버(Netty 또는 Tomcat)를 활용한 독립 실행형 JAR 배포 방식은 컨테이너 환경에서의 유연한 확장을 가능하게 한다. 프로파일 기반 설정 분리로 개발/운영 환경 전환이 용이하며, 액추에이터(Actuator)를 통한 런타임 모니터링 기능을 기본 제공한다.
보안 계층 설계
JWT 기반 무상태 인증 방식을 채택하여 세션 서버 의존성을 제거한다. 토큰 갱신 전략으로는 슬라이딩 윈도우 방식의 리프레시 토큰을 적용하여 보안성과 사용자 경험의 균형을 맞춘다.
// 인증 예외 처리 어노테이션
@PermitAll
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private MemberService memberService;
@Autowired
private JwtProvider jwtProvider;
@PostMapping("/signin")
public ResponseEntity<AuthResponse> authenticate(
@RequestBody CredentialsDto creds,
HttpServletRequest req) {
Member member = memberService.verifyCredentials(
creds.getAccountId(),
creds.getSecretKey()
);
if (member == null || !BCrypt.checkpw(creds.getSecretKey(), member.getEncryptedPass())) {
return ResponseEntity.status(401)
.body(AuthResponse.fail("인증 정보가 올바르지 않습니다"));
}
String accessToken = jwtProvider.createToken(
member.getMemberSeq(),
member.getAccountId(),
member.getMemberType(),
Duration.ofMinutes(30)
);
return ResponseEntity.ok(AuthResponse.success(accessToken));
}
}
// JWT 토큰 발급 서비스
@Service
public class JwtProvider {
@Value("${jwt.secret}")
private String secretKey;
public String createToken(Long seq, String account, String type, Duration validity) {
Date now = new Date();
Date expiry = new Date(now.getTime() + validity.toMillis());
return Jwts.builder()
.setSubject(String.valueOf(seq))
.claim("account", account)
.claim("role", type)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
}
}
// 인증 필터 인터셉터
@Component
public class AuthInterceptor implements HandlerInterceptor {
private static final String AUTH_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
@Autowired
private JwtProvider jwtProvider;
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res,
Object handler) throws Exception {
// CORS 프리플라이트 응답
if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
res.setStatus(HttpServletResponse.SC_OK);
return false;
}
// 공개 엔드포인트 체크
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler;
if (hm.hasMethodAnnotation(PermitAll.class)) {
return true;
}
}
String authHeader = req.getHeader(AUTH_HEADER);
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
sendErrorResponse(res, 401, "인증 토큰이 필요합니다");
return false;
}
String token = authHeader.substring(BEARER_PREFIX.length());
try {
Claims claims = jwtProvider.parseToken(token);
req.setAttribute("currentUser", claims.getSubject());
req.setAttribute("userRole", claims.get("role"));
return true;
} catch (ExpiredJwtException e) {
sendErrorResponse(res, 401, "토큰이 만료되었습니다");
return false;
} catch (JwtException e) {
sendErrorResponse(res, 401, "유효하지 않은 토큰입니다");
return false;
}
}
private void sendErrorResponse(HttpServletResponse res, int code, String msg)
throws IOException {
res.setContentType("application/json;charset=UTF-8");
res.setStatus(code);
res.getWriter().write("{\"code\":" + code + ",\"message\":\"" + msg + "\"}");
}
}
프론트엔드 구현
Vue.js 3 컴포지션 API 활용
Vue 3의 setup 문법과 반응형 시스템(ref, reactive, computed)을 활용하여 컴포넌트 로직을 체계적으로 구성한다. Pinia 스토어를 통한 전역 상태 관리로 사용자 세션 및 쓰레기 분류 데이터의 일관성을 유지한다.
성능 최적화 전략
라우트 레벨 코드 스플리팅, 가상 스크롤링 대용량 리스트 처리, 이미지 레이지 로딩 등을 적용하여 초기 로딩 시간을 단축한다. Composition API의 재사용 가능한 로직 분리로 코드 중복을 최소화한다.
데이터 영속화
MyBatis 동적 SQL 매핑
XML 기반 매퍼와 어노테이션 방식을 혼용하여 복잡한 조인 쿼리와 동적 검색 조건을 효과적으로 처리한다. 결과 매핑에 대한 커스텀 타입 핸들러를 구현하여 JSON 칼럼과 Java 객체 간 자동 변환을 지원한다.
데이터베이스 스키마
-- 회원 정보 테이블
CREATE TABLE `member` (
`member_seq` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '회원 일련번호',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '가입일시',
`modified_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
`account_id` VARCHAR(50) NOT NULL COMMENT '로그인 아이디',
`encrypted_pass` VARCHAR(255) NOT NULL COMMENT '암호화된 비밀번호',
`full_name` VARCHAR(100) NOT NULL COMMENT '실명',
`gender_code` CHAR(1) DEFAULT NULL COMMENT '성별코드 M/F',
`contact_number` VARCHAR(20) DEFAULT NULL COMMENT '연락처',
`profile_image` TEXT DEFAULT NULL COMMENT '프로필 이미지 URL',
`earned_points` DECIMAL(10,2) DEFAULT 0.00 COMMENT '적립 포인트',
`account_balance` DECIMAL(12,2) DEFAULT 0.00 COMMENT '예치금 잔액',
PRIMARY KEY (`member_seq`),
UNIQUE KEY `uk_account` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='커뮤니티 회원 마스터';
-- 쓰레기 분류 수거지 정보
CREATE TABLE `collection_point` (
`point_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`registered_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`location_name` VARCHAR(200) NOT NULL COMMENT '수거지명',
`address_detail` VARCHAR(300) NOT NULL COMMENT '상세 주소',
`latitude` DECIMAL(10,8) NOT NULL COMMENT '위도',
`longitude` DECIMAL(11,8) NOT NULL COMMENT '경도',
`waste_category` VARCHAR(50) NOT NULL COMMENT '수거 품목 분류',
`operating_status` VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '운영상태',
PRIMARY KEY (`point_id`),
KEY `idx_location` (`latitude`, `longitude`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='분리수거 장소 정보';
-- 커뮤니티 게시판
CREATE TABLE `community_post` (
`post_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`written_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`post_title` VARCHAR(200) DEFAULT NULL COMMENT '게시글 제목',
`post_body` LONGTEXT NOT NULL COMMENT '본문 내용',
`parent_post_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '답글 대상',
`author_seq` BIGINT UNSIGNED NOT NULL COMMENT '작성자',
`author_nickname` VARCHAR(100) DEFAULT NULL COMMENT '작성 시 닉네임',
`thumbnail_url` TEXT DEFAULT NULL COMMENT '대표 이미지',
`moderation_status` VARCHAR(20) DEFAULT 'PENDING' COMMENT '검토 상태',
`is_pinned` BOOLEAN DEFAULT FALSE COMMENT '상단 고정 여부',
`pinned_until` DATETIME DEFAULT NULL COMMENT '고정 종료 시각',
PRIMARY KEY (`post_id`),
KEY `idx_author` (`author_seq`),
KEY `idx_pinned` (`is_pinned`, `pinned_until`),
CONSTRAINT `fk_parent_post` FOREIGN KEY (`parent_post_id`)
REFERENCES `community_post` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='커뮤니티 게시글';
-- 분리수거 실적 기록
CREATE TABLE `recycling_record` (
`record_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`recorded_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`member_seq` BIGINT UNSIGNED NOT NULL COMMENT '처리 담당 회원',
`point_id` BIGINT UNSIGNED NOT NULL COMMENT '처리 장소',
`waste_type` VARCHAR(50) NOT NULL COMMENT '폐기물 종류',
`weight_kg` DECIMAL(8,3) NOT NULL COMMENT '처리 중량(kg)',
`earned_credit` INT NOT NULL DEFAULT 0 COMMENT '획득 크레딧',
`verification_image` TEXT DEFAULT NULL COMMENT '인증 사진',
PRIMARY KEY (`record_id`),
KEY `idx_member_date` (`member_seq`, `recorded_at`),
CONSTRAINT `fk_recycling_member` FOREIGN KEY (`member_seq`)
REFERENCES `member` (`member_seq`),
CONSTRAINT `fk_recycling_point` FOREIGN KEY (`point_id`)
REFERENCES `collection_point` (`point_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='분리수거 처리 이력';
핵심 기능 모듈
실시간 위치 기반 수거지 검색
Haversine 공식을 적용한 거리 계산 쿼리로 사용자 현재 위치에서 반경 내 수거지를 조회한다. Redis Geo 자료구조를 활용한 캐싱 계층으로 반복 조회 성능을 향상시킨다.
포인트 적립 및 보상 시스템
분리수거 인증 사진 업로드 → AI 이미지 분류 검증 → 크레딧 자동 적립의 파이프라인을 구현한다. 비동기 이벤트 처리로 대량 트래픽 시에도 응답 지연을 방지한다.
품질 보증
통합 테스트 전략
JUnit 5와 Testcontainers를 활용한 실제 데이터베이스 환경 테스트, RestAssured 기반 API 계약 검증, Karate를 활용한 BDD 스타일 인수 테스트를 계층적으로 수행한다.
| 테스트 시나리오 | 입력값 | 기대 결과 | 검증 방법 |
|---|---|---|---|
| 정상 로그인 | account: user001 / pass: ValidPass123! | JWT 토큰 발급, 200 OK | 토큰 서명 검증, 클레임 파싱 |
| 잠긴 계정 접근 | 5회 연속 실패 후 시도 | 403 Forbidden, 계정 잠금 알림 | Redis 블랙리스트 키 존재 확인 |
| 위치 기반 검색 | lat: 37.5665, lng: 126.9780, radius: 2km | 반경 내 15개 수거지 반환 | 실제 거리 계산 검증 |
| 동시성 포인트 적립 | 100명 동시 같은 계정 요청 | 정확한 누적, 레이스 컨디션 없음 | 데이터베이스 락 타임스탬프 분석 |
| 대용량 이미지 업로드 | 10MB 이상 이미지 파일 | 413 Payload Too Large | MultipartResolver 제한 확인 |
배포 및 운영
Docker 멀티스테이지 빌드로 경량화된 이미지를 생성하고, Kubernetes HPA(Horizontal Pod Autoscaler)로 트래픽 변동에 따른 자동 확장을 구성한다. Prometheus와 Grafana 스택으로 메트릭 수집 및 알림을 설정한다.