이클립스(Eclipse) 환경에서 내장 Apache Tomcat 9.0을 연동하여 자바 표준 웹소켓(JSR-356) 및 org.json 라이브러리 기반의 전자봉투 암호화 서비스를 구축하는 과정에서 발생한 5가지 핵심 빌드/런타임 에러와 그 해결법에 대한 정리입니다.

1. Tomcat 7 v.s. Java 최신 바이트코드 충돌 에러

[현상]

Maven Plugin(tomcat7-maven-plugin:run)으로 가동 시 콘솔에 아래와 같은 치명적 예외가 출력됨.

SEVERE: Unable to process Jar entry [META-INF/versions/9/module-info.class] from Jar [...]
org.apache.tomcat.util.bcel.classfile.ClassFormatException: Invalid byte tag in constant pool: 19

[원인]

Tomcat 7 버전에 내장된 오래된 바이트코드 컴파일러 스캔 모듈(BCEL)이, 최신 Java 규격(Java 9 이상 Multi-Release JAR) 및 최신 라이브러리(json-20240303.jar) 내부에 포함된 module-info.class 구조를 해석하지 못해 컴파일 형식을 오인한 것입니다.

[해결법]

외부 플러그인 구동 방식을 배제하고, Java 최신 스펙을 정상 지원하는 Tomcat 8.5 또는 Tomcat 9.0 메인 엔진을 이클립스 내장 서버 제어(WTP) 환경에 직접 연동하는 표준 방식으로 전환해야 합니다.

2. Project Facets 메뉴 누락 및 Java EE 버전 불일치

[현상]

이클립스 프로젝트 속성(Properties) 창에서 웹 프로젝트 설정을 위한 Project Facets 메뉴가 보이지 않거나, 설정 시 Tomcat version 9.0 only supports J2EE 1.2, 1.3, 1.4, and Java EE 5, 6, 7, and 8 Web modules 제약 메시지가 발생함.

[원인]

  • 메이븐 기본 빌드 포맷이 일반 자바 애플리케이션(jar)으로 지정되어 있어 이클립스가 구조를 오인함.
  • Dynamic Web Module 규격을 Tomcat 9.0의 최대 수용 사양인 Java EE 8 (Servlet 4.0)을 초과하여 Jakarta EE 규격(5.0 이상)으로 선택했을 때 발생하는 정적 버전 충돌입니다.

[해결법]

  1. pom.xml 최상단에 웹 아카이브 빌드 지시어를 명시합니다.
  2.  
    <packaging>war</packaging>
    
  3. 프로젝트 우클릭 -> Properties -> Project Facets 메뉴에서 Dynamic Web Module 버전을 4.0으로 하향 조정하고, Java 컴파일러 버전을 로컬 JDK 사양(예: 11 또는 17)과 일치시킵니다.

3. Bootstrap 클래스 로드 실패 예외 (ClassNotFoundException)

[현상]

톰캣 서버 실행 시 기동 시점에 메인 스레드가 다운됨.

Exception in thread "main" java.lang.ClassNotFoundException: org.apache.catalina.startup.Bootstrap

[원인]

이클립스 서버 런타임 설정이 꼬이면서 톰캣 가동의 최우선 진입점 파일인 bin/bootstrap.jar 경로 인덱스를 Java 가상 머신(JVM)의 Classpath 인자가 잃어버렸기 때문입니다.

[해결법]

  1. Run -> Run Configurations... ->  Apache Tomcat 트리 내부의 인스턴스를 선택합니다.
  2. Classpath 탭으로 이동하여 User Entries를 마우스로 선택합니다.
  3. Add External JARs... 버튼을 눌러 실제 Tomcat 설치 폴더의 bin/ 디렉토리 내부에서 아래 3가지 핵심 Core 파일을 수동으로 찾아 등록합니다.
    • bootstrap.jar
    • tomcat-juli.jar
    • commons-daemon.jar

4. Catalina 클래스 로드 예외 및 이클립스 내부 경로 꼬임

[현상]

Bootstrap은 찾았으나 연이어 엔진 초기화 실패 예외가 발생함.

경고: Problem with directory [.../eclipse/lib], exists: [false]
심각: Init exception
java.lang.ClassNotFoundException: org.apache.catalina.startup.Catalina

[원인]

톰캣 컨테이너가 구동될 때 자사 엔진 파일들을 참조할 마스터 홈 경로 파라미터인 catalina.home과 catalina.base가 실제 톰캣 설치 폴더가 아닌 이클립스 프로그램 실행 폴더로 잘못 오인되어 라이브러리 스캔 범위가 이탈한 것입니다.

[해결법]

  1. Run Configurations... 설정 창의 Arguments 탭으로 이동합니다.
  2. VM arguments 입력란 맨 뒤에 한 칸을 띄우고, 실제 Tomcat 물리 경로 파라미터를 다음과 같이 강제 주입합니다.
  3. Plaintext
     
    -Dcatalina.home="D:/apache-tomcat-9.0.100" -Dcatalina.base="D:/apache-tomcat-9.0.100"
    
  4. 하단의 Working directory를 Default에서 Other로 체크 변경한 뒤, 실제 Tomcat 최상위 설치 폴더 경로를 명시해 줍니다.

5. 웹소켓 처리 시 JSONObject 클래스 미발견 예외 (NoClassDefFoundError)

[현상]

