🏢 당근마켓 기술 스택 분석

48 min read
karrotdaangnruby-on-railskotlinswiftpostgresqlredisawskuberneteshyperlocalcommunity

당근마켓이 동네 커뮤니티를 혁신하며 유니콘으로 성장한 기술 스택 심층 분석 - Ruby on Rails, Kotlin, Swift로 만든 하이퍼로컬 플랫폼

🏢 당근마켓 기술 스택 분석

동네 중고거래에서 시작해 지역 커뮤니티 플랫폼으로 진화한 당근마켓의 기술 스택을 심층 분석합니다.
"우리 동네를 더 따뜻하게" - 기술로 이웃을 연결하는 혁신


📊 회사 개요

서비스 규모

  • 월간 활성 사용자: 2,500만+ 명
  • 월간 거래: 300만+ 건
  • 활성 동네: 4만+ 개
  • 월간 등록 물품: 2,000만+ 개
  • 서비스 국가: 4개국 (한국, 일본, 영국, 캐나다)

엔지니어링 조직

  • 개발자: 200명+ (전체 직원 600+)
  • 문화: "Trust & Impact"
  • 조직: 목적 중심 스쿼드
  • 기술 철학: 사용자 가치 우선

기술적 도전과제

  1. 하이퍼로컬: 정확한 위치 기반 서비스
  2. 실시간 매칭: 구매자-판매자 연결
  3. 신뢰 시스템: 동네 기반 평판
  4. 글로벌 확장: 각국 특성 대응

🏗️ 아키텍처 Overview

시스템 다이어그램

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Mobile    │────►│   API       │────►│  Location   │
│   Client    │     │  Gateway    │     │   Service   │
└─────────────┘     └─────────────┘     └─────────────┘
       │                    │                    │
       │              ┌─────▼─────┐       ┌─────▼─────┐
       │              │   Rails    │       │  Matching  │
       │              │   Backend  │       │   Engine   │
       │              └───────────┘       └───────────┘
       │                    │                    │
       └────────────────────┤                    │
                      ┌─────▼─────┐       ┌─────▼─────┐
                      │   Chat     │       │  ML/AI     │
                      │  Service   │       │  Platform  │
                      └───────────┘       └───────────┘

핵심 설계 원칙

  1. Hyperlocal First: 동네 중심 설계
  2. Trust by Design: 신뢰 기반 거래
  3. Mobile Native: 모바일 최적화
  4. Community Driven: 커뮤니티 우선
  5. Simple & Intuitive: 직관적 UX

기술 진화

  • 2015-2016: MVP, 중고거래 시작
  • 2017-2018: 동네인증, 매너온도
  • 2019-2020: 동네생활, 당근페이
  • 2021-2022: 글로벌 진출
  • 2023-현재: AI 기반 추천, 부동산

🧩 기술 스택 상세

Backend 기술

Ruby on Rails 메인 서버

# 당근마켓 Rails 백엔드
class ItemsController < ApplicationController
  before_action :authenticate_user!
  before_action :verify_neighborhood

  def create
    @item = current_user.items.build(item_params)
    @item.neighborhood = current_user.active_neighborhood

    ActiveRecord::Base.transaction do
      if @item.save
        # 이미지 처리 (비동기)
        ImageProcessingJob.perform_later(@item)

        # AI 카테고리 분류
        CategoryClassificationJob.perform_later(@item)

        # 동네 사용자에게 알림
        NotifyNeighborhoodJob.perform_later(@item)

        # 실시간 피드 업데이트
        ActionCable.server.broadcast(
          "neighborhood_#{@item.neighborhood_id}_feed",
          item: ItemSerializer.new(@item).as_json
        )

        render json: @item, serializer: ItemSerializer
      else
        render json: { errors: @item.errors }, status: :unprocessable_entity
      end
    end
  end

  def index
    # 동네 기반 아이템 조회
    @items = Item
      .in_neighborhood(current_user.active_neighborhood)
      .within_radius(params[:radius] || 6) # 기본 6km
      .active
      .includes(:user, :images, :category)
      .page(params[:page])

    # 개인화 추천 적용
    if params[:personalized]
      @items = PersonalizationService.new(current_user).rank_items(@items)
    end

    render json: @items, each_serializer: ItemSerializer
  end

  def search
    # Elasticsearch를 통한 검색
    results = ItemSearchService.new(
      query: params[:q],
      neighborhood: current_user.active_neighborhood,
      filters: search_filters
    ).search

    render json: results
  end

  private

  def verify_neighborhood
    unless current_user.verified_neighborhoods.exists?(id: current_user.active_neighborhood_id)
      render json: { error: "동네 인증이 필요합니다" }, status: :forbidden
    end
  end
