스프링부트와 Vue.js 기반 커뮤니티 쓰레기 분류 관리 시스템 구축

개요

커뮤니티 단위의 쓰레기 분류 관리 플랫폼은 주거 지역 내 재활용 및 폐기물 처리 효율성을 극대화하는 것을 목표로 한다. 본 시스템은 백엔드에 스프링부트, 프론트엔드에 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 LargeMultipartResolver 제한 확인

배포 및 운영

Docker 멀티스테이지 빌드로 경량화된 이미지를 생성하고, Kubernetes HPA(Horizontal Pod Autoscaler)로 트래픽 변동에 따른 자동 확장을 구성한다. Prometheus와 Grafana 스택으로 메트릭 수집 및 알림을 설정한다.

태그: Spring Boot Vue.js MyBatis jwt Redis

9월 13일 04:06에 게시됨