화면(HTTP) 진입은 성공하고 웹소켓 커넥션(ws://...) 핸드셰이크 요청까지 도달했으나, 패킷이 인입되는 순간 서버 채널이 강제로 끊어지며 자바 콘솔에 아래 에러가 출력됨.

[웹소켓 에러 발생] 사유: java.lang.NoClassDefFoundError: org/json/JSONObject
[시스템 정보] 클라이언트 세션 커넥션 닫힘

[원인]

pom.xml 파일에 org.json 의존성이 올바르게 정의되어 컴파일은 성공했으나, 이클립스 WTP 내부의 배포 어셈블리(Deployment Assembly) 명세서에 메이븐 의존성 항목이 누락된 상태입니다. 이로 인해 톰캣 가동용 런타임 가상 디렉토리인 WEB-INF/lib/ 폴더 내부로 json-20240303.jar 파일이 물리적으로 복사되지 않아 발생한 자바 전형적인 런타임 예외입니다.

[해결법]

  1. 프로젝트 우클릭 ->  Properties로 진입합니다.
  2. 왼쪽 메뉴에서 Deployment Assembly 탭을 선택합니다.
  3. 우측 메뉴 바에서 Add... 버튼을 클릭합니다.
  4. Java Build Path Entries를 선택하고 Next를 누른 뒤, 목록에서 Maven Dependencies를 선택하고 Finish를 누릅니다.
  5. 소스 목록에 Maven Dependencies가 추가되고 이의 배포 경로(Deploy Path)가 WEB-INF/lib로 안전하게 바인딩된 것을 확인한 뒤 서버를 리스타트합니다.

'전공 > Java' 카테고리의 다른 글

[JAVA] 기본 문법 정리  (0) 2026.10.22

구글 콘솔에서 정보 설정

https://console.cloud.google.com/welcome?hl=ko&inv=1&invt=Abo6BQ&project=deep-contact-450106-u3

먼저 구글 콘솔에서 앱에 관한 설정을 진행합니다

 

새 프로젝트를 설정한 후 프로젝트 이름을 적습니다

 

그 후 API 및 서비스로 들어가서 OAuth 동의 화면을 만듭니다

 

그 다음 사용자 인증 정보에 들어갑니다

사용자 인증 정보를 만듭니다

 

사용자 인증 정보에서 OAuth 클라이언트 ID를 선택합니다

 

총 3개를 만들겁니다

debug용 android, release용 android, 웹 어플리케이션입니다

먼저 debug용 android 입니다

어플리케이션 유형에서 android를 선택한 후 필요한 정보를 입력합니다

이름은 알아보기 쉽게 android debug로 하겠습니다

 

다음은 release용 android입니다

위와 마찬가지로 필요한 정보를 입력하는데 여기서는 기존에 안드로이드 gradle에서 signingreport를 통해 얻은 SHA-1키를 사용할 수 없습니다

방법은 링크를 통해 확인하시면 되겠습니다

https://blog.naver.com/jungeun85623/220607717078

여기서 apk 파일까지 만든 후

build.gradle 파일에 다음과 같이 적고 synk를 해줍니다

android {
	signingConfigs {
        create("release") {
            storeFile = file("C:/DATA/LoginTest/keystore.jks") // 예시
            storePassword = "aaaa1111" // 예시
            keyAlias = "key1" // 예시
            keyPassword = "aaaa1111" // 예시
        }
    }
    
    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            signingConfig = signingConfigs.getByName("release") // 추가
        }
    }
    ...
}

 

그 후 다시 signingreport를 하시면 release용 SHA-1키도 얻을 수 있습니다

이렇게 release용 SHA-1키를 가지고 debug용 android를 만들었던 것처럼 동일하게 진행합니다

이름은 알아보기 쉽게 android release로 하겠습니다

 

마지막으로 웹어플리케이션입니다

어플리케이션 유형을 웹어플리케이션으로 선택한 후 추가합니다

그럼 다음과 같습니다

 

파이어베이스에서 정보 설정

다음은 파이어베이스에서 로그인 정보를 설정합니다

https://console.firebase.google.com/u/0/?hl=ko

 

로그인 - Google 계정

이메일 또는 휴대전화

accounts.google.com

 

새 프로젝트를 생성합니다

어플 이름을 입력 후 프로젝트 설정에 들어갑니다

 

그 후 앱을 추가해줍니다

 

그 후 필요한 작업을 해줍니다

다음으로 debug용 SHA-1키와 release용 SHA-1키를 추가합니다

 

그 후 google-service.json을 안드로이드 프로젝트 app/에 넣어줍니다

build.gradle 파일 설정

// module
dependencies {
		...
    // google login
    implementation(libs.play.services.auth)
    implementation(platform(libs.firebase.bom))
    implementation(libs.firebase.auth.ktx)
    implementation(libs.firebase.analytics)
}
// project
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    id("com.google.gms.google-services") version "4.4.2" apply false
}

 

코드 구현

<resources>
    <string name="app_name">LoginTest</string>
    <string name="google_client_id">...</string>
</resources>

여기서 중요한 점은 google_client_id를 아까 구글 콘솔에서 설정한 웹어플리케이션의 id를 가져와야한다는 점입니다

절대 android키로 가져오면 안됩니다

 

 

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    private lateinit var mGoogleSigninClient: GoogleSignInClient // 선언 추가

    private val googleLoginResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
        val resultCode = result.resultCode
        val data = result.data

        try {
            val completedTask = GoogleSignIn.getSignedInAccountFromIntent(data)
            val account = completedTask.getResult(ApiException::class.java)
            onLoginCompleted("${account?.id}", "${account?.idToken}")
        } catch (e: ApiException) {
            onError(Error(e))
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // google login
        binding.signInButton.setOnClickListener {
            signInWithGoogle()
        }
    }

    private fun signInWithGoogle() {
        val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestIdToken(getString(R.string.google_client_id)) // 요청할 때마다 받아오기
            .requestEmail()
            .build()

        mGoogleSigninClient = GoogleSignIn.getClient(this, gso)
        googleLoginResult.launch(mGoogleSigninClient.signInIntent)
    }

    private fun onLoginCompleted(userId: String?, accessToken: String?){
        Toast.makeText(this, "구글 로그인 성공", Toast.LENGTH_SHORT).show()
        Log.e("YMC", "userId: $userId / accessToken: $accessToken")
    }
    private fun onError(error : Error?){
        Toast.makeText(this, "구글 로그인 실패", Toast.LENGTH_SHORT).show()
        Log.e("YMC", "구글 로그인 실패 onError / error: ${error} / error.msg: ${error?.message}")
    }
}

결과

# https://developers.naver.com/docs/login/android/android.md 접속

 

# 오픈 API 이용 신청

어플리케이션과 로그인 시 사용할 정보를 선택한다.

 

어플을 다운로드 할 URL과 안드로이드 앱 패키지 이름을 작성한다.

URL은 마땅히 없으면 네이버 URL을 적어도 된다.

 

등록하기를 누르면 Client ID와 Client Secret이 나온다.

이 정보를 프로젝트의 res/string.xml에 적어두도록 하자.

<resources>
    <string name="app_name">LoginTest</string>
    <string name="naver_client_id">client id 작성</string>
    <string name="naver_client_secret">client secret 작성</string>
    <string name="naver_client_name">client name 작성</string>