end

# 위치 기반 서비스
class LocationService
  include Redis::Objects

  NEIGHBORHOOD_RADIUS = 6.0 # km

  def initialize(user)
    @user = user
  end

  # 동네 인증
  def verify_neighborhood(lat, lng)
    # 좌표로 동네 찾기
    neighborhood = Neighborhood.find_by_coordinates(lat, lng)

    return false unless neighborhood

    # 이미 인증된 동네인지 확인
    if @user.verified_neighborhoods.exists?(id: neighborhood.id)
      return { status: :already_verified, neighborhood: neighborhood }
    end

    # GPS 스푸핑 검사
    if detect_gps_spoofing?(lat, lng)
      return { status: :spoofing_detected }
    end

    # 인증 처리
    verification = @user.neighborhood_verifications.create!(
      neighborhood: neighborhood,
      verified_at: Time.current,
      latitude: lat,
      longitude: lng,
      verification_method: 'gps'
    )

    # 동네 활성화
    @user.update!(active_neighborhood: neighborhood)

    # 캐시 업데이트
    update_user_location_cache(lat, lng)

    { status: :verified, neighborhood: neighborhood, verification: verification }
  end

  # 근처 아이템 조회
  def nearby_items(options = {})
    radius = options[:radius] || NEIGHBORHOOD_RADIUS
    category = options[:category]

    # Redis GeoRadius 사용
    item_ids = redis.georadius(
      location_key,
      @user.last_longitude,
      @user.last_latitude,
      radius,
      'km'
    )

    # 데이터베이스 조회
    items = Item.where(id: item_ids).active
    items = items.where(category_id: category) if category

    # 거리 정보 추가
    items.map do |item|
      distance = calculate_distance(
        @user.last_latitude,
        @user.last_longitude,
        item.latitude,
        item.longitude
      )

      ItemWithDistance.new(item, distance)
    end.sort_by(&:distance)
  end

  private

  def detect_gps_spoofing?(lat, lng)
    # 최근 위치 이동 패턴 분석
    recent_locations = @user.location_histories.recent(1.hour)

    return false if recent_locations.empty?

    last_location = recent_locations.last
    distance = calculate_distance(
      last_location.latitude,
      last_location.longitude,
      lat,
      lng
    )

    time_diff = Time.current - last_location.created_at
    speed = (distance / time_diff.to_f) * 3600 # km/h

    # 비현실적인 이동 속도 감지 (200km/h 이상)
    speed > 200
  end

  def location_key
    "items:location:#{@user.active_neighborhood_id}"
  end
end

# 매칭 엔진
class MatchingEngine
  def initialize(item)
    @item = item
  end

  def find_potential_buyers
    # 관심 카테고리 등록 사용자
    interested_users = User
      .joins(:interests)
      .where(interests: { category_id: @item.category_id })
      .in_neighborhood(@item.neighborhood_id)

    # 검색 키워드 매칭
    keyword_users = find_users_by_keywords

    # 행동 패턴 기반 추천
    behavior_users = find_users_by_behavior

    # 종합 점수 계산
    all_users = (interested_users + keyword_users + behavior_users).uniq

    rank_users(all_users)
  end

  private

  def find_users_by_keywords
    # 사용자 검색 히스토리와 매칭
    keywords = extract_keywords(@item.title, @item.description)

    SearchHistory
      .where(keyword: keywords)
      .where('created_at > ?', 7.days.ago)
      .includes(:user)
      .map(&:user)
      .select { |u| u.in_neighborhood?(@item.neighborhood_id) }
  end

  def find_users_by_behavior
    # 비슷한 아이템 조회/구매 이력
    similar_items = @item.similar_items(limit: 10)

    UserActivity
      .where(
        item_id: similar_items.pluck(:id),
        activity_type: ['view', 'like', 'inquiry']
      )
      .includes(:user)
      .map(&:user)
      .uniq
  end

  def rank_users(users)
    users.map do |user|
      score = calculate_user_score(user)
      { user: user, score: score }
    end.sort_by { |u| -u[:score] }
  end

  def calculate_user_score(user)
    score = 0.0

    # 활동성 점수
    score += user.recent_activity_score * 0.3

    # 매너온도
    score += (user.manner_temperature / 100.0) * 0.3

    # 거래 완료율
    score += user.transaction_completion_rate * 0.2

    # 응답률
    score += user.response_rate * 0.2

    score
  end
