🏢 당근마켓 기술 스택 분석

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.