</resources>

 

# 개발 환경 설정

네이버 로그인 API를 사용하기 위해 다음과 같이 gradle파일을 설정한다.

dependencies {
	...
    
    // naver login
    implementation(libs.oauth.jdk8) // jdk 8
    
    // naver login dependency
    implementation (libs.kotlin.stdlib)
    implementation (libs.kotlinx.coroutines.android)
    implementation (libs.androidx.appcompat.v131)
    implementation (libs.androidx.legacy.support.core.utils)
    implementation (libs.androidx.browser)
    implementation (libs.androidx.legacy.support.v4)
    implementation (libs.androidx.constraintlayout.v113)
    implementation (libs.androidx.security.crypto)
    implementation (libs.androidx.core.ktx.v130)
    implementation (libs.retrofit)
    implementation (libs.converter.gson)
    implementation (libs.moshi.kotlin)
    implementation (libs.logging.interceptor)
    implementation (libs.lottie)
}

 

 

activity_main.xml에 네이버 로그인 버튼을 만든다.

이때 네이버 로그인 버튼은 네이버에서 정한 디자인을 따라야 한다.

com.navercorp.nid.oauth.view.NidOAuthLoginButton

이 버튼을 사용하거나, 혹은 다른 디자인을 원한다면 아래의 디자인 가이드를 참고하면 되겠다.

https://developers.naver.com/docs/login/bi/bi.md

 

로그인 버튼 사용 가이드 - LOGIN

네이버 로그인은 애플리케이션에 사용할 수 있는 네이버 로그인 버튼 기본 이미지를 제공합니다. 애플리케이션의 상황에 맞게 버튼 이미지의 디자인을 변경할 수 있지만 네이버 고유의 아이덴

developers.naver.com

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.navercorp.nid.oauth.view.NidOAuthLoginButton
        android:id="@+id/buttonOAuthLoginImg"
        android:layout_width="wrap_content"
        android:layout_height="50dp"
        android:layout_marginTop="16dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

이후 다음과 같은 코드를 작성하면 네이버 로그인 구현을 완성할 수 있다.

package com.example.logintest

import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.example.logintest.databinding.ActivityMainBinding
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.navercorp.nid.NaverIdLoginSDK
import com.navercorp.nid.oauth.OAuthLoginCallback

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // naver login
        NaverIdLoginSDK.initialize(this, getString(R.string.naver_client_id), getString(R.string.naver_client_secret), getString(R.string.naver_client_name))

        binding.buttonOAuthLoginImg.setOAuthLogin(object : OAuthLoginCallback {
            override fun onSuccess() {
                Log.d("test", "AccessToken : " + NaverIdLoginSDK.getAccessToken())
                Log.d("test", "client id : " + getString(R.string.naver_client_id))
                Log.d("test", "ReFreshToken : " + NaverIdLoginSDK.getRefreshToken())
                Log.d("test", "Expires : " + NaverIdLoginSDK.getExpiresAt().toString())
                Log.d("test", "TokenType : " + NaverIdLoginSDK.getTokenType())
                Log.d("test", "State : " + NaverIdLoginSDK.getState().toString())
            }

            override fun onFailure(httpStatus: Int, message: String) {
                val errorCode = NaverIdLoginSDK.getLastErrorCode().code
                val errorDescription = NaverIdLoginSDK.getLastErrorDescription()
                Log.e("test", "$errorCode $errorDescription")
            }

            override fun onError(errorCode: Int, message: String) {
                onFailure(errorCode, message)
            }
        })

    }
}

# 구현하고자 하는 것

  • 캐릭터가 이벤트 오브젝트 앞에서 z키 입력 시 대사 이벤트 발생

# 플레이어 수정

  • InteractionController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InteractionController : MonoBehaviour
{
    bool isInteractive = false; // 상호작용 가능 오브젝트에 접촉하는 최초의 순간에 true로 변경
    bool ZClickedInteractive = false; // 상호작용 가능 오브젝트를 클릭했는지

    RaycastHit2D hit;
    public GameObject scanObj;

    DialogueManager theDialogueManager;
    PlayerAction thePlayerAction;

    void Start()
    {
        theDialogueManager = FindObjectOfType<DialogueManager>();
        thePlayerAction = FindObjectOfType<PlayerAction>();
    }

    void Update()
    {
        if (!ZClickedInteractive)
        {
            CheckObject();
            ZClick();
        }
    }

    public void CheckObject() // z 클릭으로 바꿔야 함
    {
        Debug.DrawRay(thePlayerAction.rigid.position, thePlayerAction.dirVec * 1.2f, new Color(0, 1, 0));
        hit = Physics2D.Raycast(thePlayerAction.rigid.position, thePlayerAction.dirVec, 1.2f, LayerMask.GetMask("EventObj"));
        
        if (hit.collider != null) // 사물을 스캔함
        {
            scanObj = hit.collider.gameObject;
            Contact();
        }
        else // 스캔된 사물이 없음
        {
            scanObj = null;
            NotContact();
        }
    }

    void Contact() // 물체를 스캔했을 때 실행
    {
        if (!isInteractive)
        {
            isInteractive = true;
        }
    }

    void NotContact()
    {
        isInteractive = false;
    }

    public void ZClick() // z키로 상호작용 했을 때 Interact 함수 호출
    {
        // 이벤트 동안, 다른 상호작용 막기
        if (!ZClickedInteractive)
        {
            if (Input.GetKeyDown(KeyCode.Z) && scanObj != null)
            {
                if (isInteractive)
                {
                    Debug.Log(scanObj.name);
                    Interact();
                }
            }
        }
    }

    void Interact() // 상호작용한 오브젝트의 대사 이벤트를 꺼내옴
    {
        ZClickedInteractive = true;
        theDialogueManager.ShowDialogue(hit.transform.GetComponent<InteractionEvent>().GetDialogue());
    }
}

 

# 대사 엑셀 파일 작성

  • csv 파일로 저장
  • csv 파일은 ,로 구분하기 때문에 , 대신 특수문자로 대체 -> 유니티에서 치환함

 

  • csv로 저장한 파일을 메모장으로 다시 연 후 맨 마지막 줄 공백을 지운다 -> IndexOutOfRangeException 방지