end

실시간 채팅 서비스

# ActionCable을 이용한 실시간 채팅
class ChatChannel < ApplicationCable::Channel
  def subscribed
    @chat_room = ChatRoom.find(params[:room_id])

    # 권한 확인
    if can_access_chat_room?
      stream_for @chat_room

      # 온라인 상태 업데이트
      update_online_status(true)

      # 읽음 처리
      mark_messages_as_read
    else
      reject
    end
  end

  def speak(data)
    message = @chat_room.messages.create!(
      user: current_user,
      content: data['message'],
      message_type: data['type'] || 'text'
    )

    # 상대방에게 전송
    ChatChannel.broadcast_to(
      @chat_room,
      message: MessageSerializer.new(message).as_json,
      action: 'new_message'
    )

    # 푸시 알림
    send_push_notification(message)

    # 메시지 분석 (비동기)
    AnalyzeMessageJob.perform_later(message)
  end

  def typing(data)
    ChatChannel.broadcast_to(
      @chat_room,
      user_id: current_user.id,
      action: 'typing',
      is_typing: data['is_typing']
    )
  end

  def unsubscribed
    update_online_status(false)
  end

  private

  def can_access_chat_room?
    @chat_room.participants.exists?(id: current_user.id)
  end

  def send_push_notification(message)
    recipient = @chat_room.other_participant(current_user)

    # 온라인 상태가 아닐 때만 푸시
    unless recipient.online?
      PushNotificationService.new.send(
        user: recipient,
        title: current_user.nickname,
        body: message.preview_text,
        data: {
          type: 'chat_message',
          room_id: @chat_room.id,
          message_id: message.id
        }
      )
    end
  end
end

# 메시지 안전성 분석
class MessageAnalyzer
  PROHIBITED_PATTERNS = [
    /계좌\s*번호/,
    /송금\s*해\s*드립니다/,
    /선입금/,
    /택배\s*착불/
  ]

  def analyze(message)
    result = {
      is_safe: true,
      warnings: [],
      auto_block: false
    }

    # 금지 패턴 검사
    PROHIBITED_PATTERNS.each do |pattern|
      if message.content.match?(pattern)
        result[:warnings] << {
          type: 'potential_fraud',
          pattern: pattern.source,
          severity: 'high'
        }
        result[:is_safe] = false
      end
    end

    # 외부 링크 검사
    if contains_suspicious_link?(message.content)
      result[:warnings] << {
        type: 'suspicious_link',
        severity: 'medium'
      }
    end

    # AI 기반 분석
    ai_result = ai_safety_check(message.content)
    if ai_result[:risk_score] > 0.8
      result[:auto_block] = true
      result[:is_safe] = false
    end

    # 결과에 따른 조치
    if result[:auto_block]
      message.update!(blocked: true, blocked_reason: result[:warnings])
      notify_safety_team(message, result)
    elsif !result[:is_safe]
      add_warning_to_chat(message.chat_room, result[:warnings])
    end

    result
  end

  private

  def contains_suspicious_link?(content)
    urls = URI.extract(content, ['http', 'https'])

    urls.any? do |url|
      # 단축 URL 서비스
      url.match?(/bit\.ly|tinyurl|goo\.gl/) ||
      # 의심스러운 도메인
      !url.match?(/daangn\.com|karrot\.com/)
    end
  end
end

Mobile 기술

iOS 앱 (Swift)

// 당근마켓 iOS 앱
import UIKit
import CoreLocation
import Combine

// 메인 피드 뷰컨트롤러
class FeedViewController: UIViewController {
    @IBOutlet weak var collectionView: UICollectionView!