- csv 파일을 수정할 때마다 이 작업을 반드시 해줘야함

  • Asset 파일 안에 Resources 파일을 만든 후 대사 파일을 옮긴다

 

# 대사 이벤트 스크립트 작성

  • Dialogue.cs

Dialogue 커스텀 클래스

[System.Serializable]
public class Dialogue
{
    [Header("스탠딩 일러스트")]
    public Transform tf_standing;

    [Tooltip("캐릭터")]
    public string name; // 캐릭터 이름

    [HideInInspector]	// 인스펙터 창에서 변수를 숨긴다.
    public string[] contexts; // 배열이라 여러 대사를 담을 수 있다.

    [HideInInspector]
    public string[] spriteName; // 여러 개의 스프라이트 이미지
}

 

DialogueEvent 커스텀 클래스

[System.Serializable]
public class DialogueEvent
{
    public string name;     // 대화 이벤트 이름
    public Vector2 line;	// 대사 줄
    public Dialogue[] dialogues;	// 대사 배열
}
  • InteractionEvent.cs

Scripts 폴더 내에 Interaction 폴더를 생성 후 작성

public class InteractionEvent : MonoBehaviour
{
    [SerializeField] DialogueEvent dialogue;

    // DatabaseManager에 저장된 실제 대사 데이터를 꺼내온다.
    public Dialogue[] GetDialogue()
    {
        DialogueEvent t_dialogueEvent = new DialogueEvent();    // 임시 변수
        t_dialogueEvent.dialogues = DatabaseManager.instance.GetDialogue((int)dialogue.line.x, (int)dialogue.line.y);

        for (int i = 0; i < dialogue.dialogues.Length; i++)
        {
            // dialogueEvent에 넣은 Standing Image 오브젝트를 임시 변수에 넣기
            t_dialogueEvent.dialogues[i].tf_standing = dialogue.dialogues[i].tf_standing;
        }

        // 원본에 임시 변수 덮어쓰기
        dialogue.dialogues = t_dialogueEvent.dialogues;

        return dialogue.dialogues;
    }
}

 

InteractionEvent.cs 파일을 메인 캐릭터에 추가

  • DialogueParser.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DialogueParser : MonoBehaviour
{
    public Dialogue[] Parse(string _CSVFileName)
    {
        List<Dialogue> dialogueList = new List<Dialogue>(); // 대사 리스트
        TextAsset csvData = Resources.Load<TextAsset>(_CSVFileName);    // CSV 데이터

        if (csvData == null)
        {
            Debug.LogError("CSV file not found: " + _CSVFileName);
            return null;
        }

        string[] data = csvData.text.Split(new char[] { '\n' });    // 엔터 단위로 대사 쪼개기

        for (int i = 1; i < data.Length;)   // data[0] = {'ID', '캐릭터 이름', '대사'}
        {
            string[] row = data[i].Split(new char[] { ',' });   // , 단위로 쪼개기

            Dialogue dialogue = new Dialogue(); // 캐릭터 한 명의 대사들

            dialogue.name = row[1];
            Debug.Log(row[1]); // 디버깅
            List<string> contextList = new List<string>();
            List<string> spriteList = new List<string>(); // 사진리스트

            do
            { 
                contextList.Add(row[2]);
                Debug.Log(row[2]); // 디버깅
                spriteList.Add(row[3]); // 엑셀 3번째 내용

                // 다음 줄 미리 비교
                if (++i < data.Length)
                {
                    row = data[i].Split(new char[] { ',' });
                }
                // 다음 줄이 데이터 보다 길어지면 그냥 break
                else
                {
                    break;
                }
            } while (row[0].ToString() == "");  // 다음 줄 캐릭터 이름이 공백이면 대사를 더 채우기

            dialogue.contexts = contextList.ToArray();   // 리스트를 배열로
            dialogue.spriteName = spriteList.ToArray();
            dialogueList.Add(dialogue); // 한캐릭터가 다른 캐릭터가 말하기 전까지 모든 대사들이 묶여서 추가됨
        }

        return dialogueList.ToArray();
    }
}

- 빈 오브젝트Manager를 생성

- Manager에 DialogueParser.cs를 추가

- 쉼표 단위로 대사 쪼개기

- 캐릭터 별로 대사 묶기

name에 row[1] 할당

context에는 row[2]를 바로 할당할 수 없기 때문에, List인 contextList를 새로 생성

do-while문 안에서 캐릭터의 첫 대사를 contextList에 할당

다음 대사의 id가 비어있으면, 계속 그 캐릭터의 대사를 contextList에 할당

  • 캐릭터 스프라이트
  • Sprite Manager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class SpriteManager : MonoBehaviour
{
    [SerializeField] float fadeSpeed; // 이미지를 서서히 자연스럽게 변경

    bool CheckSameSprite(Image p_image, Sprite p_sprite) // 스프라이트가 같은지
    {
        if (p_image.sprite == p_sprite)
            return true;
        else
            return false;
    }

    // p_target: 어떤 이미지를 변경할 것인지, p_spriteName: 어떤 이미지로 변경할 것인지
    public IEnumerator SpriteChangeCoroutine(Transform p_target, string p_spriteName)
    {
        // 1. t_image 이미지를 변경
        // Standing Image 오브젝트에는 Image 컴포넌트 X → 그 자식인 Image 오브젝트에는 Image 컴포넌트 O
        Image t_image = p_target.GetComponentInChildren<Image>();

        // 2. t_sprite 이미지로 변경
        // Characters 폴더에 있는 이미지를 가져와 Sprite 타입으로 변경 가능한지 검증한 후, Sprite로 강제 형변환
        p_spriteName = p_spriteName.Trim(); // 공백 제거
        Sprite t_sprite = Resources.Load("Characters/" + p_spriteName, typeof(Sprite)) as Sprite;

        // 두 이미지가 같지 않으면 새 이미지로 변경
        if (!CheckSameSprite(t_image, t_sprite))
        {
            // 1. 현재 이미지를 지우기
            Color t_color = t_image.color; // 현재 이미지의 color 속성
            t_color.a = 0;  // 투명도를 0으로
            t_image.color = t_color;       // 바뀐 투명도를 적용

            // 2. 새 이미지를 보여주기
            t_image.sprite = t_sprite;     // 이미지 교체

            while (t_color.a < 1)
            {
                if (t_sprite != null)
                {
                    t_color.a += fadeSpeed; // 투명도를 0에서부터 서서히 올리기
                    t_image.color = t_color;   // 바뀐 투명도를 적용
                }

                yield return null;  // 1프레임 대기
            }
        }
    }
}

 

 

# 대사 데이터 저장 및 관리

  • DatabaseManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DatabaseManager : MonoBehaviour
{
    public static DatabaseManager instance; // DB 자체를 인스턴스화, 참조가 편하게 static
    
    [SerializeField] string csv_fileName;
    
    Dictionary<int, Dialogue> dialogueDic = new Dictionary<int, Dialogue>();   // int로 대사를 찾는다.
    
    public static bool isFinish = false;    // 파싱한 데이터를 모두 저장했는지

    private void Awake()
    {
        if (instance == null)
        {
            instance = this;    // 자기 자신을 넣어준다.
            DialogueParser parser = GetComponent<DialogueParser>();
            Dialogue[] dialogues = parser.Parse(csv_fileName);  // dialogues에 모든 데이터가 담기게 된다.

            for (int i = 0; i < dialogues.Length; i++)
            {
                dialogueDic.Add(i + 1, dialogues[i]);    // 키는 1부터 시작
            }

            isFinish = true;
        }
    }

    // _StartNum ~_EndNum 사이의 대사를 가지고 옴.
    public Dialogue[] GetDialogue(int _StartNum, int _EndNum)
    {
        List<Dialogue> dialogueList = new List<Dialogue>();

        for (int i = _StartNum; i <= _EndNum; i++)
        {
            dialogueList.Add(dialogueDic[i]);
        }

        return dialogueList.ToArray();
    }

}

 

  • UI 설정

Game Canvas UI를 만든다.

그 밑에 빈 UI UI_Dialogue를 만든다.

그 밑에 빈 UI인 Standing Image, Name bar, Dialogue Bar을 만들고 각각 이미지와 텍스트 UI를 설정해준다.

 

UI_Dialogue 및에 Dialogue Manager 스크립트를 추가한다.

다음과 같이 오브젝트를 추가해준다.

 

# 상호작용 이벤트 수정

  • 꺼내올 대사를 입력한다
  • standing image 오브젝트를 Tf_standing에 드래그 해 전체 라인에 넣어준다.

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

 

# 참고

# https://developers.kakao.com/ 접속

# 로그인 후 상단 ‘내 어플리케이션’에서 어플리케이션 추가

 

# 클릭해서 진입 후 플랫폼을 설정한다

 

안드로이드 플랫폼 등록 클릭

 

키 해시 등록하기

build.gradlekts(Module) 파일에 모듈을 추가

maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") } 작성 후 싱크해준다

 

필요한 모듈을 등록해준다 

implementation("com.kakao.sdk:v2-all:2.20.0") // 전체 모듈 설치, 2.11.0 버전부터 지원

 

GlobalApplication 파일을 만들어준다

class GlobalApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // 다른 초기화 코드들

        // Kakao SDK 초기화
        KakaoSdk.init(this, "{NATIVE_APP_KEY}")
    }
}

 

앱 키는 해당 부분에서 확인할 수 있다

 

manifest 파일에 다음을 추가한다

android:name=".GlobalApplication"

 

메인화면에 다음을 추가하고 실행하면 해시키가 나온다

var keyHash = Utility.getKeyHash(this)
Log.i("kjwTest", "keyHash: $keyHash")

 

# 카카오 로그인 활성화

 

코드 작성

binding.btnKakaoLogin.setOnClickListener {
            kakaoLogin_Step01()
        }
fun kakaoLogin_Step01() {
        if (UserApiClient.instance.isKakaoTalkLoginAvailable(this)) {
            // 카카오톡 로그인
            UserApiClient.instance.loginWithKakaoTalk(this) { token, error ->
                // 로그인 실패 부분
                if (error != null) {
                    Log.e("kj308", "로그인 실패", error)
                    // 사용자가 취소
                    if (error is ClientError && error.reason == ClientErrorCause.Cancelled ) {
                        return@loginWithKakaoTalk
                    }
                    // 다른 오류
                    else {
                        UserApiClient.instance.loginWithKakaoAccount(this, callback = mCallback) // 카카오 이메일 로그인
                    }
                }
                // 로그인 성공 부분
                else if (token != null) {
                    Log.e("kj308", "로그인 성공 ${token.accessToken}")
                    UserApiClient.instance.me {
                        user, error ->
                        if (error != null) {
                            Log.e("kj308", "사용자 정보 요청 실패 $error")
                        } else if (user != null) {
                            Log.e("kj308", "사용자 정보 요청 성공 : $user")
                        }
                    }
                }
            }
        } else {
            UserApiClient.instance.loginWithKakaoAccount(this, callback = mCallback) // 카카오 이메일 로그인
        }
    }

    private val mCallback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
        if (error != null) {
            Log.e("kj308", "로그인 실패 $error")
        } else if (token != null) {
            Log.e("kj308", "로그인 성공 ${token.accessToken}")
        }
    }

 

인터넷 설정

<uses-permission android:name="android.permission.INTERNET" />

 

→ 결과

# 사진 넣기

1. 어댑터 클래스에서 뷰 홀더를 bind 해줄 때 어댑터 position을 이용한다

override fun onBindViewHolder(holder: MovieViewHolder, position: Int) {
        when (position) {
            0 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie01)
            }
            1 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie02)
            }
            2 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie03)
            }
            3 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie04)
            }
            4 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie05)
            }
            5 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie06)
            }
        }
        if (position > 5) {
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        ...

 

- 더 효율적으로 작성하려면?

val imageResources = arrayOf(
        R.drawable.movie01,
        R.drawable.movie02,
        R.drawable.movie03,
        R.drawable.movie04,
        R.drawable.movie05,
        R.drawable.movie06
    )
if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }

 

2. update activity에 사진 넣기