    private var viewModel = FeedViewModel()
    private var cancellables = Set<AnyCancellable>()
    private let locationManager = CLLocationManager()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupUI()
        bindViewModel()
        setupLocationServices()
    }

    private func setupUI() {
        // 컬렉션뷰 설정
        collectionView.delegate = self
        collectionView.dataSource = self

        // 당겨서 새로고침
        let refreshControl = UIRefreshControl()
        refreshControl.addTarget(
            self,
            action: #selector(refreshFeed),
            for: .valueChanged
        )
        collectionView.refreshControl = refreshControl

        // 플로팅 버튼
        setupFloatingButton()
    }

    private func bindViewModel() {
        // 아이템 업데이트
        viewModel.$items
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.collectionView.reloadData()
            }
            .store(in: &cancellables)

        // 에러 처리
        viewModel.$error
            .compactMap { $0 }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] error in
                self?.showError(error)
            }
            .store(in: &cancellables)

        // 위치 업데이트
        viewModel.$currentNeighborhood
            .receive(on: DispatchQueue.main)
            .sink { [weak self] neighborhood in
                self?.updateNavigationTitle(neighborhood)
            }
            .store(in: &cancellables)
    }

    @objc private func refreshFeed() {
        viewModel.refreshItems()

        // 햅틱 피드백
        let impactFeedback = UIImpactFeedbackGenerator(style: .light)
        impactFeedback.impactOccurred()

        // 애니메이션과 함께 종료
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.collectionView.refreshControl?.endRefreshing()
        }
    }

    // 무한 스크롤
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offsetY = scrollView.contentOffset.y
        let contentHeight = scrollView.contentSize.height
        let height = scrollView.frame.size.height

        if offsetY > contentHeight - height * 2 {
            viewModel.loadMoreItems()
        }
    }
}

// 위치 기반 서비스
extension FeedViewController: CLLocationManagerDelegate {
    private func setupLocationServices() {
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
        locationManager.requestWhenInUseAuthorization()
    }

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        guard let location = locations.last else { return }

        // 동네 확인
        LocationService.shared.verifyNeighborhood(
            latitude: location.coordinate.latitude,
            longitude: location.coordinate.longitude
        ) { [weak self] result in
            switch result {
            case .success(let neighborhood):
                self?.viewModel.updateNeighborhood(neighborhood)
            case .failure(let error):
                self?.handleLocationError(error)
            }
        }
    }
}

// 아이템 상세 화면
class ItemDetailViewController: UIViewController {
    @IBOutlet weak var scrollView: UIScrollView!
    @IBOutlet weak var imagePageControl: UIPageControl!
    @IBOutlet weak var priceLabel: UILabel!
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var mannerTemperatureView: MannerTemperatureView!

    var item: Item!
    private let haptics = UINotificationFeedbackGenerator()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupUI()
        loadItemDetail()
        trackView()
    }

    private func setupUI() {
        // 이미지 스와이프
        setupImageGallery()

        // 가격 포맷팅
        priceLabel.text = PriceFormatter.format(item.price)

        // 매너온도 표시
        mannerTemperatureView.temperature = item.user.mannerTemperature

        // 하단 버튼 설정
        setupBottomButtons()
    }

    @IBAction func likeButtonTapped(_ sender: UIButton) {
        // 햅틱 피드백
        haptics.notificationOccurred(.success)

        // 애니메이션
        UIView.animate(withDuration: 0.3, animations: {
            sender.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
        }) { _ in
            UIView.animate(withDuration: 0.2) {
                sender.transform = .identity
            }
        }

        // API 호출
        ItemService.shared.toggleLike(itemId: item.id) { [weak self] result in
            switch result {
            case .success(let isLiked):
                self?.updateLikeButton(isLiked: isLiked)
            case .failure(let error):
                self?.showError(error)
            }
        }
    }

    @IBAction func chatButtonTapped(_ sender: UIButton) {
        // 채팅방 생성 또는 이동
        ChatService.shared.getOrCreateChatRoom(
            itemId: item.id,
            sellerId: item.user.id
        ) { [weak self] result in
            switch result {
            case .success(let chatRoom):
                self?.navigateToChatRoom(chatRoom)
            case .failure(let error):
                self?.showError(error)
            }
        }
    }
}

// 매너온도 커스텀 뷰
@IBDesignable
class MannerTemperatureView: UIView {
    @IBInspectable var temperature: Double = 36.5 {
        didSet {
            updateTemperature()
        }
    }

    private let gradientLayer = CAGradientLayer()
    private let temperatureLabel = UILabel()
    private let iconImageView = UIImageView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    private func setupView() {
        // 그라데이션 배경
        gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
        gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
        layer.addSublayer(gradientLayer)

        // 온도 라벨
        temperatureLabel.font = .systemFont(ofSize: 14, weight: .bold)
        temperatureLabel.textAlignment = .center
        addSubview(temperatureLabel)

        // 아이콘
        iconImageView.contentMode = .scaleAspectFit
        addSubview(iconImageView)

        updateTemperature()
    }

    private func updateTemperature() {
        // 색상 결정
        let color = temperatureColor(for: temperature)
        gradientLayer.colors = [
            color.withAlphaComponent(0.3).cgColor,
            color.cgColor
        ]

        // 텍스트 업데이트
        temperatureLabel.text = "\(temperature)°C"
        temperatureLabel.textColor = color

        // 아이콘 업데이트
        iconImageView.image = temperatureIcon(for: temperature)
        iconImageView.tintColor = color
    }

    private func temperatureColor(for temp: Double) -> UIColor {
        switch temp {
        case ...30:
            return .systemBlue
        case 30..<36.5:
            return .systemTeal
        case 36.5..<40:
            return .systemOrange
        default:
            return .systemRed
        }
    }
}

Android 앱 (Kotlin)

// 당근마켓 Android 앱
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setupBottomNavigation()
        observeViewModel()
        checkLocationPermission()
    }

    private fun setupBottomNavigation() {
        binding.bottomNavigation.setOnItemSelectedListener { item ->
            when (item.itemId) {
                R.id.nav_home -> {
                    supportFragmentManager.beginTransaction()
                        .replace(R.id.fragment_container, HomeFragment())
                        .commit()
                    true
                }
                R.id.nav_neighborhood -> {
                    supportFragmentManager.beginTransaction()
                        .replace(R.id.fragment_container, NeighborhoodFragment())
                        .commit()
                    true
                }
                R.id.nav_nearby -> {
                    supportFragmentManager.beginTransaction()
                        .replace(R.id.fragment_container, NearbyFragment())
                        .commit()
                    true
                }
                R.id.nav_chat -> {
                    supportFragmentManager.beginTransaction()
                        .replace(R.id.fragment_container, ChatListFragment())
                        .commit()
                    true
                }
                R.id.nav_my_karrot -> {
                    supportFragmentManager.beginTransaction()
                        .replace(R.id.fragment_container, MyKarrotFragment())
                        .commit()
                    true
                }
                else -> false
            }
        }
    }

    private fun observeViewModel() {
        // 새 메시지 알림
        viewModel.unreadMessageCount.observe(this) { count ->
            if (count > 0) {
                binding.bottomNavigation.getOrCreateBadge(R.id.nav_chat).apply {
                    number = count
                    isVisible = true
                }
            } else {
                binding.bottomNavigation.removeBadge(R.id.nav_chat)
            }
        }

        // 동네 변경
        viewModel.currentNeighborhood.observe(this) { neighborhood ->
            updateToolbarTitle(neighborhood.name)
        }
    }
}

// 홈 프래그먼트
class HomeFragment : Fragment() {

    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!

    private val viewModel: HomeViewModel by viewModels()
    private lateinit var itemAdapter: ItemAdapter

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        setupRecyclerView()
        setupSwipeRefresh()
        observeViewModel()

        viewModel.loadItems()
    }

    private fun setupRecyclerView() {
        itemAdapter = ItemAdapter { item ->
            findNavController().navigate(
                HomeFragmentDirections.actionHomeToItemDetail(item.id)
            )
        }

        binding.recyclerView.apply {
            adapter = itemAdapter
            layoutManager = StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)

            // 무한 스크롤
            addOnScrollListener(object : RecyclerView.OnScrollListener() {
                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                    super.onScrolled(recyclerView, dx, dy)

                    val layoutManager = recyclerView.layoutManager as StaggeredGridLayoutManager
                    val visibleItemCount = layoutManager.childCount
                    val totalItemCount = layoutManager.itemCount
                    val firstVisibleItems = IntArray(2)
                    layoutManager.findFirstVisibleItemPositions(firstVisibleItems)
                    val firstVisibleItem = firstVisibleItems.minOrNull() ?: 0

                    if (!viewModel.isLoading.value &&
                        visibleItemCount + firstVisibleItem >= totalItemCount - 4) {
                        viewModel.loadMoreItems()
                    }
                }
            })
        }
    }

    private fun observeViewModel() {
        viewModel.items.observe(viewLifecycleOwner) { items ->
            itemAdapter.submitList(items)
        }

        viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
            binding.progressBar.isVisible = isLoading && viewModel.items.value.isNullOrEmpty()
        }

        viewModel.error.observe(viewLifecycleOwner) { error ->
            error?.let {
                Snackbar.make(binding.root, it.message ?: "오류가 발생했습니다", Snackbar.LENGTH_LONG).show()
            }
        }
    }
}

// 채팅 서비스
class ChatService(private val context: Context) {

    private val stompClient = StompClient()
    private val notificationManager = context.getSystemService(NotificationManager::class.java)