- 위와 같이 이미지 배열 사용

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

        val dto = intent.getSerializableExtra("dto") as MovieDto
		...

        if (dto.id in 1..imageResources.size) {
            updateBinding.ivUpdatePhoto.setImageResource(imageResources[dto.id - 1])
        } else {
            updateBinding.ivUpdatePhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        ...

 

# 검색 기능 만들기

1. 검색 레이아웃과 메뉴칸을 추가한다

- 레이아웃

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textView5"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="영화 제목 검색하기"
        android:textSize="25sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/etSearch"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:ems="10"
        android:hint="검색어 입력"
        android:inputType="text"
        android:textSize="20sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.497"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView5" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvSearch"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="20dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/btnSearchCancel"
        app:layout_constraintVertical_bias="0.0" />

    <Button
        android:id="@+id/btnSearchCancel"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="뒤로가기"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/etSearch" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

- 메뉴칸 추가

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menuItem01"
        android:title="영화 추가" />
    <item
        android:id="@+id/menuItem02"
        android:title="개발자 정보" />
    <item
        android:id="@+id/menuItem03"
        android:title="영화 정보 검색" />
    <item
        android:id="@+id/menuItem04"
        android:title="앱 종료"/>
</menu>

 

 

2. 검색 기능을 위한 어댑터를 새로 생성한다

- 기존 어댑터와 유사하지만 다른 점은 onBindHolder에서 item을 바인딩할 때 전체 리스트가 아닌 검색한 리스트를 바인딩한다.

override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
        if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        holder.itemBinding.tvMovie.text = filteredList[position].movie
        holder.itemBinding.tvRate.text = filteredList[position].rate
        holder.itemBinding.tvDirector.text = filteredList[position].director
    }

 

- getFilter 함수를 구현한다

override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(input: CharSequence?): FilterResults {
                val userInput = input.toString().lowercase() // 사용자가 입력한 값

                filteredList = if (userInput.isEmpty()) {
                    unFilteredList
                } else {
                    val filteringList = ArrayList<MovieDto>()
                    for (movie in unFilteredList) {
                        if (movie.movie.lowercase().contains(userInput) || movie.director.lowercase().contains(userInput)) {
                            filteringList.add(movie)
                        }
                    }
                    filteringList
                }

                val filterResults = FilterResults()
                filterResults.values = filteredList

                return filterResults
            }

            @SuppressLint("NotifyDataSetChanged")
            override fun publishResults(p0: CharSequence?, results: FilterResults?) {
                filteredList = results?.values as List<MovieDto>
                notifyDataSetChanged() // 필터링 된 결과가 갱신 되었음을 알려준다.
            }
        }
    }

 

- 전체 코드는 다음과 같다

더보기
package ddwu.com.mobile.finalreport

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Filter
import android.widget.Filterable
import androidx.recyclerview.widget.RecyclerView
import ddwu.com.mobile.finalreport.data.MovieDto
import ddwu.com.mobile.finalreport.databinding.ListItemBinding

class SearchAdapter(val movies: ArrayList<MovieDto>)
    : RecyclerView.Adapter<SearchAdapter.SearchViewHolder>(), Filterable {
    val imageResources = arrayOf(
        R.drawable.movie01,
        R.drawable.movie02,
        R.drawable.movie03,
        R.drawable.movie04,
        R.drawable.movie05,
        R.drawable.movie06
    )
    private var filteredList: List<MovieDto> = movies
    private val unFilteredList: List<MovieDto> = movies

    override fun getItemCount(): Int = filteredList.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder {
        val itemBinding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return SearchViewHolder(itemBinding)
    }

     override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
        if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        holder.itemBinding.tvMovie.text = filteredList[position].movie
        holder.itemBinding.tvRate.text = filteredList[position].rate
        holder.itemBinding.tvDirector.text = filteredList[position].director
    }

    inner class SearchViewHolder(val itemBinding: ListItemBinding)
        : RecyclerView.ViewHolder(itemBinding.root) {

    }

    override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(input: CharSequence?): FilterResults {
                val userInput = input.toString().lowercase() // 사용자가 입력한 값

                filteredList = if (userInput.isEmpty()) {
                    unFilteredList
                } else {
                    val filteringList = ArrayList<MovieDto>()
                    for (movie in unFilteredList) {
                        if (movie.movie.lowercase().contains(userInput) || movie.director.lowercase().contains(userInput)) {
                            filteringList.add(movie)
                        }
                    }
                    filteringList
                }

                val filterResults = FilterResults()
                filterResults.values = filteredList

                return filterResults
            }

            @SuppressLint("NotifyDataSetChanged")
            override fun publishResults(p0: CharSequence?, results: FilterResults?) {
                filteredList = results?.values as List<MovieDto>
                notifyDataSetChanged() // 필터링 된 결과가 갱신 되었음을 알려준다.
            }
        }
    }
}

 

3. SearchActivity 구현하기

- SearchAdapter를 활용하여 액티비티를 완성한다.

package ddwu.com.mobile.finalreport

import android.annotation.SuppressLint
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import ddwu.com.mobile.finalreport.data.MovieDao
import ddwu.com.mobile.finalreport.data.MovieDto
import ddwu.com.mobile.finalreport.databinding.ActivitySearchBinding

class SearchActivity : AppCompatActivity() {
    val searchBinding by lazy {
        ActivitySearchBinding.inflate(layoutInflater)
    }
    lateinit var movies: ArrayList<MovieDto> // 데이터
    val movieDao by lazy {
        MovieDao(this)
    }

    @SuppressLint("Range")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(searchBinding.root)

        // Fetch all movies from the database
        movies = movieDao.getAllMovies()

        // Setup RecyclerView
        val adapter = SearchAdapter(movies)
        searchBinding.rvSearch.layoutManager = LinearLayoutManager(this)
        searchBinding.rvSearch.adapter = adapter

        // Add TextWatcher to filter movies based on user input
        searchBinding.etSearch.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                adapter.filter.filter(s)
            }

            override fun afterTextChanged(s: Editable?) {}
        })

        // Cancel button click listener
        searchBinding.btnSearchCancel.setOnClickListener {
            finish()
        }
    }
}

 

4. 메인에서 메뉴 클릭 시 액티비티 전환 구현

R.id.menuItem03 -> {
                val intent = Intent(this@MainActivity, SearchActivity::class.java)
                startActivity(intent)
            }

 

5. 오류

- 검색 시 이미지가 filteredList의 position에 걸리는 것이 아닌 dto의 id에 영향을 받아야 제대로 이미지가 나온다