    fun connectToChat(roomId: String) {
        stompClient.connect(
            url = "${BuildConfig.WEBSOCKET_URL}/chat",
            headers = mapOf(
                "Authorization" to "Bearer ${UserSession.token}",
                "Room-Id" to roomId
            )
        )

        // 메시지 구독
        stompClient.subscribe("/topic/room/$roomId") { message ->
            handleIncomingMessage(ChatMessage.fromJson(message))
        }

        // 타이핑 상태 구독
        stompClient.subscribe("/topic/room/$roomId/typing") { data ->
            handleTypingStatus(data)
        }
    }

    fun sendMessage(roomId: String, content: String, type: MessageType = MessageType.TEXT) {
        val message = ChatMessage(
            roomId = roomId,
            senderId = UserSession.userId,
            content = content,
            type = type,
            timestamp = System.currentTimeMillis()
        )

        stompClient.send(
            destination = "/app/chat.send",
            data = message.toJson()
        )
    }

    private fun handleIncomingMessage(message: ChatMessage) {
        // 로컬 DB에 저장
        ChatDatabase.getInstance(context).messageDao().insert(message)

        // 알림 표시 (백그라운드인 경우)
        if (isAppInBackground()) {
            showNotification(message)
        }

        // 이벤트 발생
        EventBus.getDefault().post(NewMessageEvent(message))
    }

    private fun showNotification(message: ChatMessage) {
        val notification = NotificationCompat.Builder(context, CHAT_CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(message.senderName)
            .setContentText(message.content)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setAutoCancel(true)
            .setContentIntent(createPendingIntent(message))
            .build()

        notificationManager.notify(message.roomId.hashCode(), notification)
    }
}

// 위치 서비스
class LocationService @Inject constructor(
    private val context: Context,
    private val api: KarrotApi
) {

    private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)

    suspend fun verifyCurrentLocation(): NeighborhoodVerification {
        // 위치 권한 확인
        if (!hasLocationPermission()) {
            throw LocationPermissionException()
        }

        // 현재 위치 가져오기
        val location = getCurrentLocation()

        // Mock Location 검사
        if (isMockLocation(location)) {
            throw MockLocationException()
        }

        // 서버에 인증 요청
        return api.verifyNeighborhood(
            VerifyNeighborhoodRequest(
                latitude = location.latitude,
                longitude = location.longitude,
                accuracy = location.accuracy
            )
        )
    }

    @SuppressLint("MissingPermission")
    private suspend fun getCurrentLocation(): Location = suspendCancellableCoroutine { cont ->
        fusedLocationClient.getCurrentLocation(
            LocationRequest.PRIORITY_HIGH_ACCURACY,
            CancellationTokenSource().token
        ).addOnSuccessListener { location ->
            cont.resume(location)
        }.addOnFailureListener { exception ->
            cont.resumeWithException(exception)
        }
    }

    private fun isMockLocation(location: Location): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            location.isMock
        } else {
            location.isFromMockProvider
        }
    }
}

AI/ML 시스템

추천 시스템

# 당근마켓 AI 추천 엔진
import tensorflow as tf
import numpy as np
from typing import List, Dict, Tuple

class KarrotRecommendationEngine:
    def __init__(self):
        self.user_embedding_model = self.build_user_model()
        self.item_embedding_model = self.build_item_model()
        self.ranking_model = self.build_ranking_model()

    def build_user_model(self):
        # 사용자 임베딩 모델
        user_id = tf.keras.Input(shape=(1,), name='user_id')
        neighborhood_id = tf.keras.Input(shape=(1,), name='neighborhood_id')
        user_features = tf.keras.Input(shape=(20,), name='user_features')

        # 임베딩 레이어
        user_embedding = tf.keras.layers.Embedding(
            input_dim=1000000,
            output_dim=64,
            name='user_embedding'
        )(user_id)

        neighborhood_embedding = tf.keras.layers.Embedding(
            input_dim=50000,
            output_dim=32,
            name='neighborhood_embedding'
        )(neighborhood_id)

        # 결합
        concatenated = tf.keras.layers.Concatenate()([
            tf.keras.layers.Flatten()(user_embedding),
            tf.keras.layers.Flatten()(neighborhood_embedding),
            user_features
        ])

        # DNN
        dense1 = tf.keras.layers.Dense(128, activation='relu')(concatenated)
        dense2 = tf.keras.layers.Dense(64, activation='relu')(dense1)
        user_vector = tf.keras.layers.Dense(32, name='user_vector')(dense2)

        return tf.keras.Model(
            inputs=[user_id, neighborhood_id, user_features],
            outputs=user_vector
        )

    def recommend_items(self, user_id: int, limit: int = 20) -> List[Dict]:
        # 사용자 벡터 생성
        user_vector = self.get_user_vector(user_id)

        # 후보 아이템 검색
        candidate_items = self.get_candidate_items(user_id)

        # 점수 계산
        scores = []
        for item in candidate_items:
            item_vector = self.get_item_vector(item)

            # 코사인 유사도
            similarity = self.cosine_similarity(user_vector, item_vector)

            # 추가 요인 고려
            distance_factor = self.calculate_distance_factor(user_id, item)
            time_factor = self.calculate_time_factor(item)
            popularity_factor = self.calculate_popularity_factor(item)

            # 최종 점수
            score = (
                similarity * 0.4 +
                distance_factor * 0.3 +
                time_factor * 0.2 +
                popularity_factor * 0.1
            )

            scores.append({
                'item': item,
                'score': score,
                'reasons': self.generate_recommendation_reasons(
                    user_id, item, similarity, distance_factor
                )
            })

        # 상위 N개 반환
        return sorted(scores, key=lambda x: x['score'], reverse=True)[:limit]

    def calculate_distance_factor(self, user_id: int, item: Dict) -> float:
        user_location = self.get_user_location(user_id)
        item_location = item['location']

        distance = self.haversine_distance(
            user_location['lat'], user_location['lng'],
            item_location['lat'], item_location['lng']
        )

        # 거리에 따른 점수 (가까울수록 높음)
        if distance < 1:  # 1km 이내
            return 1.0
        elif distance < 3:  # 3km 이내
            return 0.8
        elif distance < 6:  # 6km 이내
            return 0.5
        else:
            return 0.1

    def train_on_interaction(self, user_id: int, item_id: int, interaction_type: str):
        # 상호작용 데이터로 모델 업데이트
        interaction_weight = {
            'view': 1.0,
            'like': 2.0,
            'inquiry': 3.0,
            'purchase': 5.0
        }.get(interaction_type, 1.0)

        # 온라인 학습
        with tf.GradientTape() as tape:
            user_vec = self.user_embedding_model(user_id)
            item_vec = self.item_embedding_model(item_id)

            # 예측 점수
            predicted = tf.reduce_sum(user_vec * item_vec)

            # 실제 점수 (상호작용 가중치)
            actual = tf.constant(interaction_weight)

            # 손실 계산
            loss = tf.square(predicted - actual)

        # 그래디언트 계산 및 적용
        gradients = tape.gradient(loss, self.ranking_model.trainable_variables)
        self.optimizer.apply_gradients(
            zip(gradients, self.ranking_model.trainable_variables)
        )

# 카테고리 자동 분류
class CategoryClassifier:
    def __init__(self):
        self.tokenizer = BertTokenizer.from_pretrained('klue/bert-base')
        self.model = self.load_fine_tuned_model()
        self.category_tree = self.load_category_tree()

    def classify(self, title: str, description: str, images: List[str]) -> Dict:
        # 텍스트 분류
        text_category = self.classify_text(title, description)

        # 이미지 분류
        if images:
            image_category = self.classify_images(images)
        else:
            image_category = None

        # 종합 판단
        if image_category and text_category != image_category:
            # 불일치 시 신뢰도 높은 것 선택
            if text_category['confidence'] > image_category['confidence']:
                final_category = text_category
            else:
                final_category = image_category
        else:
            final_category = text_category

        # 하위 카테고리 추론
        subcategories = self.infer_subcategories(
            final_category['id'],
            title,
            description
        )

        return {
            'category': final_category,
            'subcategories': subcategories,
            'keywords': self.extract_keywords(title, description)
        }

    def classify_text(self, title: str, description: str) -> Dict:
        # BERT 토큰화
        text = f"{title} [SEP] {description}"
        inputs = self.tokenizer(
            text,
            padding=True,
            truncation=True,
            max_length=256,
            return_tensors='tf'
        )

        # 예측
        outputs = self.model(inputs)
        probabilities = tf.nn.softmax(outputs.logits, axis=-1)

        # 상위 카테고리
        top_category_idx = tf.argmax(probabilities, axis=-1).numpy()[0]
        confidence = float(probabilities[0][top_category_idx])

        return {
            'id': top_category_idx,
            'name': self.category_tree[top_category_idx]['name'],
            'confidence': confidence
        }

기술 스택 요약:

  • Backend: Ruby on Rails, Node.js
  • Mobile: Swift (iOS), Kotlin (Android)
  • 데이터베이스: PostgreSQL, Redis
  • 검색: Elasticsearch
  • 실시간: ActionCable, WebSocket
  • 인프라: AWS, Kubernetes
  • ML/AI: TensorFlow, Python
  • 모니터링: Datadog, Sentry