when (filteredList[position].id) {
            1 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie01)
            }
            2 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie02)
            }
            3 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie03)
            }
            4 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie04)
            }
            5 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie05)
            }
            6 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie06)
            }
        }
        if (filteredList[position].id > 6) {
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# 구현하고자 하는 것

- recycle view 클릭 시 수정 창이 나온다

- 수정한 결과가 반영된다

 

# 어댑터 클릭 인터페이스 작성

// 클릭 인터페이스
    interface OnItemClickListener {
        fun onItemClick(view: View, position: Int)
    }

    var listener: OnItemClickListener? = null

    fun setOnItemClickListener(listener: OnItemClickListener) {
        this.listener = listener
    }

 

 

# Update Activity 작성

- id를 사용

class UpdateActivity : AppCompatActivity() {
    val updateBinding by lazy {
        ActivityUpdateBinding.inflate(layoutInflater)
    }

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

        val dto = intent.getSerializableExtra("dto") as MovieDto

        updateBinding.etUpdateMovie.setText(dto.movie)
        updateBinding.etUpdateRate.setText(dto.rate)
        updateBinding.etUpdateDirector.setText(dto.director)

        updateBinding.btnUpdate.setOnClickListener {
            dto.movie = updateBinding.etUpdateMovie.text.toString()
            dto.rate = updateBinding.etUpdateRate.text.toString()
            dto.director = updateBinding.etUpdateDirector.text.toString()

            if (updateMovie(dto) > 0) {
                setResult(RESULT_OK)
            }
            else {
                setResult(RESULT_CANCELED)
            }

            finish()
        }

        updateBinding.btnUpdateCancel.setOnClickListener {
            setResult(RESULT_CANCELED)
            finish()
        }
    }

    fun updateMovie(dto: MovieDto): Int {
        val helper = MovieDBHelper(this)
        val db = helper.writableDatabase

        val updateValue = ContentValues()
        updateValue.put(MovieDBHelper.COL_MOVIE, dto.movie)
        updateValue.put(MovieDBHelper.COL_RATE, dto.rate)
        updateValue.put(MovieDBHelper.COL_DIRECTOR, dto.director)

        val whereClause = "${BaseColumns._ID}=?"
        val whereArgs = arrayOf(dto.id.toString())

        Log.d("UpdateActivity", "Updating movie with ID: ${dto.id}, values: $updateValue")

        val resultCount = db.update(MovieDBHelper.TABLE_NAME,
            updateValue, whereClause, whereArgs)

        Log.d("UpdateActivity", "Rows updated: $resultCount")

        helper.close()
        return resultCount
    }
}

 

# Main

- 어댑터 연결

adapter.setOnItemClickListener(object: MovieAdapter.OnItemClickListener {
            override fun onItemClick(view: View, position: Int) {
                val intent = Intent(this@MainActivity, UpdateActivity::class.java)
                intent.putExtra("dto", movies.get(position))   // 클릭한 RecyclerView 항목 위치의 dto 전달
                startActivityForResult(intent, REQ_UPDATE)      // 수정결과를 받아오도록 Activity 호출
            }
        })

 

- intent 결과 반환

REQ_UPDATE -> {
                if (resultCode == RESULT_OK) {
                    movies.clear()                       // 기존 항목 제거
                    movies.addAll(movieDao.getAllMovies())         // 항목 추가
                    binding.recyclerView.adapter?.notifyDataSetChanged()      // RecyclerView 갱신
                } else {
                    // 취소하면 아무것도 안함
                }
            }

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

 

 

# 구현하고자 하는 것

- 메뉴 클릭 시 나오는 정보 추가를 클릭하면 데이터를 추가하는 창 띄우기

- 데이터 추가 창에서 정보 입력 후 확인 누르면 데이터 반영

 

# 정보 추가 레이아웃 만들기

- textView와 plainText 사용

- 버튼은 linearLayout 사용하여 병렬 정리

<LinearLayout
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:orientation="horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/etAddDirector">

        <Button
            android:id="@+id/btnAdd"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="추가" />

        <Button
            android:id="@+id/btnCancel"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="취소" />
    </LinearLayout>

 

다른 정보 추가도 넣음

 

# 메뉴 아이템 클릭했을 때 화면이 바뀌게 구성

- 아이템 id 선택지 구현

override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            R.id.menuItem01 -> {
               
            }
            R.id.menuItem02 -> {

            }
            R.id.menuItem03 -> {

            }
        }
        return true
    }

 

- 정보 추가창 id=menuItem01, intent를 사용하여 activity 바꾼다

R.id.menuItem01 -> {
       val intent = Intent(this@MainActivity, AddActivity::class.java)
       startActivityForResult(intent, REQ_ADD)
}

 

- AddActivity를 만든다

class AddActivity : AppCompatActivity()  {
    val addBinding by lazy {
        ActivityAddBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(addBinding.root)
    }
}

 

- AndroidManifest.xml 에 AddActivity를 추가한다

<activity android:name=".AddActivity" />

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# SQLDatabase 설정

class MovieDBHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, 1) {
    val TAG = "MovieDBHelper"
    
    companion object {
        const val DB_NAME = "movie_db"
        const val TABLE_NAME = "movie_table"
        const val COL_MOVIE = "movie"
        const val COL_RATE = "rate"
        const val COL_DIRECTOR = "director"
        const val COL_DATE = "date"
    }

    override fun onCreate(db: SQLiteDatabase?) {
        val CREATE_TABLE =
            "CREATE TABLE $TABLE_NAME ( ${BaseColumns._ID} INTEGER PRIMARY KEY AUTOINCREMENT, " +
                    "$COL_MOVIE TEXT, $COL_RATE TEXT, $COL_DIRECTOR TEXT, $COL_DATE TEXT )"
        Log.d(TAG, CREATE_TABLE)    // SQL 문장이 이상 없는지 Log에서 확인 필요
        db?.execSQL(CREATE_TABLE)
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        val DROP_TABLE ="DROP TABLE IF EXISTS $TABLE_NAME"
        db?.execSQL(DROP_TABLE)
        onCreate(db)
    }
}

 

- 샘플 데이터 추가

// 샘플 데이터 넣음
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '은혼 더 파이널', '4.5', '미야와키 치즈루', '2021-07-22')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '어벤저스 엔드게임', '4.3', '앤서니 루소', '2019-04-24')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '엘리멘탈', '4.1', '피터 손', '2023-06-14')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '파묘', '4.8', '장재현', '2024-02-22')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '범죄도시3', '3.8', '이상용', '2023-05-31')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '인사이드 아웃2', '4.1', '켈시 만', '2024-06-12')")

 

 