💡 핵심 기술 인사이트

1. 하이퍼로컬 기술

  • GPS 기반 동네 인증
  • 위치 기반 피드 알고리즘
  • 지역 커뮤니티 활성화

2. 신뢰 시스템

  • 매너온도 평가 체계
  • 사기 거래 방지
  • 안전한 거래 환경

3. 단순하고 직관적인 UX

  • 최소한의 탭으로 거래 완료
  • 직관적인 채팅 인터페이스
  • 빠른 사진 업로드

📈 성과 & 지표

기술적 성과

  • 앱 평점: 4.8+ (iOS/Android)
  • 크래시율: < 0.1%
  • API 응답시간: P50 < 100ms
  • 이미지 업로드: < 2초
  • 푸시 도달률: 95%+

비즈니스 영향

  • MAU: 2,500만+ (한국 인구 절반)
  • 월간 거래: 300만+ 건
  • 재거래율: 65%+
  • 기업 가치: 3조원+ (유니콘)

🎓 당근마켓에서 배울 점

✅ 적용 가능한 패턴

  1. 위치 기반 서비스: 정확한 GPS 활용
  2. 커뮤니티 형성: 지역 기반 네트워크
  3. 신뢰 메커니즘: 평판 시스템
  4. 모바일 최적화: 빠른 로딩과 반응
  5. AI 활용: 카테고리 자동 분류

❌ 주의사항

  1. 위치 정확도: GPS 스푸핑 대응
  2. 사기 거래: 지속적인 모니터링
  3. 커뮤니티 관리: 부적절한 콘텐츠
  4. 확장성: 지역별 특성 고려

📚 추천 리소스


🔮 미래 전망

현재 집중 분야

  1. 당근페이: 중고거래 결제
  2. 부동산 직거래: 중개 플랫폼
  3. 동네 커머스: 로컬 비즈니스
  4. 글로벌 확장: 북미, 유럽
  5. AI 고도화: 추천, 가격 예측

기술 투자 영역

  • 머신러닝: 사용자 행동 분석
  • 컴퓨터 비전: 상품 자동 인식
  • 자연어 처리: 채팅 분석
  • 블록체인: 거래 투명성
  • AR: 가상 상품 미리보기

"기술로 이웃과 이웃을 연결하여, 따뜻한 동네를 만들어갑니다."

  • 당근마켓 기술 조직

마지막 업데이트: 2025-01-28

Found this helpful? Share it with others!
Tweet

🔗 Related Content

You might also be interested in these articles

🏢 company

🏢 배달의민족 기술 스택 분석

배달의민족이 한국 배달 문화를 혁신하며 동남아로 확장한 기술 스택 심층 분석 - Java, Spring, Kotlin으로 구축한 O2O 플랫폼

51 min read
baemin, woowa +10
Read more
🏢 company

🏢 Canva 기술 스택 분석

캔바가 1억 명 이상의 사용자에게 쉽고 강력한 디자인 도구를 제공하는 기술 스택 심층 분석 - Java, TypeScript, AWS로 구축한 대규모 디자인 플랫폼

32 min read
canva, java +10
Read more
🏢 company

🏢 토스 기술 스택 분석

토스가 한국 금융을 혁신하며 유니콘으로 성장한 기술 스택 심층 분석 - Node.js, React Native, Kotlin으로 만든 모바일 금융 플랫폼

39 min read
toss, nodejs +8
Read more
🏗️ stack

🤖 AI-Powered App Stack

프로덕션 레벨 AI 애플리케이션 구축을 위한 검증된 기술 스택 - LangChain, FastAPI, Vector DB로 RAG 시스템 구현

13 min read
python, fastapi +13
Read more
🏗️ stack

🏢 Enterprise Microservices Stack

대규모 트래픽과 복잡한 비즈니스 로직을 위한 마이크로서비스 아키텍처 - Go, gRPC, Kubernetes로 구축하는 확장 가능한 시스템

14 min read
go, grpc +13
Read more
🏢 company

🏢 Airbnb 기술 스택 분석

에어비앤비가 전 세계 400만 개의 숙소를 연결하는 글로벌 마켓플레이스 기술 스택 심층 분석 - Ruby on Rails에서 Service-Oriented Architecture로의 진화

24 min read
airbnb, ruby +12
Read more

Found this helpful?

Help us improve this content by contributing on GitHub or sharing your feedback with the community.