# AddActivity 설정

- 확인 버튼 / 취소 버튼 구현

addBinding.btnAdd.setOnClickListener {
            val addETMovie = addBinding.etAddMovie.text.toString()
            val addETRate = addBinding.etAddRate.text.toString()
            val addETDirector = addBinding.etAddDirector.text.toString()
            val addETDate = addBinding.etAddRate.text.toString()

            if (addMovie(MovieDto( 0, addETMovie, addETRate, addETDirector, addETDate)) > 0) {
                setResult(RESULT_OK)
            } else {
                setResult(RESULT_CANCELED)
            }
            finish()
        }

        addBinding.btnCancel.setOnClickListener {
            setResult(RESULT_CANCELED)
            finish()
        }

 

- addMovie 함수 구현

fun addMovie(newDto: MovieDto) : Long {
        val helper = MovieDBHelper(this)
        val db = helper.writableDatabase

        val newValue = ContentValues()
        newValue.put(MovieDBHelper.COL_MOVIE, newDto.movie)
        newValue.put(MovieDBHelper.COL_RATE, newDto.rate)
        newValue.put(MovieDBHelper.COL_DIRECTOR, newDto.director)
        newValue.put(MovieDBHelper.COL_DATE, newDto.date)

        val newCount = db.insert(MovieDBHelper.TABLE_NAME, null, newValue)

        helper.close()

        return newCount
    }

 

# add 데이터를 main에서 받기

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        when (requestCode) {
            REQ_ADD -> {
                if (resultCode == RESULT_OK) {
                    movies.clear()                       // 기존 항목 제거
                    movies.addAll(movieDao.getAllMovies())         // 항목 추가
                    binding.recyclerView.adapter?.notifyDataSetChanged()      // RecyclerView 갱신
                } else {
                    // 취소하면 아무것도 안함
                }
            }
        }
    }

 

 

# 몰랐던 부분

- 데이터베이스에 샘플 데이터를 나중에 넣었을 시 기존 앱 삭제 후 처음부터 run해줘야 반영이 된다

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# 구현하고자 하는 것

- recycler view 롱 클릭 시 해당 뷰 삭제

 

# 코드 구현

- MovieAdapter에서 이벤트 리스너 구현

inner class MovieViewHolder(val itemBinding: ListItemBinding)
        : RecyclerView.ViewHolder(itemBinding.root) {
        init {
            itemBinding.root.setOnLongClickListener{
                longClickListener.onItemLongClick(it, adapterPosition)
            }
        }
    }

    interface onItemLongClickListener {
        fun onItemLongClick(view: View, position: Int) : Boolean
    }

    lateinit var longClickListener: onItemLongClickListener

    fun setOnItemLongClickListener (listener: onItemLongClickListener) {
        this.longClickListener = listener
    }

 

- Main Activity에서 기능을 구현한 listener 객체를 adapter에 전달

val listener = object: MovieAdapter.onItemLongClickListener {
            override fun onItemLongClick(view: View, position: Int): Boolean {
                val builder = AlertDialog.Builder(this@MainActivity)
                builder.setTitle("영화 정보 삭제")
                    .setMessage("정말 삭제하시겠습니까?")
                    .setPositiveButton(
                        "확인"
                    ) { dialog, which ->
                        movies.removeAt(position)
                        adapter.notifyDataSetChanged() // 화면 갱신
                    }
                    .setNegativeButton(
                        "취소"
                    ) { dialog, which ->
                        adapter.notifyDataSetChanged() // 화면 갱신
                    }
                builder.show()
                return true
            }
}
adapter.setOnItemLongClickListener(listener)

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# DTO 의 작성 및 샘플 데이터 추가

1. DTO(Data Transfer Object) 구현

- Item 항목 하나는 이미지와 두 개의 텍스트의 집합(영화 사진, 영화 제목, 영화 평점)

- 관련 있는 여러 데이터 값을 하나로 묶어 다른 계층에 전송 하는 용도로 사용하는 Design Pattern

data class MovieDto(val photo: Int, val movie: String, val rate: Int) {
    override fun toString() = "$movie ($rate)"
}

 

2. 샘플 데이터 추가

class MovieDao {
    val movies = ArrayList<MovieDto>()

    init {
        movies.add (MovieDto(R.drawable.movie01, "은혼 더 파이널", 4.5))
        movies.add (MovieDto(R.drawable.movie02, "어벤저스 엔드게임", 4.3))
        movies.add (MovieDto(R.drawable.movie03, "엘리멘탈", 4.1))
        movies.add (MovieDto(R.drawable.movie04, "파묘", 4.8))
        movies.add (MovieDto(R.drawable.movie05, "범죄도시3", 3.8))
        movies.add (MovieDto(R.drawable.movie06, "인사이드 아웃2", 4.1))
    }
}

 

3. Adapter 구현

class MovieAdapter(val movies: ArrayList<MovieDto>)
    : RecyclerView.Adapter<MovieAdapter.MovieViewHolder>() {
    override fun getItemCount(): Int = movies.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieViewHolder {
        val itemBinding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return MovieViewHolder(itemBinding)
    }

    override fun onBindViewHolder(holder: MovieViewHolder, position: Int) {
        holder.itemBinding.ivPhoto.setImageResource(movies[position].photo)
        holder.itemBinding.tvMovie.text = movies[position].movie
        holder.itemBinding.tvRate.text = movies[position].rate.toString()
    }

    inner class MovieViewHolder(val itemBinding: ListItemBinding): RecyclerView.ViewHolder(itemBinding.root) {
        
    }
}

 

4. MainActivity에서 RecylcerView와 Adapter의 연결

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

        val movies = MovieDao().movies // 데이터
        val adapter = MovieAdapter(movies) // 어댑터
        
        val layoutManager = LinearLayoutManager(this) // 레이아웃 매니저 생성 및 설정
        layoutManager.orientation = LinearLayoutManager.VERTICAL
        
        binding.recyclerView.layoutManager = layoutManager
        binding.recyclerView.adapter = adapter
}

감독 텍스트뷰 추가함

 

+ Recent posts